mirror of
https://github.com/yattee/yattee.git
synced 2026-02-19 17:29:45 +00:00
483 lines
17 KiB
Swift
483 lines
17 KiB
Swift
//
|
|
// DataManager+Recents.swift
|
|
// Yattee
|
|
//
|
|
// Search history, recent channels, and recent playlists operations for DataManager.
|
|
//
|
|
|
|
import Foundation
|
|
import SwiftData
|
|
|
|
extension DataManager {
|
|
// MARK: - Search History
|
|
|
|
/// Adds a search query to history. If query already exists (case-insensitive),
|
|
/// updates its timestamp and moves to top. Enforces user-configured limit.
|
|
func addSearchQuery(_ query: String) {
|
|
let trimmed = query.trimmingCharacters(in: .whitespaces)
|
|
guard !trimmed.isEmpty else { return }
|
|
|
|
let lowercased = trimmed.lowercased()
|
|
|
|
// Check for existing query (case-insensitive)
|
|
let fetchDescriptor = FetchDescriptor<SearchHistory>()
|
|
let savedEntry: SearchHistory
|
|
if let existing = (try? modelContext.fetch(fetchDescriptor))?.first(where: {
|
|
$0.query.lowercased() == lowercased
|
|
}) {
|
|
// Update timestamp to move to top
|
|
existing.searchedAt = Date()
|
|
savedEntry = existing
|
|
} else {
|
|
// Create new entry
|
|
let newHistory = SearchHistory(query: trimmed, searchedAt: Date())
|
|
modelContext.insert(newHistory)
|
|
savedEntry = newHistory
|
|
}
|
|
|
|
// Enforce limit
|
|
enforceSearchHistoryLimit()
|
|
|
|
save()
|
|
|
|
// Queue for CloudKit sync
|
|
cloudKitSync?.queueSearchHistorySave(savedEntry)
|
|
|
|
NotificationCenter.default.post(name: .searchHistoryDidChange, object: nil)
|
|
}
|
|
|
|
/// Fetches search history ordered by most recent first.
|
|
func fetchSearchHistory(limit: Int) -> [SearchHistory] {
|
|
// Process pending changes to ensure we fetch fresh data
|
|
modelContext.processPendingChanges()
|
|
|
|
var fetchDescriptor = FetchDescriptor<SearchHistory>(
|
|
sortBy: [SortDescriptor(\.searchedAt, order: .reverse)]
|
|
)
|
|
fetchDescriptor.fetchLimit = limit
|
|
return (try? modelContext.fetch(fetchDescriptor)) ?? []
|
|
}
|
|
|
|
/// Deletes a specific search history entry.
|
|
func deleteSearchQuery(_ history: SearchHistory) {
|
|
let historyID = history.id
|
|
modelContext.delete(history)
|
|
save()
|
|
|
|
// Queue for CloudKit sync
|
|
cloudKitSync?.queueSearchHistoryDelete(id: historyID)
|
|
|
|
NotificationCenter.default.post(name: .searchHistoryDidChange, object: nil)
|
|
}
|
|
|
|
/// Clears all search history.
|
|
func clearSearchHistory() {
|
|
let fetchDescriptor = FetchDescriptor<SearchHistory>()
|
|
if let allHistory = try? modelContext.fetch(fetchDescriptor) {
|
|
guard !allHistory.isEmpty else { return }
|
|
|
|
// Capture IDs before deleting
|
|
let ids = allHistory.map { $0.id }
|
|
|
|
allHistory.forEach { modelContext.delete($0) }
|
|
save()
|
|
|
|
// Queue CloudKit deletions
|
|
for id in ids {
|
|
cloudKitSync?.queueSearchHistoryDelete(id: id)
|
|
}
|
|
|
|
NotificationCenter.default.post(name: .searchHistoryDidChange, object: nil)
|
|
}
|
|
}
|
|
|
|
/// Enforces the user-configured search history limit by deleting oldest entries.
|
|
func enforceSearchHistoryLimit() {
|
|
// Get limit from settings (injected or default to 25)
|
|
let limit = settingsManager?.searchHistoryLimit ?? 25
|
|
guard limit > 0 else { return }
|
|
|
|
let fetchDescriptor = FetchDescriptor<SearchHistory>(
|
|
sortBy: [SortDescriptor(\.searchedAt, order: .reverse)]
|
|
)
|
|
|
|
guard let allHistory = try? modelContext.fetch(fetchDescriptor),
|
|
allHistory.count > limit else { return }
|
|
|
|
// Delete oldest entries beyond limit
|
|
let toDelete = allHistory.dropFirst(limit)
|
|
|
|
// Capture IDs before deleting
|
|
let ids = toDelete.map { $0.id }
|
|
|
|
toDelete.forEach { modelContext.delete($0) }
|
|
|
|
// Queue CloudKit deletions
|
|
for id in ids {
|
|
cloudKitSync?.queueSearchHistoryDelete(id: id)
|
|
}
|
|
}
|
|
|
|
/// Gets all search history (for CloudKit sync).
|
|
func allSearchHistory() -> [SearchHistory] {
|
|
let descriptor = FetchDescriptor<SearchHistory>(
|
|
sortBy: [SortDescriptor(\.searchedAt, order: .reverse)]
|
|
)
|
|
|
|
do {
|
|
return try modelContext.fetch(descriptor)
|
|
} catch {
|
|
LoggingService.shared.logCloudKitError("Failed to fetch search history", error: error)
|
|
return []
|
|
}
|
|
}
|
|
|
|
/// Gets a search history entry by ID (for CloudKit sync).
|
|
func searchHistoryEntry(forID id: UUID) -> SearchHistory? {
|
|
let descriptor = FetchDescriptor<SearchHistory>(
|
|
predicate: #Predicate { $0.id == id }
|
|
)
|
|
|
|
return try? modelContext.fetch(descriptor).first
|
|
}
|
|
|
|
/// Inserts a search history entry (for CloudKit sync).
|
|
func insertSearchHistory(_ searchHistory: SearchHistory) {
|
|
// Check for duplicates by ID
|
|
if searchHistoryEntry(forID: searchHistory.id) == nil {
|
|
modelContext.insert(searchHistory)
|
|
save()
|
|
}
|
|
}
|
|
|
|
// MARK: - Recent Channels
|
|
|
|
/// Adds a channel to recent history. If channel already exists,
|
|
/// updates its timestamp and moves to top. Enforces same limit as search history.
|
|
func addRecentChannel(_ channel: Channel) {
|
|
let channelID = channel.id.channelID
|
|
|
|
// Check for existing entry
|
|
let fetchDescriptor = FetchDescriptor<RecentChannel>(
|
|
predicate: #Predicate { $0.channelID == channelID }
|
|
)
|
|
|
|
let savedEntry: RecentChannel
|
|
if let existing = (try? modelContext.fetch(fetchDescriptor))?.first {
|
|
// Update timestamp to move to top
|
|
existing.visitedAt = Date()
|
|
// Also update metadata in case channel info changed
|
|
existing.name = channel.name
|
|
existing.thumbnailURLString = channel.thumbnailURL?.absoluteString
|
|
existing.subscriberCount = channel.subscriberCount
|
|
existing.isVerified = channel.isVerified
|
|
savedEntry = existing
|
|
} else {
|
|
// Create new entry
|
|
let recentChannel = RecentChannel.from(channel: channel)
|
|
modelContext.insert(recentChannel)
|
|
savedEntry = recentChannel
|
|
}
|
|
|
|
// Enforce limit
|
|
enforceRecentChannelsLimit()
|
|
|
|
save()
|
|
|
|
// Queue for CloudKit sync
|
|
cloudKitSync?.queueRecentChannelSave(savedEntry)
|
|
|
|
NotificationCenter.default.post(name: .recentChannelsDidChange, object: nil)
|
|
}
|
|
|
|
/// Fetches recent channels ordered by most recent first.
|
|
func fetchRecentChannels(limit: Int) -> [RecentChannel] {
|
|
// Process pending changes to ensure we fetch fresh data
|
|
modelContext.processPendingChanges()
|
|
|
|
var fetchDescriptor = FetchDescriptor<RecentChannel>(
|
|
sortBy: [SortDescriptor(\.visitedAt, order: .reverse)]
|
|
)
|
|
fetchDescriptor.fetchLimit = limit
|
|
return (try? modelContext.fetch(fetchDescriptor)) ?? []
|
|
}
|
|
|
|
/// Deletes a specific recent channel entry.
|
|
func deleteRecentChannel(_ channel: RecentChannel) {
|
|
let channelID = channel.channelID
|
|
let scope = SourceScope.from(
|
|
sourceRawValue: channel.sourceRawValue,
|
|
globalProvider: nil,
|
|
instanceURLString: channel.instanceURLString,
|
|
externalExtractor: nil
|
|
)
|
|
modelContext.delete(channel)
|
|
save()
|
|
|
|
// Queue scoped CloudKit deletion
|
|
cloudKitSync?.queueRecentChannelDelete(channelID: channelID, scope: scope)
|
|
|
|
NotificationCenter.default.post(name: .recentChannelsDidChange, object: nil)
|
|
}
|
|
|
|
/// Clears all recent channels.
|
|
func clearRecentChannels() {
|
|
let fetchDescriptor = FetchDescriptor<RecentChannel>()
|
|
if let allChannels = try? modelContext.fetch(fetchDescriptor) {
|
|
guard !allChannels.isEmpty else { return }
|
|
|
|
// Capture IDs and scopes before deleting
|
|
let deleteInfo: [(channelID: String, scope: SourceScope)] = allChannels.map { channel in
|
|
(channel.channelID, SourceScope.from(
|
|
sourceRawValue: channel.sourceRawValue,
|
|
globalProvider: nil,
|
|
instanceURLString: channel.instanceURLString,
|
|
externalExtractor: nil
|
|
))
|
|
}
|
|
|
|
allChannels.forEach { modelContext.delete($0) }
|
|
save()
|
|
|
|
// Queue CloudKit deletions
|
|
for info in deleteInfo {
|
|
cloudKitSync?.queueRecentChannelDelete(channelID: info.channelID, scope: info.scope)
|
|
}
|
|
|
|
NotificationCenter.default.post(name: .recentChannelsDidChange, object: nil)
|
|
}
|
|
}
|
|
|
|
/// Enforces the user-configured limit by deleting oldest entries.
|
|
func enforceRecentChannelsLimit() {
|
|
let limit = settingsManager?.searchHistoryLimit ?? 25
|
|
guard limit > 0 else { return }
|
|
|
|
let fetchDescriptor = FetchDescriptor<RecentChannel>(
|
|
sortBy: [SortDescriptor(\.visitedAt, order: .reverse)]
|
|
)
|
|
|
|
guard let allChannels = try? modelContext.fetch(fetchDescriptor),
|
|
allChannels.count > limit else { return }
|
|
|
|
// Delete oldest entries beyond limit
|
|
let toDelete = allChannels.dropFirst(limit)
|
|
|
|
// Capture IDs and scopes before deleting
|
|
let deleteInfo: [(channelID: String, scope: SourceScope)] = toDelete.map { channel in
|
|
(channel.channelID, SourceScope.from(
|
|
sourceRawValue: channel.sourceRawValue,
|
|
globalProvider: nil,
|
|
instanceURLString: channel.instanceURLString,
|
|
externalExtractor: nil
|
|
))
|
|
}
|
|
|
|
toDelete.forEach { modelContext.delete($0) }
|
|
|
|
// Queue CloudKit deletions
|
|
for info in deleteInfo {
|
|
cloudKitSync?.queueRecentChannelDelete(channelID: info.channelID, scope: info.scope)
|
|
}
|
|
}
|
|
|
|
/// Gets all recent channels (for CloudKit sync).
|
|
func allRecentChannels() -> [RecentChannel] {
|
|
let descriptor = FetchDescriptor<RecentChannel>(
|
|
sortBy: [SortDescriptor(\.visitedAt, order: .reverse)]
|
|
)
|
|
|
|
do {
|
|
return try modelContext.fetch(descriptor)
|
|
} catch {
|
|
LoggingService.shared.logCloudKitError("Failed to fetch recent channels", error: error)
|
|
return []
|
|
}
|
|
}
|
|
|
|
/// Gets a recent channel by channelID (for CloudKit sync).
|
|
func recentChannelEntry(forChannelID channelID: String) -> RecentChannel? {
|
|
let descriptor = FetchDescriptor<RecentChannel>(
|
|
predicate: #Predicate { $0.channelID == channelID }
|
|
)
|
|
|
|
return try? modelContext.fetch(descriptor).first
|
|
}
|
|
|
|
/// Inserts a recent channel (for CloudKit sync).
|
|
func insertRecentChannel(_ recentChannel: RecentChannel) {
|
|
// Check for duplicates by channelID
|
|
if recentChannelEntry(forChannelID: recentChannel.channelID) == nil {
|
|
modelContext.insert(recentChannel)
|
|
save()
|
|
}
|
|
}
|
|
|
|
// MARK: - Recent Playlists
|
|
|
|
/// Adds a playlist to recent history (remote playlists only).
|
|
/// If playlist already exists, updates its timestamp and moves to top.
|
|
func addRecentPlaylist(_ playlist: Playlist) {
|
|
// Skip local playlists
|
|
guard let recentPlaylist = RecentPlaylist.from(playlist: playlist) else {
|
|
return
|
|
}
|
|
|
|
let playlistID = playlist.id.playlistID
|
|
|
|
// Check for existing entry
|
|
let fetchDescriptor = FetchDescriptor<RecentPlaylist>(
|
|
predicate: #Predicate { $0.playlistID == playlistID }
|
|
)
|
|
|
|
let savedEntry: RecentPlaylist
|
|
if let existing = (try? modelContext.fetch(fetchDescriptor))?.first {
|
|
// Update timestamp to move to top
|
|
existing.visitedAt = Date()
|
|
// Also update metadata
|
|
existing.title = playlist.title
|
|
existing.authorName = playlist.authorName
|
|
existing.videoCount = playlist.videoCount
|
|
existing.thumbnailURLString = playlist.thumbnailURL?.absoluteString
|
|
savedEntry = existing
|
|
} else {
|
|
// Create new entry
|
|
modelContext.insert(recentPlaylist)
|
|
savedEntry = recentPlaylist
|
|
}
|
|
|
|
// Enforce limit
|
|
enforceRecentPlaylistsLimit()
|
|
|
|
save()
|
|
|
|
// Queue for CloudKit sync
|
|
cloudKitSync?.queueRecentPlaylistSave(savedEntry)
|
|
|
|
NotificationCenter.default.post(name: .recentPlaylistsDidChange, object: nil)
|
|
}
|
|
|
|
/// Fetches recent playlists ordered by most recent first.
|
|
func fetchRecentPlaylists(limit: Int) -> [RecentPlaylist] {
|
|
// Process pending changes to ensure we fetch fresh data
|
|
modelContext.processPendingChanges()
|
|
|
|
var fetchDescriptor = FetchDescriptor<RecentPlaylist>(
|
|
sortBy: [SortDescriptor(\.visitedAt, order: .reverse)]
|
|
)
|
|
fetchDescriptor.fetchLimit = limit
|
|
return (try? modelContext.fetch(fetchDescriptor)) ?? []
|
|
}
|
|
|
|
/// Deletes a specific recent playlist entry.
|
|
func deleteRecentPlaylist(_ playlist: RecentPlaylist) {
|
|
let playlistID = playlist.playlistID
|
|
let scope = SourceScope.from(
|
|
sourceRawValue: playlist.sourceRawValue,
|
|
globalProvider: nil,
|
|
instanceURLString: playlist.instanceURLString,
|
|
externalExtractor: nil
|
|
)
|
|
modelContext.delete(playlist)
|
|
save()
|
|
|
|
// Queue scoped CloudKit deletion
|
|
cloudKitSync?.queueRecentPlaylistDelete(playlistID: playlistID, scope: scope)
|
|
|
|
NotificationCenter.default.post(name: .recentPlaylistsDidChange, object: nil)
|
|
}
|
|
|
|
/// Clears all recent playlists.
|
|
func clearRecentPlaylists() {
|
|
let fetchDescriptor = FetchDescriptor<RecentPlaylist>()
|
|
if let allPlaylists = try? modelContext.fetch(fetchDescriptor) {
|
|
guard !allPlaylists.isEmpty else { return }
|
|
|
|
// Capture IDs and scopes before deleting
|
|
let deleteInfo: [(playlistID: String, scope: SourceScope)] = allPlaylists.map { playlist in
|
|
(playlist.playlistID, SourceScope.from(
|
|
sourceRawValue: playlist.sourceRawValue,
|
|
globalProvider: nil,
|
|
instanceURLString: playlist.instanceURLString,
|
|
externalExtractor: nil
|
|
))
|
|
}
|
|
|
|
allPlaylists.forEach { modelContext.delete($0) }
|
|
save()
|
|
|
|
// Queue CloudKit deletions
|
|
for info in deleteInfo {
|
|
cloudKitSync?.queueRecentPlaylistDelete(playlistID: info.playlistID, scope: info.scope)
|
|
}
|
|
|
|
NotificationCenter.default.post(name: .recentPlaylistsDidChange, object: nil)
|
|
}
|
|
}
|
|
|
|
/// Enforces the user-configured limit by deleting oldest entries.
|
|
func enforceRecentPlaylistsLimit() {
|
|
let limit = settingsManager?.searchHistoryLimit ?? 25
|
|
guard limit > 0 else { return }
|
|
|
|
let fetchDescriptor = FetchDescriptor<RecentPlaylist>(
|
|
sortBy: [SortDescriptor(\.visitedAt, order: .reverse)]
|
|
)
|
|
|
|
guard let allPlaylists = try? modelContext.fetch(fetchDescriptor),
|
|
allPlaylists.count > limit else { return }
|
|
|
|
// Delete oldest entries beyond limit
|
|
let toDelete = allPlaylists.dropFirst(limit)
|
|
|
|
// Capture IDs and scopes before deleting
|
|
let deleteInfo: [(playlistID: String, scope: SourceScope)] = toDelete.map { playlist in
|
|
(playlist.playlistID, SourceScope.from(
|
|
sourceRawValue: playlist.sourceRawValue,
|
|
globalProvider: nil,
|
|
instanceURLString: playlist.instanceURLString,
|
|
externalExtractor: nil
|
|
))
|
|
}
|
|
|
|
toDelete.forEach { modelContext.delete($0) }
|
|
|
|
// Queue CloudKit deletions
|
|
for info in deleteInfo {
|
|
cloudKitSync?.queueRecentPlaylistDelete(playlistID: info.playlistID, scope: info.scope)
|
|
}
|
|
}
|
|
|
|
/// Gets all recent playlists (for CloudKit sync).
|
|
func allRecentPlaylists() -> [RecentPlaylist] {
|
|
let descriptor = FetchDescriptor<RecentPlaylist>(
|
|
sortBy: [SortDescriptor(\.visitedAt, order: .reverse)]
|
|
)
|
|
|
|
do {
|
|
return try modelContext.fetch(descriptor)
|
|
} catch {
|
|
LoggingService.shared.logCloudKitError("Failed to fetch recent playlists", error: error)
|
|
return []
|
|
}
|
|
}
|
|
|
|
/// Gets a recent playlist by playlistID (for CloudKit sync).
|
|
func recentPlaylistEntry(forPlaylistID playlistID: String) -> RecentPlaylist? {
|
|
let descriptor = FetchDescriptor<RecentPlaylist>(
|
|
predicate: #Predicate { $0.playlistID == playlistID }
|
|
)
|
|
|
|
return try? modelContext.fetch(descriptor).first
|
|
}
|
|
|
|
/// Inserts a recent playlist (for CloudKit sync).
|
|
func insertRecentPlaylist(_ recentPlaylist: RecentPlaylist) {
|
|
// Check for duplicates by playlistID
|
|
if recentPlaylistEntry(forPlaylistID: recentPlaylist.playlistID) == nil {
|
|
modelContext.insert(recentPlaylist)
|
|
save()
|
|
}
|
|
}
|
|
}
|