tv-anarchy/Sources/TVAnarchyCore/Library/LibraryController.swift
Natalie 4a2ceb9781 feat(offline): inline star-to-keep and trash-to-cull on cache rows
Surface the existing pin (keep-from-cull) and per-file delete actions as
visible inline buttons on each offline cache row instead of context-menu-only:
a star toggles protection from auto-cull (and restore-if-missing), a trash
culls that file early. Aligns wording/icons to the star metaphor.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 00:12:41 -04:00

897 lines
45 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 (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 foldertype
/// 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<String> { 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<String>() // 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<String>) -> (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)
}
}