tv-anarchy/Sources/TVAnarchyCore/Metadata/MetadataController.swift

108 lines
4.7 KiB
Swift
Raw Permalink Normal View History

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