2026-06-07 22:06:27 -07:00
|
|
|
|
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
|
2026-06-08 22:04:22 -07:00
|
|
|
|
private let artwork: ArtworkService
|
2026-06-07 22:06:27 -07:00
|
|
|
|
|
2026-06-08 22:04:22 -07:00
|
|
|
|
public init(library: LibraryController, enricher: EnrichService = EnrichService(),
|
|
|
|
|
|
artwork: ArtworkService = ArtworkService()) {
|
2026-06-07 22:06:27 -07:00
|
|
|
|
self.library = library
|
|
|
|
|
|
self.enricher = enricher
|
2026-06-08 22:04:22 -07:00
|
|
|
|
self.artwork = artwork
|
2026-06-07 22:06:27 -07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// 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) }
|
2026-06-08 22:04:22 -07:00
|
|
|
|
// 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
|
2026-06-07 22:06:27 -07:00
|
|
|
|
do {
|
2026-06-08 22:04:22 -07:00
|
|
|
|
let result = try await enricher.enrich(title: show.name, year: year, category: show.category)
|
2026-06-07 22:06:27 -07:00
|
|
|
|
var meta = MediaMeta(path: show.rootDir,
|
|
|
|
|
|
parsed: sampleParse ?? ParsedFilename(title: show.name, year: year))
|
|
|
|
|
|
meta.apply(result, at: Date())
|
2026-06-08 22:04:22 -07:00
|
|
|
|
// 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)
|
|
|
|
|
|
}
|
2026-06-07 22:06:27 -07:00
|
|
|
|
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: " · "))
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|