diff --git a/Flixy/FlixyApp.swift b/Flixy/FlixyApp.swift index d7a0cc9..062350f 100644 --- a/Flixy/FlixyApp.swift +++ b/Flixy/FlixyApp.swift @@ -3,6 +3,8 @@ import SwiftUI import FlixyDesign import FlixyDownloads import FlixyPlayback +import JellyfinKit +import MediaKit import PlexKit @main @@ -35,6 +37,10 @@ struct FlixyApp: App { ) _session = State(initialValue: FlixySession( identity: identity, + jellyfinIdentity: JellyfinIdentity.current( + client: Branding.appName, + version: Branding.version + ), tokenStore: KeychainTokenStore(service: Branding.keychainService) )) diff --git a/Flixy/Resources/Localizable.xcstrings b/Flixy/Resources/Localizable.xcstrings index 678787f..a2764f6 100644 --- a/Flixy/Resources/Localizable.xcstrings +++ b/Flixy/Resources/Localizable.xcstrings @@ -33,6 +33,22 @@ "comment": "A label for a 720p movie.", "isCommentAutoGenerated": true }, + "action.addFavourite": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Zu Favoriten" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Add to favourites" + } + } + } + }, "action.apply": { "extractionState": "stale", "localizations": { @@ -186,6 +202,22 @@ } } }, + "action.removeFavourite": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Aus Favoriten entfernen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Remove from favourites" + } + } + } + }, "action.retry": { "extractionState": "stale", "localizations": { @@ -1858,6 +1890,102 @@ } } }, + "library.kind.liveTV": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Live-TV" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Live TV" + } + } + } + }, + "library.kind.movie": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Filme" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Movies" + } + } + } + }, + "library.kind.music": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Musik" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Music" + } + } + } + }, + "library.kind.other": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Sonstiges" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Other" + } + } + } + }, + "library.kind.photo": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Fotos" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Photos" + } + } + } + }, + "library.kind.show": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Serien" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "TV shows" + } + } + } + }, "library.scan": { "extractionState": "stale", "localizations": { @@ -2363,6 +2491,22 @@ } } }, + "player.jellyfinPending": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Die Wiedergabe von Jellyfin ist noch nicht fertig — sie ist der nächste Schritt." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Playback from Jellyfin isn't finished yet — it's the next step." + } + } + } + }, "player.loadFailed": { "extractionState": "stale", "localizations": { @@ -3105,6 +3249,22 @@ } } }, + "settings.account": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Konto" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Account" + } + } + } + }, "settings.address": { "extractionState": "stale", "localizations": { @@ -3544,6 +3704,22 @@ } } }, + "settings.serverKind": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Art" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Type" + } + } + } + }, "settings.serverName": { "extractionState": "stale", "localizations": { @@ -3782,22 +3958,6 @@ } } }, - "signIn.action": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Mit Plex anmelden" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Sign in with Plex" - } - } - } - }, "signIn.cancelled": { "extractionState": "stale", "localizations": { @@ -3815,6 +3975,342 @@ } } }, + "signIn.jellyfin.addressHint": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Schema und Port kannst du weglassen — die üblichen werden durchprobiert." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "You can leave out the scheme and port — the usual ones are tried in turn." + } + } + } + }, + "signIn.jellyfin.addressMissing": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Trag bitte die Adresse deines Servers ein." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Please enter your server's address." + } + } + } + }, + "signIn.jellyfin.addressPlaceholder": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "192.168.1.50 oder medien.example.com" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "192.168.1.50 or media.example.com" + } + } + } + }, + "signIn.jellyfin.addressPrompt": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Wo steht dein Server?" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Where is your server?" + } + } + } + }, + "signIn.jellyfin.codeExpired": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Der Code ist abgelaufen. Fordere einen neuen an oder melde dich mit Passwort an." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "The code has expired. Request a new one or sign in with a password." + } + } + } + }, + "signIn.jellyfin.connect": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Verbinden" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Connect" + } + } + } + }, + "signIn.jellyfin.foundServer": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Gefundener Server" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Server found" + } + } + } + }, + "signIn.jellyfin.notFound": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Unter dieser Adresse antwortet kein Jellyfin-Server." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "No Jellyfin server answers at that address." + } + } + } + }, + "signIn.jellyfin.otherAddress": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Andere Adresse eintragen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Enter a different address" + } + } + } + }, + "signIn.jellyfin.password": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Passwort" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Password" + } + } + } + }, + "signIn.jellyfin.passwordNote": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Das Passwort wird nur für diese eine Anfrage gehalten. Gespeichert wird allein der Zugangsschlüssel, und der im Schlüsselbund." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "The password is held for this one request only. Only the access token is stored, and it goes into the keychain." + } + } + } + }, + "signIn.jellyfin.privacy": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "So erreicht dein Passwort diese App nie." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "This way your password never reaches this app." + } + } + } + }, + "signIn.jellyfin.quickConnectHint": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Öffne Jellyfin auf einem Gerät, an dem du schon angemeldet bist, und gib diesen Code unter „Benutzer → Quick Connect“ ein." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Open Jellyfin on a device you're already signed in on and enter this code under “User → Quick Connect”." + } + } + } + }, + "signIn.jellyfin.searching": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Server wird gesucht …" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Looking for the server …" + } + } + } + }, + "signIn.jellyfin.signingIn": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Anmeldung läuft …" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Signing in …" + } + } + } + }, + "signIn.jellyfin.submit": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Anmelden" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Sign in" + } + } + } + }, + "signIn.jellyfin.title": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Jellyfin-Server" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Jellyfin server" + } + } + } + }, + "signIn.jellyfin.useCode": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Stattdessen einen Code bestätigen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Approve a code instead" + } + } + } + }, + "signIn.jellyfin.usePassword": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Stattdessen Benutzername und Passwort" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Use a username and password instead" + } + } + } + }, + "signIn.jellyfin.userName": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Benutzername" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Username" + } + } + } + }, + "signIn.jellyfin.waitingForApproval": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Warte auf die Bestätigung …" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Waiting for approval …" + } + } + } + }, "signIn.manualHint": { "comment": "Hinweis, wenn die Browser-Verknüpfung nicht greift", "localizations": { @@ -3837,33 +4333,33 @@ "de": { "stringUnit": { "state": "translated", - "value": "Die Anmeldemaske kommt von Plex. Dein Passwort erreicht diese App nie." + "value": "Die Anmeldung läuft über deinen Server. Dein Passwort erreicht diese App nie." } }, "en": { "stringUnit": { "state": "translated", - "value": "The sign-in page comes from Plex. Your password never reaches this app." + "value": "Sign-in happens on your server. Your password never reaches this app." } } } }, "signIn.tagline": { - "comment": "Untertitel auf dem Anmeldebildschirm", "localizations": { "de": { "stringUnit": { "state": "translated", - "value": "Deine Plex-Mediathek, schöner." + "value": "Deine Mediathek, schöner." } }, "en": { "stringUnit": { "state": "translated", - "value": "Your Plex library, nicer." + "value": "Your library, nicer." } } - } + }, + "comment": "Untertitel auf dem Anmeldebildschirm" }, "signIn.timedOut": { "comment": "Die Anmeldung wurde nicht innerhalb der Gültigkeit des Codes abgeschlossen", @@ -3900,6 +4396,38 @@ } } }, + "signIn.withJellyfin": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Mit Jellyfin anmelden" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Sign in with Jellyfin" + } + } + } + }, + "signIn.withPlex": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Mit Plex anmelden" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Sign in with Plex" + } + } + } + }, "sort.lastPlayed": { "extractionState": "stale", "localizations": { @@ -4325,4 +4853,4 @@ } }, "version": "1.1" -} \ No newline at end of file +} diff --git a/Flixy/Session/DownloadRefiller.swift b/Flixy/Session/DownloadRefiller.swift index d09a32a..1b5b9f2 100644 --- a/Flixy/Session/DownloadRefiller.swift +++ b/Flixy/Session/DownloadRefiller.swift @@ -1,7 +1,6 @@ import Foundation import FlixyDownloads import MediaKit -import PlexKit /// Füllt den Vorrat an ungesehenen Folgen wieder auf. /// @@ -10,7 +9,7 @@ import PlexKit /// Server und ist deshalb prüfbar, das Auswählen braucht ihn zwangsläufig. @MainActor struct DownloadRefiller { - let backend: PlexBackend? + let backend: (any MediaBackend)? let coordinator: DownloadCoordinator /// Lädt nach, was den Regeln nach fehlt. @@ -56,7 +55,7 @@ struct DownloadRefiller { private func nextUnwatchedEpisodes( inSeries seriesKey: String, count: Int, - backend: PlexBackend + backend: any MediaBackend ) async -> [MediaItem] { guard let seasons = try? await backend.children(of: seriesKey) else { return [] } diff --git a/Flixy/Session/DownloadService.swift b/Flixy/Session/DownloadService.swift index babbc56..72faf6b 100644 --- a/Flixy/Session/DownloadService.swift +++ b/Flixy/Session/DownloadService.swift @@ -1,7 +1,6 @@ import Foundation import FlixyDownloads import MediaKit -import PlexKit /// Übersetzt Titel vom Server in Download-Aufträge. /// @@ -80,7 +79,7 @@ enum DownloadQuality: String, CaseIterable, Identifiable { @MainActor struct DownloadService { - let backend: PlexBackend? + let backend: (any MediaBackend)? let coordinator: DownloadCoordinator /// Baut den Auftrag für einen einzelnen Titel. @@ -192,7 +191,7 @@ struct DownloadService { // Einzelne Folgen ohne Datei stoppen den Stapel nicht — sie // werden am Ende nur gezählt. unavailable += 1 - } catch let error as DownloadRefusal { + } catch { refused.append((episode, error)) // Beim Speicherlimit hat es keinen Sinn, die übrigen Folgen // noch einzeln durchzuprobieren — sie scheitern alle gleich. @@ -204,7 +203,7 @@ struct DownloadService { return (accepted, refused, unavailable) } - private func allEpisodes(in container: MediaItem, backend: PlexBackend) async throws -> [MediaItem] { + private func allEpisodes(in container: MediaItem, backend: any MediaBackend) async throws -> [MediaItem] { switch container.kind { case .season: return try await backend.children(of: container.id) diff --git a/Flixy/Session/FlixySession.swift b/Flixy/Session/FlixySession.swift index c5bb751..133e5b3 100644 --- a/Flixy/Session/FlixySession.swift +++ b/Flixy/Session/FlixySession.swift @@ -1,4 +1,5 @@ import Foundation +import JellyfinKit import MediaKit import Observation import PlexKit @@ -8,9 +9,20 @@ import PlexKit /// Alles, was die App über „bin ich angemeldet und mit wem spreche ich" weiß, /// steht hier. Die Views lesen nur `phase` — dadurch gibt es genau einen Ort, /// an dem sich entscheidet, was auf dem Bildschirm erscheint. +/// +/// Seit es zwei Serverarten gibt, hält diese Klasse **einen** aktiven Server, +/// nicht zwei. Zwei gleichzeitig hießen: zwei Startseiten, zwei Mediatheken, +/// zwei Suchergebnisse — und bei jedem Titel die Frage, woher er stammt. +/// Umschalten ja, nebeneinander nein. @MainActor @Observable final class FlixySession { + /// Welche Art von Server gerade bedient wird. + enum ServerKind: String, Sendable, CaseIterable { + case plex + case jellyfin + } + enum Phase: Equatable { /// Beim Start: liegt ein Token im Schlüsselbund? case launching @@ -43,18 +55,31 @@ final class FlixySession { } private enum Key { + /// Welche Serverart zuletzt aktiv war. + /// + /// Fehlt der Eintrag, aber es liegt ein Plex-Token bereit, war es + /// Plex — so kam die App bis hierher, und niemand soll durch ein + /// Update abgemeldet werden. + static let serverKind = "flixy.backend.kind" static let preferredServer = "flixy.server.clientIdentifier" static let cachedConnectionURI = "flixy.server.connectionURI" static let cachedServerName = "flixy.server.name" static let cachedConnectionIsLocal = "flixy.server.connectionIsLocal" + static let jellyfinAddress = "flixy.jellyfin.address" + static let jellyfinUserID = "flixy.jellyfin.userID" + static let jellyfinUserName = "flixy.jellyfin.userName" + static let jellyfinServerName = "flixy.jellyfin.serverName" + static let jellyfinIsAdministrator = "flixy.jellyfin.isAdministrator" } - /// Unter welchem Namen der Server-Token im Schlüsselbund liegt. + /// Unter welchem Namen die Server-Token im Schlüsselbund liegen. /// - /// Nicht in den Voreinstellungen: er gibt vollen Zugriff auf die - /// Mediathek, genau wie der Konto-Token. - private static let serverTokenAccount = "plex.server" - + /// Nicht in den Voreinstellungen: sie geben vollen Zugriff auf die + /// Mediathek. Getrennte Konten je Serverart, damit ein späteres Umschalten + /// zwischen beiden nicht jedes Mal eine neue Anmeldung kostet. Das + /// ausdrückliche Abmelden räumt trotzdem beide ab — siehe `signOut()`. + private static let plexTokenAccount = "plex.server" + private static let jellyfinTokenAccount = "jellyfin.server" /// Wird für die Anmelde-Adresse gebraucht: plex.tv ordnet den /// bestätigten Code darüber diesem Gerät zu. @@ -69,21 +94,72 @@ final class FlixySession { private(set) var showsManualCode = false 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) } + /// Welche Serverart gerade bedient wird — `nil`, solange niemand + /// angemeldet ist. + private(set) var kind: ServerKind? - /// Die Mediatheken in neutraler Form. - var mediaLibraries: [MediaLibrary] { libraries.map(\.asMediaLibrary) } - /// Nur die Mediatheken, die Flixy darstellen kann — Musik und Fotos + /// Der aktive Server hinter dem neutralen Vertrag. + /// + /// Die Ansichten sprechen ausschließlich hierüber. Ob dahinter Plex oder + /// Jellyfin steckt, sehen sie nicht — und genau deshalb musste für + /// Jellyfin keine einzige Ansicht neu geschrieben werden. + private(set) var backend: (any MediaBackend)? + + /// Der Plex-Server, solange Plex aktiv ist. + /// + /// Player, Live-TV und die Diagnose sprechen noch unmittelbar mit ihm. + /// Bei Jellyfin ist er `nil` — diese drei Bereiche wandern als Nächstes + /// auf den neutralen Vertrag. + private(set) var server: PlexServer? + + /// Das angemeldete Jellyfin-Konto, solange Jellyfin aktiv ist. + private(set) var jellyfinAccount: JellyfinAccount? + + /// Wer angemeldet ist — sofern der Server einen Namen dafür kennt. + /// + /// Plex führt den Kontonamen am Konto, nicht am Server; dort steht er hier + /// noch nicht zur Verfügung, und deshalb bleibt die Zeile leer, statt + /// etwas Falsches zu behaupten. + var accountName: String? { jellyfinAccount?.userName } + + /// Die Mediatheken, die Flixy darstellen kann — Musik und Fotos /// erscheinen gar nicht erst. - private(set) var libraries: [PlexLibrary] = [] + private(set) var libraries: [MediaLibrary] = [] + + /// Wie der Server heißt, so wie er sich selbst nennt. + var serverName: String { backend?.serverName ?? "" } + + /// Unter welcher Adresse er erreicht wird. + var serverAddress: String? { + switch kind { + case .plex: server?.connection.uri + case .jellyfin: defaults.string(forKey: Key.jellyfinAddress) + case nil: nil + } + } + + /// Ob der aktive Server Live-TV anbietet, **so wie die App es heute kann**. + /// + /// Nicht `capabilities.contains(.liveTV)`: Jellyfin kann es, der + /// Programmführer der App spricht aber noch unmittelbar mit Plex. Einen + /// Eintrag zu zeigen, der zu einem leeren Bildschirm führt, wäre eine + /// Zusage, die niemand einlöst. + var supportsLiveTV: Bool { server != nil } + + /// Warum gerade nicht abgespielt werden kann. + /// + /// Bei Jellyfin ist die Anmeldung fertig, die Wiedergabe noch nicht — sie + /// ist der nächste Schritt. „Server nicht erreichbar" wäre an dieser + /// Stelle schlicht gelogen: er ist erreichbar, die App kann nur noch + /// nichts von ihm abspielen. + var playbackUnavailableMessage: String { + kind == .jellyfin + ? String(localized: "player.jellyfinPending") + : String(localized: "error.serverUnreachable") + } private let identity: PlexClientIdentity + private let jellyfinIdentity: JellyfinIdentity private let tokenStore: any TokenStoring private let authenticator: PlexAuthenticator private let webAuthenticator = PlexWebAuthenticator() @@ -92,10 +168,12 @@ final class FlixySession { init( identity: PlexClientIdentity, + jellyfinIdentity: JellyfinIdentity, tokenStore: any TokenStoring, defaults: UserDefaults = .standard ) { self.identity = identity + self.jellyfinIdentity = jellyfinIdentity self.tokenStore = tokenStore self.defaults = defaults self.authenticator = PlexAuthenticator(identity: identity, tokenStore: tokenStore) @@ -105,18 +183,33 @@ final class FlixySession { /// Versucht, mit einem bereits gespeicherten Token weiterzumachen. func restore() async { - guard let token = authenticator.storedToken else { + switch storedKind { + case .jellyfin: + if await restoreJellyfin() { return } phase = .signedOut - return + + case .plex, nil: + guard let token = authenticator.storedToken else { + phase = .signedOut + return + } + // Wer vor dieser Version angemeldet war, hat keinen Eintrag über + // die Serverart — nachtragen, damit „nicht eingetragen" von hier + // an eindeutig „abgemeldet" heißt. + defaults.set(ServerKind.plex.rawValue, forKey: Key.serverKind) + + // Erst der kurze Weg: die Verbindung, die beim letzten Mal + // funktioniert hat. Ohne ihn kostet jeder Start eine Anfrage an + // plex.tv plus eine neue Verbindungssuche — zusammen mehrere + // Sekunden, in denen nur ein Ladekreis zu sehen ist. + if await restoreFromRememberedConnection() { return } + + await connect(accountToken: token) } + } - // Erst der kurze Weg: die Verbindung, die beim letzten Mal - // funktioniert hat. Ohne ihn kostet jeder Start eine Anfrage an - // plex.tv plus eine neue Verbindungssuche — zusammen mehrere - // Sekunden, in denen nur ein Ladekreis zu sehen ist. - if await restoreFromRememberedConnection() { return } - - await connect(accountToken: token) + private var storedKind: ServerKind? { + defaults.string(forKey: Key.serverKind).flatMap(ServerKind.init(rawValue:)) } /// Nimmt die zuletzt funktionierende Verbindung wieder auf. @@ -128,7 +221,7 @@ final class FlixySession { private func restoreFromRememberedConnection() async -> Bool { guard let identifier = defaults.string(forKey: Key.preferredServer), let uri = defaults.string(forKey: Key.cachedConnectionURI), - let serverToken = tokenStore.token(for: Self.serverTokenAccount) + let serverToken = tokenStore.token(for: Self.plexTokenAccount) else { return false } let name = defaults.string(forKey: Key.cachedServerName) ?? "" @@ -152,18 +245,18 @@ final class FlixySession { let server = PlexServer(resource: resource, connection: connection, identity: identity) else { return false } - self.server = server + adopt(server: server) do { - libraries = try await server.libraries().filter(\.isSupported) + libraries = try await server.libraries().filter(\.isSupported).map(\.asMediaLibrary) phase = .ready return true } catch { - self.server = nil + clearActiveServer() return false } } - // MARK: - Anmeldung + // MARK: - Anmeldung bei Plex /// Öffnet die Anmeldeseite von Plex. /// @@ -183,7 +276,7 @@ final class FlixySession { do { pin = try await authenticator.requestPin() } catch { - phase = .failed(message: message(for: error)) + phase = .failed(message: ErrorText.describe(error)) return } @@ -263,17 +356,22 @@ final class FlixySession { phase = .signedOut } - + /// Meldet ab — von allen Servern, nicht nur vom gerade aktiven. + /// + /// Der Knopf heißt „Abmelden" und nicht „diesen Server verlassen". Nur den + /// aktiven zu vergessen hieße, dass nach dem Abmelden beim nächsten Start + /// wieder eine Mediathek dasteht — die des anderen Servers. func signOut() { authorizationTask?.cancel() authenticator.signOut() forgetRememberedConnection() - server = nil - libraries = [] + forgetJellyfin() + defaults.removeObject(forKey: Key.serverKind) + clearActiveServer() phase = .signedOut } - // MARK: - Verbinden + // MARK: - Verbinden mit Plex private func connect(accountToken: String) async { phase = .connecting(serverName: "") @@ -289,7 +387,7 @@ final class FlixySession { phase = .signedOut return } catch { - phase = .failed(message: message(for: error)) + phase = .failed(message: ErrorText.describe(error)) return } @@ -325,25 +423,26 @@ final class FlixySession { return } - self.server = server + adopt(server: server) remember(resource: resource, connection: connection) do { - libraries = try await server.libraries().filter(\.isSupported) + libraries = try await server.libraries().filter(\.isSupported).map(\.asMediaLibrary) phase = .ready } catch { - phase = .failed(message: message(for: error)) + phase = .failed(message: ErrorText.describe(error)) } } /// Merkt sich, worüber es funktioniert hat. private func remember(resource: PlexResource, connection: PlexConnection) { + defaults.set(ServerKind.plex.rawValue, forKey: Key.serverKind) defaults.set(resource.clientIdentifier, forKey: Key.preferredServer) defaults.set(connection.uri, forKey: Key.cachedConnectionURI) defaults.set(connection.local, forKey: Key.cachedConnectionIsLocal) defaults.set(resource.name, forKey: Key.cachedServerName) if let token = resource.accessToken { - try? tokenStore.store(token, for: Self.serverTokenAccount) + try? tokenStore.store(token, for: Self.plexTokenAccount) } } @@ -353,29 +452,148 @@ final class FlixySession { Key.cachedConnectionIsLocal, Key.cachedServerName] { defaults.removeObject(forKey: key) } - tokenStore.remove(for: Self.serverTokenAccount) + tokenStore.remove(for: Self.plexTokenAccount) + } + + // MARK: - Verbinden mit Jellyfin + + /// Übernimmt eine fertige Jellyfin-Anmeldung. + /// + /// Die Anmeldung selbst führt `JellyfinSignInModel` — sie besteht aus + /// mehreren Schritten mit eigener Bedienung (Adresse, Code, Rückfall auf + /// Passwort) und gehört deshalb in ein eigenes Modell. Hier kommt nur das + /// Ergebnis an: ein Konto und ein Weg dorthin. + func adoptJellyfin( + client: JellyfinClient, + account: JellyfinAccount, + serverName: String + ) async { + phase = .connecting(serverName: serverName) + + let backend = JellyfinBackend(client: client, account: account, serverName: serverName) + clearActiveServer() + kind = .jellyfin + jellyfinAccount = account + self.backend = backend + + do { + libraries = try await backend.libraries().filter(\.isSupported) + } catch { + self.backend = nil + jellyfinAccount = nil + kind = nil + phase = .failed(message: ErrorText.describe(error)) + return + } + + rememberJellyfin(account: account, serverName: serverName, baseURL: client.baseURL) + phase = .ready + } + + /// Nimmt eine gespeicherte Jellyfin-Anmeldung wieder auf. + /// + /// Gibt `false` zurück, wenn nichts gespeichert ist oder der Token nicht + /// mehr gilt — dann steht die Anmeldung wieder am Anfang. Ist der Server + /// bloß gerade nicht erreichbar, bleibt die Anmeldung dagegen bestehen und + /// es gibt einen Fehler mit „Erneut versuchen": auf dem Weg zur Arbeit ist + /// jeder Server unerreichbar, und das ist kein Grund, jemanden abzumelden. + private func restoreJellyfin() async -> Bool { + guard let address = defaults.string(forKey: Key.jellyfinAddress), + let baseURL = URL(string: address), + let userID = defaults.string(forKey: Key.jellyfinUserID), + let token = tokenStore.token(for: Self.jellyfinTokenAccount) + else { return false } + + let serverName = defaults.string(forKey: Key.jellyfinServerName) ?? "" + phase = .connecting(serverName: serverName) + + let account = JellyfinAccount( + userID: userID, + userName: defaults.string(forKey: Key.jellyfinUserName) ?? "", + token: token, + isAdministrator: defaults.bool(forKey: Key.jellyfinIsAdministrator) + ) + let client = JellyfinClient(baseURL: baseURL, identity: jellyfinIdentity, token: token) + let backend = JellyfinBackend(client: client, account: account, serverName: serverName) + + clearActiveServer() + kind = .jellyfin + jellyfinAccount = account + self.backend = backend + + do { + libraries = try await backend.libraries().filter(\.isSupported) + phase = .ready + return true + } catch MediaBackendError.unauthorized { + // Der Token wurde am Server zurückgezogen. Ihn zu behalten würde + // bei jedem Start denselben Fehler erzeugen. + forgetJellyfin() + clearActiveServer() + return false + } catch { + phase = .failed(message: ErrorText.describe(error)) + return true + } + } + + private func rememberJellyfin(account: JellyfinAccount, serverName: String, baseURL: URL) { + defaults.set(ServerKind.jellyfin.rawValue, forKey: Key.serverKind) + defaults.set(baseURL.absoluteString, forKey: Key.jellyfinAddress) + defaults.set(account.userID, forKey: Key.jellyfinUserID) + defaults.set(account.userName, forKey: Key.jellyfinUserName) + defaults.set(serverName, forKey: Key.jellyfinServerName) + defaults.set(account.isAdministrator, forKey: Key.jellyfinIsAdministrator) + try? tokenStore.store(account.token, for: Self.jellyfinTokenAccount) + } + + private func forgetJellyfin() { + for key in [Key.jellyfinAddress, Key.jellyfinUserID, Key.jellyfinUserName, + Key.jellyfinServerName, Key.jellyfinIsAdministrator] { + defaults.removeObject(forKey: key) + } + tokenStore.remove(for: Self.jellyfinTokenAccount) + } + + // MARK: - Der aktive Server + + /// Setzt Plex als aktiven Server. + private func adopt(server: PlexServer) { + kind = .plex + self.server = server + backend = PlexBackend(server: server) + jellyfinAccount = nil + } + + private func clearActiveServer() { + server = nil + backend = nil + jellyfinAccount = nil + kind = nil + libraries = [] } /// Nach einem Fehler noch einmal versuchen, ohne die Anmeldung zu verlieren. func retry() async { - if let token = authenticator.storedToken { - await connect(accountToken: token) - } else { + switch storedKind { + case .jellyfin: + if await restoreJellyfin() { return } phase = .signedOut + case .plex, nil: + if let token = authenticator.storedToken { + await connect(accountToken: token) + } else { + phase = .signedOut + } } } - private func message(for error: any Error) -> String { - guard let plexError = error as? PlexError else { return error.localizedDescription } - return switch plexError { - case .unauthorized: String(localized: "error.unauthorized") - case .notFound: String(localized: "error.notFound") - case .noReachableConnection: String(localized: "error.serverUnreachable") - case .server(let status): String(localized: "error.server \(status)") - case .transport(let description): description - case .decoding: String(localized: "error.unexpectedResponse") - } - } + /// Die Kennung für Jellyfin-Anmeldungen. + /// + /// Der Anmeldebildschirm baut sich seinen eigenen Client, bevor es eine + /// Sitzung gibt — er braucht dieselbe Gerätekennung, sonst steht die App + /// nach dem Anmelden ein zweites Mal in der Geräteliste des Servers. + var jellyfinDeviceIdentity: JellyfinIdentity { jellyfinIdentity } } extension Array { diff --git a/Flixy/Session/JellyfinSignInModel.swift b/Flixy/Session/JellyfinSignInModel.swift new file mode 100644 index 0000000..4a6d888 --- /dev/null +++ b/Flixy/Session/JellyfinSignInModel.swift @@ -0,0 +1,219 @@ +import Foundation +import JellyfinKit +import MediaKit +import Observation + +/// Führt durch die Anmeldung an einem Jellyfin-Server. +/// +/// Eigenes Modell statt eines weiteren Falls in `FlixySession.Phase`: die +/// Anmeldung besteht aus mehreren Schritten mit eigener Bedienung — Adresse +/// eintippen, Code bestätigen lassen, notfalls doch Benutzername und Passwort +/// —, und keiner davon interessiert den Rest der App. Was hier ankommt, ist am +/// Ende ein einziges Ergebnis: ein Konto und ein Weg dorthin. +/// +/// **Quick Connect zuerst.** Der sechsstellige Code wird in einem bereits +/// angemeldeten Client bestätigt; das Passwort erreicht Flixy dabei nie — +/// dieselbe Zusage wie bei Plex, wo die Anmeldemaske von Plex selbst kommt. +/// Benutzername und Passwort sind der Rückfall für Server, auf denen Quick +/// Connect abgeschaltet ist. Dann hält die App das Passwort für die Dauer +/// einer Anfrage und wirft es danach weg. +@MainActor +@Observable +final class JellyfinSignInModel { + enum Step: Equatable { + /// Die Adresse wird eingetippt. + case address + /// Die Adresse wird durchprobiert. + case checking + /// Der Code steht, jetzt wird auf die Bestätigung gewartet. + case quickConnect(code: String) + /// Der Rückfall: Benutzername und Passwort. + case credentials + case signingIn + } + + var address = "" + var userName = "" + var password = "" + + private(set) var step: Step = .address + private(set) var errorMessage: String? + /// Wie der gefundene Server heißt — die Bestätigung, dass die Adresse + /// stimmt, noch bevor jemand Anmeldedaten eintippt. + private(set) var serverName: String? + /// Ob der Server die Anmeldung per Code überhaupt anbietet. + private(set) var offersQuickConnect = false + + /// Wird gerufen, sobald ein Konto feststeht. + /// + /// Der Client kommt mit: er trägt den Token und die Gerätekennung bereits + /// und wird von der Sitzung unverändert weiterverwendet. + var onAuthenticated: ((JellyfinClient, JellyfinAccount, String) -> Void)? + + private let identity: JellyfinIdentity + private var client: JellyfinClient? + private var authenticator: JellyfinAuthenticator? + private var pollTask: Task? + + init(identity: JellyfinIdentity) { + self.identity = identity + } + + // MARK: - Schritt 1: den Server finden + + /// Probiert die plausiblen Ergänzungen der eingetippten Adresse durch. + /// + /// Nicht eine raten, sondern der Reihe nach fragen: „192.168.1.50" kann + /// alles Mögliche heißen, und wer die Adresse vollständig eintippen muss, + /// tippt sie dreimal falsch. + func findServer() async { + pollTask?.cancel() + errorMessage = nil + + let candidates = JellyfinAddress.candidates(for: address) + guard !candidates.isEmpty else { + errorMessage = String(localized: "signIn.jellyfin.addressMissing") + return + } + + step = .checking + + for candidate in candidates { + let client = JellyfinClient(baseURL: candidate, identity: identity) + let authenticator = JellyfinAuthenticator(client: client) + guard let info = try? await authenticator.serverInfo() else { continue } + + self.client = client + self.authenticator = authenticator + serverName = info.serverName + address = candidate.absoluteString + offersQuickConnect = await authenticator.supportsQuickConnect() + + if offersQuickConnect { + await beginQuickConnect() + } else { + step = .credentials + } + return + } + + step = .address + errorMessage = String(localized: "signIn.jellyfin.notFound") + } + + // MARK: - Schritt 2a: Code bestätigen lassen + + func beginQuickConnect() async { + guard let authenticator else { return } + errorMessage = nil + step = .checking + + do { + let request = try await authenticator.beginQuickConnect() + step = .quickConnect(code: request.code) + startPolling(secret: request.secret, authenticator: authenticator) + } catch { + // Quick Connect abgeschaltet oder gestört: dann eben der andere + // Weg, statt hier stehen zu bleiben. + offersQuickConnect = false + step = .credentials + errorMessage = ErrorText.describe(error) + } + } + + /// Fragt im Zweisekundentakt, ob der Code bestätigt wurde. + /// + /// Fünf Minuten lang — so lange gilt ein Code bei Jellyfin in der + /// Voreinstellung. Danach wird nicht weiter gefragt, sondern gesagt, dass + /// er abgelaufen ist; eine Schleife, die ewig läuft, sieht aus wie eine + /// App, die hängt. + private func startPolling(secret: String, authenticator: JellyfinAuthenticator) { + pollTask?.cancel() + pollTask = Task { [weak self] in + for _ in 0..<150 { + try? await Task.sleep(for: .seconds(2)) + if Task.isCancelled { return } + + do { + if let account = try await authenticator.completeQuickConnect(secret: secret) { + self?.finish(with: account) + return + } + } catch MediaBackendError.notFound { + // Jellyfin kennt den Code nicht mehr: abgelaufen oder + // abgelehnt. + self?.expireQuickConnect() + return + } catch { + self?.errorMessage = ErrorText.describe(error) + return + } + } + self?.expireQuickConnect() + } + } + + private func expireQuickConnect() { + errorMessage = String(localized: "signIn.jellyfin.codeExpired") + step = .credentials + } + + // MARK: - Schritt 2b: Benutzername und Passwort + + func signInWithPassword() async { + guard let authenticator else { return } + pollTask?.cancel() + errorMessage = nil + step = .signingIn + + let secret = password + // Sofort aus dem Feld nehmen. Es wird nur noch für diese eine Anfrage + // gebraucht, und was nicht mehr dasteht, kann auch nicht in einem + // Bildschirmfoto landen. + password = "" + + do { + let account = try await authenticator.signIn(userName: userName, password: secret) + finish(with: account) + } catch { + errorMessage = ErrorText.describe(error) + step = .credentials + } + } + + // MARK: - Ende + + private func finish(with account: JellyfinAccount) { + pollTask?.cancel() + guard let client else { return } + password = "" + onAuthenticated?(client, account, serverName ?? address) + } + + /// Von der Code-Anzeige auf Benutzername und Passwort wechseln. + /// + /// Der Weg ohne Passwort steht vorn, aber er ist kein Zwang: wer keinen + /// zweiten angemeldeten Client zur Hand hat, käme sonst nicht weiter. + func useCredentials() { + pollTask?.cancel() + errorMessage = nil + step = .credentials + } + + /// Zurück zum Adressfeld — etwa nach einem Vertipper. + func startOver() { + pollTask?.cancel() + client = nil + authenticator = nil + serverName = nil + offersQuickConnect = false + password = "" + errorMessage = nil + step = .address + } + + func cancel() { + pollTask?.cancel() + password = "" + } +} diff --git a/Flixy/Views/Detail/ItemActionsMenu.swift b/Flixy/Views/Detail/ItemActionsMenu.swift index e9450ee..0271a05 100644 --- a/Flixy/Views/Detail/ItemActionsMenu.swift +++ b/Flixy/Views/Detail/ItemActionsMenu.swift @@ -1,13 +1,16 @@ import SwiftUI import FlixyDesign import MediaKit -import PlexKit /// Das Aktionsmenü auf einer Detailseite. /// /// Alles, was den Server verändert, liegt hier zusammen — statt verstreut als /// einzelne Knöpfe. Das hält die Kopfzeile frei für das, was man ständig /// braucht: Abspielen und Herunterladen. +/// +/// Welche Einträge erscheinen, entscheidet der Server: Plex kennt Sterne, aber +/// keine Favoriten, Jellyfin Favoriten, aber keine Sterne. Ein Eintrag, der +/// nichts bewirkt, ist schlimmer als keiner. struct ItemActionsMenu: View { let item: MediaItem /// Wird gerufen, wenn sich am Server etwas geändert hat und die @@ -18,14 +21,18 @@ struct ItemActionsMenu: View { @State private var isWorking = false @State private var errorMessage: String? + private var capabilities: Set { + session.backend?.capabilities ?? [] + } + var body: some View { Menu { Button { - run { server in + run { backend in if item.isWatched { - try await server.markUnwatched(id: item.id) + try await backend.markUnwatched(id: item.id) } else { - try await server.markWatched(id: item.id) + try await backend.markWatched(id: item.id) } } } label: { @@ -35,25 +42,38 @@ struct ItemActionsMenu: View { ) } - Menu { - ForEach((1...5).reversed(), id: \.self) { stars in - Button { - // Plex rechnet in Zehnteln: fünf Sterne sind eine 10. - run { try await $0.rate(id: item.id, rating: stars * 2) } - } label: { - Label(String(repeating: "★", count: stars), systemImage: "star") - } + if capabilities.contains(.favourites) { + Button { + run { try await $0.setFavourite(!item.isFavourite, id: item.id) } + } label: { + Label( + item.isFavourite ? "action.removeFavourite" : "action.addFavourite", + systemImage: item.isFavourite ? "heart.slash" : "heart" + ) } - if item.rating != nil { - Divider() - Button(role: .destructive) { - run { try await $0.rate(id: item.id, rating: 0) } - } label: { - Label("action.clearRating", systemImage: "star.slash") + } + + if capabilities.contains(.ratings) { + Menu { + ForEach((1...5).reversed(), id: \.self) { stars in + Button { + // In Zehnteln: fünf Sterne sind eine 10. + run { try await $0.rate(id: item.id, rating: stars * 2) } + } label: { + Label(String(repeating: "★", count: stars), systemImage: "star") + } } + if item.rating != nil { + Divider() + Button(role: .destructive) { + run { try await $0.rate(id: item.id, rating: 0) } + } label: { + Label("action.clearRating", systemImage: "star.slash") + } + } + } label: { + Label("action.rate", systemImage: "star") } - } label: { - Label("action.rate", systemImage: "star") } } label: { if isWorking { @@ -78,7 +98,7 @@ 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 (PlexBackend) async throws -> Void) { + private func run(_ action: @escaping (any MediaBackend) async throws -> Void) { guard let backend = session.backend else { return } isWorking = true diff --git a/Flixy/Views/Home/HomeView.swift b/Flixy/Views/Home/HomeView.swift index e69b368..e04c570 100644 --- a/Flixy/Views/Home/HomeView.swift +++ b/Flixy/Views/Home/HomeView.swift @@ -51,7 +51,7 @@ struct HomeView: View { .toolbarColorScheme(.dark, for: .navigationBar) .toolbar { SearchToolbarItem() } .navigationDestination(for: MediaRoute.self) { MediaDetailView(route: $0) } - .task(id: session.server?.name) { await load() } + .task(id: session.serverName) { await load() } .refreshable { await load() } } diff --git a/Flixy/Views/Library/LibraryFilterSheet.swift b/Flixy/Views/Library/LibraryFilterSheet.swift index 80d7829..a643ada 100644 --- a/Flixy/Views/Library/LibraryFilterSheet.swift +++ b/Flixy/Views/Library/LibraryFilterSheet.swift @@ -92,7 +92,7 @@ struct LibraryFilterSheet: View { private func loadGenres() async { guard let server = session.backend, - let library = session.mediaLibraries.first(where: { $0.kind == libraryKind }) + let library = session.libraries.first(where: { $0.kind == libraryKind }) else { return } genres = (try? await server.filterOptions(inLibrary: library, field: .genre)) ?? [] } diff --git a/Flixy/Views/Library/LibraryView.swift b/Flixy/Views/Library/LibraryView.swift index 300af18..90cb736 100644 --- a/Flixy/Views/Library/LibraryView.swift +++ b/Flixy/Views/Library/LibraryView.swift @@ -184,9 +184,10 @@ struct LibraryView: View { items = first.items page = first state = .loaded(()) - } catch is CancellationError { - return } catch { + // Siehe `LoadState.load`: eine abgebrochene Netzanfrage meldet + // sich als Übertragungsfehler, nicht als Abbruch. + if Task.isCancelled { return } state = .failed(message: ErrorText.describe(error)) } } diff --git a/Flixy/Views/LoadState.swift b/Flixy/Views/LoadState.swift index baef18f..86d08f0 100644 --- a/Flixy/Views/LoadState.swift +++ b/Flixy/Views/LoadState.swift @@ -36,12 +36,16 @@ extension LoadState { ) async -> Self { do { return .loaded(try await operation()) - } catch is CancellationError { + } catch { // Der Bildschirm wurde verlassen, während geladen wurde. Ein // Fehlertext wäre hier falsch — es ist niemand mehr da, den er // interessiert. - return previous ?? .loading - } catch { + // + // Gefragt wird die Aufgabe, nicht der Fehler: eine abgebrochene + // Netzanfrage kommt als Übertragungsfehler zurück und nicht als + // `CancellationError`. Danach zu fangen sah richtig aus und griff + // nie. + if Task.isCancelled { return previous ?? .loading } return .failed(message: ErrorText.describe(error)) } } diff --git a/Flixy/Views/Navigation/RootView.swift b/Flixy/Views/Navigation/RootView.swift index 92e385a..2c7fab3 100644 --- a/Flixy/Views/Navigation/RootView.swift +++ b/Flixy/Views/Navigation/RootView.swift @@ -126,7 +126,8 @@ struct RootView: View { diagnostics: diagnostics, policy: settings.policy(for: network), settings: settings, - isLocalConnection: session.server?.connection.local ?? false + isLocalConnection: session.server?.connection.local ?? false, + unavailableMessage: session.playbackUnavailableMessage ) // Aus Bild-in-Bild zurückzukehren soll im Vollbild landen, nicht in // der Kachel — dort kam man vorher gar nicht mehr heraus. @@ -146,7 +147,7 @@ struct RootView: View { } private var sidebarDestinations: [SidebarDestination] { - session.mediaLibraries.map { .library(key: $0.id, title: $0.title) } + session.libraries.map { .library(key: $0.id, title: $0.title) } } /// iPad: die Seitenleiste ist Teil des Fensters. @@ -154,6 +155,7 @@ struct RootView: View { NavigationSplitView { SidebarView( libraries: sidebarDestinations, + showsLiveTV: session.supportsLiveTV, selection: $selection, showsHeader: false ) {} @@ -167,7 +169,11 @@ struct RootView: View { /// iPhone: die Seitenleiste fährt über den Inhalt. private var drawerLayout: some View { DrawerContainer(isOpen: $isSidebarOpen) { - SidebarView(libraries: sidebarDestinations, selection: $selection) { + SidebarView( + libraries: sidebarDestinations, + showsLiveTV: session.supportsLiveTV, + selection: $selection + ) { isSidebarOpen = false } } content: { @@ -196,7 +202,7 @@ struct RootView: View { case .home: HomeView() case .library(let key, let title): - if let library = session.mediaLibraries.first(where: { $0.id == key }) { + if let library = session.libraries.first(where: { $0.id == key }) { LibraryView(library: library) } else { PlaceholderScreen(destination: .library(key: key, title: title)) diff --git a/Flixy/Views/Navigation/SidebarView.swift b/Flixy/Views/Navigation/SidebarView.swift index 3442d8e..be21fb2 100644 --- a/Flixy/Views/Navigation/SidebarView.swift +++ b/Flixy/Views/Navigation/SidebarView.swift @@ -8,6 +8,12 @@ import FlixyDesign /// `NavigationSplitView` stecken, ohne etwas zu ändern. struct SidebarView: View { let libraries: [SidebarDestination] + /// Ob der aktive Server Live-TV anbietet. + /// + /// Ein Eintrag, der zu einem leeren Bildschirm führt, ist schlimmer als + /// keiner — und bei Jellyfin führt er dorthin, bis der Programmführer + /// umgestellt ist. + var showsLiveTV: Bool = true @Binding var selection: SidebarDestination /// Auf dem iPad trägt die Navigationsleiste den Namen bereits — dort wäre /// eine eigene Überschrift die dritte Nennung auf demselben Bildschirm. @@ -27,7 +33,7 @@ struct SidebarView: View { section( title: String(localized: "sidebar.more"), - items: [.liveTV, .downloads, .settings] + items: showsLiveTV ? [.liveTV, .downloads, .settings] : [.downloads, .settings] ) } .padding(.horizontal, FlixySpacing.regular) diff --git a/Flixy/Views/Player/PlayerModel.swift b/Flixy/Views/Player/PlayerModel.swift index cc8662d..55a16c6 100644 --- a/Flixy/Views/Player/PlayerModel.swift +++ b/Flixy/Views/Player/PlayerModel.swift @@ -99,6 +99,13 @@ final class PlayerModel { private let policy: PlaybackPolicy private let settings: PlaybackSettings private let isLocalConnection: Bool + /// Was zu sagen ist, wenn es keinen Server gibt, mit dem sich abspielen + /// ließe. + /// + /// Als Text durchgereicht statt hier entschieden: bei Plex heißt das + /// „nicht erreichbar", bei Jellyfin „kommt als Nächstes", und welcher + /// Server angemeldet ist, weiß die Sitzung, nicht der Player. + private let unavailableMessage: String private var item: PlexItem? /// Die gerade gespielte Fassung und Datei. @@ -127,7 +134,8 @@ final class PlayerModel { diagnostics: PlaybackDiagnostics, policy: PlaybackPolicy, settings: PlaybackSettings, - isLocalConnection: Bool + isLocalConnection: Bool, + unavailableMessage: String ) { self.request = request self.server = server @@ -136,6 +144,7 @@ final class PlayerModel { self.policy = policy self.settings = settings self.isLocalConnection = isLocalConnection + self.unavailableMessage = unavailableMessage self.title = request.title } @@ -158,7 +167,7 @@ final class PlayerModel { } guard let server else { - phase = .failed(message: String(localized: "error.serverUnreachable")) + phase = .failed(message: unavailableMessage) return } diff --git a/Flixy/Views/Search/SearchView.swift b/Flixy/Views/Search/SearchView.swift index ba7a16c..6c762f5 100644 --- a/Flixy/Views/Search/SearchView.swift +++ b/Flixy/Views/Search/SearchView.swift @@ -113,9 +113,13 @@ struct SearchView: View { do { hubs = try await backend.search(trimmed, limit: 30) if !hubs.isEmpty { remember(trimmed) } - } catch is CancellationError { - return } catch { + // Abgebrochen, weil weitergetippt oder der Bildschirm verlassen + // wurde: `Task.isCancelled` statt `catch is CancellationError` — + // eine abgebrochene Netzanfrage kommt als Übertragungsfehler + // zurück, nicht als Abbruch, und die alte Prüfung lief deshalb + // ins Leere. + if Task.isCancelled { return } hubs = [] } } diff --git a/Flixy/Views/Settings/SettingsView.swift b/Flixy/Views/Settings/SettingsView.swift index c697432..254c356 100644 --- a/Flixy/Views/Settings/SettingsView.swift +++ b/Flixy/Views/Settings/SettingsView.swift @@ -2,6 +2,7 @@ import SwiftUI import FlixyDesign import FlixyDownloads import FlixyPlayback +import MediaKit import PlexKit /// Die Einstellungen. @@ -18,18 +19,29 @@ struct SettingsView: View { var body: some View { Form { - if let server = session.server { + if session.backend != nil { Section("settings.server") { - LabeledContent("settings.serverName", value: server.name) - LabeledContent("settings.connection", value: connectionLabel(server.connection)) - LabeledContent("settings.address", value: server.connection.uri) - .font(.flixyCaption) - .textSelection(.enabled) + LabeledContent("settings.serverName", value: session.serverName) + // Welche Art von Server — bei zwei möglichen ist das keine + // Nebensache mehr, sondern die erste Frage bei jeder + // Rückmeldung. + LabeledContent("settings.serverKind", value: kindLabel) + if let connection = session.server?.connection { + LabeledContent("settings.connection", value: connectionLabel(connection)) + } + if let address = session.serverAddress { + LabeledContent("settings.address", value: address) + .font(.flixyCaption) + .textSelection(.enabled) + } + if let accountName = session.accountName { + LabeledContent("settings.account", value: accountName) + } } Section("settings.libraries") { ForEach(session.libraries) { library in - LabeledContent(library.title, value: library.type.capitalized) + LabeledContent(library.title, value: library.kind.label) } } } @@ -176,6 +188,15 @@ struct SettingsView: View { /// Zeigt, über welchen Weg die Verbindung läuft — beim Suchen nach /// Ruckeln ist das die erste Frage. + /// Welche Serverart gerade bedient wird. + private var kindLabel: String { + switch session.kind { + case .plex: "Plex" + case .jellyfin: "Jellyfin" + case nil: "—" + } + } + private func connectionLabel(_ connection: PlexConnection) -> String { if connection.local { return String(localized: "settings.connectionLocal") } if connection.relay { return String(localized: "settings.connectionRelay") } @@ -194,3 +215,21 @@ extension PlaybackSettings.Quality { } } } + +extension MediaLibrary.Kind { + /// Wie diese Art von Mediathek heißt. + /// + /// Übersetzt statt durchgereicht: die Server liefern „movies" und + /// „tvshows", und das großgeschrieben anzuzeigen war eine englische + /// Beschriftung in einer deutschen Oberfläche. + var label: String { + switch self { + case .movie: String(localized: "library.kind.movie") + case .show: String(localized: "library.kind.show") + case .music: String(localized: "library.kind.music") + case .photo: String(localized: "library.kind.photo") + case .liveTV: String(localized: "library.kind.liveTV") + case .other: String(localized: "library.kind.other") + } + } +} diff --git a/Flixy/Views/SignIn/JellyfinSignInView.swift b/Flixy/Views/SignIn/JellyfinSignInView.swift new file mode 100644 index 0000000..2aaf012 --- /dev/null +++ b/Flixy/Views/SignIn/JellyfinSignInView.swift @@ -0,0 +1,266 @@ +import SwiftUI +import FlixyDesign +import JellyfinKit + +/// Die Anmeldung an einem Jellyfin-Server. +/// +/// Drei Bildschirme in einem, weil es drei Fragen sind und nicht mehr: wo +/// steht der Server, wie heißt du, und — wenn der Server es kann — bestätige +/// diesen Code. Der Weg über den Code steht vorn, weil dabei kein Passwort +/// durch diese App läuft. +struct JellyfinSignInView: View { + @Environment(FlixySession.self) private var session + @Environment(\.dismiss) private var dismiss + @Environment(\.flixyAccent) private var accent + + @State private var model: JellyfinSignInModel? + @FocusState private var focusedField: Field? + + private enum Field { case address, userName, password } + + var body: some View { + NavigationStack { + ScrollView { + if let model { + content(for: model) + .padding(.horizontal, FlixySpacing.section) + .padding(.vertical, FlixySpacing.section) + .frame(maxWidth: 420) + .frame(maxWidth: .infinity) + } + } + .scrollDismissesKeyboard(.interactively) + .background(Color.flixyCanvas) + .navigationTitle(Text("signIn.jellyfin.title")) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("action.cancel") { + model?.cancel() + dismiss() + } + } + } + } + .tint(accent) + .task { + // Erst hier, nicht im Eigenschaftswert: das Modell braucht die + // Gerätekennung der Sitzung. Baut es sich eine eigene, steht die + // App nach dem Anmelden ein zweites Mal in der Geräteliste. + guard model == nil else { return } + let fresh = JellyfinSignInModel(identity: session.jellyfinDeviceIdentity) + fresh.onAuthenticated = { client, account, serverName in + dismiss() + Task { await session.adoptJellyfin(client: client, account: account, serverName: serverName) } + } + model = fresh + } + .onDisappear { + // Wird das Blatt weggewischt, läuft sonst die Abfrageschleife + // noch minutenlang weiter — unsichtbar und für nichts. + model?.cancel() + } + } + + @ViewBuilder + private func content(for model: JellyfinSignInModel) -> some View { + @Bindable var model = model + + VStack(alignment: .leading, spacing: FlixySpacing.section) { + switch model.step { + case .address: + addressStep(model: model) + case .checking: + waiting(text: "signIn.jellyfin.searching") + case .quickConnect(let code): + quickConnectStep(model: model, code: code) + case .credentials: + credentialsStep(model: model) + case .signingIn: + waiting(text: "signIn.jellyfin.signingIn") + } + + if let message = model.errorMessage { + Label(message, systemImage: "exclamationmark.triangle") + .font(.flixyCaption) + .foregroundStyle(Color.flixyDestructive) + } + } + } + + // MARK: - Adresse + + @ViewBuilder + private func addressStep(model: JellyfinSignInModel) -> some View { + @Bindable var model = model + + VStack(alignment: .leading, spacing: FlixySpacing.regular) { + Text("signIn.jellyfin.addressPrompt") + .font(.flixyRowHeader) + .foregroundStyle(Color.flixyTextPrimary) + + TextField("signIn.jellyfin.addressPlaceholder", text: $model.address) + .textFieldStyle(.plain) + .textContentType(.URL) + .keyboardType(.URL) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .submitLabel(.go) + .focused($focusedField, equals: .address) + .onSubmit { Task { await model.findServer() } } + // Hier statt beim Öffnen des Blattes: so bekommt das Feld den + // Fokus auch dann, wenn man nach einem Vertipper zurückkommt. + .task { focusedField = .address } + .padding(FlixySpacing.regular) + .background(Color.flixyElevated, in: .rect(cornerRadius: FlixyRadius.control)) + .foregroundStyle(Color.flixyTextPrimary) + + // Ein Beispiel statt einer Erklärung: „Schema, Host, Port" sagt + // niemandem etwas, „192.168.1.50" jedem. + Text("signIn.jellyfin.addressHint") + .font(.flixyCaption) + .foregroundStyle(Color.flixyTextMuted) + + Button { + focusedField = nil + Task { await model.findServer() } + } label: { + Text("signIn.jellyfin.connect") + .font(.flixyRowHeader) + .frame(maxWidth: .infinity) + .padding(.vertical, FlixySpacing.regular) + } + .buttonStyle(.borderedProminent) + .tint(accent) + .disabled(model.address.trimmingCharacters(in: .whitespaces).isEmpty) + } + } + + // MARK: - Code + + @ViewBuilder + private func quickConnectStep(model: JellyfinSignInModel, code: String) -> some View { + VStack(alignment: .leading, spacing: FlixySpacing.regular) { + foundServerHeader(model: model) + + Text("signIn.jellyfin.quickConnectHint") + .font(.flixyBody) + .foregroundStyle(Color.flixyTextSecondary) + + Text(code) + .font(.system(.largeTitle, design: .monospaced, weight: .semibold)) + .tracking(8) + .foregroundStyle(Color.flixyTextPrimary) + .textSelection(.enabled) + .padding(.vertical, FlixySpacing.regular) + .frame(maxWidth: .infinity) + .background(Color.flixyElevated, in: .rect(cornerRadius: FlixyRadius.control)) + + HStack(spacing: FlixySpacing.snug) { + ProgressView().controlSize(.small) + Text("signIn.jellyfin.waitingForApproval") + .font(.flixyCaption) + .foregroundStyle(Color.flixyTextMuted) + } + + Text("signIn.jellyfin.privacy") + .font(.flixyCaption) + .foregroundStyle(Color.flixyTextMuted) + + Divider().overlay(Color.flixySeparator) + + Button("signIn.jellyfin.usePassword") { model.useCredentials() } + .font(.flixyCaption) + Button("signIn.jellyfin.otherAddress") { model.startOver() } + .font(.flixyCaption) + .foregroundStyle(Color.flixyTextSecondary) + } + } + + // MARK: - Benutzername und Passwort + + @ViewBuilder + private func credentialsStep(model: JellyfinSignInModel) -> some View { + @Bindable var model = model + + VStack(alignment: .leading, spacing: FlixySpacing.regular) { + foundServerHeader(model: model) + + TextField("signIn.jellyfin.userName", text: $model.userName) + .textContentType(.username) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .submitLabel(.next) + .focused($focusedField, equals: .userName) + .onSubmit { focusedField = .password } + .padding(FlixySpacing.regular) + .background(Color.flixyElevated, in: .rect(cornerRadius: FlixyRadius.control)) + + SecureField("signIn.jellyfin.password", text: $model.password) + .textContentType(.password) + .submitLabel(.go) + .focused($focusedField, equals: .password) + .onSubmit { Task { await model.signInWithPassword() } } + .padding(FlixySpacing.regular) + .background(Color.flixyElevated, in: .rect(cornerRadius: FlixyRadius.control)) + + Button { + focusedField = nil + Task { await model.signInWithPassword() } + } label: { + Text("signIn.jellyfin.submit") + .font(.flixyRowHeader) + .frame(maxWidth: .infinity) + .padding(.vertical, FlixySpacing.regular) + } + .buttonStyle(.borderedProminent) + .tint(accent) + .disabled(model.userName.isEmpty) + + // Das Passwort wird für die eine Anfrage gehalten und danach + // verworfen. Gespeichert wird nur der Token, und der im + // Schlüsselbund. + Text("signIn.jellyfin.passwordNote") + .font(.flixyCaption) + .foregroundStyle(Color.flixyTextMuted) + + Divider().overlay(Color.flixySeparator) + + if model.offersQuickConnect { + Button("signIn.jellyfin.useCode") { Task { await model.beginQuickConnect() } } + .font(.flixyCaption) + } + Button("signIn.jellyfin.otherAddress") { model.startOver() } + .font(.flixyCaption) + .foregroundStyle(Color.flixyTextSecondary) + } + .foregroundStyle(Color.flixyTextPrimary) + } + + // MARK: - Bausteine + + @ViewBuilder + private func foundServerHeader(model: JellyfinSignInModel) -> some View { + VStack(alignment: .leading, spacing: FlixySpacing.tight) { + Text("signIn.jellyfin.foundServer") + .font(.flixyCaption) + .foregroundStyle(Color.flixyTextMuted) + .textCase(.uppercase) + Text(model.serverName ?? model.address) + .font(.flixyTitle) + .foregroundStyle(Color.flixyTextPrimary) + } + } + + @ViewBuilder + private func waiting(text: LocalizedStringKey) -> some View { + HStack(spacing: FlixySpacing.snug) { + ProgressView().controlSize(.small) + Text(text) + .font(.flixyBody) + .foregroundStyle(Color.flixyTextSecondary) + } + .frame(maxWidth: .infinity, alignment: .center) + .padding(.vertical, FlixySpacing.section) + } +} diff --git a/Flixy/Views/SignIn/SignInView.swift b/Flixy/Views/SignIn/SignInView.swift index 84744b2..e48b3b8 100644 --- a/Flixy/Views/SignIn/SignInView.swift +++ b/Flixy/Views/SignIn/SignInView.swift @@ -1,20 +1,26 @@ import SwiftUI import FlixyDesign -import PlexKit /// Der Anmeldebildschirm. /// -/// Ein Knopf, mehr nicht. Die Anmeldemaske kommt von Plex und öffnet sich in -/// einem Fenster innerhalb der App; das Passwort wird dort eingegeben und -/// erreicht Flixy nie. Nach dem Anmelden schließt sich das Fenster von selbst. +/// Zwei Wege, weil es zwei Serverarten gibt. **Plex** öffnet seine eigene +/// Anmeldemaske in einem Fenster innerhalb der App; das Passwort wird dort +/// eingegeben und erreicht Flixy nie. **Jellyfin** hat kein Verzeichnis wie +/// plex.tv — dort wird die Adresse des eigenen Servers eingetippt und, wann +/// immer der Server es anbietet, ein Code bestätigt statt eines Passworts. +/// +/// Die beiden Wege sind absichtlich nicht zu einem zusammengefasst. Sie haben +/// nichts gemeinsam außer dem Ziel, und der Versuch, das zu verstecken, würde +/// beide verschlechtern. struct SignInView: View { let phase: FlixySession.Phase - /// Der Code der laufenden Anmeldung, sobald er gezeigt werden soll. + /// Der Code der laufenden Plex-Anmeldung, sobald er gezeigt werden soll. let manualCode: String? let onSignIn: () -> Void let onCancel: () -> Void @Environment(\.flixyAccent) private var accent + @State private var showsJellyfinSignIn = false private var isAuthenticating: Bool { if case .authenticating = phase { return true } @@ -48,7 +54,7 @@ struct SignInView: View { if isAuthenticating { ProgressView().tint(Color.flixyCanvas) } else { - Text("signIn.action").font(.flixyRowHeader) + Text("signIn.withPlex").font(.flixyRowHeader) } } .frame(maxWidth: .infinity) @@ -58,6 +64,18 @@ struct SignInView: View { .tint(accent) .disabled(isAuthenticating) + Button { + showsJellyfinSignIn = true + } label: { + Text("signIn.withJellyfin") + .font(.flixyRowHeader) + .frame(maxWidth: .infinity) + .padding(.vertical, FlixySpacing.regular) + } + .buttonStyle(.bordered) + .tint(Color.flixyTextSecondary) + .disabled(isAuthenticating) + if let manualCode { manualFallback(code: manualCode) } else { @@ -74,6 +92,9 @@ struct SignInView: View { .frame(maxWidth: 420) .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color.flixyCanvas) + .sheet(isPresented: $showsJellyfinSignIn) { + JellyfinSignInView() + } } /// Der Ausweg, wenn die Browser-Seite den Code nicht verknüpft. diff --git a/Packages/FlixyKit/Package.swift b/Packages/FlixyKit/Package.swift index c86b444..2a65509 100644 --- a/Packages/FlixyKit/Package.swift +++ b/Packages/FlixyKit/Package.swift @@ -43,7 +43,7 @@ let package = Package( // Ein Werkzeug für die Fragen, die reines HTTP sind — ohne Oberfläche, // ohne Gerät, ohne Upload. Eine Runde dauert damit Sekunden statt // eines halben Tages, und das Upload-Kontingent bleibt für Builds. - .executableTarget(name: "FlixyProbe", dependencies: ["PlexKit"]), + .executableTarget(name: "FlixyProbe", dependencies: ["MediaKit", "PlexKit"]), .testTarget( name: "PlexKitTests", dependencies: ["PlexKit"], diff --git a/Packages/FlixyKit/Sources/FlixyProbe/main.swift b/Packages/FlixyKit/Sources/FlixyProbe/main.swift index c03d8d8..642dcd9 100644 --- a/Packages/FlixyKit/Sources/FlixyProbe/main.swift +++ b/Packages/FlixyKit/Sources/FlixyProbe/main.swift @@ -1,4 +1,5 @@ import Foundation +import MediaKit import PlexKit // Ein Werkzeug für die Fragen, die reines HTTP sind. diff --git a/Packages/FlixyKit/Sources/JellyfinKit/JellyfinAddress.swift b/Packages/FlixyKit/Sources/JellyfinKit/JellyfinAddress.swift new file mode 100644 index 0000000..8ba5939 --- /dev/null +++ b/Packages/FlixyKit/Sources/JellyfinKit/JellyfinAddress.swift @@ -0,0 +1,65 @@ +import Foundation + +/// Macht aus dem, was jemand eintippt, eine Adresse, die antwortet. +/// +/// Jellyfin hat kein Verzeichnis wie plex.tv — die Adresse kommt vom Nutzer. +/// Und die tippt niemand vollständig: „192.168.1.50" ohne Schema, ohne Port, +/// oder „medien.example.com" ohne „https://". Dieser Typ rät nicht **eine** +/// Ergänzung, sondern nennt die plausiblen in der Reihenfolge, in der sie es +/// wahrscheinlich sind; der Aufrufer probiert sie durch und nimmt die erste, +/// die sich als Jellyfin-Server ausweist. +/// +/// Die Reihenfolge ist der ganze Inhalt dieser Datei, und sie ist nicht +/// beliebig: eine IP-Adresse im eigenen Netz spricht fast nie HTTPS, ein +/// Domänenname fast immer. +public enum JellyfinAddress { + /// Der Standardport eines Jellyfin-Servers. + public static let defaultPort = 8096 + + public static func candidates(for input: String) -> [URL] { + var text = input.trimmingCharacters(in: .whitespacesAndNewlines) + while text.hasSuffix("/") { text.removeLast() } + guard !text.isEmpty else { return [] } + + // Wer ein Schema hinschreibt, meint es auch so. Daran wird nichts + // ergänzt — sonst landet eine Anfrage unverschlüsselt, obwohl jemand + // ausdrücklich „https" getippt hat. + if text.contains("://") { + return URL(string: text).map { [$0] } ?? [] + } + + // IPv6 in eckigen Klammern: hier wird nicht geraten, weil die + // Doppelpunkte sonst als Port gelesen würden. + if text.hasPrefix("[") { + return ["http://\(text)", "https://\(text)"].compactMap(URL.init(string:)) + } + + let authority = String(text.split(separator: "/", maxSplits: 1).first ?? "") + let host = String(authority.split(separator: ":").first ?? "") + let hasExplicitPort = authority.contains(":") + && Int(authority.split(separator: ":").last ?? "") != nil + + if hasExplicitPort { + // Port genannt, Schema nicht: erst unverschlüsselt, weil ein + // eigener Port fast immer ein Server ohne Zertifikat ist. + return ["http://\(text)", "https://\(text)"].compactMap(URL.init(string:)) + } + + // Eine reine Zahlenadresse, ein Bonjour-Name oder ein Wort ohne Punkt + // — alles Dinge, die es nur im eigenen Netz gibt. + let isLocalLooking = host.allSatisfy { $0.isNumber || $0 == "." } + || host.hasSuffix(".local") + || !host.contains(".") + + let forms = isLocalLooking + ? ["http://\(withDefaultPort(text))", "http://\(text)", "https://\(text)"] + : ["https://\(text)", "http://\(text)", "http://\(withDefaultPort(text))"] + return forms.compactMap(URL.init(string:)) + } + + /// Hängt den Standardport an — vor einen etwaigen Pfad, nicht dahinter. + private static func withDefaultPort(_ text: String) -> String { + guard let slash = text.firstIndex(of: "/") else { return "\(text):\(defaultPort)" } + return "\(text[text.startIndex.. JellyfinIdentity { + let deviceID: String + if let stored = defaults.string(forKey: storageKey) { + deviceID = stored + } else { + deviceID = UUID().uuidString + defaults.set(deviceID, forKey: storageKey) + } + + #if canImport(UIKit) + let device = UIDevice.current.name + #else + let device = ProcessInfo.processInfo.hostName + #endif + + return JellyfinIdentity( + client: client, + device: device, + deviceID: deviceID, + version: version + ) + } +} diff --git a/Packages/FlixyKit/Sources/JellyfinKit/JellyfinItem+Media.swift b/Packages/FlixyKit/Sources/JellyfinKit/JellyfinItem+Media.swift index e810ed8..fa5756b 100644 --- a/Packages/FlixyKit/Sources/JellyfinKit/JellyfinItem+Media.swift +++ b/Packages/FlixyKit/Sources/JellyfinKit/JellyfinItem+Media.swift @@ -21,11 +21,16 @@ extension JellyfinItem { 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, + // Nur wenn es auch eines gibt: die Adresse entsteht aus der + // Kennung, nicht aus einer Antwort des Servers — ohne diese + // Prüfung stünde hinter jedem Titel ohne Hintergrundbild eine + // Adresse, die mit „nicht gefunden" antwortet. Fehlt dem Titel + // eines, nennt Jellyfin die Serie, von der es geliehen werden darf. + art: backdropImageTags?.first.map { imageBase.backdrop(itemID: id, tag: $0) } + ?? parentBackdropItemID.map { imageBase.backdrop(itemID: $0, tag: nil) }, + parentThumb: seasonID.map { imageBase.primary(itemID: $0, tag: nil) }, + seriesThumb: seriesID.map { imageBase.primary(itemID: $0, tag: seriesPrimaryImageTag) }, + seriesArt: seriesID.map { imageBase.backdrop(itemID: $0, tag: nil) }, index: indexNumber, parentIndex: parentIndexNumber, parentTitle: seasonName, diff --git a/Packages/FlixyKit/Sources/PlexKit/Auth/TokenStore.swift b/Packages/FlixyKit/Sources/MediaKit/TokenStore.swift similarity index 90% rename from Packages/FlixyKit/Sources/PlexKit/Auth/TokenStore.swift rename to Packages/FlixyKit/Sources/MediaKit/TokenStore.swift index 3908891..43ace02 100644 --- a/Packages/FlixyKit/Sources/PlexKit/Auth/TokenStore.swift +++ b/Packages/FlixyKit/Sources/MediaKit/TokenStore.swift @@ -3,10 +3,14 @@ import Security /// Legt Tokens im Schlüsselbund ab. /// -/// Nicht in `UserDefaults`: ein Plex-Token gibt vollen Zugriff auf die -/// Mediathek und bleibt gültig, bis er am Konto zurückgezogen wird. In den +/// Nicht in `UserDefaults`: ein Server-Token gibt vollen Zugriff auf die +/// Mediathek und bleibt gültig, bis er am Server zurückgezogen wird. In den /// Voreinstellungen läge er als Klartext in einer Datei, die jedes Backup /// mitnimmt. +/// +/// Der Kontoname trennt die Server: `plex.server` und `jellyfin.server` +/// liegen nebeneinander. Wer zwischen beiden wechselt, soll beim +/// Zurückwechseln nicht neu anmelden müssen. public protocol TokenStoring: Sendable { func store(_ token: String, for account: String) throws func token(for account: String) -> String? diff --git a/Packages/FlixyKit/Tests/JellyfinKitTests/AddressTests.swift b/Packages/FlixyKit/Tests/JellyfinKitTests/AddressTests.swift new file mode 100644 index 0000000..f2ee804 --- /dev/null +++ b/Packages/FlixyKit/Tests/JellyfinKitTests/AddressTests.swift @@ -0,0 +1,67 @@ +import Foundation +import Testing +@testable import JellyfinKit + +/// Was aus dem wird, was jemand in das Adressfeld tippt. +/// +/// Die Reihenfolge ist der Prüfgegenstand, nicht die bloße Menge: probiert +/// die App bei einer IP-Adresse zuerst HTTPS, wartet man auf jedem Server +/// ohne Zertifikat erst einmal auf eine Zeitüberschreitung. +@Suite("Adressen ergänzen") +struct AddressTests { + + @Test("Ein getipptes Schema wird nicht angetastet") + func keepsExplicitScheme() { + let candidates = JellyfinAddress.candidates(for: "https://medien.example.com/jellyfin") + + #expect(candidates.map(\.absoluteString) == ["https://medien.example.com/jellyfin"]) + } + + @Test("Nachlaufende Schrägstriche fallen weg") + func trimsTrailingSlash() { + let candidates = JellyfinAddress.candidates(for: " https://example.com/ ") + + #expect(candidates.map(\.absoluteString) == ["https://example.com"]) + } + + @Test("Bei einer IP-Adresse zuerst der Standardport, unverschlüsselt") + func prefersPlainHTTPForAddresses() { + let candidates = JellyfinAddress.candidates(for: "192.168.1.50") + + #expect(candidates.map(\.absoluteString) == [ + "http://192.168.1.50:8096", + "http://192.168.1.50", + "https://192.168.1.50", + ]) + } + + @Test("Bei einem Domänennamen zuerst HTTPS") + func prefersHTTPSForDomains() { + let candidates = JellyfinAddress.candidates(for: "medien.example.com") + + #expect(candidates.first?.absoluteString == "https://medien.example.com") + #expect(candidates.map(\.absoluteString).contains("http://medien.example.com:8096")) + } + + @Test("Ein genannter Port wird nicht überschrieben") + func keepsExplicitPort() { + let candidates = JellyfinAddress.candidates(for: "medien.example.com:8920") + + #expect(candidates.map(\.absoluteString) == [ + "http://medien.example.com:8920", + "https://medien.example.com:8920", + ]) + } + + @Test("Der Standardport landet vor dem Pfad, nicht dahinter") + func insertsPortBeforePath() { + let candidates = JellyfinAddress.candidates(for: "server.local/jellyfin") + + #expect(candidates.first?.absoluteString == "http://server.local:8096/jellyfin") + } + + @Test("Leere Eingabe ergibt keine Adresse") + func rejectsEmptyInput() { + #expect(JellyfinAddress.candidates(for: " ").isEmpty) + } +} diff --git a/Packages/FlixyKit/Tests/JellyfinKitTests/TranslationTests.swift b/Packages/FlixyKit/Tests/JellyfinKitTests/TranslationTests.swift index 61cd272..25d3c75 100644 --- a/Packages/FlixyKit/Tests/JellyfinKitTests/TranslationTests.swift +++ b/Packages/FlixyKit/Tests/JellyfinKitTests/TranslationTests.swift @@ -128,5 +128,41 @@ struct TranslationTests { #expect(episode.thumb == "/Items/a1b2c3d4e5f6/Images/Primary?tag=tag-primary") #expect(episode.clearLogoPath == "/Items/a1b2c3d4e5f6/Images/Logo?tag=tag-logo") #expect(episode.seriesThumb == "/Items/series-1/Images/Primary?tag=tag-series") + #expect(episode.art == "/Items/a1b2c3d4e5f6/Images/Backdrop?tag=tag-backdrop") + } + + @Test("Ohne eigenes Hintergrundbild kommt es von der Serie") + func borrowsBackdropFromParent() throws { + let item = try decode(""" + {"Id": "ohne-bild", "Name": "Folge ohne Bild", "Type": "Episode", + "ParentBackdropItemId": "series-1"} + """) + + // Die Bildadressen entstehen aus der Kennung, nicht aus einer Antwort + // des Servers. Ohne diese Unterscheidung stünde hinter jeder Folge + // eine Adresse, die mit „nicht gefunden" antwortet — und das Feld, das + // Jellyfin dafür mitschickt, wäre totes Gewicht. + #expect(item.art == "/Items/series-1/Images/Backdrop") + } + + @Test("Gibt es weder eigenes noch geliehenes Bild, bleibt das Feld leer") + func leavesBackdropEmpty() throws { + let item = try decode(""" + {"Id": "ganz-ohne", "Name": "Nichts da", "Type": "Movie"} + """) + + #expect(item.art == nil) + } + + /// Ein einzelner Eintrag, wie er über die Leitung käme. + /// + /// Für die Fälle, die in den aufgezeichneten Antworten nicht vorkommen — + /// eine Folge ohne eigenes Bild etwa. Eine weitere Datei dafür anzulegen + /// verstellt den Blick darauf, worum es im Test geht. + private func decode(_ json: String) throws -> MediaItem { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let item = try decoder.decode(JellyfinItem.self, from: Data(json.utf8)) + return item.asMediaItem(imageBase: JellyfinImagePaths()) } } diff --git a/project.yml b/project.yml index f462a4b..9b8fb2b 100644 --- a/project.yml +++ b/project.yml @@ -47,6 +47,10 @@ targets: product: MediaKit - package: FlixyKit product: PlexKit + # Der zweite Server. Steht neben PlexKit, nicht darin: die beiden teilen + # die neutrale Schicht und sonst nichts. + - package: FlixyKit + product: JellyfinKit - package: FlixyKit product: FlixyDesign - package: FlixyKit