558 lines
29 KiB
Swift
558 lines
29 KiB
Swift
import Foundation
|
|
import Observation
|
|
|
|
/// Owns the library snapshot for the UI: loads the cached snapshot instantly,
|
|
/// refreshes from a live scan in the background, persists the result, and builds
|
|
/// the continue-watching rail. Mirrors PlayerController's @Observable/@MainActor
|
|
/// shape. Playback launch is delegated to PlayerController (it owns the targets).
|
|
@Observable
|
|
@MainActor
|
|
public final class LibraryController {
|
|
public private(set) var shows: [CachedShow] = []
|
|
public private(set) var continueWatching: [ContinueItem] = []
|
|
public private(set) var source: String = ""
|
|
public private(set) var lastRefresh: Date?
|
|
/// Drives the Refresh spinner / disabled state. Bounded (see `refresh()`) so a
|
|
/// slow or stalled 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()
|
|
|
|
/// 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 } }
|
|
}
|
|
|
|
// 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() { 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 }
|
|
}
|
|
|
|
/// 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.
|
|
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 { 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 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 = Self.continueRail(shows: shows, progress: WatchHistory.progressPerShow())
|
|
}
|
|
|
|
/// 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] = []
|
|
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 }
|
|
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
|
|
|
|
/// 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
|
|
}
|
|
}
|
|
|
|
/// How long a library snapshot is considered fresh before `refreshIfStale`
|
|
/// re-pulls black's index. With no NFS mount there's no folder-mtime signal
|
|
/// anymore, so we fall back to a time window (the re-pull is a cheap SSH `cat` +
|
|
/// parse, not the old minutes-long walk).
|
|
private static let staleAfter: TimeInterval = 120
|
|
|
|
/// 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)
|
|
}
|
|
}
|