Flixy heißt ab hier Halo
Reine Umbenennung, kein Verhalten geändert — bewusst als eigener Commit, damit „umbenannt" und „umgebaut" im Nachhinein auseinanderzuhalten sind. 712 mal `Flixy`, 778 mal `flixy`, 7 mal `FLIXY` in 169 Dateien, dazu 25 Pfade. Ziele: `Halo`, `HaloTV`, `HaloTopShelf`, `HaloUITests`, `HaloKit` mit `HaloDesign`, `HaloDownloads`, `HaloPlayback` und `HaloProbe`. `MediaKit`, `JellyfinKit` und `DownloadWidget` tragen den Namen nicht und bleiben. `docs/superpowers/specs/` ist ausgenommen. Datierte Entwurfsdokumente sind ein Protokoll getroffener Entscheidungen; sie nachträglich umzuschreiben hiesse, die Vergangenheit zu glätten. **Die Bundle-ID wandert mit** — ausdrücklich so gewollt, mit bekanntem Preis: `com.scarriffleservices.halo` ist für iOS eine *andere* App. Die installierte Fassung bleibt daneben stehen, mit Downloads und Anmeldung in ihrem Container; Halo startet leer. Einmal neu anmelden, heruntergeladene Folgen einmal neu laden. Dasselbe gilt für die App-Gruppe, die sich TV-App und Top Shelf teilen. **Eine Folge davon, die beim Entscheiden noch nicht auf dem Tisch lag:** der Aufräumschritt aus dem vorigen Commit kommt an die alten Plex-Token nicht mehr heran. Der Schlüsselbund grenzt nach Team und Bundle-Kennung ab — was unter `…flixy` liegt, ist für `…halo` unerreichbar. Erledigt wird es trotzdem, nur anders: **die alte App löschen.** iOS räumt deren Schlüsselbund-Einträge beim Deinstallieren mit ab. Der Code bleibt stehen, weil er für jeden Stand vor dieser Umbenennung richtig ist und im Jahr einen Startvergleich kostet. Die Projektdatei ist aus `project.yml` neu erzeugt, der alte Ordner gelöscht statt umbenannt — sie steht ohnehin nicht im Repo. Das Verzeichnis des Repos selbst heißt weiter `Flixy`; das umzubenennen ist ein Handgriff ausserhalb des Projekts. 368 Tests grün. iOS und tvOS bauen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
2fcf6821c8
commit
4b9a1e7b67
@@ -0,0 +1,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"
|
||||
}
|
||||
Reference in New Issue
Block a user