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,281 @@
|
||||
import Foundation
|
||||
import MediaKit
|
||||
|
||||
/// Jellyfin als `LiveTVBackend`.
|
||||
///
|
||||
/// Deutlich kürzer als das Plex-Gegenstück, und das liegt nicht an weniger
|
||||
/// Sorgfalt: Jellyfin hat feste, beschriebene Endpunkte. Es gibt hier nichts
|
||||
/// zu suchen und darum auch nichts zu berichten, wenn die Suche scheitert.
|
||||
///
|
||||
/// Deshalb bleiben `source` und `probes` in der Senderliste leer. Die
|
||||
/// Oberfläche zeigt den Diagnoseblock dann gar nicht erst — er ist die
|
||||
/// Antwort auf ein Problem, das es hier nicht gibt.
|
||||
extension JellyfinBackend: LiveTVBackend {
|
||||
// MARK: - Ob es etwas zu sehen gibt
|
||||
|
||||
public func liveTVAvailability() async -> LiveTVAvailability {
|
||||
do {
|
||||
let info = try await client.send(
|
||||
JellyfinRequest(path: "/LiveTv/Info"),
|
||||
as: JellyfinLiveTVInfo.self
|
||||
)
|
||||
// `IsEnabled` allein genügt nicht: der Server meldet es auch dann,
|
||||
// wenn zwar der Dienst läuft, aber kein Empfänger daran hängt.
|
||||
// Erst die Dienstliste sagt, ob tatsächlich etwas anliegt.
|
||||
guard info.isEnabled == true, !(info.services ?? []).isEmpty else {
|
||||
return .notConfigured
|
||||
}
|
||||
return .available
|
||||
} catch {
|
||||
// Ein Server ohne Live-TV-Unterstützung antwortet hier mit 404.
|
||||
// Das ist kein Fehler, sondern dieselbe Auskunft wie „nichts
|
||||
// eingerichtet" — und die Oberfläche soll sie erklären, nicht
|
||||
// einen Fehlerdialog zeigen.
|
||||
if case .notFound = error { return .notConfigured }
|
||||
return .failed(message: error.probeDescription)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Sender
|
||||
|
||||
public func liveChannels() async -> LiveChannelListing {
|
||||
do {
|
||||
let page = try await client.send(
|
||||
JellyfinRequest(
|
||||
path: "/LiveTv/Channels",
|
||||
query: [
|
||||
URLQueryItem(name: "userId", value: account.userID),
|
||||
// Die laufende Sendung gleich mit. Ohne das bräuchte
|
||||
// die Senderliste einen zweiten Aufruf, nur um zu
|
||||
// zeigen, was gerade läuft.
|
||||
URLQueryItem(name: "addCurrentProgram", value: "true"),
|
||||
URLQueryItem(name: "enableImages", value: "true"),
|
||||
URLQueryItem(name: "enableUserData", value: "false"),
|
||||
]
|
||||
),
|
||||
as: JellyfinItemPage.self
|
||||
)
|
||||
return LiveChannelListing(channels: page.items.map(liveChannel(from:)))
|
||||
} catch {
|
||||
return LiveChannelListing(channels: [])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Programm
|
||||
|
||||
public func livePrograms(
|
||||
channelIDs: [String],
|
||||
from: Date,
|
||||
to: Date
|
||||
) async throws(MediaBackendError) -> [String: [LiveProgram]] {
|
||||
var query = [
|
||||
URLQueryItem(name: "userId", value: account.userID),
|
||||
URLQueryItem(name: "minEndDate", value: Self.timestamp(from)),
|
||||
URLQueryItem(name: "maxStartDate", value: Self.timestamp(to)),
|
||||
URLQueryItem(name: "enableImages", value: "true"),
|
||||
URLQueryItem(name: "enableUserData", value: "false"),
|
||||
URLQueryItem(name: "sortBy", value: "StartDate"),
|
||||
]
|
||||
// Leer heißt: alle. Eine leere Liste mitzuschicken hieße dagegen
|
||||
// „keiner", und dann käme nichts zurück.
|
||||
if !channelIDs.isEmpty {
|
||||
query.append(URLQueryItem(name: "channelIds", value: channelIDs.joined(separator: ",")))
|
||||
}
|
||||
|
||||
let page = try await client.send(
|
||||
JellyfinRequest(path: "/LiveTv/Programs", query: query),
|
||||
as: JellyfinItemPage.self
|
||||
)
|
||||
|
||||
// Nach Sender gebündelt, weil das Raster genau so danach fragt.
|
||||
var out: [String: [LiveProgram]] = [:]
|
||||
for item in page.items {
|
||||
guard let program = liveProgram(from: item) else { continue }
|
||||
out[program.channelID, default: []].append(program)
|
||||
}
|
||||
for (channel, programs) in out {
|
||||
out[channel] = programs.sorted { $0.startsAt < $1.startsAt }
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// MARK: - Einschalten
|
||||
|
||||
public func tune(
|
||||
channel: LiveChannel,
|
||||
program: LiveProgram?,
|
||||
maximumBitrate: Int?
|
||||
) async -> LiveTuneResult {
|
||||
let session = UUID().uuidString
|
||||
|
||||
// Ein Sender ist für Jellyfin ein Eintrag wie jeder andere: derselbe
|
||||
// Weg wie beim Film, nur dass die Quelle nie endet. Der Server nennt
|
||||
// die Adresse; sie selbst zusammenzubauen wäre eine Vermutung darüber,
|
||||
// was er kopieren kann und was er rechnen muss.
|
||||
do {
|
||||
let info = try await playbackInfo(
|
||||
itemID: channel.id,
|
||||
sourceID: channel.id,
|
||||
transcode: TranscodeSettings(maximumBitrate: maximumBitrate, resolution: nil)
|
||||
)
|
||||
|
||||
let source = info.mediaSources?.first
|
||||
if let path = source?.transcodingURL,
|
||||
let url = URL(string: path, relativeTo: client.baseURL)?.absoluteURL {
|
||||
return LiveTuneResult(
|
||||
url: url,
|
||||
sessionID: info.playSessionID ?? session,
|
||||
subscriptionKey: info.playSessionID
|
||||
)
|
||||
}
|
||||
|
||||
// Kein Transcoder-Weg: dann unverändert. Manche Empfänger liefern
|
||||
// bereits einen Stream, den das Gerät ohne Umrechnung versteht.
|
||||
if let url = client.url(
|
||||
path: "/Videos/\(channel.id)/stream",
|
||||
query: [
|
||||
URLQueryItem(name: "static", value: "true"),
|
||||
URLQueryItem(name: "mediaSourceId", value: source?.id ?? channel.id),
|
||||
],
|
||||
includingToken: account.token
|
||||
) {
|
||||
return LiveTuneResult(
|
||||
url: url,
|
||||
sessionID: info.playSessionID ?? session,
|
||||
subscriptionKey: info.playSessionID
|
||||
)
|
||||
}
|
||||
|
||||
return LiveTuneResult(
|
||||
url: nil,
|
||||
sessionID: session,
|
||||
trace: [LiveChannelProbe(
|
||||
path: "/Items/\(channel.id)/PlaybackInfo",
|
||||
result: "Der Server nannte weder eine Transcoder- noch eine Direktadresse"
|
||||
)]
|
||||
)
|
||||
} catch {
|
||||
return LiveTuneResult(
|
||||
url: nil,
|
||||
sessionID: session,
|
||||
trace: [LiveChannelProbe(
|
||||
path: "/Items/\(channel.id)/PlaybackInfo",
|
||||
result: error.probeDescription
|
||||
)]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
public func endLiveSession(subscriptionKey: String) async {
|
||||
// Dieselbe Freigabe wie am Ende eines Films: der Transcoder rechnet
|
||||
// sonst weiter, und beim Live-TV bleibt dabei auch der Empfänger
|
||||
// belegt — davon gibt es weniger als von allem anderen.
|
||||
await endPlayback(sessionID: subscriptionKey)
|
||||
}
|
||||
|
||||
// MARK: - Übersetzung
|
||||
|
||||
private func liveChannel(from item: JellyfinItem) -> LiveChannel {
|
||||
LiveChannel(
|
||||
id: item.id,
|
||||
// `Number` steht am Sender, `ChannelNumber` an der Sendung.
|
||||
number: item.number ?? item.channelNumber,
|
||||
title: item.name ?? "—",
|
||||
artworkPath: images.primary(itemID: item.id, tag: item.imageTags?["Primary"]),
|
||||
currentProgram: item.currentProgram.flatMap { current in
|
||||
guard let start = current.startDate, let end = current.endDate else { return nil }
|
||||
return LiveProgram(
|
||||
id: current.id,
|
||||
channelID: item.id,
|
||||
title: current.name ?? "—",
|
||||
summary: current.overview,
|
||||
startsAt: start,
|
||||
endsAt: end
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/// Eine Sendung — oder `nil`, wenn sie keine Zeit hat.
|
||||
///
|
||||
/// Ohne Anfang und Ende lässt sie sich nicht ins Raster legen, und ein
|
||||
/// Block ohne Ausdehnung wäre in der Anzeige ein unsichtbarer Streifen,
|
||||
/// den man nicht treffen kann.
|
||||
private func liveProgram(from item: JellyfinItem) -> LiveProgram? {
|
||||
guard let channelID = item.channelID,
|
||||
let start = item.startDate,
|
||||
let end = item.endDate
|
||||
else { return nil }
|
||||
|
||||
var kind: LiveProgram.Kind = []
|
||||
if item.isMovie == true { kind.insert(.movie) }
|
||||
if item.isSeries == true { kind.insert(.series) }
|
||||
if item.isNews == true { kind.insert(.news) }
|
||||
if item.isSports == true { kind.insert(.sports) }
|
||||
if item.isKids == true { kind.insert(.kids) }
|
||||
if item.isLive == true { kind.insert(.live) }
|
||||
|
||||
return LiveProgram(
|
||||
id: item.id,
|
||||
channelID: channelID,
|
||||
title: item.name ?? "—",
|
||||
// `EpisodeTitle` und nicht `Name`: hier stand der Reihentitel ein
|
||||
// zweites Mal, und im Sheet hätte derselbe Satz zweimal
|
||||
// untereinander gestanden.
|
||||
subtitle: item.episodeTitle,
|
||||
summary: item.overview,
|
||||
startsAt: start,
|
||||
endsAt: end,
|
||||
artworkPath: images.primary(itemID: item.id, tag: item.imageTags?["Primary"]),
|
||||
seriesTimerID: item.seriesTimerID,
|
||||
kind: kind
|
||||
)
|
||||
}
|
||||
|
||||
/// Ein Zeitpunkt, wie Jellyfin ihn in der Adresse erwartet.
|
||||
static func timestamp(_ date: Date) -> String {
|
||||
ISO8601DateFormatter().string(from: date)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Was der Server über Live-TV sagt
|
||||
|
||||
/// Die Auskunft unter `/LiveTv/Info`.
|
||||
struct JellyfinLiveTVInfo: Decodable, Sendable {
|
||||
let isEnabled: Bool?
|
||||
/// Die eingerichteten Dienste. Leer heißt: kein Empfänger.
|
||||
let services: [Service]?
|
||||
|
||||
struct Service: Decodable, Sendable {
|
||||
let name: String?
|
||||
|
||||
private enum CodingKeys: String, CodingKey { case name = "Name" }
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case isEnabled = "IsEnabled"
|
||||
case services = "Services"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Fehler in der Spur
|
||||
|
||||
extension MediaBackendError {
|
||||
/// Knapp und technisch — für die Diagnosespur, nicht für den Nutzer.
|
||||
///
|
||||
/// Bewusst nicht dasselbe wie die Meldung, die in der Oberfläche steht:
|
||||
/// die soll erklären, was zu tun ist, diese soll festhalten, was passiert
|
||||
/// ist. Sie wird nicht übersetzt, weil sie in einem Bericht landet, den
|
||||
/// man weitergibt.
|
||||
var probeDescription: String {
|
||||
switch self {
|
||||
case .unauthorized: "nicht angemeldet"
|
||||
case .notFound: "404"
|
||||
case .noReachableConnection: "keine Verbindung"
|
||||
case .server(let status): "HTTP \(status)"
|
||||
case .transport(let description): description
|
||||
case .decoding(let description): "unerwartete Antwort: \(description)"
|
||||
case .refused(let reason): reason
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user