94 lines
4 KiB
Swift
94 lines
4 KiB
Swift
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)
|
|
}
|
|
}
|
|
}
|