**Intro überspringen gab es für Jellyfin nie.** Der Knopf, die Marken,
das automatische Überspringen, die Einstellungen dazu — alles stand seit
Langem im Player und auf dem Fernseher. Nur kam bei Jellyfin nie eine
Marke an: `JellyfinItem+Media.swift` setzte `markers: []`, weil Jellyfin
sie nicht am Titel liefert.
Seit 10.10 gibt es `/MediaSegments/{itemId}`, gefüllt von einem Anbieter
im Server wie dem Intro-Skipper. Die Anfrage läuft **neben** dem Abruf
des Titels, nicht danach — sonst wartete die Detailseite zweimal.
Zwei Dinge, die sonst eine Stunde kosten: `includeSegmentTypes` muss
**zweimal** stehen, nicht einmal mit Komma (mit Komma antwortet Jellyfin
mit null Treffern), und die Abfrage wirft nicht. Ein Server ohne Anbieter
antwortet leer, einer vor 10.10 mit einem 404 — und ein Film ohne Intro
ist kein Fehler, sondern der Normalfall.
Geprüft im Simulator: die Attrappe meldet jetzt Segmente, und im Player
steht „Intro überspringen".
---
**„Aus Weiterschauen entfernen".** Eine halbe Stunde gesehen und keine
Lust mehr — der Titel ist weder gesehen noch ungesehen, er soll nur nicht
ewig oben stehen. Beide Server können es, auf verschiedenen Wegen:
- **Plex** hat einen eigenen: `PUT /actions/removeFromContinueWatching`.
**Nicht** `/:/unscrobble` — das setzt zugleich auf ungesehen.
- **Jellyfin** hat keinen. Was es gibt, ist das Benutzerdatenblatt; die
Liste führt, was eine gemerkte Stelle grösser null hat. Also wird das
Blatt gelesen, bekommt eine Null und geht so, wie es war, zurück.
**Erst lesen, dann schreiben**, weil dort auch „gesehen", „Favorit" und
die Bewertung stehen — wer seinen Favoriten verliert, weil er einen
Film aus einer Liste genommen hat, sucht den Grund nie dort.
Der Eintrag erscheint nur, wenn es eine gemerkte Stelle gibt.
---
**Die Mediatheken für den Aufmacher taten zu wenig.** Die Regel „Reihen
ohne Mediathek bleiben immer drin" war freundlich gemeint und falsch: bei
„nur Filme" stand weiter eine Serie oben, weil sie aus „Weiterschauen"
kam. Gemessen und nicht vermutet — es lag nicht am Zeitfenster der
Auswahl, wie zuerst angenommen.
Jetzt gilt: ist etwas gewählt, speist sich der Aufmacher **nur** daraus.
Wer Weiterschauen oben haben will, wählt keine Mediathek. Der Hinweis
unter der Auswahl sagt das auch.
---
**Der „Bearbeiten"-Knopf stand über allem und tat dort nichts.** Er gilt
nur für die Reihen; jetzt steht er an deren Überschrift.
---
Die Attrappe kann drei Dinge mehr, sonst wäre nichts davon prüfbar:
Segmente, `parentId` bei den Neuzugängen (vorher stand in jeder Mediathek
dasselbe) und damit überhaupt eine Mediathekenauswahl, die man ansehen
kann.
iOS und tvOS bauen, 468 Tests grün.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
574 lines
25 KiB
Swift
574 lines
25 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 = switch view.collectionType {
|
||
case "movies": .movie
|
||
case "tvshows": .show
|
||
case "music": .music
|
||
case "homevideos", "photos": .photo
|
||
case "livetv": .liveTV
|
||
default: .other
|
||
}
|
||
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.
|
||
///
|
||
/// **Erst lesen, dann schreiben.** Das Blatt trägt auch „gesehen",
|
||
/// „Favorit" und die Bewertung. Nur ein Feld zu schicken hiesse, dem
|
||
/// Server die übrigen ohne Wert vorzulegen — und wer seinen Favoriten
|
||
/// verliert, weil er einen Film aus einer Liste genommen hat, sucht den
|
||
/// Grund nie an dieser Stelle. Deshalb kommt das Blatt zurück, bekommt
|
||
/// eine Null und geht so, wie es war, wieder hin.
|
||
public func removeFromContinueWatching(id: String) async throws(MediaBackendError) {
|
||
let pfad = "/Users/\(account.userID)/Items/\(id)/UserData"
|
||
let roh = try await client.send(JellyfinRequest(path: pfad))
|
||
|
||
guard var blatt = (try? JSONSerialization.jsonObject(with: roh)) as? [String: Any] else {
|
||
throw .decoding(description: "Benutzerdaten zu \(id) waren kein Objekt")
|
||
}
|
||
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 \(id) liessen sich nicht schreiben")
|
||
}
|
||
try await client.send(JellyfinRequest(method: .post, path: pfad, body: körper))
|
||
}
|
||
|
||
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
|
||
}
|
||
}
|
||
}
|