import Foundation import MediaKit /// Jellyfin als `MediaBackend`. public struct JellyfinBackend: MediaBackend { let client: JellyfinClient 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 { var capabilities: Set = [.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 latestItems(limit: 20) // 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? { // 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 ) } 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 /// 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) async throws(MediaBackendError) -> [MediaItem] { let items = try await client.send( JellyfinRequest(path: "/Users/\(account.userID)/Items/Latest", 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"), ]), 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 } } }