95 lines
3.9 KiB
Swift
95 lines
3.9 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
|
||
|
||
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: " · "))
|
||
}
|
||
}
|