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