import Foundation import Observation /// Owns the library snapshot for the UI: loads the cached snapshot instantly, /// refreshes from a live scan in the background, persists the result, and builds /// the continue-watching rail. Mirrors PlayerController's @Observable/@MainActor /// shape. Playback launch is delegated to PlayerController (it owns the targets). @Observable @MainActor public final class LibraryController { public private(set) var shows: [CachedShow] = [] public private(set) var continueWatching: [ContinueItem] = [] public private(set) var source: String = "" public private(set) var lastRefresh: Date? public private(set) var refreshing = false public var query: String = "" public init() { loadCache() } public var filteredShows: [CachedShow] { let q = query.trimmingCharacters(in: .whitespaces).lowercased() guard !q.isEmpty else { return shows } return shows.filter { $0.name.lowercased().contains(q) } } private func loadCache() { if let snap = LibraryStore.load() { shows = snap.shows source = "cache (\(snap.source))" lastRefresh = snap.capturedAt } continueWatching = WatchHistory.continueItems() } /// Refresh from the best available source: live scan of ~/media when the /// mount is up (persisted to the snapshot), else keep the cache, else fall /// back to the registry title list. Never wipes a good cache with nothing. public func refresh() async { guard !refreshing else { return } refreshing = true defer { refreshing = false } continueWatching = WatchHistory.continueItems() if LibraryScanner.rootsAvailable() { let previous = shows let scanned = await Task.detached(priority: .utility) { LibraryScanner.scan() }.value if !scanned.isEmpty { shows = LibraryScanner.mergeEnrichment(scanned, from: previous) source = "scan" lastRefresh = Date() LibraryStore.save(LibrarySnapshot(shows: shows, capturedAt: Date(), source: "scan")) return } } if shows.isEmpty { let reg = RegistryIngest.shows() if !reg.isEmpty { shows = reg; source = "registry"; lastRefresh = Date() } } } /// Fold resolved poster/overview onto a show (by rootDir) and persist, so the /// grid shows artwork. Called by the metadata pipeline after enrichment. public func applyEnrichment(rootDir: String, posterURL: String?, overview: String?) { guard let i = shows.firstIndex(where: { $0.rootDir == rootDir }) else { return } if let posterURL { shows[i].posterPath = posterURL } if let overview, !overview.isEmpty { shows[i].overview = overview } LibraryStore.save(LibrarySnapshot(shows: shows, capturedAt: lastRefresh ?? Date(), source: source.isEmpty ? "scan" : source)) } /// Build the launch request for a show/episode given the active target's kind. /// black is library-aware (resolve by name + resume); VLC needs a file path. public func launchRequest(show: CachedShow, episode: CachedEpisode?, targetKind: HostKind) -> LaunchRequest? { switch targetKind { case .blacktv: if let ep = episode { return .show(name: show.name, season: ep.season, episode: ep.episode) } return .resume(name: show.name) case .vlc: guard let path = episode?.path ?? show.episodes.first?.path else { return nil } return .file(path: path) } } public func launchRequest(continue item: ContinueItem, targetKind: HostKind) -> LaunchRequest? { switch targetKind { case .blacktv: if let show = item.show { return .show(name: show, season: item.season, episode: item.episode) } return .file(path: item.path) case .vlc: return .file(path: item.path) } } }