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 (from watchlog SSOT). This is the Media Management piece /// (Library pillar). Playback (viewer clients) is delegated to PlayerController (owns targets, /// queues, execution on VLC/mpv/etc.). See v2/plan.md split and narrow glue (recordPlay /// callbacks, continueWatching consumption, activePlayerId). /// Mirrors PlayerController's @Observable/@MainActor shape. Playback launch is delegated to PlayerController (it owns the targets). @Observable @MainActor public final class LibraryController: LibraryProviding { 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 scan 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 for a directory walk, 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 local 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 /// Persisted app settings, mutated through the computed accessors below. Held /// as one struct so a toggle does a load-modify-save (never clobbers a sibling /// field — the old per-field `AppSettings(...)` save wiped its neighbours). /// `@Observable` tracks this stored property, so the computed accessors drive /// SwiftUI bindings. private var settings = SettingsStore.load() /// The single source of truth for all watch-derived state (history, resumes, /// per-ep fractions, played set). Library derives its continue rail + resume /// targets from it; views read played/positions through here instead of /// snapshotting WatchHistory statics; player pushes live positions here. public let watchHistory: WatchHistoryController /// Offline cache controller (for per-episode downloaded/downloading status). /// Attached after creation so Library can expose download state for Home/Library /// UIs. Uses DownloadsIndex (for "has local copy") + the controller's queue /// (for active "downloading" progress on specific eps). public private(set) var offlineCache: OfflineCacheController? /// Torrent downloads controller (for active "downloading" awareness from transmission /// transfers that match show/ep names). Completed torrents become "downloaded" /// once ingested and (optionally) rsynced to local cache. Attached like offlineCache. public private(set) var torrentDownloads: DownloadsController? /// Persist one field without losing the others: re-read from disk (in case /// another screen wrote a sibling field), apply the mutation, save, and keep /// the in-memory copy in sync so the UI updates. private func mutate(_ change: (inout AppSettings) -> Void) { var s = SettingsStore.load() change(&s) SettingsStore.save(s) settings = s } /// Master adult switch (persisted). Gates the Adult tab and the queue-popover /// collections. Flipped by the sidebar "hidden" icon. public var pornFeature: Bool { get { settings.pornFeature } set { mutate { $0.pornFeature = newValue } } } /// Home-screen adult gate, persisted across launches and independent of /// `showPorn`. When false (default), the *main* Home hides the porn category /// rail and filters adult items out of Continue Watching / Recently Added. public var surfaceAdultOnHome: Bool { get { settings.surfaceAdultOnHome } set { mutate { $0.surfaceAdultOnHome = newValue } } } /// When true, the Adult tab is a full adult-only Home rather than just the /// collections browser. Persisted; only meaningful while `pornFeature` is on. public var switchToAdultOnlyHome: Bool { get { settings.switchToAdultOnlyHome } set { mutate { $0.switchToAdultOnlyHome = newValue } } } /// Home (and adult-only Home) Continue Watching rail. public var showContinueWatchingOnHome: Bool { get { settings.showContinueWatchingOnHome } set { mutate { $0.showContinueWatchingOnHome = newValue } } } /// Home (and adult-only Home) Recently Added rail. public var showRecentlyAddedOnHome: Bool { get { settings.showRecentlyAddedOnHome } set { mutate { $0.showRecentlyAddedOnHome = newValue } } } /// Clips queued when tapping an Adult collection (persisted). public var adultQueueCount: Int { get { settings.adultQueueCount } set { mutate { $0.adultQueueCount = min(100, max(5, newValue)) } } } public var forwardMediaKeys: Bool { get { settings.forwardMediaKeys } set { mutate { $0.forwardMediaKeys = newValue } } } public var forwardVolumeKeys: Bool { get { settings.forwardVolumeKeys } set { mutate { $0.forwardVolumeKeys = newValue } } } public var notifyDownloads: Bool { get { settings.notifyDownloads } set { mutate { $0.notifyDownloads = newValue } } } // v1 bandwidth policy options (persisted; used by Downloads + governor for upload tiers). public var serveFriendsWhenIdle: Bool { get { settings.serveFriendsWhenIdle } set { mutate { $0.serveFriendsWhenIdle = newValue } } } public var seedPublicWhenIdle: Bool { get { settings.seedPublicWhenIdle } set { mutate { $0.seedPublicWhenIdle = newValue } } } public var totalUploadKBps: Int? { get { settings.totalUploadKBps } set { mutate { $0.totalUploadKBps = newValue } } } public var skipIntroSeconds: Int { get { settings.skipIntroSeconds } set { mutate { $0.skipIntroSeconds = newValue } } } public var hoverPreviews: Bool { get { settings.hoverPreviews } set { mutate { $0.hoverPreviews = newValue } } } public var combineSplitShows: Bool { get { settings.combineSplitShows } set { mutate { $0.combineSplitShows = newValue } } } public var useLLMGrouper: Bool { get { settings.useLLMGrouper } set { mutate { $0.useLLMGrouper = newValue } } } /// Stream (remote) vs offline (local copies). Drives the toolbar mode picker. public var playbackMode: PlaybackMode { get { settings.playbackMode } set { mutate { $0.playbackMode = newValue } } } /// Playback mode for adult paths/categories — independent of `playbackMode`. public var adultPlaybackMode: PlaybackMode { get { settings.adultPlaybackMode } set { mutate { $0.adultPlaybackMode = newValue } } } /// Which mode applies to a library item — adult content uses `adultPlaybackMode`. public func playbackMode(for path: String?, category: String?) -> PlaybackMode { if let path, LibraryConfig.isAdult(path: path) { return adultPlaybackMode } if let category, LibraryConfig.isAdult(category: category) { return adultPlaybackMode } return playbackMode } /// Local playback output. nil = auto (external TV when connected). public var playbackDisplayId: UInt32? { get { settings.playbackDisplayId } set { mutate { $0.playbackDisplayId = newValue } } } /// Last host selected in the player toolbar — restored on relaunch. public var activePlayerId: String? { get { settings.activePlayerId } set { mutate { $0.activePlayerId = newValue } } } /// App-wide visual theme (standard or Winamp-style skins). public var appTheme: AppTheme { get { settings.appTheme } set { mutate { $0.appTheme = newValue } } } /// Installed `.wsz` skin cache id (SHA256) — nil = built-in Winamp palette only. public var winampSkinId: String? { get { settings.winampSkinId } set { mutate { $0.winampSkinId = newValue } } } /// Human label for `winampSkinId`. public var winampSkinName: String? { get { settings.winampSkinName } set { mutate { $0.winampSkinName = newValue } } } /// The in-memory settings snapshot — kept in sync with disk on every mutation. public var appSettings: AppSettings { settings } /// Reload from disk (e.g. after an external writer — prefer patchSettings for MCP). public func reloadSettings() { settings = SettingsStore.load() } /// Shallow-merge a partial patch onto disk and refresh the in-memory copy. public func patchSettings(_ patch: AppSettingsPatch) { mutate { s in var p = patch p.apply(to: &s) } } // MARK: - Library folder types (configurable folder → type mapping) /// The raw top-level folders actually present in the library — the rows the /// Setup "folder types" editor offers. Raw names (the on-disk folders), not /// resolved types. public var presentFolders: [String] { Set(shows.map(\.category)).subtracting([""]).sorted() } /// The type a raw folder resolves to (identity when unmapped). Delegates to the /// shared config so the controller, value types, and SmartPlaylist agree. public func type(of folder: String) -> String { LibraryConfig.type(of: folder) } /// Assign a folder's library type. Picking the folder's own name clears the /// mapping (back to identity) rather than storing a redundant self-map. public func setFolderType(_ folder: String, _ type: String) { mutate { $0.folderTypes[folder] = (type == folder ? nil : type) } } // MARK: library type catalog (editable / expandable) /// The configured type catalog (the editable default), for the Setup editor. public var libraryTypes: [LibraryType] { settings.libraryTypes } /// Add a type (or upsert by slug id if the name already maps to one). A blank /// name is ignored. The id is a stable slug; the label keeps the entered name. public func addLibraryType(name: String, adult: Bool = false) { let id = LibraryTypes.slug(name) let label = name.trimmingCharacters(in: .whitespaces) guard !id.isEmpty, !label.isEmpty else { return } mutate { s in if let i = s.libraryTypes.firstIndex(where: { $0.id == id }) { s.libraryTypes[i].label = label; s.libraryTypes[i].adult = adult } else { s.libraryTypes.append(LibraryType(id: id, label: label, adult: adult)) } } } /// Edit an existing type's label / adult flag (id is immutable). public func updateLibraryType(_ id: String, label: String, adult: Bool) { mutate { s in guard let i = s.libraryTypes.firstIndex(where: { $0.id == id }) else { return } s.libraryTypes[i].label = label; s.libraryTypes[i].adult = adult } } /// Remove a type from the catalog. Folders still mapped to it resolve to the /// (now-unknown) id — displayed capitalized, treated non-adult — until re-typed. public func removeLibraryType(_ id: String) { mutate { s in s.libraryTypes.removeAll { $0.id == id } } } /// Restore the shipped default catalog. public func resetLibraryTypes() { mutate { $0.libraryTypes = LibraryTypes.defaults } } public init(watchHistory: WatchHistoryController, offlineCache: OfflineCacheController? = nil, torrentDownloads: DownloadsController? = nil) { self.watchHistory = watchHistory self.offlineCache = offlineCache self.torrentDownloads = torrentDownloads loadCache() } /// Display order from the configured catalog; unlisted types sort after. private func orderIndex(_ c: String) -> Int { LibraryConfig.order(c) } /// Distinct library TYPES present (folders folded through the folder→type /// config), in display order; adult types omitted unless `showPorn`. public var categories: [String] { let present = Set(shows.map { LibraryConfig.type(of: $0.category) }).subtracting([""]) let visible = showPorn ? present : present.filter { !LibraryConfig.isAdultType($0) } 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 { LibraryConfig.type(of: $0.category) }).subtracting([""]) let visible = surfaceAdultOnHome ? present : present.filter { !LibraryConfig.isAdultType($0) } return visible.sorted { (orderIndex($0), $0) < (orderIndex($1), $1) } } /// Shows in a Home type 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 { LibraryConfig.type(of: $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 || !LibraryConfig.isAdult(category: $0.category)) } .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 } } // MARK: - Adult-only Home (the Adult tab when `switchToAdultOnlyHome` is on) /// Continue Watching restricted to adult items — the adult-only Home rail. public var adultContinueWatching: [ContinueItem] { continueWatching.filter { $0.isAdult } } /// Test seam only: lets Playlist generate(.continueWatching) tests drive a synthetic rail /// without going through watchlog records + refresh. public func _test_setContinueWatching(_ items: [ContinueItem]) { continueWatching = items } /// Test seam only. public func _test_setShows(_ s: [CachedShow]) { shows = s } /// Newest adult additions, for the adult-only Home "Recently Added" rail. public func adultRecentlyAdded(limit: Int = 24) -> [CachedShow] { shows.filter { LibraryConfig.isAdult(category: $0.category) && $0.addedAt != nil } .sorted { $0.addedAt! > $1.addedAt! } .prefix(limit) .map { $0 } } /// Saved resume positions keyed by black-side path, for the episode /// resume/start-over choice. Delegates to the unified watch source. public func resumePositions() -> [String: Double] { watchHistory.resumePositions } /// Per-episode progress (pos + dur when known) for Netflix-style bars. public var episodeProgress: [String: WatchHistory.EpisodeProgress] { watchHistory.episodeProgress } /// Black-side paths that have a completed "play" (finished) event. Source for /// WatchState badges and nextUnwatched. Lives in the unified controller. public var playedPaths: Set { watchHistory.playedPaths } /// Attach the offline cache controller (called from Root after both are created). /// Enables download status queries for UI without duplicating index/queue logic. public func attach(offline: OfflineCacheController) { self.offlineCache = offline } /// Attach the torrent downloads controller for active transfer awareness. public func attach(downloads: DownloadsController) { self.torrentDownloads = downloads } // MARK: - Download / offline cache status (for Home + Library clarity) /// True if a local offline-cache copy exists for this episode's path (via /// the DownloadsIndex, which is refreshed on scans + after cache runs). public func isDownloaded(_ episode: CachedEpisode) -> Bool { DownloadsIndex.shared.localPath(for: episode.path) != nil } /// True if a local copy exists for the given library path (filename matched). public func isDownloaded(path: String) -> Bool { DownloadsIndex.shared.localPath(for: path) != nil } /// Active download progress (0...1) for this specific episode from the offline /// warmup/fetch queue (or current rsync item). nil if not actively downloading. public func downloadProgress(for episode: CachedEpisode) -> Double? { downloadProgress(path: episode.path) } public func downloadProgress(path: String) -> Double? { var prog: Double? = nil // Offline cache / rsync downloads (per-ep priority) if let off = offlineCache { let remote = MediaPaths.toRemote(path) let fname = (path as NSString).lastPathComponent.lowercased() for item in off.downloadQueue { if item.id == path || MediaPaths.toRemote(item.id) == remote || item.name.lowercased() == fname { if let p = item.progress { prog = max(prog ?? 0, p) } } } if let label = off.downloadingLabel, label.lowercased() == fname { if let p = off.downloadingProgress { prog = max(prog ?? 0, p) } } } // Torrent transfers (show or ep name match; for packs this marks the show as acquiring) if let dl = torrentDownloads { let fname = (path as NSString).lastPathComponent.lowercased() let remote = MediaPaths.toRemote(path) for t in dl.transfers where t.isDownloading && !t.isComplete { let tname = t.name.lowercased() if tname.contains(fname) || tname.contains(remote.lowercased()) { prog = max(prog ?? 0, t.progress) } } } return prog } /// Torrent-specific progress for a show (matches torrent name containing show name). /// Used to surface "acquiring via torrent" even before offline cache starts. public func torrentProgress(for show: CachedShow) -> Double? { guard let dl = torrentDownloads else { return nil } let sname = show.name.lowercased() for t in dl.transfers where t.isDownloading && !t.isComplete { if t.name.lowercased().contains(sname) { return t.progress } } return nil } /// Aggregate for a whole show: whether any episode has a local (offline cache) copy, and the /// highest active download progress among its episodes or active torrents for the show /// (for poster badges/bars on Home/Library). Torrent progress indicates the content is /// being downloaded (typically to the media server) and will become available. public func downloadState(for show: CachedShow) -> (hasLocal: Bool, progress: Double?) { let eps = show.orderedEpisodes let hasLocal = eps.contains { isDownloaded($0) } var prog: Double? = nil for ep in eps { if let p = downloadProgress(for: ep) { prog = max(prog ?? 0, p) } } if let tp = torrentProgress(for: show) { prog = max(prog ?? 0, tp) } return (hasLocal, prog) } /// Reset watch state for a show (appends reset marker; playedPaths and /// nextUnwatched will treat prior plays as forgotten so badges go back to /// unwatched and watch-next offers the first episode again). public func resetWatchState(for show: CachedShow) { watchHistory.resetWatch(show: show.name) } // 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 { LibraryConfig.type(of: $0.category) == category }.count } /// Total visible across the "All" view (respects the adult toggle). public var visibleCount: Int { showPorn ? shows.count : shows.filter { !LibraryConfig.isAdult(category: $0.category) }.count } public var filteredShows: [CachedShow] { var items = shows if !showPorn { items = items.filter { !LibraryConfig.isAdult(category: $0.category) } } if let cat = selectedCategory { items = items.filter { LibraryConfig.type(of: $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 unified watch source /// (cheap — no library scan). Called on Home appearing and after records or /// black syncs so the rail reflects live watching. public func refreshContinueWatching() { continueWatching = Self.continueRail(shows: shows, progress: watchHistory.progressPerShow()) } private var lastWatchSync = Date.distantPast /// Pull black's watchlog into the local mirror and rebuild the rail when it /// changed. TV plays are recorded on black, not plum, so without this hop the /// rail misses most real watching. Throttled (the ssh is cheap over the warm /// ControlMaster, but away from home each attempt eats the connect timeout); /// the round-trip runs off-main. Delegates to the unified controller (which /// also drives its own background poll). public func syncWatchHistory(minInterval: TimeInterval = 60) async { guard Date().timeIntervalSince(lastWatchSync) >= minInterval else { return } lastWatchSync = Date() let updated = await watchHistory.syncBlack() if updated { refreshContinueWatching() } } /// Record a play started by this app into the plum watchlog and refresh the /// rail. Resolves the episode from the library by mount-agnostic path; paths /// that aren't a library episode (or aren't a real video) are ignored. /// Delegates append + refresh to the unified watch controller. public func recordPlay(path: String, resumeSeconds: Double? = nil, finished: Bool = false) { guard watchHistory.isRealVideo(path) else { return } let remote = MediaPaths.toRemote(path) for show in shows { guard let ep = show.episodes.first(where: { MediaPaths.toRemote($0.path) == remote }) else { continue } let event = finished ? "play" : "resume" watchHistory.recordPlay(show: show.name, season: ep.season, episode: ep.episode, label: ep.label, path: path, resumeSeconds: resumeSeconds, event: event) if finished { refreshContinueWatching() } return } // Unknown to current shows (rare, e.g. just-cached not re-scanned or continue item // from watchlog before a scan): still record the marker via path parse so the // frontier advances and continue rail can resolve by SxxEyy. Matches the fallback // behavior in recordPosition. let event = finished ? "play" : "resume" WatchHistory.recordPlayForPath(path: path, resumeSeconds: resumeSeconds, durationSeconds: nil, event: event) watchHistory.refresh() if finished { refreshContinueWatching() } } /// Live position update for the currently-playing path (called from player poll /// while a show is watched). Captures resume + dur so episode bars and resume /// targets stay accurate without waiting for finish. Goes through the same /// show-resolution path as recordPlay. public func recordPosition(path: String, resumeSeconds: Double, durationSeconds: Double? = nil) { guard watchHistory.isRealVideo(path) else { return } let remote = MediaPaths.toRemote(path) for show in shows { guard let ep = show.episodes.first(where: { MediaPaths.toRemote($0.path) == remote }) else { continue } watchHistory.recordPlay(show: show.name, season: ep.season, episode: ep.episode, label: ep.label, path: path, resumeSeconds: resumeSeconds, durationSeconds: durationSeconds, event: "resume") return } // Unknown to current shows (rare, e.g. just-cached not re-scanned): still record // via path-only so the resume is persisted for next library load. watchHistory.recordPosition(path: path, resumeSeconds: resumeSeconds, durationSeconds: durationSeconds) } /// Dynamically combine split/duplicate entries of one show (the Dandadan case): /// cheap-cluster, resolve each ambiguous cluster via the local LLM (cached on /// disk → once per cluster), merge the same-work entries. Runs off-main and /// updates `shows` when done; the persisted snapshot stays raw (re-combined, /// cheaply, on the next load). Gated by `combineSplitShows` (default on). public func combineSplitShows() async { guard SettingsStore.load().combineSplitShows else { return } let current = shows guard current.count > 1 else { return } let useLLM = SettingsStore.load().useLLMGrouper let combined = await Task.detached(priority: .utility) { () -> [CachedShow] in if useLLM { // Optional: the local MLX model for the ambiguous tail (cached on disk). let decider = CachedGroupDecider(grouper: LocalLLMGrouper()) let result = ShowGrouping.combine(current) { decider.decide($0) } decider.persistIfDirty() return result } // Default: deterministic, zero-MM, instant — ships with the project. let g = DeterministicGrouper() return ShowGrouping.combine(current) { g.resolve(cluster: $0) } }.value guard combined != current else { return } shows = combined refreshContinueWatching() Log.info("combined split shows: \(current.count) → \(combined.count) entries") } /// Build the Continue Watching rail (pure + tested): ONE entry per show with /// watch history, each pointing at the **next** episode to watch — resolved /// against the library's episode order, so it crosses season boundaries and /// puts specials/movies last (the watchlog's naive "+1" can't). Fully-watched /// shows drop off. Replaces the old per-episode-path rail that showed every /// watched episode as its own row and never advanced past finished ones. static func continueRail(shows: [CachedShow], progress: [WatchHistory.ShowProgress], limit: Int = 24) -> [ContinueItem] { let byName = Dictionary(shows.map { ($0.name.lowercased(), $0) }, uniquingKeysWith: { a, _ in a }) var out: [ContinueItem] = [] var seen = Set() // resolved shows already on the rail for p in progress { // newest-first let remote = MediaPaths.toRemote(p.path) let show = byName[p.show.lowercased()] ?? shows.first { s in s.episodes.contains { MediaPaths.toRemote($0.path) == remote } } guard let show else { continue } // The watchlog can hold one show under several names (e.g. the raw // release-folder name vs the cleaned title) that all resolve to the // same merged library entry — newest progress wins, the rest drop. guard seen.insert(show.rootDir).inserted else { continue } let eps = show.orderedEpisodes // Locate the current episode (by path, else season+episode); take the one // after it. No "after" → the show is finished → drop it. guard let idx = eps.firstIndex(where: { MediaPaths.toRemote($0.path) == remote }) ?? eps.firstIndex(where: { $0.season == p.season && $0.episode == p.episode }), idx + 1 < eps.count else { continue } let next = eps[idx + 1] out.append(ContinueItem(title: "\(show.name) · \(next.label)", path: next.path, show: show.name, season: next.season, episode: next.episode, positionSeconds: nil, lastSeen: p.lastSeen, source: "watchlog", posterPath: show.posterPath)) if out.count >= limit { break } } return out } /// 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 /// Land finished-download folders in the library by scanning them directly on /// black (one SSH `find` per folder). The canonical `index.tsv` is updated in /// the background for bridge/other tools — the UI does not wait for it. public func ingestFolders(_ folders: [String]) { guard !folders.isEmpty else { return } let previous = shows Log.info("library ingest: \(folders.count) folder(s) — direct scan") Task { let scanned = await Task.detached(priority: .utility) { guard let tsv = LibraryIndex.fetchFolderLines(folders), !tsv.isEmpty else { Log.warn("ingestFolders: find returned nothing for \(folders)") return [CachedShow]() } return LibraryScanner.scanFromIndex(tsv) }.value guard !scanned.isEmpty else { return } let enriched = await Task.detached(priority: .utility) { LibraryScanner.mergeEnrichment(scanned, from: previous) }.value let merged = LibraryScanner.mergeIngest(enriched, into: shows) applyScan(merged) await combineSplitShows() await Task.detached(priority: .utility) { folders.forEach { LibraryIndex.rebuild(addDir: $0) } }.value } } /// Full black-side index rebuild ("overscan" in Setup). Completed downloads /// should use `ingestFolders` instead — they already know their `contentFolder`. public func rebuildIndex(folders: [String] = []) { if !folders.isEmpty { ingestFolders(folders); return } guard !rebuildingIndex else { return } rebuildingIndex = true Log.info("library index: full rebuild (overscan) on black") Task { let kicked = await Task.detached { LibraryIndex.rebuild() }.value guard kicked else { rebuildingIndex = false; Log.warn("index rebuild: black unreachable"); return } // 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 } } /// How long a library snapshot is considered fresh before `refreshIfStale` /// re-pulls black's index. Completed downloads land via `ingestFolders` (direct /// scan) and don't need this; the periodic re-pull only catches media added /// outside the app (rsync, manual drops). Cheap SSH `cat` + parse, not a walk. private static let staleAfter: TimeInterval = 900 /// Cheap "is a rescan warranted?" gate for Home/Library appear. Always keeps the /// Continue Watching rail and the downloaded-files index current (both cheap + /// local), and re-pulls black's index only when the snapshot has aged past /// `staleAfter`. Use this on appear instead of `refresh()`. public func refreshIfStale() async { refreshContinueWatching() await combineSplitShows() // combine cached shows on first view (cheap after the first pass) guard !scanInFlight else { return } let age = lastRefresh.map { Date().timeIntervalSince($0) } guard age == nil || age! >= Self.staleAfter else { // Snapshot still fresh — skip the library re-pull, but keep the downloads // index current so a just-cached episode plays on the local player. await Task.detached(priority: .utility) { _ = DownloadsIndex.shared.refresh() }.value Log.info("library fresh — refreshed \(Int(age ?? 0))s ago") return } Log.info("library stale (\(lastRefresh == nil ? "no prior scan" : "window elapsed")) → refresh") await refresh() // also refreshes the downloads index } /// Refresh from the best available source: black's SSH-fetched index when /// reachable (persisted to the snapshot), else a local walk of the configured /// `MEDIA_ROOTS`, 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() let previous = shows Log.info("library scan: starting") scanProgress = 0 let scan = Task.detached(priority: .utility) { [weak self] () -> [CachedShow] in // Refresh the downloaded-files index first, so playback routing knows // which items have a local copy (→ local player) vs not (→ black). let dl = DownloadsIndex.shared.refresh() if dl > 0 { Log.info("downloads index: \(dl) local files") } // Primary, NFS-free 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 structured local walk (`MEDIA_ROOTS`, if configured) 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 local MEDIA_ROOTS walk") return LibraryScanner.scan(onProgress: { dirs in Task { @MainActor in self?.scanProgress = dirs } }) } // A large local walk can be slow / stall. 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() // Merge off-main: it may read one cached `.meta` JSON per show. let merged = scanned.isEmpty ? scanned : await Task.detached(priority: .utility) { LibraryScanner.mergeEnrichment(scanned, from: previous) }.value applyScan(merged) refreshing = false scanInFlight = false await combineSplitShows() // re-combine after a fresh scan (cached decisions reused) } /// Fold a completed scan (already enrichment-merged) 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]) { if !scanned.isEmpty { shows = scanned 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 (black index 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 blacktv or mpv-ipc) resolve by name + resume; /// VLC needs a file path. public func launchRequest(show: CachedShow, episode: CachedEpisode?, targetKind: HostKind) -> 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) } // Everything plays by the EXACT path — never re-resolve a show by name on the // host (that missed merged multi-folder shows, e.g. Daria S3 in its own // "Season 3" folder). A specific episode → its file; a series with no episode // → the show-level resume target (in-progress, else next-unwatched). let path: String? if let episode { path = episode.path } else { path = resumeTarget(for: show)?.episode.path ?? show.orderedEpisodes.first?.path } return path.map { .file(path: $0) } } /// Where to resume a series: the furthest-progressed episode that has a saved /// position (resume it there), else the next unwatched episode (from its start), /// else the first. Path-based + watch-state-driven, so it's correct for shows /// merged from separate per-season folders. nil only for an empty show. public func resumeTarget(for show: CachedShow) -> (episode: CachedEpisode, position: Double?)? { Self.resumeTarget(for: show, positions: watchHistory.resumePositions, played: watchHistory.playedPaths) } /// Seconds into an episode below which a saved position is treated as a brief /// fly-by, not a real watch. VLC records a resume position the moment you open a /// file, so a stray scrub leaves a tiny position behind; we can't read durations /// here, so this floor separates "genuinely mid-watch" from "barely touched". static let resumeMidEpisodeFloor: Double = 120 /// Pure resume-target resolver (unit-tested). Finds the furthest episode carrying /// a saved position, then **advances past it** to the next episode from its start — /// because a touched episode is behind your frontier (the Daria case: a stray 82s /// VLC scrub on S2E6 must not pin Resume to S2 when S3E1 is what's next). It resumes /// that episode in place only when you're genuinely mid-watch (position ≥ /// `resumeMidEpisodeFloor` and not already marked watched), or when it's the last /// episode with nowhere to advance. No saved position anywhere → next unwatched. static func resumeTarget(for show: CachedShow, positions: [String: Double], played: Set) -> (episode: CachedEpisode, position: Double?)? { let eps = show.orderedEpisodes guard !eps.isEmpty else { return nil } if let furthest = eps.indices.last(where: { (positions[MediaPaths.toRemote(eps[$0].path)] ?? 0) > 1 }) { let ep = eps[furthest] let pos = positions[MediaPaths.toRemote(ep.path)] ?? 0 // Genuinely mid-watch → resume here; otherwise step forward to the next. if pos >= Self.resumeMidEpisodeFloor, !played.contains(MediaPaths.toRemote(ep.path)) { return (ep, pos) } if furthest + 1 < eps.count { return (eps[furthest + 1], nil) } return (ep, pos) // last episode — nowhere to advance, resume in place } return (show.nextUnwatched(watchedPaths: played) ?? eps[0], nil) } public func launchRequest(continue item: ContinueItem, targetKind: HostKind) -> LaunchRequest? { // Always play the continue item's exact file — it carries the path, so no // black-side name re-resolution (which misses merged multi-folder shows). .file(path: item.path) } }