import Foundation /// How we learned an episode title — governs merge precedence when observations /// conflict for the same `contentKey`. public enum TitleProvenance: String, Codable, Sendable, CaseIterable { case manual case tvmaze case tmdb case anilist case net case mlx case regex } /// One merged row in the Title Library — our persistent episode-title dataset. public struct TitleRecord: Codable, Sendable, Equatable { public var contentKey: String public var showName: String public var season: Int public var episode: Int public var episodeTitle: String public var displayName: String public var provenance: TitleProvenance public var confidence: Double public var sources: [String] public var updatedAt: Date public init(contentKey: String, showName: String, season: Int, episode: Int, episodeTitle: String, displayName: String, provenance: TitleProvenance, confidence: Double = 1, sources: [String] = [], updatedAt: Date = Date()) { self.contentKey = contentKey self.showName = showName self.season = season self.episode = episode self.episodeTitle = episodeTitle self.displayName = displayName self.provenance = provenance self.confidence = confidence self.sources = sources self.updatedAt = updatedAt } } /// Append-only observation written to `by-key.jsonl` before merge into the row view. public struct TitleObservation: Codable, Sendable, Equatable { public var contentKey: String public var showName: String public var season: Int public var episode: Int public var episodeTitle: String public var provenance: TitleProvenance public var confidence: Double public var sources: [String] public var observedAt: Date public init(contentKey: String, showName: String, season: Int, episode: Int, episodeTitle: String, provenance: TitleProvenance, confidence: Double = 1, sources: [String] = [], observedAt: Date = Date()) { self.contentKey = contentKey self.showName = showName self.season = season self.episode = episode self.episodeTitle = episodeTitle self.provenance = provenance self.confidence = confidence self.sources = sources self.observedAt = observedAt } func asRecord() -> TitleRecord { TitleRecord(contentKey: contentKey, showName: showName, season: season, episode: episode, episodeTitle: episodeTitle, displayName: LibraryDisplayNames.format(season: season, episode: episode, title: episodeTitle), provenance: provenance, confidence: confidence, sources: sources, updatedAt: observedAt) } } /// Persistent Title Library — lookup by `contentKey`, append observations, merge rows. /// Path: `~/.local/state/tv-anarchy/titles/by-key.jsonl` (overridable via /// `TV_ANARCHY_STATE_DIR` for tests). public final class TitleLibraryStore: @unchecked Sendable { private let lock = NSLock() private var rows: [String: TitleRecord] = [:] private var loaded = false public init() {} public static func titlesDir() -> URL { let base: URL if let dir = ProcessInfo.processInfo.environment["TV_ANARCHY_STATE_DIR"], !dir.isEmpty { base = URL(fileURLWithPath: dir, isDirectory: true) } else { base = FileManager.default.homeDirectoryForCurrentUser .appendingPathComponent(".local/state/tv-anarchy", isDirectory: true) } return base.appendingPathComponent("titles", isDirectory: true) } public static func jsonlURL() -> URL { titlesDir().appendingPathComponent("by-key.jsonl") } public func lookup(_ contentKey: String) -> TitleRecord? { lock.lock(); defer { lock.unlock() } ensureLoaded() return rows[contentKey] } @discardableResult public func observe(_ observation: TitleObservation) -> TitleRecord { lock.lock() ensureLoaded() let merged = Self.merge(existing: rows[observation.contentKey], incoming: observation.asRecord()) rows[observation.contentKey] = merged lock.unlock() appendJSONL(observation) return merged } /// Store a fully-formed row (e.g. migration). Treated as a high-confidence observation. @discardableResult public func store(_ record: TitleRecord) -> TitleRecord { observe(TitleObservation(contentKey: record.contentKey, showName: record.showName, season: record.season, episode: record.episode, episodeTitle: record.episodeTitle, provenance: record.provenance, confidence: record.confidence, sources: record.sources, observedAt: record.updatedAt)) } public func allRows() -> [TitleRecord] { lock.lock(); defer { lock.unlock() } ensureLoaded() return Array(rows.values) } /// Test hook — drop in-memory state without touching disk. public func resetForTests() { lock.lock() rows = [:] loaded = false lock.unlock() } // MARK: - Merge static func mergeRank(_ p: TitleProvenance) -> Int { switch p { case .manual: return 6 case .tvmaze, .tmdb, .anilist: return 5 case .net: return 4 case .mlx: return 3 case .regex: return 2 } } static func merge(existing: TitleRecord?, incoming: TitleRecord) -> TitleRecord { guard let existing else { return incoming } let existingRank = mergeRank(existing.provenance) let incomingRank = mergeRank(incoming.provenance) if incomingRank > existingRank { return incoming } if incomingRank < existingRank { return existing } if incoming.confidence > existing.confidence { return incoming } if incoming.confidence < existing.confidence { return existing } return incoming.updatedAt >= existing.updatedAt ? incoming : existing } // MARK: - Persistence private func ensureLoaded() { guard !loaded else { return } loaded = true let url = Self.jsonlURL() do { let data = try String(contentsOf: url, encoding: .utf8) let dec = JSONDecoder() dec.dateDecodingStrategy = .iso8601 for line in data.split(separator: "\n") { guard !line.isEmpty, let row = try? dec.decode(TitleObservation.self, from: Data(line.utf8)) else { continue } rows[row.contentKey] = Self.merge(existing: rows[row.contentKey], incoming: row.asRecord()) } } catch { if FileManager.default.fileExists(atPath: url.path) { Log.error("TitleLibrary: failed to load observations from \(url.lastPathComponent): \(error.localizedDescription)") } // Missing file on first run is expected; no log. } } private func appendJSONL(_ observation: TitleObservation) { let enc = JSONEncoder() enc.dateEncodingStrategy = .iso8601 let lineData: Data do { lineData = try enc.encode(observation) } catch { Log.error("TitleLibrary: failed to encode observation \(observation.contentKey): \(error.localizedDescription)") return } guard var text = String(data: lineData, encoding: .utf8) else { Log.error("TitleLibrary: UTF-8 roundtrip failed for observation \(observation.contentKey)") return } text += "\n" let url = Self.jsonlURL() let dir = url.deletingLastPathComponent() do { try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) } catch { Log.error("TitleLibrary: failed to create titles dir \(dir.path): \(error.localizedDescription)") // Attempt write anyway (may succeed if dir pre-existed or is writable root). } if FileManager.default.fileExists(atPath: url.path) { do { let handle = try FileHandle(forWritingTo: url) defer { try? handle.close() } try handle.seekToEndOfFile() try handle.write(contentsOf: Data(text.utf8)) return } catch { Log.error("TitleLibrary: append via handle failed for \(observation.contentKey): \(error.localizedDescription); trying atomic fallback") } } do { try Data(text.utf8).write(to: url, options: .atomic) } catch { Log.error("TitleLibrary: atomic write failed for \(observation.contentKey): \(error.localizedDescription)") } } } /// Shared Title Library facade — consult before MLX or live API. public enum TitleLibrary { public static var store = TitleLibraryStore() public static func lookup(contentKey: String) -> TitleRecord? { store.lookup(contentKey) } @discardableResult public static func observe(_ observation: TitleObservation) -> TitleRecord { store.observe(observation) } public static func contentKey(showName: String, season: Int, episode: Int) -> String { ContentID.canonical(work: showName, season: season, episode: episode) } }