Renames Sources/PlumTV→TVAnarchy and PlumTVCore→TVAnarchyCore (the rename the auto-commit service couldn't stage — it git-add'd the old, now-gone paths and aborted every cycle), and commits the accumulated work: - Library: black-built index fast path (LibraryIndex + scanFromIndex) with NFS-walk fallback; incremental --add on download-complete; mtime staleness gate; loose-file series-collapse fix; determinate scan/index progress. - Cover art: keyless TVmaze cartoon-vs-live-action disambiguation (type/year). - Player: sleep timer (timed + end-of-episode); visibility-gated polling. - Home: Continue Watching cover art + live refresh; Recently Added; adult gate. - Logs: multi-line selection + copy; truncated giant tx-list errors. - Hover previews (opt-in) via black ffmpeg + scp. Also gitignores foreign project trees (governor/mcp/fleet/recommender) that sit in this directory but belong to their own repos. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
107 lines
4.7 KiB
Swift
107 lines
4.7 KiB
Swift
import Foundation
|
||
import Observation
|
||
|
||
/// Drives the Metadata tab over the library's shows: parse filenames (instant,
|
||
/// offline), enrich titles against TMDB/IMDb (subprocess), write `.meta` to the
|
||
/// plum cache, and fold poster/overview back into the library grid. Enrichment
|
||
/// is bounded-concurrency so "Enrich all" doesn't spawn N subprocesses at once.
|
||
@Observable
|
||
@MainActor
|
||
public final class MetadataController {
|
||
public struct Row: Identifiable, Sendable {
|
||
public let show: CachedShow
|
||
public var meta: MediaMeta?
|
||
public var enriching: Bool = false
|
||
public var id: String { show.rootDir }
|
||
|
||
/// First episode's parsed fields, for an at-a-glance quality/format badge.
|
||
public var sample: ParsedFilename? {
|
||
show.episodes.first.map { FilenameParser.parse(path: $0.path) }
|
||
}
|
||
}
|
||
|
||
public private(set) var rows: [Row] = []
|
||
public private(set) var busy = false
|
||
public private(set) var lastMessage: String?
|
||
|
||
private let library: LibraryController
|
||
private let enricher: EnrichService
|
||
private let artwork: ArtworkService
|
||
|
||
public init(library: LibraryController, enricher: EnrichService = EnrichService(),
|
||
artwork: ArtworkService = ArtworkService()) {
|
||
self.library = library
|
||
self.enricher = enricher
|
||
self.artwork = artwork
|
||
}
|
||
|
||
/// Rebuild rows from the library, loading any cached `.meta` per show.
|
||
public func reload() {
|
||
rows = library.shows.map { show in
|
||
Row(show: show, meta: MetaWriter.loadCache(forPath: show.rootDir))
|
||
}
|
||
}
|
||
|
||
public func enrich(_ rootDir: String) async {
|
||
guard let idx = rows.firstIndex(where: { $0.id == rootDir }) else { return }
|
||
rows[idx].enriching = true
|
||
defer { if let i = rows.firstIndex(where: { $0.id == rootDir }) { rows[i].enriching = false } }
|
||
await enrichRow(at: idx)
|
||
}
|
||
|
||
/// Enrich every show that has no cached meta yet, at most `maxConcurrent` at
|
||
/// a time. Already-enriched shows are skipped (re-run is per-row).
|
||
public func enrichAll(maxConcurrent: Int = 3) async {
|
||
guard !busy else { return }
|
||
busy = true
|
||
defer { busy = false; lastMessage = "Enriched library" }
|
||
let targets = rows.filter { $0.meta == nil }.map(\.id)
|
||
var i = 0
|
||
while i < targets.count {
|
||
let batch = targets[i..<min(i + maxConcurrent, targets.count)]
|
||
await withTaskGroup(of: Void.self) { group in
|
||
for root in batch {
|
||
group.addTask { @MainActor [weak self] in await self?.enrich(root) }
|
||
}
|
||
}
|
||
i += maxConcurrent
|
||
}
|
||
}
|
||
|
||
// MARK: - one row
|
||
|
||
private func enrichRow(at idx: Int) async {
|
||
let show = rows[idx].show
|
||
let sampleParse = show.episodes.first.map { FilenameParser.parse(path: $0.path) }
|
||
// Prefer the year the scanner parsed from the show FOLDER (e.g. "Avatar -
|
||
// The Last Airbender (2005 - 2008)") over the episode filename, which often
|
||
// carries no year — it's what disambiguates a cartoon from its live-action
|
||
// remake in the keyless provider.
|
||
let year = show.year ?? sampleParse?.year
|
||
do {
|
||
let result = try await enricher.enrich(title: show.name, year: year, category: show.category)
|
||
var meta = MediaMeta(path: show.rootDir,
|
||
parsed: sampleParse ?? ParsedFilename(title: show.name, year: year))
|
||
meta.apply(result, at: Date())
|
||
// No database poster (movies/porn/unsorted, or no key) → grab a frame
|
||
// from the file itself so the grid still has art.
|
||
if meta.posterURL == nil, let file = show.episodes.first?.path {
|
||
meta.posterURL = await artwork.frameGrab(videoPath: file)
|
||
}
|
||
MetaWriter.writeCache(meta)
|
||
if let i = rows.firstIndex(where: { $0.id == show.rootDir }) { rows[i].meta = meta }
|
||
library.applyEnrichment(rootDir: show.rootDir, posterURL: meta.posterURL, overview: meta.overview)
|
||
lastMessage = enrichSummary(show.name, result)
|
||
} catch {
|
||
lastMessage = "‘\(show.name)’ enrich failed: \((error as? LocalizedError)?.errorDescription ?? error.localizedDescription)"
|
||
}
|
||
}
|
||
|
||
private func enrichSummary(_ name: String, _ r: EnrichResult) -> String {
|
||
var bits: [String] = []
|
||
if let rating = r.imdb_rating { bits.append(String(format: "IMDb %.1f", rating)) }
|
||
if r.poster_url != nil { bits.append("poster") }
|
||
if r.tmdb_error != nil && r.poster_url == nil { bits.append("no TMDB") }
|
||
return "‘\(name)’: " + (bits.isEmpty ? "no metadata" : bits.joined(separator: " · "))
|
||
}
|
||
}
|