tv-anarchy/Sources/TVAnarchyCore/Metadata/MetadataController.swift
Natalie 92b38b1bae refactor(tv-anarchy): rename PlumTV→TVAnarchy and land session work
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>
2026-06-08 22:04:22 -07:00

107 lines
4.7 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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: " · "))
}
}