tv-anarchy/Sources/TVAnarchyCore/Library/TitleLibrary.swift
Natalie 4a2ceb9781 feat(offline): inline star-to-keep and trash-to-cull on cache rows
Surface the existing pin (keep-from-cull) and per-file delete actions as
visible inline buttons on each offline cache row instead of context-menu-only:
a star toggles protection from auto-cull (and restore-if-missing), a trash
culls that file early. Aligns wording/icons to the star metaphor.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 00:12:41 -04:00

253 lines
No EOL
9.5 KiB
Swift

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)
}
}