diff --git a/Flaro/FlaroApp.swift b/Flaro/FlaroApp.swift index fca3768..547c11e 100644 --- a/Flaro/FlaroApp.swift +++ b/Flaro/FlaroApp.swift @@ -6,6 +6,7 @@ import UIKit import FlaroDesign import FlaroDownloads import FlaroPlayback +import FlaroServerKit import JellyfinKit import MediaKit @@ -45,6 +46,7 @@ struct FlaroApp: App { client: Branding.appName, version: Branding.version ), + flaroIdentity: FlaroIdentity.current(), tokenStore: KeychainTokenStore(service: Branding.keychainService) )) diff --git a/Flaro/Resources/Localizable.xcstrings b/Flaro/Resources/Localizable.xcstrings index e2d6299..99e4210 100644 --- a/Flaro/Resources/Localizable.xcstrings +++ b/Flaro/Resources/Localizable.xcstrings @@ -108,6 +108,23 @@ } } }, + "action.back": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Zurück" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Back" + } + } + } + }, "action.cancel": { "extractionState": "stale", "localizations": { @@ -3854,23 +3871,6 @@ } } }, - "player.jellyfinPending": { - "extractionState": "stale", - "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": { @@ -6254,6 +6254,312 @@ } } }, + "signIn.flaro.addressHint": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Schema und Port darfst du weglassen — Flaro probiert die üblichen durch." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "You can leave out the scheme and port — Flaro tries the usual ones." + } + } + } + }, + "signIn.flaro.addressMissing": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Trag eine Adresse ein." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Enter an address." + } + } + } + }, + "signIn.flaro.addressPlaceholder": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "z. B. 192.168.1.50 oder medien.example.com" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "e.g. 192.168.1.50 or media.example.com" + } + } + } + }, + "signIn.flaro.addressPrompt": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Wo steht dein Server?" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Where is your server?" + } + } + } + }, + "signIn.flaro.changeAddress": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Andere Adresse" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Change address" + } + } + } + }, + "signIn.flaro.codeExpired": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Der Code ist abgelaufen. Fordere einen neuen an." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "The code has expired. Request a new one." + } + } + } + }, + "signIn.flaro.codeHint": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Öffne Flaro auf deinem Telefon und gib diesen Code ein." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Open Flaro on your phone and enter this code." + } + } + } + }, + "signIn.flaro.credentialsMissing": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Benutzername und Passwort fehlen." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Username and password are missing." + } + } + } + }, + "signIn.flaro.findServer": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Server suchen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Find server" + } + } + } + }, + "signIn.flaro.needsSetup": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Dieser Server ist noch nicht eingerichtet. Öffne ihn im Browser und lege ein Konto an." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "This server isn't set up yet. Open it in a browser and create an account." + } + } + } + }, + "signIn.flaro.notFound": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Unter dieser Adresse antwortet kein Flaro-Server." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "No Flaro server answers at this address." + } + } + } + }, + "signIn.flaro.searching": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Server wird gesucht …" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Looking for the server…" + } + } + } + }, + "signIn.flaro.signIn": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Anmelden" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Sign in" + } + } + } + }, + "signIn.flaro.signingIn": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Wird angemeldet …" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Signing in…" + } + } + } + }, + "signIn.flaro.title": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Flaro-Server" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Flaro Server" + } + } + } + }, + "signIn.flaro.useCode": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Stattdessen Code" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Use a code instead" + } + } + } + }, + "signIn.flaro.usePassword": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Stattdessen Passwort" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Use a password instead" + } + } + } + }, + "signIn.flaro.waitingForApproval": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Warte auf Bestätigung" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Waiting for approval" + } + } + } + }, "signIn.jellyfin.addressHint": { "localizations": { "de": { @@ -6403,23 +6709,6 @@ } } }, - "signIn.jellyfin.password": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Passwort" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Password" - } - } - } - }, "signIn.jellyfin.passwordNote": { "localizations": { "de": { @@ -6568,23 +6857,6 @@ } } }, - "signIn.jellyfin.userName": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Benutzername" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Username" - } - } - } - }, "signIn.jellyfin.waitingForApproval": { "localizations": { "de": { @@ -6601,6 +6873,23 @@ } } }, + "signIn.password": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Passwort" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Password" + } + } + } + }, "signIn.privacy": { "localizations": { "de": { @@ -6634,6 +6923,40 @@ } } }, + "signIn.userName": { + "extractionState": "stale", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Benutzername" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Username" + } + } + } + }, + "signIn.withFlaro": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Mit Flaro verbinden" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Connect to Flaro" + } + } + } + }, "signIn.withJellyfin": { "localizations": { "de": { diff --git a/Flaro/Session/DownloadService.swift b/Flaro/Session/DownloadService.swift index 84075be..5270cdf 100644 --- a/Flaro/Session/DownloadService.swift +++ b/Flaro/Session/DownloadService.swift @@ -122,7 +122,8 @@ struct DownloadService { // hinterher niemanden mehr, der das nachholen könnte — und seit es // keine zweite Engine mehr gibt, wäre die Datei schlicht stumm und // schwarz. Also wird schon beim Herunterladen umgepackt. - let umpacken = quality == .original && !systemPlayerOpens(media: media, part: part) + let umpacken = quality == .original && !kannAlleSpuren + && !systemPlayerOpens(media: media, part: part) let url: URL? switch quality { @@ -200,6 +201,17 @@ struct DownloadService { ) } + /// Ob der Server beim Herunterladen alle Spuren mitgibt. + /// + /// **Der Unterschied, um dessentwillen Flaro gebaut wurde.** Flaros + /// Download liefert eine MP4 mit jeder Ton- und Textspur, auch wenn dafür + /// umgepackt werden muss. Dann entfällt die Spurwahl, und das Umpacken + /// mit genau einer Spur entfällt gleich mit — es gäbe nichts zu + /// verbessern, nur etwas zu verlieren. + private var kannAlleSpuren: Bool { + backend?.capabilities.contains(.allTracksDownload) == true + } + /// Ob beim Herunterladen eine Tonspur verlorengeht — und deshalb gefragt /// werden muss. /// @@ -211,6 +223,12 @@ struct DownloadService { /// falsche Weg. func needsAudioChoice(for item: MediaItem, mediaIndex: Int = 0, quality: DownloadQuality) -> Bool { guard quality == .original else { return false } + // **Kann der Server alles mitgeben, gibt es nichts zu entscheiden.** + // Die Frage ist nur deshalb da, weil Jellyfins Transcoder eine + // Tonspur je Sitzung abbildet. Wo das nicht gilt, wäre sie eine + // Entscheidung, die der Nutzer treffen soll, obwohl beide Antworten + // zum selben Ergebnis führen. + guard !kannAlleSpuren else { return false } guard let media = mediaIndex < item.versions.count ? item.versions[mediaIndex] : item.preferredVersion, let part = media.files.first diff --git a/Flaro/Session/FlaroSession.swift b/Flaro/Session/FlaroSession.swift index f062875..0946fb9 100644 --- a/Flaro/Session/FlaroSession.swift +++ b/Flaro/Session/FlaroSession.swift @@ -1,4 +1,5 @@ import Foundation +import FlaroServerKit import JellyfinKit import MediaKit import Observation @@ -31,12 +32,41 @@ final class FlaroSession { // stillschweigend als „ungleich" behandelt. } + /// Mit welcher Art Server gesprochen wird. + /// + /// **Diese Unterscheidung gab es schon einmal** — damals für Plex und + /// Jellyfin — und sie wurde beim Entfernen von Plex gestrichen, weil ein + /// Aufzählungstyp mit einem Fall ein umständliches Bool ist. Jetzt gibt + /// es wieder zwei, und es ist wieder eine Unterscheidung. + /// + /// **Einer zur Zeit, nicht beide.** Zwei gleichzeitig hiessen: zwei + /// Startseiten, zwei Mediatheken, zwei Suchergebnisse — und bei jedem + /// Titel die Frage, woher er stammt. + enum ServerKind: String, CaseIterable, Sendable, Identifiable { + case jellyfin, flaro + var id: String { rawValue } + } + private enum Key { + /// Welche Art zuletzt angemeldet war. + /// + /// Nicht `flaro.backend.kind`: so hiess der Schlüssel zu Plex-Zeiten, + /// und der steht in der Aufräumliste weiter unten. Denselben Namen + /// mit anderer Bedeutung zu benutzen wäre eine Falle für den + /// Nächsten, der die Liste liest. + static let activeKind = "flaro.backend.active" + static let jellyfinAddress = "flaro.jellyfin.address" static let jellyfinUserID = "flaro.jellyfin.userID" static let jellyfinUserName = "flaro.jellyfin.userName" static let jellyfinServerName = "flaro.jellyfin.serverName" static let jellyfinIsAdministrator = "flaro.jellyfin.isAdministrator" + + static let flaroAddress = "flaro.flaroServer.address" + static let flaroUserID = "flaro.flaroServer.userID" + static let flaroUserName = "flaro.flaroServer.userName" + static let flaroServerName = "flaro.flaroServer.serverName" + static let flaroIsAdministrator = "flaro.flaroServer.isAdministrator" } /// Unter welchem Namen das Server-Token im Schlüsselbund liegt. @@ -45,6 +75,7 @@ final class FlaroSession { /// Mediathek. Der Name trägt die Serverart, damit ein zweiter Server /// später daneben passt, ohne diesen zu verdrängen. private static let jellyfinTokenAccount = "jellyfin.server" + private static let flaroTokenAccount = "flaro.server" /// Was die Plex-Zeit auf dem Gerät hinterlassen hat. /// @@ -90,8 +121,14 @@ final class FlaroSession { /// Das angemeldete Jellyfin-Konto, solange Jellyfin aktiv ist. private(set) var jellyfinAccount: JellyfinAccount? + /// Das angemeldete Flaro-Konto, solange Flaro aktiv ist. + private(set) var flaroAccount: FlaroAccount? + + /// Mit welcher Art Server gerade gesprochen wird. + private(set) var activeKind: ServerKind? + /// Wer angemeldet ist — sofern der Server einen Namen dafür kennt. - var accountName: String? { jellyfinAccount?.userName } + var accountName: String? { jellyfinAccount?.userName ?? flaroAccount?.userName } /// Die Mediatheken, die Flaro darstellen kann — Musik und Fotos /// erscheinen gar nicht erst. @@ -101,7 +138,12 @@ final class FlaroSession { var serverName: String { backend?.serverName ?? "" } /// Unter welcher Adresse er erreicht wird. - var serverAddress: String? { defaults.string(forKey: Key.jellyfinAddress) } + var serverAddress: String? { + switch activeKind { + case .flaro: defaults.string(forKey: Key.flaroAddress) + case .jellyfin, nil: defaults.string(forKey: Key.jellyfinAddress) + } + } /// Wie die Verbindung zum Server zu beschreiben ist. /// @@ -136,30 +178,28 @@ final class FlaroSession { /// Warum gerade nicht abgespielt werden kann. /// - /// Zwei verschiedene Lagen, und sie dürfen nicht verwechselt werden: ist - /// ein Server da, ist die Anmeldung fertig und nur die Wiedergabe noch - /// nicht — „nicht erreichbar" wäre dann gelogen. Ist **keiner** da, ist - /// niemand angemeldet, und „Wiedergabe steht aus" wäre es. - /// - /// Die Unterscheidung hing früher an der Serverart, und deren `nil`-Fall - /// lief stillschweigend im selben Zweig mit. Jetzt steht sie da, wo sie - /// hingehört: an der Frage, ob überhaupt ein Server da ist. + /// **Nur noch ein Fall, und das ist eine Bereinigung.** Hier stand eine + /// Fallunterscheidung aus der Plex-Zeit: war Jellyfin aktiv, hiess es + /// „die Wiedergabe von Jellyfin ist noch nicht fertig". Sie ist längst + /// fertig — und der Satz war ohnehin unerreichbar, denn der Player zeigt + /// diese Meldung ausschliesslich dann, wenn gar kein Server da ist. var playbackUnavailableMessage: String { - backend != nil - ? String(localized: "player.jellyfinPending") - : String(localized: "error.serverUnreachable") + String(localized: "error.serverUnreachable") } private let jellyfinIdentity: JellyfinIdentity + private let flaroIdentity: FlaroIdentity private let tokenStore: any TokenStoring private let defaults: UserDefaults init( jellyfinIdentity: JellyfinIdentity, + flaroIdentity: FlaroIdentity, tokenStore: any TokenStoring, defaults: UserDefaults = .standard ) { self.jellyfinIdentity = jellyfinIdentity + self.flaroIdentity = flaroIdentity self.tokenStore = tokenStore self.defaults = defaults } @@ -174,7 +214,13 @@ final class FlaroSession { if await adoptStubServerIfRequested() { return } #endif - if await restoreJellyfin() { return } + // **Die gespeicherte Art zuerst.** Wer ohne sie startet, war vor + // dieser Fassung angemeldet — damals gab es nur Jellyfin, und genau + // das ist dann gemeint. + switch defaults.string(forKey: Key.activeKind).flatMap(ServerKind.init(rawValue:)) { + case .flaro: if await restoreFlaro() { return } + case .jellyfin, nil: if await restoreJellyfin() { return } + } phase = .signedOut } @@ -212,7 +258,16 @@ final class FlaroSession { /// Der Knopf heißt „Abmelden" und nicht „diesen Server verlassen": nach /// dem Abmelden soll beim nächsten Start keine Mediathek mehr dastehen. func signOut() { + // **Dem Flaro-Server sagen, dass dieses Gerät geht.** Jellyfin + // erfährt es über das Verfallen des Tokens; Flaro zieht es auf + // Zuruf zurück, und ein nicht zurückgezogenes Token stünde sonst + // bis zum Ablauf in der Geräteliste. + if let client = flaroClient { + Task { await FlaroSignIn.signOut(client: client) } + } forgetJellyfin() + forgetFlaro() + defaults.removeObject(forKey: Key.activeKind) clearActiveServer() // Titel, Zusammenfassungen und Sehfortschritt sind persönlich. Was // einem abgemeldeten Konto gehörte, hat auf der Platte nichts mehr @@ -276,14 +331,14 @@ final class FlaroSession { let backend = JellyfinBackend(client: client, account: account, serverName: serverName) clearActiveServer() jellyfinAccount = account + activeKind = .jellyfin self.backend = backend isLocalConnection = await backend.isInLocalNetwork() do { libraries = try await loadLibraries(from: backend) } catch { - self.backend = nil - jellyfinAccount = nil + clearActiveServer() phase = .failed(message: ErrorText.describe(error)) return } @@ -320,6 +375,7 @@ final class FlaroSession { clearActiveServer() jellyfinAccount = account + activeKind = .jellyfin self.backend = backend // Auch hier fragen: dasselbe Konto kann heute zuhause und morgen im // Zug wiederhergestellt werden, und die Antwort gilt je Sitzung. @@ -341,7 +397,99 @@ final class FlaroSession { } } + // MARK: - Verbinden mit Flaro + + /// Übernimmt eine fertige Flaro-Anmeldung. + /// + /// Dieselbe Form wie bei Jellyfin, und das ist der Beweis für die Naht: + /// was danach kommt — Mediatheken laden, Phase setzen, merken — steht + /// zweimal genau gleich da, weil `MediaBackend` dazwischen liegt. + func adoptFlaro(client: FlaroClient, account: FlaroAccount) async { + phase = .connecting(serverName: account.serverName) + + let backend = FlaroBackend(client: client, account: account) + clearActiveServer() + flaroAccount = account + flaroClient = client + activeKind = .flaro + self.backend = backend + // **Flaro fragt danach nicht.** Jellyfin meldet, ob die Verbindung + // im selben Netz liegt; Flaro hat dafür keinen Endpunkt und rechnet + // auch nichts daran fest. Im Zweifel vorsichtig: lieber einmal zu + // stark gedeckelt als eine Wiedergabe, die unterwegs nicht anläuft. + isLocalConnection = false + + do { + libraries = try await loadLibraries(from: backend) + } catch { + clearActiveServer() + phase = .failed(message: ErrorText.describe(error)) + return + } + + rememberFlaro(account: account, token: nil) + phase = .ready + } + + /// Dasselbe mit frischem Token — nach einer Anmeldung mit Passwort. + func adoptFlaro(client: FlaroClient, account: FlaroAccount, token: String) async { + await adoptFlaro(client: client, account: account) + guard phase == .ready else { return } + rememberFlaro(account: account, token: token) + } + + /// Nimmt eine gespeicherte Flaro-Anmeldung wieder auf. + private func restoreFlaro() async -> Bool { + guard let address = defaults.string(forKey: Key.flaroAddress), + let baseURL = URL(string: address), + let token = tokenStore.token(for: Self.flaroTokenAccount) + else { return false } + + phase = .connecting(serverName: defaults.string(forKey: Key.flaroServerName) ?? "") + + do { + let (account, client) = try await FlaroSignIn.restore( + baseURL: baseURL, token: token, identity: flaroIdentity) + await adoptFlaro(client: client, account: account) + return true + } catch MediaBackendError.unauthorized { + // Am Server zurückgezogen. Ihn zu behalten erzeugte bei jedem + // Start denselben Fehler. + forgetFlaro() + clearActiveServer() + return false + } catch { + // Nur gerade nicht erreichbar — auf dem Weg zur Arbeit ist jeder + // Server das, und es ist kein Grund, jemanden abzumelden. + // + // **Trotzdem mit Server:** sonst stünde beim Erneut-Versuchen + // kein Konto da, und das Heruntergeladene wäre unerreichbar. + phase = .failed(message: ErrorText.describe(error)) + activeKind = .flaro + return true + } + } + + private func rememberFlaro(account: FlaroAccount, token: String?) { + defaults.set(ServerKind.flaro.rawValue, forKey: Key.activeKind) + defaults.set(account.baseURL.absoluteString, forKey: Key.flaroAddress) + defaults.set(account.userID, forKey: Key.flaroUserID) + defaults.set(account.userName, forKey: Key.flaroUserName) + defaults.set(account.serverName, forKey: Key.flaroServerName) + defaults.set(account.isAdministrator, forKey: Key.flaroIsAdministrator) + if let token { try? tokenStore.store(token, for: Self.flaroTokenAccount) } + } + + private func forgetFlaro() { + for key in [Key.flaroAddress, Key.flaroUserID, Key.flaroUserName, + Key.flaroServerName, Key.flaroIsAdministrator] { + defaults.removeObject(forKey: key) + } + tokenStore.remove(for: Self.flaroTokenAccount) + } + private func rememberJellyfin(account: JellyfinAccount, serverName: String, baseURL: URL) { + defaults.set(ServerKind.jellyfin.rawValue, forKey: Key.activeKind) defaults.set(baseURL.absoluteString, forKey: Key.jellyfinAddress) defaults.set(account.userID, forKey: Key.jellyfinUserID) defaults.set(account.userName, forKey: Key.jellyfinUserName) @@ -385,12 +533,18 @@ final class FlaroSession { private func clearActiveServer() { backend = nil jellyfinAccount = nil + flaroAccount = nil + flaroClient = nil + activeKind = nil libraries = [] } /// Nach einem Fehler noch einmal versuchen, ohne die Anmeldung zu verlieren. func retry() async { - if await restoreJellyfin() { return } + switch defaults.string(forKey: Key.activeKind).flatMap(ServerKind.init(rawValue:)) { + case .flaro: if await restoreFlaro() { return } + case .jellyfin, nil: if await restoreJellyfin() { return } + } phase = .signedOut } @@ -400,4 +554,13 @@ final class FlaroSession { /// 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 } + + /// Dasselbe für Flaro. + var flaroDeviceIdentity: FlaroIdentity { flaroIdentity } + + /// Der Client des aktiven Flaro-Servers — zum Abmelden. + /// + /// Nicht `@Observable`-sichtbar: keine Ansicht liest ihn, und eine + /// Änderung daran soll kein Neuzeichnen auslösen. + @ObservationIgnored private var flaroClient: FlaroClient? } diff --git a/Flaro/Session/FlaroSignInModel.swift b/Flaro/Session/FlaroSignInModel.swift new file mode 100644 index 0000000..79d69c9 --- /dev/null +++ b/Flaro/Session/FlaroSignInModel.swift @@ -0,0 +1,211 @@ +import Foundation +import FlaroServerKit +import MediaKit +import Observation + +/// Führt durch die Anmeldung an einem Flaro-Server. +/// +/// Zwei Fragen am Telefon: wo steht der Server, und wer bist du. +/// +/// **Am Fernseher andersherum.** Dort steht der sechsstellige Code vorn: +/// es gibt keinen Passwortspeicher, getippt wird mit dem Steuerkreuz, und +/// das Gerät, auf dem man bestätigt, liegt sowieso auf dem Sofa. Am Telefon +/// führt derselbe Weg nirgendwohin — der Code braucht ein zweites, bereits +/// angemeldetes Gerät, und das erste ist gerade dieses hier. +/// +/// Welcher Fall gilt, weiss die Oberfläche; dieses Modell bekommt es im +/// Initialisierer gesagt. +@MainActor +@Observable +final class FlaroSignInModel { + enum Step: Equatable { + case address + case checking + /// Der Code steht auf dem Bildschirm, jetzt wird gewartet. + case code(String) + case credentials + case signingIn + } + + enum PreferredMethod { case credentials, code } + + 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? + /// Der Pflichthinweis auf die Metadatenquelle, schon vor der Anmeldung. + private(set) var attributions: [FlaroSignIn.MetadataAttribution] = [] + + var onAuthenticated: ((FlaroClient, FlaroAccount, String) -> Void)? + + private let identity: FlaroIdentity + private let preferred: PreferredMethod + private var baseURL: URL? + private var pollTask: Task? + + /// Der Vorgabewert hält iOS unverändert. + init(identity: FlaroIdentity, preferring: PreferredMethod = .credentials) { + self.identity = identity + self.preferred = preferring + } + + // Kein `deinit`, der abbricht: `pollTask` gehört dem Hauptakteur, und + // ein `deinit` läuft nicht dort. Abgebrochen wird über `cancel()` beim + // Verschwinden der Ansicht — wie beim Jellyfin-Modell auch. + + /// Hält die Abfrageschleife an. + /// + /// Ohne das läuft sie nach dem Wegwischen des Sheets noch minutenlang + /// weiter — unsichtbar und für nichts. + func cancel() { + pollTask?.cancel() + pollTask = nil + } + + // MARK: - Schritt 1: den Server finden + + func findServer() async { + errorMessage = nil + + let kandidaten = FlaroAddress.candidates(for: address) + guard !kandidaten.isEmpty else { + errorMessage = String(localized: "signIn.flaro.addressMissing") + return + } + + step = .checking + + for kandidat in kandidaten { + guard let gruss = try? await FlaroSignIn.greet( + baseURL: kandidat, identity: identity) else { continue } + + // **Ein frischer Server ist kein falsches Passwort.** Wer hier + // weitermachte, bekäme „Benutzername oder Passwort stimmt nicht" + // für ein Konto, das es noch gar nicht gibt. + guard !gruss.needsSetup else { + baseURL = kandidat + serverName = gruss.serverName + step = .address + errorMessage = String(localized: "signIn.flaro.needsSetup") + return + } + + baseURL = kandidat + address = kandidat.absoluteString + serverName = gruss.serverName + attributions = gruss.attributions + + if preferred == .code { + await beginCode() + } else { + step = .credentials + } + return + } + + step = .address + errorMessage = String(localized: "signIn.flaro.notFound") + } + + /// Zurück zur Adresse — etwa, weil der gefundene Server der falsche ist. + func changeAddress() { + cancel() + baseURL = nil + serverName = nil + errorMessage = nil + step = .address + } + + // MARK: - Schritt 2a: der Code + + /// Fordert einen Code an und wartet auf die Bestätigung. + func beginCode() async { + guard let baseURL else { return } + cancel() + errorMessage = nil + + let offen: FlaroSignIn.PendingCode + do { + offen = try await FlaroSignIn.requestCode(baseURL: baseURL, identity: identity) + } catch { + step = .credentials + errorMessage = ErrorText.describe(error) + return + } + + step = .code(offen.code) + + // **Nachfragen, nicht offen halten.** Eine Anfrage, die fünf Minuten + // auf eine Bestätigung wartet, läuft hinter jedem Reverse Proxy ins + // Zeitlimit. + pollTask = Task { [weak self] in + while !Task.isCancelled { + try? await Task.sleep(for: .seconds(2)) + if Task.isCancelled { return } + guard let self else { return } + guard await self.checkCode(baseURL: baseURL, pending: offen) else { return } + } + } + } + + /// Eine Runde der Abfrage. `false` heisst: es gibt nichts mehr zu warten. + private func checkCode(baseURL: URL, pending: FlaroSignIn.PendingCode) async -> Bool { + guard let antwort = try? await FlaroSignIn.pollCode( + baseURL: baseURL, pending: pending, identity: identity) + else { return true } // Ein Aussetzer ist kein Abbruch. + + switch antwort.state { + case .pending: + return true + case .expired: + errorMessage = String(localized: "signIn.flaro.codeExpired") + step = .credentials + return false + case .approved(let token, let konto): + guard let client = antwort.client else { return false } + onAuthenticated?(client, konto, token) + return false + } + } + + /// Zurück zum Passwort — für Server, auf denen niemand bestätigen kann. + func useCredentials() { + cancel() + errorMessage = nil + step = .credentials + } + + // MARK: - Schritt 2b: anmelden + + func signIn() async { + guard let baseURL else { return } + cancel() + errorMessage = nil + + let name = userName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !name.isEmpty, !password.isEmpty else { + errorMessage = String(localized: "signIn.flaro.credentialsMissing") + return + } + + step = .signingIn + do { + let (token, konto, client) = try await FlaroSignIn.signIn( + baseURL: baseURL, userName: name, password: password, identity: identity) + // **Das Passwort sofort wegwerfen.** Es wird für die Dauer einer + // Anfrage gehalten und danach nicht mehr gebraucht; es stehen zu + // lassen hiesse, es in einem Speicherabbild wiederzufinden. + password = "" + onAuthenticated?(client, konto, token) + } catch { + password = "" + step = .credentials + errorMessage = ErrorText.describe(error) + } + } +} diff --git a/Flaro/Views/SignIn/FlaroSignInView.swift b/Flaro/Views/SignIn/FlaroSignInView.swift new file mode 100644 index 0000000..b7e2ffc --- /dev/null +++ b/Flaro/Views/SignIn/FlaroSignInView.swift @@ -0,0 +1,176 @@ +import SwiftUI +import FlaroDesign +import FlaroServerKit + +/// Die Anmeldung an einem Flaro-Server. +/// +/// Zwei Schritte: wo steht er, und wer bist du. Mehr Fragen gibt es nicht — +/// und eine davon beantwortet der Server selbst, indem er seinen Namen nennt. +struct FlaroSignInView: View { + @Environment(FlaroSession.self) private var session + @Environment(\.dismiss) private var dismiss + @Environment(\.flaroAccent) private var accent + + @State private var model: FlaroSignInModel? + @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, FlaroSpacing.section) + .padding(.vertical, FlaroSpacing.section) + .frame(maxWidth: 420) + .frame(maxWidth: .infinity) + } + } + .scrollDismissesKeyboard(.interactively) + .background(Color.flaroCanvas) + .navigationTitle(Text("signIn.flaro.title")) + .flaroInlineTitle() + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("action.cancel") { dismiss() } + } + } + } + .tint(accent) + .task { + guard model == nil else { return } + let fresh = FlaroSignInModel(identity: session.flaroDeviceIdentity) + fresh.onAuthenticated = { client, konto, token in + dismiss() + Task { await session.adoptFlaro(client: client, account: konto, token: token) } + } + model = fresh + } + } + + @ViewBuilder + private func content(for model: FlaroSignInModel) -> some View { + VStack(alignment: .leading, spacing: FlaroSpacing.section) { + switch model.step { + case .address, .checking: + addressStep(model: model) + case .credentials, .signingIn: + credentialsStep(model: model) + case .code: + // **Am Telefon führt der Code nirgendwohin.** Er braucht ein + // zweites, bereits angemeldetes Gerät — und das erste ist + // gerade dieses hier. Das Modell bietet ihn deshalb nur an, + // wenn es mit `preferring: .code` gebaut wurde; am Fernseher + // ist das so, hier nicht. Der Fall steht trotzdem da, weil + // ein `default` die nächste neue Lage verschlucken würde. + EmptyView() + } + + if let message = model.errorMessage { + Text(message) + .font(.flaroCaption) + .foregroundStyle(Color.flaroDestructive) + } + } + } + + @ViewBuilder + private func addressStep(model: FlaroSignInModel) -> some View { + @Bindable var model = model + + VStack(alignment: .leading, spacing: FlaroSpacing.regular) { + Text("signIn.flaro.addressPrompt") + .font(.flaroRowHeader) + .foregroundStyle(Color.flaroTextPrimary) + + TextField("signIn.flaro.addressPlaceholder", text: $model.address) + .textFieldStyle(.roundedBorder) + .textContentType(.URL) + #if os(iOS) + .keyboardType(.URL) + .textInputAutocapitalization(.never) + #endif + .autocorrectionDisabled() + .focused($focusedField, equals: .address) + .onSubmit { Task { await model.findServer() } } + + Text("signIn.flaro.addressHint") + .font(.flaroCaption) + .foregroundStyle(Color.flaroTextMuted) + + Button { + Task { await model.findServer() } + } label: { + if model.step == .checking { + ProgressView().frame(maxWidth: .infinity) + } else { + Text("signIn.flaro.findServer").frame(maxWidth: .infinity) + } + } + .buttonStyle(.borderedProminent) + .tint(accent) + .disabled(model.step == .checking) + } + .onAppear { focusedField = .address } + } + + @ViewBuilder + private func credentialsStep(model: FlaroSignInModel) -> some View { + @Bindable var model = model + + VStack(alignment: .leading, spacing: FlaroSpacing.regular) { + if let name = model.serverName { + // Der Name des Servers steht hier, damit sichtbar ist, + // **wo** man sich gleich anmeldet — eine Adresse allein sagt + // das nicht. + HStack { + Text(name) + .font(.flaroRowHeader) + .foregroundStyle(Color.flaroTextPrimary) + Spacer() + Button("signIn.flaro.changeAddress") { model.changeAddress() } + .font(.flaroCaption) + } + } + + TextField("signIn.userName", text: $model.userName) + .textFieldStyle(.roundedBorder) + .textContentType(.username) + #if os(iOS) + .textInputAutocapitalization(.never) + #endif + .autocorrectionDisabled() + .focused($focusedField, equals: .userName) + .onSubmit { focusedField = .password } + + SecureField("signIn.password", text: $model.password) + .textFieldStyle(.roundedBorder) + .textContentType(.password) + .focused($focusedField, equals: .password) + .onSubmit { Task { await model.signIn() } } + + Button { + Task { await model.signIn() } + } label: { + if model.step == .signingIn { + ProgressView().frame(maxWidth: .infinity) + } else { + Text("signIn.flaro.signIn").frame(maxWidth: .infinity) + } + } + .buttonStyle(.borderedProminent) + .tint(accent) + .disabled(model.step == .signingIn) + + // Der Pflichthinweis auf TMDB — Bedingung ihrer Lizenz, und der + // Server liefert ihn ohne Anmeldung genau dafür. + ForEach(model.attributions, id: \.text) { hinweis in + Text(hinweis.text) + .font(.flaroCaption) + .foregroundStyle(Color.flaroTextMuted) + } + } + .onAppear { focusedField = .userName } + } +} diff --git a/Flaro/Views/SignIn/JellyfinSignInView.swift b/Flaro/Views/SignIn/JellyfinSignInView.swift index 9a898fc..aa14e77 100644 --- a/Flaro/Views/SignIn/JellyfinSignInView.swift +++ b/Flaro/Views/SignIn/JellyfinSignInView.swift @@ -184,7 +184,7 @@ struct JellyfinSignInView: View { VStack(alignment: .leading, spacing: FlaroSpacing.regular) { foundServerHeader(model: model) - TextField("signIn.jellyfin.userName", text: $model.userName) + TextField("signIn.userName", text: $model.userName) .textContentType(.username) .flaroPlainEntry() .submitLabel(.next) @@ -203,7 +203,7 @@ struct JellyfinSignInView: View { .padding(FlaroSpacing.regular) .background(Color.flaroElevated, in: .rect(cornerRadius: FlaroRadius.control)) - SecureField("signIn.jellyfin.password", text: $model.password) + SecureField("signIn.password", text: $model.password) .textContentType(.password) .submitLabel(.go) .focused($focusedField, equals: .password) diff --git a/Flaro/Views/SignIn/SignInView.swift b/Flaro/Views/SignIn/SignInView.swift index 1b04848..ee1d574 100644 --- a/Flaro/Views/SignIn/SignInView.swift +++ b/Flaro/Views/SignIn/SignInView.swift @@ -3,16 +3,23 @@ import FlaroDesign /// Der Anmeldebildschirm. /// -/// **Ein Weg, seit es einen Server gibt.** Hier standen zwei Knöpfe -/// nebeneinander, und der obere war der auffälligere — er führte zu Plex. -/// Bleibt einer übrig, erbt er die Hervorhebung: ein Knopf, der aussieht wie -/// die zweite Wahl, lässt den Benutzer nach der ersten suchen. +/// **Wieder zwei Knöpfe, und diesmal zu zwei Servern.** Hier standen schon +/// einmal zwei — Plex oben, Jellyfin darunter —, und nach dem Entfernen von +/// Plex blieb einer übrig. Jetzt gibt es Flaro, und die Frage stellt sich +/// neu: welcher steht oben? /// -/// Jellyfin hat kein Verzeichnis, an dem die eigenen Server hängen. Deshalb -/// wird die Adresse eingetippt und, wann immer der Server es anbietet, ein -/// Code bestätigt statt eines Passworts — das führt `JellyfinSignInView`. +/// **Flaro.** Nicht aus Eitelkeit, sondern weil die App dort mehr kann: der +/// Download bringt alle Tonspuren mit, der Fortschritt wird ab der ersten +/// Sekunde gemerkt. Wer Jellyfin benutzt, sucht gezielt danach — wer neu +/// anfängt, soll nicht in der schwächeren Hälfte landen, weil ihr Knopf +/// auffälliger war. +/// +/// Beide Server haben kein Verzeichnis, an dem die eigenen Instanzen hängen. +/// Die Adresse wird deshalb eingetippt; den Rest führen die beiden +/// Anmeldeansichten. struct SignInView: View { @Environment(\.flaroAccent) private var accent + @State private var showsFlaroSignIn = false @State private var showsJellyfinSignIn = false var body: some View { @@ -37,6 +44,17 @@ struct SignInView: View { Spacer() VStack(spacing: FlaroSpacing.regular) { + Button { + showsFlaroSignIn = true + } label: { + Text("signIn.withFlaro") + .font(.flaroRowHeader) + .frame(maxWidth: .infinity) + .padding(.vertical, FlaroSpacing.regular) + } + .buttonStyle(.borderedProminent) + .tint(accent) + Button { showsJellyfinSignIn = true } label: { @@ -45,8 +63,7 @@ struct SignInView: View { .frame(maxWidth: .infinity) .padding(.vertical, FlaroSpacing.regular) } - .buttonStyle(.borderedProminent) - .tint(accent) + .buttonStyle(.bordered) Text("signIn.privacy") .font(.flaroCaption) @@ -60,6 +77,9 @@ struct SignInView: View { .frame(maxWidth: 420) .frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color.flaroCanvas) + .sheet(isPresented: $showsFlaroSignIn) { + FlaroSignInView() + } .sheet(isPresented: $showsJellyfinSignIn) { JellyfinSignInView() } diff --git a/FlaroTV/FlaroTVApp.swift b/FlaroTV/FlaroTVApp.swift index 1a3e5fb..cbdab42 100644 --- a/FlaroTV/FlaroTVApp.swift +++ b/FlaroTV/FlaroTVApp.swift @@ -2,6 +2,7 @@ import SwiftUI import FlaroDesign import FlaroPlayback import MediaKit +import FlaroServerKit import JellyfinKit /// Die App auf dem Fernseher. @@ -45,6 +46,7 @@ struct FlaroTVApp: App { client: Branding.appName, version: Branding.version ), + flaroIdentity: FlaroIdentity.current(), tokenStore: KeychainTokenStore(service: Branding.keychainService) )) } diff --git a/FlaroTV/Views/SignIn/TVFlaroSignInView.swift b/FlaroTV/Views/SignIn/TVFlaroSignInView.swift new file mode 100644 index 0000000..893a125 --- /dev/null +++ b/FlaroTV/Views/SignIn/TVFlaroSignInView.swift @@ -0,0 +1,130 @@ +import SwiftUI +import FlaroDesign +import FlaroServerKit + +/// Die Anmeldung an einem Flaro-Server, am Fernseher. +/// +/// Dasselbe Modell wie am Telefon, nur mit `preferring: .code`: sechs Ziffern +/// auf dem Bildschirm, bestätigt am Telefon. Das Passwort bleibt der Rückfall +/// — für den Fall, dass gerade kein angemeldetes Gerät zur Hand ist. +struct TVFlaroSignInView: View { + @Environment(FlaroSession.self) private var session + + let zurueck: () -> Void + + @State private var model: FlaroSignInModel? + + var body: some View { + Group { + if let model { + inhalt(for: model) + } else { + ProgressView() + } + } + .task { + guard model == nil else { return } + let frisch = FlaroSignInModel( + identity: session.flaroDeviceIdentity, preferring: .code) + frisch.onAuthenticated = { client, konto, token in + Task { await session.adoptFlaro(client: client, account: konto, token: token) } + } + model = frisch + } + .onDisappear { model?.cancel() } + } + + @ViewBuilder + private func inhalt(for model: FlaroSignInModel) -> some View { + @Bindable var model = model + + VStack(alignment: .leading, spacing: FlaroSpacing.section) { + Text(Branding.appName).font(.flaroHero) + Text("signIn.flaro.title").font(.flaroBody).foregroundStyle(Color.flaroTextMuted) + + switch model.step { + case .address: + adresse(model: model) + case .checking: + warten("signIn.flaro.searching") + case .code(let code): + codeAnzeigen(model: model, code: code) + case .credentials: + zugangsdaten(model: model) + case .signingIn: + warten("signIn.flaro.signingIn") + } + + if let fehler = model.errorMessage { + Text(fehler).font(.flaroBody).foregroundStyle(Color.flaroDestructive) + } + } + .padding(FlaroSpacing.section) + } + + private func adresse(model: FlaroSignInModel) -> some View { + @Bindable var model = model + + return VStack(alignment: .leading, spacing: FlaroSpacing.regular) { + Text("signIn.flaro.addressPrompt").font(.flaroRowHeader) + TextField("signIn.flaro.addressPlaceholder", text: $model.address) + .flaroPlainEntry(isAddress: true) + Text("signIn.flaro.addressHint") + .font(.flaroCaption).foregroundStyle(Color.flaroTextMuted) + + HStack(spacing: FlaroSpacing.regular) { + Button("signIn.flaro.findServer") { Task { await model.findServer() } } + Button("action.back", action: zurueck) + } + } + } + + private func codeAnzeigen(model: FlaroSignInModel, code: String) -> some View { + VStack(alignment: .leading, spacing: FlaroSpacing.regular) { + if let name = model.serverName { + Text(name).font(.flaroRowHeader) + } + // **Der Code ist hier der Inhalt, nicht eine Beschriftung.** + // Er wird aus drei Metern abgelesen und in ein Telefon getippt; + // in Fliesstextgrösse ist das eine Zumutung. + Text(code) + .font(.system(size: 96, weight: .bold, design: .monospaced)) + .kerning(12) + .foregroundStyle(.white) + Text("signIn.flaro.codeHint").font(.flaroBody) + Label("signIn.flaro.waitingForApproval", systemImage: "hourglass") + .font(.flaroCaption).foregroundStyle(Color.flaroTextMuted) + + HStack(spacing: FlaroSpacing.regular) { + Button("signIn.flaro.usePassword") { model.useCredentials() } + Button("signIn.flaro.changeAddress") { model.changeAddress() } + } + } + } + + private func zugangsdaten(model: FlaroSignInModel) -> some View { + @Bindable var model = model + + return VStack(alignment: .leading, spacing: FlaroSpacing.regular) { + if let name = model.serverName { + Text(name).font(.flaroRowHeader) + } + TextField("signIn.userName", text: $model.userName) + .flaroPlainEntry() + SecureField("signIn.password", text: $model.password) + + HStack(spacing: FlaroSpacing.regular) { + Button("signIn.flaro.signIn") { Task { await model.signIn() } } + Button("signIn.flaro.useCode") { Task { await model.beginCode() } } + Button("signIn.flaro.changeAddress") { model.changeAddress() } + } + } + } + + private func warten(_ text: LocalizedStringKey) -> some View { + HStack(spacing: FlaroSpacing.regular) { + ProgressView() + Text(text).font(.flaroBody) + } + } +} diff --git a/FlaroTV/Views/SignIn/TVSignInView.swift b/FlaroTV/Views/SignIn/TVSignInView.swift index 2220de4..20caa35 100644 --- a/FlaroTV/Views/SignIn/TVSignInView.swift +++ b/FlaroTV/Views/SignIn/TVSignInView.swift @@ -1,40 +1,51 @@ import SwiftUI import FlaroDesign +import FlaroServerKit import JellyfinKit /// Die Anmeldung am Fernseher. /// -/// **Dieselben vier Schritte wie am Telefon, andere Reihenfolge.** Das Modell -/// `JellyfinSignInModel` ist unverändert dasselbe — nur bekommt es hier -/// `preferring: .quickConnect`. Vor einem Fernseher ist der Code der bequemere -/// Weg: es gibt keinen Passwortspeicher, getippt wird mit dem Steuerkreuz, und -/// das Gerät, auf dem man den Code bestätigt, liegt sowieso auf dem Sofa. +/// **Erst die Frage, welcher Server.** Seit es zwei gibt, steht sie vorn — +/// und Flaro oben, weil die App dort mehr kann. /// -/// Der Weg über Benutzername und Passwort bleibt erreichbar — für Server, auf -/// denen Quick Connect abgeschaltet ist. +/// Danach dieselben Schritte wie am Telefon, nur andere Reihenfolge: beide +/// Modelle bekommen hier den Code-Weg voran. Vor einem Fernseher ist er der +/// bequemere: es gibt keinen Passwortspeicher, getippt wird mit dem +/// Steuerkreuz, und das Gerät, auf dem man bestätigt, liegt sowieso auf dem +/// Sofa. Benutzername und Passwort bleiben erreichbar. struct TVSignInView: View { @Environment(FlaroSession.self) private var session + @State private var art: FlaroSession.ServerKind? @State private var model: JellyfinSignInModel? var body: some View { ZStack { Color.flaroCanvas.ignoresSafeArea() - if let model { - inhalt(for: model) - // Eine Spalte, nicht die volle Breite: 1760 Punkte Zeile - // liest sich aus drei Metern nicht mehr als Zeile. - .frame(maxWidth: 900) - } else { - ProgressView() + Group { + switch art { + case nil: + auswahl() + case .flaro: + TVFlaroSignInView(zurueck: { art = nil }) + case .jellyfin: + if let model { + inhalt(for: model) + } else { + ProgressView() + } + } } + // Eine Spalte, nicht die volle Breite: 1760 Punkte Zeile + // liest sich aus drei Metern nicht mehr als Zeile. + .frame(maxWidth: 900) } - .task { + .onChange(of: art) { _, neu in + guard neu == .jellyfin, model == nil else { return } // 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 frisch = JellyfinSignInModel( identity: session.jellyfinDeviceIdentity, preferring: .quickConnect @@ -51,6 +62,19 @@ struct TVSignInView: View { .onDisappear { model?.cancel() } } + private func auswahl() -> some View { + VStack(alignment: .leading, spacing: FlaroSpacing.section) { + Text(Branding.appName).font(.flaroHero) + Text("signIn.tagline").font(.flaroBody).foregroundStyle(Color.flaroTextMuted) + + VStack(spacing: FlaroSpacing.regular) { + Button("signIn.withFlaro") { art = .flaro } + Button("signIn.withJellyfin") { art = .jellyfin } + } + } + .padding(FlaroSpacing.section) + } + @ViewBuilder private func inhalt(for model: JellyfinSignInModel) -> some View { @Bindable var model = model @@ -129,9 +153,9 @@ struct TVSignInView: View { if let name = model.serverName { Text(name).font(.flaroRowHeader) } - TextField("signIn.jellyfin.userName", text: $model.userName) + TextField("signIn.userName", text: $model.userName) .flaroPlainEntry() - SecureField("signIn.jellyfin.password", text: $model.password) + SecureField("signIn.password", text: $model.password) Text("signIn.jellyfin.passwordNote") .font(.flaroCaption).foregroundStyle(Color.flaroTextMuted) diff --git a/Packages/FlaroKit/Package.swift b/Packages/FlaroKit/Package.swift index 1b43314..cfbe3a3 100644 --- a/Packages/FlaroKit/Package.swift +++ b/Packages/FlaroKit/Package.swift @@ -14,6 +14,7 @@ let package = Package( products: [ .library(name: "MediaKit", targets: ["MediaKit"]), .library(name: "JellyfinKit", targets: ["JellyfinKit"]), + .library(name: "FlaroServerKit", targets: ["FlaroServerKit"]), .library(name: "FlaroDesign", targets: ["FlaroDesign"]), .library(name: "FlaroPlayback", targets: ["FlaroPlayback"]), .library(name: "FlaroDownloads", targets: ["FlaroDownloads"]), @@ -39,6 +40,12 @@ let package = Package( resources: [.copy("Fixtures")] ), + // Flaros eigener Server. Daneben, nicht darin — genau der Steckplatz, + // den Plex geräumt hat. Dass die Naht zwei Server zugleich trägt, war + // schon einmal bewiesen; hier wird es wieder gebraucht. + .target(name: "FlaroServerKit", dependencies: ["MediaKit"], resources: [.process("Localizable.xcstrings")]), + .testTarget(name: "FlaroServerKitTests", dependencies: ["FlaroServerKit"]), + // 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. diff --git a/Packages/FlaroKit/Sources/FlaroPlayback/Engine/AVPlayerEngine.swift b/Packages/FlaroKit/Sources/FlaroPlayback/Engine/AVPlayerEngine.swift index 017840e..14dc575 100644 --- a/Packages/FlaroKit/Sources/FlaroPlayback/Engine/AVPlayerEngine.swift +++ b/Packages/FlaroKit/Sources/FlaroPlayback/Engine/AVPlayerEngine.swift @@ -125,11 +125,24 @@ public final class AVPlayerEngine: VideoPlayerEngine { pendingStartTime = source.startTime + // **Cookies muss man AVFoundation ausdrücklich mitgeben.** + // + // `URLSession` liest den gemeinsamen Speicher von allein, `AVURLAsset` + // nicht. Für Jellyfin ist das belanglos — dort steht der Zugang in + // der Adresse. Flaro schreibt ihn bewusst nicht dorthin: er stünde + // sonst in jedem Zugriffsprotokoll und in jedem weitergegebenen Link. + // Ohne diese Zeile bekäme der Spieler dort 401 und zeigte Schwarz. + // + // Serverneutral: wo kein Cookie liegt, ist die Liste leer und es + // ändert sich nichts. + let kekse = HTTPCookieStorage.shared.cookies(for: source.url) ?? [] + let asset = AVURLAsset(url: source.url, options: [ // Keine genaue Dauer verlangen. Sonst durchsucht AVFoundation bei // manchen Formaten die ganze Datei, bevor es überhaupt anfängt — // und „ganze Datei" heißt bei einem Remux vierzig Gigabyte. AVURLAssetPreferPreciseDurationAndTimingKey: false, + AVURLAssetHTTPCookiesKey: kekse, ]) let item = AVPlayerItem(asset: asset) diff --git a/Packages/FlaroKit/Sources/FlaroServerKit/FlaroAddress.swift b/Packages/FlaroKit/Sources/FlaroServerKit/FlaroAddress.swift new file mode 100644 index 0000000..8afdc04 --- /dev/null +++ b/Packages/FlaroKit/Sources/FlaroServerKit/FlaroAddress.swift @@ -0,0 +1,19 @@ +import Foundation +import MediaKit + +/// Die Adressraterei für Flaro. +/// +/// Der Inhalt steht in `ServerAddress`. Hier steht nur, was Flaro von +/// Jellyfin unterscheidet: der Port. +public enum FlaroAddress { + /// Der Standardport eines Flaro-Servers. + /// + /// 8080, nicht 8096. Die Beispiel-Compose-Datei bildet ihn so ab, und + /// einen fremden Port zu erben hiesse, dass jede Anmeldung einmal ins + /// Leere läuft, bevor sie den richtigen findet. + public static let defaultPort = 8080 + + public static func candidates(for input: String) -> [URL] { + ServerAddress.candidates(for: input, defaultPort: defaultPort) + } +} diff --git a/Packages/FlaroKit/Sources/FlaroServerKit/FlaroBackend.swift b/Packages/FlaroKit/Sources/FlaroServerKit/FlaroBackend.swift new file mode 100644 index 0000000..32bd765 --- /dev/null +++ b/Packages/FlaroKit/Sources/FlaroServerKit/FlaroBackend.swift @@ -0,0 +1,417 @@ +import Foundation +import MediaKit + +/// Ein Flaro-Server, gesehen durch `MediaBackend`. +/// +/// Der zweite Server an derselben Naht, an der vorher Plex und Jellyfin +/// nebeneinander hingen. Was hier steht, ist die Übersetzung von Flaros +/// Schnittstelle in den Vertrag — nichts weiter: keine Oberfläche, keine +/// Entscheidung über Wiedergabe, keine Zwischenspeicher. +public actor FlaroBackend: MediaBackend { + private let client: FlaroClient + private let account: FlaroAccount + /// Die Mediatheken, einmal geholt. + /// + /// Sie ändern sich nur, wenn jemand in der Verwaltung eine anlegt — und + /// jede Seite der Mediathek bräuchte sie sonst erneut, nur um zu wissen, + /// ob sie nach Filmen oder nach Serien fragt. + private var libraryMemo: [MediaLibrary]? + + public init(client: FlaroClient, account: FlaroAccount) { + self.client = client + self.account = account + } + + // MARK: - Wer dieser Server ist + + public nonisolated var serverName: String { account.serverName } + + /// **Die Adresse, nicht der Name.** Zwei Server dürfen „Medien" heissen, + /// und derselbe darf umbenannt werden. Was hier steht, überlebt beides. + public nonisolated var identity: String { "flaro:" + account.baseURL.absoluteString } + + public nonisolated var capabilities: Set { + [ + .favourites, + .ratings, + .libraryScan, + .transcoding, + // **Der Grund, aus dem es diesen Server gibt.** Flaros Download + // bringt jede Ton- und Textspur mit; die App muss vorher nichts + // wählen und kann offline umstellen. + .allTracksDownload, + ] + } + + // MARK: - Blättern + + public func libraries() async throws(MediaBackendError) -> [MediaLibrary] { + if let libraryMemo { return libraryMemo } + let roh = try await client.send(FlaroRequest(path: "/api/v1/libraries"), as: [Wire.Library].self) + let liste = roh.map { $0.neutral() } + libraryMemo = liste + return liste + } + + public func homeRows() async throws(MediaBackendError) -> [MediaRowGroup] { + // **Vier Anfragen, nicht eine.** Flaro hat keinen Endpunkt, der eine + // fertige Startseite liefert, und das ist Absicht: welche Reihen eine + // App zeigt, entscheidet die App. Der Server liefert die Zutaten. + async let weiter = reihe(pfad: "/api/v1/home/continue") + async let naechste = reihe(pfad: "/api/v1/home/next") + async let filme = seite(pfad: "/api/v1/movies", take: 24, as: Wire.Movie.self) + async let serien = seite(pfad: "/api/v1/series", take: 24, as: Wire.Series.self) + + var reihen: [MediaRowGroup] = [] + + if let eintraege = try? await weiter, !eintraege.isEmpty { + reihen.append(MediaRowGroup( + id: "flaro.continue", + title: String(localized: "flaro.row.continue", bundle: .module), + items: eintraege)) + } + if let eintraege = try? await naechste, !eintraege.isEmpty { + reihen.append(MediaRowGroup( + id: "flaro.nextup", + title: String(localized: "flaro.row.nextUp", bundle: .module), + items: eintraege)) + } + if let gefunden = try? await filme, !gefunden.items.isEmpty { + reihen.append(MediaRowGroup( + id: "flaro.movies", + title: String(localized: "flaro.row.movies", bundle: .module), + items: gefunden.items.map { $0.neutral() })) + } + if let gefunden = try? await serien, !gefunden.items.isEmpty { + reihen.append(MediaRowGroup( + id: "flaro.series", + title: String(localized: "flaro.row.series", bundle: .module), + items: gefunden.items.map { $0.neutral() })) + } + return reihen + } + + public func items( + inLibrary library: MediaLibrary, + query: MediaQuery, + offset: Int, + limit: Int + ) async throws(MediaBackendError) -> MediaPage { + var abfrage = [ + URLQueryItem(name: "library", value: library.id), + URLQueryItem(name: "skip", value: String(offset)), + URLQueryItem(name: "take", value: String(limit)), + ] + if let text = query.searchText, !text.isEmpty { + abfrage.append(URLQueryItem(name: "search", value: text)) + } + + if library.kind == .show { + let seite = try await client.send( + FlaroRequest(path: "/api/v1/series", query: abfrage), + as: Wire.Page.self) + return MediaPage( + items: gesiebt(seite.items.map { $0.neutral() }, query), + offset: offset, totalCount: seite.total) + } + + let seite = try await client.send( + FlaroRequest(path: "/api/v1/movies", query: abfrage), + as: Wire.Page.self) + return MediaPage( + items: gesiebt(seite.items.map { $0.neutral() }, query), + offset: offset, totalCount: seite.total) + } + + public func item(id: String) async throws(MediaBackendError) -> MediaItem { + // **Erst Film, dann Serie, dann Folge.** Die App reicht eine Kennung + // durch und weiss nicht, was dahintersteckt — sie hat sie aus einer + // Liste. Drei Anfragen im schlechtesten Fall; der Normalfall ist eine. + if let film = try? await client.send( + FlaroRequest(path: "/api/v1/movies/\(id)"), as: Wire.Movie.self) { + return film.neutral() + } + if let serie = try? await client.send( + FlaroRequest(path: "/api/v1/series/\(id)"), as: Wire.Series.self) { + return serie.neutral() + } + throw .notFound + } + + public func children(of id: String) async throws(MediaBackendError) -> [MediaItem] { + try await episodes(inSeries: id) + } + + /// Alle Folgen einer Serie — in einer Anfrage. + /// + /// Der allgemeine Weg über Staffeln kostete bei zwölf Staffeln dreizehn + /// Anfragen. Flaro kann es in einer, also tut es das. + public func episodes(inSeries id: String) async throws(MediaBackendError) -> [MediaItem] { + let serie = try? await client.send( + FlaroRequest(path: "/api/v1/series/\(id)"), as: Wire.Series.self) + let folgen = try await client.send( + FlaroRequest(path: "/api/v1/series/\(id)/episodes"), + as: Wire.Page.self) + return folgen.items.map { $0.neutral(series: serie) } + } + + public func nextEpisode(after item: MediaItem) async throws(MediaBackendError) -> MediaItem? { + guard item.kind == .episode else { return nil } + // 204 heisst: es war die letzte. Kein Fehler, nur kein Nachfolger. + let daten = try await client.send(FlaroRequest(path: "/api/v1/episodes/\(item.id)/next")) + guard !daten.isEmpty else { return nil } + guard let folge = try? JSONDecoder().decode(Wire.Episode.self, from: daten) else { return nil } + return folge.neutral() + } + + public func search(_ text: String, limit: Int) async throws(MediaBackendError) -> [MediaRowGroup] { + let abfrage = [ + URLQueryItem(name: "search", value: text), + URLQueryItem(name: "take", value: String(limit)), + ] + async let filme = client.send( + FlaroRequest(path: "/api/v1/movies", query: abfrage), as: Wire.Page.self) + async let serien = client.send( + FlaroRequest(path: "/api/v1/series", query: abfrage), as: Wire.Page.self) + + var reihen: [MediaRowGroup] = [] + if let gefunden = try? await filme, !gefunden.items.isEmpty { + reihen.append(MediaRowGroup( + id: "flaro.search.movies", + title: String(localized: "flaro.row.movies", bundle: .module), + items: gefunden.items.map { $0.neutral() })) + } + if let gefunden = try? await serien, !gefunden.items.isEmpty { + reihen.append(MediaRowGroup( + id: "flaro.search.series", + title: String(localized: "flaro.row.series", bundle: .module), + items: gefunden.items.map { $0.neutral() })) + } + return reihen + } + + /// Flaro kennt noch keine Schlagwörter. + /// + /// Eine leere Liste, kein Fehler: die Oberfläche blendet den Filter dann + /// aus, statt einen Knopf zu zeigen, hinter dem nichts ist. + public func filterOptions( + inLibrary library: MediaLibrary, + field: MediaQuery.Field + ) async throws(MediaBackendError) -> [MediaFilterOption] { [] } + + public func libraryID(for item: MediaItem) async -> String? { nil } + + // MARK: - Bilder + + /// **Ohne Grössenangabe.** Flaro legt die Bilder so ab, wie TMDB sie + /// liefert, und rechnet nicht um. Ein Plakat wiegt achtzig Kilobyte; ein + /// Server, der jede Kachel einzeln skaliert, spart Leitung und kostet + /// Rechenzeit, die auf einem NAS knapper ist. + public nonisolated func artworkURL(path: String?, width: Int, height: Int) -> URL? { + rawImageURL(path: path) + } + + public nonisolated func rawImageURL(path: String?) -> URL? { + guard let path, !path.isEmpty else { return nil } + return client.url(path: "/api/v1/images/\(path)") + } + + // MARK: - Herunterladen + + /// **Eine Adresse für alles.** Flaro entscheidet selbst, ob die Datei + /// unverändert hinausgeht oder erst umgepackt wird — und packt dabei + /// *alle* Ton- und Textspuren mit ein. + public nonisolated func downloadURL(for file: MediaFile) -> URL? { + client.url(path: "/api/v1/files/\(file.id)/download") + } + + /// **Dieselbe Adresse, und das ist der Punkt.** + /// + /// Der Vertrag nimmt hier je eine Spurnummer entgegen, weil Jellyfins + /// Transcoder genau eine Tonspur je Sitzung abbildet. Flaro braucht die + /// Wahl nicht: was herauskommt, hat alles. Die Nummern werden deshalb + /// nicht ignoriert, sondern sind gegenstandslos — und die Oberfläche + /// fragt bei `.allTracksDownload` gar nicht erst danach. + public nonisolated func remuxDownloadURL( + itemID: String, + versionIndex: Int, + audioStreamIndex: Int?, + subtitleStreamIndex: Int? + ) -> URL? { nil } + + /// Noch nicht da: das Herunterrechnen auf eine Bitrate kommt mit Stufe 3. + public nonisolated func compressedDownloadURL( + itemID: String, + versionIndex: Int, + maximumBitrate: Int, + resolution: String? + ) -> URL? { nil } + + // MARK: - Abspielen + + /// Wie lange auf ein Umpacken gewartet wird, bevor aufgegeben wird. + /// + /// Grosszügig: ein Dreistünder in 4K braucht auf einem NAS Minuten, und + /// abzubrechen, während die Arbeit läuft, wäre die eine Antwort, die + /// niemandem hilft. Irgendwann muss es trotzdem eine geben — ein Spieler, + /// der unbegrenzt auf einen hängengebliebenen Prozess wartet, zeigt + /// Schwarz ohne Begründung. + private static let packWartezeit = Duration.seconds(600) + + public func preparePlayback( + _ intent: PlaybackIntent + ) async throws(MediaBackendError) -> PlaybackTarget { + guard let adresse = client.url(path: "/api/v1/files/\(intent.file.id)/download") else { + throw .transport(description: "Adresse nicht baubar") + } + + var auskunft = try await client.send( + FlaroRequest(path: "/api/v1/files/\(intent.file.id)/delivery"), + as: Wire.Delivery.self) + + if let fehler = auskunft.error { throw .refused(reason: fehler) } + + // **Erst warten, dann die Adresse herausgeben.** + // + // Muss der Server umpacken, antwortet er auf den Download mit 202 und + // fängt an. AVPlayer bekommt darauf kein Video, sondern eine leere + // Antwort — und wartet. Unbegrenzt, ohne Fehler, mit einem Spinner. + // Genau so sah es beim ersten Lauf im Simulator aus: „Stelle wird + // wiederhergestellt", 0:00, für immer. + // + // Das Anstossen besorgt der Server beim ersten Abruf selbst; hier + // wird nur nachgefragt, bis es so weit ist. + if auskunft.method != "directFile", auskunft.packageState != "ready" { + let bis = ContinuousClock.now + Self.packWartezeit + while ContinuousClock.now < bis { + // Ein HEAD stösst an und fragt zugleich nach. Die Datei dafür + // herunterzuladen wäre absurd. + _ = try? await client.send(FlaroRequest( + method: .head, path: "/api/v1/files/\(intent.file.id)/download")) + + auskunft = try await client.send( + FlaroRequest(path: "/api/v1/files/\(intent.file.id)/delivery"), + as: Wire.Delivery.self) + + if let fehler = auskunft.error { throw .refused(reason: fehler) } + if auskunft.packageState == "ready" { break } + + try? await Task.sleep(for: .seconds(1)) + } + + guard auskunft.packageState == "ready" else { + throw .refused(reason: String( + localized: "flaro.remux.tooLong", bundle: .module)) + } + } + + // **Keine Sitzung.** Flaro legt für eine Wiedergabe nichts an, das + // hinterher aufzuräumen wäre: die Datei geht unverändert hinaus oder + // liegt umgepackt im Zwischenspeicher. Es gibt deshalb nichts, was + // weiterrechnet, wenn niemand mehr zusieht. + return PlaybackTarget(url: adresse, sessionID: nil, versionID: intent.file.id) + } + + public func endPlayback(sessionID: String) async {} + + public func reportProgress(_ report: PlaybackReport) async { + // Wirft nicht: eine verlorene Meldung kostet ein paar Sekunden + // Fortschritt, und dafür mitten im Film eine Fehlermeldung zu zeigen + // wäre die schlechtere Hälfte des Tauschs. + // + // Auch `.started` wird gemeldet. Flaro kennt dafür keinen eigenen + // Endpunkt, aber die Stelle ist dieselbe — und sie abzuschneiden + // hiesse, den ersten Bericht wegzuwerfen. + _ = try? await client.send(.json(.post, "/api/v1/items/\(report.itemID)/progress", + Wire.ProgressRequest( + positionSeconds: report.time.seconds, + runtimeSeconds: report.duration > .zero ? report.duration.seconds : nil))) + } + + // MARK: - Zustand melden + + public func markWatched(id: String) async throws(MediaBackendError) { + try await client.send(.json(.put, "/api/v1/items/\(id)/watched", Wire.WatchedRequest(watched: true))) + } + + public func markUnwatched(id: String) async throws(MediaBackendError) { + try await client.send(.json(.put, "/api/v1/items/\(id)/watched", Wire.WatchedRequest(watched: false))) + } + + /// Aus „Weiterschauen" nehmen, ohne als gesehen zu gelten. + /// + /// **Flaro hat dafür einen eigenen Weg**, und nur deshalb bleibt die + /// Stelle erhalten. Bei Jellyfin wird hier das Benutzerdatenblatt + /// geschrieben, und darin steht auch „gesehen" — wer es nicht mitschickt, + /// löscht es. + public func removeFromContinueWatching(_ item: MediaItem) async throws(MediaBackendError) { + try await client.send(FlaroRequest(method: .post, path: "/api/v1/items/\(item.id)/dismiss")) + } + + public func setFavourite(_ isFavourite: Bool, id: String) async throws(MediaBackendError) { + try await client.send(.json(.put, "/api/v1/items/\(id)/favourite", + Wire.FavouriteRequest(favourite: isFavourite))) + } + + public func rate(id: String, rating: Int) async throws(MediaBackendError) { + try await client.send(.json(.put, "/api/v1/items/\(id)/rating", + Wire.RatingRequest(rating: rating > 0 ? rating : nil))) + } + + public func refreshLibrary(_ library: MediaLibrary) async throws(MediaBackendError) { + try await client.send(FlaroRequest(method: .post, path: "/api/v1/libraries/\(library.id)/scan")) + } + + // MARK: - Gemeinsames + + private func seite( + pfad: String, take: Int, as type: T.Type + ) async throws(MediaBackendError) -> Wire.Page { + try await client.send( + FlaroRequest(path: pfad, query: [URLQueryItem(name: "take", value: String(take))]), + as: Wire.Page.self) + } + + private func reihe(pfad: String) async throws(MediaBackendError) -> [MediaItem] { + let seite = try await client.send(FlaroRequest(path: pfad), as: Wire.Page.self) + return seite.items.map { $0.neutral() } + } + + /// Was der Server noch nicht kann, siebt die App — aber nur das. + /// + /// **Nicht alles.** Gefiltert wird hier über eine *Seite*, nicht über die + /// Mediathek: wer bei fünftausend Titeln „nur ungesehene" wählt, bekommt + /// die ungesehenen der ersten fünfzig. Das ist ein Zwischenstand, kein + /// Entwurf — sobald Flaro die Einschränkung selbst kann, fällt das hier + /// weg. Es jetzt wegzulassen hiesse, einen Schalter anzubieten, der + /// nichts tut. + private func gesiebt(_ titel: [MediaItem], _ query: MediaQuery) -> [MediaItem] { + var liste = titel + if query.unwatchedOnly { liste = liste.filter { !$0.isWatched } } + if query.favouritesOnly { liste = liste.filter(\.isFavourite) } + return liste + } +} + +/// Mit wem die App spricht und als wer. +public struct FlaroAccount: Sendable, Equatable { + public let baseURL: URL + public let serverName: String + public let userID: String + public let userName: String + public let isAdministrator: Bool + + public init( + baseURL: URL, + serverName: String, + userID: String, + userName: String, + isAdministrator: Bool + ) { + self.baseURL = baseURL + self.serverName = serverName + self.userID = userID + self.userName = userName + self.isAdministrator = isAdministrator + } +} diff --git a/Packages/FlaroKit/Sources/FlaroServerKit/FlaroClient.swift b/Packages/FlaroKit/Sources/FlaroServerKit/FlaroClient.swift new file mode 100644 index 0000000..1a56f20 --- /dev/null +++ b/Packages/FlaroKit/Sources/FlaroServerKit/FlaroClient.swift @@ -0,0 +1,294 @@ +import Foundation +import MediaKit + +/// Wer wir für einen Flaro-Server sind. +/// +/// Anders als bei Jellyfin steht davon nichts in einer Kopfzeile: Flaro nimmt +/// den Gerätenamen einmal bei der Anmeldung entgegen und führt das Gerät +/// danach über das Token. Die Kennung muss trotzdem über Starts hinweg +/// gleich bleiben — wechselt sie, meldet sich dasselbe Gerät als neues an, +/// und in der Geräteliste sammelt sich Müll. +public struct FlaroIdentity: Sendable, Equatable { + public let deviceName: String + public let deviceID: String + + public init(deviceName: String, deviceID: String) { + self.deviceName = deviceName + self.deviceID = deviceID + } +} + +/// Spricht mit einem Flaro-Server. +public actor FlaroClient { + public nonisolated let baseURL: URL + private let identity: FlaroIdentity + private let transport: any FlaroTransport + private let cookieJar: (any FlaroCookieJar)? + private var token: String? + + public init( + baseURL: URL, + identity: FlaroIdentity, + token: String? = nil, + transport: any FlaroTransport = URLSession.shared, + cookieJar: (any FlaroCookieJar)? = HTTPCookieStorage.shared + ) { + self.baseURL = baseURL + self.identity = identity + self.token = token + self.transport = transport + self.cookieJar = cookieJar + if let token { plant(token) } + } + + public func setToken(_ token: String?) { + self.token = token + if let token { plant(token) } else { clearCookie() } + } + + public nonisolated var deviceID: String { identity.deviceID } + public nonisolated var deviceName: String { identity.deviceName } + + // MARK: - Das Cookie + + /// Legt den Zugang als Cookie ab, damit auch fremde Lader ihn mitschicken. + /// + /// **Warum überhaupt.** Flaro nimmt kein Token in der Adresse — es stünde + /// sonst in jedem Zugriffsprotokoll, in jedem Verlauf und in jedem + /// weitergegebenen Link. Jellyfin macht das anders, und `artworkURL` + /// heisst deshalb im Vertrag ausdrücklich „der Token steckt in der + /// Adresse selbst". + /// + /// Bleibt der Weg, den der Browser längst geht: ein Cookie. Der + /// Bilderlader und die Hintergrundübertragung benutzen `URLSession` mit + /// der Standardkonfiguration und schicken es von sich aus mit; dem + /// Systemplayer wird es ausdrücklich mitgegeben (siehe + /// `playbackCookies`), weil AVFoundation den gemeinsamen Speicher nicht + /// von allein liest. + /// + /// **Gesetzt, nicht abgewartet.** Der Server schickt es bei der Anmeldung + /// auch selbst — aber nach einem Neustart hat die App nur das Token aus + /// dem Schlüsselbund und meldet sich gar nicht erst neu an. Dann wäre + /// kein Cookie da, und jedes Plakat bliebe grau. + private nonisolated func plant(_ token: String) { + guard let cookie = Self.cookie(for: baseURL, token: token) else { return } + cookieJar?.store(cookie) + } + + private nonisolated func clearCookie() { + guard let host = baseURL.host() else { return } + cookieJar?.removeCookie(named: Self.cookieName, host: host) + } + + public static let cookieName = "flaro_session" + + nonisolated static func cookie(for baseURL: URL, token: String) -> HTTPCookie? { + guard let host = baseURL.host() else { return nil } + return HTTPCookie(properties: [ + .name: cookieName, + .value: token, + .domain: host, + .path: "/", + // Ein Jahr, wie der Server es auch setzt. Kürzer hiesse: das + // Plakat lädt nicht mehr, obwohl das Token noch gilt. + .expires: Date(timeIntervalSinceNow: 365 * 24 * 3600), + ]) + } + + /// Die Cookies, die der Systemplayer für diese Adresse mitschicken soll. + public nonisolated func cookies(for url: URL, token: String?) -> [HTTPCookie] { + guard let token, let cookie = Self.cookie(for: url, token: token) else { return [] } + return [cookie] + } + + // MARK: - Adressen + + /// Die Adresse eines Endpunkts. + /// + /// **Ohne Token.** Das ist der Unterschied zu Jellyfin und kein Versehen — + /// wer die Adresse abruft, bringt das Cookie mit. + public nonisolated func url(path: String, query: [URLQueryItem] = []) -> URL? { + guard var components = URLComponents( + url: baseURL.appending(path: path), + resolvingAgainstBaseURL: false + ) else { return nil } + components.queryItems = query.isEmpty ? nil : query + return components.url + } + + // MARK: - Anfragen + + public func send( + _ request: FlaroRequest, + as type: Value.Type + ) async throws(MediaBackendError) -> Value { + let data = try await send(request) + do { + return try Self.decoder.decode(Value.self, from: data) + } catch { + throw .decoding(description: String(describing: error)) + } + } + + @discardableResult + public func send(_ request: FlaroRequest) async throws(MediaBackendError) -> Data { + guard var components = URLComponents( + url: baseURL.appending(path: request.path), + resolvingAgainstBaseURL: false + ) else { + throw .transport(description: "Adresse nicht baubar") + } + components.queryItems = request.query.isEmpty ? nil : request.query + + guard let url = components.url else { + throw .transport(description: "Adresse nicht baubar") + } + + var urlRequest = URLRequest(url: url) + urlRequest.httpMethod = request.method.rawValue + urlRequest.timeoutInterval = request.timeout.seconds + // Die App schickt die Kopfzeile. Das Cookie ist der zweite Weg zum + // selben Token, für Lader, die keine Kopfzeilen können. + if let token { urlRequest.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } + urlRequest.setValue("application/json", forHTTPHeaderField: "Accept") + if let body = request.body { + urlRequest.httpBody = body + urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") + } + + let data: Data + let response: URLResponse + do { + (data, response) = try await transport.data(for: urlRequest) + } catch { + throw .transport(description: error.localizedDescription) + } + + guard let http = response as? HTTPURLResponse else { + throw .transport(description: "Keine HTTP-Antwort") + } + + switch http.statusCode { + case 200..<300: return data + case 401, 403: throw .unauthorized + case 404: throw .notFound + default: + // **Flaro begründet seine Absagen.** Ein Problem-Dokument nach + // RFC 9457 trägt einen Satz, den der Server selbst formuliert + // hat — und der ist mehr wert als die Zahl davor. Genau daran + // hing beim Live-TV tagelang alles, weil die Antwort weggeworfen + // wurde. + if let grund = Self.begruendung(in: data) { throw .refused(reason: grund) } + throw .server(status: http.statusCode) + } + } + + /// Der Satz aus einem Problem-Dokument, falls einer dasteht. + private static func begruendung(in data: Data) -> String? { + struct Problem: Decodable { let detail: String?; let title: String? } + guard let problem = try? JSONDecoder().decode(Problem.self, from: data) else { return nil } + let text = problem.detail ?? problem.title + return text?.isEmpty == false ? text : nil + } + + /// Flaro schreibt klein und rechnet in Sekunden. + /// + /// Kein `convertFromSnakeCase`: die Felder heissen schon so, wie sie + /// heissen sollen. ASP.NET Core schreibt sie in `camelCase` hinaus, und + /// Swift liest sie genauso. + /// + /// Nicht `private`, damit die Prüfungen denselben Dekoder benutzen. Mit + /// einem frisch gebauten `JSONDecoder()` scheitern sie — und zwar an + /// genau dem Datumsformat, für das die Strategie unten da ist. + static let decoder: JSONDecoder = { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601WithFraction + return decoder + }() +} + +/// Eine Anfrage an Flaro. +public struct FlaroRequest: Sendable { + public enum Method: String, Sendable { case get = "GET", head = "HEAD", post = "POST", put = "PUT", delete = "DELETE" } + + public let method: Method + public let path: String + public let query: [URLQueryItem] + public let body: Data? + public let timeout: Duration + + public init( + method: Method = .get, + path: String, + query: [URLQueryItem] = [], + body: Data? = nil, + timeout: Duration = .seconds(30) + ) { + self.method = method + self.path = path + self.query = query + self.body = body + self.timeout = timeout + } + + /// Eine Anfrage mit einem Rumpf aus einem beliebigen Wert. + public static func json( + _ method: Method, _ path: String, _ value: some Encodable + ) -> FlaroRequest { + FlaroRequest(method: method, path: path, body: try? JSONEncoder().encode(value)) + } +} + +/// Wie Anfragen tatsächlich verschickt werden. +/// +/// Dieselbe Trennung wie bei Jellyfin: Tests brauchen keinen Server, sondern +/// vorbereitete Antworten. +public protocol FlaroTransport: Sendable { + func data(for request: URLRequest) async throws -> (Data, URLResponse) +} + +extension URLSession: FlaroTransport {} + +/// Wo Cookies landen. +/// +/// Eigenes Protokoll, damit ein Test nachsehen kann, ob eines abgelegt wurde, +/// ohne den Cookie-Speicher des ganzen Systems anzufassen. +public protocol FlaroCookieJar: Sendable { + func store(_ cookie: HTTPCookie) + func removeCookie(named name: String, host: String) +} + +extension HTTPCookieStorage: FlaroCookieJar { + public func store(_ cookie: HTTPCookie) { setCookie(cookie) } + + public func removeCookie(named name: String, host: String) { + cookies? + .filter { $0.name == name && $0.domain.hasSuffix(host) } + .forEach(deleteCookie) + } +} + +extension JSONDecoder.DateDecodingStrategy { + /// ASP.NET Core schreibt Bruchteile von Sekunden mit. + /// + /// `.iso8601` kommt damit nicht zurecht und wirft — ein Datum, das + /// „2026-10-03T19:25:04.5209555+00:00" lautet, ist für den + /// Standarddekoder kein Datum. Der Fehler fällt erst dort auf, wo ein + /// Feld gelesen wird, das die meisten Antworten leer lassen. + static var iso8601WithFraction: Self { + .custom { decoder in + let text = try decoder.singleValueContainer().decode(String.self) + let mitBruch = ISO8601DateFormatter() + mitBruch.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let datum = mitBruch.date(from: text) { return datum } + + let ohne = ISO8601DateFormatter() + ohne.formatOptions = [.withInternetDateTime] + if let datum = ohne.date(from: text) { return datum } + + throw DecodingError.dataCorrupted(.init( + codingPath: decoder.codingPath, + debugDescription: "Kein ISO-8601-Datum: \(text)")) + } + } +} diff --git a/Packages/FlaroKit/Sources/FlaroServerKit/FlaroContracts.swift b/Packages/FlaroKit/Sources/FlaroServerKit/FlaroContracts.swift new file mode 100644 index 0000000..e20d032 --- /dev/null +++ b/Packages/FlaroKit/Sources/FlaroServerKit/FlaroContracts.swift @@ -0,0 +1,175 @@ +import Foundation + +/// Was Flaro über die Leitung schickt. +/// +/// Ein Spiegel der `Contracts.cs` auf der Serverseite, und nur das: hier wird +/// nichts gerechnet und nichts gedeutet. Die Übersetzung in die neutralen +/// Typen steht in `FlaroBackend+Mapping`. +/// +/// **Die Felder heissen wie dort.** Keine `CodingKeys`, keine Umbenennung: +/// ASP.NET Core schreibt `camelCase`, Swift liest `camelCase`, und jede +/// Abweichung wäre eine Stelle, an der beim nächsten Serverfeld jemand +/// nachziehen müsste. +enum Wire { + struct Page: Decodable, Sendable { + let items: [T] + let total: Int + } + + struct SystemInfo: Decodable, Sendable { + let version: String? + let serverName: String? + let needsSetup: Bool + let attributions: [Attribution]? + } + + struct Attribution: Decodable, Sendable { + let text: String + let logoUrl: String + let url: String + } + + struct LoginResponse: Decodable, Sendable { + let token: String + let user: User + } + + struct User: Decodable, Sendable { + let id: String + let userName: String + let isAdministrator: Bool + } + + struct Library: Decodable, Sendable { + let id: String + let name: String + let kind: String + let path: String + } + + struct Stream: Decodable, Sendable { + let index: Int + let kind: String + let codec: String? + let language: String? + let title: String? + let isDefault: Bool + let isForced: Bool + let channels: Int? + let width: Int? + let height: Int? + } + + struct File: Decodable, Sendable { + let id: String + let fileName: String + let sizeBytes: Int64 + let container: String? + let durationSeconds: Double? + let streams: [Stream] + } + + struct ItemState: Decodable, Sendable { + let positionSeconds: Double + let runtimeSeconds: Double? + let watched: Bool + let playCount: Int + let favourite: Bool + let rating: Int? + let dismissed: Bool + let lastPlayedAt: Date? + } + + struct Movie: Decodable, Sendable { + let id: String + let title: String + let year: Int? + let overview: String? + let posterImage: String? + let backdropImage: String? + let files: [File] + let state: ItemState? + } + + struct Series: Decodable, Sendable { + let id: String + let title: String + let year: Int? + let overview: String? + let posterImage: String? + let backdropImage: String? + let episodeCount: Int + let seasons: [Int] + let unwatchedCount: Int + } + + struct Episode: Decodable, Sendable { + let id: String + let season: Int + let episode: Int + let title: String? + let overview: String? + let stillImage: String? + let files: [File] + let state: ItemState? + } + + /// Eine Kachel aus „Weiterschauen" oder „Nächste Folge". + struct Started: Decodable, Sendable { + let id: String + let kind: String + let title: String + let subTitle: String? + let season: Int? + let episode: Int? + let year: Int? + let posterImage: String? + let backdropImage: String? + let state: ItemState + } + + struct CodeResponse: Decodable, Sendable { + let code: String + let handle: String + let expiresAt: Date + } + + /// `state` ist „pending", „approved" oder „expired". + struct CodeState: Decodable, Sendable { + let state: String + let login: LoginResponse? + } + + struct CodeRequest: Encodable, Sendable { + let deviceName: String + let deviceId: String + } + + struct Delivery: Decodable, Sendable { + let method: String + let reasons: [String] + let audioToEncode: [Int] + let subtitlesDropped: [Int] + let packageState: String + let progress: Double + let error: String? + } + + // --- Was hinausgeht -------------------------------------------------- + + struct LoginRequest: Encodable, Sendable { + let userName: String + let password: String + let deviceName: String + let deviceId: String + } + + struct ProgressRequest: Encodable, Sendable { + let positionSeconds: Double + let runtimeSeconds: Double? + } + + struct WatchedRequest: Encodable, Sendable { let watched: Bool } + struct FavouriteRequest: Encodable, Sendable { let favourite: Bool } + struct RatingRequest: Encodable, Sendable { let rating: Int? } +} diff --git a/Packages/FlaroKit/Sources/FlaroServerKit/FlaroIdentity+Current.swift b/Packages/FlaroKit/Sources/FlaroServerKit/FlaroIdentity+Current.swift new file mode 100644 index 0000000..aadac2a --- /dev/null +++ b/Packages/FlaroKit/Sources/FlaroServerKit/FlaroIdentity+Current.swift @@ -0,0 +1,39 @@ +import Foundation +#if canImport(UIKit) +import UIKit +#endif + +extension FlaroIdentity { + private static let storageKey = "flaro.flaroServer.deviceID" + + /// Baut die Kennung für das laufende Gerät und merkt sie sich. + /// + /// **Eine eigene, nicht die von Jellyfin.** Beide Server führen eigene + /// Geräteverzeichnisse; dieselbe Kennung doppelt zu vergeben hiesse, dass + /// ein Widerruf auf dem einen Server im anderen verwirrt — und wer von + /// Jellyfin zu Flaro wechselt, hinterliesse auf dem alten Server ein + /// Gerät, das es dort gar nicht mehr gibt. + /// + /// Gleichbleiben muss sie trotzdem: wechselt sie, gilt die App als neues + /// Gerät, und in der Liste steht bei jedem Start ein Eintrag mehr. + /// + /// `@MainActor`, weil `UIDevice.current` dort beheimatet ist. + @MainActor + public static func current(defaults: UserDefaults = .standard) -> FlaroIdentity { + 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 name = UIDevice.current.name + #else + let name = ProcessInfo.processInfo.hostName + #endif + + return FlaroIdentity(deviceName: name, deviceID: deviceID) + } +} diff --git a/Packages/FlaroKit/Sources/FlaroServerKit/FlaroMapping.swift b/Packages/FlaroKit/Sources/FlaroServerKit/FlaroMapping.swift new file mode 100644 index 0000000..3289d9b --- /dev/null +++ b/Packages/FlaroKit/Sources/FlaroServerKit/FlaroMapping.swift @@ -0,0 +1,179 @@ +import Foundation +import MediaKit + +/// Aus Flaros Antworten werden neutrale Typen. +/// +/// Die einzige Stelle, an der Flaros Schreibweise in die der App übersetzt +/// wird. Alles darüber kennt nur noch `MediaItem`. +extension Wire.Movie { + func neutral() -> MediaItem { + MediaItem( + id: id, + kind: .movie, + title: title, + summary: overview, + year: year, + duration: files.first?.durationSeconds.map { Int($0 * 1000) }, + thumb: posterImage, + art: backdropImage, + playCount: state?.playCount, + resumeMilliseconds: state.map { Int($0.positionSeconds * 1000) }, + lastPlayedAt: state?.lastPlayedAt, + isFavourite: state?.favourite ?? false, + versions: [Wire.fassung(id: id, files: files)] + ) + } +} + +extension Wire.Series { + func neutral() -> MediaItem { + MediaItem( + id: id, + kind: .show, + title: title, + summary: overview, + year: year, + thumb: posterImage, + art: backdropImage, + // **Gesehene Folgen, nicht ungesehene.** Der Server zählt, was + // offen ist — die App zeigt daraus die Plakette, rechnet aber + // intern mit der Gegenzahl. Beides anzubieten hiesse, zwei Wege + // zu derselben Aussage zu haben. + episodeCount: episodeCount, + playedEpisodeCount: episodeCount - unwatchedCount, + childCount: seasons.count + ) + } +} + +extension Wire.Episode { + func neutral(series: Wire.Series? = nil) -> MediaItem { + MediaItem( + id: id, + kind: .episode, + title: title ?? "Folge \(episode)", + summary: overview, + duration: files.first?.durationSeconds.map { Int($0 * 1000) }, + thumb: stillImage, + seriesThumb: series?.posterImage, + seriesArt: series?.backdropImage, + index: episode, + parentIndex: season, + seriesTitle: series?.title, + seriesID: series?.id, + playCount: state?.playCount, + resumeMilliseconds: state.map { Int($0.positionSeconds * 1000) }, + lastPlayedAt: state?.lastPlayedAt, + isFavourite: state?.favourite ?? false, + versions: [Wire.fassung(id: id, files: files)] + ) + } +} + +extension Wire.Started { + /// Die Kachel aus „Weiterschauen" als Titel. + /// + /// **Ohne Fassungen.** Die Reihe nennt keine Dateien, und das ist + /// richtig so: eine Kachel zeigt Plakat, Titel und Balken. Wer sie + /// antippt, holt den Titel einzeln — und bekommt dann alles. + func neutral() -> MediaItem { + let folge = kind == "episode" + return MediaItem( + id: id, + kind: folge ? .episode : .movie, + title: folge ? (subTitle ?? "Folge \(episode ?? 0)") : title, + year: year, + duration: state.runtimeSeconds.map { Int($0 * 1000) }, + thumb: folge ? backdropImage : posterImage, + art: backdropImage, + seriesThumb: folge ? posterImage : nil, + index: episode, + parentIndex: season, + seriesTitle: folge ? title : nil, + playCount: state.playCount, + resumeMilliseconds: Int(state.positionSeconds * 1000), + lastPlayedAt: state.lastPlayedAt, + isFavourite: state.favourite + ) + } +} + +extension Wire.Library { + func neutral() -> MediaLibrary { + MediaLibrary( + id: id, + title: name, + // Flaro kennt genau zwei Arten. Was es nicht gibt, als `.other` + // durchzulassen wäre eine Vorsorge für einen Fall, den der + // Server gar nicht erzeugen kann. + kind: kind.lowercased() == "shows" ? .show : .movie) + } +} + +extension Wire.File { + func neutral(itemID: String) -> MediaFile { + MediaFile( + id: id, + key: "/api/v1/files/\(id)/download", + itemID: itemID, + container: container, + size: sizeBytes, + duration: durationSeconds.map { Int($0 * 1000) }, + file: fileName, + streams: streams.map { $0.neutral() }) + } +} + +extension Wire.Stream { + func neutral() -> MediaStream { + let art: MediaStream.Kind = switch kind.lowercased() { + case "video": .video + case "subtitle": .subtitle + default: .audio + } + + return MediaStream( + // **Die Spurnummer ist die Kennung.** `MediaStream.index` liest + // sie aus `id` zurück, und daran hängt die Spurwahl beim + // Umpacken. Eine eigene Kennung wäre hier eine, die niemand + // zurückrechnen kann. + id: String(index), + kind: art, + codec: codec, + language: language, + languageCode: language, + displayTitle: title ?? language, + channels: channels, + isSelected: isDefault, + isForced: isForced, + title: title, + isDefault: isDefault) + } +} + +extension Wire { + /// Eine Fassung aus den Dateien eines Titels. + /// + /// **Alle Dateien in einer Fassung**, nicht eine Fassung je Datei: Flaro + /// führt mehrere Dateien zu einem Titel als Fassungen desselben Films — + /// 4K und 1080p nebeneinander — und `MediaVersion.files` ist genau dafür + /// da. Die Eckdaten kommen aus der ersten Datei; welche gespielt wird, + /// entscheidet die App. + static func fassung(id: String, files: [Wire.File]) -> MediaVersion { + let erste = files.first + let bild = erste?.streams.first { $0.kind.lowercased() == "video" } + let ton = erste?.streams.first { $0.kind.lowercased() == "audio" } + + return MediaVersion( + id: erste?.id ?? id, + container: erste?.container, + videoCodec: bild?.codec, + audioCodec: ton?.codec, + videoResolution: bild?.height.map(String.init), + width: bild?.width, + height: bild?.height, + audioChannels: ton?.channels, + duration: erste?.durationSeconds.map { Int($0 * 1000) }, + files: files.map { $0.neutral(itemID: id) }) + } +} diff --git a/Packages/FlaroKit/Sources/FlaroServerKit/FlaroSignIn.swift b/Packages/FlaroKit/Sources/FlaroServerKit/FlaroSignIn.swift new file mode 100644 index 0000000..8faf06b --- /dev/null +++ b/Packages/FlaroKit/Sources/FlaroServerKit/FlaroSignIn.swift @@ -0,0 +1,195 @@ +import Foundation +import MediaKit + +/// Die Anmeldung an einem Flaro-Server. +/// +/// Getrennt vom Backend, weil sie vorher stattfindet: hier gibt es noch +/// keinen Zugang und nichts, was `MediaBackend` versprechen könnte. +public enum FlaroSignIn { + /// Was der Server über sich sagt, bevor sich jemand anmeldet. + public struct Greeting: Sendable, Equatable { + public let serverName: String + public let version: String? + /// Ob der Server überhaupt schon eingerichtet ist. + /// + /// **Ein eigener Fall, keine Fehlermeldung.** Wer einen frischen + /// Server anspricht, hat kein Konto — und „Benutzername oder + /// Passwort stimmt nicht" wäre dann die unbrauchbarste aller + /// Antworten. + public let needsSetup: Bool + /// Der Pflichthinweis auf die Metadatenquelle. + public let attributions: [MetadataAttribution] + } + + public struct MetadataAttribution: Sendable, Equatable { + public let text: String + public let logoURL: URL? + public let url: URL? + } + + /// Fragt einen Server, wer er ist — ohne Anmeldung. + /// + /// Das ist zugleich die Prüfung, ob unter der eingegebenen Adresse + /// überhaupt ein Flaro steht. Ein Jellyfin antwortet hier mit 404, ein + /// Router mit seiner Anmeldeseite, und beides fällt beim Lesen auf. + public static func greet( + baseURL: URL, + identity: FlaroIdentity, + transport: any FlaroTransport = URLSession.shared + ) async throws(MediaBackendError) -> Greeting { + let client = FlaroClient( + baseURL: baseURL, identity: identity, transport: transport, cookieJar: nil) + let auskunft = try await client.send( + FlaroRequest(path: "/api/v1/system/info"), as: Wire.SystemInfo.self) + + return Greeting( + serverName: auskunft.serverName ?? baseURL.host() ?? "Flaro", + version: auskunft.version, + needsSetup: auskunft.needsSetup, + attributions: (auskunft.attributions ?? []).map { + MetadataAttribution( + text: $0.text, logoURL: URL(string: $0.logoUrl), url: URL(string: $0.url)) + }) + } + + /// Meldet an und liefert den Zugang samt fertigem Backend. + public static func signIn( + baseURL: URL, + userName: String, + password: String, + identity: FlaroIdentity, + transport: any FlaroTransport = URLSession.shared, + cookieJar: (any FlaroCookieJar)? = HTTPCookieStorage.shared + ) async throws(MediaBackendError) -> (token: String, account: FlaroAccount, client: FlaroClient) { + let gruss = try await greet(baseURL: baseURL, identity: identity, transport: transport) + + let client = FlaroClient( + baseURL: baseURL, identity: identity, transport: transport, cookieJar: cookieJar) + + let antwort = try await client.send( + .json(.post, "/api/v1/auth/login", Wire.LoginRequest( + userName: userName, + password: password, + deviceName: identity.deviceName, + deviceId: identity.deviceID)), + as: Wire.LoginResponse.self) + + // **Erst jetzt das Token setzen.** Vorher hätte die Anmeldung ihre + // eigene Kopfzeile mitgeschickt — harmlos, aber auch das Cookie wäre + // dann schon gesetzt, bevor feststeht, dass es gilt. + await client.setToken(antwort.token) + + return (antwort.token, FlaroAccount( + baseURL: baseURL, + serverName: gruss.serverName, + userID: antwort.user.id, + userName: antwort.user.userName, + isAdministrator: antwort.user.isAdministrator), client) + } + + /// Nimmt ein gespeichertes Token wieder auf. + /// + /// **Ohne Passwort und ohne neue Anmeldung.** Der Server führt das Gerät + /// über das Token; ein zweiter Anmeldevorgang legte ein zweites Gerät an, + /// und in der Liste stünde dann bei jedem Start eines mehr. + public static func restore( + baseURL: URL, + token: String, + identity: FlaroIdentity, + transport: any FlaroTransport = URLSession.shared, + cookieJar: (any FlaroCookieJar)? = HTTPCookieStorage.shared + ) async throws(MediaBackendError) -> (account: FlaroAccount, client: FlaroClient) { + let client = FlaroClient( + baseURL: baseURL, identity: identity, token: token, + transport: transport, cookieJar: cookieJar) + + let wer = try await client.send(FlaroRequest(path: "/api/v1/auth/me"), as: Wire.User.self) + let gruss = try? await greet(baseURL: baseURL, identity: identity, transport: transport) + + return (FlaroAccount( + baseURL: baseURL, + serverName: gruss?.serverName ?? baseURL.host() ?? "Flaro", + userID: wer.id, + userName: wer.userName, + isAdministrator: wer.isAdministrator), client) + } + + // MARK: - Der Weg über einen Code + + /// Ein angeforderter Code samt Abholschein. + public struct PendingCode: Sendable, Equatable { + /// Die sechs Ziffern, die auf dem Fernseher stehen. + public let code: String + /// **Der Abholschein, und er ist das eigentliche Geheimnis.** + /// + /// Abgeholt wird das Token hiermit, nicht mit dem Code. Täte es der + /// Code allein, liessen sich eine Million Möglichkeiten durchprobieren + /// — und die sechs Ziffern stehen zum Mitlesen auf einem Bildschirm + /// im Wohnzimmer. + public let handle: String + public let expiresAt: Date + } + + public enum CodeState: Sendable, Equatable { + case pending + case approved(token: String, account: FlaroAccount) + case expired + } + + /// Fordert einen Code an. Der Fernseher zeigt ihn, das Telefon bestätigt. + public static func requestCode( + baseURL: URL, + identity: FlaroIdentity, + transport: any FlaroTransport = URLSession.shared + ) async throws(MediaBackendError) -> PendingCode { + let client = FlaroClient( + baseURL: baseURL, identity: identity, transport: transport, cookieJar: nil) + let antwort = try await client.send( + .json(.post, "/api/v1/auth/code", Wire.CodeRequest( + deviceName: identity.deviceName, deviceId: identity.deviceID)), + as: Wire.CodeResponse.self) + + return PendingCode( + code: antwort.code, handle: antwort.handle, expiresAt: antwort.expiresAt) + } + + /// Fragt nach, ob jemand den Code inzwischen bestätigt hat. + public static func pollCode( + baseURL: URL, + pending: PendingCode, + identity: FlaroIdentity, + transport: any FlaroTransport = URLSession.shared, + cookieJar: (any FlaroCookieJar)? = HTTPCookieStorage.shared + ) async throws(MediaBackendError) -> (state: CodeState, client: FlaroClient?) { + let client = FlaroClient( + baseURL: baseURL, identity: identity, transport: transport, cookieJar: cookieJar) + + // **Mit dem Abholschein, nicht mit dem Code.** Siehe `PendingCode`. + let zustand = try await client.send( + FlaroRequest(path: "/api/v1/auth/code/\(pending.handle)"), as: Wire.CodeState.self) + + switch zustand.state { + case "approved": + guard let anmeldung = zustand.login else { return (.expired, nil) } + await client.setToken(anmeldung.token) + let gruss = try? await greet( + baseURL: baseURL, identity: identity, transport: transport) + return (.approved(token: anmeldung.token, account: FlaroAccount( + baseURL: baseURL, + serverName: gruss?.serverName ?? baseURL.host() ?? "Flaro", + userID: anmeldung.user.id, + userName: anmeldung.user.userName, + isAdministrator: anmeldung.user.isAdministrator)), client) + case "expired": + return (.expired, nil) + default: + return (.pending, nil) + } + } + + /// Meldet dieses Gerät ab und widerruft sein Token. + public static func signOut(client: FlaroClient) async { + _ = try? await client.send(FlaroRequest(method: .post, path: "/api/v1/auth/logout")) + await client.setToken(nil) + } +} diff --git a/Packages/FlaroKit/Sources/FlaroServerKit/Localizable.xcstrings b/Packages/FlaroKit/Sources/FlaroServerKit/Localizable.xcstrings new file mode 100644 index 0000000..1e275e2 --- /dev/null +++ b/Packages/FlaroKit/Sources/FlaroServerKit/Localizable.xcstrings @@ -0,0 +1,91 @@ +{ + "sourceLanguage": "en", + "strings": { + "flaro.remux.tooLong": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Das Umpacken dauert ungewöhnlich lange. Sieh im Serverprotokoll nach." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Remuxing is taking unusually long. Check the server log." + } + } + } + }, + "flaro.row.continue": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Weiterschauen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Continue Watching" + } + } + } + }, + "flaro.row.movies": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Filme" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Movies" + } + } + } + }, + "flaro.row.nextUp": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Nächste Folge" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Next Up" + } + } + } + }, + "flaro.row.series": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Serien" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Shows" + } + } + } + } + }, + "version": "1.0" +} diff --git a/Packages/FlaroKit/Sources/JellyfinKit/JellyfinAddress.swift b/Packages/FlaroKit/Sources/JellyfinKit/JellyfinAddress.swift index 8ba5939..4dc3be7 100644 --- a/Packages/FlaroKit/Sources/JellyfinKit/JellyfinAddress.swift +++ b/Packages/FlaroKit/Sources/JellyfinKit/JellyfinAddress.swift @@ -1,65 +1,17 @@ import Foundation +import MediaKit -/// Macht aus dem, was jemand eintippt, eine Adresse, die antwortet. +/// Die Adressraterei für Jellyfin. /// -/// 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. +/// Der Inhalt steht in `ServerAddress` — beide Server haben dasselbe Problem, +/// und es unterscheidet sie nur der Standardport. Dieser Typ bleibt stehen, +/// weil Aufrufer und Prüfungen ihn beim Namen nennen und weil „welcher Port +/// gilt hier" eine Auskunft über Jellyfin ist, nicht über Adressen. 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.. [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 mitPort = withDefaultPort(text, port: defaultPort) + let forms = isLocalLooking + ? ["http://\(mitPort)", "http://\(text)", "https://\(text)"] + : ["https://\(text)", "http://\(text)", "http://\(mitPort)"] + return forms.compactMap(URL.init(string:)) + } + + /// Hängt den Standardport an — vor einen etwaigen Pfad, nicht dahinter. + private static func withDefaultPort(_ text: String, port: Int) -> String { + guard let slash = text.firstIndex(of: "/") else { return "\(text):\(port)" } + return "\(text[text.startIndex.. FlaroBackend { + FlaroBackend( + client: FlaroClient( + baseURL: Proben.adresse, identity: Proben.kennung, token: "geheim-123", + transport: transport, cookieJar: keksglas), + account: FlaroAccount( + baseURL: Proben.adresse, serverName: "Wohnzimmer", + userID: "u1", userName: "guido", isAdministrator: true)) + } + + /// **Kein Token in der Adresse — das ist der Unterschied zu Jellyfin.** + /// + /// Dort steckt der Zugang als `?ApiKey=` in jeder Bildadresse, weil eine + /// Bildansicht keine Kopfzeilen schickt. Er steht damit in jedem + /// Zugriffsprotokoll, in jedem Verlauf und in jedem weitergegebenen Link. + /// Flaro geht über ein Cookie. + @Test("Bildadressen tragen keinen Zugang") + func bildAdresseOhneToken() { + let url = backend(Attrappe(rumpf: "{}")).rawImageURL(path: "abc123") + + #expect(url?.path == "/api/v1/images/abc123") + #expect(url?.query == nil) + #expect(url?.absoluteString.contains("geheim") == false) + } + + @Test("Ein Zugang wird als Cookie abgelegt") + func tokenWirdZuKeks() async { + let glas = Keksglas() + _ = FlaroClient( + baseURL: Proben.adresse, identity: Proben.kennung, token: "geheim-123", + transport: Attrappe(rumpf: "{}"), cookieJar: glas) + + let keks = glas.kekse.first + #expect(keks?.name == "flaro_session") + #expect(keks?.value == "geheim-123") + #expect(keks?.path == "/") + #expect(keks?.domain.hasSuffix("nas") == true) + } + + /// **Nach einem Neustart meldet sich niemand neu an.** + /// + /// Die App hat dann nur das Token aus dem Schlüsselbund. Käme das Cookie + /// allein aus der Antwort des Servers, wäre keines da — und jedes Plakat + /// bliebe grau, obwohl der Zugang gilt. + @Test("Auch ein wiederaufgenommenes Token legt das Cookie an") + func keksAuchOhneAnmeldung() async throws { + let glas = Keksglas() + let transport = Attrappe([ + ("/api/v1/auth/me", 200, #"{"id":"u1","userName":"guido","isAdministrator":false}"#), + ("/api/v1/system/info", 200, Proben.info), + ]) + + _ = try await FlaroSignIn.restore( + baseURL: Proben.adresse, token: "geheim-123", + identity: Proben.kennung, transport: transport, cookieJar: glas) + + #expect(glas.kekse.contains { $0.name == "flaro_session" && $0.value == "geheim-123" }) + } + + @Test("Die Download-Adresse zeigt auf die Datei, nicht auf den Titel") + func downloadAdresse() { + let url = backend(Attrappe(rumpf: "{}")).downloadURL( + for: MediaFile(id: "datei-1", itemID: "film-1")) + + #expect(url?.path == "/api/v1/files/datei-1/download") + } + + /// **Der Vertrag fragt nach einer Tonspur, Flaro braucht keine.** + /// + /// `remuxDownloadURL` gibt es nur, weil Jellyfins Transcoder genau eine + /// Tonspur je Sitzung abbildet. Hier wäre eine Adresse mit Spurnummer + /// eine Lüge über das, was herauskommt — also gibt es keine, und die + /// Oberfläche erkennt an der Fähigkeit, dass sie nicht fragen muss. + @Test("Kein Umpacken mit Spurwahl — stattdessen die Fähigkeit") + func alleSpuren() { + let b = backend(Attrappe(rumpf: "{}")) + + #expect(b.capabilities.contains(.allTracksDownload)) + #expect(b.remuxDownloadURL( + itemID: "film-1", versionIndex: 0, + audioStreamIndex: 1, subtitleStreamIndex: nil) == nil) + } + + /// Zwei Server dürfen „Medien" heissen; derselbe darf umbenannt werden. + @Test("Die Kennung hängt an der Adresse, nicht am Namen") + func kennung() { + #expect(backend(Attrappe(rumpf: "{}")).identity == "flaro:http://nas:8080") + } + + @Test("Anmelden schickt Gerätename und Kennung mit") + func anmeldung() async throws { + let transport = Attrappe([ + ("/api/v1/system/info", 200, Proben.info), + ("/api/v1/auth/login", 200, Proben.anmeldung), + ]) + + let (token, konto, _) = try await FlaroSignIn.signIn( + baseURL: Proben.adresse, userName: "guido", password: "geheim", + identity: Proben.kennung, transport: transport, cookieJar: Keksglas()) + + let rumpf = try #require(transport.rumpf(von: "/api/v1/auth/login")) + + #expect(token == "geheim-123") + #expect(konto.userName == "guido") + #expect(konto.isAdministrator) + #expect(konto.serverName == "Wohnzimmer") + #expect(rumpf["deviceName"] as? String == "Prüfgerät") + #expect(rumpf["deviceId"] as? String == "geraet-1") + } + + /// **Ein frischer Server ist kein falsches Passwort.** + @Test("Ein nicht eingerichteter Server sagt das") + func nochNichtEingerichtet() async throws { + let transport = Attrappe(rumpf: #"{"version":"0.1.0","serverName":null,"needsSetup":true}"#) + + let gruss = try await FlaroSignIn.greet( + baseURL: Proben.adresse, identity: Proben.kennung, transport: transport) + + #expect(gruss.needsSetup) + } + + /// **Flaro begründet seine Absagen, und der Satz zählt mehr als die Zahl.** + @Test("Eine Absage mit Begründung kommt als Begründung an") + func begruendeteAbsage() async { + let transport = Attrappe(rumpf: #"{"detail":"Für diese Bibliothek läuft bereits ein Scan."}"#, status: 409) + let b = backend(transport) + + await #expect(throws: MediaBackendError.refused( + reason: "Für diese Bibliothek läuft bereits ein Scan.")) { + try await b.refreshLibrary(MediaLibrary(id: "b1", title: "Filme", kind: .movie)) + } + } + + @Test("Ein abgelaufener Zugang führt zur Abmeldung, nicht zu einer Meldung") + func abgelaufen() async { + let b = backend(Attrappe(rumpf: "", status: 401)) + + await #expect(throws: MediaBackendError.unauthorized) { + _ = try await b.libraries() + } + } +} diff --git a/Packages/FlaroKit/Tests/FlaroServerKitTests/Hilfsmittel.swift b/Packages/FlaroKit/Tests/FlaroServerKitTests/Hilfsmittel.swift new file mode 100644 index 0000000..82f1fa2 --- /dev/null +++ b/Packages/FlaroKit/Tests/FlaroServerKitTests/Hilfsmittel.swift @@ -0,0 +1,104 @@ +import Foundation +@testable import FlaroServerKit + +/// Ein Transport, der vorbereitete Antworten gibt und mitschreibt. +/// +/// Dieselbe Trennung wie bei Jellyfin: Prüfungen brauchen keinen Server, +/// sondern eine Antwort und die Frage, die gestellt wurde. +final class Attrappe: FlaroTransport, @unchecked Sendable { + /// Antworten je Pfad. Der erste Treffer gewinnt — so kann eine Prüfung + /// `/api/v1/movies` und `/api/v1/movies/{id}` getrennt bedienen. + private let antworten: [(pfad: String, status: Int, rumpf: String)] + private let sperre = NSLock() + private var protokoll: [URLRequest] = [] + + init(_ antworten: [(pfad: String, status: Int, rumpf: String)]) { + self.antworten = antworten + } + + convenience init(rumpf: String, status: Int = 200) { + self.init([("", status, rumpf)]) + } + + var anfragen: [URLRequest] { sperre.withLock { protokoll } } + var letzte: URLRequest? { anfragen.last } + + func anfrage(auf pfad: String) -> URLRequest? { + anfragen.last { $0.url?.path == pfad } + } + + func rumpf(von pfad: String) -> [String: Any]? { + guard let daten = anfrage(auf: pfad)?.httpBody else { return nil } + return try? JSONSerialization.jsonObject(with: daten) as? [String: Any] + } + + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + sperre.withLock { protokoll.append(request) } + let pfad = request.url?.path ?? "" + let treffer = antworten.first { $0.pfad.isEmpty || pfad.hasSuffix($0.pfad) || pfad == $0.pfad } + ?? ("", 404, "") + + return ( + Data(treffer.rumpf.utf8), + HTTPURLResponse(url: request.url!, statusCode: treffer.status, + httpVersion: nil, headerFields: nil)! + ) + } +} + +/// Ein Keksglas, das nur mitschreibt. +/// +/// Der Speicher des Systems anzufassen hiesse, dass eine Prüfung die nächste +/// beeinflusst — und auf einem Entwicklerrechner auch den Browser. +final class Keksglas: FlaroCookieJar, @unchecked Sendable { + private let sperre = NSLock() + private var inhalt: [HTTPCookie] = [] + + var kekse: [HTTPCookie] { sperre.withLock { inhalt } } + + func store(_ cookie: HTTPCookie) { sperre.withLock { inhalt.append(cookie) } } + + func removeCookie(named name: String, host: String) { + sperre.withLock { inhalt.removeAll { $0.name == name } } + } +} + +enum Proben { + static let adresse = URL(string: "http://nas:8080")! + static let kennung = FlaroIdentity(deviceName: "Prüfgerät", deviceID: "geraet-1") + + /// Ein Film mit zwei Tonspuren — der Fall, um den es geht. + static let film = """ + { + "id": "film-1", "title": "Der Untergang", "year": 2004, + "overview": "Berlin, April 1945.", + "posterImage": "aaa", "backdropImage": "bbb", + "files": [{ + "id": "datei-1", "fileName": "Der Untergang (2004).mkv", + "sizeBytes": 9000000000, "container": "matroska", "durationSeconds": 9360.0, + "streams": [ + {"index":0,"kind":"Video","codec":"h264","language":null,"title":null, + "isDefault":true,"isForced":false,"channels":null,"width":1920,"height":1080}, + {"index":1,"kind":"Audio","codec":"ac3","language":"deu","title":"Deutsch", + "isDefault":true,"isForced":false,"channels":6,"width":null,"height":null}, + {"index":2,"kind":"Audio","codec":"ac3","language":"deu","title":"Audiodeskription", + "isDefault":false,"isForced":false,"channels":2,"width":null,"height":null}, + {"index":3,"kind":"Subtitle","codec":"subrip","language":"deu","title":"Deutsch", + "isDefault":false,"isForced":false,"channels":null,"width":null,"height":null} + ] + }], + "state": {"positionSeconds":300.0,"runtimeSeconds":9360.0,"watched":false, + "playCount":0,"favourite":false,"rating":null,"dismissed":false, + "lastPlayedAt":"2026-10-03T19:25:04.5209555+00:00"} + } + """ + + static let info = """ + {"version":"0.1.0","serverName":"Wohnzimmer","needsSetup":false, + "attributions":[{"text":"This product uses TMDB","logoUrl":"https://x/t.svg","url":"https://x"}]} + """ + + static let anmeldung = """ + {"token":"geheim-123","user":{"id":"u1","userName":"guido","isAdministrator":true}} + """ +} diff --git a/Packages/FlaroKit/Tests/FlaroServerKitTests/UebersetzungTests.swift b/Packages/FlaroKit/Tests/FlaroServerKitTests/UebersetzungTests.swift new file mode 100644 index 0000000..43960a5 --- /dev/null +++ b/Packages/FlaroKit/Tests/FlaroServerKitTests/UebersetzungTests.swift @@ -0,0 +1,262 @@ +import Foundation +import Testing +import MediaKit +@testable import FlaroServerKit + +/// Aus Flaros Antworten werden neutrale Typen — ohne Verlust. +@Suite("Flaro: Übersetzung") +struct UebersetzungTests { + private func backend(_ transport: any FlaroTransport) -> FlaroBackend { + FlaroBackend( + client: FlaroClient( + baseURL: Proben.adresse, identity: Proben.kennung, token: "t", + transport: transport, cookieJar: nil), + account: FlaroAccount( + baseURL: Proben.adresse, serverName: "Wohnzimmer", + userID: "u1", userName: "guido", isAdministrator: false)) + } + + /// **Beide Tonspuren kommen an, mit Namen und Nummer.** + /// + /// Das ist der ganze Anlass: eine Datei mit „Deutsch" und + /// „Audiodeskription", und die App muss beide sehen, um nicht blind die + /// falsche zu nehmen. + @Test("Ein Film behält alle Spuren") + func filmMitZweiTonspuren() async throws { + let film = try #require(try? FlaroClient.decoder.decode(Wire.Movie.self, from: Data(Proben.film.utf8))) + let titel = film.neutral() + let datei = try #require(titel.versions.first?.files.first) + + #expect(titel.title == "Der Untergang") + #expect(titel.year == 2004) + #expect(datei.audioStreams.count == 2) + #expect(datei.audioStreams.map(\.title) == ["Deutsch", "Audiodeskription"]) + // Die Nummer muss zurückrechenbar sein — daran hängt die Spurwahl. + #expect(datei.audioStreams.map(\.index) == [1, 2]) + #expect(datei.subtitleStreams.count == 1) + #expect(datei.videoStream?.codec == "h264") + } + + /// Fünf Minuten von zwei Stunden sechsunddreissig — die Stelle, die + /// Jellyfin wegwirft. + @Test("Der Stand kommt als Fortschritt an") + func standWirdFortschritt() throws { + let film = try #require(try? FlaroClient.decoder.decode(Wire.Movie.self, from: Data(Proben.film.utf8))) + let titel = film.neutral() + + #expect(titel.resumeMilliseconds == 300_000) + #expect(titel.duration == 9_360_000) + #expect(titel.isWatched == false) + let anteil = try #require(titel.watchProgress) + #expect(abs(anteil - 300.0 / 9360.0) < 0.0001) + } + + /// ASP.NET Core schreibt Bruchteile von Sekunden mit. + /// + /// **Der Standarddekoder scheitert daran**, und zwar stumm bis zur + /// Stelle, an der jemand das Datum liest. Hier stehen beide nebeneinander, + /// damit sichtbar bleibt, dass die eigene Strategie keine Verzierung ist. + @Test("Ein Datum mit Sekundenbruchteilen wird gelesen") + func datumMitBruch() async throws { + let roh = Data(Proben.film.utf8) + + #expect((try? JSONDecoder().decode(Wire.Movie.self, from: roh)) == nil, + "2026-10-03T19:25:04.5209555+00:00 ist für .iso8601 kein Datum") + #expect((try? FlaroClient.decoder.decode(Wire.Movie.self, from: roh)) != nil) + + let transport = Attrappe(rumpf: #"{"items":[\#(Proben.film)],"total":1}"#) + let seite = try await backend(transport).items( + inLibrary: MediaLibrary(id: "b1", title: "Filme", kind: .movie), + query: MediaQuery(), offset: 0, limit: 50) + + #expect(seite.items.first?.lastPlayedAt != nil) + } + + /// Der Server zählt, was offen ist; die App rechnet mit der Gegenzahl. + @Test("Offene Folgen werden zu gesehenen") + func serieZaehlt() throws { + let roh = """ + {"id":"s1","title":"Dark","year":2017,"overview":null, + "posterImage":"p","backdropImage":"b", + "episodeCount":10,"seasons":[1,2],"unwatchedCount":3} + """ + let serie = try FlaroClient.decoder.decode(Wire.Series.self, from: Data(roh.utf8)) + let titel = serie.neutral() + + #expect(titel.kind == .show) + #expect(titel.episodeCount == 10) + #expect(titel.playedEpisodeCount == 7) + #expect(titel.childCount == 2) + } + + /// **Oben die Serie, darunter die Folge.** Eine Kachel, die nur + /// „Lügen" sagt, hilft beim Wiedererkennen nicht. + @Test("Eine Folge aus Weiterschauen trägt ihre Serie mit") + func weiterschauenFolge() throws { + let roh = """ + {"id":"f2","kind":"episode","title":"Dark","subTitle":"Lügen", + "season":1,"episode":2,"year":null,"posterImage":"p","backdropImage":"still", + "state":{"positionSeconds":400.0,"runtimeSeconds":2700.0,"watched":false, + "playCount":0,"favourite":false,"rating":null,"dismissed":false, + "lastPlayedAt":null}} + """ + let eintrag = try FlaroClient.decoder.decode(Wire.Started.self, from: Data(roh.utf8)) + let titel = eintrag.neutral() + + #expect(titel.kind == .episode) + #expect(titel.title == "Lügen") + #expect(titel.seriesTitle == "Dark") + #expect(titel.parentIndex == 1) + #expect(titel.index == 2) + #expect(titel.resumeMilliseconds == 400_000) + } + + @Test("Mediatheken: Shows werden Serien, alles andere Filme") + func mediatheken() async throws { + let transport = Attrappe(rumpf: """ + [{"id":"b1","name":"Filme","kind":"Movies","path":"/media/Filme"}, + {"id":"b2","name":"Serien","kind":"Shows","path":"/media/Serien"}] + """) + + let liste = try await backend(transport).libraries() + + #expect(liste.map(\.kind) == [.movie, .show]) + #expect(liste.map(\.title) == ["Filme", "Serien"]) + } + + /// Nach der letzten Folge kommt keine — 204, kein Fehler. + @Test("Hinter der letzten Folge steht nichts") + func keineNaechsteFolge() async throws { + let transport = Attrappe(rumpf: "", status: 204) + let folge = MediaItem(id: "f9", kind: .episode, title: "Letzte") + + let naechste = try await backend(transport).nextEpisode(after: folge) + #expect(naechste == nil) + } + + @Test("Eine Fortschrittsmeldung geht in Sekunden hinaus") + func fortschrittMelden() async { + let transport = Attrappe(rumpf: "{}") + + await backend(transport).reportProgress(PlaybackReport( + itemID: "film-1", state: .playing, + time: .seconds(300), duration: .seconds(9360))) + + let rumpf = transport.rumpf(von: "/api/v1/items/film-1/progress") + #expect(transport.letzte?.httpMethod == "POST") + #expect(rumpf?["positionSeconds"] as? Double == 300) + #expect(rumpf?["runtimeSeconds"] as? Double == 9360) + } + + /// **Flaro hat einen eigenen Weg dafür — nur deshalb bleibt die Stelle.** + /// Bei Jellyfin wird hier das Benutzerdatenblatt überschrieben. + @Test("Weglegen ist nicht dasselbe wie gesehen") + func weglegen() async throws { + let transport = Attrappe(rumpf: "{}") + + try await backend(transport).removeFromContinueWatching( + MediaItem(id: "film-1", title: "Der Untergang")) + + #expect(transport.letzte?.url?.path == "/api/v1/items/film-1/dismiss") + #expect(transport.letzte?.httpMethod == "POST") + } + + @Test("Gesehen und ungesehen gehen an dieselbe Stelle, mit anderem Wert") + func gesehenMelden() async throws { + let transport = Attrappe(rumpf: "{}") + let b = backend(transport) + + try await b.markWatched(id: "film-1") + #expect(transport.rumpf(von: "/api/v1/items/film-1/watched")?["watched"] as? Bool == true) + + try await b.markUnwatched(id: "film-1") + #expect(transport.rumpf(von: "/api/v1/items/film-1/watched")?["watched"] as? Bool == false) + #expect(transport.letzte?.httpMethod == "PUT") + } + + // --- Abspielen ----------------------------------------------------- + + /// **Direkt spielbar: sofort los.** + @Test("Eine MP4 geht ohne Umweg hinaus") + func direkteWiedergabe() async throws { + let transport = Attrappe(rumpf: """ + {"method":"directFile","reasons":[],"audioToEncode":[],"subtitlesDropped":[], + "packageState":"none","progress":0.0,"error":null} + """) + + let ziel = try await backend(transport).preparePlayback(PlaybackIntent( + itemID: "film-1", versionIndex: 0, file: MediaFile(id: "datei-1"))) + + #expect(ziel.url.path == "/api/v1/files/datei-1/download") + // Flaro legt für eine Wiedergabe nichts an, das aufzuräumen wäre. + #expect(ziel.sessionID == nil) + } + + /// **Der Fehler, den erst der Simulator zeigte.** + /// + /// Muss der Server umpacken, antwortet er auf den Download mit 202 und + /// fängt an. AVPlayer bekommt darauf kein Video, sondern eine leere + /// Antwort — und wartet. Unbegrenzt, ohne Fehler, mit einem Spinner: + /// „Stelle wird wiederhergestellt", 0:00, für immer. + @Test("Ein Umpacken wird abgewartet, nicht überholt") + func wartetAufsUmpacken() async throws { + let transport = Fortschrittsattrappe() + + let ziel = try await backend(transport).preparePlayback(PlaybackIntent( + itemID: "film-1", versionIndex: 0, file: MediaFile(id: "datei-1"))) + + #expect(ziel.url.path == "/api/v1/files/datei-1/download") + #expect(transport.abfragen >= 2, "es wurde nachgefragt, nicht einmal geschaut") + } + + /// Eine Absage des Servers ist eine Absage, kein Weiterwarten. + @Test("Ein gescheitertes Umpacken kommt als Begründung an") + func gescheitertesUmpacken() async { + let transport = Attrappe(rumpf: """ + {"method":"remux","reasons":["matroska"],"audioToEncode":[],"subtitlesDropped":[], + "packageState":"failed","progress":0.0,"error":"ffmpeg ist abgestürzt."} + """) + let b = backend(transport) + + await #expect(throws: MediaBackendError.refused(reason: "ffmpeg ist abgestürzt.")) { + _ = try await b.preparePlayback(PlaybackIntent( + itemID: "film-1", versionIndex: 0, file: MediaFile(id: "datei-1"))) + } + } + + /// Flaro kennt noch keine Schlagwörter — eine leere Liste, kein Fehler. + @Test("Ohne Schlagwörter bleibt der Filter leer statt kaputt") + func keineSchlagwoerter() async throws { + let optionen = try await backend(Attrappe(rumpf: "{}")).filterOptions( + inLibrary: MediaLibrary(id: "b1", title: "Filme", kind: .movie), field: .genre) + + #expect(optionen.isEmpty) + } +} + + +/// Antwortet beim ersten Mal „läuft noch", danach „fertig". +/// +/// Damit prüfbar ist, dass gewartet wird — eine Attrappe mit immer derselben +/// Antwort könnte das nicht von „einmal geschaut und losgelaufen" trennen. +final class Fortschrittsattrappe: FlaroTransport, @unchecked Sendable { + private let sperre = NSLock() + private var runde = 0 + + var abfragen: Int { sperre.withLock { runde } } + + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + let pfad = request.url?.path ?? "" + let fertig: Bool = sperre.withLock { + if pfad.hasSuffix("/delivery") { runde += 1 } + return runde > 1 + } + + let rumpf = """ + {"method":"remux","reasons":["matroska"],"audioToEncode":[],"subtitlesDropped":[], + "packageState":"\(fertig ? "ready" : "running")","progress":\(fertig ? 1.0 : 0.4),"error":null} + """ + return (Data(rumpf.utf8), + HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!) + } +} diff --git a/project.yml b/project.yml index 9cd12b4..6f0b214 100644 --- a/project.yml +++ b/project.yml @@ -57,6 +57,8 @@ targets: # zweiter dazu, steht er daneben. - package: FlaroKit product: JellyfinKit + - package: FlaroKit + product: FlaroServerKit - package: FlaroKit product: FlaroDesign - package: FlaroKit @@ -188,6 +190,8 @@ targets: product: MediaKit - package: FlaroKit product: JellyfinKit + - package: FlaroKit + product: FlaroServerKit - package: FlaroKit product: FlaroDesign - package: FlaroKit