mirror of
https://github.com/yattee/yattee.git
synced 2026-02-20 01:39:46 +00:00
Yattee v2 rewrite
This commit is contained in:
300
Yattee/Data/Bookmark.swift
Normal file
300
Yattee/Data/Bookmark.swift
Normal file
@@ -0,0 +1,300 @@
|
||||
//
|
||||
// Bookmark.swift
|
||||
// Yattee
|
||||
//
|
||||
// SwiftData model for bookmarked/favorited videos.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
/// Represents a bookmarked video for later viewing.
|
||||
@Model
|
||||
final class Bookmark {
|
||||
// MARK: - Video Identity
|
||||
|
||||
/// The video ID string (YouTube ID or PeerTube UUID).
|
||||
var videoID: String = ""
|
||||
|
||||
/// The content source raw value for encoding ("global", "federated", "extracted").
|
||||
var sourceRawValue: String = "global"
|
||||
|
||||
/// For global sources: the provider name (e.g., "youtube", "dailymotion").
|
||||
var globalProvider: String?
|
||||
|
||||
/// For PeerTube: the instance URL string.
|
||||
var instanceURLString: String?
|
||||
|
||||
/// For PeerTube: the UUID.
|
||||
var peertubeUUID: String?
|
||||
|
||||
/// For external sources: the extractor name (e.g., "vimeo", "twitter").
|
||||
var externalExtractor: String?
|
||||
|
||||
/// For external sources: the original URL for re-extraction.
|
||||
var externalURLString: String?
|
||||
|
||||
// MARK: - Video Metadata (cached for offline display)
|
||||
|
||||
/// The video title.
|
||||
var title: String = ""
|
||||
|
||||
/// The channel/author name.
|
||||
var authorName: String = ""
|
||||
|
||||
/// The channel/author ID.
|
||||
var authorID: String = ""
|
||||
|
||||
/// Video duration in seconds.
|
||||
var duration: TimeInterval = 0
|
||||
|
||||
/// Thumbnail URL string.
|
||||
var thumbnailURLString: String?
|
||||
|
||||
/// Whether this is a live stream.
|
||||
var isLive: Bool = false
|
||||
|
||||
/// View count if available.
|
||||
var viewCount: Int?
|
||||
|
||||
/// When the video was published.
|
||||
var publishedAt: Date?
|
||||
|
||||
/// Human-readable published date from the API.
|
||||
var publishedText: String?
|
||||
|
||||
// MARK: - Bookmark Metadata
|
||||
|
||||
/// When this bookmark was created.
|
||||
var createdAt: Date = Date()
|
||||
|
||||
/// Optional user note/comment.
|
||||
var note: String?
|
||||
|
||||
/// When the note was last modified.
|
||||
var noteModifiedAt: Date?
|
||||
|
||||
/// User-defined tags for categorizing the bookmark.
|
||||
var tags: [String] = []
|
||||
|
||||
/// When the tags were last modified.
|
||||
var tagsModifiedAt: Date?
|
||||
|
||||
/// Sort order for manual ordering.
|
||||
var sortOrder: Int = 0
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init(
|
||||
videoID: String,
|
||||
sourceRawValue: String,
|
||||
globalProvider: String? = nil,
|
||||
instanceURLString: String? = nil,
|
||||
peertubeUUID: String? = nil,
|
||||
externalExtractor: String? = nil,
|
||||
externalURLString: String? = nil,
|
||||
title: String,
|
||||
authorName: String,
|
||||
authorID: String,
|
||||
duration: TimeInterval,
|
||||
thumbnailURLString: String? = nil,
|
||||
isLive: Bool = false,
|
||||
viewCount: Int? = nil,
|
||||
publishedAt: Date? = nil,
|
||||
publishedText: String? = nil,
|
||||
note: String? = nil,
|
||||
noteModifiedAt: Date? = nil,
|
||||
tags: [String] = [],
|
||||
tagsModifiedAt: Date? = nil,
|
||||
sortOrder: Int = 0
|
||||
) {
|
||||
self.videoID = videoID
|
||||
self.sourceRawValue = sourceRawValue
|
||||
self.globalProvider = globalProvider
|
||||
self.instanceURLString = instanceURLString
|
||||
self.peertubeUUID = peertubeUUID
|
||||
self.externalExtractor = externalExtractor
|
||||
self.externalURLString = externalURLString
|
||||
self.title = title
|
||||
self.authorName = authorName
|
||||
self.authorID = authorID
|
||||
self.duration = duration
|
||||
self.thumbnailURLString = thumbnailURLString
|
||||
self.isLive = isLive
|
||||
self.viewCount = viewCount
|
||||
self.publishedAt = publishedAt
|
||||
self.publishedText = publishedText
|
||||
self.note = note
|
||||
self.noteModifiedAt = noteModifiedAt
|
||||
self.tags = tags
|
||||
self.tagsModifiedAt = tagsModifiedAt
|
||||
self.sortOrder = sortOrder
|
||||
self.createdAt = Date()
|
||||
}
|
||||
|
||||
// MARK: - Computed Properties
|
||||
|
||||
/// The content source for this bookmark.
|
||||
var contentSource: ContentSource {
|
||||
if sourceRawValue == "global" {
|
||||
return .global(provider: globalProvider ?? ContentSource.youtubeProvider)
|
||||
} else if sourceRawValue == "federated",
|
||||
let urlString = instanceURLString,
|
||||
let url = URL(string: urlString) {
|
||||
return .federated(provider: ContentSource.peertubeProvider, instance: url)
|
||||
} else if sourceRawValue == "extracted",
|
||||
let extractor = externalExtractor,
|
||||
let urlString = externalURLString,
|
||||
let url = URL(string: urlString) {
|
||||
return .extracted(extractor: extractor, originalURL: url)
|
||||
}
|
||||
return .global(provider: globalProvider ?? ContentSource.youtubeProvider)
|
||||
}
|
||||
|
||||
/// The thumbnail URL if available.
|
||||
var thumbnailURL: URL? {
|
||||
thumbnailURLString.flatMap { URL(string: $0) }
|
||||
}
|
||||
|
||||
/// Formatted duration string.
|
||||
var formattedDuration: String {
|
||||
guard !isLive else { return String(localized: "video.badge.live") }
|
||||
guard duration > 0 else { return "" }
|
||||
|
||||
let hours = Int(duration) / 3600
|
||||
let minutes = (Int(duration) % 3600) / 60
|
||||
let seconds = Int(duration) % 60
|
||||
|
||||
if hours > 0 {
|
||||
return String(format: "%d:%02d:%02d", hours, minutes, seconds)
|
||||
} else {
|
||||
return String(format: "%d:%02d", minutes, seconds)
|
||||
}
|
||||
}
|
||||
|
||||
/// Formatted view count string.
|
||||
var formattedViewCount: String? {
|
||||
guard let viewCount else { return nil }
|
||||
return CountFormatter.compact(viewCount)
|
||||
}
|
||||
|
||||
/// Formatted published date, preferring parsed Date over API-provided text.
|
||||
var formattedPublishedDate: String? {
|
||||
if let publishedAt {
|
||||
let formatter = RelativeDateTimeFormatter()
|
||||
formatter.unitsStyle = .full
|
||||
return formatter.localizedString(for: publishedAt, relativeTo: Date())
|
||||
}
|
||||
return publishedText
|
||||
}
|
||||
|
||||
/// Converts this bookmark to a Video model for playback.
|
||||
func toVideo() -> Video {
|
||||
let videoIDObj: VideoID
|
||||
switch contentSource {
|
||||
case .global(let provider):
|
||||
videoIDObj = VideoID(source: .global(provider: provider), videoID: videoID)
|
||||
case .federated(let provider, let instance):
|
||||
videoIDObj = VideoID(source: .federated(provider: provider, instance: instance), videoID: videoID, uuid: peertubeUUID)
|
||||
case .extracted(let extractor, let originalURL):
|
||||
videoIDObj = VideoID(source: .extracted(extractor: extractor, originalURL: originalURL), videoID: videoID)
|
||||
}
|
||||
|
||||
let author = Author(
|
||||
id: authorID,
|
||||
name: authorName,
|
||||
thumbnailURL: nil,
|
||||
subscriberCount: nil
|
||||
)
|
||||
|
||||
return Video(
|
||||
id: videoIDObj,
|
||||
title: title,
|
||||
description: nil,
|
||||
author: author,
|
||||
duration: duration,
|
||||
publishedAt: publishedAt,
|
||||
publishedText: publishedText,
|
||||
viewCount: viewCount,
|
||||
likeCount: nil,
|
||||
thumbnails: thumbnailURL.map { [Thumbnail(url: $0, width: nil, height: nil)] } ?? [],
|
||||
isLive: isLive,
|
||||
isUpcoming: false,
|
||||
scheduledStartTime: nil
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Factory Methods
|
||||
|
||||
extension Bookmark {
|
||||
/// Creates a Bookmark from a Video model.
|
||||
static func from(video: Video, tags: [String] = [], tagsModifiedAt: Date? = nil, sortOrder: Int = 0) -> Bookmark {
|
||||
let sourceRaw: String
|
||||
var provider: String?
|
||||
var instanceURL: String?
|
||||
var uuid: String?
|
||||
var extractor: String?
|
||||
var externalURL: String?
|
||||
|
||||
switch video.id.source {
|
||||
case .global(let prov):
|
||||
sourceRaw = "global"
|
||||
provider = prov
|
||||
case .federated(_, let instance):
|
||||
sourceRaw = "federated"
|
||||
instanceURL = instance.absoluteString
|
||||
uuid = video.id.uuid
|
||||
case .extracted(let ext, let originalURL):
|
||||
sourceRaw = "extracted"
|
||||
extractor = ext
|
||||
externalURL = originalURL.absoluteString
|
||||
}
|
||||
|
||||
return Bookmark(
|
||||
videoID: video.id.videoID,
|
||||
sourceRawValue: sourceRaw,
|
||||
globalProvider: provider,
|
||||
instanceURLString: instanceURL,
|
||||
peertubeUUID: uuid,
|
||||
externalExtractor: extractor,
|
||||
externalURLString: externalURL,
|
||||
title: video.title,
|
||||
authorName: video.author.name,
|
||||
authorID: video.author.id,
|
||||
duration: video.duration,
|
||||
thumbnailURLString: video.bestThumbnail?.url.absoluteString,
|
||||
isLive: video.isLive,
|
||||
viewCount: video.viewCount,
|
||||
publishedAt: video.publishedAt,
|
||||
publishedText: video.publishedText,
|
||||
tags: tags,
|
||||
tagsModifiedAt: tagsModifiedAt,
|
||||
sortOrder: sortOrder
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview Support
|
||||
|
||||
extension Bookmark {
|
||||
/// A sample bookmark for SwiftUI previews.
|
||||
static var preview: Bookmark {
|
||||
Bookmark(
|
||||
videoID: "dQw4w9WgXcQ",
|
||||
sourceRawValue: "global",
|
||||
globalProvider: "youtube",
|
||||
title: "Sample Video Title",
|
||||
authorName: "Sample Channel",
|
||||
authorID: "UC123",
|
||||
duration: 212,
|
||||
thumbnailURLString: "https://i.ytimg.com/vi/dQw4w9WgXcQ/maxresdefault.jpg",
|
||||
isLive: false,
|
||||
viewCount: 1_234_567,
|
||||
publishedAt: Date().addingTimeInterval(-86400 * 3),
|
||||
publishedText: "3 days ago",
|
||||
note: "Great video about SwiftUI patterns and best practices",
|
||||
tags: ["Swift", "iOS", "Tutorial", "SwiftUI", "Xcode"]
|
||||
)
|
||||
}
|
||||
}
|
||||
61
Yattee/Data/ChannelNotificationSettings.swift
Normal file
61
Yattee/Data/ChannelNotificationSettings.swift
Normal file
@@ -0,0 +1,61 @@
|
||||
//
|
||||
// ChannelNotificationSettings.swift
|
||||
// Yattee
|
||||
//
|
||||
// SwiftData model for per-channel notification preferences.
|
||||
// These settings are synced via iCloud independently of the subscription source.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
/// Stores notification preferences for a channel.
|
||||
/// This is separate from subscriptions so notification settings persist
|
||||
/// across subscription account changes (local vs Invidious).
|
||||
@Model
|
||||
final class ChannelNotificationSettings {
|
||||
// MARK: - Properties
|
||||
|
||||
/// The channel ID this setting applies to.
|
||||
@Attribute(.unique) var channelID: String = ""
|
||||
|
||||
/// Whether notifications are enabled for this channel.
|
||||
var notificationsEnabled: Bool = false
|
||||
|
||||
/// When this setting was last updated.
|
||||
var updatedAt: Date = Date()
|
||||
|
||||
/// Source type: "global", "federated", or "extracted".
|
||||
var sourceRawValue: String = "global"
|
||||
|
||||
/// Instance URL for federated sources.
|
||||
var instanceURLString: String?
|
||||
|
||||
/// Provider name for global sources (e.g. "youtube").
|
||||
var globalProvider: String?
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init(
|
||||
channelID: String,
|
||||
notificationsEnabled: Bool = false,
|
||||
sourceRawValue: String = "global",
|
||||
instanceURLString: String? = nil,
|
||||
globalProvider: String? = nil
|
||||
) {
|
||||
self.channelID = channelID
|
||||
self.notificationsEnabled = notificationsEnabled
|
||||
self.updatedAt = Date()
|
||||
self.sourceRawValue = sourceRawValue
|
||||
self.instanceURLString = instanceURLString
|
||||
self.globalProvider = globalProvider
|
||||
}
|
||||
|
||||
// MARK: - Methods
|
||||
|
||||
/// Updates the notifications enabled state and timestamp.
|
||||
func setNotificationsEnabled(_ enabled: Bool) {
|
||||
notificationsEnabled = enabled
|
||||
updatedAt = Date()
|
||||
}
|
||||
}
|
||||
95
Yattee/Data/DataExportStructures.swift
Normal file
95
Yattee/Data/DataExportStructures.swift
Normal file
@@ -0,0 +1,95 @@
|
||||
//
|
||||
// DataExportStructures.swift
|
||||
// Yattee
|
||||
//
|
||||
// Codable structures for exporting data to iCloud sync.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
// MARK: - Subscription Export
|
||||
|
||||
/// Codable struct for exporting Subscription to iCloud.
|
||||
struct SubscriptionExport: Codable {
|
||||
let channelID: String
|
||||
let sourceRawValue: String
|
||||
let instanceURLString: String?
|
||||
let name: String
|
||||
let channelDescription: String?
|
||||
let subscriberCount: Int?
|
||||
let avatarURLString: String?
|
||||
let bannerURLString: String?
|
||||
let isVerified: Bool
|
||||
let subscribedAt: Date
|
||||
let lastUpdatedAt: Date
|
||||
|
||||
init(from subscription: Subscription) {
|
||||
self.channelID = subscription.channelID
|
||||
self.sourceRawValue = subscription.sourceRawValue
|
||||
self.instanceURLString = subscription.instanceURLString
|
||||
self.name = subscription.name
|
||||
self.channelDescription = subscription.channelDescription
|
||||
self.subscriberCount = subscription.subscriberCount
|
||||
self.avatarURLString = subscription.avatarURLString
|
||||
self.bannerURLString = subscription.bannerURLString
|
||||
self.isVerified = subscription.isVerified
|
||||
self.subscribedAt = subscription.subscribedAt
|
||||
self.lastUpdatedAt = subscription.lastUpdatedAt
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Media Source Export
|
||||
|
||||
/// Codable struct for exporting MediaSource (WebDAV and SMB) to iCloud.
|
||||
/// Note: Local folder sources are never synced as they are device-specific.
|
||||
struct MediaSourceExport: Codable {
|
||||
let id: UUID
|
||||
let name: String
|
||||
let type: String // "webdav" or "smb"
|
||||
let urlString: String
|
||||
let username: String?
|
||||
let isEnabled: Bool
|
||||
let dateAdded: Date
|
||||
let allowInvalidCertificates: Bool
|
||||
|
||||
// SMB-specific fields
|
||||
let smbWorkgroup: String?
|
||||
let smbProtocolVersion: Int32?
|
||||
|
||||
init(from source: MediaSource) {
|
||||
self.id = source.id
|
||||
self.name = source.name
|
||||
self.type = source.type.rawValue
|
||||
self.urlString = source.url.absoluteString
|
||||
self.username = source.username
|
||||
self.isEnabled = source.isEnabled
|
||||
self.dateAdded = source.dateAdded
|
||||
self.allowInvalidCertificates = source.allowInvalidCertificates
|
||||
self.smbWorkgroup = source.smbWorkgroup
|
||||
self.smbProtocolVersion = source.smbProtocolVersion?.rawValue
|
||||
}
|
||||
|
||||
/// Converts back to a MediaSource. Creates WebDAV or SMB based on type.
|
||||
func toMediaSource() -> MediaSource? {
|
||||
guard let url = URL(string: urlString) else { return nil }
|
||||
|
||||
// Determine the source type (default to webdav for backward compatibility)
|
||||
let sourceType = MediaSourceType(rawValue: type) ?? .webdav
|
||||
|
||||
// Only allow network source types (webdav and smb)
|
||||
guard sourceType == .webdav || sourceType == .smb else { return nil }
|
||||
|
||||
return MediaSource(
|
||||
id: id,
|
||||
name: name,
|
||||
type: sourceType,
|
||||
url: url,
|
||||
isEnabled: isEnabled,
|
||||
dateAdded: dateAdded,
|
||||
username: username,
|
||||
allowInvalidCertificates: allowInvalidCertificates,
|
||||
smbWorkgroup: smbWorkgroup,
|
||||
smbProtocolVersion: smbProtocolVersion.flatMap { SMBProtocol(rawValue: $0) }
|
||||
)
|
||||
}
|
||||
}
|
||||
181
Yattee/Data/DataManager+Bookmarks.swift
Normal file
181
Yattee/Data/DataManager+Bookmarks.swift
Normal file
@@ -0,0 +1,181 @@
|
||||
//
|
||||
// DataManager+Bookmarks.swift
|
||||
// Yattee
|
||||
//
|
||||
// Bookmark operations for DataManager.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
extension DataManager {
|
||||
// MARK: - Bookmarks
|
||||
|
||||
/// Adds a video to bookmarks.
|
||||
func addBookmark(for video: Video) {
|
||||
// Check if already bookmarked
|
||||
let videoID = video.id.videoID
|
||||
let descriptor = FetchDescriptor<Bookmark>(
|
||||
predicate: #Predicate { $0.videoID == videoID }
|
||||
)
|
||||
|
||||
do {
|
||||
let existing = try modelContext.fetch(descriptor)
|
||||
guard existing.isEmpty else {
|
||||
return
|
||||
}
|
||||
|
||||
// Get max sort order
|
||||
let allBookmarks = try modelContext.fetch(FetchDescriptor<Bookmark>())
|
||||
let maxOrder = allBookmarks.map(\.sortOrder).max() ?? -1
|
||||
|
||||
let bookmark = Bookmark.from(video: video, sortOrder: maxOrder + 1)
|
||||
modelContext.insert(bookmark)
|
||||
save()
|
||||
|
||||
// Update cache immediately for fast lookup
|
||||
cachedBookmarkedVideoIDs.insert(videoID)
|
||||
|
||||
// Queue for CloudKit sync
|
||||
cloudKitSync?.queueBookmarkSave(bookmark)
|
||||
|
||||
NotificationCenter.default.post(name: .bookmarksDidChange, object: nil)
|
||||
} catch {
|
||||
LoggingService.shared.logCloudKitError("Failed to add bookmark", error: error)
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes a video from bookmarks.
|
||||
func removeBookmark(for videoID: String) {
|
||||
let descriptor = FetchDescriptor<Bookmark>(
|
||||
predicate: #Predicate { $0.videoID == videoID }
|
||||
)
|
||||
|
||||
do {
|
||||
let bookmarks = try modelContext.fetch(descriptor)
|
||||
guard !bookmarks.isEmpty else { return }
|
||||
|
||||
// Capture scopes before deleting
|
||||
let scopes = bookmarks.map {
|
||||
SourceScope.from(
|
||||
sourceRawValue: $0.sourceRawValue,
|
||||
globalProvider: $0.globalProvider,
|
||||
instanceURLString: $0.instanceURLString,
|
||||
externalExtractor: $0.externalExtractor
|
||||
)
|
||||
}
|
||||
|
||||
bookmarks.forEach { modelContext.delete($0) }
|
||||
save()
|
||||
|
||||
// Update cache immediately for fast lookup
|
||||
cachedBookmarkedVideoIDs.remove(videoID)
|
||||
|
||||
// Queue scoped CloudKit deletions
|
||||
for scope in scopes {
|
||||
cloudKitSync?.queueBookmarkDelete(videoID: videoID, scope: scope)
|
||||
}
|
||||
|
||||
NotificationCenter.default.post(name: .bookmarksDidChange, object: nil)
|
||||
} catch {
|
||||
LoggingService.shared.logCloudKitError("Failed to remove bookmark", error: error)
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks if a video is bookmarked using cached Set for O(1) lookup.
|
||||
func isBookmarked(videoID: String) -> Bool {
|
||||
cachedBookmarkedVideoIDs.contains(videoID)
|
||||
}
|
||||
|
||||
/// Gets all bookmarks, most recent first.
|
||||
func bookmarks(limit: Int = 100) -> [Bookmark] {
|
||||
var descriptor = FetchDescriptor<Bookmark>(
|
||||
sortBy: [SortDescriptor(\.createdAt, order: .reverse)]
|
||||
)
|
||||
descriptor.fetchLimit = limit
|
||||
|
||||
do {
|
||||
return try modelContext.fetch(descriptor)
|
||||
} catch {
|
||||
LoggingService.shared.logCloudKitError("Failed to fetch bookmarks", error: error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets the total count of bookmarks.
|
||||
func bookmarksCount() -> Int {
|
||||
let descriptor = FetchDescriptor<Bookmark>()
|
||||
do {
|
||||
return try modelContext.fetchCount(descriptor)
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets a bookmark for a specific video ID.
|
||||
func bookmark(for videoID: String) -> Bookmark? {
|
||||
let descriptor = FetchDescriptor<Bookmark>(
|
||||
predicate: #Predicate { $0.videoID == videoID }
|
||||
)
|
||||
return try? modelContext.fetch(descriptor).first
|
||||
}
|
||||
|
||||
/// Inserts a bookmark into the database.
|
||||
/// Used by CloudKitSyncEngine for applying remote bookmarks.
|
||||
func insertBookmark(_ bookmark: Bookmark) {
|
||||
// Check for duplicates
|
||||
let videoID = bookmark.videoID
|
||||
let descriptor = FetchDescriptor<Bookmark>(
|
||||
predicate: #Predicate { $0.videoID == videoID }
|
||||
)
|
||||
|
||||
do {
|
||||
let existing = try modelContext.fetch(descriptor)
|
||||
if existing.isEmpty {
|
||||
modelContext.insert(bookmark)
|
||||
save()
|
||||
}
|
||||
} catch {
|
||||
// Insert anyway if we can't check
|
||||
modelContext.insert(bookmark)
|
||||
save()
|
||||
}
|
||||
}
|
||||
|
||||
/// Updates bookmark tags and note for a video.
|
||||
func updateBookmark(videoID: String, tags: [String], note: String?) {
|
||||
let descriptor = FetchDescriptor<Bookmark>(
|
||||
predicate: #Predicate { $0.videoID == videoID }
|
||||
)
|
||||
|
||||
do {
|
||||
guard let bookmark = try modelContext.fetch(descriptor).first else {
|
||||
LoggingService.shared.logCloudKitError("Failed to update bookmark: not found", error: nil)
|
||||
return
|
||||
}
|
||||
|
||||
let now = Date()
|
||||
|
||||
// Update tags and timestamp if changed
|
||||
if bookmark.tags != tags {
|
||||
bookmark.tags = tags
|
||||
bookmark.tagsModifiedAt = now
|
||||
}
|
||||
|
||||
// Update note and timestamp if changed
|
||||
if bookmark.note != note {
|
||||
bookmark.note = note
|
||||
bookmark.noteModifiedAt = now
|
||||
}
|
||||
|
||||
save()
|
||||
|
||||
// Queue for CloudKit sync
|
||||
cloudKitSync?.queueBookmarkSave(bookmark)
|
||||
|
||||
NotificationCenter.default.post(name: .bookmarksDidChange, object: nil)
|
||||
} catch {
|
||||
LoggingService.shared.logCloudKitError("Failed to update bookmark", error: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
182
Yattee/Data/DataManager+ChannelNotificationSettings.swift
Normal file
182
Yattee/Data/DataManager+ChannelNotificationSettings.swift
Normal file
@@ -0,0 +1,182 @@
|
||||
//
|
||||
// DataManager+ChannelNotificationSettings.swift
|
||||
// Yattee
|
||||
//
|
||||
// Channel notification settings operations for DataManager.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
extension DataManager {
|
||||
// MARK: - Channel Notification Settings
|
||||
|
||||
/// Sets notification preferences for a channel.
|
||||
/// Creates the settings record if it doesn't exist.
|
||||
/// - Parameters:
|
||||
/// - enabled: Whether notifications should be enabled.
|
||||
/// - channelID: The channel ID to set preferences for.
|
||||
func setNotificationsEnabled(_ enabled: Bool, for channelID: String) {
|
||||
let descriptor = FetchDescriptor<ChannelNotificationSettings>(
|
||||
predicate: #Predicate { $0.channelID == channelID }
|
||||
)
|
||||
|
||||
do {
|
||||
let existing = try modelContext.fetch(descriptor)
|
||||
|
||||
let settings: ChannelNotificationSettings
|
||||
if let existingSettings = existing.first {
|
||||
// Update existing record
|
||||
existingSettings.setNotificationsEnabled(enabled)
|
||||
settings = existingSettings
|
||||
} else {
|
||||
// Derive source info from the matching subscription
|
||||
let sub = subscription(for: channelID)
|
||||
settings = ChannelNotificationSettings(
|
||||
channelID: channelID,
|
||||
notificationsEnabled: enabled,
|
||||
sourceRawValue: sub?.sourceRawValue ?? "global",
|
||||
instanceURLString: sub?.instanceURLString,
|
||||
globalProvider: sub?.providerName
|
||||
)
|
||||
modelContext.insert(settings)
|
||||
}
|
||||
save()
|
||||
|
||||
// Queue for CloudKit sync
|
||||
cloudKitSync?.queueChannelNotificationSettingsSave(settings)
|
||||
} catch {
|
||||
LoggingService.shared.logCloudKitError("Failed to set notification settings", error: error)
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets notification preferences for a channel.
|
||||
/// - Parameter channelID: The channel ID to get preferences for.
|
||||
/// - Returns: Whether notifications are enabled. Defaults to false if no settings exist.
|
||||
func notificationsEnabled(for channelID: String) -> Bool {
|
||||
let descriptor = FetchDescriptor<ChannelNotificationSettings>(
|
||||
predicate: #Predicate { $0.channelID == channelID }
|
||||
)
|
||||
|
||||
do {
|
||||
let results = try modelContext.fetch(descriptor)
|
||||
return results.first?.notificationsEnabled ?? false
|
||||
} catch {
|
||||
LoggingService.shared.logCloudKitError("Failed to fetch notification settings", error: error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets the notification settings record for a channel.
|
||||
/// - Parameter channelID: The channel ID to get settings for.
|
||||
/// - Returns: The settings record, or nil if none exists.
|
||||
func channelNotificationSettings(for channelID: String) -> ChannelNotificationSettings? {
|
||||
let descriptor = FetchDescriptor<ChannelNotificationSettings>(
|
||||
predicate: #Predicate { $0.channelID == channelID }
|
||||
)
|
||||
|
||||
do {
|
||||
return try modelContext.fetch(descriptor).first
|
||||
} catch {
|
||||
LoggingService.shared.logCloudKitError("Failed to fetch notification settings", error: error)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets all channel IDs with notifications enabled.
|
||||
/// Used for background refresh to determine which channels to check.
|
||||
/// - Returns: Array of channel IDs with notifications enabled.
|
||||
func channelIDsWithNotificationsEnabled() -> [String] {
|
||||
let descriptor = FetchDescriptor<ChannelNotificationSettings>(
|
||||
predicate: #Predicate { $0.notificationsEnabled == true }
|
||||
)
|
||||
|
||||
do {
|
||||
let results = try modelContext.fetch(descriptor)
|
||||
return results.map { $0.channelID }
|
||||
} catch {
|
||||
LoggingService.shared.logCloudKitError("Failed to fetch channels with notifications enabled", error: error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets all channel notification settings.
|
||||
/// - Returns: Array of all notification settings records.
|
||||
func allChannelNotificationSettings() -> [ChannelNotificationSettings] {
|
||||
let descriptor = FetchDescriptor<ChannelNotificationSettings>(
|
||||
sortBy: [SortDescriptor(\.channelID)]
|
||||
)
|
||||
|
||||
do {
|
||||
return try modelContext.fetch(descriptor)
|
||||
} catch {
|
||||
LoggingService.shared.logCloudKitError("Failed to fetch all notification settings", error: error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/// Deletes notification settings for a channel.
|
||||
/// - Parameter channelID: The channel ID to delete settings for.
|
||||
func deleteNotificationSettings(for channelID: String) {
|
||||
let descriptor = FetchDescriptor<ChannelNotificationSettings>(
|
||||
predicate: #Predicate { $0.channelID == channelID }
|
||||
)
|
||||
|
||||
do {
|
||||
let results = try modelContext.fetch(descriptor)
|
||||
guard !results.isEmpty else { return }
|
||||
|
||||
// Capture scopes before deleting
|
||||
let scopes = results.map {
|
||||
SourceScope.from(
|
||||
sourceRawValue: $0.sourceRawValue,
|
||||
globalProvider: $0.globalProvider,
|
||||
instanceURLString: $0.instanceURLString,
|
||||
externalExtractor: nil
|
||||
)
|
||||
}
|
||||
|
||||
for settings in results {
|
||||
modelContext.delete(settings)
|
||||
}
|
||||
save()
|
||||
|
||||
// Queue scoped CloudKit deletions
|
||||
for scope in scopes {
|
||||
cloudKitSync?.queueChannelNotificationSettingsDelete(channelID: channelID, scope: scope)
|
||||
}
|
||||
} catch {
|
||||
LoggingService.shared.logCloudKitError("Failed to delete notification settings", error: error)
|
||||
}
|
||||
}
|
||||
|
||||
/// Inserts or updates notification settings from CloudKit sync.
|
||||
/// - Parameter settings: The notification settings to upsert.
|
||||
func upsertChannelNotificationSettings(_ settings: ChannelNotificationSettings) {
|
||||
let channelID = settings.channelID
|
||||
let descriptor = FetchDescriptor<ChannelNotificationSettings>(
|
||||
predicate: #Predicate { $0.channelID == channelID }
|
||||
)
|
||||
|
||||
do {
|
||||
let existing = try modelContext.fetch(descriptor)
|
||||
|
||||
if let existingSettings = existing.first {
|
||||
// Update if incoming is newer
|
||||
if settings.updatedAt > existingSettings.updatedAt {
|
||||
existingSettings.notificationsEnabled = settings.notificationsEnabled
|
||||
existingSettings.updatedAt = settings.updatedAt
|
||||
existingSettings.sourceRawValue = settings.sourceRawValue
|
||||
existingSettings.instanceURLString = settings.instanceURLString
|
||||
existingSettings.globalProvider = settings.globalProvider
|
||||
}
|
||||
} else {
|
||||
// Insert new record
|
||||
modelContext.insert(settings)
|
||||
}
|
||||
save()
|
||||
} catch {
|
||||
LoggingService.shared.logCloudKitError("Failed to upsert notification settings", error: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
300
Yattee/Data/DataManager+Maintenance.swift
Normal file
300
Yattee/Data/DataManager+Maintenance.swift
Normal file
@@ -0,0 +1,300 @@
|
||||
//
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
165
Yattee/Data/DataManager+Playlists.swift
Normal file
165
Yattee/Data/DataManager+Playlists.swift
Normal file
@@ -0,0 +1,165 @@
|
||||
//
|
||||
// DataManager+Playlists.swift
|
||||
// Yattee
|
||||
//
|
||||
// Local playlist operations for DataManager.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
extension DataManager {
|
||||
// MARK: - Local Playlists
|
||||
|
||||
/// Creates a new local playlist.
|
||||
func createPlaylist(title: String, description: String? = nil) -> LocalPlaylist {
|
||||
let playlist = LocalPlaylist(title: title, description: description)
|
||||
modelContext.insert(playlist)
|
||||
save()
|
||||
|
||||
// Queue for CloudKit sync
|
||||
cloudKitSync?.queuePlaylistSave(playlist)
|
||||
|
||||
NotificationCenter.default.post(name: .playlistsDidChange, object: nil)
|
||||
return playlist
|
||||
}
|
||||
|
||||
/// Updates a playlist's title and description.
|
||||
func updatePlaylist(_ playlist: LocalPlaylist, title: String, description: String?) {
|
||||
playlist.title = title
|
||||
playlist.playlistDescription = description
|
||||
playlist.updatedAt = Date()
|
||||
save()
|
||||
|
||||
// Queue for CloudKit sync
|
||||
cloudKitSync?.queuePlaylistSave(playlist)
|
||||
|
||||
NotificationCenter.default.post(name: .playlistsDidChange, object: nil)
|
||||
}
|
||||
|
||||
/// Deletes a local playlist.
|
||||
func deletePlaylist(_ playlist: LocalPlaylist) {
|
||||
let playlistID = playlist.id
|
||||
modelContext.delete(playlist)
|
||||
save()
|
||||
|
||||
// Queue for CloudKit deletion
|
||||
cloudKitSync?.queuePlaylistDelete(playlistID: playlistID)
|
||||
|
||||
NotificationCenter.default.post(name: .playlistsDidChange, object: nil)
|
||||
}
|
||||
|
||||
/// Gets all local playlists.
|
||||
func playlists() -> [LocalPlaylist] {
|
||||
let descriptor = FetchDescriptor<LocalPlaylist>(
|
||||
sortBy: [SortDescriptor(\.updatedAt, order: .reverse)]
|
||||
)
|
||||
|
||||
do {
|
||||
return try modelContext.fetch(descriptor)
|
||||
} catch {
|
||||
LoggingService.shared.logCloudKitError("Failed to fetch playlists", error: error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets a playlist by its ID.
|
||||
func playlist(forID id: UUID) -> LocalPlaylist? {
|
||||
let descriptor = FetchDescriptor<LocalPlaylist>(
|
||||
predicate: #Predicate { $0.id == id }
|
||||
)
|
||||
return try? modelContext.fetch(descriptor).first
|
||||
}
|
||||
|
||||
/// Gets a playlist item by its ID.
|
||||
func playlistItem(forID id: UUID) -> LocalPlaylistItem? {
|
||||
let descriptor = FetchDescriptor<LocalPlaylistItem>(
|
||||
predicate: #Predicate { $0.id == id }
|
||||
)
|
||||
return try? modelContext.fetch(descriptor).first
|
||||
}
|
||||
|
||||
/// Inserts a playlist into the database.
|
||||
/// Used by CloudKitSyncEngine for applying remote playlists.
|
||||
func insertPlaylist(_ playlist: LocalPlaylist) {
|
||||
// Check for duplicates
|
||||
let id = playlist.id
|
||||
let descriptor = FetchDescriptor<LocalPlaylist>(
|
||||
predicate: #Predicate { $0.id == id }
|
||||
)
|
||||
|
||||
do {
|
||||
let existing = try modelContext.fetch(descriptor)
|
||||
if existing.isEmpty {
|
||||
modelContext.insert(playlist)
|
||||
save()
|
||||
}
|
||||
} catch {
|
||||
// Insert anyway if we can't check
|
||||
modelContext.insert(playlist)
|
||||
save()
|
||||
}
|
||||
}
|
||||
|
||||
/// Inserts a playlist item into the database.
|
||||
/// Used by CloudKitSyncEngine for applying remote playlist items.
|
||||
func insertPlaylistItem(_ item: LocalPlaylistItem) {
|
||||
// Check for duplicates
|
||||
let id = item.id
|
||||
let descriptor = FetchDescriptor<LocalPlaylistItem>(
|
||||
predicate: #Predicate { $0.id == id }
|
||||
)
|
||||
|
||||
do {
|
||||
let existing = try modelContext.fetch(descriptor)
|
||||
if existing.isEmpty {
|
||||
modelContext.insert(item)
|
||||
save()
|
||||
}
|
||||
} catch {
|
||||
// Insert anyway if we can't check
|
||||
modelContext.insert(item)
|
||||
save()
|
||||
}
|
||||
}
|
||||
|
||||
/// Deletes a playlist item.
|
||||
/// Used by CloudKitSyncEngine for applying remote deletions.
|
||||
func deletePlaylistItem(_ item: LocalPlaylistItem) {
|
||||
modelContext.delete(item)
|
||||
save()
|
||||
}
|
||||
|
||||
/// Adds a video to a playlist.
|
||||
func addToPlaylist(_ video: Video, playlist: LocalPlaylist) {
|
||||
guard !playlist.contains(videoID: video.id.videoID) else {
|
||||
return
|
||||
}
|
||||
playlist.addVideo(video)
|
||||
save()
|
||||
|
||||
// Queue for CloudKit sync (will sync playlist and all items)
|
||||
cloudKitSync?.queuePlaylistSave(playlist)
|
||||
|
||||
NotificationCenter.default.post(name: .playlistsDidChange, object: nil)
|
||||
}
|
||||
|
||||
/// Removes a video from a playlist.
|
||||
func removeVideoFromPlaylist(at index: Int, playlist: LocalPlaylist) {
|
||||
guard index < playlist.sortedItems.count else { return }
|
||||
let item = playlist.sortedItems[index]
|
||||
let itemID = item.id
|
||||
|
||||
// Remove from playlist
|
||||
playlist.removeVideo(at: index)
|
||||
save()
|
||||
|
||||
// Queue updated playlist for CloudKit sync
|
||||
cloudKitSync?.queuePlaylistSave(playlist)
|
||||
|
||||
// Also delete the orphaned item from CloudKit
|
||||
cloudKitSync?.queuePlaylistItemDelete(itemID: itemID)
|
||||
|
||||
NotificationCenter.default.post(name: .playlistsDidChange, object: nil)
|
||||
}
|
||||
}
|
||||
482
Yattee/Data/DataManager+Recents.swift
Normal file
482
Yattee/Data/DataManager+Recents.swift
Normal file
@@ -0,0 +1,482 @@
|
||||
//
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
396
Yattee/Data/DataManager+Subscriptions.swift
Normal file
396
Yattee/Data/DataManager+Subscriptions.swift
Normal file
@@ -0,0 +1,396 @@
|
||||
//
|
||||
// DataManager+Subscriptions.swift
|
||||
// Yattee
|
||||
//
|
||||
// Subscription operations for DataManager.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
extension DataManager {
|
||||
// MARK: - Subscriptions
|
||||
|
||||
/// Subscribes to a channel.
|
||||
/// - Parameter channel: The channel to subscribe to.
|
||||
func subscribe(to channel: Channel) {
|
||||
let channelID = channel.id.channelID
|
||||
let descriptor = FetchDescriptor<Subscription>(
|
||||
predicate: #Predicate { $0.channelID == channelID }
|
||||
)
|
||||
|
||||
do {
|
||||
let existing = try modelContext.fetch(descriptor)
|
||||
guard existing.isEmpty else {
|
||||
return
|
||||
}
|
||||
|
||||
let subscription = Subscription.from(channel: channel)
|
||||
modelContext.insert(subscription)
|
||||
save()
|
||||
|
||||
// Queue for CloudKit sync
|
||||
cloudKitSync?.queueSubscriptionSave(subscription)
|
||||
|
||||
let change = SubscriptionChange(addedSubscriptions: [subscription], removedChannelIDs: [])
|
||||
NotificationCenter.default.post(
|
||||
name: .subscriptionsDidChange,
|
||||
object: nil,
|
||||
userInfo: [SubscriptionChange.userInfoKey: change]
|
||||
)
|
||||
} catch {
|
||||
LoggingService.shared.logCloudKitError("Failed to subscribe", error: error)
|
||||
}
|
||||
}
|
||||
|
||||
/// Subscribes to a channel from an Author.
|
||||
/// - Parameters:
|
||||
/// - author: The author/channel to subscribe to.
|
||||
/// - source: The content source (YouTube or PeerTube).
|
||||
func subscribe(to author: Author, source: ContentSource) {
|
||||
let channelID = author.id
|
||||
let descriptor = FetchDescriptor<Subscription>(
|
||||
predicate: #Predicate { $0.channelID == channelID }
|
||||
)
|
||||
|
||||
do {
|
||||
let existing = try modelContext.fetch(descriptor)
|
||||
guard existing.isEmpty else {
|
||||
return
|
||||
}
|
||||
|
||||
let sourceRaw: String
|
||||
var instanceURL: String?
|
||||
|
||||
switch source {
|
||||
case .global:
|
||||
sourceRaw = "global"
|
||||
case .federated(_, let instance):
|
||||
sourceRaw = "federated"
|
||||
instanceURL = instance.absoluteString
|
||||
case .extracted:
|
||||
// Extracted sources don't support subscriptions
|
||||
return
|
||||
}
|
||||
|
||||
let subscription = Subscription(
|
||||
channelID: channelID,
|
||||
sourceRawValue: sourceRaw,
|
||||
instanceURLString: instanceURL,
|
||||
name: author.name,
|
||||
subscriberCount: author.subscriberCount,
|
||||
avatarURLString: author.thumbnailURL?.absoluteString
|
||||
)
|
||||
modelContext.insert(subscription)
|
||||
save()
|
||||
|
||||
// Queue for CloudKit sync
|
||||
cloudKitSync?.queueSubscriptionSave(subscription)
|
||||
|
||||
let change = SubscriptionChange(addedSubscriptions: [subscription], removedChannelIDs: [])
|
||||
NotificationCenter.default.post(
|
||||
name: .subscriptionsDidChange,
|
||||
object: nil,
|
||||
userInfo: [SubscriptionChange.userInfoKey: change]
|
||||
)
|
||||
} catch {
|
||||
LoggingService.shared.logCloudKitError("Failed to subscribe", error: error)
|
||||
}
|
||||
}
|
||||
|
||||
/// Unsubscribes from a channel.
|
||||
func unsubscribe(from channelID: String) {
|
||||
let descriptor = FetchDescriptor<Subscription>(
|
||||
predicate: #Predicate { $0.channelID == channelID }
|
||||
)
|
||||
|
||||
do {
|
||||
let subscriptions = try modelContext.fetch(descriptor)
|
||||
guard !subscriptions.isEmpty else { return }
|
||||
|
||||
// Capture scopes before deleting
|
||||
let scopes = subscriptions.map {
|
||||
SourceScope.from(
|
||||
sourceRawValue: $0.sourceRawValue,
|
||||
globalProvider: $0.providerName,
|
||||
instanceURLString: $0.instanceURLString,
|
||||
externalExtractor: nil
|
||||
)
|
||||
}
|
||||
|
||||
subscriptions.forEach { modelContext.delete($0) }
|
||||
save()
|
||||
|
||||
// Queue scoped CloudKit deletions
|
||||
for scope in scopes {
|
||||
cloudKitSync?.queueSubscriptionDelete(channelID: channelID, scope: scope)
|
||||
}
|
||||
|
||||
let change = SubscriptionChange(addedSubscriptions: [], removedChannelIDs: [channelID])
|
||||
NotificationCenter.default.post(
|
||||
name: .subscriptionsDidChange,
|
||||
object: nil,
|
||||
userInfo: [SubscriptionChange.userInfoKey: change]
|
||||
)
|
||||
} catch {
|
||||
LoggingService.shared.logCloudKitError("Failed to unsubscribe", error: error)
|
||||
}
|
||||
}
|
||||
|
||||
/// Bulk adds subscriptions from channel data (for testing).
|
||||
func bulkAddSubscriptions(_ channels: [(id: String, name: String)]) {
|
||||
var addedCount = 0
|
||||
|
||||
for channel in channels {
|
||||
let channelID = channel.id
|
||||
let descriptor = FetchDescriptor<Subscription>(
|
||||
predicate: #Predicate { $0.channelID == channelID }
|
||||
)
|
||||
|
||||
do {
|
||||
let existing = try modelContext.fetch(descriptor)
|
||||
guard existing.isEmpty else {
|
||||
continue
|
||||
}
|
||||
|
||||
let subscription = Subscription(
|
||||
channelID: channel.id,
|
||||
sourceRawValue: "youtube",
|
||||
instanceURLString: nil,
|
||||
name: channel.name
|
||||
)
|
||||
modelContext.insert(subscription)
|
||||
addedCount += 1
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if addedCount > 0 {
|
||||
save()
|
||||
SubscriptionFeedCache.shared.invalidate()
|
||||
LoggingService.shared.info("Bulk added \(addedCount) subscriptions", category: .general)
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks if subscribed to a channel.
|
||||
func isSubscribed(to channelID: String) -> Bool {
|
||||
let descriptor = FetchDescriptor<Subscription>(
|
||||
predicate: #Predicate { $0.channelID == channelID }
|
||||
)
|
||||
|
||||
do {
|
||||
let count = try modelContext.fetchCount(descriptor)
|
||||
return count > 0
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets a subscription by channel ID.
|
||||
func subscription(for channelID: String) -> Subscription? {
|
||||
let descriptor = FetchDescriptor<Subscription>(
|
||||
predicate: #Predicate { $0.channelID == channelID }
|
||||
)
|
||||
|
||||
do {
|
||||
return try modelContext.fetch(descriptor).first
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets all subscriptions.
|
||||
func subscriptions() -> [Subscription] {
|
||||
let descriptor = FetchDescriptor<Subscription>(
|
||||
sortBy: [SortDescriptor(\.name)]
|
||||
)
|
||||
|
||||
do {
|
||||
return try modelContext.fetch(descriptor)
|
||||
} catch {
|
||||
LoggingService.shared.logCloudKitError("Failed to fetch subscriptions", error: error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// Inserts a subscription into the database.
|
||||
/// Used by SubscriptionService for caching server subscriptions locally.
|
||||
func insertSubscription(_ subscription: Subscription) {
|
||||
// Check for duplicates
|
||||
let channelID = subscription.channelID
|
||||
let descriptor = FetchDescriptor<Subscription>(
|
||||
predicate: #Predicate { $0.channelID == channelID }
|
||||
)
|
||||
|
||||
do {
|
||||
let existing = try modelContext.fetch(descriptor)
|
||||
if existing.isEmpty {
|
||||
modelContext.insert(subscription)
|
||||
save()
|
||||
}
|
||||
} catch {
|
||||
// Insert anyway if we can't check
|
||||
modelContext.insert(subscription)
|
||||
save()
|
||||
}
|
||||
}
|
||||
|
||||
/// Deletes a subscription from the database.
|
||||
/// Used by SubscriptionService for removing stale cached subscriptions.
|
||||
func deleteSubscription(_ subscription: Subscription) {
|
||||
modelContext.delete(subscription)
|
||||
// Note: caller is responsible for calling save() after batch operations
|
||||
}
|
||||
|
||||
/// Removes subscriptions matching the given channel IDs.
|
||||
func removeSubscriptions(matching channelIDs: Set<String>) {
|
||||
let allSubscriptions = subscriptions()
|
||||
var removedCount = 0
|
||||
var deleteInfo: [(channelID: String, scope: SourceScope)] = []
|
||||
|
||||
for subscription in allSubscriptions {
|
||||
if channelIDs.contains(subscription.channelID) {
|
||||
// Capture scope before deleting
|
||||
let scope = SourceScope.from(
|
||||
sourceRawValue: subscription.sourceRawValue,
|
||||
globalProvider: subscription.providerName,
|
||||
instanceURLString: subscription.instanceURLString,
|
||||
externalExtractor: nil
|
||||
)
|
||||
deleteInfo.append((subscription.channelID, scope))
|
||||
modelContext.delete(subscription)
|
||||
removedCount += 1
|
||||
}
|
||||
}
|
||||
|
||||
if removedCount > 0 {
|
||||
save()
|
||||
|
||||
// Queue CloudKit deletions
|
||||
for info in deleteInfo {
|
||||
cloudKitSync?.queueSubscriptionDelete(channelID: info.channelID, scope: info.scope)
|
||||
}
|
||||
|
||||
SubscriptionFeedCache.shared.invalidate()
|
||||
LoggingService.shared.info("Removed \(removedCount) test subscriptions", category: .general)
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the total count of subscriptions.
|
||||
var subscriptionCount: Int {
|
||||
let descriptor = FetchDescriptor<Subscription>()
|
||||
do {
|
||||
return try modelContext.fetchCount(descriptor)
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns all subscriptions.
|
||||
var allSubscriptions: [Subscription] {
|
||||
subscriptions()
|
||||
}
|
||||
|
||||
/// Updates lastVideoPublishedAt for subscriptions based on feed videos.
|
||||
func updateLastVideoPublishedDates(from videos: [Video]) {
|
||||
var latestByChannel: [String: Date] = [:]
|
||||
for video in videos {
|
||||
guard let publishedAt = video.publishedAt else { continue }
|
||||
let channelID = video.author.id
|
||||
if let existing = latestByChannel[channelID] {
|
||||
if publishedAt > existing { latestByChannel[channelID] = publishedAt }
|
||||
} else {
|
||||
latestByChannel[channelID] = publishedAt
|
||||
}
|
||||
}
|
||||
|
||||
guard !latestByChannel.isEmpty else { return }
|
||||
|
||||
let allSubscriptions = subscriptions()
|
||||
var updated = false
|
||||
for subscription in allSubscriptions {
|
||||
if let latestDate = latestByChannel[subscription.channelID],
|
||||
subscription.lastVideoPublishedAt == nil || latestDate > subscription.lastVideoPublishedAt! {
|
||||
subscription.lastVideoPublishedAt = latestDate
|
||||
updated = true
|
||||
}
|
||||
}
|
||||
if updated {
|
||||
save()
|
||||
NotificationCenter.default.post(name: .subscriptionsDidChange, object: nil)
|
||||
}
|
||||
}
|
||||
|
||||
/// Imports subscriptions from external sources (YouTube CSV, OPML).
|
||||
/// Skips existing subscriptions and returns import statistics.
|
||||
/// - Parameter channels: Array of tuples containing channel ID and name
|
||||
/// - Returns: Tuple with count of imported and skipped subscriptions
|
||||
func importSubscriptionsFromExternal(_ channels: [(channelID: String, name: String)]) -> (imported: Int, skipped: Int) {
|
||||
var imported = 0
|
||||
var skipped = 0
|
||||
var addedSubscriptions: [Subscription] = []
|
||||
|
||||
for channel in channels {
|
||||
// Skip if already subscribed
|
||||
if isSubscribed(to: channel.channelID) {
|
||||
skipped += 1
|
||||
continue
|
||||
}
|
||||
|
||||
// Create new subscription
|
||||
let subscription = Subscription(
|
||||
channelID: channel.channelID,
|
||||
sourceRawValue: "global",
|
||||
instanceURLString: nil,
|
||||
name: channel.name
|
||||
)
|
||||
subscription.providerName = ContentSource.youtubeProvider
|
||||
|
||||
modelContext.insert(subscription)
|
||||
addedSubscriptions.append(subscription)
|
||||
imported += 1
|
||||
}
|
||||
|
||||
if imported > 0 {
|
||||
save()
|
||||
SubscriptionFeedCache.shared.invalidate()
|
||||
|
||||
// Queue imported subscriptions for CloudKit sync
|
||||
for subscription in addedSubscriptions {
|
||||
cloudKitSync?.queueSubscriptionSave(subscription)
|
||||
}
|
||||
|
||||
// Post notification for UI updates
|
||||
let change = SubscriptionChange(addedSubscriptions: addedSubscriptions, removedChannelIDs: [])
|
||||
NotificationCenter.default.post(
|
||||
name: .subscriptionsDidChange,
|
||||
object: nil,
|
||||
userInfo: [SubscriptionChange.userInfoKey: change]
|
||||
)
|
||||
|
||||
LoggingService.shared.info("Imported \(imported) subscriptions from external source", category: .general)
|
||||
}
|
||||
|
||||
return (imported, skipped)
|
||||
}
|
||||
|
||||
/// Updates subscription metadata from fresh channel data.
|
||||
func updateSubscription(for channelID: String, with channel: Channel) {
|
||||
let descriptor = FetchDescriptor<Subscription>(
|
||||
predicate: #Predicate { $0.channelID == channelID }
|
||||
)
|
||||
|
||||
do {
|
||||
let subscriptions = try modelContext.fetch(descriptor)
|
||||
if let subscription = subscriptions.first {
|
||||
subscription.update(from: channel)
|
||||
save()
|
||||
}
|
||||
} catch {
|
||||
LoggingService.shared.logCloudKitError("Failed to update subscription", error: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
373
Yattee/Data/DataManager+WatchHistory.swift
Normal file
373
Yattee/Data/DataManager+WatchHistory.swift
Normal file
@@ -0,0 +1,373 @@
|
||||
//
|
||||
// DataManager+WatchHistory.swift
|
||||
// Yattee
|
||||
//
|
||||
// Watch history operations for DataManager.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
extension DataManager {
|
||||
// MARK: - Watch History
|
||||
|
||||
/// Records or updates watch progress locally without triggering iCloud sync.
|
||||
/// Use this for frequent updates during playback to avoid unnecessary sync overhead.
|
||||
func updateWatchProgressLocal(for video: Video, seconds: TimeInterval, duration: TimeInterval? = nil) {
|
||||
let videoID = video.id.videoID
|
||||
let descriptor = FetchDescriptor<WatchEntry>(
|
||||
predicate: #Predicate { $0.videoID == videoID }
|
||||
)
|
||||
|
||||
do {
|
||||
let existing = try modelContext.fetch(descriptor)
|
||||
if let existingEntry = existing.first {
|
||||
existingEntry.updateProgress(seconds: seconds, duration: duration)
|
||||
} else {
|
||||
let newEntry = WatchEntry.from(video: video)
|
||||
newEntry.watchedSeconds = seconds
|
||||
if let duration, duration > 0, newEntry.duration == 0 {
|
||||
newEntry.duration = duration
|
||||
}
|
||||
modelContext.insert(newEntry)
|
||||
}
|
||||
save()
|
||||
// Note: No CloudKit queueing - use updateWatchProgress() when sync is needed
|
||||
} catch {
|
||||
LoggingService.shared.logCloudKitError("Failed to update watch progress locally", error: error)
|
||||
}
|
||||
}
|
||||
|
||||
/// Records or updates watch progress for a video and queues for iCloud sync.
|
||||
/// Use this when video closes or switches to sync the final progress.
|
||||
func updateWatchProgress(for video: Video, seconds: TimeInterval, duration: TimeInterval? = nil) {
|
||||
// Find existing entry or create new one
|
||||
let videoID = video.id.videoID
|
||||
let descriptor = FetchDescriptor<WatchEntry>(
|
||||
predicate: #Predicate { $0.videoID == videoID }
|
||||
)
|
||||
|
||||
do {
|
||||
let existing = try modelContext.fetch(descriptor)
|
||||
let entry: WatchEntry
|
||||
if let existingEntry = existing.first {
|
||||
existingEntry.updateProgress(seconds: seconds, duration: duration)
|
||||
entry = existingEntry
|
||||
} else {
|
||||
let newEntry = WatchEntry.from(video: video)
|
||||
newEntry.watchedSeconds = seconds
|
||||
if let duration, duration > 0, newEntry.duration == 0 {
|
||||
newEntry.duration = duration
|
||||
}
|
||||
modelContext.insert(newEntry)
|
||||
entry = newEntry
|
||||
}
|
||||
save()
|
||||
|
||||
// Queue for CloudKit sync
|
||||
cloudKitSync?.queueWatchEntrySave(entry)
|
||||
} catch {
|
||||
LoggingService.shared.logCloudKitError("Failed to update watch progress", error: error)
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets the watch progress for a video.
|
||||
func watchProgress(for videoID: String) -> TimeInterval? {
|
||||
let descriptor = FetchDescriptor<WatchEntry>(
|
||||
predicate: #Predicate { $0.videoID == videoID }
|
||||
)
|
||||
|
||||
do {
|
||||
let entries = try modelContext.fetch(descriptor)
|
||||
return entries.first?.watchedSeconds
|
||||
} catch {
|
||||
LoggingService.shared.logCloudKitError("Failed to fetch watch progress", error: error)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets all watch history entries, most recent first.
|
||||
func watchHistory(limit: Int = 100) -> [WatchEntry] {
|
||||
var descriptor = FetchDescriptor<WatchEntry>(
|
||||
sortBy: [SortDescriptor(\.updatedAt, order: .reverse)]
|
||||
)
|
||||
descriptor.fetchLimit = limit
|
||||
|
||||
do {
|
||||
return try modelContext.fetch(descriptor)
|
||||
} catch {
|
||||
LoggingService.shared.logCloudKitError("Failed to fetch watch history", error: error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a dictionary of video ID to WatchEntry for efficient bulk lookup.
|
||||
func watchEntriesMap() -> [String: WatchEntry] {
|
||||
let entries = watchHistory(limit: 10000)
|
||||
return Dictionary(uniqueKeysWithValues: entries.map { ($0.videoID, $0) })
|
||||
}
|
||||
|
||||
/// Gets the total count of watch history entries.
|
||||
func watchHistoryCount() -> Int {
|
||||
let descriptor = FetchDescriptor<WatchEntry>()
|
||||
do {
|
||||
return try modelContext.fetchCount(descriptor)
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets the watch entry for a specific video ID.
|
||||
func watchEntry(for videoID: String) -> WatchEntry? {
|
||||
let descriptor = FetchDescriptor<WatchEntry>(
|
||||
predicate: #Predicate { $0.videoID == videoID }
|
||||
)
|
||||
return try? modelContext.fetch(descriptor).first
|
||||
}
|
||||
|
||||
/// Inserts a watch entry into the database.
|
||||
/// Used by CloudKitSyncEngine for applying remote watch history.
|
||||
func insertWatchEntry(_ watchEntry: WatchEntry) {
|
||||
// Check for duplicates
|
||||
let videoID = watchEntry.videoID
|
||||
let descriptor = FetchDescriptor<WatchEntry>(
|
||||
predicate: #Predicate { $0.videoID == videoID }
|
||||
)
|
||||
|
||||
do {
|
||||
let existing = try modelContext.fetch(descriptor)
|
||||
if existing.isEmpty {
|
||||
modelContext.insert(watchEntry)
|
||||
save()
|
||||
}
|
||||
} catch {
|
||||
// Insert anyway if we can't check
|
||||
modelContext.insert(watchEntry)
|
||||
save()
|
||||
}
|
||||
}
|
||||
|
||||
/// Clears all watch history.
|
||||
func clearWatchHistory() {
|
||||
let descriptor = FetchDescriptor<WatchEntry>()
|
||||
|
||||
do {
|
||||
let entries = try modelContext.fetch(descriptor)
|
||||
guard !entries.isEmpty else { return }
|
||||
|
||||
// Capture IDs and scopes before deleting
|
||||
let deleteInfo: [(videoID: String, scope: SourceScope)] = entries.map { entry in
|
||||
(entry.videoID, SourceScope.from(
|
||||
sourceRawValue: entry.sourceRawValue,
|
||||
globalProvider: entry.globalProvider,
|
||||
instanceURLString: entry.instanceURLString,
|
||||
externalExtractor: entry.externalExtractor
|
||||
))
|
||||
}
|
||||
|
||||
entries.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.logCloudKit("Watch history cleared, queued \(deleteInfo.count) CloudKit deletions")
|
||||
} catch {
|
||||
LoggingService.shared.logCloudKitError("Failed to clear watch history", error: error)
|
||||
}
|
||||
}
|
||||
|
||||
/// Clears watch history entries updated after a given date.
|
||||
/// Used for time-based clearing (e.g., "clear last hour").
|
||||
func clearWatchHistory(since date: Date) {
|
||||
let descriptor = FetchDescriptor<WatchEntry>(
|
||||
predicate: #Predicate { $0.updatedAt >= date }
|
||||
)
|
||||
|
||||
do {
|
||||
let entries = try modelContext.fetch(descriptor)
|
||||
guard !entries.isEmpty else { return }
|
||||
|
||||
// Capture IDs and scopes before deleting
|
||||
let deleteInfo: [(videoID: String, scope: SourceScope)] = entries.map { entry in
|
||||
(entry.videoID, SourceScope.from(
|
||||
sourceRawValue: entry.sourceRawValue,
|
||||
globalProvider: entry.globalProvider,
|
||||
instanceURLString: entry.instanceURLString,
|
||||
externalExtractor: entry.externalExtractor
|
||||
))
|
||||
}
|
||||
|
||||
entries.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.logCloudKit("Watch history cleared since \(date), queued \(deleteInfo.count) CloudKit deletions")
|
||||
} catch {
|
||||
LoggingService.shared.logCloudKitError("Failed to clear watch history since date", error: error)
|
||||
}
|
||||
}
|
||||
|
||||
/// Clears watch history entries older than a given date.
|
||||
/// Used for auto-cleanup of old history.
|
||||
func clearWatchHistory(olderThan date: Date) {
|
||||
let descriptor = FetchDescriptor<WatchEntry>(
|
||||
predicate: #Predicate { $0.updatedAt < date }
|
||||
)
|
||||
|
||||
do {
|
||||
let entries = try modelContext.fetch(descriptor)
|
||||
guard !entries.isEmpty else { return }
|
||||
|
||||
// Capture IDs and scopes before deleting
|
||||
let deleteInfo: [(videoID: String, scope: SourceScope)] = entries.map { entry in
|
||||
(entry.videoID, SourceScope.from(
|
||||
sourceRawValue: entry.sourceRawValue,
|
||||
globalProvider: entry.globalProvider,
|
||||
instanceURLString: entry.instanceURLString,
|
||||
externalExtractor: entry.externalExtractor
|
||||
))
|
||||
}
|
||||
|
||||
entries.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.logCloudKit("Watch history cleared older than \(date), queued \(deleteInfo.count) CloudKit deletions")
|
||||
} catch {
|
||||
LoggingService.shared.logCloudKitError("Failed to clear old watch history", error: error)
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes a specific watch entry.
|
||||
func removeFromHistory(videoID: String) {
|
||||
let descriptor = FetchDescriptor<WatchEntry>(
|
||||
predicate: #Predicate { $0.videoID == videoID }
|
||||
)
|
||||
|
||||
do {
|
||||
let entries = try modelContext.fetch(descriptor)
|
||||
guard !entries.isEmpty else { return }
|
||||
|
||||
// Capture scopes before deleting
|
||||
let scopes = entries.map {
|
||||
SourceScope.from(
|
||||
sourceRawValue: $0.sourceRawValue,
|
||||
globalProvider: $0.globalProvider,
|
||||
instanceURLString: $0.instanceURLString,
|
||||
externalExtractor: $0.externalExtractor
|
||||
)
|
||||
}
|
||||
|
||||
entries.forEach { modelContext.delete($0) }
|
||||
save()
|
||||
|
||||
// Queue scoped CloudKit deletions
|
||||
for scope in scopes {
|
||||
cloudKitSync?.queueWatchEntryDelete(videoID: videoID, scope: scope)
|
||||
}
|
||||
|
||||
NotificationCenter.default.post(name: .watchHistoryDidChange, object: nil)
|
||||
} catch {
|
||||
LoggingService.shared.logCloudKitError("Failed to remove from history", error: error)
|
||||
}
|
||||
}
|
||||
|
||||
/// Marks a video as watched by creating or updating a WatchEntry with isFinished = true.
|
||||
func markAsWatched(video: Video) {
|
||||
let videoID = video.id.videoID
|
||||
let descriptor = FetchDescriptor<WatchEntry>(
|
||||
predicate: #Predicate { $0.videoID == videoID }
|
||||
)
|
||||
|
||||
do {
|
||||
let existing = try modelContext.fetch(descriptor)
|
||||
let entry: WatchEntry
|
||||
if let existingEntry = existing.first {
|
||||
existingEntry.isFinished = true
|
||||
existingEntry.updatedAt = Date()
|
||||
// Set watchedSeconds to duration for 100% progress
|
||||
if existingEntry.duration > 0 {
|
||||
existingEntry.watchedSeconds = existingEntry.duration
|
||||
} else if video.duration > 0 {
|
||||
existingEntry.duration = video.duration
|
||||
existingEntry.watchedSeconds = video.duration
|
||||
}
|
||||
entry = existingEntry
|
||||
} else {
|
||||
let newEntry = WatchEntry.from(video: video)
|
||||
newEntry.isFinished = true
|
||||
// Set watchedSeconds to duration for 100% progress
|
||||
if video.duration > 0 {
|
||||
newEntry.duration = video.duration
|
||||
newEntry.watchedSeconds = video.duration
|
||||
}
|
||||
modelContext.insert(newEntry)
|
||||
entry = newEntry
|
||||
}
|
||||
save()
|
||||
|
||||
// Queue for CloudKit sync
|
||||
cloudKitSync?.queueWatchEntrySave(entry)
|
||||
|
||||
NotificationCenter.default.post(name: .watchHistoryDidChange, object: nil)
|
||||
} catch {
|
||||
LoggingService.shared.logCloudKitError("Failed to mark as watched", error: error)
|
||||
}
|
||||
}
|
||||
|
||||
/// Marks a video as unwatched by removing the watch history entry entirely.
|
||||
func markAsUnwatched(videoID: String) {
|
||||
removeFromHistory(videoID: videoID)
|
||||
}
|
||||
|
||||
/// Clears all in-progress (not finished, watched > 10 seconds) watch entries.
|
||||
func clearInProgressHistory() {
|
||||
let descriptor = FetchDescriptor<WatchEntry>(
|
||||
predicate: #Predicate { !$0.isFinished && $0.watchedSeconds > 10 }
|
||||
)
|
||||
|
||||
do {
|
||||
let entries = try modelContext.fetch(descriptor)
|
||||
guard !entries.isEmpty else { return }
|
||||
|
||||
// Capture video IDs and scopes before deleting
|
||||
let deleteInfo: [(videoID: String, scope: SourceScope)] = entries.map { entry in
|
||||
(entry.videoID, SourceScope.from(
|
||||
sourceRawValue: entry.sourceRawValue,
|
||||
globalProvider: entry.globalProvider,
|
||||
instanceURLString: entry.instanceURLString,
|
||||
externalExtractor: entry.externalExtractor
|
||||
))
|
||||
}
|
||||
|
||||
// Delete all entries
|
||||
entries.forEach { modelContext.delete($0) }
|
||||
save()
|
||||
|
||||
// Queue scoped CloudKit deletions
|
||||
for info in deleteInfo {
|
||||
cloudKitSync?.queueWatchEntryDelete(videoID: info.videoID, scope: info.scope)
|
||||
}
|
||||
|
||||
// Post single notification
|
||||
NotificationCenter.default.post(name: .watchHistoryDidChange, object: nil)
|
||||
LoggingService.shared.logCloudKit("Cleared \(entries.count) in-progress watch entries")
|
||||
} catch {
|
||||
LoggingService.shared.logCloudKitError("Failed to clear in-progress history", error: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
151
Yattee/Data/DataManager.swift
Normal file
151
Yattee/Data/DataManager.swift
Normal file
@@ -0,0 +1,151 @@
|
||||
//
|
||||
// DataManager.swift
|
||||
// Yattee
|
||||
//
|
||||
// Central manager for all local data operations using SwiftData.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
/// Manages all local data persistence using SwiftData with CloudKit sync.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class DataManager {
|
||||
// MARK: - Properties
|
||||
|
||||
let modelContainer: ModelContainer
|
||||
let modelContext: ModelContext
|
||||
|
||||
/// Weak reference to settings manager for accessing search history limit.
|
||||
weak var settingsManager: SettingsManager?
|
||||
|
||||
/// Weak reference to CloudKit sync engine.
|
||||
weak var cloudKitSync: CloudKitSyncEngine?
|
||||
|
||||
/// Whether CloudKit sync is currently enabled for this instance.
|
||||
private(set) var isCloudKitEnabled: Bool = false
|
||||
|
||||
/// Cached set of bookmarked video IDs for fast O(1) lookup.
|
||||
/// Updated when bookmarks are added/removed locally or via CloudKit sync.
|
||||
var cachedBookmarkedVideoIDs: Set<String> = []
|
||||
|
||||
/// Shared schema for all data models.
|
||||
static let schema = Schema([
|
||||
WatchEntry.self,
|
||||
Bookmark.self,
|
||||
LocalPlaylist.self,
|
||||
LocalPlaylistItem.self,
|
||||
Subscription.self,
|
||||
SearchHistory.self,
|
||||
RecentChannel.self,
|
||||
RecentPlaylist.self,
|
||||
ChannelNotificationSettings.self
|
||||
])
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init(inMemory: Bool = false, iCloudSyncEnabled _: Bool = false) throws {
|
||||
let configuration: ModelConfiguration
|
||||
|
||||
// IMPORTANT: We intentionally do NOT use SwiftData's built-in CloudKit sync.
|
||||
// SwiftData CloudKit sync uses internal UUIDs which causes duplicates when
|
||||
// the same data is created on multiple devices (each device generates different IDs).
|
||||
//
|
||||
// Instead, we use CKSyncEngine directly via CloudKitSyncEngine for iCloud sync.
|
||||
// This approach:
|
||||
// 1. Uses business identifiers (channelID, videoID) for deduplication
|
||||
// 2. Gives explicit control over sync timing and conflict resolution
|
||||
// 3. Avoids the duplicate data problem inherent in SwiftData CloudKit sync
|
||||
if inMemory {
|
||||
configuration = ModelConfiguration(
|
||||
schema: Self.schema,
|
||||
isStoredInMemoryOnly: true,
|
||||
cloudKitDatabase: .none // Explicitly disable SwiftData CloudKit sync
|
||||
)
|
||||
} else {
|
||||
configuration = ModelConfiguration(
|
||||
schema: Self.schema,
|
||||
isStoredInMemoryOnly: false,
|
||||
cloudKitDatabase: .none // Explicitly disable SwiftData CloudKit sync
|
||||
)
|
||||
}
|
||||
|
||||
self.modelContainer = try ModelContainer(
|
||||
for: Self.schema,
|
||||
configurations: [configuration]
|
||||
)
|
||||
self.modelContext = modelContainer.mainContext
|
||||
self.modelContext.autosaveEnabled = true
|
||||
self.isCloudKitEnabled = false
|
||||
|
||||
LoggingService.shared.logCloudKit("SwiftData initialized with local storage (CloudKit sync via CKSyncEngine)")
|
||||
|
||||
// Initialize bookmark cache
|
||||
refreshBookmarkCache()
|
||||
|
||||
// Listen for bookmark changes from CloudKit sync to refresh cache
|
||||
NotificationCenter.default.addObserver(
|
||||
forName: .bookmarksDidChange,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
Task { @MainActor [weak self] in
|
||||
self?.refreshBookmarkCache()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Refreshes the cached set of bookmarked video IDs.
|
||||
/// Called at init and when bookmarks change via CloudKit sync.
|
||||
private func refreshBookmarkCache() {
|
||||
let descriptor = FetchDescriptor<Bookmark>()
|
||||
do {
|
||||
let bookmarks = try modelContext.fetch(descriptor)
|
||||
cachedBookmarkedVideoIDs = Set(bookmarks.map { $0.videoID })
|
||||
} catch {
|
||||
cachedBookmarkedVideoIDs = []
|
||||
LoggingService.shared.logCloudKitError("Failed to refresh bookmark cache", error: error)
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates an in-memory DataManager for previews and testing.
|
||||
static func preview() throws -> DataManager {
|
||||
try DataManager(inMemory: true)
|
||||
}
|
||||
|
||||
// MARK: - Utilities
|
||||
|
||||
/// Forces a save of any pending changes.
|
||||
func save() {
|
||||
do {
|
||||
try modelContext.save()
|
||||
} catch {
|
||||
LoggingService.shared.logCloudKitError("Failed to save data", error: error)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Subscription Updates
|
||||
|
||||
/// Updates subscriber count and verified status for a subscription by channel ID.
|
||||
/// Used to populate cached metadata from Yattee Server.
|
||||
func updateSubscriberCount(for channelID: String, count: Int, isVerified: Bool?) {
|
||||
let descriptor = FetchDescriptor<Subscription>(
|
||||
predicate: #Predicate { $0.channelID == channelID }
|
||||
)
|
||||
|
||||
do {
|
||||
if let subscription = try modelContext.fetch(descriptor).first {
|
||||
subscription.subscriberCount = count
|
||||
if let verified = isVerified {
|
||||
subscription.isVerified = verified
|
||||
}
|
||||
subscription.lastUpdatedAt = Date()
|
||||
save()
|
||||
}
|
||||
} catch {
|
||||
LoggingService.shared.logCloudKitError("Failed to update subscriber count for \(channelID)", error: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
47
Yattee/Data/DataManagerNotifications.swift
Normal file
47
Yattee/Data/DataManagerNotifications.swift
Normal file
@@ -0,0 +1,47 @@
|
||||
//
|
||||
// DataManagerNotifications.swift
|
||||
// Yattee
|
||||
//
|
||||
// Notification definitions for data changes.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
// MARK: - Notification Names
|
||||
|
||||
extension Notification.Name {
|
||||
/// Posted when subscriptions are modified (subscribe or unsubscribe).
|
||||
static let subscriptionsDidChange = Notification.Name("subscriptionsDidChange")
|
||||
/// Posted when bookmarks are modified (add or remove).
|
||||
static let bookmarksDidChange = Notification.Name("bookmarksDidChange")
|
||||
/// Posted when watch history is modified (add, update, or remove).
|
||||
static let watchHistoryDidChange = Notification.Name("watchHistoryDidChange")
|
||||
/// Posted when playlists are modified (create, update, or delete).
|
||||
static let playlistsDidChange = Notification.Name("playlistsDidChange")
|
||||
/// Posted when media sources are modified (add, update, or delete).
|
||||
static let mediaSourcesDidChange = Notification.Name("mediaSourcesDidChange")
|
||||
/// Posted when search history is modified (add or delete).
|
||||
static let searchHistoryDidChange = Notification.Name("searchHistoryDidChange")
|
||||
/// Posted when recent channels are modified (add or delete).
|
||||
static let recentChannelsDidChange = Notification.Name("recentChannelsDidChange")
|
||||
/// Posted when recent playlists are modified (add or delete).
|
||||
static let recentPlaylistsDidChange = Notification.Name("recentPlaylistsDidChange")
|
||||
/// Posted when video details (likes, views, etc.) are loaded or updated.
|
||||
static let videoDetailsDidLoad = Notification.Name("videoDetailsDidLoad")
|
||||
/// Posted when sidebar settings (max channels, sort order, etc.) are modified.
|
||||
static let sidebarSettingsDidChange = Notification.Name("sidebarSettingsDidChange")
|
||||
}
|
||||
|
||||
// MARK: - Subscription Change
|
||||
|
||||
/// Describes what changed in subscriptions for incremental feed updates.
|
||||
struct SubscriptionChange {
|
||||
let addedSubscriptions: [Subscription]
|
||||
let removedChannelIDs: [String]
|
||||
|
||||
static let userInfoKey = "subscriptionChange"
|
||||
|
||||
var isEmpty: Bool {
|
||||
addedSubscriptions.isEmpty && removedChannelIDs.isEmpty
|
||||
}
|
||||
}
|
||||
299
Yattee/Data/LocalPlaylist.swift
Normal file
299
Yattee/Data/LocalPlaylist.swift
Normal file
@@ -0,0 +1,299 @@
|
||||
//
|
||||
// LocalPlaylist.swift
|
||||
// Yattee
|
||||
//
|
||||
// SwiftData model for user-created local playlists.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
/// Represents a user-created local playlist.
|
||||
@Model
|
||||
final class LocalPlaylist {
|
||||
/// Unique identifier for the playlist.
|
||||
var id: UUID = UUID()
|
||||
|
||||
/// The playlist title.
|
||||
var title: String = ""
|
||||
|
||||
/// Optional description.
|
||||
var playlistDescription: String?
|
||||
|
||||
/// When the playlist was created.
|
||||
var createdAt: Date = Date()
|
||||
|
||||
/// When the playlist was last modified.
|
||||
var updatedAt: Date = Date()
|
||||
|
||||
/// Whether this playlist is a placeholder awaiting sync.
|
||||
/// Placeholder playlists are created when playlist items arrive before their parent playlist.
|
||||
var isPlaceholder: Bool = false
|
||||
|
||||
/// The videos in this playlist (ordered). Optional for CloudKit compatibility.
|
||||
@Relationship(deleteRule: .cascade, inverse: \LocalPlaylistItem.playlist)
|
||||
var items: [LocalPlaylistItem]? = []
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init(
|
||||
id: UUID = UUID(),
|
||||
title: String,
|
||||
description: String? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.title = title
|
||||
self.playlistDescription = description
|
||||
self.createdAt = Date()
|
||||
self.updatedAt = Date()
|
||||
self.items = []
|
||||
}
|
||||
|
||||
// MARK: - Computed Properties
|
||||
|
||||
/// Number of videos in the playlist.
|
||||
var videoCount: Int {
|
||||
(items ?? []).count
|
||||
}
|
||||
|
||||
/// Total duration of all videos.
|
||||
var totalDuration: TimeInterval {
|
||||
(items ?? []).reduce(0) { $0 + $1.duration }
|
||||
}
|
||||
|
||||
/// Formatted total duration.
|
||||
var formattedTotalDuration: String {
|
||||
let total = Int(totalDuration)
|
||||
let hours = total / 3600
|
||||
let minutes = (total % 3600) / 60
|
||||
|
||||
if hours > 0 {
|
||||
return "\(hours)h \(minutes)m"
|
||||
} else {
|
||||
return "\(minutes) min"
|
||||
}
|
||||
}
|
||||
|
||||
/// The first video's thumbnail URL for display.
|
||||
var thumbnailURL: URL? {
|
||||
(items ?? []).sorted { $0.sortOrder < $1.sortOrder }
|
||||
.first?
|
||||
.thumbnailURL
|
||||
}
|
||||
|
||||
/// Sorted items by order.
|
||||
var sortedItems: [LocalPlaylistItem] {
|
||||
(items ?? []).sorted { $0.sortOrder < $1.sortOrder }
|
||||
}
|
||||
|
||||
// MARK: - Methods
|
||||
|
||||
/// Adds a video to the playlist.
|
||||
func addVideo(_ video: Video) {
|
||||
let maxOrder = (items ?? []).map(\.sortOrder).max() ?? -1
|
||||
let item = LocalPlaylistItem.from(video: video, sortOrder: maxOrder + 1)
|
||||
item.playlist = self
|
||||
if items == nil {
|
||||
items = []
|
||||
}
|
||||
items?.append(item)
|
||||
updatedAt = Date()
|
||||
}
|
||||
|
||||
/// Removes a video from the playlist.
|
||||
func removeVideo(at index: Int) {
|
||||
guard index < sortedItems.count else { return }
|
||||
let item = sortedItems[index]
|
||||
items?.removeAll { $0.id == item.id }
|
||||
updatedAt = Date()
|
||||
}
|
||||
|
||||
/// Checks if a video is already in the playlist.
|
||||
func contains(videoID: String) -> Bool {
|
||||
(items ?? []).contains { $0.videoID == videoID }
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents a video item within a local playlist.
|
||||
@Model
|
||||
final class LocalPlaylistItem {
|
||||
/// Unique identifier.
|
||||
var id: UUID = UUID()
|
||||
|
||||
/// The parent playlist.
|
||||
var playlist: LocalPlaylist?
|
||||
|
||||
/// Sort order within the playlist.
|
||||
var sortOrder: Int = 0
|
||||
|
||||
// MARK: - Video Identity
|
||||
|
||||
/// The video ID string.
|
||||
var videoID: String = ""
|
||||
|
||||
/// The content source raw value ("global", "federated", "extracted").
|
||||
var sourceRawValue: String = "global"
|
||||
|
||||
/// For global sources: the provider name (e.g., "youtube", "dailymotion").
|
||||
var globalProvider: String?
|
||||
|
||||
/// For PeerTube: the instance URL string.
|
||||
var instanceURLString: String?
|
||||
|
||||
/// For PeerTube: the UUID.
|
||||
var peertubeUUID: String?
|
||||
|
||||
/// For external sources: the extractor name (e.g., "vimeo", "twitter").
|
||||
var externalExtractor: String?
|
||||
|
||||
/// For external sources: the original URL string.
|
||||
var externalURLString: String?
|
||||
|
||||
// MARK: - Video Metadata
|
||||
|
||||
/// The video title.
|
||||
var title: String = ""
|
||||
|
||||
/// The channel/author name.
|
||||
var authorName: String = ""
|
||||
|
||||
/// The channel/author ID.
|
||||
var authorID: String = ""
|
||||
|
||||
/// Video duration in seconds.
|
||||
var duration: TimeInterval = 0
|
||||
|
||||
/// Thumbnail URL string.
|
||||
var thumbnailURLString: String?
|
||||
|
||||
/// Whether this is a live stream.
|
||||
var isLive: Bool = false
|
||||
|
||||
/// When this item was added.
|
||||
var addedAt: Date = Date()
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init(
|
||||
id: UUID = UUID(),
|
||||
sortOrder: Int,
|
||||
videoID: String,
|
||||
sourceRawValue: String,
|
||||
globalProvider: String? = nil,
|
||||
instanceURLString: String? = nil,
|
||||
peertubeUUID: String? = nil,
|
||||
externalExtractor: String? = nil,
|
||||
externalURLString: String? = nil,
|
||||
title: String,
|
||||
authorName: String,
|
||||
authorID: String,
|
||||
duration: TimeInterval,
|
||||
thumbnailURLString: String? = nil,
|
||||
isLive: Bool = false
|
||||
) {
|
||||
self.id = id
|
||||
self.sortOrder = sortOrder
|
||||
self.videoID = videoID
|
||||
self.sourceRawValue = sourceRawValue
|
||||
self.globalProvider = globalProvider
|
||||
self.instanceURLString = instanceURLString
|
||||
self.peertubeUUID = peertubeUUID
|
||||
self.externalExtractor = externalExtractor
|
||||
self.externalURLString = externalURLString
|
||||
self.title = title
|
||||
self.authorName = authorName
|
||||
self.authorID = authorID
|
||||
self.duration = duration
|
||||
self.thumbnailURLString = thumbnailURLString
|
||||
self.isLive = isLive
|
||||
self.addedAt = Date()
|
||||
}
|
||||
|
||||
// MARK: - Computed Properties
|
||||
|
||||
/// The thumbnail URL if available.
|
||||
var thumbnailURL: URL? {
|
||||
thumbnailURLString.flatMap { URL(string: $0) }
|
||||
}
|
||||
|
||||
/// The content source for this item.
|
||||
var contentSource: ContentSource {
|
||||
if sourceRawValue == "global" {
|
||||
return .global(provider: globalProvider ?? ContentSource.youtubeProvider)
|
||||
} else if sourceRawValue == "federated",
|
||||
let urlString = instanceURLString,
|
||||
let url = URL(string: urlString) {
|
||||
return .federated(provider: ContentSource.peertubeProvider, instance: url)
|
||||
} else if sourceRawValue == "extracted",
|
||||
let extractor = externalExtractor,
|
||||
let urlString = externalURLString,
|
||||
let url = URL(string: urlString) {
|
||||
return .extracted(extractor: extractor, originalURL: url)
|
||||
}
|
||||
return .global(provider: globalProvider ?? ContentSource.youtubeProvider)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Conversion Methods
|
||||
|
||||
extension LocalPlaylistItem {
|
||||
/// Converts this LocalPlaylistItem back to a Video model for playback or display.
|
||||
func toVideo() -> Video {
|
||||
Video(
|
||||
id: VideoID(source: contentSource, videoID: videoID),
|
||||
title: title,
|
||||
description: nil,
|
||||
author: Author(id: authorID, name: authorName),
|
||||
duration: duration,
|
||||
publishedAt: nil,
|
||||
publishedText: nil,
|
||||
viewCount: nil,
|
||||
likeCount: nil,
|
||||
thumbnails: thumbnailURL.map { [Thumbnail(url: $0, quality: .medium)] } ?? [],
|
||||
isLive: isLive,
|
||||
isUpcoming: false,
|
||||
scheduledStartTime: nil
|
||||
)
|
||||
}
|
||||
|
||||
/// Creates a LocalPlaylistItem from a Video model.
|
||||
static func from(video: Video, sortOrder: Int) -> LocalPlaylistItem {
|
||||
let sourceRaw: String
|
||||
var provider: String?
|
||||
var instanceURL: String?
|
||||
var uuid: String?
|
||||
var extractor: String?
|
||||
var externalURL: String?
|
||||
|
||||
switch video.id.source {
|
||||
case .global(let prov):
|
||||
sourceRaw = "global"
|
||||
provider = prov
|
||||
case .federated(_, let instance):
|
||||
sourceRaw = "federated"
|
||||
instanceURL = instance.absoluteString
|
||||
uuid = video.id.uuid
|
||||
case .extracted(let ext, let originalURL):
|
||||
sourceRaw = "extracted"
|
||||
extractor = ext
|
||||
externalURL = originalURL.absoluteString
|
||||
}
|
||||
|
||||
return LocalPlaylistItem(
|
||||
sortOrder: sortOrder,
|
||||
videoID: video.id.videoID,
|
||||
sourceRawValue: sourceRaw,
|
||||
globalProvider: provider,
|
||||
instanceURLString: instanceURL,
|
||||
peertubeUUID: uuid,
|
||||
externalExtractor: extractor,
|
||||
externalURLString: externalURL,
|
||||
title: video.title,
|
||||
authorName: video.author.name,
|
||||
authorID: video.author.id,
|
||||
duration: video.duration,
|
||||
thumbnailURLString: video.bestThumbnail?.url.absoluteString,
|
||||
isLive: video.isLive
|
||||
)
|
||||
}
|
||||
}
|
||||
69
Yattee/Data/RecentChannel.swift
Normal file
69
Yattee/Data/RecentChannel.swift
Normal file
@@ -0,0 +1,69 @@
|
||||
//
|
||||
// RecentChannel.swift
|
||||
// Yattee
|
||||
//
|
||||
// SwiftData model for recent channel visits.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
@Model
|
||||
final class RecentChannel {
|
||||
var id: UUID
|
||||
var channelID: String
|
||||
var sourceRawValue: String
|
||||
var instanceURLString: String?
|
||||
var name: String
|
||||
var thumbnailURLString: String?
|
||||
var subscriberCount: Int?
|
||||
var isVerified: Bool
|
||||
var visitedAt: Date
|
||||
|
||||
init(
|
||||
id: UUID = UUID(),
|
||||
channelID: String,
|
||||
sourceRawValue: String,
|
||||
instanceURLString: String? = nil,
|
||||
name: String,
|
||||
thumbnailURLString: String? = nil,
|
||||
subscriberCount: Int? = nil,
|
||||
isVerified: Bool = false,
|
||||
visitedAt: Date = Date()
|
||||
) {
|
||||
self.id = id
|
||||
self.channelID = channelID
|
||||
self.sourceRawValue = sourceRawValue
|
||||
self.instanceURLString = instanceURLString
|
||||
self.name = name
|
||||
self.thumbnailURLString = thumbnailURLString
|
||||
self.subscriberCount = subscriberCount
|
||||
self.isVerified = isVerified
|
||||
self.visitedAt = visitedAt
|
||||
}
|
||||
|
||||
/// Creates a RecentChannel from a Channel model
|
||||
static func from(channel: Channel) -> RecentChannel {
|
||||
let (sourceRaw, instanceURL) = extractSourceInfo(from: channel.id.source)
|
||||
return RecentChannel(
|
||||
channelID: channel.id.channelID,
|
||||
sourceRawValue: sourceRaw,
|
||||
instanceURLString: instanceURL,
|
||||
name: channel.name,
|
||||
thumbnailURLString: channel.thumbnailURL?.absoluteString,
|
||||
subscriberCount: channel.subscriberCount,
|
||||
isVerified: channel.isVerified
|
||||
)
|
||||
}
|
||||
|
||||
private static func extractSourceInfo(from source: ContentSource) -> (String, String?) {
|
||||
switch source {
|
||||
case .global:
|
||||
return ("global", nil)
|
||||
case .federated(_, let instance):
|
||||
return ("federated", instance.absoluteString)
|
||||
case .extracted:
|
||||
return ("extracted", nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
74
Yattee/Data/RecentPlaylist.swift
Normal file
74
Yattee/Data/RecentPlaylist.swift
Normal file
@@ -0,0 +1,74 @@
|
||||
//
|
||||
// RecentPlaylist.swift
|
||||
// Yattee
|
||||
//
|
||||
// SwiftData model for recent playlist visits (remote playlists only).
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
@Model
|
||||
final class RecentPlaylist {
|
||||
var id: UUID
|
||||
var playlistID: String
|
||||
var sourceRawValue: String
|
||||
var instanceURLString: String?
|
||||
var title: String
|
||||
var authorName: String
|
||||
var videoCount: Int
|
||||
var thumbnailURLString: String?
|
||||
var visitedAt: Date
|
||||
|
||||
init(
|
||||
id: UUID = UUID(),
|
||||
playlistID: String,
|
||||
sourceRawValue: String,
|
||||
instanceURLString: String? = nil,
|
||||
title: String,
|
||||
authorName: String = "",
|
||||
videoCount: Int = 0,
|
||||
thumbnailURLString: String? = nil,
|
||||
visitedAt: Date = Date()
|
||||
) {
|
||||
self.id = id
|
||||
self.playlistID = playlistID
|
||||
self.sourceRawValue = sourceRawValue
|
||||
self.instanceURLString = instanceURLString
|
||||
self.title = title
|
||||
self.authorName = authorName
|
||||
self.videoCount = videoCount
|
||||
self.thumbnailURLString = thumbnailURLString
|
||||
self.visitedAt = visitedAt
|
||||
}
|
||||
|
||||
/// Creates a RecentPlaylist from a Playlist model
|
||||
/// Returns nil for local playlists (we only track remote ones)
|
||||
static func from(playlist: Playlist) -> RecentPlaylist? {
|
||||
guard !playlist.isLocal, let source = playlist.id.source else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let (sourceRaw, instanceURL) = extractSourceInfo(from: source)
|
||||
return RecentPlaylist(
|
||||
playlistID: playlist.id.playlistID,
|
||||
sourceRawValue: sourceRaw,
|
||||
instanceURLString: instanceURL,
|
||||
title: playlist.title,
|
||||
authorName: playlist.authorName,
|
||||
videoCount: playlist.videoCount,
|
||||
thumbnailURLString: playlist.thumbnailURL?.absoluteString
|
||||
)
|
||||
}
|
||||
|
||||
private static func extractSourceInfo(from source: ContentSource) -> (String, String?) {
|
||||
switch source {
|
||||
case .global:
|
||||
return ("global", nil)
|
||||
case .federated(_, let instance):
|
||||
return ("federated", instance.absoluteString)
|
||||
case .extracted:
|
||||
return ("extracted", nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
22
Yattee/Data/SearchHistory.swift
Normal file
22
Yattee/Data/SearchHistory.swift
Normal file
@@ -0,0 +1,22 @@
|
||||
//
|
||||
// SearchHistory.swift
|
||||
// Yattee
|
||||
//
|
||||
// SwiftData model for search history.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
@Model
|
||||
final class SearchHistory {
|
||||
var id: UUID
|
||||
var query: String
|
||||
var searchedAt: Date
|
||||
|
||||
init(id: UUID = UUID(), query: String, searchedAt: Date = Date()) {
|
||||
self.id = id
|
||||
self.query = query
|
||||
self.searchedAt = searchedAt
|
||||
}
|
||||
}
|
||||
205
Yattee/Data/Subscription.swift
Normal file
205
Yattee/Data/Subscription.swift
Normal file
@@ -0,0 +1,205 @@
|
||||
//
|
||||
// Subscription.swift
|
||||
// Yattee
|
||||
//
|
||||
// SwiftData model for channel subscriptions.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
/// Represents a subscribed channel.
|
||||
@Model
|
||||
final class Subscription {
|
||||
// MARK: - Channel Identity
|
||||
|
||||
/// The channel ID string.
|
||||
var channelID: String = ""
|
||||
|
||||
/// The content source raw value.
|
||||
var sourceRawValue: String = "youtube"
|
||||
|
||||
/// For PeerTube: the instance URL string.
|
||||
var instanceURLString: String?
|
||||
|
||||
// MARK: - Channel Metadata
|
||||
|
||||
/// The channel name.
|
||||
var name: String = ""
|
||||
|
||||
/// Channel description.
|
||||
var channelDescription: String?
|
||||
|
||||
/// Subscriber count (if known).
|
||||
var subscriberCount: Int?
|
||||
|
||||
/// Avatar/thumbnail URL string.
|
||||
var avatarURLString: String?
|
||||
|
||||
/// Banner URL string.
|
||||
var bannerURLString: String?
|
||||
|
||||
/// Whether the channel is verified.
|
||||
var isVerified: Bool = false
|
||||
|
||||
// MARK: - Subscription Metadata
|
||||
|
||||
/// When the subscription was created.
|
||||
var subscribedAt: Date = Date()
|
||||
|
||||
/// When channel info was last updated.
|
||||
var lastUpdatedAt: Date = Date()
|
||||
|
||||
/// When the channel's most recent video was published (for sorting).
|
||||
var lastVideoPublishedAt: Date?
|
||||
|
||||
// MARK: - Server Sync (Yattee Server)
|
||||
|
||||
/// The server's subscription ID (for deletion via server API).
|
||||
var serverSubscriptionID: Int?
|
||||
|
||||
/// The provider name (e.g., "youtube", "peertube") for server sync.
|
||||
/// Used as the `site` field in server API calls.
|
||||
var providerName: String?
|
||||
|
||||
/// The channel URL for external/extracted sources (required for feed fetching).
|
||||
var channelURLString: String?
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init(
|
||||
channelID: String,
|
||||
sourceRawValue: String,
|
||||
instanceURLString: String? = nil,
|
||||
name: String,
|
||||
channelDescription: String? = nil,
|
||||
subscriberCount: Int? = nil,
|
||||
avatarURLString: String? = nil,
|
||||
bannerURLString: String? = nil,
|
||||
isVerified: Bool = false,
|
||||
channelURLString: String? = nil
|
||||
) {
|
||||
self.channelID = channelID
|
||||
self.sourceRawValue = sourceRawValue
|
||||
self.instanceURLString = instanceURLString
|
||||
self.name = name
|
||||
self.channelDescription = channelDescription
|
||||
self.subscriberCount = subscriberCount
|
||||
self.avatarURLString = avatarURLString
|
||||
self.bannerURLString = bannerURLString
|
||||
self.isVerified = isVerified
|
||||
self.channelURLString = channelURLString
|
||||
self.subscribedAt = Date()
|
||||
self.lastUpdatedAt = Date()
|
||||
}
|
||||
|
||||
// MARK: - Computed Properties
|
||||
|
||||
/// The content source for this subscription.
|
||||
var contentSource: ContentSource {
|
||||
let provider = providerName ?? ContentSource.youtubeProvider
|
||||
|
||||
if sourceRawValue == "global" {
|
||||
return .global(provider: provider)
|
||||
} else if sourceRawValue == "federated",
|
||||
let urlString = instanceURLString,
|
||||
let url = URL(string: urlString) {
|
||||
return .federated(provider: providerName ?? ContentSource.peertubeProvider, instance: url)
|
||||
}
|
||||
return .global(provider: provider)
|
||||
}
|
||||
|
||||
/// The site value for server API calls (same as provider).
|
||||
var site: String {
|
||||
providerName ?? contentSource.provider
|
||||
}
|
||||
|
||||
/// The avatar URL if available.
|
||||
var avatarURL: URL? {
|
||||
avatarURLString.flatMap { URL(string: $0) }
|
||||
}
|
||||
|
||||
/// The banner URL if available.
|
||||
var bannerURL: URL? {
|
||||
bannerURLString.flatMap { URL(string: $0) }
|
||||
}
|
||||
|
||||
/// Formatted subscriber count.
|
||||
var formattedSubscriberCount: String? {
|
||||
guard let count = subscriberCount else { return nil }
|
||||
return CountFormatter.compact(count)
|
||||
}
|
||||
|
||||
// MARK: - Methods
|
||||
|
||||
/// Updates the channel metadata from fresh data.
|
||||
/// Uses a merge strategy: only updates optional fields if the new value is non-nil,
|
||||
/// preventing nil values from overwriting valid cached data.
|
||||
func update(from channel: Channel) {
|
||||
name = channel.name
|
||||
isVerified = channel.isVerified
|
||||
lastUpdatedAt = Date()
|
||||
|
||||
// Only update optional fields if new value is non-nil
|
||||
if let desc = channel.description {
|
||||
channelDescription = desc
|
||||
}
|
||||
if let count = channel.subscriberCount {
|
||||
subscriberCount = count
|
||||
}
|
||||
if let thumb = channel.thumbnailURL {
|
||||
avatarURLString = thumb.absoluteString
|
||||
}
|
||||
if let banner = channel.bannerURL {
|
||||
bannerURLString = banner.absoluteString
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Factory Methods
|
||||
|
||||
extension Subscription {
|
||||
/// Creates a Subscription from a Channel model.
|
||||
static func from(channel: Channel) -> Subscription {
|
||||
let sourceRaw: String
|
||||
var instanceURL: String?
|
||||
var channelURL: String?
|
||||
let provider = channel.id.source.provider
|
||||
|
||||
switch channel.id.source {
|
||||
case .global(let prov):
|
||||
sourceRaw = "global"
|
||||
// Construct YouTube channel URL
|
||||
if prov == ContentSource.youtubeProvider {
|
||||
if channel.id.channelID.hasPrefix("@") {
|
||||
channelURL = "https://www.youtube.com/\(channel.id.channelID)"
|
||||
} else {
|
||||
channelURL = "https://www.youtube.com/channel/\(channel.id.channelID)"
|
||||
}
|
||||
}
|
||||
case .federated(_, let instance):
|
||||
sourceRaw = "federated"
|
||||
instanceURL = instance.absoluteString
|
||||
// Construct PeerTube channel URL
|
||||
channelURL = instance.appendingPathComponent("video-channels/\(channel.id.channelID)").absoluteString
|
||||
case .extracted(_, let originalURL):
|
||||
sourceRaw = "extracted"
|
||||
channelURL = originalURL.absoluteString
|
||||
}
|
||||
|
||||
let subscription = Subscription(
|
||||
channelID: channel.id.channelID,
|
||||
sourceRawValue: sourceRaw,
|
||||
instanceURLString: instanceURL,
|
||||
name: channel.name,
|
||||
channelDescription: channel.description,
|
||||
subscriberCount: channel.subscriberCount,
|
||||
avatarURLString: channel.thumbnailURL?.absoluteString,
|
||||
bannerURLString: channel.bannerURL?.absoluteString,
|
||||
isVerified: channel.isVerified,
|
||||
channelURLString: channelURL
|
||||
)
|
||||
subscription.providerName = provider
|
||||
return subscription
|
||||
}
|
||||
}
|
||||
267
Yattee/Data/WatchEntry.swift
Normal file
267
Yattee/Data/WatchEntry.swift
Normal file
@@ -0,0 +1,267 @@
|
||||
//
|
||||
// WatchEntry.swift
|
||||
// Yattee
|
||||
//
|
||||
// SwiftData model for tracking video watch history.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
/// Represents a watched video entry in the user's history.
|
||||
@Model
|
||||
final class WatchEntry {
|
||||
// MARK: - Video Identity
|
||||
|
||||
/// The video ID string (YouTube ID or PeerTube UUID).
|
||||
var videoID: String = ""
|
||||
|
||||
/// The content source raw value for encoding ("global", "federated", "extracted").
|
||||
var sourceRawValue: String = "global"
|
||||
|
||||
/// For global sources: the provider name (e.g., "youtube", "dailymotion").
|
||||
var globalProvider: String?
|
||||
|
||||
/// For PeerTube: the instance URL string.
|
||||
var instanceURLString: String?
|
||||
|
||||
/// For PeerTube: the UUID.
|
||||
var peertubeUUID: String?
|
||||
|
||||
/// For external sources: the extractor name (e.g., "vimeo", "twitter").
|
||||
var externalExtractor: String?
|
||||
|
||||
/// For external sources: the original URL for re-extraction.
|
||||
var externalURLString: String?
|
||||
|
||||
// MARK: - Video Metadata (cached for offline display)
|
||||
|
||||
/// The video title at time of watching.
|
||||
var title: String = ""
|
||||
|
||||
/// The channel/author name.
|
||||
var authorName: String = ""
|
||||
|
||||
/// The channel/author ID.
|
||||
var authorID: String = ""
|
||||
|
||||
/// Video duration in seconds.
|
||||
var duration: TimeInterval = 0
|
||||
|
||||
/// Thumbnail URL string.
|
||||
var thumbnailURLString: String?
|
||||
|
||||
// MARK: - Watch Progress
|
||||
|
||||
/// Last watched position in seconds.
|
||||
var watchedSeconds: TimeInterval = 0
|
||||
|
||||
/// Whether the video has been fully watched (>90% or manually marked).
|
||||
var isFinished: Bool = false
|
||||
|
||||
/// When the video was marked as finished.
|
||||
var finishedAt: Date?
|
||||
|
||||
// MARK: - Timestamps
|
||||
|
||||
/// When this entry was first created.
|
||||
var createdAt: Date = Date()
|
||||
|
||||
/// When this entry was last updated.
|
||||
var updatedAt: Date = Date()
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init(
|
||||
videoID: String,
|
||||
sourceRawValue: String,
|
||||
globalProvider: String? = nil,
|
||||
instanceURLString: String? = nil,
|
||||
peertubeUUID: String? = nil,
|
||||
externalExtractor: String? = nil,
|
||||
externalURLString: String? = nil,
|
||||
title: String,
|
||||
authorName: String,
|
||||
authorID: String,
|
||||
duration: TimeInterval,
|
||||
thumbnailURLString: String? = nil,
|
||||
watchedSeconds: TimeInterval = 0,
|
||||
isFinished: Bool = false
|
||||
) {
|
||||
self.videoID = videoID
|
||||
self.sourceRawValue = sourceRawValue
|
||||
self.globalProvider = globalProvider
|
||||
self.instanceURLString = instanceURLString
|
||||
self.peertubeUUID = peertubeUUID
|
||||
self.externalExtractor = externalExtractor
|
||||
self.externalURLString = externalURLString
|
||||
self.title = title
|
||||
self.authorName = authorName
|
||||
self.authorID = authorID
|
||||
self.duration = duration
|
||||
self.thumbnailURLString = thumbnailURLString
|
||||
self.watchedSeconds = watchedSeconds
|
||||
self.isFinished = isFinished
|
||||
self.createdAt = Date()
|
||||
self.updatedAt = Date()
|
||||
}
|
||||
|
||||
// MARK: - Computed Properties
|
||||
|
||||
/// The content source for this entry.
|
||||
var contentSource: ContentSource {
|
||||
if sourceRawValue == "global" {
|
||||
return .global(provider: globalProvider ?? ContentSource.youtubeProvider)
|
||||
} else if sourceRawValue == "federated",
|
||||
let urlString = instanceURLString,
|
||||
let url = URL(string: urlString) {
|
||||
return .federated(provider: ContentSource.peertubeProvider, instance: url)
|
||||
} else if sourceRawValue == "extracted",
|
||||
let extractor = externalExtractor,
|
||||
let urlString = externalURLString,
|
||||
let url = URL(string: urlString) {
|
||||
return .extracted(extractor: extractor, originalURL: url)
|
||||
}
|
||||
return .global(provider: globalProvider ?? ContentSource.youtubeProvider)
|
||||
}
|
||||
|
||||
/// The full VideoID for this entry, matching what VideoRowView uses for zoom transitions.
|
||||
var videoIdentifier: VideoID {
|
||||
VideoID(source: contentSource, videoID: videoID, uuid: peertubeUUID)
|
||||
}
|
||||
|
||||
/// The thumbnail URL if available.
|
||||
var thumbnailURL: URL? {
|
||||
thumbnailURLString.flatMap { URL(string: $0) }
|
||||
}
|
||||
|
||||
/// Watch progress as a percentage (0.0 to 1.0).
|
||||
var progress: Double {
|
||||
guard duration > 0 else { return 0 }
|
||||
return min(watchedSeconds / duration, 1.0)
|
||||
}
|
||||
|
||||
/// Formatted total duration.
|
||||
var formattedDuration: String {
|
||||
guard duration > 0 else { return "" }
|
||||
let hours = Int(duration) / 3600
|
||||
let minutes = (Int(duration) % 3600) / 60
|
||||
let seconds = Int(duration) % 60
|
||||
|
||||
if hours > 0 {
|
||||
return String(format: "%d:%02d:%02d", hours, minutes, seconds)
|
||||
} else {
|
||||
return String(format: "%d:%02d", minutes, seconds)
|
||||
}
|
||||
}
|
||||
|
||||
/// Formatted remaining time.
|
||||
var remainingTime: String {
|
||||
let remaining = max(0, duration - watchedSeconds)
|
||||
let hours = Int(remaining) / 3600
|
||||
let minutes = (Int(remaining) % 3600) / 60
|
||||
let seconds = Int(remaining) % 60
|
||||
|
||||
if hours > 0 {
|
||||
return String(format: "%d:%02d:%02d", hours, minutes, seconds)
|
||||
} else {
|
||||
return String(format: "%d:%02d", minutes, seconds)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Methods
|
||||
|
||||
/// Updates the watch progress.
|
||||
func updateProgress(seconds: TimeInterval, duration: TimeInterval? = nil) {
|
||||
watchedSeconds = seconds
|
||||
updatedAt = Date()
|
||||
|
||||
// Update duration if it was 0 and a valid duration is now known
|
||||
if self.duration == 0, let newDuration = duration, newDuration > 0 {
|
||||
self.duration = newDuration
|
||||
}
|
||||
|
||||
// Mark as finished if watched more than 90%
|
||||
if progress >= 0.9 && !isFinished {
|
||||
isFinished = true
|
||||
finishedAt = Date()
|
||||
}
|
||||
}
|
||||
|
||||
/// Marks the video as finished.
|
||||
func markAsFinished() {
|
||||
isFinished = true
|
||||
finishedAt = Date()
|
||||
updatedAt = Date()
|
||||
}
|
||||
|
||||
/// Resets the watch progress.
|
||||
func resetProgress() {
|
||||
watchedSeconds = 0
|
||||
isFinished = false
|
||||
finishedAt = nil
|
||||
updatedAt = Date()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Conversion Methods
|
||||
|
||||
extension WatchEntry {
|
||||
/// Converts this WatchEntry back to a Video model for playback or display.
|
||||
func toVideo() -> Video {
|
||||
Video(
|
||||
id: VideoID(source: contentSource, videoID: videoID),
|
||||
title: title,
|
||||
description: nil,
|
||||
author: Author(id: authorID, name: authorName),
|
||||
duration: duration,
|
||||
publishedAt: nil,
|
||||
publishedText: nil,
|
||||
viewCount: nil,
|
||||
likeCount: nil,
|
||||
thumbnails: thumbnailURL.map { [Thumbnail(url: $0, quality: .medium)] } ?? [],
|
||||
isLive: false,
|
||||
isUpcoming: false,
|
||||
scheduledStartTime: nil
|
||||
)
|
||||
}
|
||||
|
||||
/// Creates a WatchEntry from a Video model.
|
||||
static func from(video: Video) -> WatchEntry {
|
||||
let sourceRaw: String
|
||||
var provider: String?
|
||||
var instanceURL: String?
|
||||
var uuid: String?
|
||||
var extractor: String?
|
||||
var externalURL: String?
|
||||
|
||||
switch video.id.source {
|
||||
case .global(let prov):
|
||||
sourceRaw = "global"
|
||||
provider = prov
|
||||
case .federated(_, let instance):
|
||||
sourceRaw = "federated"
|
||||
instanceURL = instance.absoluteString
|
||||
uuid = video.id.uuid
|
||||
case .extracted(let ext, let originalURL):
|
||||
sourceRaw = "extracted"
|
||||
extractor = ext
|
||||
externalURL = originalURL.absoluteString
|
||||
}
|
||||
|
||||
return WatchEntry(
|
||||
videoID: video.id.videoID,
|
||||
sourceRawValue: sourceRaw,
|
||||
globalProvider: provider,
|
||||
instanceURLString: instanceURL,
|
||||
peertubeUUID: uuid,
|
||||
externalExtractor: extractor,
|
||||
externalURLString: externalURL,
|
||||
title: video.title,
|
||||
authorName: video.author.name,
|
||||
authorID: video.author.id,
|
||||
duration: video.duration,
|
||||
thumbnailURLString: video.bestThumbnail?.url.absoluteString
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user