Halo heisst ab hier Flaro — die Marke stand im Weg
Der Upload zu App Store Connect scheiterte am Namen. „Halo" ist eine eingetragene Marke von Microsoft; im App Store heisst zwar keine App exakt so, aber reservierte und markenrechtlich belegte Namen tauchen in keiner Suche auf. Der einzige verlässliche Test war die Eingabe dort, und die ging daneben. **Der Name wurde diesmal vorher geprüft.** Zwanzig Kandidaten gegen den deutschen und amerikanischen App Store, auf exakte Treffer und auf ähnliche Namen. „Kavo" wäre dieselbe Wand gewesen — exakt belegt in beiden Stores, in Deutschland von KaVo Dental GmbH. „Flare" ist mit 42 ähnlichen Apps je Store der am dichtesten besetzte Name der ganzen Runde. **Flaro** ist in beiden frei und liest sich als eigenes Wort, nicht als Flare mit Tippfehler. Das bleibt eine Store-Suche und keine Markenrecherche — reservierte Namen ohne veröffentlichte App sieht sie nicht. Aber es ist mehr Prüfung, als beim letzten Mal stattfand. --- 2160 Textvorkommen in 273 Dateien, 49 Pfade. Betroffen ist diesmal auch der Server: `Flaro.Api`, `Flaro.Core`, `Flaro.Data`, `Flaro.Media`, `Flaro.Metadata`, `FlaroContext`, `FlaroPaths`, `flaro.db`, die Variablen `FLARO_DATA`, `FLARO_CACHE`, `FLARO_TRANSCODE`, `FLARO_WRITE_SIDECARS`, und das Image. **Vor dem Ersetzen geprüft**, ob „halo" irgendwo als Teil eines anderen Wortes steckt — und ob es im TMDB-Token vorkommt. Tat es nicht; ein blindes Ersetzen hätte den sonst zerlegt. `docs/superpowers/specs/` bleibt wie beim letzten Mal unangetastet: datierte Entwurfsdokumente sind ein Protokoll getroffener Entscheidungen. Das Server-Dokument hat stattdessen einen Nachtrag bekommen, damit es niemanden in die Irre führt. **Die Bundle-ID wandert mit**, zum zweiten Mal an einem Tag: `com.scarriffleservices.flaro`. Wer die vorige Fassung installiert hat, hat jetzt zwei Karteileichen auf dem Gerät — beide löschen, dann ist Ruhe. 308 Server-Tests grün, 368 im Swift-Paket, null Warnungen. iOS und tvOS bauen, das Image baut, und ein frischer Container läuft durch Einrichtung und Anmeldung. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
f98b995050
commit
a7cf6c5def
@@ -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 Flaro 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: "flaro-\(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 = "flaro.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