378 lines
18 KiB
Swift
378 lines
18 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?
|
|
/// Drives the Refresh spinner / disabled state. Bounded (see `refresh()`) so a
|
|
/// slow or stalled NFS walk can never leave it stuck `true` forever.
|
|
public private(set) var refreshing = false
|
|
/// True while a scan task is actually running — guards against launching a
|
|
/// second concurrent scan even after the spinner has been un-stuck.
|
|
private var scanInFlight = false
|
|
/// Directories read so far in the in-progress scan — drives the loading
|
|
/// indicator. There's no known total over NFS, so this is a live count, not a
|
|
/// percentage. 0 when idle.
|
|
public private(set) var scanProgress = 0
|
|
/// Denominator for a DETERMINATE progress bar during a full index rebuild
|
|
/// (prior index size). nil ⇒ indeterminate (e.g. the fallback NFS walk, which
|
|
/// has no known total).
|
|
public private(set) var scanTotal: Int?
|
|
public var query: String = ""
|
|
/// nil = all categories (minus porn unless `showPorn`). Otherwise a single category.
|
|
public var selectedCategory: String?
|
|
/// The show whose detail page is open (nil = the grid). Shared so Home can
|
|
/// open a show in the Library, and the breadcrumb can navigate back out.
|
|
public var selectedShow: CachedShow?
|
|
/// Porn is scanned but hidden until toggled on — it's 64% of the library.
|
|
/// This is the Library tab's browse toggle (session-only); Home has its own
|
|
/// persisted gate, `surfaceAdultOnHome`, so browsing porn in Library never
|
|
/// makes it leak onto the landing screen.
|
|
public var showPorn = false
|
|
/// Home-screen adult gate, persisted across launches and independent of
|
|
/// `showPorn`. When false (default), Home hides the porn category rail and
|
|
/// filters adult items out of Continue Watching / Recently Added.
|
|
public var surfaceAdultOnHome: Bool = SettingsStore.load().surfaceAdultOnHome {
|
|
didSet { SettingsStore.save(AppSettings(surfaceAdultOnHome: surfaceAdultOnHome)) }
|
|
}
|
|
|
|
/// Preferred display order; anything unlisted sorts after, alphabetically.
|
|
private static let categoryOrder = ["tv", "movies", "anime", "cartoons",
|
|
"collections", "unsorted", "misc", "porn"]
|
|
|
|
public init() { loadCache() }
|
|
|
|
private func orderIndex(_ c: String) -> Int {
|
|
Self.categoryOrder.firstIndex(of: c) ?? Self.categoryOrder.count
|
|
}
|
|
|
|
/// Distinct categories present, in display order; porn omitted unless `showPorn`.
|
|
public var categories: [String] {
|
|
let present = Set(shows.map(\.category)).subtracting([""])
|
|
let visible = showPorn ? present : present.subtracting(["porn"])
|
|
return visible.sorted { (orderIndex($0), $0) < (orderIndex($1), $1) }
|
|
}
|
|
|
|
// MARK: - Home screen (gated by `surfaceAdultOnHome`, not `showPorn`)
|
|
|
|
/// Categories for the Home rails — like `categories`, but gated by the
|
|
/// persisted Home adult setting rather than the Library browse toggle.
|
|
public var homeCategories: [String] {
|
|
let present = Set(shows.map(\.category)).subtracting([""])
|
|
let visible = surfaceAdultOnHome ? present : present.subtracting(["porn"])
|
|
return visible.sorted { (orderIndex($0), $0) < (orderIndex($1), $1) }
|
|
}
|
|
|
|
/// Shows in a Home category rail, newest-added first so each rail leads with
|
|
/// fresh content (the Library grid stays alphabetical).
|
|
public func homeShows(in category: String) -> [CachedShow] {
|
|
shows.filter { $0.category == category }
|
|
.sorted { ($0.addedAt ?? .distantPast) > ($1.addedAt ?? .distantPast) }
|
|
}
|
|
|
|
/// The Home "Recently Added" rail: newest additions across the whole library,
|
|
/// porn excluded unless `surfaceAdultOnHome`. Only items with a known
|
|
/// `addedAt` (i.e. seen by a scan on this build) qualify — so it's empty until
|
|
/// the first rescan, never bogus.
|
|
public func recentlyAdded(limit: Int = 24) -> [CachedShow] {
|
|
shows.filter { $0.addedAt != nil && (surfaceAdultOnHome || $0.category != "porn") }
|
|
.sorted { $0.addedAt! > $1.addedAt! }
|
|
.prefix(limit)
|
|
.map { $0 }
|
|
}
|
|
|
|
/// Continue Watching for Home — adult items removed unless `surfaceAdultOnHome`.
|
|
/// (The raw `continueWatching` rail elsewhere is unfiltered.)
|
|
public var homeContinueWatching: [ContinueItem] {
|
|
surfaceAdultOnHome ? continueWatching : continueWatching.filter { !$0.isAdult }
|
|
}
|
|
|
|
/// Saved resume positions keyed by black-side path, for the episode
|
|
/// resume/start-over choice.
|
|
public func resumePositions() -> [String: Double] { WatchHistory.resumePositions() }
|
|
|
|
// MARK: - Franchise (series + related movies, chronological)
|
|
|
|
private var franchisePrefs = FranchiseStore.load()
|
|
|
|
/// A movie belongs to `series` if it's in the same category and its name
|
|
/// begins with the series name at a word boundary ("Psych 2", "Psych: The
|
|
/// Movie" — but not "Psycho").
|
|
nonisolated static func nameHasPrefix(_ name: String, _ prefix: String) -> Bool {
|
|
guard name.count > prefix.count, name.hasPrefix(prefix) else { return false }
|
|
let next = name[name.index(name.startIndex, offsetBy: prefix.count)]
|
|
return !next.isLetter
|
|
}
|
|
|
|
/// The franchise timeline for `series`: the series itself plus prefix-matched
|
|
/// movies (minus unlinked), ordered by the manual override if set, else by
|
|
/// release year. Returns just the series when nothing matches.
|
|
public func franchiseTimeline(for series: CachedShow) -> [CachedShow] {
|
|
guard series.kind == .series else { return [series] }
|
|
let unlinked = Set(franchisePrefs.unlinked[series.rootDir] ?? [])
|
|
let prefix = series.name.lowercased()
|
|
let movies = shows.filter {
|
|
$0.kind == .movie && $0.category == series.category
|
|
&& !unlinked.contains($0.rootDir)
|
|
&& Self.nameHasPrefix($0.name.lowercased(), prefix)
|
|
}
|
|
guard !movies.isEmpty else { return [series] }
|
|
var items = [series] + movies
|
|
if let manual = franchisePrefs.order[series.rootDir], !manual.isEmpty {
|
|
let rank = Dictionary(uniqueKeysWithValues: manual.enumerated().map { ($1, $0) })
|
|
items.sort { (rank[$0.rootDir] ?? Int.max, $0.year ?? 0) < (rank[$1.rootDir] ?? Int.max, $1.year ?? 0) }
|
|
} else {
|
|
items.sort { ($0.year ?? Int.max) < ($1.year ?? Int.max) }
|
|
}
|
|
return items
|
|
}
|
|
|
|
public func unlinkFromFranchise(series: CachedShow, movie: CachedShow) {
|
|
franchisePrefs.unlinked[series.rootDir, default: []].append(movie.rootDir)
|
|
FranchiseStore.save(franchisePrefs)
|
|
}
|
|
|
|
/// Persist a manual franchise order (item rootDirs, series + movies).
|
|
public func reorderFranchise(series: CachedShow, order: [String]) {
|
|
franchisePrefs.order[series.rootDir] = order
|
|
FranchiseStore.save(franchisePrefs)
|
|
}
|
|
|
|
public func count(of category: String) -> Int {
|
|
shows.filter { $0.category == category }.count
|
|
}
|
|
|
|
/// Total visible across the "All" view (respects the porn toggle).
|
|
public var visibleCount: Int {
|
|
showPorn ? shows.count : shows.filter { $0.category != "porn" }.count
|
|
}
|
|
|
|
public var filteredShows: [CachedShow] {
|
|
var items = shows
|
|
if !showPorn { items = items.filter { $0.category != "porn" } }
|
|
if let cat = selectedCategory { items = items.filter { $0.category == cat } }
|
|
let q = query.trimmingCharacters(in: .whitespaces).lowercased()
|
|
if !q.isEmpty { items = items.filter { $0.name.lowercased().contains(q) } }
|
|
return items
|
|
}
|
|
|
|
private func loadCache() {
|
|
if let snap = LibraryStore.load() {
|
|
shows = snap.shows
|
|
source = "cache (\(snap.source))"
|
|
lastRefresh = snap.capturedAt
|
|
}
|
|
refreshContinueWatching()
|
|
}
|
|
|
|
/// Rebuild ONLY the Continue Watching rail from the watchlog + VLC recents
|
|
/// (cheap — no library scan). Called on Home appearing and on a short poll so
|
|
/// the rail reflects what was just played via the in-app player.
|
|
public func refreshContinueWatching() {
|
|
continueWatching = withPosters(WatchHistory.continueItems())
|
|
}
|
|
|
|
/// Attach library cover art to each watch entry (the watchlog/VLC recents carry
|
|
/// none): by show name for series, else by matching the file's basename to a
|
|
/// scanned episode — so a VLC recent like "Psych S01E06.mkv" inherits Psych's art.
|
|
private func withPosters(_ items: [ContinueItem]) -> [ContinueItem] {
|
|
guard !shows.isEmpty else { return items }
|
|
let byName = Dictionary(shows.map { ($0.name.lowercased(), $0.posterPath) },
|
|
uniquingKeysWith: { a, _ in a })
|
|
var byBasename: [String: String?] = [:]
|
|
for s in shows where s.posterPath != nil {
|
|
for e in s.episodes { byBasename[(e.path as NSString).lastPathComponent] = s.posterPath }
|
|
}
|
|
return items.map { item in
|
|
var it = item
|
|
if let show = item.show, let p = byName[show.lowercased()] ?? nil { it.posterPath = p }
|
|
else if let p = byBasename[(item.path as NSString).lastPathComponent] ?? nil { it.posterPath = p }
|
|
return it
|
|
}
|
|
}
|
|
|
|
/// True while a full index rebuild was requested on black (it runs ~3.5 min
|
|
/// out-of-band there); drives the Setup button state.
|
|
public private(set) var rebuildingIndex = false
|
|
|
|
/// Update the black-side index. `folders` (finished-download content dirs) are
|
|
/// appended INCREMENTALLY — cheap, just those dirs — which is the common case;
|
|
/// avoid the minutes-long full re-walk unless `folders` is empty (the Setup
|
|
/// "overscan" button). Then refresh once from the updated index.
|
|
public func rebuildIndex(folders: [String] = []) {
|
|
guard !rebuildingIndex else { return }
|
|
rebuildingIndex = true
|
|
let incremental = !folders.isEmpty
|
|
Log.info(incremental
|
|
? "library index: incremental add of \(folders.count) folder(s)"
|
|
: "library index: full rebuild (overscan) on black")
|
|
Task {
|
|
let kicked = await Task.detached {
|
|
if incremental { return folders.allSatisfy { LibraryIndex.rebuild(addDir: $0) } }
|
|
return LibraryIndex.rebuild()
|
|
}.value
|
|
guard kicked else { rebuildingIndex = false; Log.warn("index rebuild: black unreachable"); return }
|
|
if incremental {
|
|
// Lands in seconds — a beat, then one refresh.
|
|
try? await Task.sleep(for: .seconds(8))
|
|
} else {
|
|
// Determinate progress: poll the growing temp file against the prior
|
|
// index size until the build finishes (bounded ~12 min).
|
|
scanTotal = await Task.detached { LibraryIndex.indexCount() }.value.flatMap { $0 > 0 ? $0 : nil }
|
|
scanProgress = 0
|
|
for _ in 0..<240 {
|
|
try? await Task.sleep(for: .seconds(3))
|
|
guard let s = await Task.detached(operation: { LibraryIndex.buildStatus() }).value else { continue }
|
|
scanProgress = s.lines
|
|
if !s.building { break }
|
|
}
|
|
scanTotal = nil; scanProgress = 0
|
|
}
|
|
await refresh()
|
|
rebuildingIndex = false
|
|
}
|
|
}
|
|
|
|
/// Cheap "is a rescan even warranted?" gate — the library is a durable
|
|
/// snapshot, not something to re-walk on a timer. Stats the category dirs
|
|
/// (~one readdir per root, milliseconds) and only triggers the minutes-long
|
|
/// full `refresh()` when a category folder is newer than our last scan (a new
|
|
/// show/season folder appeared). The Continue Watching rail is always refreshed
|
|
/// (it's local + cheap). Use this on Home/Library appear instead of `refresh()`.
|
|
public func refreshIfStale() async {
|
|
refreshContinueWatching()
|
|
guard !scanInFlight else { return }
|
|
let last = lastRefresh
|
|
let newest = await Task.detached(priority: .utility) {
|
|
LibraryScanner.rootsAvailable() ? LibraryScanner.newestCategoryMTime() : nil
|
|
}.value
|
|
guard let newest else { return } // mount down → keep cache
|
|
if let last, newest <= last {
|
|
Log.info("library fresh — no category changes since last scan")
|
|
return
|
|
}
|
|
Log.info("library stale (folder change since last scan) → rescan")
|
|
await refresh()
|
|
}
|
|
|
|
/// 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 !scanInFlight else { return }
|
|
scanInFlight = true
|
|
refreshing = true
|
|
|
|
refreshContinueWatching()
|
|
|
|
// Availability check + scan both run off-main: `rootsAvailable` retries to
|
|
// let the autofs automount fire (a cold-launch access triggers the mount
|
|
// but can return/throw before it's ready — without the retry the very
|
|
// first refresh races it, falls back to the registry, and never rescans).
|
|
let previous = shows
|
|
Log.info("library scan: starting")
|
|
scanProgress = 0
|
|
let scan = Task.detached(priority: .utility) { [weak self] () -> [CachedShow] in
|
|
// Fast path: parse black's prebuilt index (one instant SSH `cat`).
|
|
// The disk-bound walk is black's job, out-of-band — we only fall back to
|
|
// a live NFS walk when the index is unavailable (black down / not built).
|
|
if let tsv = LibraryIndex.fetch() {
|
|
let shows = LibraryScanner.scanFromIndex(tsv)
|
|
if !shows.isEmpty { Log.info("library: \(shows.count) shows from black index"); return shows }
|
|
}
|
|
guard LibraryScanner.rootsAvailable() else { return [] }
|
|
Log.info("library: index unavailable — falling back to live NFS walk")
|
|
return LibraryScanner.scan(onProgress: { dirs in
|
|
Task { @MainActor in self?.scanProgress = dirs }
|
|
})
|
|
}
|
|
|
|
// The walk is slow over NFS (~thousands of files) and can stall if the
|
|
// mount flaps. Drop the spinner / re-enable Refresh after a generous bound
|
|
// so it's never stuck disabled forever; the scan keeps running and applies
|
|
// when it lands. `scanInFlight` still blocks a second concurrent scan.
|
|
let spinnerBound = Task { [weak self] in
|
|
try? await Task.sleep(for: .seconds(600))
|
|
guard let self, !Task.isCancelled else { return }
|
|
Log.warn("library scan: exceeded 600s — re-enabling Refresh (scan still running)")
|
|
self.refreshing = false
|
|
}
|
|
|
|
let scanned = await scan.value
|
|
spinnerBound.cancel()
|
|
applyScan(scanned, previous: previous)
|
|
refreshing = false
|
|
scanInFlight = false
|
|
}
|
|
|
|
/// Fold a completed scan into the UI state (or fall back to the registry when
|
|
/// the mount yielded nothing and we have no cache). Persists the snapshot.
|
|
private func applyScan(_ scanned: [CachedShow], previous: [CachedShow]) {
|
|
if !scanned.isEmpty {
|
|
shows = LibraryScanner.mergeEnrichment(scanned, from: previous)
|
|
source = "scan"
|
|
lastRefresh = Date()
|
|
LibraryStore.save(LibrarySnapshot(shows: shows, capturedAt: Date(), source: "scan"))
|
|
refreshContinueWatching() // re-attach posters now that shows are fresh
|
|
Log.info("library scan → \(shows.count) shows")
|
|
return
|
|
}
|
|
Log.warn("library scan found nothing (mount unreachable?) — keeping cache/registry")
|
|
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.
|
|
/// Library-aware hosts (black via mpv) resolve by name + resume; VLC needs a
|
|
/// file path.
|
|
public func launchRequest(show: CachedShow, episode: CachedEpisode?, targetKind: ServiceKind) -> LaunchRequest? {
|
|
// Movies have no season/episode/resume semantics — always play the file
|
|
// directly, on every target.
|
|
if show.kind == .movie {
|
|
guard let path = episode?.path ?? show.episodes.first?.path else { return nil }
|
|
return .file(path: path)
|
|
}
|
|
switch targetKind {
|
|
case .mpv:
|
|
if let ep = episode { return .show(name: show.name, season: ep.season, episode: ep.episode) }
|
|
return .resume(name: show.name)
|
|
case .vlc, .quicktime:
|
|
guard let path = episode?.path ?? show.episodes.first?.path else { return nil }
|
|
return .file(path: path)
|
|
case .resourcesDrive:
|
|
return nil // not a playback target
|
|
}
|
|
}
|
|
|
|
public func launchRequest(continue item: ContinueItem, targetKind: ServiceKind) -> LaunchRequest? {
|
|
switch targetKind {
|
|
case .mpv:
|
|
if let show = item.show { return .show(name: show, season: item.season, episode: item.episode) }
|
|
return .file(path: item.path)
|
|
case .vlc, .quicktime:
|
|
return .file(path: item.path)
|
|
case .resourcesDrive:
|
|
return nil // not a playback target
|
|
}
|
|
}
|
|
}
|