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:
Scarriffle
2026-10-03 11:12:32 +02:00
co-authored by Claude Opus 5
parent 2fcf6821c8
commit 4b9a1e7b67
280 changed files with 1456 additions and 1456 deletions
@@ -0,0 +1,65 @@
import Foundation
/// Macht aus dem, was jemand eintippt, eine Adresse, die antwortet.
///
/// Jellyfin hat kein Verzeichnis wie plex.tv — die Adresse kommt vom Nutzer.
/// Und die tippt niemand vollständig: „192.168.1.50" ohne Schema, ohne Port,
/// oder „medien.example.com" ohne „https://". Dieser Typ rät nicht **eine**
/// Ergänzung, sondern nennt die plausiblen in der Reihenfolge, in der sie es
/// wahrscheinlich sind; der Aufrufer probiert sie durch und nimmt die erste,
/// die sich als Jellyfin-Server ausweist.
///
/// Die Reihenfolge ist der ganze Inhalt dieser Datei, und sie ist nicht
/// beliebig: eine IP-Adresse im eigenen Netz spricht fast nie HTTPS, ein
/// Domänenname fast immer.
public enum JellyfinAddress {
/// Der Standardport eines Jellyfin-Servers.
public static let defaultPort = 8096
public static func candidates(for input: String) -> [URL] {
var text = input.trimmingCharacters(in: .whitespacesAndNewlines)
while text.hasSuffix("/") { text.removeLast() }
guard !text.isEmpty else { return [] }
// Wer ein Schema hinschreibt, meint es auch so. Daran wird nichts
// ergänzt — sonst landet eine Anfrage unverschlüsselt, obwohl jemand
// ausdrücklich „https" getippt hat.
if text.contains("://") {
return URL(string: text).map { [$0] } ?? []
}
// IPv6 in eckigen Klammern: hier wird nicht geraten, weil die
// Doppelpunkte sonst als Port gelesen würden.
if text.hasPrefix("[") {
return ["http://\(text)", "https://\(text)"].compactMap(URL.init(string:))
}
let authority = String(text.split(separator: "/", maxSplits: 1).first ?? "")
let host = String(authority.split(separator: ":").first ?? "")
let hasExplicitPort = authority.contains(":")
&& Int(authority.split(separator: ":").last ?? "") != nil
if hasExplicitPort {
// Port genannt, Schema nicht: erst unverschlüsselt, weil ein
// eigener Port fast immer ein Server ohne Zertifikat ist.
return ["http://\(text)", "https://\(text)"].compactMap(URL.init(string:))
}
// Eine reine Zahlenadresse, ein Bonjour-Name oder ein Wort ohne Punkt
// — alles Dinge, die es nur im eigenen Netz gibt.
let isLocalLooking = host.allSatisfy { $0.isNumber || $0 == "." }
|| host.hasSuffix(".local")
|| !host.contains(".")
let forms = isLocalLooking
? ["http://\(withDefaultPort(text))", "http://\(text)", "https://\(text)"]
: ["https://\(text)", "http://\(text)", "http://\(withDefaultPort(text))"]
return forms.compactMap(URL.init(string:))
}
/// Hängt den Standardport an — vor einen etwaigen Pfad, nicht dahinter.
private static func withDefaultPort(_ text: String) -> String {
guard let slash = text.firstIndex(of: "/") else { return "\(text):\(defaultPort)" }
return "\(text[text.startIndex..<slash]):\(defaultPort)\(text[slash...])"
}
}
@@ -0,0 +1,189 @@
import Foundation
import MediaKit
/// Wer angemeldet ist.
public struct JellyfinAccount: Sendable, Equatable {
public let userID: String
public let userName: String
public let token: String
/// Ob dieses Konto den Server verwalten darf.
///
/// Entscheidet, ob der Verwaltungsbereich überhaupt erscheint. Ihn zu
/// zeigen und bei jedem Tipp „keine Berechtigung" zu melden, wäre die
/// schlechtere Antwort.
public let isAdministrator: Bool
public init(userID: String, userName: String, token: String, isAdministrator: Bool) {
self.userID = userID
self.userName = userName
self.token = token
self.isAdministrator = isAdministrator
}
}
/// Was ein Server über sich verrät, bevor man angemeldet ist.
public struct JellyfinServerInfo: Sendable, Equatable, Decodable {
public let serverName: String
public let version: String?
public let id: String?
/// Ob die Einrichtung abgeschlossen ist.
public let startupWizardCompleted: Bool?
private enum CodingKeys: String, CodingKey {
case serverName = "ServerName"
case version = "Version"
case id = "Id"
case startupWizardCompleted = "StartupWizardCompleted"
}
}
/// Der Code, mit dem sich die App ohne Passwort anmeldet.
public struct QuickConnectRequest: Sendable, Equatable, Decodable {
public let secret: String
public let code: String
private enum CodingKeys: String, CodingKey {
case secret = "Secret"
case code = "Code"
}
}
/// Meldet an einem Jellyfin-Server an.
///
/// Zwei Wege, und die Reihenfolge ist Absicht.
///
/// **Quick Connect** ist der erste: die App zeigt einen sechsstelligen Code,
/// den man in einem bereits angemeldeten Client bestätigt. Das Passwort
/// erreicht Halo dabei nie — dieselbe Zusage, die bei Plex gilt, wo die
/// Anmeldemaske von Plex selbst kommt.
///
/// **Benutzername und Passwort** sind der Rückfall, falls der Server Quick
/// Connect abgeschaltet hat. Dann hält die App das Passwort für die Dauer
/// einer Anfrage — sie speichert es nicht, protokolliert es nicht und legt es
/// nirgends ab. Gespeichert wird nur der Token, und der im Schlüsselbund.
public actor JellyfinAuthenticator {
private let client: JellyfinClient
public init(client: JellyfinClient) {
self.client = client
}
/// Fragt den Server, ob er überhaupt einer ist.
public func serverInfo() async throws(MediaBackendError) -> JellyfinServerInfo {
try await client.send(
JellyfinRequest(path: "/System/Info/Public", timeout: .seconds(10)),
as: JellyfinServerInfo.self
)
}
/// Ob der Server die Anmeldung per Code anbietet.
public func supportsQuickConnect() async -> Bool {
// Schweigt er oder antwortet mit einem Fehler, gilt es als
// abgeschaltet. Ein Code, den niemand bestätigen kann, wäre eine
// Sackgasse mit Wartezeit.
guard let data = try? await client.send(JellyfinRequest(path: "/QuickConnect/Enabled")) else {
return false
}
let text = String(decoding: data, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines)
return text.lowercased() == "true"
}
public func beginQuickConnect() async throws(MediaBackendError) -> QuickConnectRequest {
try await client.send(
JellyfinRequest(method: .post, path: "/QuickConnect/Initiate"),
as: QuickConnectRequest.self
)
}
/// Sieht nach, ob der Code inzwischen bestätigt wurde.
///
/// Gibt `nil` zurück, solange er es nicht ist — der Aufrufer führt seine
/// eigene Schleife, weil er nebenher auf Abbruch achten muss.
public func completeQuickConnect(
secret: String
) async throws(MediaBackendError) -> JellyfinAccount? {
let state = try await client.send(
JellyfinRequest(
path: "/QuickConnect/Connect",
query: [URLQueryItem(name: "secret", value: secret)]
),
as: QuickConnectState.self
)
guard state.authenticated else { return nil }
let body = try encode(["Secret": secret])
return try await authenticate(
JellyfinRequest(method: .post, path: "/Users/AuthenticateWithQuickConnect", body: body)
)
}
/// Der Rückfall, wenn Quick Connect abgeschaltet ist.
public func signIn(
userName: String,
password: String
) async throws(MediaBackendError) -> JellyfinAccount {
let body = try encode(["Username": userName, "Pw": password])
return try await authenticate(
JellyfinRequest(method: .post, path: "/Users/AuthenticateByName", body: body)
)
}
private func authenticate(
_ request: JellyfinRequest
) async throws(MediaBackendError) -> JellyfinAccount {
let result = try await client.send(request, as: AuthenticationResult.self)
await client.setToken(result.accessToken)
return JellyfinAccount(
userID: result.user.id,
userName: result.user.name,
token: result.accessToken,
isAdministrator: result.user.policy?.isAdministrator ?? false
)
}
private func encode(_ fields: [String: String]) throws(MediaBackendError) -> Data {
do {
return try JSONSerialization.data(withJSONObject: fields)
} catch {
throw .decoding(description: String(describing: error))
}
}
}
private struct QuickConnectState: Decodable, Sendable {
let authenticated: Bool
private enum CodingKeys: String, CodingKey {
case authenticated = "Authenticated"
}
}
private struct AuthenticationResult: Decodable, Sendable {
let accessToken: String
let user: User
struct User: Decodable, Sendable {
let id: String
let name: String
let policy: Policy?
private enum CodingKeys: String, CodingKey {
case id = "Id"
case name = "Name"
case policy = "Policy"
}
}
struct Policy: Decodable, Sendable {
let isAdministrator: Bool?
private enum CodingKeys: String, CodingKey {
case isAdministrator = "IsAdministrator"
}
}
private enum CodingKeys: String, CodingKey {
case accessToken = "AccessToken"
case user = "User"
}
}
@@ -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
}
}
}
@@ -0,0 +1,342 @@
import Foundation
import MediaKit
extension JellyfinBackend {
// MARK: - Abspielen
public func preparePlayback(
_ intent: PlaybackIntent
) async throws(MediaBackendError) -> PlaybackTarget {
// Jellyfin trennt Fassung und Datei nicht: was die neutrale Form
// „Datei" nennt, ist dort die Quelle, und ihre Kennung wird bei jedem
// weiteren Schritt wieder verlangt.
let sourceID = intent.file.key ?? intent.itemID
guard let transcode = intent.transcode else {
// `static=true` schaltet jede Umrechnung ab: die Datei kommt so,
// wie sie auf der Platte liegt. Ob das Gerät sie überhaupt
// abspielen kann, ist vorher entschieden worden — und wenn nicht
// der Systemplayer.
guard let url = client.url(
path: "/Videos/\(intent.itemID)/stream",
query: [
URLQueryItem(name: "static", value: "true"),
URLQueryItem(name: "mediaSourceId", value: sourceID),
],
includingToken: account.token
) else { throw .notFound }
// Auch hier eine Sitzungskennung, und zwar eine selbst vergebene:
// ohne sie ordnet Jellyfin die Fortschrittsmeldungen keiner
// Wiedergabe zu, und der Titel bliebe für den Server ungesehen.
return PlaybackTarget(
url: url,
sessionID: UUID().uuidString,
versionID: sourceID
)
}
let info = try await playbackInfo(
itemID: intent.itemID,
sourceID: sourceID,
transcode: transcode,
audioStreamIndex: intent.audioStreamIndex,
subtitleStreamIndex: intent.subtitleStreamIndex,
remux: transcode.prefersRemux
)
let source = info.mediaSources?.first { $0.id == sourceID } ?? info.mediaSources?.first
// Die Adresse kommt vom Server, nicht von uns. Er weiß, welche Spuren
// er kopieren kann und welche er neu rechnen muss; eine selbst
// zusammengebaute Adresse wäre eine Vermutung darüber.
guard let path = source?.transcodingURL,
let url = URL(string: path, relativeTo: client.baseURL)?.absoluteURL
else {
throw .refused(reason: String(
localized: "player.transcodeUnavailable",
bundle: .module
))
}
var adresse = Self.limiting(height: transcode.resolution, in: url)
// **Immer die Master-Liste, auch ohne Leiter.**
//
// Der Kommentar, der hier stand, war überholt und hat einen Fehler
// verdeckt: er stammte aus der Zeit, als Umpacken eine fortlaufende
// MP4 war — da wäre eine Abspielliste tatsächlich verkehrt gewesen.
// Seit das Umpacken über HLS läuft, ist sie genau richtig.
//
// Und sie ist nicht nur richtig, sie ist notwendig: eine
// Untertitelspur wird mit `#EXT-X-MEDIA` angekündigt, und das steht
// ausschliesslich in einer Master-Liste. Auf `main.m3u8` kann es
// strukturell keine Untertitel geben.
//
// Die **Leiter** bleibt davon unberührt. Sie ist eine eigene Frage
// und wird weiterhin nur bei „automatisch" und ohne Umpacken
// bestellt — beim Umpacken gibt es nur eine Fassung, nämlich die
// Datei selbst.
if transcode.prefersAdaptive, !transcode.prefersRemux {
adresse = Self.adaptive(adresse)
} else {
adresse = Self.masterPlaylist(adresse)
}
return PlaybackTarget(
url: adresse,
sessionID: info.playSessionID,
versionID: source?.id ?? sourceID
)
}
public func endPlayback(sessionID: String) async {
// Ohne dieses Signal rechnet der Transcoder weiter, bis der Server von
// selbst merkt, dass niemand mehr Segmente abholt. Bei direkter
// Wiedergabe gibt es nichts anzuhalten — der Aufruf läuft dann ins
// Leere, und das ist billiger als sich zu merken, welcher Fall es war.
_ = try? await client.send(JellyfinRequest(
method: .delete,
path: "/Videos/ActiveEncodings",
query: [
URLQueryItem(name: "deviceId", value: client.deviceID),
URLQueryItem(name: "playSessionId", value: sessionID),
],
timeout: .seconds(5)
))
}
public func reportProgress(_ report: PlaybackReport) async {
var fields: [String: Any] = [
"ItemId": report.itemID,
"PositionTicks": report.time.milliseconds.ticksFromMilliseconds,
"IsPaused": report.state == .paused,
"CanSeek": true,
]
if let sessionID = report.sessionID { fields["PlaySessionId"] = sessionID }
if let versionID = report.versionID { fields["MediaSourceId"] = versionID }
// Erster und letzter Bericht gehen an eigene Endpunkte. Ohne den
// ersten erscheint der Titel nirgends als „läuft gerade"; ohne den
// letzten bleibt er für immer dort stehen. Ein Fortschritt mit
// derselben Zahl täte weder das eine noch das andere.
let path = switch report.state {
case .started: "/Sessions/Playing"
case .stopped: "/Sessions/Playing/Stopped"
default: "/Sessions/Playing/Progress"
}
guard let body = try? JSONSerialization.data(withJSONObject: fields) else { return }
_ = try? await client.send(JellyfinRequest(
method: .post,
path: path,
body: body,
timeout: .seconds(10)
))
}
// MARK: - Hilfen
/// Nicht `private`: Live-TV in `JellyfinBackend+LiveTV` fragt denselben
/// Weg. Ein Sender ist für Jellyfin ein Eintrag wie jeder andere, und
/// eine zweite Fassung dieser Abfrage wäre eine Kopie, die auseinanderläuft.
func playbackInfo(
itemID: String,
sourceID: String,
transcode: TranscodeSettings,
audioStreamIndex: Int? = nil,
subtitleStreamIndex: Int? = nil,
remux: Bool = false
) async throws(MediaBackendError) -> JellyfinPlaybackInfo {
// Jellyfin rechnet in Bit, die App in Kilobit.
let bitsPerSecond = (transcode.maximumBitrate ?? 8_000) * 1_000
// **Umpacken: dieselben Ströme, andere Schachtel.**
//
// Die Codecs stehen genau so breit wie das, was AVPlayer wirklich
// kann: trifft der Server eine Übereinstimmung, **kopiert** er den
// Strom, statt ihn neu zu rechnen. Genau dafür ist der Weg da — ein
// Matroska-Rip mit HEVC und AC3 kommt so bei AVPlayer an, mit
// Bild-in-Bild und AirPlay, ohne dass ein Bild angefasst wurde.
//
// **Und doch als HLS, obwohl eine fortlaufende Datei schöner wäre.**
// Der Versuch mit `Protocol: http` ist am Gerät gescheitert:
// AVPlayer verlangt bei einer fortlaufenden Datei Bereichsanfragen —
// er will hineinspringen können. Eine MP4, die ffmpeg gerade erst
// erzeugt, kann das nicht: sie hat weder Länge noch ein
// Inhaltsverzeichnis am Anfang. Die Antwort war
// `AVErrorServerIncorrectlyConfigured` (-11850), und zwar sofort.
//
// Damit bleibt vom ursprünglichen Ziel das Umpacken, nicht das weite
// Vorausladen. Das ist immer noch der grössere Teil des Gewinns.
//
// `Container: mp4` statt `ts`: als fragmentierte MP4 trägt HLS auch
// HEVC. In MPEG-TS wäre dafür eine Neukodierung nötig — also genau
// das, was hier vermieden werden soll.
let umpacken: [String: Any] = [
"Type": "Video",
"Container": "mp4",
"Protocol": "hls",
"VideoCodec": "h264,hevc",
"AudioCodec": "aac,ac3,eac3,mp3",
"Context": "Streaming",
"MaxAudioChannels": "8",
"MinSegments": 1,
]
let profile: [String: Any] = [
"MaxStreamingBitrate": bitsPerSecond,
// Leer heißt: nichts geht unverändert. An dieser Stelle ist bereits
// entschieden, dass umgerechnet werden soll — dem Server die Wahl
// zu lassen hieße, die Entscheidung zweimal zu treffen.
"DirectPlayProfiles": [],
"TranscodingProfiles": remux ? [umpacken] : [[
"Type": "Video",
"Container": "ts",
"Protocol": "hls",
"VideoCodec": "h264",
"AudioCodec": "aac,mp3",
"Context": "Streaming",
"MaxAudioChannels": "2",
"MinSegments": 1,
"BreakOnNonKeyFrames": true,
]],
"SubtitleProfiles": [
["Format": "vtt", "Method": "Hls"],
["Format": "srt", "Method": "External"],
],
]
let body: Data
do {
body = try JSONSerialization.data(withJSONObject: ["DeviceProfile": profile])
} catch {
throw .decoding(description: String(describing: error))
}
return try await client.send(
JellyfinRequest(
method: .post,
path: "/Items/\(itemID)/PlaybackInfo",
query: [
URLQueryItem(name: "userId", value: account.userID),
URLQueryItem(name: "mediaSourceId", value: sourceID),
URLQueryItem(name: "maxStreamingBitrate", value: String(bitsPerSecond)),
// **Kein** `startTimeTicks`. Es gäbe zwei Stellen, die
// über den Startpunkt entscheiden: der Server ließe den
// Stream dort beginnen, und der Player spränge anschließend
// noch einmal um dieselbe Strecke vor — bei einundzwanzig
// Minuten Fortschritt landete man bei zweiundvierzig.
// Gesprungen wird an genau einer Stelle, und das ist der
// Player.
// **Die Tonspur ausdrücklich nennen.** Ohne sie wählt
// der Server, und bei einem umgerechneten Stream kommt
// danach nur noch diese eine an — dann ist im Player
// nichts mehr umzustellen. Die Sprachwahl muss hier
// stattfinden oder gar nicht.
URLQueryItem(name: "enableDirectPlay", value: "false"),
URLQueryItem(name: "enableDirectStream", value: "false"),
URLQueryItem(name: "enableTranscoding", value: "true"),
] + (audioStreamIndex.map {
[URLQueryItem(name: "audioStreamIndex", value: String($0))]
} ?? []) + (subtitleStreamIndex.map {
[URLQueryItem(name: "subtitleStreamIndex", value: String($0))]
} ?? []),
body: body
),
as: JellyfinPlaybackInfo.self
)
}
/// Hängt die gewünschte Bildhöhe an die Adresse des Servers.
///
/// Die Bitrate versteht Jellyfin über das Geräteprofil, die Bildhöhe nicht
/// ohne Weiteres. Sie wird deshalb nachträglich gesetzt — und eine bereits
/// vorhandene Angabe ersetzt, statt eine zweite danebenzustellen: bei zwei
/// gleichnamigen Werten gilt beim Server der erste, und das wäre dann
/// gerade nicht der gewählte.
///
/// `resolution` kommt in der Form „1280x720"; gebraucht wird die zweite
/// Zahl.
static func limiting(height resolution: String?, in url: URL) -> URL {
guard let resolution,
let height = resolution.split(separator: "x").last.flatMap({ Int($0) }),
var components = URLComponents(url: url, resolvingAgainstBaseURL: false)
else { return url }
var items = (components.queryItems ?? []).filter {
let name = $0.name.lowercased()
return name != "maxheight" && name != "maxwidth"
}
items.append(URLQueryItem(name: "maxHeight", value: String(height)))
components.queryItems = items
return components.url ?? url
}
/// Bestellt eine **Leiter** statt einer einzelnen Stufe.
///
/// Zwei Handgriffe, und beide sind nötig:
///
/// **`main.m3u8` wird zu `master.m3u8`.** Die eine ist die Liste der
/// Schnipsel *einer* Stufe, die andere die Liste der Stufen. Nur auf der
/// zweiten kann der Parameter überhaupt wirken — auf der ersten gibt es
/// keine Varianten, über die zu entscheiden wäre. Heißt die Adresse schon
/// `master.m3u8`, bleibt sie.
///
/// **Der Parameter wird gesetzt, nicht danebengestellt.** Die
/// Voreinstellung im Server ist `false`; ohne ihn kommt in jedem Fall nur
/// eine Stufe. Und bei zwei gleichnamigen Werten gilt beim Server der
/// erste — dieselbe Falle wie bei `maxHeight` eine Funktion weiter oben.
///
/// **Was das nicht kann.** Jellyfin verweigert die Leiter im lokalen Netz
/// grundsätzlich, und zwar mit Absicht: „Within the local network this
/// will likely do more harm than good." Ein schwaches WLAN zählt dort mit.
/// Ebenso fällt sie weg, wenn Bild **oder** Ton nur kopiert statt neu
/// gerechnet werden. Ob am Ende wirklich mehrere Stufen ankommen, steht
/// deshalb nicht hier, sondern in `PlaybackStatistics.variantCount` — und
/// das ist die einzige belastbare Auskunft darüber.
static func adaptive(_ url: URL) -> URL {
guard var components = URLComponents(url: masterPlaylist(url), resolvingAgainstBaseURL: false)
else { return masterPlaylist(url) }
var items = (components.queryItems ?? []).filter {
$0.name.lowercased() != "enableadaptivebitratestreaming"
}
items.append(URLQueryItem(name: "enableAdaptiveBitrateStreaming", value: "true"))
components.queryItems = items
return components.url ?? url
}
/// Von der Liste **einer** Stufe zur Liste **aller** Spuren.
///
/// **Der eigene Handgriff, und der wichtigere.** `main.m3u8` ist eine
/// Media Playlist: eine Liste von Schnipseln, sonst nichts. `master.m3u8`
/// ist eine Master Playlist, und nur dort darf nach RFC 8216 ein
/// `#EXT-X-MEDIA` stehen — also die Zeile, mit der ein Server eine
/// **Untertitelspur** ankündigt.
///
/// Das heisst: auf `main.m3u8` kann es strukturell keine Untertitel
/// geben, ganz gleich was bestellt wurde und was der Server liefern
/// wollte. Genau daran lag es, dass beim Umpacken und bei jeder von Hand
/// gewählten Stufe nie ein Untertitel erschien — dort blieb die Adresse
/// `main.m3u8`, weil der Wechsel an der Leiter hing.
///
/// Deshalb steht er jetzt für sich: **jeder HLS-Stream bekommt die
/// Master-Liste.** Ob darin mehrere Stufen stehen, ist eine andere Frage
/// — die beantwortet `enableAdaptiveBitrateStreaming`, und eine
/// Master-Liste mit einer einzigen Stufe ist völlig in Ordnung.
static func masterPlaylist(_ url: URL) -> URL {
guard url.lastPathComponent == "main.m3u8" else { return url }
return url.deletingLastPathComponent().appending(path: "master.m3u8")
}
}
/// Was der Server für eine Wiedergabe vorbereitet hat.
public struct JellyfinPlaybackInfo: Decodable, Sendable {
public let mediaSources: [JellyfinMediaSource]?
public let playSessionID: String?
private enum CodingKeys: String, CodingKey {
case mediaSources = "MediaSources"
case playSessionID = "PlaySessionId"
}
}
@@ -0,0 +1,192 @@
import Foundation
import MediaKit
/// Jellyfin als `LiveTVRecordingBackend`.
///
/// Plex erfüllt diesen Vertrag nicht — es kann in dieser App nichts aufnehmen.
/// Deshalb steht er getrennt: wer aufnehmen will, fragt mit `as?` danach und
/// bekommt eine ehrliche Antwort, statt auf Methoden zu treffen, die nur
/// werfen.
extension JellyfinBackend: LiveTVRecordingBackend {
// MARK: - Aufnahmen
public func recordings() async throws(MediaBackendError) -> [LiveRecording] {
let page = try await client.send(
JellyfinRequest(
path: "/LiveTv/Recordings",
query: [
URLQueryItem(name: "userId", value: account.userID),
URLQueryItem(name: "enableImages", value: "true"),
]
),
as: JellyfinItemPage.self
)
let now = Date()
return page.items.map { item in
LiveRecording(
id: item.id,
title: item.name ?? "—",
channelName: item.channelName,
summary: item.overview,
startsAt: item.startDate,
endsAt: item.endDate,
artworkPath: images.primary(itemID: item.id, tag: item.imageTags?["Primary"]),
// Abgeleitet statt erfragt: eine Aufnahme, deren Ende noch
// bevorsteht, läuft gerade. Das spart ein Feld, das sonst nur
// für diese eine Anzeige im Modell stünde.
isInProgress: (item.endDate.map { $0 > now }) ?? false
)
}
}
public func deleteRecording(id: String) async throws(MediaBackendError) {
_ = try await client.send(JellyfinRequest(
method: .delete,
path: "/LiveTv/Recordings/\(id)"
))
}
// MARK: - Vorgemerkt
public func timers() async throws(MediaBackendError) -> [LiveTimer] {
let page = try await client.send(
JellyfinRequest(path: "/LiveTv/Timers"),
as: JellyfinTimerPage.self
)
return page.items.map { timer in
LiveTimer(
id: timer.id ?? "",
programID: timer.programID,
channelID: timer.channelID,
channelName: timer.channelName,
title: timer.name ?? "—",
startsAt: timer.startDate,
endsAt: timer.endDate,
seriesTimerID: timer.seriesTimerID
)
}
}
public func scheduleTimer(for program: LiveProgram) async throws(MediaBackendError) {
try await schedule(program: program, path: "/LiveTv/Timers")
}
public func cancelTimer(id: String) async throws(MediaBackendError) {
_ = try await client.send(JellyfinRequest(
method: .delete,
path: "/LiveTv/Timers/\(id)"
))
}
// MARK: - Ganze Reihen
public func seriesTimers() async throws(MediaBackendError) -> [LiveSeriesTimer] {
let page = try await client.send(
JellyfinRequest(path: "/LiveTv/SeriesTimers"),
as: JellyfinSeriesTimerPage.self
)
return page.items.map { timer in
LiveSeriesTimer(
id: timer.id ?? "",
name: timer.name ?? "—",
channelID: timer.channelID,
channelName: timer.channelName,
recordsNewOnly: timer.skipEpisodesInLibrary ?? false,
recordsAnyChannel: timer.recordAnyChannel ?? false
)
}
}
public func scheduleSeriesTimer(for program: LiveProgram) async throws(MediaBackendError) {
try await schedule(program: program, path: "/LiveTv/SeriesTimers")
}
public func cancelSeriesTimer(id: String) async throws(MediaBackendError) {
_ = try await client.send(JellyfinRequest(
method: .delete,
path: "/LiveTv/SeriesTimers/\(id)"
))
}
// MARK: - Der gemeinsame Weg
/// Merkt eine Sendung vor — einmalig oder als ganze Reihe.
///
/// **Zwei Schritte, und der erste ist der wichtige.** Der Server liefert
/// unter `Timers/Defaults` eine für diese Sendung fertig ausgefüllte
/// Vorlage: Vor- und Nachlauf, Priorität, Dienstname, wie lange sie
/// aufgehoben wird. Was davon Pflicht ist, steht nirgends — die Beschreibung
/// nennt kein einziges Feld als erforderlich.
///
/// Deshalb wird die Vorlage **roh weitergereicht**, so wie sie kam, und
/// nicht entschlüsselt und neu zusammengesetzt. Jedes Feld, das dabei
/// durch die Modellierung fiele, wäre eines, das der Server vermisst —
/// und man erführe es erst an einer Aufnahme, die nicht startet.
private func schedule(program: LiveProgram, path: String) async throws(MediaBackendError) {
let template = try await client.send(JellyfinRequest(
path: "/LiveTv/Timers/Defaults",
query: [URLQueryItem(name: "programId", value: program.id)]
))
_ = try await client.send(JellyfinRequest(
method: .post,
path: path,
body: template
))
}
}
// MARK: - Was Jellyfin über Vorgemerktes schickt
/// Ein vorgemerkter Termin, wie Jellyfin ihn meldet.
///
/// Kein `JellyfinItem`: Timer sind bei Jellyfin ausdrücklich **keine**
/// Einträge, sondern ein eigener Typ mit eigenen Feldern.
struct JellyfinTimer: Decodable, Sendable {
let id: String?
let programID: String?
let channelID: String?
let channelName: String?
let name: String?
let startDate: Date?
let endDate: Date?
let seriesTimerID: String?
private enum CodingKeys: String, CodingKey {
case id = "Id", programID = "ProgramId", channelID = "ChannelId"
case channelName = "ChannelName", name = "Name"
case startDate = "StartDate", endDate = "EndDate"
case seriesTimerID = "SeriesTimerId"
}
}
struct JellyfinTimerPage: Decodable, Sendable {
let items: [JellyfinTimer]
private enum CodingKeys: String, CodingKey { case items = "Items" }
}
/// Eine stehende Aufnahme für alles, was zu einer Reihe gehört.
struct JellyfinSeriesTimer: Decodable, Sendable {
let id: String?
let channelID: String?
let channelName: String?
let name: String?
/// Ob bereits Vorhandenes übersprungen wird — das, was man umgangssprachlich
/// „nur neue Folgen" nennt.
let skipEpisodesInLibrary: Bool?
let recordAnyChannel: Bool?
private enum CodingKeys: String, CodingKey {
case id = "Id", channelID = "ChannelId", channelName = "ChannelName"
case name = "Name", skipEpisodesInLibrary = "SkipEpisodesInLibrary"
case recordAnyChannel = "RecordAnyChannel"
}
}
struct JellyfinSeriesTimerPage: Decodable, Sendable {
let items: [JellyfinSeriesTimer]
private enum CodingKeys: String, CodingKey { case items = "Items" }
}
@@ -0,0 +1,82 @@
import Foundation
import MediaKit
/// Ein Abschnitt, den man überspringen können soll.
///
/// Jellyfin nennt sie seit 10.10 „Media Segments" und liefert sie über einen
/// eigenen Weg — **nicht** als Feld am Titel. Deshalb steht das hier und nicht
/// in `JellyfinItem`.
///
/// Gefüllt werden sie von einem Anbieter im Server, etwa dem Intro-Skipper.
/// Hat der Server keinen, kommt eine leere Liste — oder gar nichts, wenn er
/// älter ist als 10.10. Beides ist kein Fehler, sondern heisst schlicht: es
/// gibt hier nichts zu überspringen.
struct JellyfinSegment: Decodable, Sendable {
let type: String?
/// In Hundertnanosekunden, wie alles Zeitliche bei Jellyfin.
let startTicks: Int64?
let endTicks: Int64?
enum CodingKeys: String, CodingKey {
case type = "Type"
case startTicks = "StartTicks"
case endTicks = "EndTicks"
}
/// `nil`, wenn der Abschnitt keiner ist, mit dem die App etwas anfangen
/// kann — etwa eine Vorschau oder ein Rückblick. Lieber nichts anbieten
/// als einen Knopf, der an einer unerwarteten Stelle auftaucht.
var asMediaMarker: MediaMarker? {
guard let startTicks, let endTicks, endTicks > startTicks else { return nil }
let kind: MediaMarker.Kind
switch type?.lowercased() {
case "intro": kind = .intro
case "outro": kind = .credits
case "commercial": kind = .commercial
default: return nil
}
return MediaMarker(
kind: kind,
startMilliseconds: Int(startTicks / 10_000),
endMilliseconds: Int(endTicks / 10_000)
)
}
}
/// Die Antwort ist eine Seite wie jede andere bei Jellyfin.
private struct JellyfinSegmentPage: Decodable {
let items: [JellyfinSegment]
enum CodingKeys: String, CodingKey { case items = "Items" }
}
extension JellyfinBackend {
/// Holt Intro und Abspann zu einem Titel.
///
/// **Wirft nicht.** Ein Server ohne Anbieter antwortet mit einer leeren
/// Liste, einer vor 10.10 mit einem 404 — und ein Film ohne Intro ist
/// kein Fehler, sondern der Normalfall. Daran die Detailseite scheitern
/// zu lassen, wäre die schlechtere Hälfte des Tauschs.
///
/// **`includeSegmentTypes` zweimal, nicht einmal mit Komma.** Als
/// `Intro,Outro` beantwortet Jellyfin dieselbe Frage mit null Treffern;
/// als zwei Angaben kommen sie. Das steht nirgends in der Dokumentation
/// und kostet sonst eine Stunde Suche am falschen Ende.
func segments(forItem id: String) async -> [MediaMarker] {
do {
let page = try await client.send(
JellyfinRequest(
path: "/MediaSegments/\(id)",
query: [
URLQueryItem(name: "includeSegmentTypes", value: "Intro"),
URLQueryItem(name: "includeSegmentTypes", value: "Outro"),
]
),
as: JellyfinSegmentPage.self
)
return page.items.compactMap(\.asMediaMarker).sorted { $0.startMilliseconds < $1.startMilliseconds }
} catch {
return []
}
}
}
@@ -0,0 +1,736 @@
import Foundation
import MediaKit
/// Jellyfin als `MediaBackend`.
public struct JellyfinBackend: MediaBackend {
let client: JellyfinClient
let account: JellyfinAccount
/// Nicht `private`: Live-TV und Aufnahmen liegen in eigenen Dateien und
/// brauchen dieselben Bildpfade.
let images = JellyfinImagePaths()
/// Wo ein Titel hingehört — einmal gefragt, dann gemerkt.
///
/// Ein Actor, also ein Referenztyp: Kopien dieses Structs teilen sich
/// denselben und damit das Gedächtnis. Ohne das fragte jede Folge einer
/// Serie dasselbe noch einmal.
let ancestors = JellyfinLibraryMemo()
public let serverName: String
/// Konto **und** Adresse: dasselbe Konto auf zwei Servern ist nicht
/// dasselbe, und zwei Konten auf einem Server erst recht nicht.
public nonisolated var identity: String {
"jellyfin:\(client.baseURL.absoluteString):\(account.userID)"
}
public init(client: JellyfinClient, account: JellyfinAccount, serverName: String) {
self.client = client
self.account = account
self.serverName = serverName
}
public var isAdministrator: Bool { account.isAdministrator }
public nonisolated var capabilities: Set<BackendCapability> {
var capabilities: Set<BackendCapability> = [
.favourites, .liveTV, .liveTVRecordings, .libraryScan, .transcoding,
]
// Verwaltung nur, wenn das Konto sie auch darf. Den Bereich zu zeigen
// und bei jedem Tipp abzulehnen, wäre die schlechtere Antwort.
if account.isAdministrator { capabilities.insert(.administration) }
return capabilities
}
// MARK: - Blättern
/// Ob der Server diese Verbindung als „im eigenen Netz" ansieht.
///
/// **Gefragt, nicht geraten.** Ein Blick auf die Adresse taugt nicht: wer
/// über einen Reverse Proxy geht, erreicht seinen Server auch von der
/// Couch aus unter einem öffentlichen Namen. Umgekehrt kann eine private
/// Adresse über ein VPN von weit her kommen.
///
/// `/System/Endpoint` beantwortet es mit **demselben** Aufruf, mit dem
/// Jellyfin auch selbst entscheidet, ob es eine Bitratenleiter ausliefert.
/// Näher an der Wahrheit kommt man nicht heran.
///
/// Bei Zweifeln `false`: lieber einmal zu vorsichtig gedeckelt als eine
/// Wiedergabe, die über eine Fernverbindung gar nicht erst anläuft.
public func isInLocalNetwork() async -> Bool {
struct Lage: Decodable {
let isInNetwork: Bool
private enum CodingKeys: String, CodingKey { case isInNetwork = "IsInNetwork" }
}
let lage = try? await client.send(
JellyfinRequest(path: "/System/Endpoint"),
as: Lage.self
)
return lage?.isInNetwork ?? false
}
public func libraries() async throws(MediaBackendError) -> [MediaLibrary] {
let page = try await client.send(
JellyfinRequest(path: "/Users/\(account.userID)/Views"),
as: JellyfinItemPage.self
)
return page.items.map { view in
let kind = MediaLibrary.Kind(jellyfinCollectionType: view.collectionType)
return MediaLibrary(
id: view.id,
title: view.name ?? "—",
kind: kind,
thumb: images.primary(itemID: view.id, tag: nil)
)
}
}
public func homeRows() async throws(MediaBackendError) -> [MediaRowGroup] {
// Jellyfin hat keine fertigen Reihen wie Plex' Hubs — die Startseite
// wird aus einzelnen Abfragen zusammengesetzt. Das ist kein Mangel:
// so steht hier, was die Reihen bedeuten, statt dass es der Server
// entscheidet.
//
// Die Mediatheken werden streng geholt: sie sind die Probe darauf, ob
// der Server überhaupt antwortet. Alles Weitere ist nachsichtig —
// fällt „Als Nächstes" aus, soll deshalb nicht die ganze Startseite
// leer bleiben.
let libraries = try await libraries().filter(\.isSupported)
async let resuming = try? rowItems(path: "/Users/\(account.userID)/Items/Resume", query: [
URLQueryItem(name: "Limit", value: "20"),
URLQueryItem(name: "MediaTypes", value: "Video"),
URLQueryItem(name: "Fields", value: Self.fields),
])
async let nextUp = try? rowItems(path: "/Shows/NextUp", query: [
URLQueryItem(name: "userId", value: account.userID),
URLQueryItem(name: "Limit", value: "20"),
URLQueryItem(name: "Fields", value: Self.fields),
])
async let favourites = try? rowItems(path: "/Users/\(account.userID)/Items", query: [
URLQueryItem(name: "Filters", value: "IsFavorite"),
URLQueryItem(name: "Recursive", value: "true"),
URLQueryItem(name: "IncludeItemTypes", value: "Movie,Series"),
URLQueryItem(name: "Limit", value: "20"),
URLQueryItem(name: "Fields", value: Self.fields),
])
var rows = [
MediaRowGroup(
id: "resume",
title: String(localized: "row.continueWatching", bundle: .module),
items: await resuming ?? []
),
MediaRowGroup(
id: "nextup",
title: String(localized: "row.nextUp", bundle: .module),
items: await nextUp ?? []
),
]
// Je Mediathek eine eigene Reihe. „Neu in Filme" und „Neu in Serien"
// sind zwei verschiedene Fragen — in einer gemeinsamen Reihe steht am
// Ende beides durcheinander, und wer nach einem Film sucht, blättert
// an Folgen vorbei.
for library in libraries {
let items = try? await latestItems(limit: 20, inLibrary: library.id)
rows.append(MediaRowGroup(
id: "latest.\(library.id)",
title: String(
localized: "row.latestIn \(library.title)",
bundle: .module
),
libraryID: library.id,
items: items ?? []
))
}
rows.append(MediaRowGroup(
id: "favourites",
title: String(localized: "row.favourites", bundle: .module),
items: await favourites ?? []
))
// Leere Reihen wären auf der Startseite nur Überschriften ohne Inhalt.
return rows.filter { !$0.items.isEmpty }
}
public func items(
inLibrary library: MediaLibrary,
query: MediaQuery,
offset: Int,
limit: Int
) async throws(MediaBackendError) -> MediaPage {
var items: [URLQueryItem] = [
URLQueryItem(name: "ParentId", value: library.id),
URLQueryItem(name: "Recursive", value: "true"),
URLQueryItem(name: "StartIndex", value: String(offset)),
URLQueryItem(name: "Limit", value: String(limit)),
URLQueryItem(name: "Fields", value: Self.fields),
URLQueryItem(name: "SortBy", value: query.sort.jellyfinField),
URLQueryItem(name: "SortOrder", value: query.sort.isDescending ? "Descending" : "Ascending"),
]
// Ohne diese Angabe liefert eine Serienmediathek alle Folgen einzeln
// statt der Serien — dieselbe Falle wie bei Plex.
switch library.kind {
case .movie: items.append(URLQueryItem(name: "IncludeItemTypes", value: "Movie"))
case .show: items.append(URLQueryItem(name: "IncludeItemTypes", value: "Series"))
// Gemischter Inhalt: Filme **und** Serien, aber eben nur die. Ohne
// diese Angabe liefert `Recursive` auch jede einzelne Folge, und das
// Raster füllt sich mit Folge 3 von Staffel 2 statt mit Titeln.
case .other: items.append(URLQueryItem(name: "IncludeItemTypes", value: "Movie,Series"))
default: break
}
if query.unwatchedOnly { items.append(URLQueryItem(name: "Filters", value: "IsUnplayed")) }
if query.favouritesOnly { items.append(URLQueryItem(name: "Filters", value: "IsFavorite")) }
if let genre = query.genre { items.append(URLQueryItem(name: "Genres", value: genre)) }
if let year = query.year { items.append(URLQueryItem(name: "Years", value: String(year))) }
if let rating = query.contentRating {
items.append(URLQueryItem(name: "OfficialRatings", value: rating))
}
if let text = query.searchText { items.append(URLQueryItem(name: "searchTerm", value: text)) }
// Auflösung filtert Jellyfin nicht selbst; „4K" wird unten aussortiert.
let page = try await client.send(
JellyfinRequest(path: "/Users/\(account.userID)/Items", query: items),
as: JellyfinItemPage.self
)
var mapped = page.items.map { $0.asMediaItem(imageBase: images) }
var total = page.totalRecordCount ?? mapped.count
if query.resolution?.lowercased() == "4k" {
// Nach der Stufe, nicht nach der Zeilenzahl: ein breitwandiges
// 4K misst 3840 × 1608 und fiele durch jede Höhenschwelle,
// die 2160 erwartet.
mapped = mapped.filter { ($0.preferredVersion?.resolution ?? .sd) >= .uhd4K }
// Die Gesamtzahl stimmt dann nicht mehr — lieber die gefilterte
// Menge als eine Zahl, die zum Nachladen auffordert, obwohl nichts
// mehr kommt.
total = mapped.count + offset
}
return MediaPage(items: mapped, offset: offset, totalCount: total)
}
/// Die Mediathek über die Ahnenkette.
///
/// Jellyfin beantwortet das mit `/Items/{id}/Ancestors`: Staffel, Serie,
/// Mediathek, Wurzel. Gesucht ist der Eintrag vom Typ `CollectionFolder`
/// — das ist eine Mediathek. `UserRootFolder` ist die Wurzel darüber und
/// wäre für alle Titel dieselbe, also nutzlos.
///
/// Die Antwort wird gemerkt: innerhalb einer Serie fragt sonst jede Folge
/// dasselbe noch einmal.
public func libraryID(for item: MediaItem) async -> String? {
let schlüssel = item.seriesID ?? item.id
if let gemerkt = await ancestors.recorded(schlüssel) { return gemerkt.value }
let kette = try? await client.send(
JellyfinRequest(path: "/Items/\(schlüssel)/Ancestors"),
as: [JellyfinItem].self
)
let gefunden = kette?.first { $0.type == "CollectionFolder" }?.id
await ancestors.store(gefunden, for: schlüssel)
return gefunden
}
public func item(id: String) async throws(MediaBackendError) -> MediaItem {
// **Nebenher, nicht nacheinander.** Die Marken stehen bei Jellyfin
// hinter einem eigenen Weg; sie hinterher zu holen hiesse, die
// Detailseite um eine zweite Laufzeit warten zu lassen.
async let marken = segments(forItem: id)
let item = try await client.send(
JellyfinRequest(
path: "/Users/\(account.userID)/Items/\(id)",
query: [URLQueryItem(name: "Fields", value: Self.fields)]
),
as: JellyfinItem.self
)
return item.asMediaItem(imageBase: images, markers: await marken)
}
/// Alle Folgen einer Serie in **einer** Anfrage.
///
/// **Nicht `/Shows/NextUp`.** Der braucht `seriesID`, das bei einer Serie
/// selbst `nil` ist, versteckt vollständig gesehene Serien und bringt
/// eine eigene Zeitgrenze mit. Dann antworteten Plex und Jellyfin
/// verschieden auf dieselbe Frage — und welche Folge dran ist, soll eine
/// Regel entscheiden, nicht der Server.
///
/// `EnableUserData` ausdrücklich: ohne den Sehfortschritt wäre jede Folge
/// ungesehen, und es begänne immer bei der ersten.
public func episodes(inSeries id: String) async throws(MediaBackendError) -> [MediaItem] {
try await rowItems(path: "/Shows/\(id)/Episodes", query: [
URLQueryItem(name: "userId", value: account.userID),
URLQueryItem(name: "EnableUserData", value: "true"),
URLQueryItem(name: "Fields", value: Self.fields),
])
}
public func children(of id: String) async throws(MediaBackendError) -> [MediaItem] {
try await rowItems(path: "/Users/\(account.userID)/Items", query: [
URLQueryItem(name: "ParentId", value: id),
URLQueryItem(name: "SortBy", value: "SortName"),
URLQueryItem(name: "Fields", value: Self.fields),
])
}
public func search(_ text: String, limit: Int) async throws(MediaBackendError) -> [MediaRowGroup] {
let page = try await client.send(
JellyfinRequest(path: "/Users/\(account.userID)/Items", query: [
URLQueryItem(name: "searchTerm", value: text),
URLQueryItem(name: "Recursive", value: "true"),
URLQueryItem(name: "IncludeItemTypes", value: "Movie,Series,Episode"),
URLQueryItem(name: "Limit", value: String(limit)),
URLQueryItem(name: "Fields", value: Self.fields),
]),
as: JellyfinItemPage.self
)
// Nach Art gruppiert, weil Plex' Suche gruppiert zurückkommt und die
// Ergebnisansicht darauf ausgelegt ist.
let items = page.items.map { $0.asMediaItem(imageBase: images) }
let groups: [(MediaItem.Kind, String)] = [
(.movie, "search.movies"), (.show, "search.shows"), (.episode, "search.episodes"),
]
return groups.compactMap { kind, key in
let matching = items.filter { $0.kind == kind }
guard !matching.isEmpty else { return nil }
return MediaRowGroup(
id: kind.rawValue,
title: String(localized: String.LocalizationValue(key), bundle: .module),
items: matching
)
}
}
public func filterOptions(
inLibrary library: MediaLibrary,
field: MediaQuery.Field
) async throws(MediaBackendError) -> [MediaFilterOption] {
guard field == .genre else { return [] }
let page = try await client.send(
JellyfinRequest(path: "/Genres", query: [
URLQueryItem(name: "ParentId", value: library.id),
URLQueryItem(name: "userId", value: account.userID),
]),
as: JellyfinItemPage.self
)
return page.items.map { MediaFilterOption(id: $0.name ?? $0.id, title: $0.name ?? "—") }
}
public func nextEpisode(after item: MediaItem) async throws(MediaBackendError) -> MediaItem? {
guard let seriesID = item.seriesID else { return nil }
let next = try await rowItems(path: "/Shows/NextUp", query: [
URLQueryItem(name: "userId", value: account.userID),
URLQueryItem(name: "SeriesId", value: seriesID),
URLQueryItem(name: "Limit", value: "1"),
URLQueryItem(name: "Fields", value: Self.fields),
])
// Die laufende Folge selbst ist nicht die nächste.
return next.first { $0.id != item.id }
}
// MARK: - Bilder
public nonisolated func artworkURL(path: String?, width: Int, height: Int) -> URL? {
guard let path else { return nil }
return imageURL(path: path, extra: [
URLQueryItem(name: "fillWidth", value: String(width)),
URLQueryItem(name: "fillHeight", value: String(height)),
URLQueryItem(name: "quality", value: "90"),
])
}
public nonisolated func rawImageURL(path: String?) -> URL? {
guard let path else { return nil }
return imageURL(path: path, extra: [])
}
/// Setzt Pfad, mitgelieferte Marke und Größenangaben zusammen.
private nonisolated func imageURL(path: String, extra: [URLQueryItem]) -> URL? {
let pieces = path.split(separator: "?", maxSplits: 1)
var query = extra
if pieces.count > 1 {
query += URLComponents(string: "?\(pieces[1])")?.queryItems ?? []
}
// Bilder holt kein angemeldeter Client, sondern eine Bildansicht — sie
// schickt keine Kopfzeilen mit, also muss der Schlüssel in die Adresse.
return client.url(path: String(pieces[0]), query: query, includingToken: account.token)
}
// MARK: - Herunterladen
public nonisolated func downloadURL(for file: MediaFile) -> URL? {
// Im Pfad steht der Titel, in der Abfrage die Fassung. Beides zu
// vertauschen fällt bei einem Film mit nur einer Fassung nicht auf —
// die Kennungen sind dann dieselbe — und bei zweien sofort.
guard let itemID = file.itemID ?? file.key else { return nil }
return client.url(
path: "/Videos/\(itemID)/stream",
query: [
URLQueryItem(name: "static", value: "true"),
URLQueryItem(name: "mediaSourceId", value: file.key ?? itemID),
],
includingToken: account.token
)
}
/// Dieselbe Datei in einer MP4-Hülle.
///
/// **Für Downloads, die sonst offline unspielbar wären.** Eine Matroska
/// mit H.264 und AC3 ist inhaltlich tadellos — nur die Schachtel öffnet
/// AVFoundation nicht. Online hilft derselbe Weg schon
/// (`PlaybackDecision.remux`); auf dem Gerät gibt es hinterher niemanden
/// mehr, der umpacken könnte. Also beim Herunterladen.
///
/// `allowVideoStreamCopy`/`allowAudioStreamCopy` sind der ganze Punkt: der
/// Server soll die Ströme **durchreichen**, wenn sie in die Liste passen,
/// und nur umrechnen, was nicht passt. Das Bild bleibt damit das Original;
/// nur DTS oder TrueHD werden zu AC3.
///
/// NOCH NICHT AN EINEM ECHTEN SERVER GEPRÜFT: ob Jellyfin das Kopieren
/// hier tatsächlich wählt, steht erst nach dem ersten Download fest — die
/// Dateigröße verrät es.
public nonisolated func remuxDownloadURL(
itemID: String,
versionIndex: Int,
audioStreamIndex: Int?,
subtitleStreamIndex: Int?
) -> URL? {
var query = [
URLQueryItem(name: "videoCodec", value: "h264,hevc"),
URLQueryItem(name: "audioCodec", value: "aac,ac3,eac3"),
URLQueryItem(name: "allowVideoStreamCopy", value: "true"),
URLQueryItem(name: "allowAudioStreamCopy", value: "true"),
URLQueryItem(name: "TranscodingContainer", value: "mp4"),
URLQueryItem(name: "TranscodingMaxAudioChannels", value: "6"),
]
// **Eine Tonspur, nicht alle.** Jellyfin bildet beim Umpacken genau
// eine ab; welche, muss hier stehen. Ohne diese Zeile nimmt der Server
// seine eigene Vorgabe — und das ist bei deutschen Aufnahmen
// regelmäßig die mit Audiodeskription.
if let audioStreamIndex {
query.append(URLQueryItem(name: "audioStreamIndex", value: String(audioStreamIndex)))
}
// `Embed` legt den Untertitel als **Spur** in die Datei, statt ihn ins
// Bild zu brennen. Nur so bleibt er abschaltbar.
if let subtitleStreamIndex {
query.append(URLQueryItem(name: "subtitleStreamIndex", value: String(subtitleStreamIndex)))
query.append(URLQueryItem(name: "subtitleMethod", value: "Embed"))
// **`mov_text`, und zwar ausdrücklich.** Ohne diese Zeile
// antwortet der Server mit `500`. Gemessen an einem echten
// Jellyfin: derselbe Aufruf ohne Untertitel liefert 61 MB, mit
// Untertitel und ohne Kodierer-Angabe einen Fehler, mit
// `mov_text` 42 MB samt Spur. MP4 kennt kein SubRip; wer den
// Untertitel hineinlegen will, muss sagen, als was.
query.append(URLQueryItem(name: "subtitleCodec", value: "mov_text"))
}
return client.url(
path: "/Videos/\(itemID)/stream.mp4",
query: query + Self.transcodeSession(itemID: itemID, deviceID: client.deviceID),
includingToken: account.token
)
}
/// Wer fragt, und für welche Sitzung.
///
/// **Ohne diese zwei Angaben liefert der Server gar nichts.** Gemessen an
/// einem echten Jellyfin: dieselbe Adresse ohne sie antwortet mit `200`
/// und `Content-Length: 0` — kein Fehler, keine Datei, nichts. Mit ihnen
/// kamen in fünfzehn Sekunden 66 MB.
///
/// Das betraf auch die verkleinerten Fassungen: die hatten sie nie, und
/// damit war „in 480p herunterladen" die ganze Zeit folgenlos.
///
/// Die Sitzungskennung ist je Auftrag neu, nicht je Titel. Zwei Anfragen
/// unter derselben Kennung hielt der Server für dieselbe Sitzung und
/// antwortete der zweiten mit einer leeren Datei. Gebaut wird die Adresse
/// einmal beim Anlegen des Auftrags; ein Wiederholversuch nimmt dieselbe
/// und bleibt damit dieselbe Sitzung.
private static func transcodeSession(itemID: String, deviceID: String) -> [URLQueryItem] {
[
URLQueryItem(name: "deviceId", value: deviceID),
URLQueryItem(name: "playSessionId", value: "halo-\(UUID().uuidString)"),
]
}
/// Eine verkleinerte Fassung zum Herunterladen.
///
/// **Fortlaufendes MP4, nicht HLS.** Zum Abspielen liefert Jellyfin eine
/// Abspielliste mit hunderten Schnipseln; das ist für einen Stream richtig
/// und für eine Datei, die auf dem Gerät liegen soll, unbrauchbar. Der
/// Endpunkt `/Videos/{id}/stream.mp4` gibt dieselbe Umrechnung als **eine**
/// Datei heraus.
///
/// Hier wird die Adresse selbst gebaut statt über `/PlaybackInfo` erfragt —
/// anders als beim Abspielen. Der Grund ist die Signatur: diese Stelle darf
/// keine Anfrage stellen, weil die Oberfläche die Adresse beim Zeichnen
/// braucht. Das ist vertretbar, weil hier nichts zu entscheiden ist: die
/// Stufe steht fest, der Server soll sie liefern.
///
/// Die Größe bleibt eine Schätzung. Eine Umrechnung, die erst beim Abruf
/// beginnt, kennt ihre eigene Länge nicht — der Fortschrittsbalken rechnet
/// deshalb gegen die geschätzten Bytes, nicht gegen eine Zusage des Servers.
public nonisolated func compressedDownloadURL(
itemID: String,
versionIndex: Int,
maximumBitrate: Int,
resolution: String?
) -> URL? {
var query = [
URLQueryItem(name: "videoCodec", value: "h264"),
URLQueryItem(name: "audioCodec", value: "aac"),
// Jellyfin rechnet in Bit, die App in Kilobit.
URLQueryItem(name: "videoBitRate", value: String(maximumBitrate * 1_000)),
URLQueryItem(name: "audioBitRate", value: "128000"),
// Zwei Kanäle: ein Handy hat keine Rückkanäle, und 5.1 auf zwei
// Lautsprecher heruntergemischt klingt schlechter als von
// vornherein Stereo.
URLQueryItem(name: "TranscodingMaxAudioChannels", value: "2"),
URLQueryItem(name: "TranscodingContainer", value: "mp4"),
]
if let height = resolution?.split(separator: "x").last.flatMap({ Int($0) }) {
query.append(URLQueryItem(name: "maxHeight", value: String(height)))
}
return client.url(
path: "/Videos/\(itemID)/stream.mp4",
query: query + Self.transcodeSession(itemID: itemID, deviceID: client.deviceID),
includingToken: account.token
)
}
// MARK: - Zustand melden
public func markWatched(id: String) async throws(MediaBackendError) {
try await client.send(JellyfinRequest(
method: .post,
path: "/Users/\(account.userID)/PlayedItems/\(id)"
))
}
public func markUnwatched(id: String) async throws(MediaBackendError) {
try await client.send(JellyfinRequest(
method: .delete,
path: "/Users/\(account.userID)/PlayedItems/\(id)"
))
}
/// Setzt die gemerkte Stelle auf null — damit fällt der Titel aus
/// „Weiterschauen" heraus.
///
/// **Jellyfin kennt dafür keinen eigenen Weg.** Ein Vorschlag, einen
/// Titel mit einem Merker auszublenden, liegt seit Jahren als Entwurf
/// vor; was es gibt, ist das Benutzerdatenblatt. „Weiterschauen" führt,
/// was eine Stelle grösser null hat — also nimmt man sie weg.
///
/// **Das Blatt wird gebaut, nicht zurückgelesen.** Hier stand erst ein
/// `GET` auf denselben Pfad, und genau daran scheiterte es stumm: diese
/// Stelle war die einzige im ganzen Projekt, die ein Benutzerdatenblatt
/// eigens liest — überall sonst kommt es am Titel mit. Es gab also kein
/// zweites Beispiel, an dem der Weg je funktioniert hätte, und ein 404
/// beendete die Sache, bevor überhaupt etwas geschrieben wurde.
///
/// Gelesen wird trotzdem, aber **unverbindlich**: gelingt es, bleiben
/// auch Felder erhalten, die diese App gar nicht kennt — Jellyfins
/// Daumen etwa. Gelingt es nicht, wird aus dem Titel gebaut, was wir
/// wissen. Weggeworfen wird in keinem Fall mehr als die Stelle.
public func removeFromContinueWatching(_ item: MediaItem) async throws(MediaBackendError) {
var blatt = await userData(forItem: item.id) ?? [
"Played": item.isWatched,
"IsFavorite": item.isFavourite,
"PlayCount": item.playCount ?? 0,
]
blatt["PlaybackPositionTicks"] = 0
// Ohne das bliebe der Anteil stehen, und manche Ansicht rechnet den
// Balken daraus statt aus der Stelle.
blatt["PlayedPercentage"] = NSNull()
guard let körper = try? JSONSerialization.data(withJSONObject: blatt) else {
throw .decoding(description: "Benutzerdaten zu \(item.id) liessen sich nicht schreiben")
}
try await writeUserData(körper, forItem: item.id)
}
/// Das Benutzerdatenblatt, wenn der Server es herausgibt.
///
/// `nil` statt eines Fehlers: ob es diesen Weg gibt, hängt an der
/// Serverfassung, und die Antwort darauf darf die Aktion nicht
/// aufhalten.
private func userData(forItem id: String) async -> [String: Any]? {
for pfad in Self.userDataPaths(itemID: id, userID: account.userID) {
guard let roh = try? await client.send(JellyfinRequest(
path: pfad.path,
query: pfad.query
)) else { continue }
if let blatt = (try? JSONSerialization.jsonObject(with: roh)) as? [String: Any],
// Der Auffangzweig mancher Server antwortet mit einer leeren
// Seite und Status 200. Die ist kein Blatt.
blatt["PlaybackPositionTicks"] != nil || blatt["Played"] != nil {
return blatt
}
}
return nil
}
/// Schreibt das Blatt zurück — über den Weg, den dieser Server kennt.
private func writeUserData(_ body: Data, forItem id: String) async throws(MediaBackendError) {
var letzter: MediaBackendError?
for pfad in Self.userDataPaths(itemID: id, userID: account.userID) {
do {
try await client.send(JellyfinRequest(
method: .post,
path: pfad.path,
query: pfad.query,
body: body
))
return
} catch {
letzter = error
// Nur ein „gibt es hier nicht" rechtfertigt den zweiten
// Versuch. Ein abgelehnter Zugriff oder ein Serverfehler
// würde beim anderen Pfad genauso ausgehen.
guard case .notFound = error else { throw error }
}
}
throw letzter ?? .notFound
}
/// Die beiden Wege zum Benutzerdatenblatt, neuerer zuerst.
///
/// Jellyfin hat die verstreuten Aufrufe in `/UserItems/{id}/UserData`
/// gebündelt; ältere Fassungen kennen nur `/Users/{id}/Items/{id}/UserData`.
/// Welcher gilt, sagt einem der Server erst, wenn man fragt.
private static func userDataPaths(
itemID: String,
userID: String
) -> [(path: String, query: [URLQueryItem])] {
[
("/UserItems/\(itemID)/UserData", [URLQueryItem(name: "userId", value: userID)]),
("/Users/\(userID)/Items/\(itemID)/UserData", []),
]
}
public func setFavourite(_ isFavourite: Bool, id: String) async throws(MediaBackendError) {
try await client.send(JellyfinRequest(
method: isFavourite ? .post : .delete,
path: "/Users/\(account.userID)/FavoriteItems/\(id)"
))
}
public func rate(id: String, rating: Int) async throws(MediaBackendError) {
// Jellyfin kennt nur Daumen hoch und runter, keine Sterne. Eine Zahl
// von eins bis zehn hier hineinzupressen hieße, etwas zu behaupten,
// was der Server nicht speichert.
throw .notFound
}
public func refreshLibrary(_ library: MediaLibrary) async throws(MediaBackendError) {
try await client.send(JellyfinRequest(
method: .post,
path: "/Items/\(library.id)/Refresh",
query: [URLQueryItem(name: "Recursive", value: "true")]
))
}
// MARK: - Hilfen
/// Die Neuzugänge, wie der Server sie selbst versteht.
///
/// Ein eigener Endpunkt statt einer Liste, sortiert nach Anlegedatum —
/// und das aus zwei Gründen. Der erste ist die Sortierung selbst: sie kam
/// alphabetisch zurück, nicht nach Datum. Der zweite wiegt schwerer und
/// bliebe auch bestehen, wenn die Sortierung griffe: das Anlegedatum einer
/// **Serie** ist der Tag, an dem sie in die Mediathek kam. Eine Serie, die
/// seit zwei Jahren dort steht und gestern eine neue Folge bekommen hat,
/// ist neu — nach Anlegedatum wäre sie es nie.
///
/// Die Antwort ist hier ausnahmsweise ein nacktes Feld, kein Objekt mit
/// `Items` darin.
private func latestItems(
limit: Int,
inLibrary libraryID: String? = nil
) async throws(MediaBackendError) -> [MediaItem] {
var query = [
URLQueryItem(name: "Limit", value: String(limit)),
URLQueryItem(name: "Fields", value: Self.fields),
// Neue Folgen erscheinen als ihre Serie statt als zwölf
// Einzelkacheln, die alle dasselbe Plakat tragen.
URLQueryItem(name: "GroupItems", value: "true"),
]
if let libraryID { query.append(URLQueryItem(name: "ParentId", value: libraryID)) }
let items = try await client.send(
JellyfinRequest(path: "/Users/\(account.userID)/Items/Latest", query: query),
as: [JellyfinItem].self
)
return items.map { $0.asMediaItem(imageBase: images) }
}
private func rowItems(
path: String,
query: [URLQueryItem]
) async throws(MediaBackendError) -> [MediaItem] {
let page = try await client.send(
JellyfinRequest(path: path, query: query),
as: JellyfinItemPage.self
)
return page.items.map { $0.asMediaItem(imageBase: images) }
}
/// Was Jellyfin zusätzlich mitschicken soll.
///
/// Ohne diese Liste fehlen Beschreibung, Besetzung und Fassungen — und
/// jede Detailseite bräuchte eine zweite Anfrage.
private static let fields = "Overview,Genres,People,MediaSources,DateCreated,ParentId"
}
extension MediaQuery.Sort {
var jellyfinField: String {
switch self {
case .titleAscending, .titleDescending: "SortName"
case .recentlyAdded: "DateCreated"
case .releaseDate: "PremiereDate"
case .rating: "CommunityRating"
case .lastPlayed: "DatePlayed"
}
}
var isDescending: Bool {
switch self {
case .titleAscending: false
case .titleDescending, .recentlyAdded, .releaseDate, .rating, .lastPlayed: true
}
}
}
extension MediaLibrary.Kind {
/// Was Jellyfin als Art einer Mediathek meldet.
///
/// **Eigene Funktion, damit sie prüfbar ist.** Die Zuordnung entscheidet
/// mehr, als sie aussieht: was hier als `.photo` oder `.music` landet,
/// fällt unter `MediaLibrary.isSupported` heraus und ist in der App
/// **gar nicht vorhanden** — keine Reihe, kein Eintrag in der Leiste,
/// nicht einmal in der Auswahl für den Aufmacher. Ein Fehler hier sieht
/// aus wie eine Mediathek, die der Server nicht liefert.
///
/// Genau das war mit `homevideos` der Fall: Jellyfin nennt „Heimvideos &
/// Fotos" so, und weil Fotos im Namen stehen, lag es bei den Fotos. Der
/// Inhalt sind Videos.
init(jellyfinCollectionType: String?) {
switch jellyfinCollectionType {
case "movies": self = .movie
case "tvshows": self = .show
case "music": self = .music
case "photos": self = .photo
case "livetv": self = .liveTV
// Auch `homevideos`, `musicvideos`, `mixed` und alles Unbekannte:
// Videos, die man ansehen kann, auch wenn der Server sie nicht
// einsortiert.
default: self = .other
}
}
}
@@ -0,0 +1,198 @@
import Foundation
import MediaKit
/// Wer wir für einen Jellyfin-Server sind.
///
/// Jellyfin erwartet all das in **einer** Kopfzeile, in einer eigenen
/// Schreibweise. Die Gerätekennung muss über Starts hinweg gleich bleiben:
/// wechselt sie, gilt die App als neues Gerät, und in der Geräteliste des
/// Servers sammelt sich Müll an.
public struct JellyfinIdentity: Sendable, Equatable {
public let client: String
public let device: String
public let deviceID: String
public let version: String
public init(client: String, device: String, deviceID: String, version: String) {
self.client = client
self.device = device
self.deviceID = deviceID
self.version = version
}
/// Die Kopfzeile, mit oder ohne Token.
///
/// Anführungszeichen sind Pflicht, auch um Werte ohne Leerzeichen —
/// Jellyfin zerlegt die Zeile sonst falsch und meldet, man sei nicht
/// angemeldet, obwohl der Token stimmt.
func authorizationHeader(token: String?) -> String {
var fields = [
"Client=\"\(client)\"",
"Device=\"\(device)\"",
"DeviceId=\"\(deviceID)\"",
"Version=\"\(version)\"",
]
if let token { fields.append("Token=\"\(token)\"") }
return "MediaBrowser " + fields.joined(separator: ", ")
}
}
/// Spricht mit einem Jellyfin-Server.
public actor JellyfinClient {
public nonisolated let baseURL: URL
private let identity: JellyfinIdentity
private let transport: any JellyfinTransport
private var token: String?
public init(
baseURL: URL,
identity: JellyfinIdentity,
token: String? = nil,
transport: any JellyfinTransport = URLSession.shared
) {
self.baseURL = baseURL
self.identity = identity
self.token = token
self.transport = transport
}
public func setToken(_ token: String?) {
self.token = token
}
/// Unter welcher Kennung der Server dieses Gerät führt.
///
/// Wird zum Abräumen gebraucht: eine laufende Umrechnung wird über Gerät
/// **und** Sitzung angehalten, nicht über die Sitzung allein.
public nonisolated var deviceID: String { identity.deviceID }
public nonisolated func authorization(token: String?) -> String {
identity.authorizationHeader(token: token)
}
/// Die Adresse eines Endpunkts, mit Token in der Abfrage.
///
/// Für alles, was nicht die App abruft, sondern der Systemplayer oder eine
/// Bildansicht: die schicken keine Kopfzeilen mit. Bei Plex hat genau das
/// tagelang gekostet — hier steht es von Anfang an.
public nonisolated func url(
path: String,
query: [URLQueryItem] = [],
includingToken token: String? = nil
) -> URL? {
guard var components = URLComponents(
url: baseURL.appending(path: path),
resolvingAgainstBaseURL: false
) else { return nil }
var items = query
if let token { items.append(URLQueryItem(name: "ApiKey", value: token)) }
components.queryItems = items.isEmpty ? nil : items
return components.url
}
public nonisolated var currentTokenForURLs: String? { nil }
// MARK: - Anfragen
public func send<Value: Decodable & Sendable>(
_ request: JellyfinRequest,
as type: Value.Type
) async throws(MediaBackendError) -> Value {
let data = try await send(request)
do {
return try Self.decoder.decode(Value.self, from: data)
} catch {
throw .decoding(description: String(describing: error))
}
}
@discardableResult
public func send(_ request: JellyfinRequest) async throws(MediaBackendError) -> Data {
guard var components = URLComponents(
url: baseURL.appending(path: request.path),
resolvingAgainstBaseURL: false
) else {
throw .transport(description: "Adresse nicht baubar")
}
components.queryItems = request.query.isEmpty ? nil : request.query
guard let url = components.url else {
throw .transport(description: "Adresse nicht baubar")
}
var urlRequest = URLRequest(url: url)
urlRequest.httpMethod = request.method.rawValue
urlRequest.timeoutInterval = request.timeout.seconds
urlRequest.setValue(identity.authorizationHeader(token: token), forHTTPHeaderField: "Authorization")
urlRequest.setValue("application/json", forHTTPHeaderField: "Accept")
if let body = request.body {
urlRequest.httpBody = body
urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type")
}
let data: Data
let response: URLResponse
do {
(data, response) = try await transport.data(for: urlRequest)
} catch {
throw .transport(description: error.localizedDescription)
}
guard let http = response as? HTTPURLResponse else {
throw .transport(description: "Keine HTTP-Antwort")
}
switch http.statusCode {
case 200..<300: return data
case 401, 403: throw .unauthorized
case 404: throw .notFound
default: throw .server(status: http.statusCode)
}
}
/// Jellyfin schreibt seine Felder groß und rechnet Zeiten in Ticks.
///
/// Kein `convertFromPascalCase` — das gibt es nicht, und die Felder
/// tragen ihre Schreibweise in den `CodingKeys` der Modelle.
private static let decoder: JSONDecoder = {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
return decoder
}()
}
/// Eine Anfrage an Jellyfin.
public struct JellyfinRequest: Sendable {
public enum Method: String, Sendable { case get = "GET", post = "POST", delete = "DELETE" }
public let method: Method
public let path: String
public let query: [URLQueryItem]
public let body: Data?
public let timeout: Duration
public init(
method: Method = .get,
path: String,
query: [URLQueryItem] = [],
body: Data? = nil,
timeout: Duration = .seconds(30)
) {
self.method = method
self.path = path
self.query = query
self.body = body
self.timeout = timeout
}
}
/// Wie Anfragen tatsächlich verschickt werden.
///
/// Dieselbe Trennung wie bei Plex: Tests brauchen keinen Server, sondern
/// vorbereitete Antworten.
public protocol JellyfinTransport: Sendable {
func data(for request: URLRequest) async throws -> (Data, URLResponse)
}
extension URLSession: JellyfinTransport {}
@@ -0,0 +1,44 @@
import Foundation
#if canImport(UIKit)
import UIKit
#endif
extension JellyfinIdentity {
private static let storageKey = "halo.jellyfin.deviceID"
/// Baut die Kennung für das laufende Gerät und merkt sie sich.
///
/// Dieselbe Vorsicht wie bei Plex: wechselt die Gerätekennung, gilt die
/// App dem Server als neues Gerät. In der Geräteliste steht dann bei jedem
/// Start ein weiterer Eintrag, und laufende Sitzungen lassen sich nicht
/// mehr zuordnen.
///
/// `@MainActor`, weil `UIDevice.current` dort beheimatet ist.
@MainActor
public static func current(
client: String,
version: String,
defaults: UserDefaults = .standard
) -> JellyfinIdentity {
let deviceID: String
if let stored = defaults.string(forKey: storageKey) {
deviceID = stored
} else {
deviceID = UUID().uuidString
defaults.set(deviceID, forKey: storageKey)
}
#if canImport(UIKit)
let device = UIDevice.current.name
#else
let device = ProcessInfo.processInfo.hostName
#endif
return JellyfinIdentity(
client: client,
device: device,
deviceID: deviceID,
version: version
)
}
}
@@ -0,0 +1,203 @@
import Foundation
import MediaKit
/// Die Übersetzung der Jellyfin-Modelle in die neutrale Form.
///
/// **Hier und nicht in den Ansichten.** Jeder Server bringt seine eigenen
/// Felder mit; sie genau einmal an der Modulgrenze zu übersetzen ist der
/// Preis dafür, dass es die Ansichten nur einmal gibt. Kommt ein zweiter
/// Server dazu, bekommt er seine eigene Datei dieser Art — und sonst nichts.
extension JellyfinItem {
/// `markers` kommen von aussen, weil Jellyfin sie nicht am Titel
/// liefert: sie stehen hinter `/MediaSegments/{id}` und damit hinter
/// einer zweiten Anfrage. Siehe `JellyfinBackend.segments(forItem:)`.
public func asMediaItem(
imageBase: JellyfinImagePaths,
markers: [MediaMarker] = []
) -> MediaItem {
MediaItem(
id: id,
path: nil,
kind: Self.kind(type),
title: name ?? "—",
summary: overview,
year: productionYear,
duration: runTimeTicks?.millisecondsFromTicks,
contentRating: officialRating,
// Jellyfin führt beide auf einer Zehnerskala, wie Plex auch.
rating: criticRating.map { $0 / 10 } ?? communityRating,
audienceRating: communityRating,
// **Nur wenn es auch eines gibt** — dieselbe Vorsicht wie eine
// Zeile tiefer beim Hintergrundbild, die hier gefehlt hat.
//
// Die Adresse entsteht aus der Kennung, nicht aus einer Antwort
// des Servers. Ohne die Prüfung trug jeder Titel ohne eigenes
// Plakat eine Adresse, die mit „nicht gefunden" antwortet — und
// weil ein 404 zu Recht nicht wiederholt wird, blieb die Kachel
// für immer grau. Betroffen waren vor allem Staffeln: die haben
// bei Jellyfin oft kein eigenes Bild, sondern leihen das der
// Serie. Ist hier nichts, darf die Kachel genau das tun.
thumb: imageTags?["Primary"].map { imageBase.primary(itemID: id, tag: $0) },
// Nur wenn es auch eines gibt: die Adresse entsteht aus der
// Kennung, nicht aus einer Antwort des Servers — ohne diese
// Prüfung stünde hinter jedem Titel ohne Hintergrundbild eine
// Adresse, die mit „nicht gefunden" antwortet. Fehlt dem Titel
// eines, nennt Jellyfin die Serie, von der es geliehen werden darf.
art: backdropImageTags?.first.map { imageBase.backdrop(itemID: id, tag: $0) }
?? parentBackdropItemID.map { imageBase.backdrop(itemID: $0, tag: nil) },
parentThumb: seasonID.map { imageBase.primary(itemID: $0, tag: nil) },
seriesThumb: seriesID.map { imageBase.primary(itemID: $0, tag: seriesPrimaryImageTag) },
seriesArt: seriesID.map { imageBase.backdrop(itemID: $0, tag: nil) },
index: indexNumber,
parentIndex: parentIndexNumber,
parentTitle: seasonName,
parentID: seasonID,
seriesTitle: seriesName,
seriesID: seriesID,
playCount: userData?.playCount,
resumeMilliseconds: userData?.playbackPositionTicks?.millisecondsFromTicks,
lastPlayedAt: nil,
addedAt: dateCreated,
// Das Feld, das es bei Plex nicht gibt — hier kommt es frei mit.
isFavourite: userData?.isFavorite ?? false,
episodeCount: recursiveItemCount,
playedEpisodeCount: playedCount,
childCount: childCount,
versions: (mediaSources ?? []).map { $0.asMediaVersion(itemID: id) },
genres: genres ?? [],
directors: (people ?? []).filter { $0.type == "Director" }.compactMap(\.name),
writers: (people ?? []).filter { $0.type == "Writer" }.compactMap(\.name),
cast: (people ?? []).filter { $0.type == "Actor" }.map { person in
MediaPerson(
id: person.id ?? person.name ?? UUID().uuidString,
name: person.name ?? "—",
role: person.role,
thumb: person.id.map { imageBase.primary(itemID: $0, tag: person.primaryImageTag) } ?? nil
)
},
markers: markers,
artwork: logoArtwork(imageBase: imageBase)
)
}
/// Wie viele Folgen gesehen sind.
///
/// Jellyfin meldet die **ungesehenen**. Die Zahl, die die Oberfläche
/// zeigt, ist die andere — und sie auszurechnen ist ehrlicher, als das
/// Gegenteil anzuzeigen und es „Fortschritt" zu nennen.
private var playedCount: Int? {
guard let total = recursiveItemCount, let unplayed = userData?.unplayedItemCount else {
return nil
}
return max(0, total - unplayed)
}
private func logoArtwork(imageBase: JellyfinImagePaths) -> [MediaArtwork] {
guard let tag = imageTags?["Logo"] else { return [] }
return [MediaArtwork(kind: .clearLogo, path: imageBase.logo(itemID: id, tag: tag))]
}
private static func kind(_ type: String?) -> MediaItem.Kind? {
switch type {
case "Movie": .movie
case "Series": .show
case "Season": .season
case "Episode": .episode
case "BoxSet": .collection
case "Trailer", "Video": .clip
default: nil
}
}
}
extension JellyfinMediaSource {
public func asMediaVersion(itemID: String) -> MediaVersion {
let streams = mediaStreams ?? []
let video = streams.first { $0.type == "Video" }
let audio = streams.first { $0.type == "Audio" && $0.isDefault == true }
?? streams.first { $0.type == "Audio" }
return MediaVersion(
id: id ?? itemID,
container: container,
videoCodec: video?.codec,
audioCodec: audio?.codec,
videoResolution: video?.height.map(String.init),
// Jellyfin rechnet in Bit je Sekunde, die App in Kilobit.
bitrate: bitrate.map { $0 / 1_000 },
width: video?.width,
height: video?.height,
audioChannels: audio?.channels,
duration: runTimeTicks?.millisecondsFromTicks,
files: [
// Jellyfin trennt Fassung und Datei nicht: eine Quelle ist
// beides. Die neutrale Form kennt beide Ebenen, weil Plex sie
// trennt — hier ist die zweite eben einelementig.
MediaFile(
id: id ?? itemID,
key: id ?? itemID,
itemID: itemID,
container: container,
size: size,
duration: runTimeTicks?.millisecondsFromTicks,
streams: streams.compactMap(\.asMediaStream)
)
]
)
}
}
extension JellyfinMediaStream {
public var asMediaStream: MediaStream? {
let mapped: MediaStream.Kind
switch type {
case "Video": mapped = .video
case "Audio": mapped = .audio
case "Subtitle": mapped = .subtitle
default: return nil
}
return MediaStream(
id: String(index ?? 0),
kind: mapped,
codec: codec,
language: language,
languageCode: language,
displayTitle: displayTitle,
channels: channels,
isSelected: isDefault ?? false,
isForced: isForced ?? false,
title: title,
isDefault: isDefault ?? false,
isHearingImpaired: isHearingImpaired ?? false,
isOriginal: isOriginal ?? false
)
}
}
/// Baut die Adressen der Bilder.
///
/// Eigener Typ, weil jedes Bild eine eigene Adresse ist und nicht ein Pfad wie
/// bei Plex. Die Marke (`tag`) gehört dazu: ohne sie liefert der Server zwar
/// ein Bild, aber der Zwischenspeicher merkt nicht, wenn es sich ändert.
public struct JellyfinImagePaths: Sendable {
public init() {}
public func primary(itemID: String, tag: String?) -> String {
path(itemID: itemID, kind: "Primary", tag: tag)
}
public func backdrop(itemID: String, tag: String?) -> String {
path(itemID: itemID, kind: "Backdrop", tag: tag)
}
public func logo(itemID: String, tag: String?) -> String {
path(itemID: itemID, kind: "Logo", tag: tag)
}
private func path(itemID: String, kind: String, tag: String?) -> String {
var path = "/Items/\(itemID)/Images/\(kind)"
if let tag { path += "?tag=\(tag)" }
return path
}
}
@@ -0,0 +1,221 @@
import Foundation
import MediaKit
/// Ein Eintrag, wie Jellyfin ihn über die Leitung schickt.
///
/// Die Felder heißen groß und die Zeiten sind Ticks zu hundert Nanosekunden —
/// beides bleibt hier unten. Nach oben geht nur `MediaItem`.
public struct JellyfinItem: Decodable, Sendable {
public let id: String
public let name: String?
public let type: String?
public let overview: String?
public let productionYear: Int?
/// Laufzeit in Ticks zu 100 ns.
public let runTimeTicks: Int64?
public let indexNumber: Int?
public let parentIndexNumber: Int?
public let seriesName: String?
public let seriesID: String?
public let seasonID: String?
public let seasonName: String?
public let officialRating: String?
public let communityRating: Double?
public let criticRating: Double?
public let dateCreated: Date?
public let childCount: Int?
public let recursiveItemCount: Int?
public let genres: [String]?
public let people: [Person]?
public let userData: UserData?
public let imageTags: [String: String]?
public let backdropImageTags: [String]?
public let parentBackdropItemID: String?
public let seriesPrimaryImageTag: String?
public let mediaSources: [JellyfinMediaSource]?
public let collectionType: String?
// MARK: - Live-TV
//
// Sender **und** Sendungen kommen bei Jellyfin als derselbe Typ wie
// Filme und Folgen. Deshalb stehen ihre Felder hier und nicht in einem
// eigenen Modell: ein zweites, das zu neunzig Prozent dasselbe trägt,
// wäre eine Kopie mit Pflegeaufwand.
/// Zu welchem Sender eine Sendung gehört.
public let channelID: String?
public let channelName: String?
/// Der Folgentitel einer Sendung — der Reihentitel steht in `name`.
public let episodeTitle: String?
/// Die Nummer, unter der ein Sender im Programm steht — „3" oder „11.1".
public let channelNumber: String?
/// Dasselbe, aber am Sender selbst statt an der Sendung.
public let number: String?
public let startDate: Date?
public let endDate: Date?
/// Was auf diesem Sender gerade läuft.
///
/// Kommt nur, wenn die Senderliste mit `addCurrentProgram` geholt wurde —
/// dann aber gleich mit, und die Liste braucht keinen zweiten Aufruf.
public let currentProgram: JellyfinCurrentProgram?
/// Ob die Sendung zu einem laufenden Serien-Timer gehört.
public let seriesTimerID: String?
public let isMovie: Bool?
public let isSeries: Bool?
public let isNews: Bool?
public let isSports: Bool?
public let isKids: Bool?
public let isLive: Bool?
public struct Person: Decodable, Sendable {
public let id: String?
public let name: String?
public let role: String?
public let type: String?
public let primaryImageTag: String?
private enum CodingKeys: String, CodingKey {
case id = "Id", name = "Name", role = "Role", type = "Type"
case primaryImageTag = "PrimaryImageTag"
}
}
public struct UserData: Decodable, Sendable {
public let playbackPositionTicks: Int64?
public let playCount: Int?
public let played: Bool?
public let isFavorite: Bool?
public let unplayedItemCount: Int?
private enum CodingKeys: String, CodingKey {
case playbackPositionTicks = "PlaybackPositionTicks"
case playCount = "PlayCount"
case played = "Played"
case isFavorite = "IsFavorite"
case unplayedItemCount = "UnplayedItemCount"
}
}
private enum CodingKeys: String, CodingKey {
case id = "Id", name = "Name", type = "Type", overview = "Overview"
case productionYear = "ProductionYear", runTimeTicks = "RunTimeTicks"
case indexNumber = "IndexNumber", parentIndexNumber = "ParentIndexNumber"
case seriesName = "SeriesName", seriesID = "SeriesId"
case seasonID = "SeasonId", seasonName = "SeasonName"
case officialRating = "OfficialRating", communityRating = "CommunityRating"
case criticRating = "CriticRating", dateCreated = "DateCreated"
case childCount = "ChildCount", recursiveItemCount = "RecursiveItemCount"
case genres = "Genres", people = "People", userData = "UserData"
case imageTags = "ImageTags", backdropImageTags = "BackdropImageTags"
case parentBackdropItemID = "ParentBackdropItemId"
case seriesPrimaryImageTag = "SeriesPrimaryImageTag"
case mediaSources = "MediaSources", collectionType = "CollectionType"
case channelID = "ChannelId", channelName = "ChannelName"
case episodeTitle = "EpisodeTitle"
case channelNumber = "ChannelNumber", number = "Number"
case startDate = "StartDate", endDate = "EndDate"
case currentProgram = "CurrentProgram", seriesTimerID = "SeriesTimerId"
case isMovie = "IsMovie", isSeries = "IsSeries", isNews = "IsNews"
case isSports = "IsSports", isKids = "IsKids", isLive = "IsLive"
}
}
/// Was auf einem Sender gerade läuft.
///
/// Ein eigenes, schmales Modell statt eines verschachtelten `JellyfinItem`:
/// ein Struct darf sich nicht selbst enthalten — seine Größe wäre unendlich.
/// Gebraucht werden hier ohnehin nur fünf Felder.
public struct JellyfinCurrentProgram: Decodable, Sendable {
public let id: String
public let name: String?
public let overview: String?
public let startDate: Date?
public let endDate: Date?
private enum CodingKeys: String, CodingKey {
case id = "Id", name = "Name", overview = "Overview"
case startDate = "StartDate", endDate = "EndDate"
}
}
/// Eine Fassung, wie Jellyfin sie meldet.
public struct JellyfinMediaSource: Decodable, Sendable {
public let id: String?
public let container: String?
public let size: Int64?
public let bitrate: Int?
public let runTimeTicks: Int64?
public let mediaStreams: [JellyfinMediaStream]?
public let supportsDirectPlay: Bool?
public let supportsDirectStream: Bool?
public let supportsTranscoding: Bool?
public let transcodingURL: String?
private enum CodingKeys: String, CodingKey {
case id = "Id", container = "Container", size = "Size", bitrate = "Bitrate"
case runTimeTicks = "RunTimeTicks", mediaStreams = "MediaStreams"
case supportsDirectPlay = "SupportsDirectPlay"
case supportsDirectStream = "SupportsDirectStream"
case supportsTranscoding = "SupportsTranscoding"
case transcodingURL = "TranscodingUrl"
}
}
/// Eine Spur, wie Jellyfin sie meldet.
public struct JellyfinMediaStream: Decodable, Sendable {
public let index: Int?
public let type: String?
public let codec: String?
public let language: String?
public let displayTitle: String?
/// Was **in der Datei** steht — im Gegensatz zu `displayTitle`, das
/// Jellyfin aus Sprache, Codec und Kennzeichen zusammensetzt.
///
/// Das zweite Signal neben dem Sprachkürzel, und manchmal das einzige
/// richtige: wo „eng" steht, kann im Titel „Deutsch" stehen.
public let title: String?
/// Für Hörgeschädigte. **Nicht** Audiodeskription — dafür hat Jellyfin
/// kein Kennzeichen.
public let isHearingImpaired: Bool?
public let isOriginal: Bool?
public let channels: Int?
public let isDefault: Bool?
public let isForced: Bool?
public let height: Int?
public let width: Int?
private enum CodingKeys: String, CodingKey {
case index = "Index", type = "Type", codec = "Codec", language = "Language"
case displayTitle = "DisplayTitle", channels = "Channels"
case title = "Title"
case isHearingImpaired = "IsHearingImpaired", isOriginal = "IsOriginal"
case isDefault = "IsDefault", isForced = "IsForced"
case height = "Height", width = "Width"
}
}
/// Die Hülle, in der Jellyfin Listen schickt.
public struct JellyfinItemPage: Decodable, Sendable {
public let items: [JellyfinItem]
public let totalRecordCount: Int?
public let startIndex: Int?
private enum CodingKeys: String, CodingKey {
case items = "Items"
case totalRecordCount = "TotalRecordCount"
case startIndex = "StartIndex"
}
}
extension Int64 {
/// Ticks zu hundert Nanosekunden in Millisekunden.
///
/// Jellyfins Einheit. Zehntausend Ticks sind eine Millisekunde — eine
/// Zahl, die man beim Lesen zweimal prüft und deshalb genau einmal
/// aufschreibt.
var millisecondsFromTicks: Int { Int(self / 10_000) }
}
extension Int {
var ticksFromMilliseconds: Int64 { Int64(self) * 10_000 }
}
@@ -0,0 +1,32 @@
import Foundation
/// Merkt sich, in welcher Mediathek ein Titel liegt.
///
/// **Warum überhaupt.** Die Antwort kostet eine Serveranfrage und ändert sich
/// praktisch nie — ein Titel wandert nicht von den Filmen zu den Serien. Ohne
/// Gedächtnis fragte jede Folge einer Serie dasselbe noch einmal, und zwar
/// genau in dem Augenblick, in dem der Film anlaufen soll.
///
/// **Auch das Nichts wird gemerkt.** Ein Server, der die Frage nicht
/// beantworten kann, beantwortet sie beim zweiten Mal auch nicht — sonst
/// wiederholte sich die vergebliche Anfrage bei jedem Titel. Deshalb die
/// doppelte Optionalität: „nie gefragt" und „gefragt, keine Antwort" sind
/// zwei verschiedene Dinge.
public actor JellyfinLibraryMemo {
/// Eine Antwort, die es gab — der Inhalt darf leer sein.
public struct Answer: Sendable {
public let value: String?
}
private var seen: [String: String?] = [:]
public init() {}
public func recorded(_ key: String) -> Answer? {
seen[key].map(Answer.init)
}
public func store(_ value: String?, for key: String) {
seen[key] = value
}
}
@@ -0,0 +1,151 @@
{
"sourceLanguage" : "en",
"strings" : {
"player.transcodeUnavailable" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Der Server hat keine umgerechnete Fassung angeboten."
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "The server didn't offer a transcoded version."
}
}
}
},
"row.continueWatching" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Weiterschauen"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Continue watching"
}
}
}
},
"row.favourites" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Favoriten"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Favourites"
}
}
}
},
"row.latestIn %@" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Neu in %@"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "New in %@"
}
}
}
},
"row.nextUp" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Als Nächstes"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Next up"
}
}
}
},
"row.recentlyAdded" : {
"extractionState" : "stale",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Zuletzt hinzugefügt"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Recently added"
}
}
}
},
"search.episodes" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Folgen"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Episodes"
}
}
}
},
"search.movies" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Filme"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Movies"
}
}
}
},
"search.shows" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Serien"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Shows"
}
}
}
}
},
"version" : "1.0"
}