Reine Umbenennung, kein Verhalten geändert — bewusst als eigener Commit, damit „umbenannt" und „umgebaut" im Nachhinein auseinanderzuhalten sind. 712 mal `Flixy`, 778 mal `flixy`, 7 mal `FLIXY` in 169 Dateien, dazu 25 Pfade. Ziele: `Halo`, `HaloTV`, `HaloTopShelf`, `HaloUITests`, `HaloKit` mit `HaloDesign`, `HaloDownloads`, `HaloPlayback` und `HaloProbe`. `MediaKit`, `JellyfinKit` und `DownloadWidget` tragen den Namen nicht und bleiben. `docs/superpowers/specs/` ist ausgenommen. Datierte Entwurfsdokumente sind ein Protokoll getroffener Entscheidungen; sie nachträglich umzuschreiben hiesse, die Vergangenheit zu glätten. **Die Bundle-ID wandert mit** — ausdrücklich so gewollt, mit bekanntem Preis: `com.scarriffleservices.halo` ist für iOS eine *andere* App. Die installierte Fassung bleibt daneben stehen, mit Downloads und Anmeldung in ihrem Container; Halo startet leer. Einmal neu anmelden, heruntergeladene Folgen einmal neu laden. Dasselbe gilt für die App-Gruppe, die sich TV-App und Top Shelf teilen. **Eine Folge davon, die beim Entscheiden noch nicht auf dem Tisch lag:** der Aufräumschritt aus dem vorigen Commit kommt an die alten Plex-Token nicht mehr heran. Der Schlüsselbund grenzt nach Team und Bundle-Kennung ab — was unter `…flixy` liegt, ist für `…halo` unerreichbar. Erledigt wird es trotzdem, nur anders: **die alte App löschen.** iOS räumt deren Schlüsselbund-Einträge beim Deinstallieren mit ab. Der Code bleibt stehen, weil er für jeden Stand vor dieser Umbenennung richtig ist und im Jahr einen Startvergleich kostet. Die Projektdatei ist aus `project.yml` neu erzeugt, der alte Ordner gelöscht statt umbenannt — sie steht ohnehin nicht im Repo. Das Verzeichnis des Repos selbst heißt weiter `Flixy`; das umzubenennen ist ein Handgriff ausserhalb des Projekts. 368 Tests grün. iOS und tvOS bauen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
375 lines
13 KiB
Swift
375 lines
13 KiB
Swift
import Foundation
|
|
|
|
// MARK: - Was ein Sender ist
|
|
|
|
/// Der Griff, mit dem eine Implementierung ihren Sender wiederfindet.
|
|
///
|
|
/// Undurchsichtig mit Absicht. Bei Plex stecken darin die Schlüssel, die der
|
|
/// Suchlauf gefunden hat — Rasterschlüssel, Sendernummer am Empfänger, der
|
|
/// Empfänger selbst. Bei Jellyfin genügt die Kennung, und er bleibt leer.
|
|
///
|
|
/// Er wird **weitergereicht, nicht gelesen**. Wer einen Sender anzeigt, geht
|
|
/// sein Inhalt nichts an; wer ihn einschaltet, hat ihn selbst gefüllt.
|
|
///
|
|
/// Der Umweg ist nötig, weil beide Backends Werttypen sind und sich zwischen
|
|
/// zwei Aufrufen nichts merken können. Ohne ihn müsste das Einschalten die
|
|
/// Senderliste neu laden — bei Plex hieße das, den ganzen Suchlauf noch
|
|
/// einmal zu gehen.
|
|
public struct LiveTuningHandle: Sendable, Equatable, Hashable {
|
|
private let fields: [String: String]
|
|
|
|
public init(_ fields: [String: String] = [:]) {
|
|
self.fields = fields
|
|
}
|
|
|
|
public subscript(_ key: String) -> String? { fields[key] }
|
|
|
|
public var isEmpty: Bool { fields.isEmpty }
|
|
}
|
|
|
|
/// Ein Fernsehsender.
|
|
public struct LiveChannel: Sendable, Equatable, Identifiable {
|
|
public let id: String
|
|
/// Die Nummer, unter der er im Programm steht — „3" oder „11.1".
|
|
///
|
|
/// Optional, weil nicht jeder Server sie führt. Steht sie, gehört sie vor
|
|
/// den Namen: danach sucht man.
|
|
public let number: String?
|
|
public let title: String
|
|
public let artworkPath: String?
|
|
/// Was gerade läuft, sofern der Server es gleich mitliefert.
|
|
///
|
|
/// Jellyfin tut das auf Wunsch (`addCurrentProgram`), und dann spart es
|
|
/// einen zweiten Aufruf für die Senderliste.
|
|
public let currentProgram: LiveProgram?
|
|
public let tuning: LiveTuningHandle
|
|
|
|
public init(
|
|
id: String,
|
|
number: String? = nil,
|
|
title: String,
|
|
artworkPath: String? = nil,
|
|
currentProgram: LiveProgram? = nil,
|
|
tuning: LiveTuningHandle = LiveTuningHandle()
|
|
) {
|
|
self.id = id
|
|
self.number = number
|
|
self.title = title
|
|
self.artworkPath = artworkPath
|
|
self.currentProgram = currentProgram
|
|
self.tuning = tuning
|
|
}
|
|
}
|
|
|
|
// MARK: - Was eine Sendung ist
|
|
|
|
/// Eine Sendung im Programm.
|
|
public struct LiveProgram: Sendable, Equatable, Identifiable {
|
|
public let id: String
|
|
public let channelID: String
|
|
public let title: String
|
|
/// Der Folgentitel, wenn es einer ist.
|
|
public let subtitle: String?
|
|
public let summary: String?
|
|
public let startsAt: Date
|
|
public let endsAt: Date
|
|
public let artworkPath: String?
|
|
/// Ob sie zu einem laufenden Serien-Timer gehört.
|
|
///
|
|
/// Steht sie, ist die Sendung bereits zur Aufnahme vorgemerkt, und die
|
|
/// Oberfläche soll „Aufnehmen" nicht noch einmal anbieten.
|
|
public let seriesTimerID: String?
|
|
public let kind: Kind
|
|
|
|
/// Wovon eine Sendung handelt — soweit der Server es sagt.
|
|
///
|
|
/// Als Menge und nicht als einzelner Wert: eine Sportübertragung kann
|
|
/// zugleich eine Live-Sendung sein, und Jellyfin meldet beide Kennzeichen
|
|
/// nebeneinander.
|
|
public struct Kind: OptionSet, Sendable, Equatable, Hashable {
|
|
public let rawValue: Int
|
|
public init(rawValue: Int) { self.rawValue = rawValue }
|
|
|
|
public static let movie = Kind(rawValue: 1 << 0)
|
|
public static let series = Kind(rawValue: 1 << 1)
|
|
public static let news = Kind(rawValue: 1 << 2)
|
|
public static let sports = Kind(rawValue: 1 << 3)
|
|
public static let kids = Kind(rawValue: 1 << 4)
|
|
public static let live = Kind(rawValue: 1 << 5)
|
|
}
|
|
|
|
public init(
|
|
id: String,
|
|
channelID: String,
|
|
title: String,
|
|
subtitle: String? = nil,
|
|
summary: String? = nil,
|
|
startsAt: Date,
|
|
endsAt: Date,
|
|
artworkPath: String? = nil,
|
|
seriesTimerID: String? = nil,
|
|
kind: Kind = []
|
|
) {
|
|
self.id = id
|
|
self.channelID = channelID
|
|
self.title = title
|
|
self.subtitle = subtitle
|
|
self.summary = summary
|
|
self.startsAt = startsAt
|
|
self.endsAt = endsAt
|
|
self.artworkPath = artworkPath
|
|
self.seriesTimerID = seriesTimerID
|
|
self.kind = kind
|
|
}
|
|
|
|
/// Ob sie zu diesem Zeitpunkt läuft.
|
|
public func isRunning(at moment: Date = .now) -> Bool {
|
|
startsAt <= moment && endsAt > moment
|
|
}
|
|
|
|
/// Wie weit sie fortgeschritten ist, 0…1.
|
|
///
|
|
/// Gerechnet statt gemeldet: beide Server nennen Anfang und Ende, keiner
|
|
/// nennt den Fortschritt. Beim Umschalten ist er die nützlichste Angabe
|
|
/// überhaupt — er sagt, ob man noch einsteigt oder nur noch den Abspann
|
|
/// sieht.
|
|
public func progress(at moment: Date = .now) -> Double {
|
|
let total = endsAt.timeIntervalSince(startsAt)
|
|
guard total > 0 else { return 0 }
|
|
return min(max(moment.timeIntervalSince(startsAt) / total, 0), 1)
|
|
}
|
|
}
|
|
|
|
// MARK: - Ob es überhaupt etwas zu sehen gibt
|
|
|
|
/// Was beim Live-TV herauskommen kann.
|
|
public enum LiveTVAvailability: Sendable, Equatable {
|
|
/// Es gibt einen Empfänger und Sender.
|
|
///
|
|
/// Ohne Angabe, **welchen** Empfänger: das ist Sache der Implementierung.
|
|
/// Vorher stand hier die Liste der Plex-Empfänger, und damit trug die
|
|
/// neutrale Form einen fremden Typ durch die halbe App.
|
|
case available
|
|
/// Der Server läuft, hat aber keinen Empfänger eingerichtet.
|
|
///
|
|
/// Der mit Abstand häufigste Fall — Live-TV verlangt eine Fernsehkarte
|
|
/// oder einen Netzwerk-Tuner. Die Oberfläche soll das erklären, statt
|
|
/// einen Fehler zu zeigen.
|
|
case notConfigured
|
|
case failed(message: String)
|
|
}
|
|
|
|
/// Eine abgeklopfte Adresse mit ihrer Antwort.
|
|
///
|
|
/// Nur Plex füllt das: dort wird die Senderliste gesucht, weil ihre Adresse
|
|
/// sich zwischen Serverfassungen unterscheidet. Jellyfin hat einen festen
|
|
/// Endpunkt und liefert nichts — die Oberfläche zeigt den Block dann gar
|
|
/// nicht erst.
|
|
public struct LiveChannelProbe: Sendable, Equatable, Identifiable {
|
|
public let path: String
|
|
public let result: String
|
|
|
|
public var id: String { path }
|
|
|
|
public init(path: String, result: String) {
|
|
self.path = path
|
|
self.result = result
|
|
}
|
|
}
|
|
|
|
/// Die Senderliste samt dem, was ihr Zustandekommen erklärt.
|
|
public struct LiveChannelListing: Sendable, Equatable {
|
|
public let channels: [LiveChannel]
|
|
/// Woher sie kam. `nil`, wo es nichts zu erklären gibt.
|
|
public let source: String?
|
|
/// Jeder versuchte Weg. Leer, wo nicht gesucht werden musste.
|
|
public let probes: [LiveChannelProbe]
|
|
|
|
public init(channels: [LiveChannel], source: String? = nil, probes: [LiveChannelProbe] = []) {
|
|
self.channels = channels
|
|
self.source = source
|
|
self.probes = probes
|
|
}
|
|
}
|
|
|
|
// MARK: - Einschalten
|
|
|
|
/// Was beim Einschalten herauskam.
|
|
public struct LiveTuneResult: Sendable {
|
|
public let url: URL?
|
|
public let sessionID: String
|
|
/// Womit der Empfänger wieder freigegeben wird.
|
|
///
|
|
/// Ohne dieses Signal bleibt ein Tuner belegt, bis der Server von selbst
|
|
/// merkt, dass niemand zusieht — und vier Stück gibt es davon.
|
|
public let subscriptionKey: String?
|
|
/// Jeder versuchte Weg mit seiner Antwort.
|
|
public let trace: [LiveChannelProbe]
|
|
|
|
public init(
|
|
url: URL?,
|
|
sessionID: String,
|
|
subscriptionKey: String? = nil,
|
|
trace: [LiveChannelProbe] = []
|
|
) {
|
|
self.url = url
|
|
self.sessionID = sessionID
|
|
self.subscriptionKey = subscriptionKey
|
|
self.trace = trace
|
|
}
|
|
}
|
|
|
|
// MARK: - Aufnehmen
|
|
|
|
/// Eine fertige Aufnahme auf dem Server.
|
|
public struct LiveRecording: Sendable, Equatable, Identifiable {
|
|
public let id: String
|
|
public let title: String
|
|
public let channelName: String?
|
|
public let summary: String?
|
|
public let startsAt: Date?
|
|
public let endsAt: Date?
|
|
public let artworkPath: String?
|
|
/// Ob gerade noch aufgenommen wird.
|
|
public let isInProgress: Bool
|
|
|
|
public init(
|
|
id: String,
|
|
title: String,
|
|
channelName: String? = nil,
|
|
summary: String? = nil,
|
|
startsAt: Date? = nil,
|
|
endsAt: Date? = nil,
|
|
artworkPath: String? = nil,
|
|
isInProgress: Bool = false
|
|
) {
|
|
self.id = id
|
|
self.title = title
|
|
self.channelName = channelName
|
|
self.summary = summary
|
|
self.startsAt = startsAt
|
|
self.endsAt = endsAt
|
|
self.artworkPath = artworkPath
|
|
self.isInProgress = isInProgress
|
|
}
|
|
}
|
|
|
|
/// Eine vorgemerkte Aufnahme.
|
|
public struct LiveTimer: Sendable, Equatable, Identifiable {
|
|
public let id: String
|
|
public let programID: String?
|
|
public let channelID: String?
|
|
public let channelName: String?
|
|
public let title: String
|
|
public let startsAt: Date?
|
|
public let endsAt: Date?
|
|
/// Ob sie zu einem Serien-Timer gehört.
|
|
public let seriesTimerID: String?
|
|
|
|
public init(
|
|
id: String,
|
|
programID: String? = nil,
|
|
channelID: String? = nil,
|
|
channelName: String? = nil,
|
|
title: String,
|
|
startsAt: Date? = nil,
|
|
endsAt: Date? = nil,
|
|
seriesTimerID: String? = nil
|
|
) {
|
|
self.id = id
|
|
self.programID = programID
|
|
self.channelID = channelID
|
|
self.channelName = channelName
|
|
self.title = title
|
|
self.startsAt = startsAt
|
|
self.endsAt = endsAt
|
|
self.seriesTimerID = seriesTimerID
|
|
}
|
|
}
|
|
|
|
/// Eine stehende Aufnahme für alles, was zu einer Serie gehört.
|
|
public struct LiveSeriesTimer: Sendable, Equatable, Identifiable {
|
|
public let id: String
|
|
public let name: String
|
|
public let channelID: String?
|
|
public let channelName: String?
|
|
/// Ob nur Erstausstrahlungen aufgenommen werden.
|
|
public let recordsNewOnly: Bool
|
|
/// Ob auf allen Sendern aufgenommen wird, nicht nur auf einem.
|
|
public let recordsAnyChannel: Bool
|
|
|
|
public init(
|
|
id: String,
|
|
name: String,
|
|
channelID: String? = nil,
|
|
channelName: String? = nil,
|
|
recordsNewOnly: Bool = false,
|
|
recordsAnyChannel: Bool = false
|
|
) {
|
|
self.id = id
|
|
self.name = name
|
|
self.channelID = channelID
|
|
self.channelName = channelName
|
|
self.recordsNewOnly = recordsNewOnly
|
|
self.recordsAnyChannel = recordsAnyChannel
|
|
}
|
|
}
|
|
|
|
// MARK: - Die Verträge
|
|
|
|
/// Was ein Server können muss, damit Live-TV funktioniert.
|
|
///
|
|
/// Bewusst **außerhalb** von `MediaBackend`: nicht jeder Server kann das, und
|
|
/// ein Vertrag, den die Hälfte der Erfüller nur mit Ausnahmen erfüllt, ist
|
|
/// keiner. Wer Live-TV will, fragt mit `as?` danach.
|
|
public protocol LiveTVBackend: Sendable {
|
|
/// Ob es einen Empfänger gibt.
|
|
///
|
|
/// Wirft nicht: „kein Empfänger eingerichtet" ist kein Fehler, sondern
|
|
/// eine Antwort, und die Oberfläche soll sie erklären können.
|
|
func liveTVAvailability() async -> LiveTVAvailability
|
|
|
|
/// Die Sender — und, wo gesucht werden musste, wie sie gefunden wurden.
|
|
func liveChannels() async -> LiveChannelListing
|
|
|
|
/// Das Programm für die genannten Sender in einem Zeitraum.
|
|
///
|
|
/// Nach Senderkennung gebündelt, weil das Raster genau so danach fragt.
|
|
func livePrograms(
|
|
channelIDs: [String],
|
|
from: Date,
|
|
to: Date
|
|
) async throws(MediaBackendError) -> [String: [LiveProgram]]
|
|
|
|
/// Schaltet ein.
|
|
///
|
|
/// Wirft nicht, sondern berichtet: scheitert es, ist die Spur der einzige
|
|
/// Weg zu sehen, woran. Genau daran hing beim Live-TV tagelang alles.
|
|
func tune(
|
|
channel: LiveChannel,
|
|
program: LiveProgram?,
|
|
maximumBitrate: Int?
|
|
) async -> LiveTuneResult
|
|
|
|
/// Gibt den Empfänger wieder frei.
|
|
func endLiveSession(subscriptionKey: String) async
|
|
}
|
|
|
|
/// Was ein Server darüber hinaus können muss, damit Aufnehmen funktioniert.
|
|
///
|
|
/// Getrennt vom Rest, weil Plex das eine kann und das andere nicht. Ein
|
|
/// `as? LiveTVRecordingBackend` sagt die Wahrheit; eine Methode, die bei Plex
|
|
/// nur wirft, verspricht etwas und bricht es dann.
|
|
public protocol LiveTVRecordingBackend: Sendable {
|
|
func recordings() async throws(MediaBackendError) -> [LiveRecording]
|
|
func deleteRecording(id: String) async throws(MediaBackendError)
|
|
|
|
func timers() async throws(MediaBackendError) -> [LiveTimer]
|
|
func scheduleTimer(for program: LiveProgram) async throws(MediaBackendError)
|
|
func cancelTimer(id: String) async throws(MediaBackendError)
|
|
|
|
func seriesTimers() async throws(MediaBackendError) -> [LiveSeriesTimer]
|
|
func scheduleSeriesTimer(for program: LiveProgram) async throws(MediaBackendError)
|
|
func cancelSeriesTimer(id: String) async throws(MediaBackendError)
|
|
}
|