tv-anarchy/Sources/PlumTVCore/Metadata/MetadataController.swift
Natalie 65f3cb1e4e feat(plum-tv): add async poster loading for shows
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
2026-06-07 22:06:27 -07:00

95 lines
3.9 KiB
Swift
Raw 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
public init(library: LibraryController, enricher: EnrichService = EnrichService()) {
self.library = library
self.enricher = enricher
}
/// 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) }
let year = sampleParse?.year
do {
let result = try await enricher.enrich(title: show.name, year: year)
var meta = MediaMeta(path: show.rootDir,
parsed: sampleParse ?? ParsedFilename(title: show.name, year: year))
meta.apply(result, at: Date())
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: " · "))
}
}