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>
253 lines
No EOL
9.5 KiB
Swift
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)
|
|
}
|
|
} |