mirror of
https://github.com/yattee/yattee.git
synced 2026-02-19 17:29:45 +00:00
301 lines
11 KiB
Swift
301 lines
11 KiB
Swift
//
|
|
// DataManager+Maintenance.swift
|
|
// Yattee
|
|
//
|
|
// Media source cleanup and deduplication operations for DataManager.
|
|
//
|
|
|
|
import Foundation
|
|
import SwiftData
|
|
|
|
extension DataManager {
|
|
// MARK: - Media Source Cleanup
|
|
|
|
/// Removes all watch history entries for videos from a specific media source.
|
|
func removeHistoryForMediaSource(sourceID: UUID) {
|
|
let prefix = sourceID.uuidString + ":"
|
|
let descriptor = FetchDescriptor<WatchEntry>()
|
|
|
|
do {
|
|
let allEntries = try modelContext.fetch(descriptor)
|
|
let toDelete = allEntries.filter { $0.videoID.hasPrefix(prefix) }
|
|
guard !toDelete.isEmpty else { return }
|
|
|
|
// Capture IDs and scopes before deleting
|
|
let deleteInfo: [(videoID: String, scope: SourceScope)] = toDelete.map { entry in
|
|
(entry.videoID, SourceScope.from(
|
|
sourceRawValue: entry.sourceRawValue,
|
|
globalProvider: entry.globalProvider,
|
|
instanceURLString: entry.instanceURLString,
|
|
externalExtractor: entry.externalExtractor
|
|
))
|
|
}
|
|
|
|
toDelete.forEach { modelContext.delete($0) }
|
|
save()
|
|
|
|
// Queue CloudKit deletions
|
|
for info in deleteInfo {
|
|
cloudKitSync?.queueWatchEntryDelete(videoID: info.videoID, scope: info.scope)
|
|
}
|
|
|
|
NotificationCenter.default.post(name: .watchHistoryDidChange, object: nil)
|
|
LoggingService.shared.debug("Removed \(toDelete.count) history entries for media source \(sourceID)", category: .general)
|
|
} catch {
|
|
LoggingService.shared.logCloudKitError("Failed to remove history for media source", error: error)
|
|
}
|
|
}
|
|
|
|
/// Removes all bookmarks for videos from a specific media source.
|
|
func removeBookmarksForMediaSource(sourceID: UUID) {
|
|
let prefix = sourceID.uuidString + ":"
|
|
let descriptor = FetchDescriptor<Bookmark>()
|
|
|
|
do {
|
|
let allBookmarks = try modelContext.fetch(descriptor)
|
|
let toDelete = allBookmarks.filter { $0.videoID.hasPrefix(prefix) }
|
|
guard !toDelete.isEmpty else { return }
|
|
|
|
// Capture IDs and scopes before deleting
|
|
let deleteInfo: [(videoID: String, scope: SourceScope)] = toDelete.map { bookmark in
|
|
(bookmark.videoID, SourceScope.from(
|
|
sourceRawValue: bookmark.sourceRawValue,
|
|
globalProvider: bookmark.globalProvider,
|
|
instanceURLString: bookmark.instanceURLString,
|
|
externalExtractor: bookmark.externalExtractor
|
|
))
|
|
}
|
|
|
|
toDelete.forEach { modelContext.delete($0) }
|
|
save()
|
|
|
|
// Queue CloudKit deletions
|
|
for info in deleteInfo {
|
|
cloudKitSync?.queueBookmarkDelete(videoID: info.videoID, scope: info.scope)
|
|
}
|
|
|
|
NotificationCenter.default.post(name: .bookmarksDidChange, object: nil)
|
|
LoggingService.shared.debug("Removed \(toDelete.count) bookmarks for media source \(sourceID)", category: .general)
|
|
} catch {
|
|
LoggingService.shared.logCloudKitError("Failed to remove bookmarks for media source", error: error)
|
|
}
|
|
}
|
|
|
|
/// Removes all playlist items for videos from a specific media source.
|
|
func removePlaylistItemsForMediaSource(sourceID: UUID) {
|
|
let prefix = sourceID.uuidString + ":"
|
|
let descriptor = FetchDescriptor<LocalPlaylistItem>()
|
|
|
|
do {
|
|
let allItems = try modelContext.fetch(descriptor)
|
|
let toDelete = allItems.filter { $0.videoID.hasPrefix(prefix) }
|
|
guard !toDelete.isEmpty else { return }
|
|
|
|
// Capture IDs before deleting
|
|
let itemIDs = toDelete.map { $0.id }
|
|
|
|
toDelete.forEach { modelContext.delete($0) }
|
|
save()
|
|
|
|
// Queue CloudKit deletions
|
|
for itemID in itemIDs {
|
|
cloudKitSync?.queuePlaylistItemDelete(itemID: itemID)
|
|
}
|
|
|
|
NotificationCenter.default.post(name: .playlistsDidChange, object: nil)
|
|
LoggingService.shared.debug("Removed \(toDelete.count) playlist items for media source \(sourceID)", category: .general)
|
|
} catch {
|
|
LoggingService.shared.logCloudKitError("Failed to remove playlist items for media source", error: error)
|
|
}
|
|
}
|
|
|
|
/// Removes all data associated with a media source (history, bookmarks, playlist items).
|
|
func removeAllDataForMediaSource(sourceID: UUID) {
|
|
removeHistoryForMediaSource(sourceID: sourceID)
|
|
removeBookmarksForMediaSource(sourceID: sourceID)
|
|
removePlaylistItemsForMediaSource(sourceID: sourceID)
|
|
}
|
|
|
|
// MARK: - Deduplication
|
|
|
|
/// Results from a deduplication operation.
|
|
struct DeduplicationResult {
|
|
var subscriptionsRemoved: Int = 0
|
|
var bookmarksRemoved: Int = 0
|
|
var historyEntriesRemoved: Int = 0
|
|
var playlistsRemoved: Int = 0
|
|
var playlistItemsRemoved: Int = 0
|
|
|
|
var totalRemoved: Int {
|
|
subscriptionsRemoved + bookmarksRemoved + historyEntriesRemoved + playlistsRemoved + playlistItemsRemoved
|
|
}
|
|
|
|
var summary: String {
|
|
var parts: [String] = []
|
|
if subscriptionsRemoved > 0 { parts.append("\(subscriptionsRemoved) subscriptions") }
|
|
if bookmarksRemoved > 0 { parts.append("\(bookmarksRemoved) bookmarks") }
|
|
if historyEntriesRemoved > 0 { parts.append("\(historyEntriesRemoved) history entries") }
|
|
if playlistsRemoved > 0 { parts.append("\(playlistsRemoved) playlists") }
|
|
if playlistItemsRemoved > 0 { parts.append("\(playlistItemsRemoved) playlist items") }
|
|
return parts.isEmpty ? "No duplicates found" : "Removed: " + parts.joined(separator: ", ")
|
|
}
|
|
}
|
|
|
|
/// Removes all duplicate entries from subscriptions, bookmarks, history, and playlists.
|
|
func deduplicateAllData() -> DeduplicationResult {
|
|
var result = DeduplicationResult()
|
|
|
|
result.subscriptionsRemoved = deduplicateSubscriptions()
|
|
result.bookmarksRemoved = deduplicateBookmarks()
|
|
result.historyEntriesRemoved = deduplicateWatchHistory()
|
|
let (playlists, items) = deduplicatePlaylists()
|
|
result.playlistsRemoved = playlists
|
|
result.playlistItemsRemoved = items
|
|
|
|
LoggingService.shared.logCloudKit("Deduplication completed: \(result.summary)")
|
|
return result
|
|
}
|
|
|
|
/// Removes duplicate subscriptions, keeping the oldest one.
|
|
func deduplicateSubscriptions() -> Int {
|
|
let allSubscriptions = subscriptions()
|
|
var seenChannelIDs = Set<String>()
|
|
var duplicates: [Subscription] = []
|
|
|
|
// Sort by subscribedAt to keep the oldest
|
|
let sorted = allSubscriptions.sorted { $0.subscribedAt < $1.subscribedAt }
|
|
|
|
for subscription in sorted {
|
|
if seenChannelIDs.contains(subscription.channelID) {
|
|
duplicates.append(subscription)
|
|
LoggingService.shared.logCloudKit("Found duplicate subscription: \(subscription.name) (\(subscription.channelID))")
|
|
} else {
|
|
seenChannelIDs.insert(subscription.channelID)
|
|
}
|
|
}
|
|
|
|
for duplicate in duplicates {
|
|
modelContext.delete(duplicate)
|
|
}
|
|
|
|
if !duplicates.isEmpty {
|
|
save()
|
|
SubscriptionFeedCache.shared.invalidate()
|
|
}
|
|
|
|
return duplicates.count
|
|
}
|
|
|
|
/// Removes duplicate bookmarks, keeping the oldest one.
|
|
func deduplicateBookmarks() -> Int {
|
|
let allBookmarks = bookmarks(limit: 10000)
|
|
var seenVideoIDs = Set<String>()
|
|
var duplicates: [Bookmark] = []
|
|
|
|
// Sort by createdAt to keep the oldest
|
|
let sorted = allBookmarks.sorted { $0.createdAt < $1.createdAt }
|
|
|
|
for bookmark in sorted {
|
|
if seenVideoIDs.contains(bookmark.videoID) {
|
|
duplicates.append(bookmark)
|
|
LoggingService.shared.logCloudKit("Found duplicate bookmark: \(bookmark.title) (\(bookmark.videoID))")
|
|
} else {
|
|
seenVideoIDs.insert(bookmark.videoID)
|
|
}
|
|
}
|
|
|
|
for duplicate in duplicates {
|
|
modelContext.delete(duplicate)
|
|
}
|
|
|
|
if !duplicates.isEmpty {
|
|
save()
|
|
}
|
|
|
|
return duplicates.count
|
|
}
|
|
|
|
/// Removes duplicate watch history entries, keeping the one with most progress.
|
|
func deduplicateWatchHistory() -> Int {
|
|
let allEntries = watchHistory(limit: 10000)
|
|
var bestByVideoID = [String: WatchEntry]()
|
|
var duplicates: [WatchEntry] = []
|
|
|
|
for entry in allEntries {
|
|
if let existing = bestByVideoID[entry.videoID] {
|
|
// Keep the one with more progress, or mark finished if either is
|
|
if entry.watchedSeconds > existing.watchedSeconds {
|
|
duplicates.append(existing)
|
|
bestByVideoID[entry.videoID] = entry
|
|
LoggingService.shared.logCloudKit("Found duplicate history (keeping newer progress): \(entry.title) (\(entry.videoID))")
|
|
} else {
|
|
duplicates.append(entry)
|
|
LoggingService.shared.logCloudKit("Found duplicate history (keeping existing progress): \(entry.title) (\(entry.videoID))")
|
|
}
|
|
} else {
|
|
bestByVideoID[entry.videoID] = entry
|
|
}
|
|
}
|
|
|
|
for duplicate in duplicates {
|
|
modelContext.delete(duplicate)
|
|
}
|
|
|
|
if !duplicates.isEmpty {
|
|
save()
|
|
}
|
|
|
|
return duplicates.count
|
|
}
|
|
|
|
/// Removes duplicate playlists and playlist items.
|
|
func deduplicatePlaylists() -> (playlists: Int, items: Int) {
|
|
let allPlaylists = playlists()
|
|
var seenPlaylistIDs = Set<UUID>()
|
|
var duplicatePlaylists: [LocalPlaylist] = []
|
|
var totalDuplicateItems = 0
|
|
|
|
// Sort by createdAt to keep the oldest
|
|
let sorted = allPlaylists.sorted { $0.createdAt < $1.createdAt }
|
|
|
|
for playlist in sorted {
|
|
if seenPlaylistIDs.contains(playlist.id) {
|
|
duplicatePlaylists.append(playlist)
|
|
LoggingService.shared.logCloudKit("Found duplicate playlist: \(playlist.title) (\(playlist.id))")
|
|
} else {
|
|
seenPlaylistIDs.insert(playlist.id)
|
|
|
|
// Also deduplicate items within the playlist
|
|
var seenItemVideoIDs = Set<String>()
|
|
var duplicateItems: [LocalPlaylistItem] = []
|
|
|
|
let sortedItems = playlist.sortedItems
|
|
|
|
for item in sortedItems {
|
|
if seenItemVideoIDs.contains(item.videoID) {
|
|
duplicateItems.append(item)
|
|
LoggingService.shared.logCloudKit("Found duplicate playlist item: \(item.title) in playlist \(playlist.title)")
|
|
} else {
|
|
seenItemVideoIDs.insert(item.videoID)
|
|
}
|
|
}
|
|
|
|
for duplicateItem in duplicateItems {
|
|
modelContext.delete(duplicateItem)
|
|
}
|
|
totalDuplicateItems += duplicateItems.count
|
|
}
|
|
}
|
|
|
|
for duplicate in duplicatePlaylists {
|
|
modelContext.delete(duplicate)
|
|
}
|
|
|
|
if !duplicatePlaylists.isEmpty || totalDuplicateItems > 0 {
|
|
save()
|
|
}
|
|
|
|
return (duplicatePlaylists.count, totalDuplicateItems)
|
|
}
|
|
}
|