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