Compare commits
1 commit
main
...
worktree-b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9bdd4cbca |
30 changed files with 1413 additions and 581 deletions
19
README.md
19
README.md
|
|
@ -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)).
|
||||
|
|
|
|||
195
Sources/TVAnarchy/DeviceEditView.swift
Normal file
195
Sources/TVAnarchy/DeviceEditView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
109
Sources/TVAnarchy/DevicesView.swift
Normal file
109
Sources/TVAnarchy/DevicesView.swift
Normal 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" }
|
||||
}
|
||||
}
|
||||
|
|
@ -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: "-")
|
||||
}
|
||||
}
|
||||
|
|
@ -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" }
|
||||
}
|
||||
}
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
396
Sources/TVAnarchyCore/Device.swift
Normal file
396
Sources/TVAnarchyCore/Device.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)" }
|
||||
|
||||
|
|
|
|||
59
Sources/TVAnarchyCore/ResourcesService.swift
Normal file
59
Sources/TVAnarchyCore/ResourcesService.swift
Normal 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 } // ↑ local→drive, ↓ drive→local
|
||||
|
||||
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: "Couldn’t sync assets — all drive endpoints unreachable", endpoint: nil)
|
||||
}.value
|
||||
}
|
||||
}
|
||||
18
Sources/TVAnarchyCore/StatePaths.swift
Normal file
18
Sources/TVAnarchyCore/StatePaths.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
98
Sources/TVAnarchyCore/UpdateService.swift
Normal file
98
Sources/TVAnarchyCore/UpdateService.swift
Normal 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: "Couldn’t read the drive’s build manifest")
|
||||
}
|
||||
let newer = isNewer(remote: remote, localCommitCount: local)
|
||||
let msg = newer
|
||||
? "Update available: v\(remote.marketing) · \(remote.sha) (build \(remote.commitCount), you’re 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: "Couldn’t pull the build — all drive endpoints unreachable", endpoint: nil)
|
||||
}.value
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
128
Tests/TVAnarchyCoreTests/DeviceDecodeTests.swift
Normal file
128
Tests/TVAnarchyCoreTests/DeviceDecodeTests.swift
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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}"])
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
65
tools/publish.sh
Executable 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
64
tools/update.sh
Executable 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."
|
||||
Loading…
Add table
Reference in a new issue