diff --git a/Sources/PlumTV/LibraryView.swift b/Sources/PlumTV/LibraryView.swift index 8703eef..91d5a90 100644 --- a/Sources/PlumTV/LibraryView.swift +++ b/Sources/PlumTV/LibraryView.swift @@ -143,9 +143,14 @@ private struct ShowPoster: View { VStack(alignment: .leading, spacing: 6) { ZStack { RoundedRectangle(cornerRadius: 10).fill(.quaternary) - if let path = show.posterPath, let img = NSImage(contentsOfFile: path) { - Image(nsImage: img).resizable().aspectRatio(contentMode: .fill) - .clipShape(RoundedRectangle(cornerRadius: 10)) + if let poster = show.posterPath, let url = posterURL(poster) { + AsyncImage(url: url) { img in + img.resizable().aspectRatio(contentMode: .fill) + } placeholder: { + Text(initials(show.name)).font(.system(size: 34, weight: .bold)) + .foregroundStyle(.secondary) + } + .clipShape(RoundedRectangle(cornerRadius: 10)) } else { Text(initials(show.name)).font(.system(size: 34, weight: .bold)) .foregroundStyle(.secondary) @@ -163,6 +168,13 @@ private struct ShowPoster: View { let words = name.split(separator: " ").prefix(2) return words.compactMap { $0.first.map(String.init) }.joined().uppercased() } + + /// posterPath is a remote TMDB URL once enriched; treat http(s) as a URL, + /// otherwise a local file path. + private func posterURL(_ poster: String) -> URL? { + if poster.hasPrefix("http") { return URL(string: poster) } + return URL(fileURLWithPath: poster) + } } private struct ContinueCard: View { diff --git a/Sources/PlumTV/MetadataView.swift b/Sources/PlumTV/MetadataView.swift new file mode 100644 index 0000000..f871432 --- /dev/null +++ b/Sources/PlumTV/MetadataView.swift @@ -0,0 +1,101 @@ +import SwiftUI +import PlumTVCore + +/// Metadata pipeline: shows each library title with its regex-parsed format +/// badge (instant, offline) and an Enrich action that resolves TMDB/IMDb, +/// writes the `.meta` cache, and pushes poster/overview into the Library grid. +struct MetadataView: View { + @Bindable var metadata: MetadataController + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + header + Divider() + List(metadata.rows) { row in + MetaRowView(row: row) { Task { await metadata.enrich(row.id) } } + } + } + .onAppear { metadata.reload() } + } + + private var header: some View { + HStack(spacing: 12) { + Text("Metadata").font(.title2).bold() + Spacer() + if let msg = metadata.lastMessage { + Text(msg).font(.caption).foregroundStyle(.secondary).lineLimit(1) + } + if metadata.busy { ProgressView().controlSize(.small) } + Button { Task { await metadata.enrichAll() } } label: { + Label("Enrich all", systemImage: "wand.and.stars") + } + .disabled(metadata.busy || metadata.rows.isEmpty) + } + .padding(16) + } +} + +private struct MetaRowView: View { + let row: MetadataController.Row + let onEnrich: () -> Void + + var body: some View { + HStack(alignment: .top, spacing: 12) { + PosterThumb(urlString: row.meta?.posterURL).frame(width: 46, height: 69) + VStack(alignment: .leading, spacing: 4) { + Text(row.meta?.resolvedTitle ?? row.show.name).font(.headline).lineLimit(1) + badges + if let overview = row.meta?.overview, !overview.isEmpty { + Text(overview).font(.caption).foregroundStyle(.secondary).lineLimit(2) + } + } + Spacer() + if row.enriching { + ProgressView().controlSize(.small) + } else { + Button(row.meta == nil ? "Enrich" : "Re-enrich", action: onEnrich) + .buttonStyle(.bordered) + } + } + .padding(.vertical, 4) + } + + private var badges: some View { + HStack(spacing: 6) { + if let s = row.sample { + if let q = s.quality { tag(q.uppercased(), .blue) } + if let c = s.codec { tag(c.uppercased(), .purple) } + } + Text("\(row.show.episodes.count) eps").font(.caption2).foregroundStyle(.secondary) + if let r = row.meta?.imdbRating { tag(String(format: "IMDb %.1f", r), .yellow) } + if let genres = row.meta?.genres, let g = genres.first { tag(g, .gray) } + } + } + + private func tag(_ text: String, _ color: Color) -> some View { + Text(text) + .font(.caption2.monospacedDigit()) + .padding(.horizontal, 6).padding(.vertical, 2) + .background(color.opacity(0.2), in: Capsule()) + .foregroundStyle(color) + } +} + +/// Small poster that fetches a remote TMDB URL (or shows a placeholder). +private struct PosterThumb: View { + let urlString: String? + + var body: some View { + ZStack { + RoundedRectangle(cornerRadius: 6).fill(.quaternary) + if let s = urlString, let url = URL(string: s) { + AsyncImage(url: url) { img in + img.resizable().aspectRatio(contentMode: .fill) + } placeholder: { ProgressView().controlSize(.small) } + .clipShape(RoundedRectangle(cornerRadius: 6)) + } else { + Image(systemName: "film").foregroundStyle(.secondary) + } + } + } +} diff --git a/Sources/PlumTV/RootView.swift b/Sources/PlumTV/RootView.swift index 63c8c73..337b895 100644 --- a/Sources/PlumTV/RootView.swift +++ b/Sources/PlumTV/RootView.swift @@ -6,6 +6,7 @@ struct RootView: View { case player = "Player" case library = "Library" case downloads = "Downloads" + case metadata = "Metadata" case hosts = "Hosts" var id: String { rawValue } var icon: String { @@ -13,16 +14,24 @@ struct RootView: View { case .player: return "play.rectangle.fill" case .library: return "square.grid.2x2.fill" case .downloads: return "arrow.down.circle.fill" + case .metadata: return "wand.and.stars" case .hosts: return "server.rack" } } } @State private var controller = PlayerController() - @State private var library = LibraryController() + @State private var library: LibraryController @State private var downloads = DownloadsController() + @State private var metadata: MetadataController @State private var selection: Section = .player + init() { + let lib = LibraryController() + _library = State(initialValue: lib) + _metadata = State(initialValue: MetadataController(library: lib)) + } + var body: some View { NavigationSplitView { List(Section.allCases, selection: $selection) { section in @@ -38,6 +47,8 @@ struct RootView: View { LibraryView(library: library, player: controller) case .downloads: DownloadsView(downloads: downloads) + case .metadata: + MetadataView(metadata: metadata) case .hosts: HostsView(controller: controller) } diff --git a/Sources/PlumTVCore/Library/LibraryController.swift b/Sources/PlumTVCore/Library/LibraryController.swift index aab844b..65d740c 100644 --- a/Sources/PlumTVCore/Library/LibraryController.swift +++ b/Sources/PlumTVCore/Library/LibraryController.swift @@ -59,6 +59,16 @@ public final class LibraryController { } } + /// Fold resolved poster/overview onto a show (by rootDir) and persist, so the + /// grid shows artwork. Called by the metadata pipeline after enrichment. + public func applyEnrichment(rootDir: String, posterURL: String?, overview: String?) { + guard let i = shows.firstIndex(where: { $0.rootDir == rootDir }) else { return } + if let posterURL { shows[i].posterPath = posterURL } + if let overview, !overview.isEmpty { shows[i].overview = overview } + LibraryStore.save(LibrarySnapshot(shows: shows, capturedAt: lastRefresh ?? Date(), + source: source.isEmpty ? "scan" : source)) + } + /// Build the launch request for a show/episode given the active target's kind. /// black is library-aware (resolve by name + resume); VLC needs a file path. public func launchRequest(show: CachedShow, episode: CachedEpisode?, targetKind: HostKind) -> LaunchRequest? { diff --git a/Sources/PlumTVCore/Metadata/EnrichService.swift b/Sources/PlumTVCore/Metadata/EnrichService.swift new file mode 100644 index 0000000..8c6bc14 --- /dev/null +++ b/Sources/PlumTVCore/Metadata/EnrichService.swift @@ -0,0 +1,43 @@ +import Foundation + +public enum EnrichError: Error, LocalizedError { + case failed(String) + public var errorDescription: String? { if case let .failed(m) = self { return m }; return nil } +} + +/// Resolves a title against TMDB + the local IMDb index by shelling into +/// media-recommender's `enrich` module (single source of truth for the +/// TMDB/IMDb pipeline). Runs under a login shell so `uv` is on PATH. The CLI +/// degrades gracefully, so a result with only IMDb fields is normal and useful. +public final class EnrichService: Sendable { + private let projectDir: String + + public init(projectDir: String? = nil) { + self.projectDir = projectDir ?? FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Code/@applications/media-recommender").path + } + + public func enrich(title: String, year: Int?) async throws -> EnrichResult { + var inner = ["uv", "run", "python", "-m", "media_rec.enrich", title] + if let year { inner.append(String(year)) } + let command = "cd \(shq(projectDir)) && " + inner.map(shq).joined(separator: " ") + let dir = projectDir + let r: ProcessResult = await Task.detached(priority: .utility) { + ProcessRunner.runShell(command, timeout: 60, cwd: dir) + }.value + guard r.ok else { + let detail = r.stderr.split(separator: "\n").last.map(String.init) ?? r.stderr + throw EnrichError.failed(detail.isEmpty ? "enrich exited \(r.status)" : detail) + } + guard let data = r.stdout.trimmingCharacters(in: .whitespacesAndNewlines).data(using: .utf8), + !data.isEmpty else { + throw EnrichError.failed("enrich produced no output") + } + do { return try JSONDecoder().decode(EnrichResult.self, from: data) } + catch { throw EnrichError.failed("decode failed: \(error.localizedDescription)") } + } + + private func shq(_ s: String) -> String { + "'" + s.replacingOccurrences(of: "'", with: "'\\''") + "'" + } +} diff --git a/Sources/PlumTVCore/Metadata/FilenameParser.swift b/Sources/PlumTVCore/Metadata/FilenameParser.swift new file mode 100644 index 0000000..06cc650 --- /dev/null +++ b/Sources/PlumTVCore/Metadata/FilenameParser.swift @@ -0,0 +1,89 @@ +import Foundation + +/// Regex-first filename → structured fields. This is the deterministic core of +/// the metadata pipeline (handles the overwhelming majority of releases); an +/// MLX `TitleRefiner` is consulted only for the messy tail (see `refiner`). +public enum FilenameParser { + + /// Optional model-backed refiner for titles regex can't cleanly extract. + /// nil by default — the regex path stands alone. MLX plugs in here. + public static var refiner: (any TitleRefiner)? + + public static func parse(path: String) -> ParsedFilename { + let base = (((path as NSString).lastPathComponent) as NSString).deletingPathExtension + return parse(filename: base) + } + + public static func parse(filename: String) -> ParsedFilename { + let s = filename + let se = LibraryScanner.parseSxxEyy(s) + // Episode releases give S+E; season packs give only "S01" / "Season 6". + let season = se?.0 + ?? captureInt(s, "\\bS(\\d{1,2})(?![0-9])") + ?? captureInt(s, "\\bSeason\\s*(\\d+)\\b") + let episode = se?.1 + let year = firstInt(s, "\\b(19|20)\\d{2}\\b") + let quality = firstGroup(s, "\\b(2160p|1080p|720p|480p)\\b") + let codec = firstGroup(s, "\\b(x ?265|x ?264|h\\.?265|h\\.?264|hevc|xvid|divx|av1)\\b") + let releaseSource = firstGroup(s, "\\b(blu-?ray|web-?dl|web-?rip|hdtv|dvdrip|brrip|bdrip|remux|hdrip)\\b") + + var title = extractTitle(s) + if title.count < 2, let r = refiner, let refined = r.refineTitle(from: s), !refined.isEmpty { + title = refined + } + + return ParsedFilename(title: title, year: year, season: season, episode: episode, + quality: quality.map(normalizeQuality), codec: codec, + releaseSource: releaseSource) + } + + /// Title = the text before the earliest "noise" marker (SxxEyy, year, + /// quality, "Season N"), with separators tidied. Mirrors the library + /// normalization but keeps the year out of the title. + private static func extractTitle(_ s: String) -> String { + let markers = [ + "S\\d{1,2}E\\d{1,3}", + "\\bS\\d{1,2}\\b", + "\\bSeason\\s*\\d+\\b", + "\\b(19|20)\\d{2}\\b", + "\\b(2160p|1080p|720p|480p)\\b", + ] + var cut = s.count + for m in markers { + if let r = s.range(of: m, options: [.regularExpression, .caseInsensitive]) { + cut = min(cut, s.distance(from: s.startIndex, to: r.lowerBound)) + } + } + let head = String(s.prefix(cut)) + var title = head.replacingOccurrences(of: "[._-]+", with: " ", options: .regularExpression) + title = title.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression) + title = title.trimmingCharacters(in: .whitespaces) + return title.isEmpty ? s : title + } + + private static func normalizeQuality(_ q: String) -> String { q.lowercased() } + + private static func firstGroup(_ s: String, _ pattern: String) -> String? { + guard let r = s.range(of: pattern, options: [.regularExpression, .caseInsensitive]) else { return nil } + return String(s[r]) + } + + private static func firstInt(_ s: String, _ pattern: String) -> Int? { + firstGroup(s, pattern).flatMap { Int($0) } + } + + /// Capture group 1 of the first match, as Int (e.g. "S01" → 1). + private static func captureInt(_ s: String, _ pattern: String) -> Int? { + guard let re = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else { return nil } + let range = NSRange(s.startIndex..., in: s) + guard let m = re.firstMatch(in: s, range: range), + let r = Range(m.range(at: 1), in: s) else { return nil } + return Int(s[r]) + } +} + +/// Seam for a model-backed title cleaner (MLX). Implementations refine a title +/// from a messy filename when the regex path produced nothing useful. +public protocol TitleRefiner: Sendable { + func refineTitle(from filename: String) -> String? +} diff --git a/Sources/PlumTVCore/Metadata/MetaModels.swift b/Sources/PlumTVCore/Metadata/MetaModels.swift new file mode 100644 index 0000000..9be549f --- /dev/null +++ b/Sources/PlumTVCore/Metadata/MetaModels.swift @@ -0,0 +1,71 @@ +import Foundation + +/// Deterministic fields pulled from a filename by regex (no network). +public struct ParsedFilename: Codable, Sendable, Equatable { + public var title: String + public var year: Int? + public var season: Int? + public var episode: Int? + public var quality: String? // 2160p / 1080p / 720p / 480p + public var codec: String? // x265 / x264 / HEVC / XviD + public var releaseSource: String? // BluRay / WEB-DL / HDTV / … + + public init(title: String, year: Int? = nil, season: Int? = nil, episode: Int? = nil, + quality: String? = nil, codec: String? = nil, releaseSource: String? = nil) { + self.title = title; self.year = year; self.season = season; self.episode = episode + self.quality = quality; self.codec = codec; self.releaseSource = releaseSource + } +} + +/// What `media_rec.enrich` returns (snake_case JSON). All optional — the CLI +/// degrades to a partial result when TMDB/IMDb aren't configured. +public struct EnrichResult: Decodable, Sendable, Equatable { + public var tmdb_id: Int? + public var media_type: String? + public var title: String? + public var year: Int? + public var overview: String? + public var poster_url: String? + public var vote_average: Double? + public var vote_count: Int? + public var imdb_rating: Double? + public var imdb_votes: Int? + public var genres: [String]? + public var tmdb_error: String? + public var imdb_error: String? +} + +/// The `.meta` sidecar payload: the parse plus whatever enrichment resolved. +/// Cached per-path on plum, mirrored next to the file on black opportunistically. +public struct MediaMeta: Codable, Sendable, Equatable { + public var path: String + public var parsed: ParsedFilename + public var resolvedTitle: String? + public var mediaType: String? + public var overview: String? + public var posterURL: String? + public var voteAverage: Double? + public var voteCount: Int? + public var imdbRating: Double? + public var imdbVotes: Int? + public var genres: [String]? + public var enrichedAt: Date? + + public init(path: String, parsed: ParsedFilename) { + self.path = path; self.parsed = parsed + } + + /// Fold a (possibly partial) enrich result into this meta. + public mutating func apply(_ e: EnrichResult, at now: Date) { + resolvedTitle = e.title ?? resolvedTitle + mediaType = e.media_type ?? mediaType + overview = e.overview ?? overview + posterURL = e.poster_url ?? posterURL + voteAverage = e.vote_average ?? voteAverage + voteCount = e.vote_count ?? voteCount + imdbRating = e.imdb_rating ?? imdbRating + imdbVotes = e.imdb_votes ?? imdbVotes + genres = e.genres ?? genres + enrichedAt = now + } +} diff --git a/Sources/PlumTVCore/Metadata/MetaWriter.swift b/Sources/PlumTVCore/Metadata/MetaWriter.swift new file mode 100644 index 0000000..9803f2a --- /dev/null +++ b/Sources/PlumTVCore/Metadata/MetaWriter.swift @@ -0,0 +1,59 @@ +import Foundation +import CryptoKit + +/// Persists `.meta` payloads. The plum-local cache (keyed by file path) is the +/// always-on source of truth; mirroring `filename.ext.meta` next to the file on +/// black is opportunistic and guarded — it never blocks the UI and tolerates an +/// unreachable host (black is non-ECC ZFS; the cache is what we rely on). +public enum MetaWriter { + public static func metaDir() -> URL { + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".local/state/plum-tv/meta") + } + + public static func cacheURL(forPath path: String) -> URL { + let digest = SHA256.hash(data: Data(path.utf8)) + let hex = digest.map { String(format: "%02x", $0) }.joined() + return metaDir().appendingPathComponent("\(hex).json") + } + + @discardableResult + public static func writeCache(_ meta: MediaMeta) -> Bool { + let url = cacheURL(forPath: meta.path) + try? FileManager.default.createDirectory(at: metaDir(), withIntermediateDirectories: true) + let enc = JSONEncoder() + enc.outputFormatting = [.prettyPrinted, .withoutEscapingSlashes] + enc.dateEncodingStrategy = .iso8601 + guard let data = try? enc.encode(meta) else { return false } + return (try? data.write(to: url, options: .atomic)) != nil + } + + public static func loadCache(forPath path: String) -> MediaMeta? { + guard let data = try? Data(contentsOf: cacheURL(forPath: path)) else { return nil } + let dec = JSONDecoder() + dec.dateDecodingStrategy = .iso8601 + return try? dec.decode(MediaMeta.self, from: data) + } + + /// Best-effort: write `.meta` on black via ssh (base64 to dodge + /// quoting/stdin issues). Returns false on any failure — callers ignore it. + /// `remotePath` must be a black-side path (e.g. under /bigdisk/_/media). + @discardableResult + public static func mirrorToBlack(_ meta: MediaMeta, remotePath: String, endpoint: String) -> Bool { + let enc = JSONEncoder() + enc.outputFormatting = [.withoutEscapingSlashes] + enc.dateEncodingStrategy = .iso8601 + guard let data = try? enc.encode(meta) else { return false } + let b64 = data.base64EncodedString() + let remote = remotePath + ".meta" + // printf the base64 locally, pipe over ssh, decode into the sidecar. + let command = "printf %s \(shq(b64)) | /usr/bin/ssh -o BatchMode=yes -o ConnectTimeout=5 " + + "\(shq(endpoint)) \(shq("base64 -d > " + shq(remote)))" + let r = ProcessRunner.runShell(command, timeout: 20) + return r.ok + } + + private static func shq(_ s: String) -> String { + "'" + s.replacingOccurrences(of: "'", with: "'\\''") + "'" + } +} diff --git a/Sources/PlumTVCore/Metadata/MetadataController.swift b/Sources/PlumTVCore/Metadata/MetadataController.swift new file mode 100644 index 0000000..35ae69c --- /dev/null +++ b/Sources/PlumTVCore/Metadata/MetadataController.swift @@ -0,0 +1,95 @@ +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 + + public init(library: LibraryController, enricher: EnrichService = EnrichService()) { + self.library = library + self.enricher = enricher + } + + /// 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: " · ")) + } +} diff --git a/Tests/PlumTVCoreTests/FilenameParserTests.swift b/Tests/PlumTVCoreTests/FilenameParserTests.swift new file mode 100644 index 0000000..850513b --- /dev/null +++ b/Tests/PlumTVCoreTests/FilenameParserTests.swift @@ -0,0 +1,67 @@ +import XCTest +@testable import PlumTVCore + +/// The deterministic metadata core — filename → structured fields. Cases are +/// real-shaped release names (incl. the messy registry style with " — " notes). +final class FilenameParserTests: XCTestCase { + func testEpisodeRelease() { + let p = FilenameParser.parse(filename: "Psych.S01E04.720p.WEB-DL.x264-GROUP") + XCTAssertEqual(p.title, "Psych") + XCTAssertEqual(p.season, 1) + XCTAssertEqual(p.episode, 4) + XCTAssertEqual(p.quality, "720p") + XCTAssertEqual(p.codec?.lowercased(), "x264") + XCTAssertEqual(p.releaseSource?.lowercased(), "web-dl") + } + + func testYearKeptOutOfTitle() { + let p = FilenameParser.parse(filename: "Psych 2006 Season 6 Complete 720p AMZN WEB-DL x264") + XCTAssertEqual(p.title, "Psych") + XCTAssertEqual(p.year, 2006) + XCTAssertEqual(p.quality, "720p") + } + + func testDottedNameAndHevc() { + let p = FilenameParser.parse(filename: "Twin.Peaks.S01.COMPLETE.1080p.BluRay.x265.HEVC-PSA") + XCTAssertEqual(p.title, "Twin Peaks") + XCTAssertEqual(p.season, 1) + XCTAssertEqual(p.quality, "1080p") + XCTAssertEqual(p.codec?.lowercased(), "x265") + } + + func testParseFromPathStripsDirAndExt() { + let p = FilenameParser.parse(path: "/m/cartoons/Futurama/Futurama S03E01 The Honking.mp4") + XCTAssertEqual(p.title, "Futurama") + XCTAssertEqual(p.season, 3) + XCTAssertEqual(p.episode, 1) + } + + func testMovieWithYearNoEpisode() { + let p = FilenameParser.parse(filename: "The.Mists.Of.Avalon.2001.DVDRip.XviD-DiVAS") + XCTAssertEqual(p.title, "The Mists Of Avalon") + XCTAssertEqual(p.year, 2001) + XCTAssertNil(p.season) + XCTAssertNil(p.episode) + XCTAssertEqual(p.codec?.lowercased(), "xvid") + } + + func testEnrichResultPartialDecode() throws { + // The real graceful-degrade shape (TMDB key unset, IMDb present). + let json = #""" + {"query":"Psych","year":2006,"tmdb_error":"TMDB_API_KEY unset", + "imdb_rating":8.4,"imdb_votes":120400,"genres":["Comedy","Crime","Mystery"]} + """# + let r = try JSONDecoder().decode(EnrichResult.self, from: Data(json.utf8)) + XCTAssertEqual(r.imdb_rating, 8.4) + XCTAssertEqual(r.imdb_votes, 120400) + XCTAssertEqual(r.genres, ["Comedy", "Crime", "Mystery"]) + XCTAssertNil(r.poster_url) + XCTAssertEqual(r.tmdb_error, "TMDB_API_KEY unset") + + var meta = MediaMeta(path: "/m/Psych", parsed: ParsedFilename(title: "Psych", year: 2006)) + meta.apply(r, at: Date(timeIntervalSince1970: 0)) + XCTAssertEqual(meta.imdbRating, 8.4) + XCTAssertNil(meta.posterURL) + XCTAssertNotNil(meta.enrichedAt) + } +} diff --git a/project.yml b/project.yml index 0324a20..5b5d9f8 100644 --- a/project.yml +++ b/project.yml @@ -11,11 +11,20 @@ settings: CODE_SIGNING_ALLOWED: "NO" # local dev, unsigned CODE_SIGNING_REQUIRED: "NO" ENABLE_HARDENED_RUNTIME: "NO" # needs Process/ssh + localhost+overlay network, no sandbox +packages: + mlx-swift-examples: + url: https://github.com/ml-explore/mlx-swift-examples.git + branch: main targets: PlumTVCore: type: framework platform: macOS sources: [Sources/PlumTVCore] + dependencies: + - package: mlx-swift-examples + product: MLXLLM + - package: mlx-swift-examples + product: MLXLMCommon settings: base: PRODUCT_BUNDLE_IDENTIFIER: local.lilith.PlumTVCore