Renames Sources/PlumTV→TVAnarchy and PlumTVCore→TVAnarchyCore (the rename the auto-commit service couldn't stage — it git-add'd the old, now-gone paths and aborted every cycle), and commits the accumulated work: - Library: black-built index fast path (LibraryIndex + scanFromIndex) with NFS-walk fallback; incremental --add on download-complete; mtime staleness gate; loose-file series-collapse fix; determinate scan/index progress. - Cover art: keyless TVmaze cartoon-vs-live-action disambiguation (type/year). - Player: sleep timer (timed + end-of-episode); visibility-gated polling. - Home: Continue Watching cover art + live refresh; Recently Added; adult gate. - Logs: multi-line selection + copy; truncated giant tx-list errors. - Hover previews (opt-in) via black ffmpeg + scp. Also gitignores foreign project trees (governor/mcp/fleet/recommender) that sit in this directory but belong to their own repos. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
199 lines
8.7 KiB
Swift
199 lines
8.7 KiB
Swift
import Foundation
|
|
|
|
/// What kind of backend a host speaks.
|
|
public enum HostKind: String, Codable, Sendable, CaseIterable, Identifiable {
|
|
case vlc // VLC HTTP/Lua interface
|
|
case blacktv // the legacy `black-tv` verb script over SSH (being retired)
|
|
case mpvIPC = "mpv-ipc" // generic mpv JSON IPC over SSH + delegated commands
|
|
case quicktime // local QuickTime Player driven by AppleScript (zero-install)
|
|
|
|
public var id: String { rawValue }
|
|
/// Kinds offered in the editor (blacktv is legacy/auto-migrated, hidden).
|
|
public static var editable: [HostKind] { [.mpvIPC, .vlc, .quicktime] }
|
|
public var label: String {
|
|
switch self {
|
|
case .vlc: "VLC (HTTP)"
|
|
case .blacktv: "black-tv (legacy)"
|
|
case .mpvIPC: "mpv over SSH"
|
|
case .quicktime: "QuickTime (local)"
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Connection to a generic mpv host: its JSON IPC socket reached over SSH, plus
|
|
/// how to read it (root-owned sockets need `sudo socat`). `volumeScale` is the
|
|
/// slider max (mpv's volume is already a percentage; default mirrors mpv's
|
|
/// `--volume-max` of 130).
|
|
public struct MpvConn: Codable, Sendable, Equatable {
|
|
public var endpoints: [String]
|
|
public var socket: String
|
|
public var sudo: Bool
|
|
public var socat: String
|
|
public var volumeScale: Int
|
|
|
|
public init(endpoints: [String], socket: String = "/tmp/mpv.sock",
|
|
sudo: Bool = true, socat: String = "socat", volumeScale: Int = 130) {
|
|
self.endpoints = endpoints; self.socket = socket
|
|
self.sudo = sudo; self.socat = socat; self.volumeScale = volumeScale
|
|
}
|
|
|
|
// Decode with defaults so a minimal `{ "endpoints": [...] }` is valid.
|
|
enum CodingKeys: String, CodingKey { case endpoints, socket, sudo, socat, volumeScale }
|
|
public init(from d: Decoder) throws {
|
|
let c = try d.container(keyedBy: CodingKeys.self)
|
|
endpoints = try c.decode([String].self, forKey: .endpoints)
|
|
socket = try c.decodeIfPresent(String.self, forKey: .socket) ?? "/tmp/mpv.sock"
|
|
sudo = try c.decodeIfPresent(Bool.self, forKey: .sudo) ?? true
|
|
socat = try c.decodeIfPresent(String.self, forKey: .socat) ?? "socat"
|
|
volumeScale = try c.decodeIfPresent(Int.self, forKey: .volumeScale) ?? 130
|
|
}
|
|
}
|
|
|
|
/// Per-host command templates (argv arrays) for the operations a generic mpv
|
|
/// host can't do over IPC: launch/library/stats/teardown. A nil template means
|
|
/// the host lacks that capability. Tokens: `{query}`, `{season?}`, `{episode?}`,
|
|
/// `{path}`, `{releaseId}` (see CommandTemplate).
|
|
public struct CommandsConfig: Codable, Sendable, Equatable {
|
|
public var launchShow: [String]?
|
|
public var launchResume: [String]?
|
|
public var launchFile: [String]?
|
|
public var releases: [String]?
|
|
public var resolveRelease: [String]?
|
|
public var stats: [String]?
|
|
public var stop: [String]?
|
|
|
|
public init(launchShow: [String]? = nil, launchResume: [String]? = nil,
|
|
launchFile: [String]? = nil, releases: [String]? = nil,
|
|
resolveRelease: [String]? = nil, stats: [String]? = nil, stop: [String]? = nil) {
|
|
self.launchShow = launchShow; self.launchResume = launchResume
|
|
self.launchFile = launchFile; self.releases = releases
|
|
self.resolveRelease = resolveRelease; self.stats = stats; self.stop = stop
|
|
}
|
|
|
|
/// The delegated commands for a `black-tv` helper at `bin` — the seed default
|
|
/// and the legacy-config migration target.
|
|
public static func blackTVDefaults(bin: String) -> CommandsConfig {
|
|
CommandsConfig(
|
|
launchShow: [bin, "play-show", "{query}", "{season?}", "{episode?}"],
|
|
launchResume: [bin, "resume-show", "{query}"],
|
|
launchFile: [bin, "play", "{path}"],
|
|
releases: [bin, "releases"],
|
|
resolveRelease: [bin, "resolve-release", "{releaseId}"],
|
|
stats: [bin, "stats"],
|
|
stop: [bin, "stop"])
|
|
}
|
|
}
|
|
|
|
public struct VLCConn: Codable, Sendable, Equatable {
|
|
public var host: String
|
|
public var port: Int
|
|
public init(host: String, port: Int) { self.host = host; self.port = port }
|
|
}
|
|
|
|
public struct SSHConn: Codable, Sendable, Equatable {
|
|
/// Ordered endpoints to try (e.g. LAN first, overlay fallback). The working
|
|
/// one is pinned at runtime; we only re-probe the others on failure.
|
|
public var endpoints: [String]
|
|
public var bin: String
|
|
public init(endpoints: [String], bin: String) { self.endpoints = endpoints; self.bin = bin }
|
|
}
|
|
|
|
/// One configurable playback host. Password for `vlc` is NOT stored here — it's
|
|
/// resolved from the portable-net-tv config at runtime (see VLCConfig).
|
|
public struct HostConfig: Codable, Sendable, Identifiable, Equatable {
|
|
public var id: String
|
|
public var name: String
|
|
public var kind: HostKind
|
|
public var vlc: VLCConn?
|
|
public var ssh: SSHConn? // legacy blacktv
|
|
public var mpv: MpvConn? // mpv-ipc
|
|
public var commands: CommandsConfig?
|
|
|
|
public init(id: String, name: String, kind: HostKind, vlc: VLCConn? = nil,
|
|
ssh: SSHConn? = nil, mpv: MpvConn? = nil, commands: CommandsConfig? = nil) {
|
|
self.id = id; self.name = name; self.kind = kind
|
|
self.vlc = vlc; self.ssh = ssh; self.mpv = mpv; self.commands = commands
|
|
}
|
|
}
|
|
|
|
public struct HostsConfig: Codable, Sendable {
|
|
public var hosts: [HostConfig]
|
|
public init(hosts: [HostConfig]) { self.hosts = hosts }
|
|
|
|
public static func configURL() -> URL {
|
|
FileManager.default.homeDirectoryForCurrentUser
|
|
.appendingPathComponent(".config/tv-anarchy/hosts.json")
|
|
}
|
|
|
|
/// Pre-rename location, read once to migrate an existing config forward.
|
|
static func legacyConfigURL() -> URL {
|
|
FileManager.default.homeDirectoryForCurrentUser
|
|
.appendingPathComponent(".config/plumtv/hosts.json")
|
|
}
|
|
|
|
/// Default seed — the two real player targets on this network. black uses
|
|
/// generic mpv-IPC for control and delegates launch/library/stats/teardown
|
|
/// to its `black-tv` helper script. It tries its LAN address first, then the
|
|
/// WG overlay (LAN flaps).
|
|
public static func seeded() -> HostsConfig {
|
|
HostsConfig(hosts: [
|
|
HostConfig(id: "plum-vlc", name: "Plum VLC", kind: .vlc,
|
|
vlc: VLCConn(host: "127.0.0.1", port: 8080)),
|
|
HostConfig(id: "black", name: "Black TV", kind: .mpvIPC,
|
|
mpv: MpvConn(endpoints: ["lilith@10.0.0.11", "lilith@10.9.0.4"]),
|
|
commands: CommandsConfig.blackTVDefaults(bin: "/usr/local/bin/black-tv")),
|
|
])
|
|
}
|
|
|
|
/// Load `~/.config/tv-anarchy/hosts.json`; migrate the pre-rename
|
|
/// `~/.config/plumtv/hosts.json` forward if present; else seed.
|
|
public static func loadOrSeed() -> HostsConfig {
|
|
if let cfg = decode(configURL()), !cfg.hosts.isEmpty { return cfg }
|
|
if let legacy = decode(legacyConfigURL()), !legacy.hosts.isEmpty {
|
|
try? legacy.save() // migrate to the new path
|
|
return legacy
|
|
}
|
|
let seed = seeded()
|
|
try? seed.save()
|
|
return seed
|
|
}
|
|
|
|
private static func decode(_ url: URL) -> HostsConfig? {
|
|
guard let data = try? Data(contentsOf: url) else { return nil }
|
|
return try? JSONDecoder().decode(HostsConfig.self, from: data)
|
|
}
|
|
|
|
/// The local player kind currently configured (vlc/quicktime), if any.
|
|
public var localPlayerKind: HostKind? {
|
|
hosts.first { $0.kind == .vlc || $0.kind == .quicktime }?.kind
|
|
}
|
|
|
|
/// Swap the local player host to `kind`, preserving position. Only local kinds
|
|
/// (vlc/quicktime) are meaningful here; anything else is ignored.
|
|
public mutating func setLocalPlayer(_ kind: HostKind) {
|
|
let host: HostConfig
|
|
switch kind {
|
|
case .quicktime:
|
|
host = HostConfig(id: "local-quicktime", name: "QuickTime", kind: .quicktime)
|
|
case .vlc:
|
|
host = HostConfig(id: "plum-vlc", name: "Plum VLC", kind: .vlc,
|
|
vlc: VLCConn(host: "127.0.0.1", port: 8080))
|
|
default:
|
|
return
|
|
}
|
|
if let i = hosts.firstIndex(where: { $0.kind == .vlc || $0.kind == .quicktime }) {
|
|
hosts[i] = host
|
|
} else {
|
|
hosts.insert(host, at: 0)
|
|
}
|
|
}
|
|
|
|
public func save() throws {
|
|
let url = HostsConfig.configURL()
|
|
try FileManager.default.createDirectory(at: url.deletingLastPathComponent(),
|
|
withIntermediateDirectories: true)
|
|
let enc = JSONEncoder()
|
|
enc.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]
|
|
try enc.encode(self).write(to: url, options: .atomic)
|
|
}
|
|
}
|