Flixy heißt ab hier Halo
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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
2fcf6821c8
commit
4b9a1e7b67
@@ -0,0 +1,374 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user