Flixy heißt ab hier Halo

Reine Umbenennung, kein Verhalten geändert — bewusst als eigener Commit,
damit „umbenannt" und „umgebaut" im Nachhinein auseinanderzuhalten sind.

712 mal `Flixy`, 778 mal `flixy`, 7 mal `FLIXY` in 169 Dateien, dazu 25
Pfade. Ziele: `Halo`, `HaloTV`, `HaloTopShelf`, `HaloUITests`, `HaloKit` mit
`HaloDesign`, `HaloDownloads`, `HaloPlayback` und `HaloProbe`. `MediaKit`,
`JellyfinKit` und `DownloadWidget` tragen den Namen nicht und bleiben.

`docs/superpowers/specs/` ist ausgenommen. Datierte Entwurfsdokumente sind
ein Protokoll getroffener Entscheidungen; sie nachträglich umzuschreiben
hiesse, die Vergangenheit zu glätten.

**Die Bundle-ID wandert mit** — ausdrücklich so gewollt, mit bekanntem Preis:
`com.scarriffleservices.halo` ist für iOS eine *andere* App. Die installierte
Fassung bleibt daneben stehen, mit Downloads und Anmeldung in ihrem Container;
Halo startet leer. Einmal neu anmelden, heruntergeladene Folgen einmal neu
laden. Dasselbe gilt für die App-Gruppe, die sich TV-App und Top Shelf teilen.

**Eine Folge davon, die beim Entscheiden noch nicht auf dem Tisch lag:** der
Aufräumschritt aus dem vorigen Commit kommt an die alten Plex-Token nicht mehr
heran. Der Schlüsselbund grenzt nach Team und Bundle-Kennung ab — was unter
`…flixy` liegt, ist für `…halo` unerreichbar. Erledigt wird es trotzdem, nur
anders: **die alte App löschen.** iOS räumt deren Schlüsselbund-Einträge beim
Deinstallieren mit ab. Der Code bleibt stehen, weil er für jeden Stand vor
dieser Umbenennung richtig ist und im Jahr einen Startvergleich kostet.

Die Projektdatei ist aus `project.yml` neu erzeugt, der alte Ordner gelöscht
statt umbenannt — sie steht ohnehin nicht im Repo.

Das Verzeichnis des Repos selbst heißt weiter `Flixy`; das umzubenennen ist
ein Handgriff ausserhalb des Projekts.

368 Tests grün. iOS und tvOS bauen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-03 11:12:32 +02:00
co-authored by Claude Opus 5
parent 2fcf6821c8
commit 4b9a1e7b67
280 changed files with 1456 additions and 1456 deletions
@@ -0,0 +1,736 @@
import Foundation
import MediaKit
/// Jellyfin als `MediaBackend`.
public struct JellyfinBackend: MediaBackend {
let client: JellyfinClient
let account: JellyfinAccount
/// Nicht `private`: Live-TV und Aufnahmen liegen in eigenen Dateien und
/// brauchen dieselben Bildpfade.
let images = JellyfinImagePaths()
/// Wo ein Titel hingehört — einmal gefragt, dann gemerkt.
///
/// Ein Actor, also ein Referenztyp: Kopien dieses Structs teilen sich
/// denselben und damit das Gedächtnis. Ohne das fragte jede Folge einer
/// Serie dasselbe noch einmal.
let ancestors = JellyfinLibraryMemo()
public let serverName: String
/// Konto **und** Adresse: dasselbe Konto auf zwei Servern ist nicht
/// dasselbe, und zwei Konten auf einem Server erst recht nicht.
public nonisolated var identity: String {
"jellyfin:\(client.baseURL.absoluteString):\(account.userID)"
}
public init(client: JellyfinClient, account: JellyfinAccount, serverName: String) {
self.client = client
self.account = account
self.serverName = serverName
}
public var isAdministrator: Bool { account.isAdministrator }
public nonisolated var capabilities: Set<BackendCapability> {
var capabilities: Set<BackendCapability> = [
.favourites, .liveTV, .liveTVRecordings, .libraryScan, .transcoding,
]
// Verwaltung nur, wenn das Konto sie auch darf. Den Bereich zu zeigen
// und bei jedem Tipp abzulehnen, wäre die schlechtere Antwort.
if account.isAdministrator { capabilities.insert(.administration) }
return capabilities
}
// MARK: - Blättern
/// Ob der Server diese Verbindung als „im eigenen Netz" ansieht.
///
/// **Gefragt, nicht geraten.** Ein Blick auf die Adresse taugt nicht: wer
/// über einen Reverse Proxy geht, erreicht seinen Server auch von der
/// Couch aus unter einem öffentlichen Namen. Umgekehrt kann eine private
/// Adresse über ein VPN von weit her kommen.
///
/// `/System/Endpoint` beantwortet es mit **demselben** Aufruf, mit dem
/// Jellyfin auch selbst entscheidet, ob es eine Bitratenleiter ausliefert.
/// Näher an der Wahrheit kommt man nicht heran.
///
/// Bei Zweifeln `false`: lieber einmal zu vorsichtig gedeckelt als eine
/// Wiedergabe, die über eine Fernverbindung gar nicht erst anläuft.
public func isInLocalNetwork() async -> Bool {
struct Lage: Decodable {
let isInNetwork: Bool
private enum CodingKeys: String, CodingKey { case isInNetwork = "IsInNetwork" }
}
let lage = try? await client.send(
JellyfinRequest(path: "/System/Endpoint"),
as: Lage.self
)
return lage?.isInNetwork ?? false
}
public func libraries() async throws(MediaBackendError) -> [MediaLibrary] {
let page = try await client.send(
JellyfinRequest(path: "/Users/\(account.userID)/Views"),
as: JellyfinItemPage.self
)
return page.items.map { view in
let kind = MediaLibrary.Kind(jellyfinCollectionType: view.collectionType)
return MediaLibrary(
id: view.id,
title: view.name ?? "—",
kind: kind,
thumb: images.primary(itemID: view.id, tag: nil)
)
}
}
public func homeRows() async throws(MediaBackendError) -> [MediaRowGroup] {
// Jellyfin hat keine fertigen Reihen wie Plex' Hubs — die Startseite
// wird aus einzelnen Abfragen zusammengesetzt. Das ist kein Mangel:
// so steht hier, was die Reihen bedeuten, statt dass es der Server
// entscheidet.
//
// Die Mediatheken werden streng geholt: sie sind die Probe darauf, ob
// der Server überhaupt antwortet. Alles Weitere ist nachsichtig —
// fällt „Als Nächstes" aus, soll deshalb nicht die ganze Startseite
// leer bleiben.
let libraries = try await libraries().filter(\.isSupported)
async let resuming = try? rowItems(path: "/Users/\(account.userID)/Items/Resume", query: [
URLQueryItem(name: "Limit", value: "20"),
URLQueryItem(name: "MediaTypes", value: "Video"),
URLQueryItem(name: "Fields", value: Self.fields),
])
async let nextUp = try? rowItems(path: "/Shows/NextUp", query: [
URLQueryItem(name: "userId", value: account.userID),
URLQueryItem(name: "Limit", value: "20"),
URLQueryItem(name: "Fields", value: Self.fields),
])
async let favourites = try? rowItems(path: "/Users/\(account.userID)/Items", query: [
URLQueryItem(name: "Filters", value: "IsFavorite"),
URLQueryItem(name: "Recursive", value: "true"),
URLQueryItem(name: "IncludeItemTypes", value: "Movie,Series"),
URLQueryItem(name: "Limit", value: "20"),
URLQueryItem(name: "Fields", value: Self.fields),
])
var rows = [
MediaRowGroup(
id: "resume",
title: String(localized: "row.continueWatching", bundle: .module),
items: await resuming ?? []
),
MediaRowGroup(
id: "nextup",
title: String(localized: "row.nextUp", bundle: .module),
items: await nextUp ?? []
),
]
// Je Mediathek eine eigene Reihe. „Neu in Filme" und „Neu in Serien"
// sind zwei verschiedene Fragen — in einer gemeinsamen Reihe steht am
// Ende beides durcheinander, und wer nach einem Film sucht, blättert
// an Folgen vorbei.
for library in libraries {
let items = try? await latestItems(limit: 20, inLibrary: library.id)
rows.append(MediaRowGroup(
id: "latest.\(library.id)",
title: String(
localized: "row.latestIn \(library.title)",
bundle: .module
),
libraryID: library.id,
items: items ?? []
))
}
rows.append(MediaRowGroup(
id: "favourites",
title: String(localized: "row.favourites", bundle: .module),
items: await favourites ?? []
))
// Leere Reihen wären auf der Startseite nur Überschriften ohne Inhalt.
return rows.filter { !$0.items.isEmpty }
}
public func items(
inLibrary library: MediaLibrary,
query: MediaQuery,
offset: Int,
limit: Int
) async throws(MediaBackendError) -> MediaPage {
var items: [URLQueryItem] = [
URLQueryItem(name: "ParentId", value: library.id),
URLQueryItem(name: "Recursive", value: "true"),
URLQueryItem(name: "StartIndex", value: String(offset)),
URLQueryItem(name: "Limit", value: String(limit)),
URLQueryItem(name: "Fields", value: Self.fields),
URLQueryItem(name: "SortBy", value: query.sort.jellyfinField),
URLQueryItem(name: "SortOrder", value: query.sort.isDescending ? "Descending" : "Ascending"),
]
// Ohne diese Angabe liefert eine Serienmediathek alle Folgen einzeln
// statt der Serien — dieselbe Falle wie bei Plex.
switch library.kind {
case .movie: items.append(URLQueryItem(name: "IncludeItemTypes", value: "Movie"))
case .show: items.append(URLQueryItem(name: "IncludeItemTypes", value: "Series"))
// Gemischter Inhalt: Filme **und** Serien, aber eben nur die. Ohne
// diese Angabe liefert `Recursive` auch jede einzelne Folge, und das
// Raster füllt sich mit Folge 3 von Staffel 2 statt mit Titeln.
case .other: items.append(URLQueryItem(name: "IncludeItemTypes", value: "Movie,Series"))
default: break
}
if query.unwatchedOnly { items.append(URLQueryItem(name: "Filters", value: "IsUnplayed")) }
if query.favouritesOnly { items.append(URLQueryItem(name: "Filters", value: "IsFavorite")) }
if let genre = query.genre { items.append(URLQueryItem(name: "Genres", value: genre)) }
if let year = query.year { items.append(URLQueryItem(name: "Years", value: String(year))) }
if let rating = query.contentRating {
items.append(URLQueryItem(name: "OfficialRatings", value: rating))
}
if let text = query.searchText { items.append(URLQueryItem(name: "searchTerm", value: text)) }
// Auflösung filtert Jellyfin nicht selbst; „4K" wird unten aussortiert.
let page = try await client.send(
JellyfinRequest(path: "/Users/\(account.userID)/Items", query: items),
as: JellyfinItemPage.self
)
var mapped = page.items.map { $0.asMediaItem(imageBase: images) }
var total = page.totalRecordCount ?? mapped.count
if query.resolution?.lowercased() == "4k" {
// Nach der Stufe, nicht nach der Zeilenzahl: ein breitwandiges
// 4K misst 3840 × 1608 und fiele durch jede Höhenschwelle,
// die 2160 erwartet.
mapped = mapped.filter { ($0.preferredVersion?.resolution ?? .sd) >= .uhd4K }
// Die Gesamtzahl stimmt dann nicht mehr — lieber die gefilterte
// Menge als eine Zahl, die zum Nachladen auffordert, obwohl nichts
// mehr kommt.
total = mapped.count + offset
}
return MediaPage(items: mapped, offset: offset, totalCount: total)
}
/// Die Mediathek über die Ahnenkette.
///
/// Jellyfin beantwortet das mit `/Items/{id}/Ancestors`: Staffel, Serie,
/// Mediathek, Wurzel. Gesucht ist der Eintrag vom Typ `CollectionFolder`
/// — das ist eine Mediathek. `UserRootFolder` ist die Wurzel darüber und
/// wäre für alle Titel dieselbe, also nutzlos.
///
/// Die Antwort wird gemerkt: innerhalb einer Serie fragt sonst jede Folge
/// dasselbe noch einmal.
public func libraryID(for item: MediaItem) async -> String? {
let schlüssel = item.seriesID ?? item.id
if let gemerkt = await ancestors.recorded(schlüssel) { return gemerkt.value }
let kette = try? await client.send(
JellyfinRequest(path: "/Items/\(schlüssel)/Ancestors"),
as: [JellyfinItem].self
)
let gefunden = kette?.first { $0.type == "CollectionFolder" }?.id
await ancestors.store(gefunden, for: schlüssel)
return gefunden
}
public func item(id: String) async throws(MediaBackendError) -> MediaItem {
// **Nebenher, nicht nacheinander.** Die Marken stehen bei Jellyfin
// hinter einem eigenen Weg; sie hinterher zu holen hiesse, die
// Detailseite um eine zweite Laufzeit warten zu lassen.
async let marken = segments(forItem: id)
let item = try await client.send(
JellyfinRequest(
path: "/Users/\(account.userID)/Items/\(id)",
query: [URLQueryItem(name: "Fields", value: Self.fields)]
),
as: JellyfinItem.self
)
return item.asMediaItem(imageBase: images, markers: await marken)
}
/// Alle Folgen einer Serie in **einer** Anfrage.
///
/// **Nicht `/Shows/NextUp`.** Der braucht `seriesID`, das bei einer Serie
/// selbst `nil` ist, versteckt vollständig gesehene Serien und bringt
/// eine eigene Zeitgrenze mit. Dann antworteten Plex und Jellyfin
/// verschieden auf dieselbe Frage — und welche Folge dran ist, soll eine
/// Regel entscheiden, nicht der Server.
///
/// `EnableUserData` ausdrücklich: ohne den Sehfortschritt wäre jede Folge
/// ungesehen, und es begänne immer bei der ersten.
public func episodes(inSeries id: String) async throws(MediaBackendError) -> [MediaItem] {
try await rowItems(path: "/Shows/\(id)/Episodes", query: [
URLQueryItem(name: "userId", value: account.userID),
URLQueryItem(name: "EnableUserData", value: "true"),
URLQueryItem(name: "Fields", value: Self.fields),
])
}
public func children(of id: String) async throws(MediaBackendError) -> [MediaItem] {
try await rowItems(path: "/Users/\(account.userID)/Items", query: [
URLQueryItem(name: "ParentId", value: id),
URLQueryItem(name: "SortBy", value: "SortName"),
URLQueryItem(name: "Fields", value: Self.fields),
])
}
public func search(_ text: String, limit: Int) async throws(MediaBackendError) -> [MediaRowGroup] {
let page = try await client.send(
JellyfinRequest(path: "/Users/\(account.userID)/Items", query: [
URLQueryItem(name: "searchTerm", value: text),
URLQueryItem(name: "Recursive", value: "true"),
URLQueryItem(name: "IncludeItemTypes", value: "Movie,Series,Episode"),
URLQueryItem(name: "Limit", value: String(limit)),
URLQueryItem(name: "Fields", value: Self.fields),
]),
as: JellyfinItemPage.self
)
// Nach Art gruppiert, weil Plex' Suche gruppiert zurückkommt und die
// Ergebnisansicht darauf ausgelegt ist.
let items = page.items.map { $0.asMediaItem(imageBase: images) }
let groups: [(MediaItem.Kind, String)] = [
(.movie, "search.movies"), (.show, "search.shows"), (.episode, "search.episodes"),
]
return groups.compactMap { kind, key in
let matching = items.filter { $0.kind == kind }
guard !matching.isEmpty else { return nil }
return MediaRowGroup(
id: kind.rawValue,
title: String(localized: String.LocalizationValue(key), bundle: .module),
items: matching
)
}
}
public func filterOptions(
inLibrary library: MediaLibrary,
field: MediaQuery.Field
) async throws(MediaBackendError) -> [MediaFilterOption] {
guard field == .genre else { return [] }
let page = try await client.send(
JellyfinRequest(path: "/Genres", query: [
URLQueryItem(name: "ParentId", value: library.id),
URLQueryItem(name: "userId", value: account.userID),
]),
as: JellyfinItemPage.self
)
return page.items.map { MediaFilterOption(id: $0.name ?? $0.id, title: $0.name ?? "—") }
}
public func nextEpisode(after item: MediaItem) async throws(MediaBackendError) -> MediaItem? {
guard let seriesID = item.seriesID else { return nil }
let next = try await rowItems(path: "/Shows/NextUp", query: [
URLQueryItem(name: "userId", value: account.userID),
URLQueryItem(name: "SeriesId", value: seriesID),
URLQueryItem(name: "Limit", value: "1"),
URLQueryItem(name: "Fields", value: Self.fields),
])
// Die laufende Folge selbst ist nicht die nächste.
return next.first { $0.id != item.id }
}
// MARK: - Bilder
public nonisolated func artworkURL(path: String?, width: Int, height: Int) -> URL? {
guard let path else { return nil }
return imageURL(path: path, extra: [
URLQueryItem(name: "fillWidth", value: String(width)),
URLQueryItem(name: "fillHeight", value: String(height)),
URLQueryItem(name: "quality", value: "90"),
])
}
public nonisolated func rawImageURL(path: String?) -> URL? {
guard let path else { return nil }
return imageURL(path: path, extra: [])
}
/// Setzt Pfad, mitgelieferte Marke und Größenangaben zusammen.
private nonisolated func imageURL(path: String, extra: [URLQueryItem]) -> URL? {
let pieces = path.split(separator: "?", maxSplits: 1)
var query = extra
if pieces.count > 1 {
query += URLComponents(string: "?\(pieces[1])")?.queryItems ?? []
}
// Bilder holt kein angemeldeter Client, sondern eine Bildansicht — sie
// schickt keine Kopfzeilen mit, also muss der Schlüssel in die Adresse.
return client.url(path: String(pieces[0]), query: query, includingToken: account.token)
}
// MARK: - Herunterladen
public nonisolated func downloadURL(for file: MediaFile) -> URL? {
// Im Pfad steht der Titel, in der Abfrage die Fassung. Beides zu
// vertauschen fällt bei einem Film mit nur einer Fassung nicht auf —
// die Kennungen sind dann dieselbe — und bei zweien sofort.
guard let itemID = file.itemID ?? file.key else { return nil }
return client.url(
path: "/Videos/\(itemID)/stream",
query: [
URLQueryItem(name: "static", value: "true"),
URLQueryItem(name: "mediaSourceId", value: file.key ?? itemID),
],
includingToken: account.token
)
}
/// Dieselbe Datei in einer MP4-Hülle.
///
/// **Für Downloads, die sonst offline unspielbar wären.** Eine Matroska
/// mit H.264 und AC3 ist inhaltlich tadellos — nur die Schachtel öffnet
/// AVFoundation nicht. Online hilft derselbe Weg schon
/// (`PlaybackDecision.remux`); auf dem Gerät gibt es hinterher niemanden
/// mehr, der umpacken könnte. Also beim Herunterladen.
///
/// `allowVideoStreamCopy`/`allowAudioStreamCopy` sind der ganze Punkt: der
/// Server soll die Ströme **durchreichen**, wenn sie in die Liste passen,
/// und nur umrechnen, was nicht passt. Das Bild bleibt damit das Original;
/// nur DTS oder TrueHD werden zu AC3.
///
/// NOCH NICHT AN EINEM ECHTEN SERVER GEPRÜFT: ob Jellyfin das Kopieren
/// hier tatsächlich wählt, steht erst nach dem ersten Download fest — die
/// Dateigröße verrät es.
public nonisolated func remuxDownloadURL(
itemID: String,
versionIndex: Int,
audioStreamIndex: Int?,
subtitleStreamIndex: Int?
) -> URL? {
var query = [
URLQueryItem(name: "videoCodec", value: "h264,hevc"),
URLQueryItem(name: "audioCodec", value: "aac,ac3,eac3"),
URLQueryItem(name: "allowVideoStreamCopy", value: "true"),
URLQueryItem(name: "allowAudioStreamCopy", value: "true"),
URLQueryItem(name: "TranscodingContainer", value: "mp4"),
URLQueryItem(name: "TranscodingMaxAudioChannels", value: "6"),
]
// **Eine Tonspur, nicht alle.** Jellyfin bildet beim Umpacken genau
// eine ab; welche, muss hier stehen. Ohne diese Zeile nimmt der Server
// seine eigene Vorgabe — und das ist bei deutschen Aufnahmen
// regelmäßig die mit Audiodeskription.
if let audioStreamIndex {
query.append(URLQueryItem(name: "audioStreamIndex", value: String(audioStreamIndex)))
}
// `Embed` legt den Untertitel als **Spur** in die Datei, statt ihn ins
// Bild zu brennen. Nur so bleibt er abschaltbar.
if let subtitleStreamIndex {
query.append(URLQueryItem(name: "subtitleStreamIndex", value: String(subtitleStreamIndex)))
query.append(URLQueryItem(name: "subtitleMethod", value: "Embed"))
// **`mov_text`, und zwar ausdrücklich.** Ohne diese Zeile
// antwortet der Server mit `500`. Gemessen an einem echten
// Jellyfin: derselbe Aufruf ohne Untertitel liefert 61 MB, mit
// Untertitel und ohne Kodierer-Angabe einen Fehler, mit
// `mov_text` 42 MB samt Spur. MP4 kennt kein SubRip; wer den
// Untertitel hineinlegen will, muss sagen, als was.
query.append(URLQueryItem(name: "subtitleCodec", value: "mov_text"))
}
return client.url(
path: "/Videos/\(itemID)/stream.mp4",
query: query + Self.transcodeSession(itemID: itemID, deviceID: client.deviceID),
includingToken: account.token
)
}
/// Wer fragt, und für welche Sitzung.
///
/// **Ohne diese zwei Angaben liefert der Server gar nichts.** Gemessen an
/// einem echten Jellyfin: dieselbe Adresse ohne sie antwortet mit `200`
/// und `Content-Length: 0` — kein Fehler, keine Datei, nichts. Mit ihnen
/// kamen in fünfzehn Sekunden 66 MB.
///
/// Das betraf auch die verkleinerten Fassungen: die hatten sie nie, und
/// damit war „in 480p herunterladen" die ganze Zeit folgenlos.
///
/// Die Sitzungskennung ist je Auftrag neu, nicht je Titel. Zwei Anfragen
/// unter derselben Kennung hielt der Server für dieselbe Sitzung und
/// antwortete der zweiten mit einer leeren Datei. Gebaut wird die Adresse
/// einmal beim Anlegen des Auftrags; ein Wiederholversuch nimmt dieselbe
/// und bleibt damit dieselbe Sitzung.
private static func transcodeSession(itemID: String, deviceID: String) -> [URLQueryItem] {
[
URLQueryItem(name: "deviceId", value: deviceID),
URLQueryItem(name: "playSessionId", value: "halo-\(UUID().uuidString)"),
]
}
/// Eine verkleinerte Fassung zum Herunterladen.
///
/// **Fortlaufendes MP4, nicht HLS.** Zum Abspielen liefert Jellyfin eine
/// Abspielliste mit hunderten Schnipseln; das ist für einen Stream richtig
/// und für eine Datei, die auf dem Gerät liegen soll, unbrauchbar. Der
/// Endpunkt `/Videos/{id}/stream.mp4` gibt dieselbe Umrechnung als **eine**
/// Datei heraus.
///
/// Hier wird die Adresse selbst gebaut statt über `/PlaybackInfo` erfragt —
/// anders als beim Abspielen. Der Grund ist die Signatur: diese Stelle darf
/// keine Anfrage stellen, weil die Oberfläche die Adresse beim Zeichnen
/// braucht. Das ist vertretbar, weil hier nichts zu entscheiden ist: die
/// Stufe steht fest, der Server soll sie liefern.
///
/// Die Größe bleibt eine Schätzung. Eine Umrechnung, die erst beim Abruf
/// beginnt, kennt ihre eigene Länge nicht — der Fortschrittsbalken rechnet
/// deshalb gegen die geschätzten Bytes, nicht gegen eine Zusage des Servers.
public nonisolated func compressedDownloadURL(
itemID: String,
versionIndex: Int,
maximumBitrate: Int,
resolution: String?
) -> URL? {
var query = [
URLQueryItem(name: "videoCodec", value: "h264"),
URLQueryItem(name: "audioCodec", value: "aac"),
// Jellyfin rechnet in Bit, die App in Kilobit.
URLQueryItem(name: "videoBitRate", value: String(maximumBitrate * 1_000)),
URLQueryItem(name: "audioBitRate", value: "128000"),
// Zwei Kanäle: ein Handy hat keine Rückkanäle, und 5.1 auf zwei
// Lautsprecher heruntergemischt klingt schlechter als von
// vornherein Stereo.
URLQueryItem(name: "TranscodingMaxAudioChannels", value: "2"),
URLQueryItem(name: "TranscodingContainer", value: "mp4"),
]
if let height = resolution?.split(separator: "x").last.flatMap({ Int($0) }) {
query.append(URLQueryItem(name: "maxHeight", value: String(height)))
}
return client.url(
path: "/Videos/\(itemID)/stream.mp4",
query: query + Self.transcodeSession(itemID: itemID, deviceID: client.deviceID),
includingToken: account.token
)
}
// MARK: - Zustand melden
public func markWatched(id: String) async throws(MediaBackendError) {
try await client.send(JellyfinRequest(
method: .post,
path: "/Users/\(account.userID)/PlayedItems/\(id)"
))
}
public func markUnwatched(id: String) async throws(MediaBackendError) {
try await client.send(JellyfinRequest(
method: .delete,
path: "/Users/\(account.userID)/PlayedItems/\(id)"
))
}
/// Setzt die gemerkte Stelle auf null — damit fällt der Titel aus
/// „Weiterschauen" heraus.
///
/// **Jellyfin kennt dafür keinen eigenen Weg.** Ein Vorschlag, einen
/// Titel mit einem Merker auszublenden, liegt seit Jahren als Entwurf
/// vor; was es gibt, ist das Benutzerdatenblatt. „Weiterschauen" führt,
/// was eine Stelle grösser null hat — also nimmt man sie weg.
///
/// **Das Blatt wird gebaut, nicht zurückgelesen.** Hier stand erst ein
/// `GET` auf denselben Pfad, und genau daran scheiterte es stumm: diese
/// Stelle war die einzige im ganzen Projekt, die ein Benutzerdatenblatt
/// eigens liest — überall sonst kommt es am Titel mit. Es gab also kein
/// zweites Beispiel, an dem der Weg je funktioniert hätte, und ein 404
/// beendete die Sache, bevor überhaupt etwas geschrieben wurde.
///
/// Gelesen wird trotzdem, aber **unverbindlich**: gelingt es, bleiben
/// auch Felder erhalten, die diese App gar nicht kennt — Jellyfins
/// Daumen etwa. Gelingt es nicht, wird aus dem Titel gebaut, was wir
/// wissen. Weggeworfen wird in keinem Fall mehr als die Stelle.
public func removeFromContinueWatching(_ item: MediaItem) async throws(MediaBackendError) {
var blatt = await userData(forItem: item.id) ?? [
"Played": item.isWatched,
"IsFavorite": item.isFavourite,
"PlayCount": item.playCount ?? 0,
]
blatt["PlaybackPositionTicks"] = 0
// Ohne das bliebe der Anteil stehen, und manche Ansicht rechnet den
// Balken daraus statt aus der Stelle.
blatt["PlayedPercentage"] = NSNull()
guard let körper = try? JSONSerialization.data(withJSONObject: blatt) else {
throw .decoding(description: "Benutzerdaten zu \(item.id) liessen sich nicht schreiben")
}
try await writeUserData(körper, forItem: item.id)
}
/// Das Benutzerdatenblatt, wenn der Server es herausgibt.
///
/// `nil` statt eines Fehlers: ob es diesen Weg gibt, hängt an der
/// Serverfassung, und die Antwort darauf darf die Aktion nicht
/// aufhalten.
private func userData(forItem id: String) async -> [String: Any]? {
for pfad in Self.userDataPaths(itemID: id, userID: account.userID) {
guard let roh = try? await client.send(JellyfinRequest(
path: pfad.path,
query: pfad.query
)) else { continue }
if let blatt = (try? JSONSerialization.jsonObject(with: roh)) as? [String: Any],
// Der Auffangzweig mancher Server antwortet mit einer leeren
// Seite und Status 200. Die ist kein Blatt.
blatt["PlaybackPositionTicks"] != nil || blatt["Played"] != nil {
return blatt
}
}
return nil
}
/// Schreibt das Blatt zurück — über den Weg, den dieser Server kennt.
private func writeUserData(_ body: Data, forItem id: String) async throws(MediaBackendError) {
var letzter: MediaBackendError?
for pfad in Self.userDataPaths(itemID: id, userID: account.userID) {
do {
try await client.send(JellyfinRequest(
method: .post,
path: pfad.path,
query: pfad.query,
body: body
))
return
} catch {
letzter = error
// Nur ein „gibt es hier nicht" rechtfertigt den zweiten
// Versuch. Ein abgelehnter Zugriff oder ein Serverfehler
// würde beim anderen Pfad genauso ausgehen.
guard case .notFound = error else { throw error }
}
}
throw letzter ?? .notFound
}
/// Die beiden Wege zum Benutzerdatenblatt, neuerer zuerst.
///
/// Jellyfin hat die verstreuten Aufrufe in `/UserItems/{id}/UserData`
/// gebündelt; ältere Fassungen kennen nur `/Users/{id}/Items/{id}/UserData`.
/// Welcher gilt, sagt einem der Server erst, wenn man fragt.
private static func userDataPaths(
itemID: String,
userID: String
) -> [(path: String, query: [URLQueryItem])] {
[
("/UserItems/\(itemID)/UserData", [URLQueryItem(name: "userId", value: userID)]),
("/Users/\(userID)/Items/\(itemID)/UserData", []),
]
}
public func setFavourite(_ isFavourite: Bool, id: String) async throws(MediaBackendError) {
try await client.send(JellyfinRequest(
method: isFavourite ? .post : .delete,
path: "/Users/\(account.userID)/FavoriteItems/\(id)"
))
}
public func rate(id: String, rating: Int) async throws(MediaBackendError) {
// Jellyfin kennt nur Daumen hoch und runter, keine Sterne. Eine Zahl
// von eins bis zehn hier hineinzupressen hieße, etwas zu behaupten,
// was der Server nicht speichert.
throw .notFound
}
public func refreshLibrary(_ library: MediaLibrary) async throws(MediaBackendError) {
try await client.send(JellyfinRequest(
method: .post,
path: "/Items/\(library.id)/Refresh",
query: [URLQueryItem(name: "Recursive", value: "true")]
))
}
// MARK: - Hilfen
/// Die Neuzugänge, wie der Server sie selbst versteht.
///
/// Ein eigener Endpunkt statt einer Liste, sortiert nach Anlegedatum —
/// und das aus zwei Gründen. Der erste ist die Sortierung selbst: sie kam
/// alphabetisch zurück, nicht nach Datum. Der zweite wiegt schwerer und
/// bliebe auch bestehen, wenn die Sortierung griffe: das Anlegedatum einer
/// **Serie** ist der Tag, an dem sie in die Mediathek kam. Eine Serie, die
/// seit zwei Jahren dort steht und gestern eine neue Folge bekommen hat,
/// ist neu — nach Anlegedatum wäre sie es nie.
///
/// Die Antwort ist hier ausnahmsweise ein nacktes Feld, kein Objekt mit
/// `Items` darin.
private func latestItems(
limit: Int,
inLibrary libraryID: String? = nil
) async throws(MediaBackendError) -> [MediaItem] {
var query = [
URLQueryItem(name: "Limit", value: String(limit)),
URLQueryItem(name: "Fields", value: Self.fields),
// Neue Folgen erscheinen als ihre Serie statt als zwölf
// Einzelkacheln, die alle dasselbe Plakat tragen.
URLQueryItem(name: "GroupItems", value: "true"),
]
if let libraryID { query.append(URLQueryItem(name: "ParentId", value: libraryID)) }
let items = try await client.send(
JellyfinRequest(path: "/Users/\(account.userID)/Items/Latest", query: query),
as: [JellyfinItem].self
)
return items.map { $0.asMediaItem(imageBase: images) }
}
private func rowItems(
path: String,
query: [URLQueryItem]
) async throws(MediaBackendError) -> [MediaItem] {
let page = try await client.send(
JellyfinRequest(path: path, query: query),
as: JellyfinItemPage.self
)
return page.items.map { $0.asMediaItem(imageBase: images) }
}
/// Was Jellyfin zusätzlich mitschicken soll.
///
/// Ohne diese Liste fehlen Beschreibung, Besetzung und Fassungen — und
/// jede Detailseite bräuchte eine zweite Anfrage.
private static let fields = "Overview,Genres,People,MediaSources,DateCreated,ParentId"
}
extension MediaQuery.Sort {
var jellyfinField: String {
switch self {
case .titleAscending, .titleDescending: "SortName"
case .recentlyAdded: "DateCreated"
case .releaseDate: "PremiereDate"
case .rating: "CommunityRating"
case .lastPlayed: "DatePlayed"
}
}
var isDescending: Bool {
switch self {
case .titleAscending: false
case .titleDescending, .recentlyAdded, .releaseDate, .rating, .lastPlayed: true
}
}
}
extension MediaLibrary.Kind {
/// Was Jellyfin als Art einer Mediathek meldet.
///
/// **Eigene Funktion, damit sie prüfbar ist.** Die Zuordnung entscheidet
/// mehr, als sie aussieht: was hier als `.photo` oder `.music` landet,
/// fällt unter `MediaLibrary.isSupported` heraus und ist in der App
/// **gar nicht vorhanden** — keine Reihe, kein Eintrag in der Leiste,
/// nicht einmal in der Auswahl für den Aufmacher. Ein Fehler hier sieht
/// aus wie eine Mediathek, die der Server nicht liefert.
///
/// Genau das war mit `homevideos` der Fall: Jellyfin nennt „Heimvideos &
/// Fotos" so, und weil Fotos im Namen stehen, lag es bei den Fotos. Der
/// Inhalt sind Videos.
init(jellyfinCollectionType: String?) {
switch jellyfinCollectionType {
case "movies": self = .movie
case "tvshows": self = .show
case "music": self = .music
case "photos": self = .photo
case "livetv": self = .liveTV
// Auch `homevideos`, `musicvideos`, `mixed` und alles Unbekannte:
// Videos, die man ansehen kann, auch wenn der Server sie nicht
// einsortiert.
default: self = .other
}
}
}