From fe2d730c00a0663d3ca03a085db5ac953d2fcaa3 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Sun, 13 Sep 2026 12:46:52 +0200 Subject: [PATCH] Die Ansichten sprechen nicht mehr mit Plex MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Start, Suche, Mediathek, Detailseiten und Downloads arbeiten jetzt mit `MediaItem` und dem `MediaBackend`-Vertrag. Damit sind sie fertig für einen zweiten Server, ohne dass an ihnen noch etwas zu tun wäre. Dabei sind ein paar Namen ehrlicher geworden: aus `grandparentTitle` wurde `seriesTitle`, aus `viewedLeafCount` `playedEpisodeCount`. Und `TagStrip` nahm bisher Plex-Marken mit Kennung und Rolle entgegen, um davon nur den Namen anzuzeigen — Genres sind jetzt schlicht Zeichenketten. `Arrangement` ist aus PlexKit heraus und generisch: dieselbe Rechnung gilt für jede Liste, die jemand selbst zusammenstellt. Phase 3 baut die konfigurierbare Seitenleiste darauf, statt sie ein zweites Mal — und ein zweites Mal falsch — zu schreiben. Die Fehlerübersetzung gibt es nur noch einmal. Es waren zwei, und sie waren bereits auseinandergelaufen: derselbe Fehler bekam je nach Bildschirm eine andere Erklärung. Das Nachladen der Mediathek bleibt unangetastet: eine lange Liste, die fünf Einträge vor dem Ende nachlädt. `MediaPage` heißt zwar Seite, ist aber eine technische Portion — Blättern mit „weiter zu Seite 2" gehört nicht in eine Mediathek, in der man stöbert. Der Kommentar dort sagt das jetzt ausdrücklich, damit es niemand später „aufräumt". Player, Live-TV und die Download-Engine sprechen noch unmittelbar mit Plex. Sie folgen als Nächstes. Co-Authored-By: Claude Opus 5 --- Flixy/FlixyApp.swift | 2 +- Flixy/Resources/Localizable.xcstrings | 2 +- Flixy/Session/DownloadRefiller.swift | 23 +++--- Flixy/Session/DownloadService.swift | 71 +++++++++--------- Flixy/Session/FlixySession.swift | 10 +++ Flixy/Session/HomeLayoutSettings.swift | 19 ++--- Flixy/Session/PlaybackCoordinator.swift | 18 ++--- Flixy/Views/Detail/ItemActionsMenu.swift | 19 ++--- Flixy/Views/Detail/MediaDetailView.swift | 8 +-- Flixy/Views/Detail/ParentLinks.swift | 12 ++-- Flixy/Views/Detail/PlayableDetailView.swift | 44 ++++++------ Flixy/Views/Detail/SeriesDetailView.swift | 32 ++++----- Flixy/Views/Detail/TechnicalDetails.swift | 10 +-- Flixy/Views/Downloads/DownloadButton.swift | 17 ++--- Flixy/Views/Home/HeroSection.swift | 12 ++-- Flixy/Views/Home/HomeView.swift | 20 +++--- Flixy/Views/Home/MediaRow.swift | 28 ++++---- Flixy/Views/Library/LibraryFilterSheet.swift | 28 ++++---- Flixy/Views/Library/LibraryView.swift | 45 ++++++------ Flixy/Views/LoadState.swift | 25 +++++-- Flixy/Views/Navigation/RootView.swift | 4 +- Flixy/Views/Player/PlayerModel.swift | 13 ++-- Flixy/Views/Search/SearchView.swift | 10 +-- Flixy/Views/Settings/HomeLayoutView.swift | 6 +- .../Sources/MediaKit/Arrangement.swift | 72 +++++++++++++++++++ .../Sources/MediaKit/MediaBackend.swift | 33 ++++++++- .../Sources/MediaKit/MediaVersion.swift | 25 +++++++ .../PlexKit/Models/HubArrangement.swift | 67 ----------------- .../Sources/PlexKit/PlexBackend.swift | 29 +++++++- .../ArrangementTests.swift} | 46 ++++++------ 30 files changed, 432 insertions(+), 318 deletions(-) create mode 100644 Packages/FlixyKit/Sources/MediaKit/Arrangement.swift delete mode 100644 Packages/FlixyKit/Sources/PlexKit/Models/HubArrangement.swift rename Packages/FlixyKit/Tests/{PlexKitTests/HubArrangementTests.swift => MediaKitTests/ArrangementTests.swift} (65%) diff --git a/Flixy/FlixyApp.swift b/Flixy/FlixyApp.swift index 3a92c1f..d7a0cc9 100644 --- a/Flixy/FlixyApp.swift +++ b/Flixy/FlixyApp.swift @@ -81,7 +81,7 @@ struct FlixyApp: App { // Beim Start einmal prüfen: während die App weg war, // können Folgen gesehen oder gelöscht worden sein. await DownloadRefiller( - server: session.server, + backend: session.backend, coordinator: downloads ).refill() } diff --git a/Flixy/Resources/Localizable.xcstrings b/Flixy/Resources/Localizable.xcstrings index 3c1445b..678787f 100644 --- a/Flixy/Resources/Localizable.xcstrings +++ b/Flixy/Resources/Localizable.xcstrings @@ -3900,7 +3900,7 @@ } } }, - "sort.lastViewed": { + "sort.lastPlayed": { "extractionState": "stale", "localizations": { "de": { diff --git a/Flixy/Session/DownloadRefiller.swift b/Flixy/Session/DownloadRefiller.swift index cc69841..d09a32a 100644 --- a/Flixy/Session/DownloadRefiller.swift +++ b/Flixy/Session/DownloadRefiller.swift @@ -1,5 +1,6 @@ import Foundation import FlixyDownloads +import MediaKit import PlexKit /// Füllt den Vorrat an ungesehenen Folgen wieder auf. @@ -9,7 +10,7 @@ import PlexKit /// Server und ist deshalb prüfbar, das Auswählen braucht ihn zwangsläufig. @MainActor struct DownloadRefiller { - let server: PlexServer? + let backend: PlexBackend? let coordinator: DownloadCoordinator /// Lädt nach, was den Regeln nach fehlt. @@ -19,26 +20,26 @@ struct DownloadRefiller { /// wäre eine unangenehme Überraschung auf der Mobilfunkrechnung. @discardableResult func refill() async -> Int { - guard let server, coordinator.rules.keepUnwatchedEpisodes != nil else { return 0 } + guard let backend, coordinator.rules.keepUnwatchedEpisodes != nil else { return 0 } let needed = coordinator.rules.seriesNeedingRefill(in: coordinator.items) guard !needed.isEmpty else { return 0 } var added = 0 - let service = DownloadService(server: server, coordinator: coordinator) + let service = DownloadService(backend: backend, coordinator: coordinator) for (seriesKey, count) in needed { let candidates = await nextUnwatchedEpisodes( inSeries: seriesKey, count: count, - server: server + backend: backend ) for episode in candidates { // Die Kinderliste bringt keine Dateiangaben mit — ohne sie // ließe sich weder das Speicherlimit prüfen noch ein // Fortschritt anzeigen. - let detailed = (try? await server.metadata(ratingKey: episode.ratingKey)) ?? episode + let detailed = (try? await backend.item(id: episode.id)) ?? episode // Folgen ohne abrufbare Datei still überspringen: das Nachladen // läuft im Hintergrund, da hilft keine Fehlermeldung. guard let plan = try? service.plan(for: detailed) else { continue } @@ -55,21 +56,21 @@ struct DownloadRefiller { private func nextUnwatchedEpisodes( inSeries seriesKey: String, count: Int, - server: PlexServer - ) async -> [PlexItem] { - guard let seasons = try? await server.children(of: seriesKey) else { return [] } + backend: PlexBackend + ) async -> [MediaItem] { + guard let seasons = try? await backend.children(of: seriesKey) else { return [] } let offlineKeys = Set(coordinator.items.map(\.ratingKey)) - var result: [PlexItem] = [] + var result: [MediaItem] = [] for season in seasons.sorted(by: { ($0.index ?? 0) < ($1.index ?? 0) }) { // Specials führt Plex als Staffel 0. Sie gehören nicht in den // Vorrat für „was kommt als Nächstes". guard (season.index ?? 0) > 0 else { continue } - guard let episodes = try? await server.children(of: season.ratingKey) else { continue } + guard let episodes = try? await backend.children(of: season.id) else { continue } for episode in episodes.sorted(by: { ($0.index ?? 0) < ($1.index ?? 0) }) { - guard !episode.isWatched, !offlineKeys.contains(episode.ratingKey) else { continue } + guard !episode.isWatched, !offlineKeys.contains(episode.id) else { continue } result.append(episode) if result.count >= count { return result } } diff --git a/Flixy/Session/DownloadService.swift b/Flixy/Session/DownloadService.swift index 931049d..babbc56 100644 --- a/Flixy/Session/DownloadService.swift +++ b/Flixy/Session/DownloadService.swift @@ -1,5 +1,6 @@ import Foundation import FlixyDownloads +import MediaKit import PlexKit /// Übersetzt Titel vom Server in Download-Aufträge. @@ -79,7 +80,7 @@ enum DownloadQuality: String, CaseIterable, Identifiable { @MainActor struct DownloadService { - let server: PlexServer? + let backend: PlexBackend? let coordinator: DownloadCoordinator /// Baut den Auftrag für einen einzelnen Titel. @@ -89,33 +90,33 @@ struct DownloadService { /// nichts, obwohl der Grund bekannt war — etwa ein Titel, den der Server /// noch nicht analysiert hat. func plan( - for item: PlexItem, + for item: MediaItem, mediaIndex: Int = 0, quality: DownloadQuality = .original ) throws(DownloadRefusal) -> DownloadPlan { - guard let server else { + guard let backend else { throw .noDownloadableFile(reason: String(localized: "downloads.refused.noServer")) } - let media = item.media.indices.contains(mediaIndex) - ? item.media[mediaIndex] - : item.preferredMedia + let media = item.versions.indices.contains(mediaIndex) + ? item.versions[mediaIndex] + : item.preferredVersion guard let media else { throw .noDownloadableFile(reason: String(localized: "downloads.refused.noMedia")) } - guard let part = media.parts.first else { + guard let part = media.files.first else { throw .noDownloadableFile(reason: String(localized: "downloads.refused.noPart")) } let url: URL? switch quality { case .original: - url = server.downloadURL(part: part) + url = backend.downloadURL(for: part) case .fullHD, .hd, .low: // Über den Universal-Transcoder. NOCH NICHT AM SERVER GEPRÜFT: // offen ist, ob Plex eine Content-Length mitschickt — ohne sie // gibt es keinen Fortschrittsbalken und kein Fortsetzen. - url = server.transcodedDownloadURL( - ratingKey: item.ratingKey, - mediaIndex: mediaIndex, + url = backend.compressedDownloadURL( + itemID: item.id, + versionIndex: mediaIndex, maximumBitrate: quality.maximumBitrate ?? 8_000, resolution: quality.resolution ) @@ -125,13 +126,13 @@ struct DownloadService { } return DownloadPlan( - ratingKey: item.ratingKey, + ratingKey: item.id, title: item.rowTitle, subtitle: item.downloadSubtitle, sourceURL: url, - seriesKey: item.grandparentRatingKey, - seriesTitle: item.grandparentTitle, - seasonKey: item.parentRatingKey, + seriesKey: item.seriesID, + seriesTitle: item.seriesTitle, + seasonKey: item.parentID, seasonIndex: item.parentIndex, episodeIndex: item.index, durationMilliseconds: item.duration, @@ -146,7 +147,7 @@ struct DownloadService { /// Lädt einen einzelnen Titel. @discardableResult func download( - _ item: PlexItem, + _ item: MediaItem, mediaIndex: Int = 0, quality: DownloadQuality = .original ) throws(DownloadRefusal) -> DownloadItem { @@ -159,30 +160,30 @@ struct DownloadService { /// keine Dateiangaben, und ohne Dateigröße ließe sich weder das /// Speicherlimit prüfen noch ein Fortschrittsbalken zeigen. func downloadAll( - in container: PlexItem - ) async -> (accepted: Int, refused: [(PlexItem, DownloadRefusal)], unavailable: Int) { - guard let server else { return (0, [], 0) } + in container: MediaItem + ) async -> (accepted: Int, refused: [(MediaItem, DownloadRefusal)], unavailable: Int) { + guard let backend else { return (0, [], 0) } - let episodes: [PlexItem] + let episodes: [MediaItem] do { - episodes = try await allEpisodes(in: container, server: server) + episodes = try await allEpisodes(in: container, backend: backend) } catch { return (0, [], 0) } var accepted = 0 - var refused: [(PlexItem, DownloadRefusal)] = [] + var refused: [(MediaItem, DownloadRefusal)] = [] var unavailable = 0 for episode in episodes { // Folgen, die schon offline sind, werden übersprungen statt // abgelehnt — bei einer Serie, von der die Hälfte geladen ist, // wäre eine Liste aus zwanzig Fehlern keine Hilfe. - if coordinator.items.contains(where: { $0.ratingKey == episode.ratingKey }) { + if coordinator.items.contains(where: { $0.ratingKey == episode.id }) { continue } - let detailed = (try? await server.metadata(ratingKey: episode.ratingKey)) ?? episode + let detailed = (try? await backend.item(id: episode.id)) ?? episode do { _ = try coordinator.enqueue(try plan(for: detailed)) @@ -191,7 +192,7 @@ struct DownloadService { // Einzelne Folgen ohne Datei stoppen den Stapel nicht — sie // werden am Ende nur gezählt. unavailable += 1 - } catch { + } catch let error as DownloadRefusal { refused.append((episode, error)) // Beim Speicherlimit hat es keinen Sinn, die übrigen Folgen // noch einzeln durchzuprobieren — sie scheitern alle gleich. @@ -203,14 +204,14 @@ struct DownloadService { return (accepted, refused, unavailable) } - private func allEpisodes(in container: PlexItem, server: PlexServer) async throws -> [PlexItem] { + private func allEpisodes(in container: MediaItem, backend: PlexBackend) async throws -> [MediaItem] { switch container.kind { case .season: - return try await server.children(of: container.ratingKey) + return try await backend.children(of: container.id) case .show: - var episodes: [PlexItem] = [] - for season in try await server.children(of: container.ratingKey) { - episodes.append(contentsOf: try await server.children(of: season.ratingKey)) + var episodes: [MediaItem] = [] + for season in try await backend.children(of: container.id) { + episodes.append(contentsOf: try await backend.children(of: season.id)) } return episodes default: @@ -219,7 +220,7 @@ struct DownloadService { } /// Der Abzug der Serverangaben, den der Player offline braucht. - private func offlineMedia(for item: PlexItem, media: PlexMedia, part: PlexPart) -> OfflineMedia { + private func offlineMedia(for item: MediaItem, media: MediaVersion, part: MediaFile) -> OfflineMedia { OfflineMedia( container: part.container ?? media.container, videoCodec: media.videoCodec, @@ -230,16 +231,16 @@ struct DownloadService { subtitleLanguages: part.subtitleStreams.compactMap(\.language), markers: item.markers.map { OfflineMarker( - type: $0.type, - startMilliseconds: $0.startTimeOffset, - endMilliseconds: $0.endTimeOffset + type: $0.kind.rawValue, + startMilliseconds: $0.startMilliseconds, + endMilliseconds: $0.endMilliseconds ) } ) } } -extension PlexItem { +extension MediaItem { /// Die Zeile unter dem Titel in der Downloads-Ansicht. var downloadSubtitle: String? { if kind == .episode, let season = parentIndex, let episode = index { diff --git a/Flixy/Session/FlixySession.swift b/Flixy/Session/FlixySession.swift index 98fd3e0..c5bb751 100644 --- a/Flixy/Session/FlixySession.swift +++ b/Flixy/Session/FlixySession.swift @@ -1,4 +1,5 @@ import Foundation +import MediaKit import Observation import PlexKit @@ -69,6 +70,15 @@ final class FlixySession { private(set) var phase: Phase = .launching private(set) var server: PlexServer? + /// Derselbe Server, aber hinter dem neutralen Vertrag. + /// + /// Die Ansichten sprechen hierüber, nicht mehr mit Plex unmittelbar. Der + /// Weg darunter bleibt offen, solange Player, Downloads und Live-TV noch + /// nicht umgestellt sind — die drei folgen als Nächstes. + var backend: PlexBackend? { server.map(PlexBackend.init) } + + /// Die Mediatheken in neutraler Form. + var mediaLibraries: [MediaLibrary] { libraries.map(\.asMediaLibrary) } /// Nur die Mediatheken, die Flixy darstellen kann — Musik und Fotos /// erscheinen gar nicht erst. private(set) var libraries: [PlexLibrary] = [] diff --git a/Flixy/Session/HomeLayoutSettings.swift b/Flixy/Session/HomeLayoutSettings.swift index 97e5267..2560d36 100644 --- a/Flixy/Session/HomeLayoutSettings.swift +++ b/Flixy/Session/HomeLayoutSettings.swift @@ -1,11 +1,12 @@ import Foundation import Observation +import MediaKit import PlexKit /// Welche Reihen die Startseite zeigt und in welcher Reihenfolge. /// /// Hält nur die Einstellung und ihre Speicherung. Das Anordnen selbst macht -/// `HubArrangement` in PlexKit — dort ist es reine Rechnung und ohne Simulator +/// `Arrangement` in MediaKit — dort ist es reine Rechnung und ohne Simulator /// prüfbar, und genau dort liegen die kniffligen Fälle: neue Reihen vom /// Server, verschwundene aus der gespeicherten Liste. @MainActor @@ -17,11 +18,11 @@ final class HomeLayoutSettings { } private let defaults: UserDefaults - private(set) var arrangement: HubArrangement + private(set) var arrangement: Arrangement init(defaults: UserDefaults = .standard) { self.defaults = defaults - self.arrangement = HubArrangement( + self.arrangement = Arrangement( hidden: Set(defaults.stringArray(forKey: Key.hidden) ?? []), order: defaults.stringArray(forKey: Key.order) ?? [] ) @@ -45,15 +46,15 @@ final class HomeLayoutSettings { defaults.set(identifiers, forKey: Key.order) } - func apply(to hubs: [PlexHub]) -> [PlexHub] { - arrangement.apply(to: hubs) + func apply(to rows: [MediaRowGroup]) -> [MediaRowGroup] { + arrangement.apply(to: rows) } /// Die Zeilen für den Einstellungsbildschirm. - func rows(from hubs: [PlexHub]) -> [HomeRow] { - arrangement.arrangedIdentifiers(from: hubs).compactMap { identifier in - guard let hub = hubs.first(where: { $0.hubIdentifier == identifier }) else { return nil } - return HomeRow(id: identifier, title: hub.title, isHidden: isHidden(identifier)) + func rows(from rows: [MediaRowGroup]) -> [HomeRow] { + arrangement.arrangedIdentifiers(from: rows).compactMap { identifier in + guard let row = rows.first(where: { $0.id == identifier }) else { return nil } + return HomeRow(id: identifier, title: row.title, isHidden: isHidden(identifier)) } } } diff --git a/Flixy/Session/PlaybackCoordinator.swift b/Flixy/Session/PlaybackCoordinator.swift index 5b6b352..b86b3ca 100644 --- a/Flixy/Session/PlaybackCoordinator.swift +++ b/Flixy/Session/PlaybackCoordinator.swift @@ -1,6 +1,6 @@ import Foundation import Observation -import PlexKit +import MediaKit /// Was abgespielt werden soll. struct PlaybackRequest: Identifiable, Hashable { @@ -63,9 +63,9 @@ final class PlaybackCoordinator { private(set) var request: PlaybackRequest? private(set) var presentation: Presentation = .hidden - func play(_ item: PlexItem, mediaIndex: Int = 0, resuming: Bool = true) { + func play(_ item: MediaItem, mediaIndex: Int = 0, resuming: Bool = true) { play(PlaybackRequest( - ratingKey: item.ratingKey, + ratingKey: item.id, title: item.playbackTitle, mediaIndex: mediaIndex, startTime: resuming ? item.resumeTime : .zero @@ -127,24 +127,18 @@ final class PlaybackCoordinator { } } -extension PlexItem { +extension MediaItem { /// Wie der Titel im Player heißt. /// /// Bei Folgen ist der eigene Titel allein nutzlos — „Die erste Folge" /// sagt nichts darüber, welche Serie läuft. var playbackTitle: String { - if kind == .episode, let series = grandparentTitle { + if kind == .episode, let series = seriesTitle { return "\(series) · \(title)" } return title } - /// Ab welcher Stelle fortgesetzt wird. - var resumeTime: Duration { - guard let viewOffset, viewOffset > 0 else { return .zero } - return .milliseconds(viewOffset) - } - /// Die Beschriftung unter einer Kachel. /// /// Sie ordnet ein, was darüber steht: zu einer Folge gehört die Serie, zu @@ -157,7 +151,7 @@ extension PlexItem { if let season = parentIndex, let episode = index { code = String(localized: "episode.short \(season) \(episode)") } - guard let series = grandparentTitle else { return code } + guard let series = seriesTitle else { return code } return code.map { "\(series) · \($0)" } ?? series case .season: // Ohne die Serie darüber wäre „Staffel 10" beliebig — deshalb diff --git a/Flixy/Views/Detail/ItemActionsMenu.swift b/Flixy/Views/Detail/ItemActionsMenu.swift index 0882ffc..e9450ee 100644 --- a/Flixy/Views/Detail/ItemActionsMenu.swift +++ b/Flixy/Views/Detail/ItemActionsMenu.swift @@ -1,5 +1,6 @@ import SwiftUI import FlixyDesign +import MediaKit import PlexKit /// Das Aktionsmenü auf einer Detailseite. @@ -8,7 +9,7 @@ import PlexKit /// einzelne Knöpfe. Das hält die Kopfzeile frei für das, was man ständig /// braucht: Abspielen und Herunterladen. struct ItemActionsMenu: View { - let item: PlexItem + let item: MediaItem /// Wird gerufen, wenn sich am Server etwas geändert hat und die /// Detailseite neu geladen werden sollte. let onChange: () async -> Void @@ -22,9 +23,9 @@ struct ItemActionsMenu: View { Button { run { server in if item.isWatched { - try await server.markUnwatched(ratingKey: item.ratingKey) + try await server.markUnwatched(id: item.id) } else { - try await server.markWatched(ratingKey: item.ratingKey) + try await server.markWatched(id: item.id) } } } label: { @@ -38,7 +39,7 @@ struct ItemActionsMenu: View { ForEach((1...5).reversed(), id: \.self) { stars in Button { // Plex rechnet in Zehnteln: fünf Sterne sind eine 10. - run { try await $0.rate(ratingKey: item.ratingKey, rating: stars * 2) } + run { try await $0.rate(id: item.id, rating: stars * 2) } } label: { Label(String(repeating: "★", count: stars), systemImage: "star") } @@ -46,7 +47,7 @@ struct ItemActionsMenu: View { if item.rating != nil { Divider() Button(role: .destructive) { - run { try await $0.rate(ratingKey: item.ratingKey, rating: 0) } + run { try await $0.rate(id: item.id, rating: 0) } } label: { Label("action.clearRating", systemImage: "star.slash") } @@ -77,17 +78,17 @@ struct ItemActionsMenu: View { /// Bewusst ohne vorweggenommene Anzeige: den Haken sofort zu setzen und /// bei einem Fehler wieder zurückzunehmen sieht aus wie ein Wackler. Die /// Aktionen dauern Millisekunden. - private func run(_ action: @escaping (PlexServer) async throws -> Void) { - guard let server = session.server else { return } + private func run(_ action: @escaping (PlexBackend) async throws -> Void) { + guard let backend = session.backend else { return } isWorking = true Task { defer { isWorking = false } do { - try await action(server) + try await action(backend) await onChange() } catch { - errorMessage = PlexErrorText.describe(error) + errorMessage = ErrorText.describe(error) } } } diff --git a/Flixy/Views/Detail/MediaDetailView.swift b/Flixy/Views/Detail/MediaDetailView.swift index 3224170..cda7d50 100644 --- a/Flixy/Views/Detail/MediaDetailView.swift +++ b/Flixy/Views/Detail/MediaDetailView.swift @@ -1,6 +1,6 @@ import SwiftUI import FlixyDesign -import PlexKit +import MediaKit /// Die Detailseite eines Titels. /// @@ -11,7 +11,7 @@ struct MediaDetailView: View { let route: MediaRoute @Environment(FlixySession.self) private var session - @State private var state: LoadState = .loading + @State private var state: LoadState = .loading var body: some View { Group { @@ -51,9 +51,9 @@ struct MediaDetailView: View { } private func load() async { - guard let server = session.server else { return } + guard let server = session.backend else { return } state = await LoadState.load { - try await server.metadata(ratingKey: route.ratingKey) + try await server.item(id: route.ratingKey) } } } diff --git a/Flixy/Views/Detail/ParentLinks.swift b/Flixy/Views/Detail/ParentLinks.swift index af4b9de..05b78d7 100644 --- a/Flixy/Views/Detail/ParentLinks.swift +++ b/Flixy/Views/Detail/ParentLinks.swift @@ -1,6 +1,6 @@ import SwiftUI import FlixyDesign -import PlexKit +import MediaKit /// Der Weg nach oben: von der Folge zur Staffel, von der Staffel zur Serie. /// @@ -10,7 +10,7 @@ import PlexKit /// Serie → Staffel erreichbar. Genau dieser Umweg ist der Grund, dass die /// Zeile existiert. struct ParentLinks: View { - let item: PlexItem + let item: MediaItem var body: some View { if !destinations.isEmpty { @@ -53,7 +53,7 @@ struct ParentLinks: View { switch item.kind { case .episode: var stops: [Destination] = [] - if let season = item.parentRatingKey { + if let season = item.parentID { let title = item.parentIndex.map { String(localized: "season.short \($0)") } ?? item.parentTitle ?? String(localized: "detail.toSeason") @@ -62,11 +62,11 @@ struct ParentLinks: View { symbol: "rectangle.stack" )) } - if let show = item.grandparentRatingKey { + if let show = item.seriesID { stops.append(Destination( route: MediaRoute( ratingKey: show, - title: item.grandparentTitle ?? String(localized: "detail.toSeries") + title: item.seriesTitle ?? String(localized: "detail.toSeries") ), symbol: "tv" )) @@ -74,7 +74,7 @@ struct ParentLinks: View { return stops case .season: - guard let show = item.parentRatingKey else { return [] } + guard let show = item.parentID else { return [] } return [Destination( route: MediaRoute( ratingKey: show, diff --git a/Flixy/Views/Detail/PlayableDetailView.swift b/Flixy/Views/Detail/PlayableDetailView.swift index b325272..3ab27c9 100644 --- a/Flixy/Views/Detail/PlayableDetailView.swift +++ b/Flixy/Views/Detail/PlayableDetailView.swift @@ -1,11 +1,11 @@ import SwiftUI import FlixyDesign -import PlexKit +import MediaKit /// Die Detailseite von etwas, das sich direkt abspielen lässt — Film oder /// einzelne Folge. struct PlayableDetailView: View { - let item: PlexItem + let item: MediaItem @Environment(FlixySession.self) private var session @Environment(PlaybackCoordinator.self) private var playback @@ -22,7 +22,7 @@ struct PlayableDetailView: View { ParentLinks(item: item) if item.hasMultipleVersions { - VersionPicker(media: item.media, selection: $selectedVersion) + VersionPicker(media: item.versions, selection: $selectedVersion) .padding(.horizontal, FlixySpacing.screenMargin) } @@ -37,11 +37,11 @@ struct PlayableDetailView: View { TagStrip(tags: item.genres) } - if !item.roles.isEmpty { - CastRow(roles: item.roles) + if !item.cast.isEmpty { + CastRow(cast: item.cast) } - if let media = currentMedia, let part = media.parts.first { + if let media = currentMedia, let part = media.files.first { TechnicalDetails( media: media, part: part, @@ -55,9 +55,9 @@ struct PlayableDetailView: View { .scrollIndicators(.hidden) } - private var currentMedia: PlexMedia? { - guard item.media.indices.contains(selectedVersion) else { return item.media.first } - return item.media[selectedVersion] + private var currentMedia: MediaVersion? { + guard item.versions.indices.contains(selectedVersion) else { return item.versions.first } + return item.versions[selectedVersion] } private var header: some View { @@ -65,12 +65,12 @@ struct PlayableDetailView: View { title: item.rowTitle, metadata: metadataLine, summary: nil, - artworkURL: session.server?.artworkURL( - path: item.art ?? item.grandparentArt ?? item.thumb, + artworkURL: session.backend?.artworkURL( + path: item.art ?? item.seriesArt ?? item.thumb, width: 900, height: 1200 ), - logoURL: session.server?.rawImageURL(path: item.clearLogoPath) + logoURL: session.backend?.rawImageURL(path: item.clearLogoPath) ) { Button { playback.play(item, mediaIndex: selectedVersion) @@ -85,7 +85,7 @@ struct PlayableDetailView: View { } .buttonStyle(.borderedProminent) .tint(accent) - .disabled(currentMedia?.parts.first == nil) + .disabled(currentMedia?.files.first == nil) if let progress = item.watchProgress, progress > 0 { Button { @@ -109,7 +109,7 @@ struct PlayableDetailView: View { if item.kind == .episode { // Der Folgentitel steht jetzt groß darüber; ohne die Serie hier // wüsste man auf dieser Seite nicht, wozu die Folge gehört. - if let series = item.grandparentTitle { pieces.append(series) } + if let series = item.seriesTitle { pieces.append(series) } if let season = item.parentIndex, let episode = item.index { pieces.append(String(localized: "episode.short \(season) \(episode)")) } @@ -124,7 +124,7 @@ struct PlayableDetailView: View { /// Auswahl zwischen mehreren Fassungen desselben Titels. struct VersionPicker: View { - let media: [PlexMedia] + let media: [MediaVersion] @Binding var selection: Int var body: some View { @@ -146,13 +146,13 @@ struct VersionPicker: View { /// Genres als Streifen. struct TagStrip: View { - let tags: [PlexTag] + let tags: [String] var body: some View { ScrollView(.horizontal) { HStack(spacing: FlixySpacing.snug) { - ForEach(tags) { tag in - Text(tag.tag) + ForEach(tags, id: \.self) { tag in + Text(tag) .font(.flixyCaption) .foregroundStyle(Color.flixyTextSecondary) .padding(.horizontal, FlixySpacing.regular) @@ -168,7 +168,7 @@ struct TagStrip: View { /// Die Besetzung mit Porträts. struct CastRow: View { - let roles: [PlexTag] + let cast: [MediaPerson] @Environment(FlixySession.self) private var session var body: some View { @@ -180,17 +180,17 @@ struct CastRow: View { ScrollView(.horizontal) { HStack(alignment: .top, spacing: FlixySpacing.regular) { - ForEach(roles.prefix(20)) { role in + ForEach(cast.prefix(20)) { role in VStack(spacing: FlixySpacing.snug) { FlixyArtwork( - url: session.server?.artworkURL(path: role.thumb, width: 160, height: 160), + url: session.backend?.artworkURL(path: role.thumb, width: 160, height: 160), aspectRatio: 1, fallbackSymbol: "person" ) .frame(width: 72, height: 72) .clipShape(.circle) - Text(role.tag) + Text(role.name) .font(.flixyCaption) .foregroundStyle(Color.flixyTextPrimary) .lineLimit(2) diff --git a/Flixy/Views/Detail/SeriesDetailView.swift b/Flixy/Views/Detail/SeriesDetailView.swift index 36f652a..98ea3a1 100644 --- a/Flixy/Views/Detail/SeriesDetailView.swift +++ b/Flixy/Views/Detail/SeriesDetailView.swift @@ -1,16 +1,16 @@ import SwiftUI import FlixyDesign -import PlexKit +import MediaKit /// Die Detailseite einer Serie oder Staffel. /// /// Beide nutzen dieselbe Ansicht, weil sie sich nur im Inhalt der Liste /// unterscheiden: eine Serie führt zu Staffeln, eine Staffel zu Folgen. struct SeriesDetailView: View { - let item: PlexItem + let item: MediaItem @Environment(FlixySession.self) private var session - @State private var children: LoadState<[PlexItem]> = .loading + @State private var children: LoadState<[MediaItem]> = .loading var body: some View { ScrollView { @@ -30,12 +30,12 @@ struct SeriesDetailView: View { childList - if !item.roles.isEmpty { CastRow(roles: item.roles) } + if !item.cast.isEmpty { CastRow(cast: item.cast) } } .padding(.bottom, FlixySpacing.section) } .scrollIndicators(.hidden) - .task(id: item.ratingKey) { await loadChildren() } + .task(id: item.id) { await loadChildren() } } private var header: some View { @@ -43,12 +43,12 @@ struct SeriesDetailView: View { title: item.rowTitle, metadata: metadataLine, summary: nil, - artworkURL: session.server?.artworkURL( + artworkURL: session.backend?.artworkURL( path: item.art ?? item.thumb, width: 900, height: 1200 ), - logoURL: session.server?.rawImageURL(path: item.clearLogoPath) + logoURL: session.backend?.rawImageURL(path: item.clearLogoPath) ) { DownloadAllButton(container: item) } @@ -75,7 +75,7 @@ struct SeriesDetailView: View { LazyVStack(spacing: 0) { ForEach(entries) { entry in - NavigationLink(value: MediaRoute(ratingKey: entry.ratingKey, title: entry.title)) { + NavigationLink(value: MediaRoute(ratingKey: entry.id, title: entry.title)) { ChildRow(item: entry) } .buttonStyle(.plain) @@ -95,10 +95,10 @@ struct SeriesDetailView: View { pieces.append(String(localized: "season.short \(number)")) } if let year = item.year { pieces.append(String(year)) } - if let leafCount = item.leafCount { - pieces.append(String(localized: "detail.episodeCount \(leafCount)")) + if let episodeCount = item.episodeCount { + pieces.append(String(localized: "detail.episodeCount \(episodeCount)")) } - if let viewed = item.viewedLeafCount, let total = item.leafCount, viewed < total { + if let viewed = item.playedEpisodeCount, let total = item.episodeCount, viewed < total { pieces.append(String(localized: "detail.unwatchedCount \(total - viewed)")) } if let rating = item.contentRating { pieces.append(rating) } @@ -106,22 +106,22 @@ struct SeriesDetailView: View { } private func loadChildren() async { - guard let server = session.server else { return } + guard let server = session.backend else { return } children = await LoadState.load { - try await server.children(of: item.ratingKey) + try await server.children(of: item.id) } } } /// Eine Staffel- oder Folgenzeile. private struct ChildRow: View { - let item: PlexItem + let item: MediaItem @Environment(FlixySession.self) private var session var body: some View { HStack(spacing: FlixySpacing.regular) { FlixyArtwork( - url: session.server?.artworkURL( + url: session.backend?.artworkURL( path: item.thumb, width: item.kind == .season ? 120 : 240, height: item.kind == .season ? 180 : 135 @@ -176,7 +176,7 @@ private struct ChildRow: View { private var subtitle: String? { switch item.kind { case .season: - item.leafCount.map { String(localized: "detail.episodeCount \($0)") } + item.episodeCount.map { String(localized: "detail.episodeCount \($0)") } case .episode: item.duration.map { Duration.milliseconds($0).runtimeLabel } default: diff --git a/Flixy/Views/Detail/TechnicalDetails.swift b/Flixy/Views/Detail/TechnicalDetails.swift index c15b292..2f63a59 100644 --- a/Flixy/Views/Detail/TechnicalDetails.swift +++ b/Flixy/Views/Detail/TechnicalDetails.swift @@ -1,7 +1,7 @@ import SwiftUI import FlixyDesign import FlixyPlayback -import PlexKit +import MediaKit /// Die aufklappbaren technischen Angaben zu einer Datei. /// @@ -10,12 +10,12 @@ import PlexKit /// erste Stelle zum Nachsehen — und in Phase 1, wo es noch kein Transcoding /// gibt, erklärt es, warum bei manchen Dateien kein Bild-in-Bild angeboten wird. struct TechnicalDetails: View { - let media: PlexMedia - let part: PlexPart + let media: MediaVersion + let part: MediaFile @Binding var isExpanded: Bool private var decision: PlaybackDecision { - PlaybackDecider().decide(profile: media.profile(part: part), policy: .directPlayOnly) + PlaybackDecider().decide(profile: media.profile(file: part), policy: .directPlayOnly) } var body: some View { @@ -88,7 +88,7 @@ struct TechnicalDetails: View { } } - private func trackLabel(_ stream: PlexStream) -> String { + private func trackLabel(_ stream: MediaStream) -> String { stream.displayTitle ?? [stream.language, stream.codec?.uppercased()] .compactMap(\.self) .joined(separator: " · ") diff --git a/Flixy/Views/Downloads/DownloadButton.swift b/Flixy/Views/Downloads/DownloadButton.swift index 9ed0813..d4cbc07 100644 --- a/Flixy/Views/Downloads/DownloadButton.swift +++ b/Flixy/Views/Downloads/DownloadButton.swift @@ -1,6 +1,7 @@ import SwiftUI import FlixyDesign import FlixyDownloads +import MediaKit import PlexKit /// Der Download-Knopf auf einer Detailseite. @@ -9,7 +10,7 @@ import PlexKit /// verfügbar, gescheitert. Ein getrennter Knopf je Zustand würde die /// Kopfzeile überfüllen — und der Zustand ist ohnehin immer genau einer. struct DownloadButton: View { - let item: PlexItem + let item: MediaItem var mediaIndex: Int = 0 @Environment(FlixySession.self) private var session @@ -20,7 +21,7 @@ struct DownloadButton: View { @State private var showsQualityChoice = false private var existing: DownloadItem? { - downloads.items.first { $0.ratingKey == item.ratingKey } + downloads.items.first { $0.ratingKey == item.id } } var body: some View { @@ -100,7 +101,7 @@ struct DownloadButton: View { } private func enqueue(quality: DownloadQuality) { - let service = DownloadService(server: session.server, coordinator: downloads) + let service = DownloadService(backend: session.backend, coordinator: downloads) do { _ = try service.download(item, mediaIndex: mediaIndex, quality: quality) } catch { @@ -108,8 +109,8 @@ struct DownloadButton: View { } } - private var currentMedia: PlexMedia? { - item.media.indices.contains(mediaIndex) ? item.media[mediaIndex] : item.preferredMedia + private var currentMedia: MediaVersion? { + item.versions.indices.contains(mediaIndex) ? item.versions[mediaIndex] : item.preferredVersion } /// Nur Stufen, die unter der Auflösung der Datei liegen. @@ -123,7 +124,7 @@ struct DownloadButton: View { /// Ohne diese Angabe ist die Wahl blind — man weiß nicht, ob man gerade /// herunterrechnet oder nicht. private func qualityLabel(_ quality: DownloadQuality) -> String { - let originalBytes = currentMedia?.parts.first?.size ?? 0 + let originalBytes = currentMedia?.files.first?.size ?? 0 let bytes = quality.estimatedBytes( durationMilliseconds: item.duration, originalBytes: originalBytes @@ -156,7 +157,7 @@ extension DownloadQuality { /// Der Knopf für eine ganze Staffel oder Serie. struct DownloadAllButton: View { - let container: PlexItem + let container: MediaItem @Environment(FlixySession.self) private var session @Environment(DownloadCoordinator.self) private var downloads @@ -195,7 +196,7 @@ struct DownloadAllButton: View { isWorking = true defer { isWorking = false } - let service = DownloadService(server: session.server, coordinator: downloads) + let service = DownloadService(backend: session.backend, coordinator: downloads) let result = await service.downloadAll(in: container) var lines = [String(localized: "downloads.batch.queued \(result.accepted)")] diff --git a/Flixy/Views/Home/HeroSection.swift b/Flixy/Views/Home/HeroSection.swift index bc665a3..aabde91 100644 --- a/Flixy/Views/Home/HeroSection.swift +++ b/Flixy/Views/Home/HeroSection.swift @@ -1,10 +1,10 @@ import SwiftUI import FlixyDesign -import PlexKit +import MediaKit /// Der Aufmacher oben auf der Startseite. struct HeroSection: View { - let item: PlexItem + let item: MediaItem @Environment(FlixySession.self) private var session @Environment(PlaybackCoordinator.self) private var playback @@ -15,12 +15,12 @@ struct HeroSection: View { title: item.rowTitle, metadata: metadata, summary: item.summary, - artworkURL: session.server?.artworkURL( - path: item.art ?? item.grandparentArt ?? item.thumb, + artworkURL: session.backend?.artworkURL( + path: item.art ?? item.seriesArt ?? item.thumb, width: 900, height: 1200 ), - logoURL: session.server?.rawImageURL(path: item.clearLogoPath) + logoURL: session.backend?.rawImageURL(path: item.clearLogoPath) ) { Button { playback.play(item) @@ -33,7 +33,7 @@ struct HeroSection: View { .buttonStyle(.borderedProminent) .tint(accent) - NavigationLink(value: MediaRoute(ratingKey: item.ratingKey, title: item.title)) { + NavigationLink(value: MediaRoute(ratingKey: item.id, title: item.title)) { Label("hero.details", systemImage: "info.circle") .font(.flixyCardTitle) .padding(.horizontal, FlixySpacing.loose) diff --git a/Flixy/Views/Home/HomeView.swift b/Flixy/Views/Home/HomeView.swift index 437ce84..e69b368 100644 --- a/Flixy/Views/Home/HomeView.swift +++ b/Flixy/Views/Home/HomeView.swift @@ -1,12 +1,12 @@ import SwiftUI import FlixyDesign -import PlexKit +import MediaKit /// Die Startseite: Aufmacher oben, darunter die Reihen vom Server. struct HomeView: View { @Environment(FlixySession.self) private var session @Environment(HomeLayoutSettings.self) private var layout - @State private var state: LoadState<[PlexHub]> = .loading + @State private var state: LoadState<[MediaRowGroup]> = .loading var body: some View { Group { @@ -23,8 +23,8 @@ struct HomeView: View { Button("action.retry") { Task { await load() } } } - case .loaded(let allHubs): - let hubs = layout.apply(to: allHubs) + case .loaded(let allRows): + let hubs = layout.apply(to: allRows) if hubs.isEmpty { ContentUnavailableView { Label("home.empty.title", systemImage: "film.stack") @@ -55,7 +55,7 @@ struct HomeView: View { .refreshable { await load() } } - private func content(hubs: [PlexHub]) -> some View { + private func content(hubs: [MediaRowGroup]) -> some View { ScrollView { LazyVStack(alignment: .leading, spacing: FlixySpacing.section) { if let featured = heroItem(in: hubs) { @@ -63,7 +63,7 @@ struct HomeView: View { } ForEach(hubs) { hub in - MediaRow(hub: hub) + MediaRow(row: hub) } } .padding(.bottom, FlixySpacing.section) @@ -76,15 +76,15 @@ struct HomeView: View { /// Erste Wahl ist etwas Angefangenes — wer die App öffnet, will meistens /// weiterschauen. Erst wenn es nichts Angefangenes gibt, tritt der /// Neuzugang an die Stelle. - private func heroItem(in hubs: [PlexHub]) -> PlexItem? { - let continuing = hubs.first { $0.hubIdentifier.contains("continue") } + private func heroItem(in hubs: [MediaRowGroup]) -> MediaItem? { + let continuing = hubs.first(where: \.isContinueWatching) return continuing?.items.first ?? hubs.first?.items.first } private func load() async { - guard let server = session.server else { return } + guard let backend = session.backend else { return } state = await LoadState.load(keepingPrevious: state) { - try await server.homeHubs() + try await backend.homeRows() } } } diff --git a/Flixy/Views/Home/MediaRow.swift b/Flixy/Views/Home/MediaRow.swift index a1456f8..113907c 100644 --- a/Flixy/Views/Home/MediaRow.swift +++ b/Flixy/Views/Home/MediaRow.swift @@ -1,10 +1,10 @@ import SwiftUI import FlixyDesign -import PlexKit +import MediaKit /// Eine waagerechte Reihe von Titeln. struct MediaRow: View { - let hub: PlexHub + let row: MediaRowGroup @Environment(FlixySession.self) private var session @@ -17,15 +17,15 @@ struct MediaRow: View { var body: some View { VStack(alignment: .leading, spacing: FlixySpacing.regular) { - Text(hub.title) + Text(row.title) .font(.flixyRowHeader) .foregroundStyle(Color.flixyTextPrimary) .padding(.horizontal, FlixySpacing.screenMargin) ScrollView(.horizontal) { LazyHStack(spacing: FlixySpacing.regular) { - ForEach(hub.items) { item in - NavigationLink(value: MediaRoute(ratingKey: item.ratingKey, title: item.title)) { + ForEach(row.items) { item in + NavigationLink(value: MediaRoute(ratingKey: item.id, title: item.title)) { card(for: item) } .buttonStyle(.plain) @@ -47,12 +47,12 @@ struct MediaRow: View { /// dafür ist das Querformat richtig. Wer dagegen aussucht, was er als /// Nächstes sehen will, sucht nach Plakaten. @ViewBuilder - private func card(for item: PlexItem) -> some View { - if hub.isContinueWatching { + private func card(for item: MediaItem) -> some View { + if row.isContinueWatching { LandscapeCard( title: item.rowTitle, subtitle: item.cardSubtitle, - artworkURL: session.server?.artworkURL( + artworkURL: session.backend?.artworkURL( path: item.rowArtworkPath, width: 440, height: 248 @@ -64,7 +64,7 @@ struct MediaRow: View { PosterCard( title: item.rowTitle, subtitle: item.cardSubtitle, - artworkURL: session.server?.artworkURL( + artworkURL: session.backend?.artworkURL( path: item.posterArtworkPath, width: 300, height: 450 @@ -76,7 +76,7 @@ struct MediaRow: View { } } -extension PlexItem { +extension MediaItem { /// Der Titel auf einer Kachel in einer Reihe. /// /// Groß steht, worum es geht: bei einer Folge ihr eigener Titel, bei @@ -87,9 +87,9 @@ extension PlexItem { switch kind { case .episode: title // Die Serie steht je nach Antwort mal unter `parentTitle`, mal unter - // `grandparentTitle`. Fehlt beides, bleibt „Staffel 2" — besser als + // `seriesTitle`. Fehlt beides, bleibt „Staffel 2" — besser als // gar keine Beschriftung. - case .season: parentTitle ?? grandparentTitle ?? title + case .season: parentTitle ?? seriesTitle ?? title default: title } } @@ -101,7 +101,7 @@ extension PlexItem { /// würde; dort ist `art` (das Hintergrundbild) die bessere Wahl. var rowArtworkPath: String? { switch kind { - case .episode: thumb ?? grandparentThumb + case .episode: thumb ?? seriesThumb default: art ?? thumb } } @@ -113,7 +113,7 @@ extension PlexItem { /// Wiedererkennen wertlos. var posterArtworkPath: String? { switch kind { - case .episode: grandparentThumb ?? parentThumb ?? thumb + case .episode: seriesThumb ?? parentThumb ?? thumb default: thumb } } diff --git a/Flixy/Views/Library/LibraryFilterSheet.swift b/Flixy/Views/Library/LibraryFilterSheet.swift index b89482b..80d7829 100644 --- a/Flixy/Views/Library/LibraryFilterSheet.swift +++ b/Flixy/Views/Library/LibraryFilterSheet.swift @@ -1,24 +1,24 @@ import SwiftUI import FlixyDesign -import PlexKit +import MediaKit /// Filter und Sortierung einer Mediathek. struct LibraryFilterSheet: View { - @Binding var query: LibraryQuery - let libraryKind: PlexLibrary.Kind? + @Binding var query: MediaQuery + let libraryKind: MediaLibrary.Kind? @Environment(\.dismiss) private var dismiss @Environment(\.flixyAccent) private var accent @Environment(FlixySession.self) private var session - @State private var genres: [PlexFilterOption] = [] + @State private var genres: [MediaFilterOption] = [] /// Ein Arbeitsstand, der erst beim Bestätigen übernommen wird. /// /// Sonst löste jeder Tipp im Blatt sofort eine neue Serverabfrage aus — /// bei drei Änderungen hintereinander drei verworfene Ladevorgänge. - @State private var draft: LibraryQuery + @State private var draft: MediaQuery - init(query: Binding, libraryKind: PlexLibrary.Kind?) { + init(query: Binding, libraryKind: MediaLibrary.Kind?) { self._query = query self.libraryKind = libraryKind self._draft = State(initialValue: query.wrappedValue) @@ -29,7 +29,7 @@ struct LibraryFilterSheet: View { Form { Section("filter.sort") { Picker("filter.sort", selection: $draft.sort) { - ForEach(LibraryQuery.Sort.allCases, id: \.self) { sort in + ForEach(MediaQuery.Sort.allCases, id: \.self) { sort in Text(sort.label).tag(sort) } } @@ -43,7 +43,7 @@ struct LibraryFilterSheet: View { Picker("filter.genre", selection: $draft.genre) { Text("filter.any").tag(String?.none) ForEach(genres) { genre in - Text(genre.title).tag(String?.some(genre.key)) + Text(genre.title).tag(String?.some(genre.id)) } } @@ -58,7 +58,7 @@ struct LibraryFilterSheet: View { Section { Button("filter.reset", role: .destructive) { - draft = LibraryQuery(sort: draft.sort) + draft = MediaQuery(sort: draft.sort) } .disabled(!draft.isFiltering) } @@ -91,14 +91,14 @@ struct LibraryFilterSheet: View { } private func loadGenres() async { - guard let server = session.server, - let library = session.libraries.first(where: { $0.kind == libraryKind }) + guard let server = session.backend, + let library = session.mediaLibraries.first(where: { $0.kind == libraryKind }) else { return } - genres = (try? await server.filterOptions(inLibrary: library, field: "genre")) ?? [] + genres = (try? await server.filterOptions(inLibrary: library, field: .genre)) ?? [] } } -extension LibraryQuery.Sort { +extension MediaQuery.Sort { var label: LocalizedStringKey { switch self { case .titleAscending: "sort.titleAscending" @@ -106,7 +106,7 @@ extension LibraryQuery.Sort { case .recentlyAdded: "sort.recentlyAdded" case .releaseDate: "sort.releaseDate" case .rating: "sort.rating" - case .lastViewed: "sort.lastViewed" + case .lastPlayed: "sort.lastPlayed" } } } diff --git a/Flixy/Views/Library/LibraryView.swift b/Flixy/Views/Library/LibraryView.swift index 447b1a7..300af18 100644 --- a/Flixy/Views/Library/LibraryView.swift +++ b/Flixy/Views/Library/LibraryView.swift @@ -1,18 +1,18 @@ import SwiftUI import FlixyDesign -import PlexKit +import MediaKit /// Eine Mediathek zum Durchblättern. struct LibraryView: View { - let library: PlexLibrary + let library: MediaLibrary @Environment(FlixySession.self) private var session @AppStorage("flixy.library.layout") private var layout: LibraryLayout = .grid - @State private var items: [PlexItem] = [] + @State private var items: [MediaItem] = [] @State private var state: LoadState = .loading - @State private var query = LibraryQuery() - @State private var page: PlexPage? + @State private var query = MediaQuery() + @State private var page: MediaPage? @State private var isLoadingMore = false @State private var showsFilters = false @State private var isScanning = false @@ -41,7 +41,7 @@ struct LibraryView: View { } actions: { if query.isFiltering { Button("library.clearFilters") { - query = LibraryQuery(sort: query.sort) + query = MediaQuery(sort: query.sort) } } } @@ -58,7 +58,7 @@ struct LibraryView: View { LibraryFilterSheet(query: $query, libraryKind: library.kind) } .navigationDestination(for: MediaRoute.self) { MediaDetailView(route: $0) } - .task(id: library.key) { await reload() } + .task(id: library.id) { await reload() } // Jede Änderung an Filter oder Sortierung verwirft die bisherige // Liste — Nachladen mit altem Versatz würde sonst Einträge aus zwei // verschiedenen Abfragen vermischen. @@ -84,11 +84,11 @@ struct LibraryView: View { spacing: FlixySpacing.loose ) { ForEach(items) { item in - NavigationLink(value: MediaRoute(ratingKey: item.ratingKey, title: item.title)) { + NavigationLink(value: MediaRoute(ratingKey: item.id, title: item.title)) { PosterCard( title: item.title, subtitle: item.cardSubtitle, - artworkURL: session.server?.artworkURL(path: item.thumb, width: 300, height: 450), + artworkURL: session.backend?.artworkURL(path: item.thumb, width: 300, height: 450), progress: item.watchProgress ) } @@ -106,7 +106,7 @@ struct LibraryView: View { ScrollView { LazyVStack(spacing: 0) { ForEach(items) { item in - NavigationLink(value: MediaRoute(ratingKey: item.ratingKey, title: item.title)) { + NavigationLink(value: MediaRoute(ratingKey: item.id, title: item.title)) { LibraryListRow(item: item) } .buttonStyle(.plain) @@ -164,7 +164,7 @@ struct LibraryView: View { /// nach kurzer Wartezeit neu geholt. Wer gerade Dateien abgelegt hat, /// sieht sie damit meistens schon. private func scan() async { - guard let server = session.server, !isScanning else { return } + guard let server = session.backend, !isScanning else { return } isScanning = true defer { isScanning = false } @@ -176,30 +176,30 @@ struct LibraryView: View { // MARK: - Laden private func reload() async { - guard let server = session.server else { return } + guard let server = session.backend else { return } state = .loading do { - let first = try await server.items(inLibrary: library, query: query) + let first = try await server.items(inLibrary: library, query: query, offset: 0, limit: 60) items = first.items page = first state = .loaded(()) } catch is CancellationError { return } catch { - state = .failed(message: PlexErrorText.describe(error)) + state = .failed(message: ErrorText.describe(error)) } } /// Lädt nach, sobald die Ansicht sich dem Ende der Liste nähert. - private func loadMoreIfNeeded(reaching item: PlexItem) async { - guard let server = session.server, + private func loadMoreIfNeeded(reaching item: MediaItem) async { + guard let server = session.backend, let page, page.hasMore, !isLoadingMore, // Fünf Einträge vor Schluss: früh genug, dass der Nachschub da // ist, bevor jemand am Ende ankommt, und spät genug, dass nicht // jedes Scrollen eine Anfrage auslöst. - items.suffix(5).contains(where: { $0.ratingKey == item.ratingKey }) + items.suffix(5).contains(where: { $0.id == item.id }) else { return } isLoadingMore = true @@ -209,13 +209,14 @@ struct LibraryView: View { let next = try await server.items( inLibrary: library, query: query, - offset: page.nextOffset + offset: page.nextOffset, + limit: 60 ) // Plex kann bei gleichzeitigem Serverscan denselben Titel auf zwei // Seiten liefern. Ohne diese Prüfung stünde er doppelt im Raster — // und SwiftUI beschwert sich über doppelte Kennungen. - let known = Set(items.map(\.ratingKey)) - items.append(contentsOf: next.items.filter { !known.contains($0.ratingKey) }) + let known = Set(items.map(\.id)) + items.append(contentsOf: next.items.filter { !known.contains($0.id) }) self.page = next } catch { // Stillschweigend: die Liste steht bereits, ein Fehlerblatt mitten @@ -231,13 +232,13 @@ enum LibraryLayout: String, CaseIterable { /// Eine Zeile in der Listenansicht. private struct LibraryListRow: View { - let item: PlexItem + let item: MediaItem @Environment(FlixySession.self) private var session var body: some View { HStack(spacing: FlixySpacing.regular) { FlixyArtwork( - url: session.server?.artworkURL(path: item.thumb, width: 120, height: 180), + url: session.backend?.artworkURL(path: item.thumb, width: 120, height: 180), aspectRatio: PosterCard.aspectRatio ) .frame(width: 44) diff --git a/Flixy/Views/LoadState.swift b/Flixy/Views/LoadState.swift index db280f4..baef18f 100644 --- a/Flixy/Views/LoadState.swift +++ b/Flixy/Views/LoadState.swift @@ -1,4 +1,5 @@ import Foundation +import MediaKit import PlexKit /// Der Ladezustand eines Bildschirms. @@ -41,16 +42,32 @@ extension LoadState { // interessiert. return previous ?? .loading } catch { - return .failed(message: PlexErrorText.describe(error)) + return .failed(message: ErrorText.describe(error)) } } } /// Übersetzt Fehler in Sätze, die man einem Menschen zeigen kann. -enum PlexErrorText { +/// +/// **Eine** Stelle. Es gab davon zwei — hier und in `FlixySession` —, und sie +/// waren bereits auseinandergelaufen: derselbe Fehler bekam je nach +/// Bildschirm eine andere Erklärung. +enum ErrorText { static func describe(_ error: any Error) -> String { - guard let plexError = error as? PlexError else { return error.localizedDescription } - return switch plexError { + // Die neutrale Form zuerst, weil die Ansichten nur noch sie sehen. + // Plex' eigener Fehler kommt nur noch dort an, wo unmittelbar mit ihm + // gesprochen wird — Player, Downloads, Live-TV. + if let backendError = error as? MediaBackendError { + return describe(backendError) + } + if let plexError = error as? PlexError { + return describe(plexError.asMediaBackendError) + } + return error.localizedDescription + } + + static func describe(_ error: MediaBackendError) -> String { + switch error { case .unauthorized: String(localized: "error.unauthorized") case .notFound: String(localized: "error.notFound") case .noReachableConnection: String(localized: "error.serverUnreachable") diff --git a/Flixy/Views/Navigation/RootView.swift b/Flixy/Views/Navigation/RootView.swift index 305cc08..92e385a 100644 --- a/Flixy/Views/Navigation/RootView.swift +++ b/Flixy/Views/Navigation/RootView.swift @@ -146,7 +146,7 @@ struct RootView: View { } private var sidebarDestinations: [SidebarDestination] { - session.libraries.map { .library(key: $0.key, title: $0.title) } + session.mediaLibraries.map { .library(key: $0.id, title: $0.title) } } /// iPad: die Seitenleiste ist Teil des Fensters. @@ -196,7 +196,7 @@ struct RootView: View { case .home: HomeView() case .library(let key, let title): - if let library = session.libraries.first(where: { $0.key == key }) { + if let library = session.mediaLibraries.first(where: { $0.id == key }) { LibraryView(library: library) } else { PlaceholderScreen(destination: .library(key: key, title: title)) diff --git a/Flixy/Views/Player/PlayerModel.swift b/Flixy/Views/Player/PlayerModel.swift index a95470a..cc8662d 100644 --- a/Flixy/Views/Player/PlayerModel.swift +++ b/Flixy/Views/Player/PlayerModel.swift @@ -168,13 +168,13 @@ final class PlayerModel { item = try await server.metadata(ratingKey: request.ratingKey) diagnostics.note("Metadaten da") } catch { - diagnostics.finish(outcome: "Metadaten fehlgeschlagen: \(PlexErrorText.describe(error))") - phase = .failed(message: PlexErrorText.describe(error)) + diagnostics.finish(outcome: "Metadaten fehlgeschlagen: \(ErrorText.describe(error))") + phase = .failed(message: ErrorText.describe(error)) return } self.item = item - title = item.rowTitle + title = item.asMediaItem.rowTitle subtitle = playerSubtitle(for: item) guard let media = version(of: item), let part = media.parts.first else { @@ -591,7 +591,7 @@ final class PlayerModel { guard let nextEpisode else { return nil } return PlaybackRequest( ratingKey: nextEpisode.ratingKey, - title: nextEpisode.playbackTitle, + title: nextEpisode.asMediaItem.playbackTitle, mediaIndex: 0, startTime: .zero ) @@ -689,7 +689,10 @@ final class PlayerModel { // um eine Folge kleiner. downloads.markWatched(ratingKey: request.ratingKey) Task { [server, downloads] in - await DownloadRefiller(server: server, coordinator: downloads).refill() + await DownloadRefiller( + backend: server.map(PlexBackend.init), + coordinator: downloads + ).refill() } case .tracksResolved(let audio, let subtitle): audioTracks = audio diff --git a/Flixy/Views/Search/SearchView.swift b/Flixy/Views/Search/SearchView.swift index d662236..ba7a16c 100644 --- a/Flixy/Views/Search/SearchView.swift +++ b/Flixy/Views/Search/SearchView.swift @@ -1,6 +1,6 @@ import SwiftUI import FlixyDesign -import PlexKit +import MediaKit /// Der Knopf, der zur Suche führt. Steht in mehreren Werkzeugleisten. struct SearchToolbarItem: ToolbarContent { @@ -21,7 +21,7 @@ struct SearchView: View { @Environment(FlixySession.self) private var session @State private var text = "" - @State private var hubs: [PlexHub] = [] + @State private var hubs: [MediaRowGroup] = [] @State private var isSearching = false @State private var recent: [String] = UserDefaults.standard.stringArray(forKey: recentKey) ?? [] @@ -53,7 +53,7 @@ struct SearchView: View { ScrollView { LazyVStack(alignment: .leading, spacing: FlixySpacing.section) { ForEach(hubs) { hub in - MediaRow(hub: hub) + MediaRow(row: hub) } } .padding(.vertical, FlixySpacing.loose) @@ -94,7 +94,7 @@ struct SearchView: View { private func search() async { let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) - guard let server = session.server, trimmed.count >= 2 else { + guard let backend = session.backend, trimmed.count >= 2 else { hubs = [] return } @@ -111,7 +111,7 @@ struct SearchView: View { defer { isSearching = false } do { - hubs = try await server.search(trimmed) + hubs = try await backend.search(trimmed, limit: 30) if !hubs.isEmpty { remember(trimmed) } } catch is CancellationError { return diff --git a/Flixy/Views/Settings/HomeLayoutView.swift b/Flixy/Views/Settings/HomeLayoutView.swift index f2ccb93..6b0d700 100644 --- a/Flixy/Views/Settings/HomeLayoutView.swift +++ b/Flixy/Views/Settings/HomeLayoutView.swift @@ -1,6 +1,6 @@ import SwiftUI import FlixyDesign -import PlexKit +import MediaKit /// Reihen der Startseite ein- und ausblenden und umsortieren. struct HomeLayoutView: View { @@ -68,11 +68,11 @@ struct HomeLayoutView: View { private func load() async { defer { isLoading = false } - guard let server = session.server else { return } + guard let backend = session.backend else { return } // Die Reihen kommen vom Server — es gibt keine feste Liste, die sich // vorweg anzeigen ließe. Was hier steht, hängt davon ab, welche // Mediatheken und Sammlungen angelegt sind. - let hubs = (try? await server.homeHubs()) ?? [] + let hubs = (try? await backend.homeRows()) ?? [] rows = layout.rows(from: hubs) } } diff --git a/Packages/FlixyKit/Sources/MediaKit/Arrangement.swift b/Packages/FlixyKit/Sources/MediaKit/Arrangement.swift new file mode 100644 index 0000000..a37f26d --- /dev/null +++ b/Packages/FlixyKit/Sources/MediaKit/Arrangement.swift @@ -0,0 +1,72 @@ +import Foundation + +/// Etwas, das der Nutzer ausblenden und verschieben kann. +public protocol Arrangeable { + /// Eine Kennung, die über Abrufe hinweg gleich bleibt. + /// + /// **Nicht** der angezeigte Titel: benennt der Server eine Reihe oder eine + /// Mediathek um, wäre sonst alles Gespeicherte hinfällig. + var arrangementID: String { get } + var arrangementTitle: String { get } +} + +/// Was ausgeblendet ist und in welcher Reihenfolge der Rest steht. +/// +/// War bis eben an die Startseiten-Reihen von Plex gebunden. Die Rechnung ist +/// dieselbe für jede Liste, die jemand selbst zusammenstellt — Reihen, +/// Seitenleisteneinträge, Sender. Sie ein zweites Mal zu schreiben hieße, sie +/// ein zweites Mal falsch zu machen. +public struct Arrangement: Sendable, Equatable { + public var hidden: Set + public var order: [String] + + public init(hidden: Set = [], order: [String] = []) { + self.hidden = hidden + self.order = order + } + + /// Wendet Auswahl und Reihenfolge an. + /// + /// Unbekannte Einträge landen am Ende statt zu verschwinden. Sie zu + /// verwerfen wäre die einfachere Regel — und der Grund dafür, dass ein + /// neu angelegtes Regal auf dem Server in der App nie aufgetaucht wäre. + public func apply(to elements: [Element]) -> [Element] { + let visible = elements.filter { !hidden.contains($0.arrangementID) } + guard !order.isEmpty else { return visible } + + let positions = Dictionary( + order.enumerated().map { ($1, $0) }, + // Ein doppelter Eintrag in der gespeicherten Reihenfolge — etwa + // nach einem fehlgeschlagenen Schreibvorgang — soll nicht + // abstürzen. Die erste Position gewinnt. + uniquingKeysWith: { first, _ in first } + ) + + return visible.enumerated().sorted { left, right in + let leftPosition = positions[left.element.arrangementID] ?? Int.max + let rightPosition = positions[right.element.arrangementID] ?? Int.max + // Bei gleicher Position — beide unbekannt — bleibt die Reihenfolge + // des Servers erhalten. Ohne diese Rückfalllinie wäre die + // Anordnung neuer Einträge zufällig. + if leftPosition == rightPosition { return left.offset < right.offset } + return leftPosition < rightPosition + }.map(\.element) + } + + /// Die Liste für den Einstellungsbildschirm: gespeicherte Reihenfolge + /// zuerst, danach alles Übrige. + /// + /// Gespeicherte Einträge, die es nicht mehr gibt, fallen heraus — sie + /// anzuzeigen hieße, eine Reihe anzubieten, die niemand mehr hat. + public func arrangedIdentifiers(from elements: [Element]) -> [String] { + let available = elements.map(\.arrangementID) + let known = Set(available) + let stored = order.filter(known.contains) + return stored + available.filter { !stored.contains($0) } + } +} + +extension MediaRowGroup: Arrangeable { + public var arrangementID: String { id } + public var arrangementTitle: String { title } +} diff --git a/Packages/FlixyKit/Sources/MediaKit/MediaBackend.swift b/Packages/FlixyKit/Sources/MediaKit/MediaBackend.swift index 955337a..402d190 100644 --- a/Packages/FlixyKit/Sources/MediaKit/MediaBackend.swift +++ b/Packages/FlixyKit/Sources/MediaKit/MediaBackend.swift @@ -43,6 +43,22 @@ public protocol MediaBackend: Sendable { nonisolated func artworkURL(path: String?, width: Int, height: Int) -> URL? nonisolated func rawImageURL(path: String?) -> URL? + // MARK: - Herunterladen + + /// Die Adresse, unter der eine Datei unverändert zu holen ist. + nonisolated func downloadURL(for file: MediaFile) -> URL? + + /// Die Adresse für eine verkleinerte Fassung. + /// + /// `nil`, wenn der Server das nicht anbietet — dann bleibt nur das + /// Original, und die Oberfläche soll die Wahl gar nicht erst zeigen. + nonisolated func compressedDownloadURL( + itemID: String, + versionIndex: Int, + maximumBitrate: Int, + resolution: String? + ) -> URL? + // MARK: - Zustand melden func markWatched(id: String) async throws(MediaBackendError) @@ -67,16 +83,27 @@ public enum BackendCapability: String, Sendable, Hashable { case transcoding } -/// Eine Seite aus einer Mediathek. +/// Ein Ausschnitt aus einer Mediathek. +/// +/// „Seite" ist hier eine technische Portion, **keine Seite in der Oberfläche**. +/// Die Mediathek ist eine einzige lange Liste, die nachlädt, sobald man sich +/// dem Ende nähert — Blättern mit „weiter zu Seite 2" gehört nicht in eine +/// Mediathek, in der man stöbert. public struct MediaPage: Sendable, Equatable { public let items: [MediaItem] + /// Ab welcher Stelle dieser Ausschnitt beginnt. + public let offset: Int /// Wie viele es insgesamt gibt — Grundlage fürs Nachladen. - public let totalCount: Int? + public let totalCount: Int - public init(items: [MediaItem], totalCount: Int? = nil) { + public init(items: [MediaItem], offset: Int = 0, totalCount: Int) { self.items = items + self.offset = offset self.totalCount = totalCount } + + public var hasMore: Bool { offset + items.count < totalCount } + public var nextOffset: Int { offset + items.count } } /// Ein möglicher Wert für einen Filter. diff --git a/Packages/FlixyKit/Sources/MediaKit/MediaVersion.swift b/Packages/FlixyKit/Sources/MediaKit/MediaVersion.swift index 01028b1..2dcc2bf 100644 --- a/Packages/FlixyKit/Sources/MediaKit/MediaVersion.swift +++ b/Packages/FlixyKit/Sources/MediaKit/MediaVersion.swift @@ -62,6 +62,31 @@ public struct MediaVersion: Sendable, Equatable, Identifiable { } } + /// Die Auflösung als Wort — „4K", „1080p". + /// + /// Aus der Bildhöhe, nicht aus dem Namen des Servers: Plex sagt „4k", + /// Jellyfin gibt Zahlen. Gemeint ist dasselbe. + public var resolutionLabel: String? { + guard let pixelHeight else { return videoResolution?.uppercased() } + switch pixelHeight { + case 1_600...: return "4K" + case 1_000..<1_600: return "1080p" + case 700..<1_000: return "720p" + case 1..<700: return "SD" + default: return nil + } + } + + /// Eine kurze Beschreibung für die Versionsauswahl, etwa + /// „4K · HEVC · 38 Mbit/s". + public var qualityLabel: String { + var pieces: [String] = [] + if let label = resolutionLabel { pieces.append(label) } + if let videoCodec { pieces.append(videoCodec.uppercased()) } + if let bitrate { pieces.append("\(bitrate / 1000) Mbit/s") } + return pieces.joined(separator: " · ") + } + /// Was die Wiedergabe-Entscheidung über diese Fassung wissen muss. public func profile(file: MediaFile?) -> MediaProfile { MediaProfile( diff --git a/Packages/FlixyKit/Sources/PlexKit/Models/HubArrangement.swift b/Packages/FlixyKit/Sources/PlexKit/Models/HubArrangement.swift deleted file mode 100644 index 4363b49..0000000 --- a/Packages/FlixyKit/Sources/PlexKit/Models/HubArrangement.swift +++ /dev/null @@ -1,67 +0,0 @@ -import Foundation - -/// Wie die Reihen der Startseite angeordnet werden. -/// -/// Reine Rechnung ohne Speicher und ohne Oberfläche — die Stelle, an der die -/// zwei unangenehmen Fälle entschieden werden: was mit Reihen geschieht, die -/// der Server neu liefert, und was mit gespeicherten, die es nicht mehr gibt. -public struct HubArrangement: Sendable, Equatable { - /// Ausdrücklich ausgeblendete Reihen. - public var hidden: Set - /// Die gewünschte Reihenfolge. Reihen, die hier fehlen, landen am Ende. - public var order: [String] - - public init(hidden: Set = [], order: [String] = []) { - self.hidden = hidden - self.order = order - } - - /// Wendet Auswahl und Reihenfolge an. - /// - /// Unbekannte Reihen landen am Ende statt zu verschwinden. Sie zu - /// verwerfen wäre die einfachere Regel — und der Grund dafür, dass ein - /// neu angelegtes Regal auf dem Server in der App nie aufgetaucht wäre. - public func apply(to hubs: [PlexHub]) -> [PlexHub] { - let visible = hubs.filter { !hidden.contains($0.hubIdentifier) } - guard !order.isEmpty else { return visible } - - let positions = Dictionary( - order.enumerated().map { ($1, $0) }, - // Ein doppelter Eintrag in der gespeicherten Reihenfolge — etwa - // nach einem fehlgeschlagenen Schreibvorgang — soll nicht - // abstürzen. Die erste Position gewinnt. - uniquingKeysWith: { first, _ in first } - ) - - return visible.enumerated().sorted { left, right in - let leftPosition = positions[left.element.hubIdentifier] ?? Int.max - let rightPosition = positions[right.element.hubIdentifier] ?? Int.max - // Bei gleicher Position — beide unbekannt — bleibt die Reihenfolge - // des Servers erhalten. Ohne diese Rückfalllinie wäre die - // Anordnung neuer Reihen zufällig. - if leftPosition == rightPosition { return left.offset < right.offset } - return leftPosition < rightPosition - }.map(\.element) - } - - /// Die Liste für den Einstellungsbildschirm: gespeicherte Reihenfolge - /// zuerst, danach alles, was der Server sonst noch liefert. - /// - /// Gespeicherte Reihen, die es nicht mehr gibt, fallen dabei heraus — sie - /// anzuzeigen hieße, eine Reihe anzubieten, die nie erscheinen kann. - public func arrangedIdentifiers(from hubs: [PlexHub]) -> [String] { - var result: [String] = [] - var seen = Set() - - for identifier in order - where !seen.contains(identifier) && hubs.contains(where: { $0.hubIdentifier == identifier }) { - result.append(identifier) - seen.insert(identifier) - } - for hub in hubs where !seen.contains(hub.hubIdentifier) { - result.append(hub.hubIdentifier) - seen.insert(hub.hubIdentifier) - } - return result - } -} diff --git a/Packages/FlixyKit/Sources/PlexKit/PlexBackend.swift b/Packages/FlixyKit/Sources/PlexKit/PlexBackend.swift index 32456f9..0592bb6 100644 --- a/Packages/FlixyKit/Sources/PlexKit/PlexBackend.swift +++ b/Packages/FlixyKit/Sources/PlexKit/PlexBackend.swift @@ -49,7 +49,11 @@ public struct PlexBackend: MediaBackend { offset: offset, limit: limit ) - return MediaPage(items: page.items.map(\.asMediaItem), totalCount: page.totalSize) + return MediaPage( + items: page.items.map(\.asMediaItem), + offset: page.offset, + totalCount: page.totalSize + ) } } @@ -95,6 +99,29 @@ public struct PlexBackend: MediaBackend { server.rawImageURL(path: path) } + // MARK: - Herunterladen + + public nonisolated func downloadURL(for file: MediaFile) -> URL? { + guard let key = file.key else { return nil } + // Nur der Schlüssel wird gebraucht; alles andere steckt schon in der + // Adresse oder ist für den Abruf ohne Belang. + return server.downloadURL(part: PlexPart(id: 0, key: key)) + } + + public nonisolated func compressedDownloadURL( + itemID: String, + versionIndex: Int, + maximumBitrate: Int, + resolution: String? + ) -> URL? { + server.transcodedDownloadURL( + ratingKey: itemID, + mediaIndex: versionIndex, + maximumBitrate: maximumBitrate, + resolution: resolution + ) + } + // MARK: - Zustand melden public func markWatched(id: String) async throws(MediaBackendError) { diff --git a/Packages/FlixyKit/Tests/PlexKitTests/HubArrangementTests.swift b/Packages/FlixyKit/Tests/MediaKitTests/ArrangementTests.swift similarity index 65% rename from Packages/FlixyKit/Tests/PlexKitTests/HubArrangementTests.swift rename to Packages/FlixyKit/Tests/MediaKitTests/ArrangementTests.swift index b3b58cd..5004783 100644 --- a/Packages/FlixyKit/Tests/PlexKitTests/HubArrangementTests.swift +++ b/Packages/FlixyKit/Tests/MediaKitTests/ArrangementTests.swift @@ -1,33 +1,33 @@ import Foundation import Testing -@testable import PlexKit +@testable import MediaKit -private func hub(_ identifier: String) -> PlexHub { - PlexHub(hubIdentifier: identifier, title: identifier.capitalized) +private func hub(_ identifier: String) -> MediaRowGroup { + MediaRowGroup(id: identifier, title: identifier.capitalized) } @Suite("Anordnung der Startseiten-Reihen") -struct HubArrangementTests { +struct ArrangementTests { private let serverHubs = [hub("continue"), hub("recent"), hub("watchlist"), hub("collections")] @Test("Ohne Einstellungen bleibt die Reihenfolge des Servers") func defaultKeepsServerOrder() { - let result = HubArrangement().apply(to: serverHubs) - #expect(result.map(\.hubIdentifier) == ["continue", "recent", "watchlist", "collections"]) + let result = Arrangement().apply(to: serverHubs) + #expect(result.map(\.id) == ["continue", "recent", "watchlist", "collections"]) } @Test("Ausgeblendete Reihen verschwinden") func hiddenRowsAreRemoved() { - let arrangement = HubArrangement(hidden: ["watchlist", "collections"]) + let arrangement = Arrangement(hidden: ["watchlist", "collections"]) let result = arrangement.apply(to: serverHubs) - #expect(result.map(\.hubIdentifier) == ["continue", "recent"]) + #expect(result.map(\.id) == ["continue", "recent"]) } @Test("Die gespeicherte Reihenfolge wird angewendet") func storedOrderIsApplied() { - let arrangement = HubArrangement(order: ["watchlist", "continue", "recent", "collections"]) + let arrangement = Arrangement(order: ["watchlist", "continue", "recent", "collections"]) let result = arrangement.apply(to: serverHubs) - #expect(result.map(\.hubIdentifier) == ["watchlist", "continue", "recent", "collections"]) + #expect(result.map(\.id) == ["watchlist", "continue", "recent", "collections"]) } @Test("Neue Reihen vom Server landen am Ende statt zu verschwinden") @@ -35,53 +35,53 @@ struct HubArrangementTests { // Der Fall, der die einfachere Regel — nur Bekanntes anzeigen — zur // Falle macht: ein neu angelegtes Regal auf dem Server wäre in der App // nie aufgetaucht, ohne dass jemand den Grund gesehen hätte. - let arrangement = HubArrangement(order: ["recent", "continue"]) + let arrangement = Arrangement(order: ["recent", "continue"]) let result = arrangement.apply(to: serverHubs) - #expect(result.map(\.hubIdentifier) == ["recent", "continue", "watchlist", "collections"]) + #expect(result.map(\.id) == ["recent", "continue", "watchlist", "collections"]) } @Test("Mehrere neue Reihen behalten die Reihenfolge des Servers") func multipleUnknownRowsKeepServerOrder() { // Ohne stabile Rückfalllinie wäre ihre Anordnung zufällig — und // änderte sich bei jedem Start. - let arrangement = HubArrangement(order: ["collections"]) + let arrangement = Arrangement(order: ["collections"]) let result = arrangement.apply(to: serverHubs) - #expect(result.map(\.hubIdentifier) == ["collections", "continue", "recent", "watchlist"]) + #expect(result.map(\.id) == ["collections", "continue", "recent", "watchlist"]) } @Test("Gespeicherte Reihen, die es nicht mehr gibt, stören nicht") func staleOrderEntriesAreIgnored() { // Entsteht beim Löschen einer Mediathek auf dem Server. - let arrangement = HubArrangement(order: ["geloescht", "recent", "continue"]) + let arrangement = Arrangement(order: ["geloescht", "recent", "continue"]) let result = arrangement.apply(to: serverHubs) - #expect(result.map(\.hubIdentifier) == ["recent", "continue", "watchlist", "collections"]) + #expect(result.map(\.id) == ["recent", "continue", "watchlist", "collections"]) } @Test("Ein doppelter Eintrag in der Reihenfolge stürzt nicht ab") func duplicateOrderEntriesAreTolerated() { // Kann nach einem abgebrochenen Schreibvorgang entstehen. Ein // Dictionary aus doppelten Schlüsseln wäre ein harter Absturz. - let arrangement = HubArrangement(order: ["recent", "recent", "continue"]) + let arrangement = Arrangement(order: ["recent", "recent", "continue"]) let result = arrangement.apply(to: serverHubs) - #expect(result.first?.hubIdentifier == "recent") + #expect(result.first?.id == "recent") #expect(result.count == 4) } @Test("Ausblenden und Sortieren greifen zusammen") func hidingAndOrderingCombine() { - let arrangement = HubArrangement( + let arrangement = Arrangement( hidden: ["recent"], order: ["collections", "recent", "continue"] ) let result = arrangement.apply(to: serverHubs) - #expect(result.map(\.hubIdentifier) == ["collections", "continue", "watchlist"]) + #expect(result.map(\.id) == ["collections", "continue", "watchlist"]) } // MARK: - Die Liste für die Einstellungen @Test("Der Einstellungsbildschirm zeigt Gespeichertes zuerst") func settingsListPutsStoredFirst() { - let arrangement = HubArrangement(order: ["watchlist", "continue"]) + let arrangement = Arrangement(order: ["watchlist", "continue"]) let identifiers = arrangement.arrangedIdentifiers(from: serverHubs) #expect(identifiers == ["watchlist", "continue", "recent", "collections"]) } @@ -90,14 +90,14 @@ struct HubArrangementTests { func settingsListIncludesHidden() { // Sonst ließe sich eine einmal ausgeblendete Reihe nie wieder // einschalten — eine Einbahnstraße. - let arrangement = HubArrangement(hidden: ["recent"]) + let arrangement = Arrangement(hidden: ["recent"]) let identifiers = arrangement.arrangedIdentifiers(from: serverHubs) #expect(identifiers.contains("recent")) } @Test("Verschwundene Reihen erscheinen nicht in den Einstellungen") func settingsListDropsStaleEntries() { - let arrangement = HubArrangement(order: ["geloescht", "recent"]) + let arrangement = Arrangement(order: ["geloescht", "recent"]) let identifiers = arrangement.arrangedIdentifiers(from: serverHubs) #expect(!identifiers.contains("geloescht")) }