Jellyfin nennt „Heimvideos & Fotos" `collectionType: homevideos`. Die Zuordnung legte das zusammen mit `photos` auf `.photo` — und damit fiel die ganze Mediathek unter `MediaLibrary.isSupported` heraus: keine Reihe auf der Startseite, kein Eintrag in der Mediathekenleiste, nicht einmal eine Zeile in der Auswahl für den Aufmacher. Aus Sicht der App gab es sie nicht. Dass Fotos im Namen stehen, macht den Inhalt nicht zu Fotos. `photos` bleibt bei den Fotos, `homevideos` ist jetzt `.other` — wie alles, was der Server nicht einsortiert. Die Zuordnung steht dafür nicht mehr mitten in `libraries()`, sondern als eigene Funktion mit Tests. Sie entscheidet mehr, als sie aussieht: was hier als `.photo` oder `.music` landet, ist in der App unsichtbar, und das sieht aus wie ein Server, der nichts liefert — nicht wie eine Zuordnung, die zu scharf ist. Drei Tests halten es fest, darunter der Fall ohne Angabe: eine gemischte Mediathek meldet gar keine Art und muss trotzdem bleiben. iOS und tvOS bauen, 472 Tests grün. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
658 lines
28 KiB
Swift
658 lines
28 KiB
Swift
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
|
||
)
|
||
}
|
||
|
||
/// 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,
|
||
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
|
||
}
|
||
}
|
||
}
|