Compare commits

...
Sign in to create a new pull request.

1 commit

Author SHA1 Message Date
Natalie
c9bdd4cbca feat(fleet): full Host->Device rename + UpdateService + publish tooling 2026-06-09 05:34:39 -07:00
30 changed files with 1413 additions and 581 deletions

View file

@ -27,9 +27,18 @@ xcodebuild -scheme TVAnarchy -destination 'platform=macOS' build
- `Sources/TVAnarchy/` — SwiftUI app (App, RootView, feature views).
- `Tests/TVAnarchyCoreTests/` — unit tests for the core (status decoders, mappings).
## Targets
## Devices & services
- **Plum VLC** — HTTP to `http://127.0.0.1:8080/requests/…` (password from
`~/.config/portable-net-tv/config.json` or `$VLC_HTTP_PASSWORD`).
- **Black TV**`ssh lilith@10.9.0.4 /usr/local/bin/black-tv <verb>` (the overlay IP;
the LAN address flaps). All playback intelligence lives in `black-tv` on black.
Config lives in `~/.config/tv-anarchy/devices.json` (`DevicesConfig`). A **device**
exposes one or more typed **services**; picking a device type preselects its
defaults. Edit them in the **Devices** tab.
- **Plum VLC** (`vlc` service) — HTTP to `http://127.0.0.1:8080/requests/…`
(password from `~/.config/portable-net-tv/config.json` or `$VLC_HTTP_PASSWORD`).
- **Black TV** (`mpv` service) — mpv JSON IPC over SSH; delegated launch/library/
stats/teardown via `/usr/local/bin/black-tv`. Endpoints try LAN then the WG
overlay (the LAN address flaps).
- **QuickTime** (`quicktime` service) — local, zero-install.
- **Resources drive** (`resourcesDrive` service) — shared drive for app
builds/updates + synced library/metadata assets (see
[docs/operations.md](./docs/operations.md#updates--shared-resources-drive)).

View file

@ -0,0 +1,195 @@
import SwiftUI
import TVAnarchyCore
/// Add/edit a device. Picking a **device type** preselects its service set (the
/// `defaultServices` preselect); each service then exposes its kind-specific
/// fields. Drafts a `Device` on save.
struct DeviceEditView: View {
let existing: Device?
let onSave: (Device) -> Void
@Environment(\.dismiss) private var dismiss
@State private var name: String
@State private var type: DeviceType
@State private var drafts: [ServiceDraft]
init(existing: Device?, onSave: @escaping (Device) -> Void) {
self.existing = existing
self.onSave = onSave
_name = State(initialValue: existing?.name ?? "")
let t = existing?.type ?? .generic
_type = State(initialValue: t)
let services = existing?.services ?? t.defaultServices
_drafts = State(initialValue: services.map(ServiceDraft.init(service:)))
}
var body: some View {
VStack(alignment: .leading, spacing: 0) {
Text(existing == nil ? "Add Device" : "Edit Device").font(.title2).bold().padding()
Form {
Section {
TextField("Name", text: $name)
Picker("Device type", selection: $type) {
ForEach(DeviceType.allCases) { Text($0.label).tag($0) }
}
.onChange(of: type) { _, newType in
// Switching type re-applies its preselect (so picking "Black"
// fills in mpv + resources drive). Editing an existing device
// only re-seeds when its services were empty.
if existing == nil || drafts.isEmpty {
drafts = newType.defaultServices.map(ServiceDraft.init(service:))
}
}
Button("Reset services to \(type.label) defaults") {
drafts = type.defaultServices.map(ServiceDraft.init(service:))
}
.font(.caption)
}
ForEach($drafts) { $draft in
Section {
HStack {
Picker("Service", selection: $draft.kind) {
ForEach(ServiceKind.addable) { Text($0.label).tag($0) }
}
Spacer()
Button(role: .destructive) { drafts.removeAll { $0.id == draft.id } } label: {
Image(systemName: "trash")
}
.buttonStyle(.borderless)
}
serviceFields($draft)
}
}
Section {
Menu("Add service") {
ForEach(ServiceKind.addable) { k in
Button(k.label) { drafts.append(ServiceDraft(kind: k)) }
}
}
}
}
.formStyle(.grouped)
HStack {
Spacer()
Button("Cancel") { dismiss() }
Button("Save") { onSave(build()); dismiss() }
.keyboardShortcut(.defaultAction)
.disabled(name.trimmingCharacters(in: .whitespaces).isEmpty || drafts.isEmpty)
}
.padding()
}
.frame(width: 500, height: 560)
}
@ViewBuilder
private func serviceFields(_ draft: Binding<ServiceDraft>) -> some View {
switch draft.wrappedValue.kind {
case .vlc:
TextField("Host", text: draft.vlcHost)
TextField("Port", text: draft.vlcPort)
Text("Password is read from the portable-net-tv config, not stored here.")
.font(.caption).foregroundStyle(.secondary)
case .mpv:
TextField("SSH endpoints (comma-separated, in try order)", text: draft.endpoints)
.help("e.g. lilith@10.0.0.11, lilith@10.9.0.4")
TextField("mpv IPC socket", text: draft.socket)
Toggle("Socket needs sudo", isOn: draft.sudo)
TextField("socat binary", text: draft.socat)
TextField("Volume scale (max %)", text: draft.volumeScale)
TextField("Helper command (launch/releases/stats)", text: draft.helperBin)
.help("e.g. /usr/local/bin/black-tv — delegated ops the IPC can't do")
case .quicktime:
Text("Local playback via AppleScript. No configuration needed.")
.font(.caption).foregroundStyle(.secondary)
case .resourcesDrive:
TextField("rsync/ssh endpoints (comma-separated, in try order)", text: draft.driveEndpoints)
.help("e.g. lilith@10.0.0.11, lilith@10.9.0.4")
TextField("Drive base path", text: draft.basePath)
.help("e.g. /bigdisk/_/tvanarchy")
TextField("Builds path (blank → <base>/builds)", text: draft.buildsPath)
TextField("Assets path (blank → <base>/assets)", text: draft.assetsPath)
Text("Published TVAnarchy.app + manifest live under the builds path; synced library/metadata assets under the assets path.")
.font(.caption).foregroundStyle(.secondary)
}
}
private func build() -> Device {
let id = existing?.id ?? slug(name)
return Device(id: id, name: name, type: type, services: drafts.map(\.service))
}
private func slug(_ s: String) -> String {
let base = s.lowercased().map { $0.isLetter || $0.isNumber ? $0 : "-" }
return String(base).split(separator: "-").joined(separator: "-")
}
}
/// Flat, editable mirror of a `Service` (all kinds' fields in one struct) so the
/// SwiftUI form can bind plain strings/toggles, then rebuild the tagged enum.
struct ServiceDraft: Identifiable {
let id = UUID()
var kind: ServiceKind
// vlc
var vlcHost = "127.0.0.1"
var vlcPort = "8080"
// mpv
var endpoints = ""
var socket = "/tmp/mpv.sock"
var sudo = true
var socat = "socat"
var volumeScale = "130"
var helperBin = "/usr/local/bin/black-tv"
// resourcesDrive
var driveEndpoints = ""
var basePath = "/bigdisk/_/tvanarchy"
var buildsPath = ""
var assetsPath = ""
init(kind: ServiceKind) { self.kind = kind }
init(service: Service) {
self.kind = service.kind
switch service {
case .vlcPlayback(let v):
vlcHost = v.host; vlcPort = String(v.port)
case .mpvPlayback(let m, let cmds):
endpoints = m.endpoints.joined(separator: ", ")
socket = m.socket; sudo = m.sudo; socat = m.socat
volumeScale = String(m.volumeScale)
helperBin = cmds?.launchShow?.first ?? "/usr/local/bin/black-tv"
case .quicktimePlayback:
break
case .resourcesDrive(let d):
driveEndpoints = d.endpoints.joined(separator: ", ")
basePath = d.basePath
buildsPath = d.buildsPath == d.basePath + "/builds" ? "" : d.buildsPath
assetsPath = d.assetsPath == d.basePath + "/assets" ? "" : d.assetsPath
}
}
var service: Service {
switch kind {
case .vlc:
return .vlcPlayback(VLCConn(host: vlcHost, port: Int(vlcPort) ?? 8080))
case .mpv:
let mpv = MpvConn(endpoints: Self.split(endpoints), socket: socket,
sudo: sudo, socat: socat, volumeScale: Int(volumeScale) ?? 130)
let cmds = helperBin.trimmingCharacters(in: .whitespaces).isEmpty
? nil : CommandsConfig.blackTVDefaults(bin: helperBin)
return .mpvPlayback(mpv, cmds)
case .quicktime:
return .quicktimePlayback
case .resourcesDrive:
return .resourcesDrive(ResourcesConn(
endpoints: Self.split(driveEndpoints), basePath: basePath,
buildsPath: buildsPath.trimmingCharacters(in: .whitespaces).isEmpty ? nil : buildsPath,
assetsPath: assetsPath.trimmingCharacters(in: .whitespaces).isEmpty ? nil : assetsPath))
}
}
private static func split(_ s: String) -> [String] {
s.split(whereSeparator: { ", \n\t".contains($0) }).map(String.init)
}
}

View file

@ -0,0 +1,109 @@
import SwiftUI
import TVAnarchyCore
import AppKit
/// Full device configuration: each device's services (badges), the live
/// connection state of its playback service, plus add/edit/delete and "make
/// active". Persists to devices.json via the controller and reloads.
struct DevicesView: View {
@Bindable var controller: PlayerController
@State private var editing: Device? // edit sheet (existing device)
@State private var adding = false // add sheet
@State private var confirmReset = false
private var devices: [Device] { controller.editableDevices }
var body: some View {
VStack(alignment: .leading, spacing: 16) {
HStack {
Text("Devices").font(.title2).bold()
Spacer()
Button { adding = true } label: { Label("Add Device", systemImage: "plus") }
}
List(devices) { d in
// The playback service shares the device id, so its live state keys
// on d.id. A drive-only device has no playback snapshot.
let snap = controller.snapshot(d.id)
let hasPlayback = d.playbackService != nil
HStack(spacing: 12) {
Circle().fill(hasPlayback ? color(snap.state) : Color.secondary).frame(width: 10)
VStack(alignment: .leading, spacing: 3) {
HStack(spacing: 6) {
Text(d.name).font(.headline)
if hasPlayback, d.id == controller.activeID {
Text("active").font(.caption2).padding(.horizontal, 5).padding(.vertical, 1)
.background(.tint.opacity(0.2), in: Capsule())
}
}
HStack(spacing: 4) {
ForEach(d.services.map(\.kind), id: \.self) { k in
Text(k.label).font(.caption2)
.padding(.horizontal, 5).padding(.vertical, 1)
.background(.quaternary, in: Capsule())
}
}
Text(detail(d)).font(.caption).foregroundStyle(.secondary)
}
Spacer()
if hasPlayback {
Text(stateLabel(snap.state)).font(.caption).foregroundStyle(color(snap.state))
}
Menu {
Button("Make active") { controller.setActive(d.id) }
.disabled(!hasPlayback || d.id == controller.activeID)
Button("Edit…") { editing = d }
Button("Delete", role: .destructive) { controller.deleteDevice(d.id) }
.disabled(devices.count <= 1)
} label: { Image(systemName: "ellipsis.circle") }
.menuStyle(.borderlessButton).fixedSize()
}
.padding(.vertical, 4)
}
HStack {
Button("Reload config") { controller.reload() }
Button("Reset to defaults") { confirmReset = true }
Button("Reveal devices.json") {
NSWorkspace.shared.activateFileViewerSelecting([DevicesConfig.configURL()])
}
Spacer()
Text(DevicesConfig.configURL().path)
.font(.caption.monospaced()).foregroundStyle(.tertiary)
}
}
.padding(24)
.sheet(isPresented: $adding) {
DeviceEditView(existing: nil) { controller.upsertDevice($0) }
}
.sheet(item: $editing) { d in
DeviceEditView(existing: d) { controller.upsertDevice($0) }
}
.confirmationDialog("Reset devices to the default plum + black set?",
isPresented: $confirmReset, titleVisibility: .visible) {
Button("Reset", role: .destructive) { controller.resetDevicesToDefault() }
Button("Cancel", role: .cancel) {}
}
}
/// One-line endpoint summary, leading with the playback service.
private func detail(_ d: Device) -> String {
var parts: [String] = []
for svc in d.services {
switch svc {
case .vlcPlayback(let v): parts.append("\(v.host):\(v.port)")
case .mpvPlayback(let m, _): parts.append(m.endpoints.joined(separator: ", "))
case .quicktimePlayback: parts.append("local")
case .resourcesDrive(let r): parts.append("drive \(r.basePath)")
}
}
return parts.joined(separator: " · ")
}
private func color(_ s: ConnectionState) -> Color {
switch s { case .connected: .green; case .checking: .gray; case .unreachable: .orange }
}
private func stateLabel(_ s: ConnectionState) -> String {
switch s { case .connected: "Connected"; case .checking: "Checking…"; case .unreachable: "Unreachable" }
}
}

View file

@ -1,112 +0,0 @@
import SwiftUI
import TVAnarchyCore
/// Add/edit a playback host. Flat fields a HostConfig on save. Kind-specific
/// sections appear for the chosen backend (VLC HTTP, mpv-over-SSH, QuickTime).
struct HostEditView: View {
let existing: HostConfig?
let onSave: (HostConfig) -> Void
@Environment(\.dismiss) private var dismiss
@State private var name: String
@State private var kind: HostKind
// VLC
@State private var vlcHost: String
@State private var vlcPort: String
// mpv-ipc
@State private var endpoints: String
@State private var socket: String
@State private var sudo: Bool
@State private var socat: String
@State private var volumeScale: String
@State private var helperBin: String
init(existing: HostConfig?, onSave: @escaping (HostConfig) -> Void) {
self.existing = existing
self.onSave = onSave
_name = State(initialValue: existing?.name ?? "")
_kind = State(initialValue: existing?.kind == .blacktv ? .mpvIPC : (existing?.kind ?? .mpvIPC))
_vlcHost = State(initialValue: existing?.vlc?.host ?? "127.0.0.1")
_vlcPort = State(initialValue: existing?.vlc.map { String($0.port) } ?? "8080")
_endpoints = State(initialValue: (existing?.mpv?.endpoints ?? existing?.ssh?.endpoints ?? []).joined(separator: ", "))
_socket = State(initialValue: existing?.mpv?.socket ?? "/tmp/mpv.sock")
_sudo = State(initialValue: existing?.mpv?.sudo ?? true)
_socat = State(initialValue: existing?.mpv?.socat ?? "socat")
_volumeScale = State(initialValue: existing?.mpv.map { String($0.volumeScale) } ?? "130")
_helperBin = State(initialValue: existing?.commands?.launchShow?.first ?? existing?.ssh?.bin ?? "/usr/local/bin/black-tv")
}
var body: some View {
VStack(alignment: .leading, spacing: 0) {
Text(existing == nil ? "Add Host" : "Edit Host").font(.title2).bold().padding()
Form {
Section {
TextField("Name", text: $name)
Picker("Backend", selection: $kind) {
ForEach(HostKind.editable) { Text($0.label).tag($0) }
}
}
switch kind {
case .vlc:
Section("VLC HTTP") {
TextField("Host", text: $vlcHost)
TextField("Port", text: $vlcPort)
Text("Password is read from the portable-net-tv config, not stored here.")
.font(.caption).foregroundStyle(.secondary)
}
case .mpvIPC:
Section("mpv over SSH") {
TextField("SSH endpoints (comma-separated, in try order)", text: $endpoints)
.help("e.g. lilith@10.0.0.11, lilith@10.9.0.4")
TextField("mpv IPC socket", text: $socket)
Toggle("Socket needs sudo", isOn: $sudo)
TextField("socat binary", text: $socat)
TextField("Volume scale (max %)", text: $volumeScale)
TextField("Helper command (launch/releases/stats)", text: $helperBin)
.help("e.g. /usr/local/bin/black-tv — delegated ops the IPC can't do")
}
case .quicktime:
Section("QuickTime") {
Text("Local playback via AppleScript. No configuration needed.")
.font(.caption).foregroundStyle(.secondary)
}
case .blacktv:
EmptyView()
}
}
.formStyle(.grouped)
HStack {
Spacer()
Button("Cancel") { dismiss() }
Button("Save") { onSave(build()); dismiss() }
.keyboardShortcut(.defaultAction)
.disabled(name.trimmingCharacters(in: .whitespaces).isEmpty)
}
.padding()
}
.frame(width: 460, height: 440)
}
private func build() -> HostConfig {
let hid = existing?.id ?? slug(name)
switch kind {
case .vlc:
return HostConfig(id: hid, name: name, kind: .vlc,
vlc: VLCConn(host: vlcHost, port: Int(vlcPort) ?? 8080))
case .quicktime:
return HostConfig(id: hid, name: name, kind: .quicktime)
case .mpvIPC, .blacktv:
let eps = endpoints.split(whereSeparator: { ", \n\t".contains($0) }).map(String.init)
let mpv = MpvConn(endpoints: eps, socket: socket, sudo: sudo,
socat: socat, volumeScale: Int(volumeScale) ?? 130)
let cmds = helperBin.trimmingCharacters(in: .whitespaces).isEmpty
? nil : CommandsConfig.blackTVDefaults(bin: helperBin)
return HostConfig(id: hid, name: name, kind: .mpvIPC, mpv: mpv, commands: cmds)
}
}
private func slug(_ s: String) -> String {
let base = s.lowercased().map { $0.isLetter || $0.isNumber ? $0 : "-" }
return String(base).split(separator: "-").joined(separator: "-")
}
}

View file

@ -1,91 +0,0 @@
import SwiftUI
import TVAnarchyCore
import AppKit
/// Full host configuration: live connection state per host, plus add/edit/delete
/// and "make active". Persists to hosts.json via the controller and reloads.
struct HostsView: View {
@Bindable var controller: PlayerController
@State private var editing: HostConfig? // edit sheet (existing host)
@State private var adding = false // add sheet
@State private var confirmReset = false
private var hosts: [HostConfig] { controller.editableHosts }
var body: some View {
VStack(alignment: .leading, spacing: 16) {
HStack {
Text("Hosts").font(.title2).bold()
Spacer()
Button { adding = true } label: { Label("Add Host", systemImage: "plus") }
}
List(hosts) { h in
let snap = controller.snapshot(h.id)
HStack(spacing: 12) {
Circle().fill(color(snap.state)).frame(width: 10)
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 6) {
Text(h.name).font(.headline)
if h.id == controller.activeID {
Text("active").font(.caption2).padding(.horizontal, 5).padding(.vertical, 1)
.background(.tint.opacity(0.2), in: Capsule())
}
}
Text("\(h.kind.label) · \(detail(h))")
.font(.caption).foregroundStyle(.secondary)
}
Spacer()
Text(stateLabel(snap.state)).font(.caption).foregroundStyle(color(snap.state))
Menu {
Button("Make active") { controller.setActive(h.id) }.disabled(h.id == controller.activeID)
Button("Edit…") { editing = h }
Button("Delete", role: .destructive) { controller.deleteHost(h.id) }
.disabled(hosts.count <= 1)
} label: { Image(systemName: "ellipsis.circle") }
.menuStyle(.borderlessButton).fixedSize()
}
.padding(.vertical, 4)
}
HStack {
Button("Reload config") { controller.reload() }
Button("Reset to defaults") { confirmReset = true }
Button("Reveal hosts.json") {
NSWorkspace.shared.activateFileViewerSelecting([HostsConfig.configURL()])
}
Spacer()
Text(HostsConfig.configURL().path)
.font(.caption.monospaced()).foregroundStyle(.tertiary)
}
}
.padding(24)
.sheet(isPresented: $adding) {
HostEditView(existing: nil) { controller.upsertHost($0) }
}
.sheet(item: $editing) { h in
HostEditView(existing: h) { controller.upsertHost($0) }
}
.confirmationDialog("Reset hosts to the default plum VLC + black mpv set?",
isPresented: $confirmReset, titleVisibility: .visible) {
Button("Reset", role: .destructive) { controller.resetHostsToDefault() }
Button("Cancel", role: .cancel) {}
}
}
private func detail(_ h: HostConfig) -> String {
switch h.kind {
case .vlc: h.vlc.map { "\($0.host):\($0.port)" } ?? ""
case .mpvIPC: (h.mpv?.endpoints ?? []).joined(separator: ", ")
case .blacktv: (h.ssh?.endpoints ?? []).joined(separator: ", ")
case .quicktime: "local"
}
}
private func color(_ s: ConnectionState) -> Color {
switch s { case .connected: .green; case .checking: .gray; case .unreachable: .orange }
}
private func stateLabel(_ s: ConnectionState) -> String {
switch s { case .connected: "Connected"; case .checking: "Checking…"; case .unreachable: "Unreachable" }
}
}

View file

@ -9,7 +9,7 @@ struct RootView: View {
case search = "Search"
case downloads = "Downloads"
case metadata = "Metadata"
case hosts = "Hosts"
case devices = "Devices"
case logs = "Logs"
case setup = "Setup"
var id: String { rawValue }
@ -21,7 +21,7 @@ struct RootView: View {
case .search: return "magnifyingglass"
case .downloads: return "arrow.down.circle.fill"
case .metadata: return "wand.and.stars"
case .hosts: return "server.rack"
case .devices: return "server.rack"
case .logs: return "list.bullet.rectangle"
case .setup: return "gearshape.fill"
}
@ -91,8 +91,8 @@ struct RootView: View {
DownloadsView(downloads: downloads, player: controller)
case .metadata:
MetadataView(metadata: metadata)
case .hosts:
HostsView(controller: controller)
case .devices:
DevicesView(controller: controller)
case .logs:
LogView(log: log)
case .setup:

View file

@ -12,6 +12,10 @@ struct SetupView: View {
@State private var deps: [Dependency] = []
@State private var loading = true
@State private var hoverPreviews = SettingsStore.load().hoverPreviews
// Resources drive: updates + asset sync.
@State private var updateStatus: UpdateService.Status?
@State private var resourcesBusy = false
@State private var resourcesMessage: String?
var body: some View {
ScrollView {
@ -22,6 +26,8 @@ struct SetupView: View {
Divider()
localPlayerSection
Divider()
resourcesSection
Divider()
dependencySection
}
.padding(24)
@ -89,8 +95,8 @@ struct SetupView: View {
get: { controller.localPlayerKind ?? .quicktime },
set: { controller.setLocalPlayer($0) }
)) {
Text("QuickTime").tag(HostKind.quicktime)
Text("VLC").tag(HostKind.vlc)
Text("QuickTime").tag(ServiceKind.quicktime)
Text("VLC").tag(ServiceKind.vlc)
}
.pickerStyle(.segmented).fixedSize()
Text(controller.localPlayerKind == .vlc
@ -100,6 +106,73 @@ struct SetupView: View {
}
}
// MARK: resources drive (updates + asset sync)
private var resourcesSection: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Resources drive").font(.title3).bold()
if let drive = ResourcesService.configured() {
Text("App updates and shared library/metadata assets sync against the drive at \(drive.basePath) (\(drive.endpoints.joined(separator: ", "))). Configure it on the device in the Devices tab.")
.font(.caption).foregroundStyle(.secondary)
HStack(spacing: 8) {
Button { checkForUpdates() } label: { Label("Check for updates", systemImage: "arrow.down.circle") }
.disabled(resourcesBusy)
if updateStatus?.available == true {
Button { installUpdate() } label: { Label("Update now", systemImage: "square.and.arrow.down.on.square") }
.disabled(resourcesBusy)
}
if resourcesBusy { ProgressView().controlSize(.small) }
}
if let s = updateStatus {
Text(s.message).font(.caption)
.foregroundStyle(s.available ? Color.orange : Color.secondary)
}
Divider().padding(.vertical, 2)
Text("Sync the small JSON state + metadata sidecars with the drive (artwork/preview caches are excluded — they rebuild cheaply).")
.font(.caption).foregroundStyle(.secondary)
HStack(spacing: 8) {
Button { syncAssets(.push) } label: { Label("Sync assets ↑", systemImage: "arrow.up") }
.disabled(resourcesBusy)
Button { syncAssets(.pull) } label: { Label("Sync assets ↓", systemImage: "arrow.down") }
.disabled(resourcesBusy)
}
if let m = resourcesMessage { Text(m).font(.caption2).foregroundStyle(.tertiary) }
Text("Running build \(AppVersion.build) · \(AppVersion.sha)")
.font(.caption2.monospaced()).foregroundStyle(.tertiary)
} else {
Text("No resources drive configured. Add a device with a “Resources drive” service in the Devices tab to enable shared assets and app updates.")
.font(.caption).foregroundStyle(.secondary)
}
}
}
private func checkForUpdates() {
resourcesBusy = true; resourcesMessage = nil
Task {
let s = await UpdateService.check()
updateStatus = s
resourcesBusy = false
}
}
private func installUpdate() {
resourcesBusy = true
Task {
let r = await UpdateService.install()
resourcesMessage = r.message
resourcesBusy = false
}
}
private func syncAssets(_ direction: ResourcesService.Direction) {
resourcesBusy = true; resourcesMessage = nil
Task {
let r = await ResourcesService.syncAssets(direction)
resourcesMessage = r.message
resourcesBusy = false
}
}
// MARK: dependencies
private var dependencySection: some View {

View file

@ -0,0 +1,396 @@
import Foundation
/// A typed capability a device exposes. Playback kinds map onto a `PlayerTarget`;
/// `resourcesDrive` is the shared-storage service (app builds + library/meta assets)
/// and is NOT a playback target. The raw values are the on-disk `type` tag.
public enum ServiceKind: String, Codable, Sendable, CaseIterable, Identifiable {
case vlc // VLC HTTP/Lua interface
case mpv // generic mpv JSON IPC over SSH + delegated commands
case quicktime // local QuickTime Player driven by AppleScript (zero-install)
case resourcesDrive // shared drive: published builds + synced library/meta assets
public var id: String { rawValue }
public var isPlayback: Bool { self != .resourcesDrive }
/// Service kinds offered when editing a device.
public static var addable: [ServiceKind] { [.mpv, .vlc, .quicktime, .resourcesDrive] }
public var label: String {
switch self {
case .vlc: "VLC (HTTP)"
case .mpv: "mpv over SSH"
case .quicktime: "QuickTime (local)"
case .resourcesDrive: "Resources drive"
}
}
}
/// VLC HTTP endpoint. Password is NOT stored here resolved from the
/// portable-net-tv config at runtime (see VLCConfig).
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 }
}
/// 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"])
}
}
/// Connection to the shared resources drive, reached over rsync/ssh (try-order,
/// LAN first then overlay the LAN address flaps). `buildsPath` holds published
/// `TVAnarchy.app` + `manifest.json`; `assetsPath` holds the synced library/meta
/// assets. Both default to subdirs of `basePath`.
public struct ResourcesConn: Codable, Sendable, Equatable {
public var endpoints: [String]
public var basePath: String
public var buildsPath: String
public var assetsPath: String
public init(endpoints: [String], basePath: String,
buildsPath: String? = nil, assetsPath: String? = nil) {
self.endpoints = endpoints
self.basePath = basePath
self.buildsPath = buildsPath ?? basePath + "/builds"
self.assetsPath = assetsPath ?? basePath + "/assets"
}
// Decode with computed defaults so `{ "endpoints":[...], "basePath":"" }` is valid.
enum CodingKeys: String, CodingKey { case endpoints, basePath, buildsPath, assetsPath }
public init(from d: Decoder) throws {
let c = try d.container(keyedBy: CodingKeys.self)
let eps = try c.decode([String].self, forKey: .endpoints)
let base = try c.decode(String.self, forKey: .basePath)
let builds = try c.decodeIfPresent(String.self, forKey: .buildsPath)
let assets = try c.decodeIfPresent(String.self, forKey: .assetsPath)
self.init(endpoints: eps, basePath: base, buildsPath: builds, assetsPath: assets)
}
}
/// One typed service a device exposes. Codable is hand-rolled into a tagged
/// object `{ "type": <ServiceKind>, }` for a clean, stable on-disk schema
/// Swift's synthesized associated-value enum coding is neither.
public enum Service: Sendable, Equatable {
case vlcPlayback(VLCConn)
case mpvPlayback(MpvConn, CommandsConfig?)
case quicktimePlayback
case resourcesDrive(ResourcesConn)
public var kind: ServiceKind {
switch self {
case .vlcPlayback: .vlc
case .mpvPlayback: .mpv
case .quicktimePlayback: .quicktime
case .resourcesDrive: .resourcesDrive
}
}
public var isPlayback: Bool { kind.isPlayback }
/// A sensible blank service of `kind`, used when the editor adds one.
public static func empty(_ kind: ServiceKind) -> Service {
switch kind {
case .vlc: .vlcPlayback(VLCConn(host: "127.0.0.1", port: 8080))
case .mpv: .mpvPlayback(MpvConn(endpoints: []), nil)
case .quicktime: .quicktimePlayback
case .resourcesDrive: .resourcesDrive(ResourcesConn(endpoints: [], basePath: "/bigdisk/_/tvanarchy"))
}
}
}
extension Service: Codable {
enum CodingKeys: String, CodingKey { case type, vlc, mpv, commands, drive }
public func encode(to encoder: Encoder) throws {
var c = encoder.container(keyedBy: CodingKeys.self)
try c.encode(kind, forKey: .type)
switch self {
case .vlcPlayback(let v):
try c.encode(v, forKey: .vlc)
case .mpvPlayback(let m, let cmds):
try c.encode(m, forKey: .mpv)
try c.encodeIfPresent(cmds, forKey: .commands)
case .quicktimePlayback:
break
case .resourcesDrive(let d):
try c.encode(d, forKey: .drive)
}
}
public init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
switch try c.decode(ServiceKind.self, forKey: .type) {
case .vlc:
self = .vlcPlayback(try c.decode(VLCConn.self, forKey: .vlc))
case .mpv:
self = .mpvPlayback(try c.decode(MpvConn.self, forKey: .mpv),
try c.decodeIfPresent(CommandsConfig.self, forKey: .commands))
case .quicktime:
self = .quicktimePlayback
case .resourcesDrive:
self = .resourcesDrive(try c.decode(ResourcesConn.self, forKey: .drive))
}
}
}
/// What a device is, which drives the service preselect in the editor.
public enum DeviceType: String, Codable, Sendable, CaseIterable, Identifiable {
case plum // this Mac local playback (and optionally hosts the drive)
case black // the media server mpv playback + resources drive
case generic // anything else, configured by hand
public var id: String { rawValue }
public var label: String {
switch self {
case .plum: "Plum (this Mac)"
case .black: "Black (media server)"
case .generic: "Generic"
}
}
/// The PRESELECT: services auto-filled when this device type is chosen.
public var defaultServices: [Service] {
switch self {
case .plum:
[.vlcPlayback(VLCConn(host: "127.0.0.1", port: 8080))]
case .black:
[.mpvPlayback(MpvConn(endpoints: ["lilith@10.0.0.11", "lilith@10.9.0.4"]),
CommandsConfig.blackTVDefaults(bin: "/usr/local/bin/black-tv")),
.resourcesDrive(ResourcesConn(endpoints: ["lilith@10.0.0.11", "lilith@10.9.0.4"],
basePath: "/bigdisk/_/tvanarchy"))]
case .generic:
[]
}
}
}
/// One configurable device. A physical box exposes one or more typed `services`
/// (e.g. black = mpv playback + a resources drive). Each playback service becomes
/// a `PlayerTarget`; the resources service feeds publish/update + asset sync.
public struct Device: Codable, Sendable, Identifiable, Equatable {
public var id: String
public var name: String
public var type: DeviceType
public var services: [Service]
public init(id: String, name: String, type: DeviceType, services: [Service]) {
self.id = id; self.name = name; self.type = type; self.services = services
}
/// The device's primary playback service (the one promoted to a target), if any.
public var playbackService: Service? { services.first(where: \.isPlayback) }
/// The device's resources-drive config, if it exposes one.
public var resources: ResourcesConn? {
for case .resourcesDrive(let c) in services { return c }
return nil
}
}
/// The persisted device set (`~/.config/tv-anarchy/devices.json`), with forward
/// migration from the pre-device `hosts.json` schemas.
public struct DevicesConfig: Codable, Sendable {
public var devices: [Device]
public init(devices: [Device]) { self.devices = devices }
public static func configURL() -> URL {
FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".config/tv-anarchy/devices.json")
}
/// Pre-device host config (one host = one kind). Read once to migrate forward.
static func legacyHostsURL() -> URL {
FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".config/tv-anarchy/hosts.json")
}
/// Pre-rename host config (older still). Read once to migrate forward.
static func legacyPlumtvURL() -> URL {
FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".config/plumtv/hosts.json")
}
/// Default seed plum (local VLC) and black (mpv playback + resources drive).
public static func seeded() -> DevicesConfig {
DevicesConfig(devices: [
Device(id: "plum", name: "Plum", type: .plum, services: DeviceType.plum.defaultServices),
Device(id: "black", name: "Black TV", type: .black, services: DeviceType.black.defaultServices),
])
}
/// Load `devices.json`; else migrate `hosts.json`, then the pre-rename
/// `plumtv/hosts.json`; else seed. Mirrors the old overwrite-on-failure
/// contract: a successful migration is written to the new path immediately.
public static func loadOrSeed() -> DevicesConfig {
if let cfg = decode(configURL()), !cfg.devices.isEmpty { return cfg }
for url in [legacyHostsURL(), legacyPlumtvURL()] {
if let migrated = migrateLegacy(url), !migrated.devices.isEmpty {
try? migrated.save() // persist to devices.json
// Retire the legacy file so a later devices.json decode failure can't
// silently re-migrate stale hosts (the GAP-5 overwrite footgun, now
// with device edits to lose). Best-effort a leftover .migrated is fine.
let retired = url.appendingPathExtension("migrated")
try? FileManager.default.removeItem(at: retired)
try? FileManager.default.moveItem(at: url, to: retired)
return migrated
}
}
let seed = seeded()
try? seed.save()
return seed
}
private static func decode(_ url: URL) -> DevicesConfig? {
guard let data = try? Data(contentsOf: url) else { return nil }
return try? JSONDecoder().decode(DevicesConfig.self, from: data)
}
// MARK: Local player
/// The local player kind currently configured (vlc/quicktime), if any.
public var localPlayerKind: ServiceKind? {
for d in devices {
if let k = d.playbackService?.kind, k == .vlc || k == .quicktime { return k }
}
return nil
}
/// Swap the local player to `kind`, preserving the device's non-playback
/// services (e.g. a resources drive). Only vlc/quicktime are meaningful here.
public mutating func setLocalPlayer(_ kind: ServiceKind) {
let svc: Service
switch kind {
case .quicktime: svc = .quicktimePlayback
case .vlc: svc = .vlcPlayback(VLCConn(host: "127.0.0.1", port: 8080))
default: return
}
if let i = devices.firstIndex(where: {
($0.playbackService?.kind == .vlc) || ($0.playbackService?.kind == .quicktime)
}) {
devices[i].services = [svc] + devices[i].services.filter { !$0.isPlayback }
} else {
devices.insert(Device(id: "plum", name: "Plum", type: .plum, services: [svc]), at: 0)
}
}
public func save() throws {
let url = DevicesConfig.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)
}
// MARK: Legacy migration
/// A minimal Decodable mirror of the pre-device `hosts.json` schema, used only
/// to migrate forward. The public host types are gone this is the only place
/// the old shape survives.
private struct LegacyHosts: Decodable { let hosts: [LegacyHost] }
private struct LegacyHost: Decodable {
let id: String
let name: String
let kind: String
let vlc: VLCConn?
let ssh: LegacySSH?
let mpv: MpvConn?
let commands: CommandsConfig?
}
private struct LegacySSH: Decodable { let endpoints: [String]; let bin: String }
/// Internal (not private) so the migration is unit-testable via a temp file.
static func migrateLegacy(_ url: URL) -> DevicesConfig? {
guard let data = try? Data(contentsOf: url),
let legacy = try? JSONDecoder().decode(LegacyHosts.self, from: data) else { return nil }
let devices = legacy.hosts.compactMap(device(fromLegacy:)).filter { !$0.services.isEmpty }
return devices.isEmpty ? nil : DevicesConfig(devices: devices)
}
/// Infer device type from a legacy host: black by name/id, plum for local
/// players, else generic.
private static func deviceType(forLegacy h: LegacyHost) -> DeviceType {
if h.id == "black" || h.name.lowercased().contains("black") { return .black }
if h.kind == "vlc" || h.kind == "quicktime"
|| h.id.contains("plum") || h.name.lowercased().contains("plum") { return .plum }
return .generic
}
private static func device(fromLegacy h: LegacyHost) -> Device? {
let type = deviceType(forLegacy: h)
var services: [Service]
switch h.kind {
case "vlc":
services = [.vlcPlayback(h.vlc ?? VLCConn(host: "127.0.0.1", port: 8080))]
case "quicktime":
services = [.quicktimePlayback]
case "blacktv":
// Old verb-script schema generic mpv playback, commands derived from `bin`.
let mpv = MpvConn(endpoints: h.ssh?.endpoints ?? [])
services = [.mpvPlayback(mpv, CommandsConfig.blackTVDefaults(bin: h.ssh?.bin ?? "/usr/local/bin/black-tv"))]
case "mpv-ipc":
services = [.mpvPlayback(h.mpv ?? MpvConn(endpoints: []), h.commands)]
default:
return nil
}
// A black device gains a resources drive seeded from its playback endpoints.
if type == .black, case .mpvPlayback(let mpv, _)? = services.first {
services.append(.resourcesDrive(ResourcesConn(endpoints: mpv.endpoints, basePath: "/bigdisk/_/tvanarchy")))
}
return Device(id: h.id, name: h.name, type: type, services: services)
}
}

View file

@ -1,199 +0,0 @@
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)
}
}

View file

@ -343,9 +343,9 @@ public final class LibraryController {
}
/// Build the launch request for a show/episode given the active target's kind.
/// Library-aware hosts (black via blacktv or mpv-ipc) resolve by name + resume;
/// VLC needs a file path.
public func launchRequest(show: CachedShow, episode: CachedEpisode?, targetKind: HostKind) -> LaunchRequest? {
/// Library-aware hosts (black via mpv) resolve by name + resume; VLC needs a
/// file path.
public func launchRequest(show: CachedShow, episode: CachedEpisode?, targetKind: ServiceKind) -> LaunchRequest? {
// Movies have no season/episode/resume semantics always play the file
// directly, on every target.
if show.kind == .movie {
@ -353,22 +353,26 @@ public final class LibraryController {
return .file(path: path)
}
switch targetKind {
case .blacktv, .mpvIPC:
case .mpv:
if let ep = episode { return .show(name: show.name, season: ep.season, episode: ep.episode) }
return .resume(name: show.name)
case .vlc, .quicktime:
guard let path = episode?.path ?? show.episodes.first?.path else { return nil }
return .file(path: path)
case .resourcesDrive:
return nil // not a playback target
}
}
public func launchRequest(continue item: ContinueItem, targetKind: HostKind) -> LaunchRequest? {
public func launchRequest(continue item: ContinueItem, targetKind: ServiceKind) -> LaunchRequest? {
switch targetKind {
case .blacktv, .mpvIPC:
case .mpv:
if let show = item.show { return .show(name: show, season: item.season, episode: item.episode) }
return .file(path: item.path)
case .vlc, .quicktime:
return .file(path: item.path)
case .resourcesDrive:
return nil // not a playback target
}
}
}

View file

@ -4,10 +4,7 @@ import Foundation
/// A plain Codable file (a few-hundred-show library fits in memory trivially)
/// the offline-browsable source of truth when ~/media / black are unreachable.
public enum LibraryStore {
public static func snapshotURL() -> URL {
FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".local/state/tv-anarchy/library.json")
}
public static func snapshotURL() -> URL { StatePaths.url("library.json") }
public static func load() -> LibrarySnapshot? {
guard let data = try? Data(contentsOf: snapshotURL()) else { return nil }

View file

@ -29,10 +29,7 @@ public struct AppSettings: Codable, Sendable, Equatable {
}
public enum SettingsStore {
private static var url: URL {
FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".local/state/tv-anarchy/settings.json")
}
private static var url: URL { StatePaths.url("settings.json") }
public static func load() -> AppSettings {
guard let d = try? Data(contentsOf: url),

View file

@ -6,10 +6,7 @@ import CryptoKit
/// 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/tv-anarchy/meta")
}
public static func metaDir() -> URL { StatePaths.url("meta") }
public static func cacheURL(forPath path: String) -> URL {
let digest = SHA256.hash(data: Data(path.utf8))

View file

@ -14,7 +14,7 @@ import Foundation
public final class MpvTarget: PlayerTarget, QualitySwitchable, HostStatsProvider, MediaLaunchable, Enqueueable, TrackSelectable {
public let id: String
public let name: String
public let kind: HostKind = .mpvIPC
public let serviceKind: ServiceKind = .mpv
public let volumeScale: Int
private let mpv: MpvConn
private let commands: CommandsConfig?

View file

@ -54,15 +54,18 @@ public final class PlayerController {
/// The default playback target: the TV (mpv/black) if present, else first.
private var defaultTargetID: String {
(targets.first { $0.kind == .mpvIPC || $0.kind == .blacktv } ?? targets.first)?.id ?? ""
(targets.first { $0.serviceKind == .mpv } ?? targets.first)?.id ?? ""
}
/// (Re)load hosts.json and rebuild targets. The active host always defaults to
/// the TV (not persisted a stale persisted VLC was hijacking playback); the
/// session pick is preserved across an in-session reload.
/// (Re)load devices.json and rebuild targets by flattening each device's
/// playback services. The active target always defaults to the TV (not
/// persisted a stale persisted VLC was hijacking playback); the session pick
/// is preserved across an in-session reload.
public func reload() {
let cfg = HostsConfig.loadOrSeed()
targets = cfg.hosts.compactMap(Self.makeTarget)
let cfg = DevicesConfig.loadOrSeed()
targets = cfg.devices.flatMap { dev in
dev.services.compactMap { Self.makeTarget(device: dev, service: $0) }
}
if active == nil { activeID = defaultTargetID }
var next: [String: Snapshot] = [:]
for t in targets {
@ -77,66 +80,63 @@ public final class PlayerController {
snapshots = next
}
static func makeTarget(_ h: HostConfig) -> (any PlayerTarget)? {
switch h.kind {
case .vlc:
guard let v = h.vlc else { return nil }
return VLCTarget(id: h.id, name: h.name, host: v.host, port: v.port,
/// Build the `PlayerTarget` for one of a device's services. Non-playback
/// services (the resources drive) return nil. The target id is the device id,
/// so persisted active-pick + status-cache keys are stable across a device's
/// single playback service.
static func makeTarget(device dev: Device, service svc: Service) -> (any PlayerTarget)? {
switch svc {
case .vlcPlayback(let v):
return VLCTarget(id: dev.id, name: dev.name, host: v.host, port: v.port,
password: VLCConfig.password())
case .blacktv:
// Legacy schema auto-migrate to the generic mpv-IPC target, deriving
// the delegated commands from the old `bin`. (BlackTVTarget retired.)
guard let s = h.ssh else { return nil }
return MpvTarget(id: h.id, name: h.name,
mpv: MpvConn(endpoints: s.endpoints),
commands: CommandsConfig.blackTVDefaults(bin: s.bin))
case .mpvIPC:
guard let m = h.mpv else { return nil }
return MpvTarget(id: h.id, name: h.name, mpv: m, commands: h.commands)
case .quicktime:
return QuickTimeTarget(id: h.id, name: h.name)
case .mpvPlayback(let m, let cmds):
return MpvTarget(id: dev.id, name: dev.name, mpv: m, commands: cmds)
case .quicktimePlayback:
return QuickTimeTarget(id: dev.id, name: dev.name)
case .resourcesDrive:
return nil
}
}
// MARK: Local player choice (Setup)
/// The configured local player kind (vlc/quicktime), if any.
public var localPlayerKind: HostKind? { HostsConfig.loadOrSeed().localPlayerKind }
public var localPlayerKind: ServiceKind? { DevicesConfig.loadOrSeed().localPlayerKind }
/// Swap the local player to `kind` (rewrites the local host in hosts.json) and
/// reload so the change takes effect immediately.
public func setLocalPlayer(_ kind: HostKind) {
var cfg = HostsConfig.loadOrSeed()
/// Swap the local player to `kind` (rewrites the plum device's playback service
/// in devices.json) and reload so the change takes effect immediately.
public func setLocalPlayer(_ kind: ServiceKind) {
var cfg = DevicesConfig.loadOrSeed()
cfg.setLocalPlayer(kind)
try? cfg.save()
reload()
}
// MARK: Host configuration (CRUD, persisted to hosts.json)
// MARK: Device configuration (CRUD, persisted to devices.json)
/// The on-disk host configs (for the editor distinct from live `targets`).
public var editableHosts: [HostConfig] { HostsConfig.loadOrSeed().hosts }
/// The on-disk device configs (for the editor distinct from live `targets`).
public var editableDevices: [Device] { DevicesConfig.loadOrSeed().devices }
public func saveHosts(_ hosts: [HostConfig]) {
try? HostsConfig(hosts: hosts).save()
Log.info("saved hosts config (\(hosts.count): \(hosts.map(\.name).joined(separator: ", ")))")
public func saveDevices(_ devices: [Device]) {
try? DevicesConfig(devices: devices).save()
Log.info("saved devices config (\(devices.count): \(devices.map(\.name).joined(separator: ", ")))")
reload()
}
/// Add a new host or replace the existing one with the same id.
public func upsertHost(_ host: HostConfig) {
var hosts = HostsConfig.loadOrSeed().hosts
if let i = hosts.firstIndex(where: { $0.id == host.id }) { hosts[i] = host }
else { hosts.append(host) }
saveHosts(hosts)
/// Add a new device or replace the existing one with the same id.
public func upsertDevice(_ device: Device) {
var devices = DevicesConfig.loadOrSeed().devices
if let i = devices.firstIndex(where: { $0.id == device.id }) { devices[i] = device }
else { devices.append(device) }
saveDevices(devices)
}
public func deleteHost(_ id: String) {
saveHosts(HostsConfig.loadOrSeed().hosts.filter { $0.id != id })
public func deleteDevice(_ id: String) {
saveDevices(DevicesConfig.loadOrSeed().devices.filter { $0.id != id })
}
/// Re-seed the default set (plum VLC + black mpv-ipc with LAN+overlay endpoints).
public func resetHostsToDefault() { saveHosts(HostsConfig.seeded().hosts) }
/// Re-seed the default set (plum local VLC + black mpv playback & resources drive).
public func resetDevicesToDefault() { saveDevices(DevicesConfig.seeded().devices) }
/// True while the Player tab is on screen. Off-tab we still poll the active
/// target slowly so the HostSelector dots stay fresh and an armed sleep
@ -230,7 +230,7 @@ public final class PlayerController {
/// The active target's backend kind, so callers can build the right
/// LaunchRequest (black resolves by name; VLC needs a file path).
public var activeKind: HostKind? { active?.kind }
public var activeKind: ServiceKind? { active?.serviceKind }
/// THE single source of truth for where playback goes. Set by the shared
/// HostSelector (Player + Library use the same control); Library and Player

View file

@ -46,7 +46,7 @@ public struct PollResult: Sendable {
public protocol PlayerTarget: AnyObject {
var id: String { get }
var name: String { get }
var kind: HostKind { get }
var serviceKind: ServiceKind { get }
var detail: String { get } // human-readable endpoint, for the Hosts view
var volumeScale: Int { get } // max value for the volume slider (percent)

View file

@ -10,6 +10,13 @@ public struct ProcessResult: Sendable {
/// Minimal blocking command runner. Outputs here are tiny (a line of JSON), so
/// the read-to-EOF pattern is safe. Always call off the main thread.
public enum ProcessRunner {
/// Wrap a string in single quotes for safe interpolation into a `runShell`
/// command, escaping any embedded single quote. Use for paths/endpoints that
/// reach the shell.
public static func shellQuote(_ s: String) -> String {
"'" + s.replacingOccurrences(of: "'", with: "'\\''") + "'"
}
public static func run(_ launchPath: String, _ args: [String]) -> ProcessResult {
let p = Process()
p.executableURL = URL(fileURLWithPath: launchPath)

View file

@ -8,7 +8,7 @@ import Foundation
public final class QuickTimeTarget: PlayerTarget, MediaLaunchable {
public let id: String
public let name: String
public let kind: HostKind = .quicktime
public let serviceKind: ServiceKind = .quicktime
public let volumeScale = 100
public var detail: String { "QuickTime Player (local)" }

View file

@ -0,0 +1,59 @@
import Foundation
/// Push/pull the shared library + metadata assets against the configured
/// `resourcesDrive`, over rsync-on-ssh (the same transport the playback hosts use,
/// LAN endpoint first then overlay). Deliberately syncs only the small JSON state
/// + metadata sidecars the big regenerable caches (artwork posters, hover
/// previews, the live status cache) are excluded, since they're cheaper to rebuild
/// than to ship and would dwarf the useful payload.
public enum ResourcesService {
public enum Direction: Sendable { case push, pull } // localdrive, drivelocal
public struct Result: Sendable {
public let ok: Bool
public let message: String
public let endpoint: String?
}
/// Subpaths under the state root NOT worth syncing regenerable or transient.
private static let excludes = ["previews", "posters", "player-status.json"]
/// The first configured resources drive across all devices, if any.
public static func configured() -> ResourcesConn? {
DevicesConfig.loadOrSeed().devices.compactMap(\.resources).first
}
/// rsync the state tree to/from the drive's `assetsPath`, trying each endpoint
/// in order and returning on the first that succeeds. Non-destructive (no
/// `--delete`): a sync never removes files the other side is missing.
public static func syncAssets(_ direction: Direction) async -> Result {
guard let drive = configured(), !drive.endpoints.isEmpty else {
return Result(ok: false, message: "No resources drive configured", endpoint: nil)
}
let localRoot = StatePaths.root.path
let assets = drive.assetsPath
let arrow = direction == .push ? "" : ""
let excludeFlags = excludes.map { "--exclude=\(ProcessRunner.shellQuote($0))" }.joined(separator: " ")
return await Task.detached(priority: .utility) {
for ep in drive.endpoints {
let remote = "\(ep):\(assets)"
let cmd: String
switch direction {
case .push:
cmd = "ssh -o ConnectTimeout=6 \(ProcessRunner.shellQuote(ep)) mkdir -p \(ProcessRunner.shellQuote(assets)) && "
+ "rsync -az -e ssh \(excludeFlags) \(ProcessRunner.shellQuote(localRoot))/ \(ProcessRunner.shellQuote(remote))/"
case .pull:
cmd = "mkdir -p \(ProcessRunner.shellQuote(localRoot)) && "
+ "rsync -az -e 'ssh -o ConnectTimeout=6' \(excludeFlags) \(ProcessRunner.shellQuote(remote))/ \(ProcessRunner.shellQuote(localRoot))/"
}
let r = ProcessRunner.runShell(cmd, timeout: 120)
if r.ok {
return Result(ok: true, message: "Synced assets \(arrow) via \(ep)", endpoint: ep)
}
Log.error("asset sync \(arrow) via \(ep) failed (\(r.status)): \(r.stderr.trimmingCharacters(in: .whitespacesAndNewlines))")
}
return Result(ok: false, message: "Couldnt sync assets — all drive endpoints unreachable", endpoint: nil)
}.value
}
}

View file

@ -0,0 +1,18 @@
import Foundation
/// The single home for the app's local state directory. Everything under
/// `~/.local/state/tv-anarchy/` (library snapshot, metadata sidecars, settings)
/// resolves through here so the resources-drive asset sync has exactly one tree to
/// push/pull no store gets to invent its own path.
public enum StatePaths {
/// Root of the app's local state: `~/.local/state/tv-anarchy`.
public static var root: URL {
FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".local/state/tv-anarchy")
}
/// A path under the state root, e.g. `url("library.json")` or `url("meta")`.
public static func url(_ relative: String) -> URL {
root.appendingPathComponent(relative)
}
}

View file

@ -0,0 +1,98 @@
import Foundation
/// Build identity published alongside `TVAnarchy.app` on the resources drive. The
/// fields mirror `BuildStamp` exactly `commitCount` (monotonic git commit count)
/// is the version comparator; the rest are for display. Written by
/// `tools/publish.sh`, read by `UpdateService`.
public struct BuildManifest: Codable, Sendable, Equatable {
public var marketing: String
public var commitCount: Int
public var sha: String
public var buildTime: String
public init(marketing: String, commitCount: Int, sha: String, buildTime: String) {
self.marketing = marketing; self.commitCount = commitCount
self.sha = sha; self.buildTime = buildTime
}
}
/// Check the resources drive for a newer published build and pull it into
/// `~/Applications`. The drive (its `buildsPath`) holds `TVAnarchy.app` +
/// `manifest.json`; we compare the manifest's `commitCount` against this build's
/// `BuildStamp.commitCount` and rsync the bundle over if it's ahead.
public enum UpdateService {
public struct Status: Sendable {
public let available: Bool
public let localBuild: Int
public let remote: BuildManifest?
public let message: String
}
/// Pure comparison: is the published build ahead of what's running? Newer ==
/// strictly greater commit count, so re-publishing the same commit is a no-op.
public static func isNewer(remote: BuildManifest, localCommitCount: Int) -> Bool {
remote.commitCount > localCommitCount
}
private static var installDestination: URL {
FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Applications/TVAnarchy.app")
}
/// Fetch `manifest.json` from the drive and compare to the running build.
public static func check() async -> Status {
let local = BuildStamp.commitCount
guard let drive = ResourcesService.configured(), !drive.endpoints.isEmpty else {
return Status(available: false, localBuild: local, remote: nil,
message: "No resources drive configured")
}
let manifestPath = drive.buildsPath + "/manifest.json"
let remote: BuildManifest? = await Task.detached(priority: .utility) {
for ep in drive.endpoints {
let cmd = "ssh -o ConnectTimeout=6 \(ProcessRunner.shellQuote(ep)) cat \(ProcessRunner.shellQuote(manifestPath))"
let r = ProcessRunner.runShell(cmd, timeout: 20)
guard r.ok, let data = r.stdout.data(using: .utf8),
let m = try? JSONDecoder().decode(BuildManifest.self, from: data) else { continue }
return m
}
return nil
}.value
guard let remote else {
return Status(available: false, localBuild: local, remote: nil,
message: "Couldnt read the drives build manifest")
}
let newer = isNewer(remote: remote, localCommitCount: local)
let msg = newer
? "Update available: v\(remote.marketing) · \(remote.sha) (build \(remote.commitCount), youre on \(local))"
: "Up to date (build \(local))"
return Status(available: newer, localBuild: local, remote: remote, message: msg)
}
/// rsync the published `TVAnarchy.app` from the drive into `~/Applications`,
/// mirroring the bundle. The caller then prompts quit + relaunch (a running
/// native app can't be hot-swapped). Trailing slashes mirror bundle contents;
/// `--delete` removes files dropped between releases.
public static func install() async -> ResourcesService.Result {
guard let drive = ResourcesService.configured(), !drive.endpoints.isEmpty else {
return ResourcesService.Result(ok: false, message: "No resources drive configured", endpoint: nil)
}
let dest = installDestination.path
let appRemote = drive.buildsPath + "/TVAnarchy.app"
return await Task.detached(priority: .utility) {
for ep in drive.endpoints {
let cmd = "mkdir -p \(ProcessRunner.shellQuote(installDestination.deletingLastPathComponent().path)) && "
+ "rsync -a --delete -e 'ssh -o ConnectTimeout=6' "
+ "\(ProcessRunner.shellQuote("\(ep):\(appRemote)"))/ \(ProcessRunner.shellQuote(dest))/"
let r = ProcessRunner.runShell(cmd, timeout: 300)
if r.ok {
return ResourcesService.Result(ok: true,
message: "Updated → \(dest). Quit and relaunch TVAnarchy to pick it up.", endpoint: ep)
}
Log.error("update install via \(ep) failed (\(r.status)): \(r.stderr.trimmingCharacters(in: .whitespacesAndNewlines))")
}
return ResourcesService.Result(ok: false, message: "Couldnt pull the build — all drive endpoints unreachable", endpoint: nil)
}.value
}
}

View file

@ -7,7 +7,7 @@ import Foundation
public final class VLCTarget: PlayerTarget, MediaLaunchable, Enqueueable, TrackSelectable {
public let id: String
public let name: String
public let kind: HostKind = .vlc
public let serviceKind: ServiceKind = .vlc
public let volumeScale = 125
private let base: URL
private let password: String

View file

@ -0,0 +1,128 @@
import XCTest
@testable import TVAnarchyCore
/// The device schema must round-trip cleanly and migrate every legacy `hosts.json`
/// shape forward `DevicesConfig.loadOrSeed()` OVERWRITES devices.json on any
/// decode failure, so a botched decode would silently wipe a user's config.
final class DeviceDecodeTests: XCTestCase {
// MARK: tagged-enum round-trip
func testServiceTaggedEnumRoundTrips() throws {
let services: [Service] = [
.vlcPlayback(VLCConn(host: "127.0.0.1", port: 8080)),
.mpvPlayback(MpvConn(endpoints: ["lilith@10.9.0.4"]),
CommandsConfig.blackTVDefaults(bin: "/usr/local/bin/black-tv")),
.quicktimePlayback,
.resourcesDrive(ResourcesConn(endpoints: ["lilith@10.0.0.11"], basePath: "/bigdisk/_/tvanarchy")),
]
let data = try JSONEncoder().encode(services)
let back = try JSONDecoder().decode([Service].self, from: data)
XCTAssertEqual(back, services)
XCTAssertEqual(back.map(\.kind), [.vlc, .mpv, .quicktime, .resourcesDrive])
}
func testResourcesConnDefaultsDeriveFromBase() throws {
// Minimal drive block builds/assets paths default under basePath.
let json = #"{"type":"resourcesDrive","drive":{"endpoints":["lilith@10.9.0.4"],"basePath":"/srv/tva"}}"#
let svc = try JSONDecoder().decode(Service.self, from: Data(json.utf8))
guard case .resourcesDrive(let c) = svc else { return XCTFail("expected resourcesDrive") }
XCTAssertEqual(c.buildsPath, "/srv/tva/builds")
XCTAssertEqual(c.assetsPath, "/srv/tva/assets")
}
func testSeedRoundTrips() throws {
let seed = DevicesConfig.seeded()
let data = try JSONEncoder().encode(seed)
let back = try JSONDecoder().decode(DevicesConfig.self, from: data)
XCTAssertEqual(back.devices.map(\.id), seed.devices.map(\.id))
// black is preselected with mpv playback AND a resources drive.
let black = back.devices.first { $0.type == .black }
XCTAssertEqual(black?.services.map(\.kind), [.mpv, .resourcesDrive])
XCTAssertNotNil(black?.resources)
}
// MARK: legacy migration
func testMigratesLegacyHostsToDevices() throws {
// The pre-device schema (one host = one kind) must migrate forward, with
// black gaining a resources drive seeded from its endpoints.
let json = #"""
{"hosts":[
{"id":"plum-vlc","name":"Plum VLC","kind":"vlc","vlc":{"host":"127.0.0.1","port":8080}},
{"id":"black","name":"Black TV","kind":"mpv-ipc",
"mpv":{"endpoints":["lilith@10.0.0.11","lilith@10.9.0.4"]},
"commands":{"launchShow":["btv","play-show","{query}"],"stop":["btv","stop"]}}
]}
"""#
let cfg = try migrate(json)
XCTAssertEqual(cfg.devices.count, 2)
let plum = cfg.devices.first { $0.id == "plum-vlc" }
XCTAssertEqual(plum?.type, .plum)
XCTAssertEqual(plum?.services.map(\.kind), [.vlc])
let black = cfg.devices.first { $0.id == "black" }
XCTAssertEqual(black?.type, .black)
XCTAssertEqual(black?.services.map(\.kind), [.mpv, .resourcesDrive])
// The drive inherits the playback endpoints.
XCTAssertEqual(black?.resources?.endpoints, ["lilith@10.0.0.11", "lilith@10.9.0.4"])
}
func testMigratesLegacyBlacktvToMpv() throws {
// The oldest `blacktv` verb-script schema generic mpv playback.
let json = #"""
{"hosts":[
{"id":"black","name":"Black TV","kind":"blacktv",
"ssh":{"endpoints":["lilith@10.9.0.4"],"bin":"/usr/local/bin/black-tv"}}
]}
"""#
let cfg = try migrate(json)
let black = cfg.devices.first { $0.id == "black" }
XCTAssertEqual(black?.type, .black)
XCTAssertEqual(black?.playbackService?.kind, .mpv)
XCTAssertNotNil(black?.resources)
// The delegated commands are derived from the old `bin`.
guard case .mpvPlayback(_, let cmds)? = black?.playbackService else { return XCTFail("expected mpv") }
XCTAssertEqual(cmds?.launchResume, ["/usr/local/bin/black-tv", "resume-show", "{query}"])
}
@MainActor
func testMigratedMpvBuildsTarget() {
let dev = Device(id: "black", name: "Black", type: .black,
services: [.mpvPlayback(MpvConn(endpoints: ["lilith@10.9.0.4"]), nil),
.resourcesDrive(ResourcesConn(endpoints: ["lilith@10.9.0.4"], basePath: "/bigdisk/_/tvanarchy"))])
// The playback service builds an MpvTarget; the drive does not.
let mpv = PlayerController.makeTarget(device: dev, service: dev.services[0])
XCTAssertTrue(mpv is MpvTarget)
XCTAssertEqual(mpv?.serviceKind, .mpv)
XCTAssertNil(PlayerController.makeTarget(device: dev, service: dev.services[1]))
}
// MARK: update comparison
func testUpdateIsNewerByCommitCount() {
let remote = BuildManifest(marketing: "1.1.0", commitCount: 220, sha: "abc", buildTime: "t")
XCTAssertTrue(UpdateService.isNewer(remote: remote, localCommitCount: 219))
XCTAssertFalse(UpdateService.isNewer(remote: remote, localCommitCount: 220)) // same commit no-op
XCTAssertFalse(UpdateService.isNewer(remote: remote, localCommitCount: 221))
}
// MARK: helpers
/// Decode a legacy `hosts.json` string through the same path `loadOrSeed` uses,
/// by encoding a devices.json the migrator would produce. We exercise the
/// migration by writing the legacy JSON to a temp file the loader reads.
private func migrate(_ legacyJSON: String) throws -> DevicesConfig {
let dir = FileManager.default.temporaryDirectory
.appendingPathComponent("tva-test-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: dir) }
let url = dir.appendingPathComponent("hosts.json")
try Data(legacyJSON.utf8).write(to: url)
guard let cfg = DevicesConfig.migrateLegacy(url) else {
throw XCTSkip("migration returned nil")
}
return cfg
}
}

View file

@ -1,63 +0,0 @@
import XCTest
@testable import TVAnarchyCore
/// GAP-5 regression: the schema must stay strictly additive, because
/// `loadOrSeed()` OVERWRITES hosts.json on any decode failure. Old configs must
/// still decode; new ones decode with defaults; round-trips preserve fields.
final class HostConfigDecodeTests: XCTestCase {
func testLegacyConfigStillDecodes() throws {
let json = #"""
{"hosts":[
{"id":"plum-vlc","name":"Plum VLC","kind":"vlc","vlc":{"host":"127.0.0.1","port":8080}},
{"id":"black","name":"Black TV","kind":"blacktv",
"ssh":{"endpoints":["lilith@10.0.0.11","lilith@10.9.0.4"],"bin":"/usr/local/bin/black-tv"}}
]}
"""#
let cfg = try JSONDecoder().decode(HostsConfig.self, from: Data(json.utf8))
XCTAssertEqual(cfg.hosts.count, 2)
XCTAssertEqual(cfg.hosts[0].kind, .vlc)
XCTAssertEqual(cfg.hosts[1].kind, .blacktv)
XCTAssertEqual(cfg.hosts[1].ssh?.bin, "/usr/local/bin/black-tv")
}
func testMpvIPCConfigDecodesWithDefaults() throws {
// Minimal mpv block socket/sudo/socat/volumeScale should default.
let json = #"""
{"hosts":[
{"id":"black","name":"Black TV","kind":"mpv-ipc",
"mpv":{"endpoints":["lilith@10.9.0.4"]},
"commands":{"launchShow":["btv","play-show","{query}"],"stop":["btv","stop"]}}
]}
"""#
let cfg = try JSONDecoder().decode(HostsConfig.self, from: Data(json.utf8))
let h = cfg.hosts[0]
XCTAssertEqual(h.kind, .mpvIPC)
XCTAssertEqual(h.mpv?.socket, "/tmp/mpv.sock")
XCTAssertEqual(h.mpv?.sudo, true)
XCTAssertEqual(h.mpv?.socat, "socat")
XCTAssertEqual(h.mpv?.volumeScale, 130)
XCTAssertEqual(h.commands?.launchShow, ["btv", "play-show", "{query}"])
XCTAssertNil(h.commands?.releases) // unspecified capability nil
}
@MainActor
func testLegacyBlacktvMigratesToMpvTarget() {
// An old `blacktv` host must still build a working target the generic
// MpvTarget, with delegated commands derived from the old `bin`.
let h = HostConfig(id: "black", name: "Black", kind: .blacktv,
ssh: SSHConn(endpoints: ["lilith@10.9.0.4"], bin: "/usr/local/bin/black-tv"))
let t = PlayerController.makeTarget(h)
XCTAssertNotNil(t)
XCTAssertEqual(t?.kind, .mpvIPC)
XCTAssertTrue(t is MpvTarget)
}
func testSeedRoundTrips() throws {
let seed = HostsConfig.seeded()
let data = try JSONEncoder().encode(seed)
let back = try JSONDecoder().decode(HostsConfig.self, from: data)
XCTAssertEqual(back.hosts.map(\.kind), seed.hosts.map(\.kind))
XCTAssertEqual(back.hosts.first(where: { $0.kind == .mpvIPC })?.commands?.launchResume,
["/usr/local/bin/black-tv", "resume-show", "{query}"])
}
}

View file

@ -184,10 +184,10 @@ final class LibraryScannerTests: XCTestCase {
let show = CachedShow(name: "Psych", rootDir: "/m/Psych", episodes: [ep])
// black is library-aware: resolve by name + S/E
XCTAssertEqual(lib.launchRequest(show: show, episode: ep, targetKind: .blacktv),
XCTAssertEqual(lib.launchRequest(show: show, episode: ep, targetKind: .mpv),
.show(name: "Psych", season: 1, episode: 4))
// black with no episode resume the show
XCTAssertEqual(lib.launchRequest(show: show, episode: nil, targetKind: .blacktv),
XCTAssertEqual(lib.launchRequest(show: show, episode: nil, targetKind: .mpv),
.resume(name: "Psych"))
// VLC needs a concrete file path
XCTAssertEqual(lib.launchRequest(show: show, episode: ep, targetKind: .vlc),

View file

@ -6,6 +6,9 @@
set -euo pipefail
cd "$(dirname "$0")"
PUBLISH=0
[ "${1:-}" = "--publish" ] && PUBLISH=1 # after install, push app+manifest to the resources drive
DD="build/dd"
APP="$DD/Build/Products/Release/TVAnarchy.app"
DEST="$HOME/Applications/TVAnarchy.app"
@ -34,3 +37,8 @@ STAMP=$(sed -n 's/.*buildTime = "\(.*\)"/\1/p' Sources/TVAnarchyCore/BuildStamp.
echo "✓ installed → $DEST"
echo " v$VER (build $BUILD) · $SHA · $STAMP"
echo " quit any running TVAnarchy and relaunch to pick this up."
if [ "$PUBLISH" = "1" ]; then
echo "→ publish to resources drive"
tools/publish.sh "$DEST"
fi

View file

@ -7,56 +7,82 @@ and write these), and the **planned fleet/mesh data model** (designed, unbuilt).
## In use today
### `hosts.json` — playback target config
### `devices.json` — device + service config
Path: `~/.config/tv-anarchy/hosts.json` (auto-migrated forward from the pre-rename
`~/.config/plumtv/hosts.json`). Source: `Sources/TVAnarchyCore/HostConfig.swift`.
Written pretty-printed + sorted-keys; seeded on first run with Plum VLC + Black
(mpv) if absent.
Path: `~/.config/tv-anarchy/devices.json`. Source:
`Sources/TVAnarchyCore/Device.swift`. Written pretty-printed + sorted-keys; seeded
on first run with **plum** (local VLC) + **black** (mpv playback + resources drive)
if absent. Auto-migrated forward from the pre-device `~/.config/tv-anarchy/hosts.json`
(and the pre-rename `~/.config/plumtv/hosts.json`) — each old host becomes a device
with one playback service; a `black` host also gains a `resourcesDrive` service.
A **device** exposes one or more typed **services**. Picking a device **type**
(`plum` | `black` | `generic`) in the editor preselects that type's default
services. Each playback service becomes a `PlayerTarget` (the playback service
shares the device's `id`, so its live status keys on the device); the
`resourcesDrive` service feeds publish/update + asset sync — it is **not** a
playback target.
```jsonc
{
"hosts": [
"devices": [
{
"id": "plum-vlc",
"name": "Plum VLC",
"kind": "vlc", // vlc | mpv-ipc | quicktime | blacktv(legacy)
"vlc": { "host": "127.0.0.1", "port": 8080 }
"id": "plum",
"name": "Plum",
"type": "plum",
"services": [
{ "type": "vlc", "vlc": { "host": "127.0.0.1", "port": 8080 } }
]
},
{
"id": "black",
"name": "Black TV",
"kind": "mpv-ipc",
"mpv": {
"endpoints": ["lilith@10.0.0.11", "lilith@10.9.0.4"], // LAN first, WG overlay fallback
"socket": "/tmp/mpv.sock", // default
"sudo": true, // root-owned socket → sudo socat
"socat": "socat", // default
"volumeScale": 130 // mpv --volume-max
},
"commands": { // argv templates for what IPC can't do
"launchShow": ["/usr/local/bin/black-tv","play-show","{query}","{season?}","{episode?}"],
"launchResume": ["/usr/local/bin/black-tv","resume-show","{query}"],
"launchFile": ["/usr/local/bin/black-tv","play","{path}"],
"releases": ["/usr/local/bin/black-tv","releases"],
"resolveRelease": ["/usr/local/bin/black-tv","resolve-release","{releaseId}"],
"stats": ["/usr/local/bin/black-tv","stats"],
"stop": ["/usr/local/bin/black-tv","stop"]
}
"type": "black",
"services": [
{
"type": "mpv",
"mpv": {
"endpoints": ["lilith@10.0.0.11", "lilith@10.9.0.4"], // LAN first, WG overlay fallback
"socket": "/tmp/mpv.sock", // default
"sudo": true, // root-owned socket → sudo socat
"socat": "socat", // default
"volumeScale": 130 // mpv --volume-max
},
"commands": { // argv templates for what IPC can't do
"launchShow": ["/usr/local/bin/black-tv","play-show","{query}","{season?}","{episode?}"],
"launchResume": ["/usr/local/bin/black-tv","resume-show","{query}"],
"stats": ["/usr/local/bin/black-tv","stats"],
"stop": ["/usr/local/bin/black-tv","stop"]
}
},
{
"type": "resourcesDrive",
"drive": {
"endpoints": ["lilith@10.0.0.11", "lilith@10.9.0.4"],
"basePath": "/bigdisk/_/tvanarchy",
"buildsPath": "/bigdisk/_/tvanarchy/builds", // optional; defaults to <base>/builds
"assetsPath": "/bigdisk/_/tvanarchy/assets" // optional; defaults to <base>/assets
}
}
]
}
]
}
```
Notes:
- `kind`: `vlc`, `mpv-ipc`, `quicktime`. `blacktv` is legacy and auto-migrated to
`mpv-ipc` at load; only `mpv-ipc`/`vlc`/`quicktime` are offered in the editor.
- Service `type`: `vlc`, `mpv`, `quicktime`, `resourcesDrive` (a tagged object —
the `type` field selects the payload key: `vlc`/`mpv`/`drive`). The legacy
`blacktv` host kind migrates to an `mpv` service.
- Command-template tokens: `{query}`, `{season?}`, `{episode?}`, `{path}`,
`{releaseId}` (a `nil` template = capability absent).
- VLC password is **not** stored here — it's resolved at runtime from the
governor's config or `$VLC_HTTP_PASSWORD` (see `VLCConfig`).
- `MpvConn` decodes from a minimal `{ "endpoints": [...] }`; every other field
has a default.
- `MpvConn` decodes from a minimal `{ "endpoints": [...] }`; `ResourcesConn` from
`{ "endpoints": [...], "basePath": "…" }` — every other field has a default.
- `resourcesDrive`: `buildsPath` holds the published `TVAnarchy.app` +
`manifest.json`; `assetsPath` holds the synced library/metadata state. See
[operations.md](./operations.md#updates--shared-resources-drive).
### `config.json` — governor (`portable-net-tv`)

View file

@ -17,6 +17,14 @@ irreducible manual step is **quit + relaunch** — a running native app can't be
hot-swapped. The sidebar build stamp (`v<ver> · <sha> · <time>`) makes a stale
copy obvious.
Add `--publish` to also push the built app + a version manifest to the resources
drive for other machines to pull (see
[Updates & the shared resources drive](#updates--shared-resources-drive)):
```sh
./build-install.sh --publish
```
### Build manually / in Xcode
```sh
@ -34,11 +42,11 @@ localhost+overlay networking).
| File | Purpose |
|---|---|
| `~/.config/tv-anarchy/hosts.json` | Playback targets (migrates from `~/.config/plumtv/hosts.json`) |
| `~/.config/tv-anarchy/devices.json` | Devices + services (migrates from `hosts.json`, then `~/.config/plumtv/hosts.json`) |
| `~/.config/portable-net-tv/config.json` | Governor config **and** the VLC HTTP password source |
| `~/.local/state/plum-control-mcp/watched.jsonl` | Shared watch log |
| `~/.local/state/tv-anarchy/library.json` | Cached library snapshot |
| `~/.local/state/tv-anarchy/meta/` | Metadata sidecars (path-digest keyed) |
| `~/.local/state/tv-anarchy/` | App state root (`StatePaths`): `library.json`, `meta/`, `settings.json`, … — the asset-sync tree |
| `~/.local/state/tv-anarchy/installed-build.json` | Marker `tools/update.sh` writes to track the installed build |
| `$VLC_HTTP_PASSWORD` | Alternate VLC password source |
## Playback targets
@ -52,8 +60,47 @@ localhost+overlay networking).
(`10.9.0.4`) because the LAN address flaps.
- **QuickTime** — local, zero-install; no setup.
Hosts are editable in-app (Hosts tab: add/edit/delete, make-active, reload, reset,
reveal `hosts.json`).
Devices are editable in-app (**Devices** tab: add/edit/delete, make-active, reload,
reset, reveal `devices.json`). Each device exposes one or more typed services;
picking a device type (plum / black / generic) preselects its default services.
Playback targets are the `vlc`/`mpv`/`quicktime` services; the `resourcesDrive`
service is not a playback target (see below).
## Updates & the shared resources drive
A device can expose a **`resourcesDrive`** service — a shared drive (reached over
rsync/ssh, LAN endpoint first then overlay) that holds published app builds and
synced library/metadata assets. It is configured per device in the Devices tab
(`basePath`, endpoints, optional `buildsPath` / `assetsPath`).
**Publishing a build** (from a machine with the toolchain):
```sh
./build-install.sh --publish # build + install, then push to the drive
# or, against an already-installed app:
tools/publish.sh # rsyncs ~/Applications/TVAnarchy.app + manifest.json
```
`tools/publish.sh` writes `manifest.json` from the compiled `BuildStamp`
(`{marketing, commitCount, sha, buildTime}`; `commitCount` is the version
comparator) and rsyncs the app + manifest to the drive's `buildsPath`.
**Updating from the drive** (any machine, no Xcode toolchain needed):
```sh
tools/update.sh # compare, pull if the drive is ahead, then relaunch
```
It compares the drive's `manifest.json` against the local marker
(`~/.local/state/tv-anarchy/installed-build.json`, build `-1` on first run), rsyncs
`TVAnarchy.app` into `~/Applications` when the drive is newer, and records the new
build. The in-app **Setup → Resources drive** does the same against the *running*
build's `BuildStamp` (`UpdateService`), plus **Check for updates** / **Update now**.
**Asset sync** — Setup → Resources drive → **Sync assets ↑/↓** rsyncs the
`StatePaths` tree (`library.json`, `meta/`, `settings.json`, …) to/from the drive's
`assetsPath`. Non-destructive (no `--delete`); the big regenerable caches (artwork
posters, hover previews, the live status cache) are excluded.
## governor (`portable-net-tv`)

65
tools/publish.sh Executable file
View file

@ -0,0 +1,65 @@
#!/usr/bin/env bash
# Publish the installed TVAnarchy.app + a build manifest to the resources drive
# configured in ~/.config/tv-anarchy/devices.json, so other machines can update
# without a full Xcode toolchain. The manifest mirrors the in-app BuildStamp
# (commitCount is the version comparator); clients compare it in tools/update.sh
# or the in-app "Check for updates" (UpdateService).
#
# Run standalone after a build, or via `build-install.sh --publish`.
set -euo pipefail
cd "$(dirname "$0")/.."
APP="${1:-$HOME/Applications/TVAnarchy.app}"
STAMP="Sources/TVAnarchyCore/BuildStamp.swift"
DEVICES="$HOME/.config/tv-anarchy/devices.json"
[ -d "$APP" ] || { echo "✗ no app at $APP — build/install first" >&2; exit 1; }
[ -f "$STAMP" ] || { echo "✗ no BuildStamp.swift — run tools/stamp-build.sh" >&2; exit 1; }
[ -f "$DEVICES" ] || { echo "✗ no devices.json — configure a resources drive first" >&2; exit 1; }
# Build identity (compiled constant is the source of truth — same as the sidebar stamp).
SHA=$(sed -n 's/.*sha = "\(.*\)"/\1/p' "$STAMP")
COUNT=$(sed -n 's/.*commitCount = \(.*\)/\1/p' "$STAMP")
TIME=$(sed -n 's/.*buildTime = "\(.*\)"/\1/p' "$STAMP")
VER=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$APP/Contents/Info.plist")
MANIFEST=$(mktemp -t tvanarchy-manifest)
trap 'rm -f "$MANIFEST"' EXIT
cat > "$MANIFEST" <<EOF
{"marketing":"$VER","commitCount":$COUNT,"sha":"$SHA","buildTime":"$TIME"}
EOF
# Pull the drive's builds path (line 1) + endpoints (line 2, try-order) out of
# devices.json. Captured via $(...) then parsed line-by-line — NOT a nested
# heredoc inside process substitution, which mis-parses under `set -u`.
DRIVE_INFO=$(/usr/bin/python3 - "$DEVICES" <<'PY'
import json, sys
cfg = json.load(open(sys.argv[1]))
for d in cfg.get("devices", []):
for s in d.get("services", []):
if s.get("type") == "resourcesDrive":
dr = s.get("drive", {}) # the tagged payload lives under "drive"
base = dr.get("basePath", "")
print(dr.get("buildsPath") or (base + "/builds"))
print(" ".join(dr.get("endpoints", [])))
sys.exit(0)
sys.exit(1)
PY
) || { echo "✗ no resourcesDrive service in devices.json" >&2; exit 1; }
BUILDS_PATH=$(printf '%s\n' "$DRIVE_INFO" | sed -n 1p)
ENDPOINTS=$(printf '%s\n' "$DRIVE_INFO" | sed -n 2p)
[ -n "$ENDPOINTS" ] || { echo "✗ resourcesDrive has no endpoints" >&2; exit 1; }
echo "→ publishing v$VER (build $COUNT · $SHA) to $BUILDS_PATH"
for EP in $ENDPOINTS; do
echo " trying ${EP}"
if ssh -o ConnectTimeout=6 "$EP" "mkdir -p '$BUILDS_PATH'" 2>/dev/null \
&& rsync -a --delete -e ssh "$APP/" "$EP:$BUILDS_PATH/TVAnarchy.app/" \
&& rsync -a -e ssh "$MANIFEST" "$EP:$BUILDS_PATH/manifest.json"; then
echo "✓ published via $EP$EP:$BUILDS_PATH"
exit 0
fi
echo "$EP failed, trying next…" >&2
done
echo "✗ publish failed — all drive endpoints unreachable" >&2
exit 1

64
tools/update.sh Executable file
View file

@ -0,0 +1,64 @@
#!/usr/bin/env bash
# Update TVAnarchy.app from the resources drive — for machines WITHOUT an Xcode
# toolchain (no build-install.sh). Compares the drive's published build manifest
# against a locally-recorded marker; if the drive is ahead, rsyncs the .app into
# ~/Applications and records the new build. Quit + relaunch to pick it up (a
# running native app can't be hot-swapped).
#
# The in-app "Check for updates" (UpdateService) does the same against the running
# build's compiled BuildStamp; this script is the standalone equivalent.
set -euo pipefail
DEST="$HOME/Applications/TVAnarchy.app"
DEVICES="$HOME/.config/tv-anarchy/devices.json"
MARKER="$HOME/.local/state/tv-anarchy/installed-build.json"
[ -f "$DEVICES" ] || { echo "✗ no devices.json — configure a resources drive first" >&2; exit 1; }
# Builds path (line 1) + endpoints (line 2). Captured via $(...) then parsed —
# NOT a nested heredoc inside process substitution (mis-parses under `set -u`).
DRIVE_INFO=$(/usr/bin/python3 - "$DEVICES" <<'PY'
import json, sys
cfg = json.load(open(sys.argv[1]))
for d in cfg.get("devices", []):
for s in d.get("services", []):
if s.get("type") == "resourcesDrive":
dr = s.get("drive", {}) # the tagged payload lives under "drive"
base = dr.get("basePath", "")
print(dr.get("buildsPath") or (base + "/builds"))
print(" ".join(dr.get("endpoints", [])))
sys.exit(0)
sys.exit(1)
PY
) || { echo "✗ no resourcesDrive service in devices.json" >&2; exit 1; }
BUILDS_PATH=$(printf '%s\n' "$DRIVE_INFO" | sed -n 1p)
ENDPOINTS=$(printf '%s\n' "$DRIVE_INFO" | sed -n 2p)
[ -n "$ENDPOINTS" ] || { echo "✗ resourcesDrive has no endpoints" >&2; exit 1; }
# Locally-recorded build (commitCount), -1 if never updated via this script.
LOCAL=-1
[ -f "$MARKER" ] && LOCAL=$(/usr/bin/python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("commitCount",-1))' "$MARKER" 2>/dev/null || echo -1)
# Fetch the remote manifest from the first reachable endpoint.
REMOTE_JSON=""; USED_EP=""
for EP in $ENDPOINTS; do
if REMOTE_JSON=$(ssh -o ConnectTimeout=6 "$EP" "cat '$BUILDS_PATH/manifest.json'" 2>/dev/null); then
USED_EP="$EP"; break
fi
done
[ -n "$USED_EP" ] || { echo "✗ couldn't read manifest — all drive endpoints unreachable" >&2; exit 1; }
REMOTE=$(printf '%s' "$REMOTE_JSON" | /usr/bin/python3 -c 'import json,sys; print(json.load(sys.stdin)["commitCount"])')
REMOTE_VER=$(printf '%s' "$REMOTE_JSON" | /usr/bin/python3 -c 'import json,sys; m=json.load(sys.stdin); print(m["marketing"], m["sha"])')
if [ "$REMOTE" -le "$LOCAL" ]; then
echo "✓ up to date (build $LOCAL; drive has $REMOTE)"
exit 0
fi
echo "→ updating to v$REMOTE_VER (build $REMOTE, you have $LOCAL) from $USED_EP"
mkdir -p "$(dirname "$DEST")" "$(dirname "$MARKER")"
rsync -a --delete -e ssh "$USED_EP:$BUILDS_PATH/TVAnarchy.app/" "$DEST/"
printf '%s' "$REMOTE_JSON" > "$MARKER"
echo "✓ installed → $DEST"
echo " quit any running TVAnarchy and relaunch to pick this up."