feat(plum-tv): add async poster loading for shows

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-06-07 22:06:27 -07:00
parent b804d52bc5
commit 65f3cb1e4e
11 changed files with 571 additions and 4 deletions

View file

@ -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 {

View file

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

View file

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

View file

@ -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? {

View file

@ -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: "'\\''") + "'"
}
}

View file

@ -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?
}

View file

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

View file

@ -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 `<remotePath>.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: "'\\''") + "'"
}
}

View file

@ -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..<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) }
let year = sampleParse?.year
do {
let result = try await enricher.enrich(title: show.name, year: year)
var meta = MediaMeta(path: show.rootDir,
parsed: sampleParse ?? ParsedFilename(title: show.name, year: year))
meta.apply(result, at: Date())
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: " · "))
}
}

View file

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

View file

@ -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