JellyfinKit: anmelden, stöbern, übersetzen
Der zweite Server. Eigenes Target neben PlexKit, nicht darin — die beiden teilen die neutrale Schicht und sonst nichts. Die Anmeldung geht zuerst über **Quick Connect**: die App zeigt einen sechsstelligen Code, den man in einem bereits angemeldeten Client bestätigt. Damit gilt hier dieselbe Zusage wie bei Plex — das Passwort erreicht Flixy nie. Benutzername und Passwort bleiben als Rückfall, falls der Server Quick Connect abgeschaltet hat; dann hält die App das Passwort für die Dauer einer Anfrage und speichert es nirgends. Zwei Stellen, an denen sich eine Übersetzung verrechnet, und die deshalb Tests haben: Jellyfin zählt Zeiten in Ticks zu hundert Nanosekunden. Eine Null zu viel, und aus 44 Minuten werden siebeneinhalb Stunden. Und es meldet die **ungesehenen** Folgen, nicht die gesehenen. Ungeprüft übernommen stünde bei einer fast fertigen Serie „9 von 24" statt „15 von 24". Dass die Adressen den Token tragen, steht hier von Anfang an — bei Plex hat genau das tagelang gekostet, weil AVPlayer keine Kopfzeilen mitschickt. Zwei Fähigkeiten sind ausdrücklich nicht da: Sterne-Bewertungen kennt Jellyfin nicht, nur Daumen hoch und runter. Und die Adresse einer verkleinerten Fassung gibt es erst nach einer Anfrage — sie zu erfinden hieße, eine Adresse anzubieten, die nicht antwortet. Die Startseite ist nachsichtig gebaut: fällt „Als Nächstes" aus, bleibt deshalb nicht die ganze Seite leer. Die App bietet Jellyfin noch nicht an — das ist der nächste Schritt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
fe2d730c00
commit
9540f26dd8
@@ -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 Flixy 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,360 @@
|
||||
import Foundation
|
||||
import MediaKit
|
||||
|
||||
/// Jellyfin als `MediaBackend`.
|
||||
public struct JellyfinBackend: MediaBackend {
|
||||
private let client: JellyfinClient
|
||||
private let account: JellyfinAccount
|
||||
private let images = JellyfinImagePaths()
|
||||
public let serverName: String
|
||||
|
||||
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, .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
|
||||
|
||||
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.
|
||||
// „Zuletzt hinzugefügt" wird streng geholt: scheitert es, ist der
|
||||
// Server nicht erreichbar, und dann gehört eine Fehlermeldung hin.
|
||||
let latest = try await rowItems(path: "/Users/\(account.userID)/Items", query: [
|
||||
URLQueryItem(name: "SortBy", value: "DateCreated"),
|
||||
URLQueryItem(name: "SortOrder", value: "Descending"),
|
||||
URLQueryItem(name: "IncludeItemTypes", value: "Movie,Series"),
|
||||
URLQueryItem(name: "Recursive", value: "true"),
|
||||
URLQueryItem(name: "Limit", value: "20"),
|
||||
URLQueryItem(name: "Fields", value: Self.fields),
|
||||
])
|
||||
|
||||
// Die beiden anderen nachsichtig: fällt „Als Nächstes" aus, soll
|
||||
// deshalb nicht die ganze Startseite leer bleiben.
|
||||
let resuming = try? await rowItems(path: "/Users/\(account.userID)/Items/Resume", query: [
|
||||
URLQueryItem(name: "Limit", value: "20"),
|
||||
URLQueryItem(name: "MediaTypes", value: "Video"),
|
||||
URLQueryItem(name: "Fields", value: Self.fields),
|
||||
])
|
||||
let nextUp = try? await rowItems(path: "/Shows/NextUp", query: [
|
||||
URLQueryItem(name: "userId", value: account.userID),
|
||||
URLQueryItem(name: "Limit", value: "20"),
|
||||
URLQueryItem(name: "Fields", value: Self.fields),
|
||||
])
|
||||
|
||||
let rows = [
|
||||
MediaRowGroup(
|
||||
id: "resume",
|
||||
title: String(localized: "row.continueWatching", bundle: .module),
|
||||
items: resuming ?? []
|
||||
),
|
||||
MediaRowGroup(
|
||||
id: "nextup",
|
||||
title: String(localized: "row.nextUp", bundle: .module),
|
||||
items: nextUp ?? []
|
||||
),
|
||||
MediaRowGroup(
|
||||
id: "latest",
|
||||
title: String(localized: "row.recentlyAdded", bundle: .module),
|
||||
items: latest
|
||||
),
|
||||
]
|
||||
// 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"))
|
||||
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" {
|
||||
mapped = mapped.filter { ($0.preferredVersion?.pixelHeight ?? 0) >= 1_600 }
|
||||
// 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)
|
||||
}
|
||||
|
||||
public func item(id: String) async throws(MediaBackendError) -> MediaItem {
|
||||
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)
|
||||
}
|
||||
|
||||
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? {
|
||||
guard let sourceID = file.key else { return nil }
|
||||
return client.url(
|
||||
path: "/Videos/\(sourceID)/stream",
|
||||
query: [
|
||||
URLQueryItem(name: "static", value: "true"),
|
||||
URLQueryItem(name: "mediaSourceId", value: sourceID),
|
||||
],
|
||||
includingToken: account.token
|
||||
)
|
||||
}
|
||||
|
||||
public nonisolated func compressedDownloadURL(
|
||||
itemID: String,
|
||||
versionIndex: Int,
|
||||
maximumBitrate: Int,
|
||||
resolution: String?
|
||||
) -> URL? {
|
||||
// Jellyfin liefert die Adresse für eine umgerechnete Fassung erst nach
|
||||
// einer Anfrage an `/PlaybackInfo` — das geht hier nicht, weil diese
|
||||
// Methode keine Anfrage stellen darf. `nil` heißt: nur Original
|
||||
// anbieten, statt eine Adresse zu erfinden, die nicht antwortet.
|
||||
nil
|
||||
}
|
||||
|
||||
// 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)"
|
||||
))
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
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
|
||||
}
|
||||
|
||||
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,176 @@
|
||||
import Foundation
|
||||
import MediaKit
|
||||
|
||||
/// Die Übersetzung der Jellyfin-Modelle in die neutrale Form.
|
||||
///
|
||||
/// Das Gegenstück zu `PlexItem+Media`. Beide Dateien machen dasselbe, und dass
|
||||
/// es sie zweimal gibt, ist der Preis dafür, dass es die Ansichten nur einmal
|
||||
/// gibt.
|
||||
extension JellyfinItem {
|
||||
public func asMediaItem(imageBase: JellyfinImagePaths) -> 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,
|
||||
thumb: imageBase.primary(itemID: id, tag: imageTags?["Primary"]),
|
||||
art: imageBase.backdrop(itemID: id, tag: backdropImageTags?.first)
|
||||
?? parentBackdropItemID.map { imageBase.backdrop(itemID: $0, tag: nil) } ?? nil,
|
||||
parentThumb: seasonID.map { imageBase.primary(itemID: $0, tag: nil) } ?? nil,
|
||||
seriesThumb: seriesID.map { imageBase.primary(itemID: $0, tag: seriesPrimaryImageTag) } ?? nil,
|
||||
seriesArt: seriesID.map { imageBase.backdrop(itemID: $0, tag: nil) } ?? 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: [],
|
||||
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,
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,152 @@
|
||||
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?
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
|
||||
/// 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?
|
||||
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 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,102 @@
|
||||
{
|
||||
"sourceLanguage": "en",
|
||||
"version": "1.0",
|
||||
"strings": {
|
||||
"row.continueWatching": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Weiterschauen"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Continue watching"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"row.nextUp": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Als Nächstes"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Next up"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"row.recentlyAdded": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Zuletzt hinzugefügt"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Recently added"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"search.episodes": {
|
||||
"localizations": {
|
||||
"de": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Folgen"
|
||||
}
|
||||
},
|
||||
"en": {
|
||||
"stringUnit": {
|
||||
"state": "translated",
|
||||
"value": "Episodes"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user