tv-anarchy/Sources/PlumTVCore/Library/LibraryController.swift
Natalie 65f3cb1e4e feat(plum-tv): add async poster loading for shows
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
2026-06-07 22:06:27 -07:00

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)
}
}
}