Files
Flaro/Packages/HaloKit/Sources/MediaKit/LiveTV.swift
T
ScarriffleandClaude Opus 5 4b9a1e7b67 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>
2026-10-03 11:12:32 +02:00

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)
}