From fb418c842dbc6dd93733f379ccfe152a672bcffa Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Sun, 13 Sep 2026 14:00:02 +0200 Subject: [PATCH] Wiedergabe: der Player spricht nicht mehr mit Plex, sondern mit dem Vertrag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bis hierher konnte man sich bei Jellyfin anmelden und stöbern, aber nichts abspielen. Der Player war die letzte große Stelle, die Plex unmittelbar kannte. Drei Mitglieder kommen dafür in den Vertrag: eine Wiedergabe vorbereiten, sie beenden, den Fortschritt melden. Die Entscheidung **ob** umgerechnet wird, fällt weiterhin vorher im `PlaybackDecider` — der Server führt sie nur aus. Genau diese Trennung fehlte: der Player hat Plex' Transcoder angesprochen und konnte deshalb mit nichts anderem reden. `MediaBackendError` bekommt einen siebten Fall: `refused(reason:)`. Der Server hat verstanden und sagt nein, in eigenen Worten. Bei Plex war genau dieser Satz die einzige brauchbare Spur, und er ging tagelang verloren, weil es dafür keinen Fall gab. Bei Jellyfin kommt die Adresse einer umgerechneten Fassung vom Server selbst (`PlaybackInfo` → `TranscodingUrl`), nicht von uns. Er weiß, welche Spuren er kopieren kann und welche er neu rechnen muss. Drei Dinge, die beim Hinsehen auffielen: - **`startTimeTicks` gehört nicht in die Anfrage.** Sonst entscheiden zwei Stellen über den Startpunkt: der Server ließe den Strom dort beginnen, und der Player spränge noch einmal um dieselbe Strecke vor. Bei einundzwanzig Minuten Fortschritt landet man bei zweiundvierzig. - **Der erste Bericht braucht einen eigenen Endpunkt.** Ohne ihn erscheint der Titel bei Jellyfin nirgends als „läuft gerade". Er fiel zunächst aus, weil er die Laufzeit verlangte — und die steht erst Sekunden nach dem Öffnen fest. Jetzt tut es für die erste Meldung die des Servers. - **In der Download-Adresse standen Titel und Fassung vertauscht.** Bei einem Film mit einer Fassung sind beide Kennungen dieselbe und nichts fällt auf; bei zweien schon. `MediaFile` sagt jetzt, zu welchem Titel sie gehört. Live-TV bleibt bei Plex und wird als Einziges noch unmittelbar durchgereicht. Ein Tuner ist ein Stück Hardware, und die fünfhundert Zeilen, die es gekostet hat, ihn zu bändigen, gehören in keinen gemeinsamen Vertrag. Nachgesehen, nicht nur gebaut: gegen die Attrappe läuft ein echtes Video, der Beginn wird gemeldet, der Fortschritt kommt in Ticks mit Sitzung und Fassung an. Was die Attrappe **nicht** beantworten kann, ist, ob ein echter Jellyfin das Geräteprofil annimmt und eine Umrechnung anbietet — das entscheidet erst ein richtiger Server. 257 Tests grün. Co-Authored-By: Claude Opus 5 --- Flixy/Resources/Localizable.xcstrings | 6294 ++++++++--------- Flixy/Session/FlixySession.swift | 20 + Flixy/Session/PlaybackDiagnostics.swift | 10 +- Flixy/Views/Downloads/DownloadButton.swift | 17 +- Flixy/Views/LoadState.swift | 3 + Flixy/Views/Navigation/RootView.swift | 5 +- Flixy/Views/Player/PlayerModel.swift | 199 +- .../JellyfinBackend+Playback.swift | 211 + .../Sources/JellyfinKit/JellyfinBackend.swift | 13 +- .../Sources/JellyfinKit/JellyfinClient.swift | 6 + .../JellyfinKit/JellyfinItem+Media.swift | 3 +- .../Sources/JellyfinKit/Localizable.xcstrings | 50 +- .../Sources/MediaKit/MediaBackend.swift | 22 + .../Sources/MediaKit/MediaBackendError.swift | 9 +- .../FlixyKit/Sources/MediaKit/MediaItem.swift | 13 +- .../Sources/MediaKit/MediaPlayback.swift | 117 + .../Sources/MediaKit/MediaVersion.swift | 10 + .../Endpoints/PlexServer+Playback.swift | 15 +- .../Sources/PlexKit/PlexBackend.swift | 47 + .../JellyfinKitTests/PlaybackTests.swift | 208 + Tools/JellyfinStub/README.md | 12 + Tools/JellyfinStub/sample.mp4 | Bin 0 -> 28135 bytes Tools/JellyfinStub/server.py | 80 +- 23 files changed, 4090 insertions(+), 3274 deletions(-) create mode 100644 Packages/FlixyKit/Sources/JellyfinKit/JellyfinBackend+Playback.swift create mode 100644 Packages/FlixyKit/Sources/MediaKit/MediaPlayback.swift create mode 100644 Packages/FlixyKit/Tests/JellyfinKitTests/PlaybackTests.swift create mode 100644 Tools/JellyfinStub/sample.mp4 diff --git a/Flixy/Resources/Localizable.xcstrings b/Flixy/Resources/Localizable.xcstrings index f967c05..61d36a9 100644 --- a/Flixy/Resources/Localizable.xcstrings +++ b/Flixy/Resources/Localizable.xcstrings @@ -1,4894 +1,4892 @@ { - "sourceLanguage": "en", - "strings": { - "%@ GB": { - "comment": "A picker option in the settings view that lets the user select the maximum amount of storage space that can be used for downloaded content. The value is given in gigabytes.", - "isCommentAutoGenerated": true - }, - "%@ → %@": { - "comment": "A line in a diagnostic trace that shows a network request path and its result. The first argument is the network request path. The second argument is the network response result.", - "isCommentAutoGenerated": true, - "localizations": { - "en": { - "stringUnit": { - "state": "new", - "value": "%1$@ → %2$@" + "sourceLanguage" : "en", + "strings" : { + "%@ → %@" : { + "comment" : "A line in a diagnostic trace that shows a network request path and its result. The first argument is the network request path. The second argument is the network response result.", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$@ → %2$@" } } } }, - "1080p": { - "comment": "A label for a 1080p video.", - "isCommentAutoGenerated": true + "%@ GB" : { + "comment" : "A picker option in the settings view that lets the user select the maximum amount of storage space that can be used for downloaded content. The value is given in gigabytes.", + "isCommentAutoGenerated" : true }, - "4K": { - "comment": "A label for a 4K video.", - "isCommentAutoGenerated": true + "4K" : { + "comment" : "A label for a 4K video.", + "isCommentAutoGenerated" : true }, - "720p": { - "comment": "A label for a 720p video.", - "isCommentAutoGenerated": true + "720p" : { + "comment" : "A label for a 720p video.", + "isCommentAutoGenerated" : true }, - "SD": { - "comment": "A label for a 720p movie.", - "isCommentAutoGenerated": true + "1080p" : { + "comment" : "A label for a 1080p video.", + "isCommentAutoGenerated" : true }, - "action.addFavourite": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Zu Favoriten" + "action.addFavourite" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zu Favoriten" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Add to favourites" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Add to favourites" } } } }, - "action.apply": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Übernehmen" + "action.apply" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Übernehmen" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Apply" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apply" } } } }, - "action.cancel": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Abbrechen" + "action.cancel" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Abbrechen" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Cancel" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cancel" } } } }, - "action.clearRating": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Bewertung entfernen" + "action.clearRating" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bewertung entfernen" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Clear rating" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Clear rating" } } } }, - "action.close": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Schließen" + "action.close" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Schließen" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Close" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Close" } } } }, - "action.delete": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Löschen" + "action.delete" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Löschen" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Delete" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Delete" } } } }, - "action.done": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Fertig" + "action.done" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fertig" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Done" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Done" } } } }, - "action.markUnwatched": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Als ungesehen markieren" + "action.markUnwatched" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Als ungesehen markieren" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Mark as unwatched" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mark as unwatched" } } } }, - "action.markWatched": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Als gesehen markieren" + "action.markWatched" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Als gesehen markieren" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Mark as watched" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mark as watched" } } } }, - "action.rate": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Bewerten" + "action.rate" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bewerten" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Rate" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rate" } } } }, - "action.removeFavourite": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Aus Favoriten entfernen" + "action.removeFavourite" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aus Favoriten entfernen" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Remove from favourites" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Remove from favourites" } } } }, - "action.retry": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Erneut versuchen" + "action.retry" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Erneut versuchen" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Try again" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Try again" } } } }, - "action.signOut": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Abmelden" + "action.signOut" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Abmelden" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Sign out" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sign out" } } } }, - "appearance.accent": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Akzentfarbe" + "appearance.accent" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Akzentfarbe" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Accent color" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Accent color" } } } }, - "appearance.accentExplanation": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Die Farbe erscheint auf Knöpfen, Fortschrittsbalken und der ausgewählten Zeile in der Seitenleiste." + "appearance.accentExplanation" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Die Farbe erscheint auf Knöpfen, Fortschrittsbalken und der ausgewählten Zeile in der Seitenleiste." } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "The color appears on buttons, progress bars and the selected row in the sidebar." + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The color appears on buttons, progress bars and the selected row in the sidebar." } } } }, - "appearance.adjusted %@": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Die Farbe wurde auf %@ aufgehellt, damit sie auf dem dunklen Hintergrund lesbar bleibt." + "appearance.adjusted %@" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Die Farbe wurde auf %@ aufgehellt, damit sie auf dem dunklen Hintergrund lesbar bleibt." } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Lightened to %@ so it stays readable on the dark background." + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lightened to %@ so it stays readable on the dark background." } } } }, - "appearance.customColor": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Eigene Farbe" + "appearance.customColor" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eigene Farbe" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Custom color" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Custom color" } } } }, - "appearance.hexPlaceholder": { - "comment": "Beispiel für eine Hex-Farbe", - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "#1DB954" + "appearance.hexPlaceholder" : { + "comment" : "Beispiel für eine Hex-Farbe", + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "#1DB954" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "#1DB954" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "#1DB954" } } } }, - "appearance.invalidHex": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Das ist keine gültige Farbe. Erwartet wird etwas wie #1DB954." + "appearance.invalidHex" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Das ist keine gültige Farbe. Erwartet wird etwas wie #1DB954." } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "That's not a valid color. Try something like #1DB954." + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "That's not a valid color. Try something like #1DB954." } } } }, - "appearance.preview": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Vorschau" + "appearance.preview" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vorschau" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Preview" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Preview" } } } }, - "appearance.title": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Erscheinungsbild" + "appearance.title" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Erscheinungsbild" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Appearance" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Appearance" } } } }, - "connecting.searching": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Server werden gesucht …" + "connecting.searching" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Server werden gesucht …" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Looking for servers…" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Looking for servers…" } } } }, - "connecting.to %@": { - "comment": "Platzhalter: Servername", - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Verbinde mit %@ …" + "connecting.to %@" : { + "comment" : "Platzhalter: Servername", + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verbinde mit %@ …" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Connecting to %@…" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Connecting to %@…" } } } }, - "detail.cast": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Besetzung" + "detail.cast" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Besetzung" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Cast" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cast" } } } }, - "detail.episodeCount %lld": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "%lld Folgen" + "detail.episodeCount %lld" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld Folgen" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "%lld episodes" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld episodes" } } } }, - "detail.episodes": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Folgen" + "detail.episodes" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Folgen" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Episodes" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Episodes" } } } }, - "detail.playFromStart": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Von vorn abspielen" + "detail.playFromStart" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Von vorn abspielen" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Play from start" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Play from start" } } } }, - "detail.seasons": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Staffeln" + "detail.seasons" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Staffeln" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Seasons" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Seasons" } } } }, - "detail.toSeason": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Zur Staffel" + "detail.toSeason" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zur Staffel" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Go to season" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Go to season" } } } }, - "detail.toSeries": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Zur Serie" + "detail.toSeries" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zur Serie" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Go to series" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Go to series" } } } }, - "detail.unwatchedCount %lld": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "%lld ungesehen" + "detail.unwatchedCount %lld" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld ungesehen" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "%lld unwatched" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld unwatched" } } } }, - "detail.versions": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Fassung" + "detail.versions" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fassung" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Version" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Version" } } } }, - "diagnostics.connection": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Verbindung" + "diagnostics.connection" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verbindung" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Connection" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Connection" } } } }, - "diagnostics.connectionUsed": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Verbindung" + "diagnostics.connectionUsed" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verbindung" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Connection" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Connection" } } } }, - "diagnostics.copy": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Bericht kopieren" + "diagnostics.copy" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bericht kopieren" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Copy report" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Copy report" } } } }, - "diagnostics.copyHint": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Enthält keinen Token. Kannst du unbesorgt weitergeben." + "diagnostics.copyHint" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enthält keinen Token. Kannst du unbesorgt weitergeben." } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Contains no token. Safe to share." + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Contains no token. Safe to share." } } } }, - "diagnostics.lastAttempt": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Letzter Abspielversuch" + "diagnostics.lastAttempt" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Letzter Abspielversuch" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Last playback attempt" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Last playback attempt" } } } }, - "diagnostics.liveTV": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Live-TV-Antwort ansehen" + "diagnostics.liveTV" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Live-TV-Antwort ansehen" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Inspect Live TV response" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inspect Live TV response" } } } }, - "diagnostics.liveTVHint": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Zeigt den Aufbau der Serverantwort — nur Schlüssel, Anzahl und Sendernamen, keine Zugangsdaten. Hilft dabei zu finden, wo die Sender stehen." + "diagnostics.liveTVHint" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zeigt den Aufbau der Serverantwort — nur Schlüssel, Anzahl und Sendernamen, keine Zugangsdaten. Hilft dabei zu finden, wo die Sender stehen." } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Shows the structure of the server's response — keys, counts and channel names only, no credentials. Helps locate where the channels are." + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Shows the structure of the server's response — keys, counts and channel names only, no credentials. Helps locate where the channels are." } } } }, - "diagnostics.method": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Weg" + "diagnostics.method" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Weg" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Method" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Method" } } } }, - "diagnostics.noServer": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Keine Serververbindung." + "diagnostics.noServer" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Keine Serververbindung." } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "No server connection." + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No server connection." } } } }, - "diagnostics.nothingYet": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Noch nichts abgespielt." + "diagnostics.nothingYet" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Noch nichts abgespielt." } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Nothing played yet." + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nothing played yet." } } } }, - "diagnostics.relayWarning": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Über die Plex-Vermittlung. Stark gedrosselt — große Dateien laufen darüber kaum an." + "diagnostics.relayWarning" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Über die Plex-Vermittlung. Stark gedrosselt — große Dateien laufen darüber kaum an." } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Via the Plex relay. Heavily throttled — large files barely start over it." + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Via the Plex relay. Heavily throttled — large files barely start over it." } } } }, - "diagnostics.source": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Quelle (ohne Token)" + "diagnostics.source" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Quelle (ohne Token)" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Source (token removed)" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Source (token removed)" } } } }, - "diagnostics.steps": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Ablauf" + "diagnostics.steps" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ablauf" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Timeline" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Timeline" } } } }, - "diagnostics.title": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Diagnose" + "diagnostics.title" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Diagnose" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Diagnostics" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Diagnostics" } } } }, - "diagnostics.title.label": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Titel" + "diagnostics.title.label" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Titel" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Title" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Title" } } } }, - "downloads.active": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Läuft gerade" + "downloads.active" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Läuft gerade" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "In progress" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "In progress" } } } }, - "downloads.allEpisodes": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Alle Folgen laden" + "downloads.allEpisodes" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alle Folgen laden" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Download all episodes" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Download all episodes" } } } }, - "downloads.available": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Offline verfügbar" + "downloads.available" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Offline verfügbar" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Available offline" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Available offline" } } } }, - "downloads.batch.queued %lld": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "%lld Folgen in die Warteschlange gelegt." + "downloads.batch.queued %lld" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld Folgen in die Warteschlange gelegt." } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Queued %lld episodes." + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Queued %lld episodes." } } } }, - "downloads.batch.title": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Downloads" + "downloads.batch.title" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Downloads" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Downloads" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Downloads" } } } }, - "downloads.batch.unavailable %lld": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "%lld Folgen haben keine abspielbare Datei auf dem Server." + "downloads.batch.unavailable %lld" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld Folgen haben keine abspielbare Datei auf dem Server." } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "%lld episodes have no playable file on the server." + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld episodes have no playable file on the server." } } } }, - "downloads.cancel": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Download abbrechen" + "downloads.cancel" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Download abbrechen" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Cancel download" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cancel download" } } } }, - "downloads.download": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Herunterladen" + "downloads.download" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Herunterladen" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Download" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Download" } } } }, - "downloads.empty.description": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Lade Filme und Folgen herunter, um sie ohne Verbindung anzusehen." + "downloads.empty.description" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lade Filme und Folgen herunter, um sie ohne Verbindung anzusehen." } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Download movies and episodes to watch them without a connection." + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Download movies and episodes to watch them without a connection." } } } }, - "downloads.empty.title": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Nichts offline" + "downloads.empty.title" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nichts offline" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Nothing offline" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nothing offline" } } } }, - "downloads.failed": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Fehlgeschlagen" + "downloads.failed" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fehlgeschlagen" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Failed" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Failed" } } } }, - "downloads.hold.offline": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Keine Verbindung" + "downloads.hold.offline" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Keine Verbindung" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "No connection" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No connection" } } } }, - "downloads.hold.offline.description": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Ohne Verbindung geht nichts weiter. Angefangene Downloads laufen weiter, sobald das Gerät wieder am Netz ist." + "downloads.hold.offline.description" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ohne Verbindung geht nichts weiter. Angefangene Downloads laufen weiter, sobald das Gerät wieder am Netz ist." } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Nothing transfers without a connection. Started downloads continue once the device is back online." + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nothing transfers without a connection. Started downloads continue once the device is back online." } } } }, - "downloads.hold.wifi": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Wartet auf WLAN" + "downloads.hold.wifi" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wartet auf WLAN" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Waiting for Wi-Fi" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Waiting for Wi-Fi" } } } }, - "downloads.hold.wifi.description": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "„Nur über WLAN“ ist eingeschaltet und das Gerät hängt gerade am Mobilfunk. Sobald WLAN da ist, geht es von selbst weiter." + "downloads.hold.wifi.description" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "„Nur über WLAN“ ist eingeschaltet und das Gerät hängt gerade am Mobilfunk. Sobald WLAN da ist, geht es von selbst weiter." } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "“Wi-Fi only” is on and the device is on cellular. Transfers resume by themselves once Wi-Fi is back." + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "“Wi-Fi only” is on and the device is on cellular. Transfers resume by themselves once Wi-Fi is back." } } } }, - "downloads.movies": { - "comment": "Überschrift für heruntergeladene Filme", - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Filme" + "downloads.movies" : { + "comment" : "Überschrift für heruntergeladene Filme", + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Filme" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Movies" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Movies" } } } }, - "downloads.pause": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Anhalten" + "downloads.pause" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Anhalten" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Pause" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pause" } } } }, - "downloads.paused": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Angehalten" + "downloads.paused" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Angehalten" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Paused" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Paused" } } } }, - "downloads.quality.approx %@ %@": { - "comment": "Platzhalter: Stufenname, geschätzte Größe", - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "%@ · etwa %@" + "downloads.quality.approx %@ %@" : { + "comment" : "Platzhalter: Stufenname, geschätzte Größe", + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ · etwa %@" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "%@ · about %@" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ · about %@" } } } }, - "downloads.quality.hint": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Niedrigere Stufen rechnet der Server unterwegs herunter. Das dauert länger, spart aber deutlich Platz." + "downloads.quality.hint" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Niedrigere Stufen rechnet der Server unterwegs herunter. Das dauert länger, spart aber deutlich Platz." } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Lower levels are transcoded by the server. That takes longer but saves a lot of space." + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lower levels are transcoded by the server. That takes longer but saves a lot of space." } } } }, - "downloads.quality.title": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "In welcher Qualität?" + "downloads.quality.title" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "In welcher Qualität?" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Which quality?" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Which quality?" } } } }, - "downloads.queued": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Wartet" + "downloads.queued" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wartet" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Queued" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Queued" } } } }, - "downloads.refused.exists": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Dieser Titel steht bereits in der Warteschlange." + "downloads.refused.exists" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dieser Titel steht bereits in der Warteschlange." } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "This title is already queued." + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This title is already queued." } } } }, - "downloads.refused.limit %@ %@": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Das Speicherlimit ist erreicht: %@ von %@ belegt." + "downloads.refused.limit %@ %@" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Das Speicherlimit ist erreicht: %@ von %@ belegt." } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Storage limit reached: %@ of %@ used." + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Storage limit reached: %@ of %@ used." } } } }, - "downloads.refused.noMedia": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Zu diesem Titel liegt keine Datei auf dem Server." + "downloads.refused.noMedia" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zu diesem Titel liegt keine Datei auf dem Server." } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "There's no file for this title on the server." + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "There's no file for this title on the server." } } } }, - "downloads.refused.noPart": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Die Datei auf dem Server hat keinen abrufbaren Teil. Möglicherweise wurde sie noch nicht analysiert." + "downloads.refused.noPart" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Die Datei auf dem Server hat keinen abrufbaren Teil. Möglicherweise wurde sie noch nicht analysiert." } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "The file on the server has no retrievable part. It may not have been analyzed yet." + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The file on the server has no retrievable part. It may not have been analyzed yet." } } } }, - "downloads.refused.noServer": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Keine Verbindung zum Server." + "downloads.refused.noServer" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Keine Verbindung zum Server." } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "No connection to the server." + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No connection to the server." } } } }, - "downloads.refused.noURL": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Die Download-Adresse ließ sich nicht bilden." + "downloads.refused.noURL" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Die Download-Adresse ließ sich nicht bilden." } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "The download address couldn't be built." + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The download address couldn't be built." } } } }, - "downloads.refused.space %@ %@": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Zu wenig Platz auf dem Gerät: %@ nötig, %@ frei." + "downloads.refused.space %@ %@" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zu wenig Platz auf dem Gerät: %@ nötig, %@ frei." } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Not enough space on this device: %@ needed, %@ free." + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Not enough space on this device: %@ needed, %@ free." } } } }, - "downloads.refused.title": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Download nicht möglich" + "downloads.refused.title" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Download nicht möglich" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Can't download" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Can't download" } } } }, - "downloads.removeConfirm": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Download entfernen?" + "downloads.removeConfirm" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Download entfernen?" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Remove download?" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Remove download?" } } } }, - "downloads.resume": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Fortsetzen" + "downloads.resume" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fortsetzen" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Resume" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Resume" } } } }, - "downloads.season": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Staffel laden" + "downloads.season" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Staffel laden" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Download season" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Download season" } } } }, - "downloads.used": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Belegt" + "downloads.used" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Belegt" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Used" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Used" } } } }, - "downloads.wifiOnly": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Nur über WLAN laden" + "downloads.wifiOnly" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nur über WLAN laden" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Download over Wi-Fi only" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Download over Wi-Fi only" } } } }, - "episode.short %lld %lld": { - "comment": "Staffel- und Folgennummer", - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "S%lld E%lld" + "episode.short %lld %lld" : { + "comment" : "Staffel- und Folgennummer", + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "S%lld E%lld" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "S%lld E%lld" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "S%lld E%lld" } } } }, - "error.noServers": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "An deinem Plex-Konto hängt kein Server." + "error.noServers" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "An deinem Plex-Konto hängt kein Server." } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "There's no server on your Plex account." + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "There's no server on your Plex account." } } } }, - "error.notFound": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Dieser Titel ist auf dem Server nicht mehr vorhanden." + "error.notFound" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dieser Titel ist auf dem Server nicht mehr vorhanden." } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "This title is no longer on the server." + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This title is no longer on the server." } } } }, - "error.server %lld": { - "comment": "Platzhalter: HTTP-Statuscode", - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Der Server hat mit Fehler %lld geantwortet." + "error.server %lld" : { + "comment" : "Platzhalter: HTTP-Statuscode", + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Der Server hat mit Fehler %lld geantwortet." } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "The server responded with error %lld." + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The server responded with error %lld." } } } }, - "error.serverUnreachable": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Der Server ist nicht erreichbar. Prüfe, ob er läuft und ob du im selben Netzwerk bist." + "error.serverUnreachable" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Der Server ist nicht erreichbar. Prüfe, ob er läuft und ob du im selben Netzwerk bist." } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "The server can't be reached. Check that it's running and that you're on the same network." + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The server can't be reached. Check that it's running and that you're on the same network." } } } }, - "error.title": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Das hat nicht geklappt" + "error.title" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Das hat nicht geklappt" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Something went wrong" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Something went wrong" } } } }, - "error.unauthorized": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Die Anmeldung ist abgelaufen. Melde dich erneut an." + "error.unauthorized" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Die Anmeldung ist abgelaufen. Melde dich erneut an." } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Your sign-in has expired. Please sign in again." + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Your sign-in has expired. Please sign in again." } } } }, - "error.unexpectedResponse": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Die Antwort des Servers war unerwartet. Möglicherweise ist eine neuere Plex-Version installiert." + "error.unexpectedResponse" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Die Antwort des Servers war unerwartet. Möglicherweise ist eine neuere Plex-Version installiert." } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "The server's response was unexpected. It may be running a newer version of Plex." + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The server's response was unexpected. It may be running a newer version of Plex." } } } }, - "filter.any": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Alle" + "filter.any" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alle" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Any" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Any" } } } }, - "filter.filters": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Filter" + "filter.filters" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Filter" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Filters" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Filters" } } } }, - "filter.genre": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Genre" + "filter.genre" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Genre" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Genre" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Genre" } } } }, - "filter.reset": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Zurücksetzen" + "filter.reset" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zurücksetzen" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Reset" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reset" } } } }, - "filter.resolution": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Auflösung" + "filter.resolution" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Auflösung" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Resolution" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Resolution" } } } }, - "filter.sort": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Sortierung" + "filter.sort" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sortierung" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Sort by" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sort by" } } } }, - "filter.title": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Filter" + "filter.title" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Filter" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Filters" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Filters" } } } }, - "filter.unwatchedOnly": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Nur Ungesehene" + "filter.unwatchedOnly" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nur Ungesehene" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Unwatched only" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unwatched only" } } } }, - "hero.details": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Details" + "hero.details" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Details" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Details" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Details" } } } }, - "hero.play": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Abspielen" + "hero.play" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Abspielen" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Play" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Play" } } } }, - "hero.resume": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Weiterschauen" + "hero.resume" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Weiterschauen" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Resume" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Resume" } } } }, - "home.empty.description": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Sobald dein Server Mediatheken enthält, erscheinen sie hier." + "home.empty.description" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sobald dein Server Mediatheken enthält, erscheinen sie hier." } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Once your server has libraries, they'll show up here." + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Once your server has libraries, they'll show up here." } } } }, - "home.empty.title": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Noch nichts da" + "home.empty.title" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Noch nichts da" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Nothing here yet" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nothing here yet" } } } }, - "homeLayout.empty": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Der Server liefert derzeit keine Reihen." + "homeLayout.empty" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Der Server liefert derzeit keine Reihen." } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "The server isn't providing any rows right now." + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The server isn't providing any rows right now." } } } }, - "homeLayout.explanation": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Schalte Reihen aus, die du nicht brauchst, und sortiere sie über „Bearbeiten“ um. Neue Reihen vom Server erscheinen am Ende." + "homeLayout.explanation" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Schalte Reihen aus, die du nicht brauchst, und sortiere sie über „Bearbeiten“ um. Neue Reihen vom Server erscheinen am Ende." } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Turn off rows you don't need and reorder them via Edit. New rows from the server appear at the end." + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Turn off rows you don't need and reorder them via Edit. New rows from the server appear at the end." } } } }, - "homeLayout.title": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Startseite" + "homeLayout.title" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Startseite" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Home screen" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Home screen" } } } }, - "item.watched": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Gesehen" + "item.watched" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gesehen" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Watched" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Watched" } } } }, - "library.clearFilters": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Filter zurücksetzen" + "library.clearFilters" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Filter zurücksetzen" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Clear filters" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Clear filters" } } } }, - "library.empty.description": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Mit den aktuellen Filtern bleibt nichts übrig." + "library.empty.description" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mit den aktuellen Filtern bleibt nichts übrig." } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Nothing matches the current filters." + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nothing matches the current filters." } } } }, - "library.empty.title": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Keine Titel" + "library.empty.title" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Keine Titel" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "No titles" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No titles" } } } }, - "library.filters": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Filter" + "library.filters" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Filter" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Filters" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Filters" } } } }, - "library.kind.liveTV": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Live-TV" + "library.kind.liveTV" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Live-TV" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Live TV" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Live TV" } } } }, - "library.kind.movie": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Filme" + "library.kind.movie" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Filme" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Movies" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Movies" } } } }, - "library.kind.music": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Musik" + "library.kind.music" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Musik" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Music" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Music" } } } }, - "library.kind.other": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Sonstiges" + "library.kind.other" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sonstiges" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Other" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Other" } } } }, - "library.kind.photo": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Fotos" + "library.kind.photo" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fotos" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Photos" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Photos" } } } }, - "library.kind.show": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Serien" + "library.kind.show" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Serien" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "TV shows" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "TV shows" } } } }, - "library.scan": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Mediathek durchsuchen" + "library.scan" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mediathek durchsuchen" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Scan library" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Scan library" } } } }, - "library.toggleLayout": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Ansicht wechseln" + "library.toggleLayout" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ansicht wechseln" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Switch layout" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Switch layout" } } } }, - "liveTV.channelCount %@": { - "comment": "A label displaying the number of channels. The argument is the number of channels.", - "isCommentAutoGenerated": true + "liveTV.channelCount %@" : { + "comment" : "A label displaying the number of channels. The argument is the number of channels.", + "isCommentAutoGenerated" : true }, - "liveTV.channelCount %lld": { - "comment": "Platzhalter: Anzahl Sender", - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "%lld Sender" + "liveTV.channelCount %lld" : { + "comment" : "Platzhalter: Anzahl Sender", + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld Sender" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "%lld channels" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld channels" } } } }, - "liveTV.gap": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Keine Angaben" + "liveTV.gap" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Keine Angaben" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "No data" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No data" } } } }, - "liveTV.mode": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Ansicht" + "liveTV.mode" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ansicht" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "View" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "View" } } } }, - "liveTV.mode.guide": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Programm" + "liveTV.mode.guide" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Programm" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Guide" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Guide" } } } }, - "liveTV.mode.now": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Jetzt" + "liveTV.mode.now" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Jetzt" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Now" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Now" } } } }, - "liveTV.next %@ %@": { - "comment": "Platzhalter: Uhrzeit, Sendungstitel", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Danach %@ · %@" + "liveTV.next %@ %@" : { + "comment" : "Platzhalter: Uhrzeit, Sendungstitel", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Danach %@ · %@" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Next %@ · %@" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Next %@ · %@" } } } }, - "liveTV.noChannels.description": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Der Empfänger ist eingerichtet, liefert aber keine Sender." + "liveTV.noChannels.description" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Der Empfänger ist eingerichtet, liefert aber keine Sender." } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "The tuner is set up but isn't providing any channels." + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The tuner is set up but isn't providing any channels." } } } }, - "liveTV.noChannels.title": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Keine Sender" + "liveTV.noChannels.title" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Keine Sender" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "No channels" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No channels" } } } }, - "liveTV.noProgram": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Kein Programm verfügbar" + "liveTV.noProgram" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kein Programm verfügbar" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "No guide data" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No guide data" } } } }, - "liveTV.noStream": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Der Server hat keine Adresse für diesen Sender geliefert." + "liveTV.noStream" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Der Server hat keine Adresse für diesen Sender geliefert." } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "The server did not return a stream for this channel." + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The server did not return a stream for this channel." } } } }, - "liveTV.notConfigured.description": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Live-TV braucht eine Fernsehkarte oder einen Netzwerk-Tuner am Plex-Server. Richte ihn in den Servereinstellungen ein, dann erscheinen die Sender hier." + "liveTV.notConfigured.description" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Live-TV braucht eine Fernsehkarte oder einen Netzwerk-Tuner am Plex-Server. Richte ihn in den Servereinstellungen ein, dann erscheinen die Sender hier." } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Live TV needs a tuner card or network tuner on your Plex server. Set one up in the server settings and channels will appear here." + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Live TV needs a tuner card or network tuner on your Plex server. Set one up in the server settings and channels will appear here." } } } }, - "liveTV.notConfigured.title": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Kein Empfänger eingerichtet" + "liveTV.notConfigured.title" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kein Empfänger eingerichtet" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "No tuner set up" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No tuner set up" } } } }, - "liveTV.now": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Jetzt" + "liveTV.now" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Jetzt" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Now" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Now" } } } }, - "liveTV.runningNow": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Läuft gerade" + "liveTV.runningNow" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Läuft gerade" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "On now" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "On now" } } } }, - "liveTV.selection": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Sender auswählen" + "liveTV.selection" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sender auswählen" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Choose channels" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Choose channels" } } } }, - "liveTV.selection.all": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Alle" + "liveTV.selection.all" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alle" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "All" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "All" } } } }, - "liveTV.selection.hint": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Ohne Auswahl werden alle Sender gezeigt." + "liveTV.selection.hint" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ohne Auswahl werden alle Sender gezeigt." } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "With nothing selected, all channels are shown." + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "With nothing selected, all channels are shown." } } } }, - "liveTV.timeRange %@ %@": { - "comment": "Platzhalter: Anfangs- und Endzeit", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "%@ – %@" + "liveTV.timeRange %@ %@" : { + "comment" : "Platzhalter: Anfangs- und Endzeit", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ – %@" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "%@ – %@" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ – %@" } } } }, - "liveTV.tune": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Einschalten" + "liveTV.tune" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Einschalten" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Watch" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Watch" } } } }, - "liveTV.tuneFailed": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Sender lässt sich nicht einschalten" + "liveTV.tuneFailed" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sender lässt sich nicht einschalten" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Cannot tune channel" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cannot tune channel" } } } }, - "miniPlayer.close": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Wiedergabe beenden" + "miniPlayer.close" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wiedergabe beenden" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Stop playback" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stop playback" } } } }, - "miniPlayer.expand": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Vollbild" + "miniPlayer.expand" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vollbild" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Full screen" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Full screen" } } } }, - "miniPlayer.resize": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Größe ändern" + "miniPlayer.resize" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Größe ändern" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Resize" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Resize" } } } }, - "placeholder.description": { - "comment": "Platzhalter-Bildschirme", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Dieser Bereich entsteht in einer späteren Phase." + "placeholder.description" : { + "comment" : "Platzhalter-Bildschirme", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dieser Bereich entsteht in einer späteren Phase." } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "This area is coming in a later phase." + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This area is coming in a later phase." } } } }, - "player.audioTracks": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Tonspur" + "player.audioTracks" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tonspur" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Audio" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Audio" } } } }, - "player.back10": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "10 Sekunden zurück" + "player.back10" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "10 Sekunden zurück" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Back 10 seconds" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Back 10 seconds" } } } }, - "player.close": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Player schließen" + "player.close" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Player schließen" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Close player" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Close player" } } } }, - "player.forward10": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "10 Sekunden vor" + "player.forward10" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "10 Sekunden vor" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Forward 10 seconds" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Forward 10 seconds" } } } }, - "player.jellyfinPending": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Die Wiedergabe von Jellyfin ist noch nicht fertig — sie ist der nächste Schritt." + "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." + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Playback from Jellyfin isn't finished yet — it's the next step." } } } }, - "player.loadFailed": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Die Datei ließ sich nicht öffnen." + "player.loadFailed" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Die Datei ließ sich nicht öffnen." } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "The file couldn't be opened." + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The file couldn't be opened." } } } }, - "player.minimize": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Verkleinern" + "player.minimize" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verkleinern" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Minimize" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Minimize" } } } }, - "player.nextEpisode": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Nächste Folge" + "player.nextEpisode" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nächste Folge" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Next episode" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Next episode" } } } }, - "player.noPlayableFile": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Zu diesem Titel gibt es keine abspielbare Datei auf dem Server." + "player.noPlayableFile" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zu diesem Titel gibt es keine abspielbare Datei auf dem Server." } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "There's no playable file for this title on the server." + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "There's no playable file for this title on the server." } } } }, - "player.pause": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Pause" + "player.pause" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pause" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Pause" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pause" } } } }, - "player.play": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Abspielen" + "player.play" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Abspielen" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Play" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Play" } } } }, - "player.playbackFailed": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Die Wiedergabe wurde unterbrochen." + "player.playbackFailed" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Die Wiedergabe wurde unterbrochen." } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Playback was interrupted." + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Playback was interrupted." } } } }, - "player.preparing": { - "comment": "Hinweis, während der Player die Datei öffnet", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Wird vorbereitet …" + "player.preparing" : { + "comment" : "Hinweis, während der Player die Datei öffnet", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wird vorbereitet …" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Preparing…" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Preparing…" } } } }, - "player.quality": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Qualität" + "player.quality" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Qualität" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Quality" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Quality" } } } }, - "player.relayWarning": { - "comment": "Warnung im Player, wenn die Verbindung über die Plex-Vermittlung läuft", - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Die Verbindung läuft über die Vermittlung von Plex und ist stark gedrosselt. Große Dateien laufen darüber kaum an." + "player.relayWarning" : { + "comment" : "Warnung im Player, wenn die Verbindung über die Plex-Vermittlung läuft", + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Die Verbindung läuft über die Vermittlung von Plex und ist stark gedrosselt. Große Dateien laufen darüber kaum an." } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "You're connected through the Plex relay, which is heavily throttled. Large files barely start over it." + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "You're connected through the Plex relay, which is heavily throttled. Large files barely start over it." } } } }, - "player.restoring": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Stelle wird wiederhergestellt" + "player.restoring" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stelle wird wiederhergestellt" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Returning to your position" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Returning to your position" } } } }, - "player.skipCredits": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Abspann überspringen" + "player.skipCredits" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Abspann überspringen" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Skip credits" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Skip credits" } } } }, - "player.skipIntro": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Intro überspringen" + "player.skipIntro" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Intro überspringen" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Skip intro" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Skip intro" } } } }, - "player.stalled": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Es kommen keine Daten mehr. Der Strom ist abgerissen." + "player.stalled" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Es kommen keine Daten mehr. Der Strom ist abgerissen." } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "No more data is arriving. The stream stopped." + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No more data is arriving. The stream stopped." } } } }, - "player.stop": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Beenden" + "player.stop" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Beenden" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Stop" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stop" } } } }, - "player.subtitleTracks": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Untertitel" + "player.subtitlesOff" : { + "comment" : "Eintrag, der die Untertitel abschaltet", + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aus" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Subtitles" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Off" } } } }, - "player.subtitlesOff": { - "comment": "Eintrag, der die Untertitel abschaltet", - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Aus" + "player.subtitleTracks" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Untertitel" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Off" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Subtitles" } } } }, - "player.tracks": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Ton und Untertitel" + "player.tracks" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ton und Untertitel" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Audio and subtitles" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Audio and subtitles" } } } }, - "player.transcodeRefused": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Der Server hat die Wiedergabe abgelehnt." + "player.transcodeRefused" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Der Server hat die Wiedergabe abgelehnt." } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "The server refused playback." + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The server refused playback." } } } }, - "quality.audioOnly": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Nur Ton" + "quality.audioOnly" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nur Ton" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Audio only" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Audio only" } } } }, - "quality.audioOnly.hint": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Plex kennt für Filme keinen reinen Tonstrom. Diese Stufe überträgt das Bild in der kleinsten Auflösung und blendet es aus." + "quality.audioOnly.hint" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Plex kennt für Filme keinen reinen Tonstrom. Diese Stufe überträgt das Bild in der kleinsten Auflösung und blendet es aus." } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Plex has no audio-only stream for video. This setting sends the picture at the smallest size and hides it." + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Plex has no audio-only stream for video. This setting sends the picture at the smallest size and hides it." } } } }, - "quality.automatic": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Automatisch" + "quality.automatic" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Automatisch" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Automatic" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Automatic" } } } }, - "quality.current": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Läuft gerade" + "quality.current" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Läuft gerade" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Currently playing" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Currently playing" } } } }, - "quality.current.bitrate": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Bitrate" + "quality.current.bitrate" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bitrate" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Bitrate" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bitrate" } } } }, - "quality.current.dropped": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Verworfene Bilder" + "quality.current.dropped" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verworfene Bilder" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Dropped frames" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dropped frames" } } } }, - "quality.current.hint": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Gemessen, nicht gewünscht: die Stufe sagt, was erlaubt ist — hier steht, was ankommt." + "quality.current.hint" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gemessen, nicht gewünscht: die Stufe sagt, was erlaubt ist — hier steht, was ankommt." } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Measured, not requested: the setting caps the rate, this is what arrives." + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Measured, not requested: the setting caps the rate, this is what arrives." } } } }, - "quality.current.resolution": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Auflösung" + "quality.current.resolution" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Auflösung" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Resolution" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Resolution" } } } }, - "quality.everyRung": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Alle Stufen" + "quality.everyRung" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alle Stufen" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "All steps" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "All steps" } } } }, - "quality.high": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Hoch (12 Mbit/s)" + "quality.high" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hoch (12 Mbit/s)" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "High (12 Mbps)" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "High (12 Mbps)" } } } }, - "quality.kilobits %lld": { - "comment": "Platzhalter: Kilobit je Sekunde", - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "%lld kbps" + "quality.kilobits %lld" : { + "comment" : "Platzhalter: Kilobit je Sekunde", + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld kbps" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "%lld kbps" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld kbps" } } } }, - "quality.low": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Niedrig (4 Mbit/s)" + "quality.low" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Niedrig (4 Mbit/s)" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Low (4 Mbps)" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Low (4 Mbps)" } } } }, - "quality.medium": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Mittel (8 Mbit/s)" + "quality.medium" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mittel (8 Mbit/s)" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Medium (8 Mbps)" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Medium (8 Mbps)" } } } }, - "quality.megabits %@": { - "comment": "Platzhalter: Megabit je Sekunde", - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "%@ Mbps" + "quality.megabits %@" : { + "comment" : "Platzhalter: Megabit je Sekunde", + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ Mbps" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "%@ Mbps" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ Mbps" } } } }, - "quality.minimal": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Sparsam (2 Mbit/s)" + "quality.minimal" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sparsam (2 Mbit/s)" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Data saver (2 Mbps)" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Data saver (2 Mbps)" } } } }, - "quality.original": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Original" + "quality.original" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Original" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Original" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Original" } } } }, - "runtime.hoursMinutes %lld %lld": { - "comment": "Laufzeit mit Stunden", - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "%lld Std %lld Min" + "runtime.hoursMinutes %lld %lld" : { + "comment" : "Laufzeit mit Stunden", + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld Std %lld Min" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "%lldh %lldm" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lldh %lldm" } } } }, - "runtime.minutes %lld": { - "comment": "Laufzeit unter einer Stunde", - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "%lld Min" + "runtime.minutes %lld" : { + "comment" : "Laufzeit unter einer Stunde", + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld Min" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "%lldm" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lldm" } } } }, - "search.hint": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Suche nach Filmen, Serien, Folgen und Personen." + "SD" : { + "comment" : "A label for a 720p movie.", + "isCommentAutoGenerated" : true + }, + "search.hint" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Suche nach Filmen, Serien, Folgen und Personen." } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Search for movies, shows, episodes and people." + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Search for movies, shows, episodes and people." } } } }, - "search.recent": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Zuletzt gesucht" + "search.recent" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zuletzt gesucht" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Recent searches" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Recent searches" } } } }, - "search.title": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Suche" + "search.title" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Suche" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Search" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Search" } } } }, - "season.short %lld": { - "comment": "Platzhalter: Staffelnummer", - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Staffel %lld" + "season.short %lld" : { + "comment" : "Platzhalter: Staffelnummer", + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Staffel %lld" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Season %lld" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Season %lld" } } } }, - "serverPicker.owned": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Eigener Server" + "serverPicker.owned" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Eigener Server" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Your server" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Your server" } } } }, - "serverPicker.shared": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Geteilt" + "serverPicker.shared" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Geteilt" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Shared" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Shared" } } } }, - "serverPicker.title": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Server wählen" + "serverPicker.title" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Server wählen" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Choose a server" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Choose a server" } } } }, - "settings.account": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Konto" + "settings.account" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Konto" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Account" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Account" } } } }, - "settings.address": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Adresse" + "settings.address" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Adresse" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Address" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Address" } } } }, - "settings.app": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "App" + "settings.app" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "App" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "App" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "App" } } } }, - "settings.autoPlayNext": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Nächste Folge automatisch" + "settings.autoPlayNext" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nächste Folge automatisch" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Play next episode automatically" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Play next episode automatically" } } } }, - "settings.autoSkipCredits": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Abspann automatisch überspringen" + "settings.autoSkipCredits" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Abspann automatisch überspringen" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Skip credits automatically" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Skip credits automatically" } } } }, - "settings.autoSkipIntro": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Intro automatisch überspringen" + "settings.autoSkipIntro" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Intro automatisch überspringen" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Skip intros automatically" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Skip intros automatically" } } } }, - "settings.build": { - "comment": "Zeitpunkt, zu dem der laufende Build entstanden ist", - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Build" + "settings.build" : { + "comment" : "Zeitpunkt, zu dem der laufende Build entstanden ist", + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Build" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Build" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Build" } } } }, - "settings.connection": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Verbindung" + "settings.connection" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verbindung" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Connection" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Connection" } } } }, - "settings.connectionLocal": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Lokal" + "settings.connectionLocal" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lokal" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Local" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Local" } } } }, - "settings.connectionRelay": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Über Plex-Vermittlung (gedrosselt)" + "settings.connectionRelay" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Über Plex-Vermittlung (gedrosselt)" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Via Plex relay (throttled)" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Via Plex relay (throttled)" } } } }, - "settings.connectionRemote": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Direkt von außen" + "settings.connectionRemote" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Direkt von außen" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Direct remote" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Direct remote" } } } }, - "settings.deleteAfterDay": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Nach einem Tag" + "settings.deleteAfterDay" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nach einem Tag" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "After a day" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "After a day" } } } }, - "settings.deleteAfterWeek": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Nach einer Woche" + "settings.deleteAfterWeek" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nach einer Woche" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "After a week" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "After a week" } } } }, - "settings.deleteImmediately": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Sofort" + "settings.deleteImmediately" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sofort" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Immediately" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Immediately" } } } }, - "settings.deleteNever": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Nie" + "settings.deleteNever" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nie" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Never" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Never" } } } }, - "settings.deleteWatched": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Gesehenes löschen" + "settings.deleteWatched" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gesehenes löschen" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Delete watched" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Delete watched" } } } }, - "settings.downloadsExplanation": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Fehlende Folgen werden nachgeladen, sobald du eine gesehen hast. Gesehenes wird nach der eingestellten Frist entfernt." + "settings.downloadsExplanation" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fehlende Folgen werden nachgeladen, sobald du eine gesehen hast. Gesehenes wird nach der eingestellten Frist entfernt." } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Missing episodes are downloaded once you finish one. Watched titles are removed after the chosen delay." + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Missing episodes are downloaded once you finish one. Watched titles are removed after the chosen delay." } } } }, - "settings.keepCount %@": { - "comment": "A label that lets the user choose how many episodes of a series to keep after they've finished watching them all. The argument is the number of episodes to keep.", - "isCommentAutoGenerated": true + "settings.keepCount %@" : { + "comment" : "A label that lets the user choose how many episodes of a series to keep after they've finished watching them all. The argument is the number of episodes to keep.", + "isCommentAutoGenerated" : true }, - "settings.keepCount %lld": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "%lld ungesehene" + "settings.keepCount %lld" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld ungesehene" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "%lld unwatched" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld unwatched" } } } }, - "settings.keepOff": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Aus" + "settings.keepOff" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aus" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Off" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Off" } } } }, - "settings.keepUnwatched": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Folgen vorhalten" + "settings.keepUnwatched" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Folgen vorhalten" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Keep episodes ready" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Keep episodes ready" } } } }, - "settings.libraries": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Mediatheken" + "settings.libraries" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mediatheken" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Libraries" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Libraries" } } } }, - "settings.playback": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Wiedergabe" + "settings.playback" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wiedergabe" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Playback" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Playback" } } } }, - "settings.quality": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Qualität" + "settings.quality" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Qualität" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Quality" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Quality" } } } }, - "settings.qualityCellular": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Über Mobilfunk" + "settings.qualityCellular" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Über Mobilfunk" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "On cellular" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "On cellular" } } } }, - "settings.qualityExplanation": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Wird eine Datei größer als die eingestellte Grenze, rechnet der Server sie unterwegs herunter und passt die Bitrate laufend an die Verbindung an." + "settings.qualityExplanation" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wird eine Datei größer als die eingestellte Grenze, rechnet der Server sie unterwegs herunter und passt die Bitrate laufend an die Verbindung an." } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "If a file exceeds the limit, the server transcodes it on the fly and adapts the bitrate to your connection." + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "If a file exceeds the limit, the server transcodes it on the fly and adapts the bitrate to your connection." } } } }, - "settings.qualityWifi": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Im WLAN" + "settings.qualityWifi" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Im WLAN" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "On Wi-Fi" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "On Wi-Fi" } } } }, - "settings.scanExplanation": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Der Suchlauf durchsucht die Ordner auf dem Server nach neuen Dateien. Das kann einige Minuten dauern." + "settings.scanExplanation" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Der Suchlauf durchsucht die Ordner auf dem Server nach neuen Dateien. Das kann einige Minuten dauern." } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "A scan looks through the folders on the server for new files. It can take a few minutes." + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "A scan looks through the folders on the server for new files. It can take a few minutes." } } } }, - "settings.server": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Server" + "settings.server" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Server" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Server" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Server" } } } }, - "settings.serverKind": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Art" + "settings.serverKind" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Art" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Type" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Type" } } } }, - "settings.serverName": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Name" + "settings.serverName" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Name" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Name" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Name" } } } }, - "settings.signOutConfirm": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Wirklich abmelden?" + "settings.signOutConfirm" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wirklich abmelden?" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Sign out?" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sign out?" } } } }, - "settings.signOutExplanation": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Das Token wird vom Gerät entfernt. Heruntergeladene Titel bleiben erhalten." + "settings.signOutExplanation" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Das Token wird vom Gerät entfernt. Heruntergeladene Titel bleiben erhalten." } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "The token will be removed from this device. Downloaded titles are kept." + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The token will be removed from this device. Downloaded titles are kept." } } } }, - "settings.storageLimit": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Speicherlimit" + "settings.storageLimit" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Speicherlimit" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Storage limit" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Storage limit" } } } }, - "settings.storageUnlimited": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Unbegrenzt" + "settings.storageUnlimited" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unbegrenzt" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Unlimited" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unlimited" } } } }, - "settings.version": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Version" + "settings.version" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Version" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Version" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Version" } } } }, - "sidebar.close": { - "comment": "Barrierefreiheit: abgedunkelte Fläche", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Seitenleiste schließen" + "sidebar.close" : { + "comment" : "Barrierefreiheit: abgedunkelte Fläche", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Seitenleiste schließen" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Close sidebar" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Close sidebar" } } } }, - "sidebar.downloads": { - "comment": "Eintrag in der Seitenleiste", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Downloads" + "sidebar.downloads" : { + "comment" : "Eintrag in der Seitenleiste", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Downloads" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Downloads" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Downloads" } } } }, - "sidebar.home": { - "comment": "Eintrag der Startseite in der Seitenleiste", - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Start" + "sidebar.home" : { + "comment" : "Eintrag der Startseite in der Seitenleiste", + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Start" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Home" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Home" } } } }, - "sidebar.libraries": { - "comment": "Überschrift über der Liste der Mediatheken", - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Mediatheken" + "sidebar.libraries" : { + "comment" : "Überschrift über der Liste der Mediatheken", + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mediatheken" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Libraries" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Libraries" } } } }, - "sidebar.liveTV": { - "comment": "Eintrag in der Seitenleiste", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Live-TV" + "sidebar.liveTV" : { + "comment" : "Eintrag in der Seitenleiste", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Live-TV" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Live TV" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Live TV" } } } }, - "sidebar.more": { - "comment": "Überschrift über Live-TV, Downloads und Einstellungen", - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Mehr" + "sidebar.more" : { + "comment" : "Überschrift über Live-TV, Downloads und Einstellungen", + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mehr" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "More" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "More" } } } }, - "sidebar.open": { - "comment": "Barrierefreiheit: Hamburger-Knopf", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Seitenleiste öffnen" + "sidebar.open" : { + "comment" : "Barrierefreiheit: Hamburger-Knopf", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Seitenleiste öffnen" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Open sidebar" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Open sidebar" } } } }, - "sidebar.settings": { - "comment": "Eintrag in der Seitenleiste", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Einstellungen" + "sidebar.settings" : { + "comment" : "Eintrag in der Seitenleiste", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Einstellungen" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Settings" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Settings" } } } }, - "signIn.cancelled": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Anmeldung abgebrochen." + "signIn.cancelled" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Anmeldung abgebrochen." } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Sign-in cancelled." + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sign-in cancelled." } } } }, - "signIn.jellyfin.addressHint": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Schema und Port kannst du weglassen — die üblichen werden durchprobiert." + "signIn.jellyfin.addressHint" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Schema und Port kannst du weglassen — die üblichen werden durchprobiert." } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "You can leave out the scheme and port — the usual ones are tried in turn." + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "You can leave out the scheme and port — the usual ones are tried in turn." } } } }, - "signIn.jellyfin.addressMissing": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Trag bitte die Adresse deines Servers ein." + "signIn.jellyfin.addressMissing" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Trag bitte die Adresse deines Servers ein." } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Please enter your server's address." + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Please enter your server's address." } } } }, - "signIn.jellyfin.addressPlaceholder": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "192.168.1.50 oder medien.example.com" + "signIn.jellyfin.addressPlaceholder" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "192.168.1.50 oder medien.example.com" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "192.168.1.50 or media.example.com" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "192.168.1.50 or media.example.com" } } } }, - "signIn.jellyfin.addressPrompt": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Wo steht dein Server?" + "signIn.jellyfin.addressPrompt" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wo steht dein Server?" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Where is your server?" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Where is your server?" } } } }, - "signIn.jellyfin.codeExpired": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Der Code ist abgelaufen. Fordere einen neuen an oder melde dich mit Passwort an." + "signIn.jellyfin.codeExpired" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Der Code ist abgelaufen. Fordere einen neuen an oder melde dich mit Passwort an." } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "The code has expired. Request a new one or sign in with a password." + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The code has expired. Request a new one or sign in with a password." } } } }, - "signIn.jellyfin.connect": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Verbinden" + "signIn.jellyfin.connect" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verbinden" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Connect" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Connect" } } } }, - "signIn.jellyfin.foundServer": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Gefundener Server" + "signIn.jellyfin.foundServer" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gefundener Server" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Server found" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Server found" } } } }, - "signIn.jellyfin.notFound": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Unter dieser Adresse antwortet kein Jellyfin-Server." + "signIn.jellyfin.notFound" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Unter dieser Adresse antwortet kein Jellyfin-Server." } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "No Jellyfin server answers at that address." + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No Jellyfin server answers at that address." } } } }, - "signIn.jellyfin.otherAddress": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Andere Adresse eintragen" + "signIn.jellyfin.otherAddress" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Andere Adresse eintragen" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Enter a different address" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enter a different address" } } } }, - "signIn.jellyfin.password": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Passwort" + "signIn.jellyfin.password" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Passwort" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Password" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Password" } } } }, - "signIn.jellyfin.passwordNote": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Das Passwort wird nur für diese eine Anfrage gehalten. Gespeichert wird allein der Zugangsschlüssel, und der im Schlüsselbund." + "signIn.jellyfin.passwordNote" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Das Passwort wird nur für diese eine Anfrage gehalten. Gespeichert wird allein der Zugangsschlüssel, und der im Schlüsselbund." } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "The password is held for this one request only. Only the access token is stored, and it goes into the keychain." + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The password is held for this one request only. Only the access token is stored, and it goes into the keychain." } } } }, - "signIn.jellyfin.privacy": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "So erreicht dein Passwort diese App nie." + "signIn.jellyfin.privacy" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "So erreicht dein Passwort diese App nie." } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "This way your password never reaches this app." + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This way your password never reaches this app." } } } }, - "signIn.jellyfin.quickConnectHint": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Öffne Jellyfin auf einem Gerät, an dem du schon angemeldet bist, und gib diesen Code unter „Benutzer → Quick Connect“ ein." + "signIn.jellyfin.quickConnectHint" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Öffne Jellyfin auf einem Gerät, an dem du schon angemeldet bist, und gib diesen Code unter „Benutzer → Quick Connect“ ein." } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Open Jellyfin on a device you're already signed in on and enter this code under “User → Quick Connect”." + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Open Jellyfin on a device you're already signed in on and enter this code under “User → Quick Connect”." } } } }, - "signIn.jellyfin.searching": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Server wird gesucht …" + "signIn.jellyfin.searching" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Server wird gesucht …" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Looking for the server …" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Looking for the server …" } } } }, - "signIn.jellyfin.signingIn": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Anmeldung läuft …" + "signIn.jellyfin.signingIn" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Anmeldung läuft …" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Signing in …" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Signing in …" } } } }, - "signIn.jellyfin.submit": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Anmelden" + "signIn.jellyfin.submit" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Anmelden" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Sign in" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sign in" } } } }, - "signIn.jellyfin.title": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Jellyfin-Server" + "signIn.jellyfin.title" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Jellyfin-Server" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Jellyfin server" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Jellyfin server" } } } }, - "signIn.jellyfin.useCode": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Stattdessen einen Code bestätigen" + "signIn.jellyfin.useCode" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stattdessen einen Code bestätigen" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Approve a code instead" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Approve a code instead" } } } }, - "signIn.jellyfin.usePassword": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Stattdessen Benutzername und Passwort" + "signIn.jellyfin.usePassword" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stattdessen Benutzername und Passwort" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Use a username and password instead" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Use a username and password instead" } } } }, - "signIn.jellyfin.userName": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Benutzername" + "signIn.jellyfin.userName" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Benutzername" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Username" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Username" } } } }, - "signIn.jellyfin.waitingForApproval": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Warte auf die Bestätigung …" + "signIn.jellyfin.waitingForApproval" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Warte auf die Bestätigung …" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Waiting for approval …" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Waiting for approval …" } } } }, - "signIn.manualHint": { - "comment": "Hinweis, wenn die Browser-Verknüpfung nicht greift", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Hat das Fenster nicht verknüpft? Öffne plex.tv/link und gib diesen Code ein:" + "signIn.manualHint" : { + "comment" : "Hinweis, wenn die Browser-Verknüpfung nicht greift", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hat das Fenster nicht verknüpft? Öffne plex.tv/link und gib diesen Code ein:" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Window didn't link? Open plex.tv/link and enter this code:" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Window didn't link? Open plex.tv/link and enter this code:" } } } }, - "signIn.privacy": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Die Anmeldung läuft über deinen Server. Dein Passwort erreicht diese App nie." + "signIn.privacy" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Die Anmeldung läuft über deinen Server. Dein Passwort erreicht diese App nie." } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Sign-in happens on your server. Your password never reaches this app." + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sign-in happens on your server. Your password never reaches this app." } } } }, - "signIn.tagline": { - "comment": "Untertitel auf dem Anmeldebildschirm", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Deine Mediathek, schöner." + "signIn.tagline" : { + "comment" : "Untertitel auf dem Anmeldebildschirm", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Deine Mediathek, schöner." } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Your library, nicer." + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Your library, nicer." } } } }, - "signIn.timedOut": { - "comment": "Die Anmeldung wurde nicht innerhalb der Gültigkeit des Codes abgeschlossen", - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Die Anmeldung wurde nicht abgeschlossen. Versuch es noch einmal." + "signIn.timedOut" : { + "comment" : "Die Anmeldung wurde nicht innerhalb der Gültigkeit des Codes abgeschlossen", + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Die Anmeldung wurde nicht abgeschlossen. Versuch es noch einmal." } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Sign-in wasn't completed. Please try again." + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sign-in wasn't completed. Please try again." } } } }, - "signIn.windowFailed": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Das Anmeldefenster ließ sich nicht öffnen." + "signIn.windowFailed" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Das Anmeldefenster ließ sich nicht öffnen." } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "The sign-in window couldn't be opened." + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The sign-in window couldn't be opened." } } } }, - "signIn.withJellyfin": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Mit Jellyfin anmelden" + "signIn.withJellyfin" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mit Jellyfin anmelden" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Sign in with Jellyfin" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sign in with Jellyfin" } } } }, - "signIn.withPlex": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Mit Plex anmelden" + "signIn.withPlex" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mit Plex anmelden" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Sign in with Plex" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sign in with Plex" } } } }, - "sort.lastPlayed": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Zuletzt gesehen" + "sort.lastPlayed" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zuletzt gesehen" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Last watched" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Last watched" } } } }, - "sort.rating": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Bewertung" + "sort.rating" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bewertung" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Rating" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rating" } } } }, - "sort.recentlyAdded": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Zuletzt hinzugefügt" + "sort.recentlyAdded" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zuletzt hinzugefügt" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Recently added" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Recently added" } } } }, - "sort.releaseDate": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Erscheinungsjahr" + "sort.releaseDate" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Erscheinungsjahr" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Release date" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Release date" } } } }, - "sort.titleAscending": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Titel (A–Z)" + "sort.titleAscending" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Titel (A–Z)" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Title (A–Z)" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Title (A–Z)" } } } }, - "sort.titleDescending": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Titel (Z–A)" + "sort.titleDescending" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Titel (Z–A)" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Title (Z–A)" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Title (Z–A)" } } } }, - "technical.audio": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Tonspur" + "technical.audio" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tonspur" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Audio" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Audio" } } } }, - "technical.bitrate": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Bitrate" + "technical.bitrate" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bitrate" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Bitrate" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bitrate" } } } }, - "technical.container": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Container" + "technical.container" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Container" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Container" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Container" } } } }, - "technical.directPlayNative": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Direkt (Systemplayer)" + "technical.directPlayNative" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Direkt (Systemplayer)" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Direct (system player)" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Direct (system player)" } } } }, - "technical.directPlayVLC": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Direkt (VLC)" + "technical.directPlayVLC" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Direkt (VLC)" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Direct (VLC)" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Direct (VLC)" } } } }, - "technical.file": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Datei auf dem Server" + "technical.file" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Datei auf dem Server" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "File on server" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "File on server" } } } }, - "technical.playbackMethod": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Wiedergabe" + "technical.playbackMethod" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wiedergabe" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Playback" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Playback" } } } }, - "technical.reason": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Grund" + "technical.reason" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Grund" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Reason" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reason" } } } }, - "technical.reason.audioCodec %@": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "iOS kann %@-Ton nicht wiedergeben." + "technical.reason.audioCodec %@" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "iOS kann %@-Ton nicht wiedergeben." } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "iOS can't play %@ audio." + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "iOS can't play %@ audio." } } } }, - "technical.reason.chosen": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Im Player wurde eine Qualitätsstufe gewählt." + "technical.reason.chosen" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Im Player wurde eine Qualitätsstufe gewählt." } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "A quality step was chosen in the player." + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "A quality step was chosen in the player." } } } }, - "technical.reason.container %@": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "iOS öffnet den Container %@ nicht." + "technical.reason.container %@" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "iOS öffnet den Container %@ nicht." } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "iOS can't open the %@ container." + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "iOS can't open the %@ container." } } } }, - "technical.reason.unanalyzed": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Der Server hat diese Datei noch nicht analysiert." + "technical.reason.unanalyzed" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Der Server hat diese Datei noch nicht analysiert." } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "The server hasn't analyzed this file yet." + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The server hasn't analyzed this file yet." } } } }, - "technical.reason.videoCodec %@": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "iOS kann %@ nicht dekodieren." + "technical.reason.videoCodec %@" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "iOS kann %@ nicht dekodieren." } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "iOS can't decode %@." + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "iOS can't decode %@." } } } }, - "technical.resolution": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Auflösung" + "technical.resolution" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Auflösung" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Resolution" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Resolution" } } } }, - "technical.size": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Dateigröße" + "technical.size" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dateigröße" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "File size" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "File size" } } } }, - "technical.subtitle": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Untertitel" + "technical.subtitle" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Untertitel" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Subtitle" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Subtitle" } } } }, - "technical.title": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Technische Angaben" + "technical.title" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Technische Angaben" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Technical details" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Technical details" } } } }, - "technical.transcode": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Umgerechnet vom Server" + "technical.transcode" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Umgerechnet vom Server" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Transcoded by server" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Transcoded by server" } } } }, - "technical.videoCodec": { - "extractionState": "stale", - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Bildcodec" + "technical.videoCodec" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bildcodec" } }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Video codec" + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Video codec" } } } } }, - "version": "1.1" -} + "version" : "1.1" +} \ No newline at end of file diff --git a/Flixy/Session/FlixySession.swift b/Flixy/Session/FlixySession.swift index 133e5b3..d9ae46e 100644 --- a/Flixy/Session/FlixySession.swift +++ b/Flixy/Session/FlixySession.swift @@ -138,6 +138,26 @@ final class FlixySession { } } + /// Wie die Verbindung zum Server zu beschreiben ist. + /// + /// Für die Diagnose, und deshalb hier: welcher Weg zum Server führt, weiß + /// die Sitzung. Bei Plex ist es die entscheidende Angabe — über die + /// Vermittlung von plex.tv ist alles gedrosselt, und das erklärt die + /// Hälfte aller Ruckler, bevor man weitersucht. + var connectionDescription: String? { + switch kind { + case .plex: + guard let connection = server?.connection else { return nil } + if connection.local { return "Lokal · \(connection.uri)" } + if connection.relay { return "Plex-Vermittlung (gedrosselt)" } + return "Direkt von außen · \(connection.uri)" + case .jellyfin: + return serverAddress + case nil: + return nil + } + } + /// Ob der aktive Server Live-TV anbietet, **so wie die App es heute kann**. /// /// Nicht `capabilities.contains(.liveTV)`: Jellyfin kann es, der diff --git a/Flixy/Session/PlaybackDiagnostics.swift b/Flixy/Session/PlaybackDiagnostics.swift index 2d32363..61a3767 100644 --- a/Flixy/Session/PlaybackDiagnostics.swift +++ b/Flixy/Session/PlaybackDiagnostics.swift @@ -1,7 +1,6 @@ import Foundation import Observation import FlixyPlayback -import PlexKit /// Hält fest, was beim letzten Abspielversuch passiert ist. /// @@ -30,12 +29,12 @@ final class PlaybackDiagnostics { private(set) var lastAttempt: Attempt? - func begin(title: String, connection: PlexConnection?) { + func begin(title: String, connection: String?) { lastAttempt = Attempt( startedAt: Date(), title: title, method: "—", - connection: connection.map(Self.describe) ?? "—", + connection: connection ?? "—", sourceDescription: "—" ) note("Player geöffnet") @@ -76,11 +75,6 @@ final class PlaybackDiagnostics { } } - private static func describe(_ connection: PlexConnection) -> String { - if connection.local { return "Lokal · \(connection.uri)" } - if connection.relay { return "Plex-Vermittlung (gedrosselt)" } - return "Direkt von außen · \(connection.uri)" - } private static func describe(_ url: URL) -> String { guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { diff --git a/Flixy/Views/Downloads/DownloadButton.swift b/Flixy/Views/Downloads/DownloadButton.swift index d4cbc07..0b17e26 100644 --- a/Flixy/Views/Downloads/DownloadButton.swift +++ b/Flixy/Views/Downloads/DownloadButton.swift @@ -114,8 +114,23 @@ struct DownloadButton: View { } /// Nur Stufen, die unter der Auflösung der Datei liegen. + /// + /// Und nur, wenn der Server verkleinerte Fassungen überhaupt herausgibt. + /// Jellyfin nennt die Adresse dafür erst nach einer eigenen Anfrage — die + /// Wahl anzubieten und dann abzulehnen wäre eine leere Zusage. private var availableQualities: [DownloadQuality] { - DownloadQuality.available(forSourceHeight: currentMedia?.pixelHeight) + let all = DownloadQuality.available(forSourceHeight: currentMedia?.pixelHeight) + guard canDownloadCompressed else { return [.original] } + return all + } + + private var canDownloadCompressed: Bool { + session.backend?.compressedDownloadURL( + itemID: item.id, + versionIndex: mediaIndex, + maximumBitrate: 8_000, + resolution: nil + ) != nil } /// Die Beschriftung samt Auflösung und geschätzter Größe. diff --git a/Flixy/Views/LoadState.swift b/Flixy/Views/LoadState.swift index 86d08f0..444cd12 100644 --- a/Flixy/Views/LoadState.swift +++ b/Flixy/Views/LoadState.swift @@ -78,6 +78,9 @@ enum ErrorText { case .server(let status): String(localized: "error.server \(status)") case .transport(let description): description case .decoding: String(localized: "error.unexpectedResponse") + // Die Worte des Servers, nicht unsere: er weiß besser, warum er + // ablehnt, als wir es raten könnten. + case .refused(let reason): reason } } } diff --git a/Flixy/Views/Navigation/RootView.swift b/Flixy/Views/Navigation/RootView.swift index 2c7fab3..2d2791c 100644 --- a/Flixy/Views/Navigation/RootView.swift +++ b/Flixy/Views/Navigation/RootView.swift @@ -121,12 +121,15 @@ struct RootView: View { let fresh = PlayerModel( request: request, - server: session.server, + backend: session.backend, + // Live-TV spricht als Einziges noch unmittelbar mit Plex. + liveTV: session.server, downloads: downloads, diagnostics: diagnostics, policy: settings.policy(for: network), settings: settings, isLocalConnection: session.server?.connection.local ?? false, + connectionDescription: session.connectionDescription, unavailableMessage: session.playbackUnavailableMessage ) // Aus Bild-in-Bild zurückzukehren soll im Vollbild landen, nicht in diff --git a/Flixy/Views/Player/PlayerModel.swift b/Flixy/Views/Player/PlayerModel.swift index 55a16c6..a7f3dee 100644 --- a/Flixy/Views/Player/PlayerModel.swift +++ b/Flixy/Views/Player/PlayerModel.swift @@ -44,10 +44,10 @@ final class PlayerModel { /// Die Marke, in der wir uns gerade befinden — Grundlage für den /// Überspringen-Knopf. - private(set) var activeMarker: PlexMarker? + private(set) var activeMarker: MediaMarker? /// Die nächste Folge, sofern es eine gibt. Wird im Hintergrund geholt, /// damit sie am Ende ohne Wartezeit bereitsteht. - private(set) var nextEpisode: PlexItem? + private(set) var nextEpisode: MediaItem? private(set) var hasReachedEnd = false /// Ob aus dem Download-Ordner gespielt wird. Dann gibt es keinen /// Fortschritt zu melden und keine nächste Folge nachzuschlagen. @@ -93,12 +93,25 @@ final class PlayerModel { @ObservationIgnored private var restoreFromPictureInPicture: (() -> Void)? private let request: PlaybackRequest - private let server: PlexServer? + private let backend: (any MediaBackend)? + /// Der Plex-Server — nur noch für Live-TV. + /// + /// Programmführer und Tuner sind das Einzige, was hier noch unmittelbar + /// mit Plex spricht. Ein Tuner ist ein Stück Hardware, und die fünfhundert + /// Zeilen, die es gekostet hat, ihn zu bändigen, gehören in keinen + /// gemeinsamen Vertrag. Bei Jellyfin ist er `nil`. + private let liveTV: PlexServer? private let downloads: DownloadCoordinator private let diagnostics: PlaybackDiagnostics private let policy: PlaybackPolicy private let settings: PlaybackSettings private let isLocalConnection: Bool + /// Wie die Verbindung in der Diagnose heißt. + /// + /// Von außen gereicht: welcher Weg zum Server führt, weiß die Sitzung — + /// und über die Vermittlung von plex.tv ist alles gedrosselt, was die + /// Hälfte aller Ruckler erklärt, bevor man weitersucht. + private let connectionDescription: String? /// Was zu sagen ist, wenn es keinen Server gibt, mit dem sich abspielen /// ließe. /// @@ -107,16 +120,23 @@ final class PlayerModel { /// Server angemeldet ist, weiß die Sitzung, nicht der Player. private let unavailableMessage: String - private var item: PlexItem? + private var item: MediaItem? /// Die gerade gespielte Fassung und Datei. /// /// Festgehalten, damit ein Qualitätswechsel die Quelle neu bauen kann, /// ohne die Metadaten erneut zu holen. - private var currentMedia: PlexMedia? - private var currentPart: PlexPart? + private var currentMedia: MediaVersion? + private var currentPart: MediaFile? private var eventTask: Task? private var reportTask: Task? - private var transcodeSession: String? + /// Was der Server für diese Wiedergabe angelegt hat. + /// + /// Zum Abräumen und zum Melden: Jellyfin ordnet den Fortschritt über + /// dieselbe Kennung zu, über die Plex den Transcoder anhält. + private var playbackSession: String? + /// Welche Fassung gerade läuft — Jellyfin will sie bei jeder Meldung + /// zurückhaben. + private var playbackVersionID: String? /// Wann die angezeigte Sendung endet — danach stimmt der Untertitel nicht /// mehr. private var programmeEndsAt: Date? @@ -129,21 +149,25 @@ final class PlayerModel { init( request: PlaybackRequest, - server: PlexServer?, + backend: (any MediaBackend)?, + liveTV: PlexServer?, downloads: DownloadCoordinator, diagnostics: PlaybackDiagnostics, policy: PlaybackPolicy, settings: PlaybackSettings, isLocalConnection: Bool, + connectionDescription: String?, unavailableMessage: String ) { self.request = request - self.server = server + self.backend = backend + self.liveTV = liveTV self.downloads = downloads self.diagnostics = diagnostics self.policy = policy self.settings = settings self.isLocalConnection = isLocalConnection + self.connectionDescription = connectionDescription self.unavailableMessage = unavailableMessage self.title = request.title } @@ -151,7 +175,7 @@ final class PlayerModel { // MARK: - Start func start() async { - diagnostics.begin(title: request.title, connection: server?.connection) + diagnostics.begin(title: request.title, connection: connectionDescription) // Die lokale Datei hat immer Vorrang. Sie ist schneller, kostet kein // Datenvolumen und funktioniert auch dann, wenn der Server gerade @@ -166,15 +190,15 @@ final class PlayerModel { return } - guard let server else { + guard let backend else { phase = .failed(message: unavailableMessage) return } diagnostics.note("Metadaten anfragen") - let item: PlexItem + let item: MediaItem do { - item = try await server.metadata(ratingKey: request.ratingKey) + item = try await backend.item(id: request.ratingKey) diagnostics.note("Metadaten da") } catch { diagnostics.finish(outcome: "Metadaten fehlgeschlagen: \(ErrorText.describe(error))") @@ -183,10 +207,10 @@ final class PlayerModel { } self.item = item - title = item.asMediaItem.rowTitle + title = item.rowTitle subtitle = playerSubtitle(for: item) - guard let media = version(of: item), let part = media.parts.first else { + guard let media = version(of: item), let part = media.files.first else { phase = .failed(message: String(localized: "player.noPlayableFile")) return } @@ -194,10 +218,10 @@ final class PlayerModel { currentMedia = media currentPart = part - let decision = PlaybackDecider().decide(profile: media.profile(part: part), policy: policy) + let decision = PlaybackDecider().decide(profile: media.profile(file: part), policy: policy) self.decision = decision - guard let source = await source(for: decision, item: item, media: media, part: part, server: server) else { + guard let source = await source(for: decision, item: item, media: media, part: part, backend: backend) else { phase = .failed(message: String(localized: "player.noPlayableFile")) return } @@ -220,7 +244,7 @@ final class PlayerModel { let isRetry = { if case .failed = phase { return true } else { return false } }() guard newQuality != quality || isRetry, !isOffline, - let server, let item, let media = currentMedia, let part = currentPart + let backend, let item, let media = currentMedia, let part = currentPart else { return } isChangingQuality = true @@ -232,9 +256,9 @@ final class PlayerModel { // Den alten Transcoder anhalten, bevor der neue anläuft. Sonst rechnet // der Server zwei Streams gleichzeitig für einen Zuschauer. - if let transcodeSession { - await server.stopTranscode(sessionIdentifier: transcodeSession) - self.transcodeSession = nil + if let playbackSession { + await backend.endPlayback(sessionID: playbackSession) + self.playbackSession = nil } eventTask?.cancel() engine?.teardown() @@ -243,7 +267,7 @@ final class PlayerModel { let newPolicy = newQuality.policy(basedOn: policy) let newDecision = PlaybackDecider().decide( - profile: media.profile(part: part), + profile: media.profile(file: part), policy: newPolicy ) decision = newDecision @@ -253,7 +277,7 @@ final class PlayerModel { item: item, media: media, part: part, - server: server, + backend: backend, startingAt: resumeAt ) else { // Nur dann eine allgemeine Meldung, wenn `source` keine eigene @@ -298,7 +322,7 @@ final class PlayerModel { // Die Sitzungskennung aus der Adresse übernehmen. Ohne sie bleibt der // Tuner nach dem Schließen belegt — und beim nächsten Sender hat der // Server keinen freien mehr. - transcodeSession = URLComponents(url: url, resolvingAgainstBaseURL: false)? + playbackSession = URLComponents(url: url, resolvingAgainstBaseURL: false)? .queryItems?.first { $0.name == "session" }?.value // Live kommt immer umgerechnet: der Tuner liefert einen @@ -360,43 +384,45 @@ final class PlayerModel { } } - /// Baut die Quelle — entweder die Datei selbst oder den HLS-Stream. + /// Lässt den Server die Wiedergabe vorbereiten. + /// + /// Was hier steht, ist für jeden Server dasselbe: die Entscheidung ist + /// gefallen, der Server soll sie ausführen. Wie er das tut — Plex' Universal + /// Transcoder, Jellyfins PlaybackInfo — steht in der jeweiligen Hülle. private func source( for decision: PlaybackDecision, - item: PlexItem, - media: PlexMedia, - part: PlexPart, - server: PlexServer, + item: MediaItem, + media: MediaVersion, + part: MediaFile, + backend: any MediaBackend, startingAt: Duration? = nil ) async -> PlaybackSource? { let start = startingAt ?? request.startTime - switch decision.method { - case .directPlay: - guard let url = server.directPlayURL(part: part) else { return nil } - return PlaybackSource(url: url, origin: .remote, startTime: start) - + let transcode: TranscodeSettings? = switch decision.method { + case .directPlay: nil case .transcode(let maximumBitrate): - let transcode = TranscodeRequest( - ratingKey: item.ratingKey, - mediaIndex: request.mediaIndex, - maximumBitrate: maximumBitrate, - resolution: quality.resolution, - isLocal: isLocalConnection - ) - transcodeSession = transcode.sessionIdentifier + TranscodeSettings(maximumBitrate: maximumBitrate, resolution: quality.resolution) + } - // Vorher fragen, ob der Server überhaupt mitspielt. Sagt er hier - // schon nein, steht der Grund im Klartext da — statt eines - // schwarzen Bildes nach zwanzig Sekunden Warten. - if let decision = try? await server.transcodeDecision(for: transcode), - !decision.isAllowed { - phase = .failed(message: decision.explanation ?? String(localized: "player.transcodeRefused")) - return nil - } - - guard let url = server.transcodeURL(for: transcode) else { return nil } - return PlaybackSource(url: url, origin: .remote, startTime: start) + do { + let target = try await backend.preparePlayback(PlaybackIntent( + itemID: item.id, + versionIndex: request.mediaIndex, + file: part, + transcode: transcode, + startTime: start, + isLocalConnection: isLocalConnection + )) + playbackSession = target.sessionID + playbackVersionID = target.versionID + return PlaybackSource(url: target.url, origin: .remote, startTime: start) + } catch { + // Die Worte des Servers stehen auf dem Bildschirm, nicht „keine + // abspielbare Datei". Sagt er, warum er ablehnt, ist das die einzige + // brauchbare Spur — und genau sie ging vorher verloren. + phase = .failed(message: ErrorText.describe(error)) + return nil } } @@ -432,7 +458,7 @@ final class PlayerModel { /// wirklich abgelaufen ist — das Raster über alle Sender zu holen ist /// nichts, was man bei jedem Antippen tun will. func refreshLiveProgrammeIfNeeded() async { - guard isLive, let server else { return } + guard isLive, let server = liveTV else { return } if let programmeEndsAt, programmeEndsAt > .now { return } guard let provider = await server.electronicProgramGuideIdentifier() else { return } @@ -475,7 +501,7 @@ final class PlayerModel { func skipActiveMarker() { guard let marker = activeMarker else { return } skippedMarkers.insert(marker.id) - seek(to: .milliseconds(marker.endTimeOffset)) + seek(to: .milliseconds(marker.endMilliseconds)) } func selectAudioTrack(_ track: MediaTrack) { @@ -520,14 +546,14 @@ final class PlayerModel { // Den Transcoder anhalten. Ohne dieses Signal rechnet der Server // minutenlang weiter, obwohl niemand mehr zusieht. - if let transcodeSession, let server { - await server.stopTranscode(sessionIdentifier: transcodeSession) + if let playbackSession, let backend { + await backend.endPlayback(sessionID: playbackSession) } // Und bei Live-TV den Tuner freigeben. Er ist ein Stück Hardware: // vier Stück gibt es, und ein nicht freigegebener fehlt beim nächsten // Einschalten — bis der Server von selbst merkt, dass niemand zusieht. - if let subscription = request.liveSubscriptionKey, let server { + if let subscription = request.liveSubscriptionKey, let server = liveTV { await server.endLiveSession(subscriptionKey: subscription) } } @@ -542,6 +568,10 @@ final class PlayerModel { private func startReporting() { reportTask?.cancel() reportTask = Task { [weak self] in + // Sofort, nicht erst nach zehn Sekunden: erst diese Meldung legt + // beim Server die Sitzung an, unter der alles Weitere läuft. + await self?.report(state: .started) + while !Task.isCancelled { // Alle zehn Sekunden — so oft melden es auch die Apps von Plex // selbst. Häufiger brächte nichts, seltener verlöre beim @@ -554,13 +584,28 @@ final class PlayerModel { } private func report(state: PlaybackReportState) async { - guard let server, let item, let duration, currentTime > .zero else { return } - await server.reportProgress( - ratingKey: item.ratingKey, + guard let backend, let item else { return } + + // Die Laufzeit der Engine ist die genauere, aber sie steht erst ein + // paar Sekunden nach dem Öffnen fest. Für die erste Meldung tut es die + // des Servers — sonst fiele ausgerechnet der Bericht aus, der die + // Sitzung anlegt, und der Titel erschiene nirgends als „läuft gerade". + guard let total = duration ?? item.duration.map({ Duration.milliseconds($0) }) + else { return } + + // Beim Beginn steht die Zeit noch auf null — das ist keine fehlende + // Angabe, sondern der Anfang. Alle anderen Meldungen ohne Zeit wären + // dagegen wertlos und würden einen gemerkten Fortschritt überschreiben. + guard currentTime > .zero || state == .started else { return } + + await backend.reportProgress(PlaybackReport( + itemID: item.id, + sessionID: playbackSession, + versionID: playbackVersionID, state: state, time: currentTime, - duration: duration - ) + duration: total + )) } // MARK: - Marken und nächste Folge @@ -570,9 +615,7 @@ final class PlayerModel { let milliseconds = currentTime.milliseconds activeMarker = item.markers.first { marker in - guard milliseconds >= marker.startTimeOffset, milliseconds < marker.endTimeOffset else { - return false - } + guard marker.contains(milliseconds) else { return false } // Der Abspann zählt nur als Marke, wenn danach noch etwas kommt — // bei einem Film ist „Abspann überspringen" gleichbedeutend mit // „Film beenden". @@ -587,9 +630,9 @@ final class PlayerModel { } private func preloadNextEpisode() { - guard let item, item.kind == .episode, let server else { return } + guard let item, item.kind == .episode, let backend else { return } Task { [weak self] in - let next = try? await server.nextEpisode(after: item) + let next = try? await backend.nextEpisode(after: item) self?.nextEpisode = next } } @@ -599,8 +642,8 @@ final class PlayerModel { func nextEpisodeRequest() -> PlaybackRequest? { guard let nextEpisode else { return nil } return PlaybackRequest( - ratingKey: nextEpisode.ratingKey, - title: nextEpisode.asMediaItem.playbackTitle, + ratingKey: nextEpisode.id, + title: nextEpisode.playbackTitle, mediaIndex: 0, startTime: .zero ) @@ -608,11 +651,12 @@ final class PlayerModel { // MARK: - Innereien - private func version(of item: PlexItem) -> PlexMedia? { - guard item.media.indices.contains(request.mediaIndex) else { return item.preferredMedia } - return item.media[request.mediaIndex] + private func version(of item: MediaItem) -> MediaVersion? { + guard item.versions.indices.contains(request.mediaIndex) else { return item.preferredVersion } + return item.versions[request.mediaIndex] } + /// Hängt die gehaltene Bildebene an den aktuellen Engine. /// /// Nach **jedem** Engine-Wechsel nötig — also auch nach einem @@ -697,11 +741,8 @@ final class PlayerModel { // und der Auslöser fürs Nachladen: der Vorrat ist jetzt // um eine Folge kleiner. downloads.markWatched(ratingKey: request.ratingKey) - Task { [server, downloads] in - await DownloadRefiller( - backend: server.map(PlexBackend.init), - coordinator: downloads - ).refill() + Task { [backend, downloads] in + await DownloadRefiller(backend: backend, coordinator: downloads).refill() } case .tracksResolved(let audio, let subtitle): audioTracks = audio @@ -727,7 +768,7 @@ final class PlayerModel { } } - private func playerSubtitle(for item: PlexItem) -> String? { + private func playerSubtitle(for item: MediaItem) -> String? { guard item.kind == .episode else { return nil } if let season = item.parentIndex, let episode = item.index { return "\(String(localized: "episode.short \(season) \(episode)")) · \(item.title)" diff --git a/Packages/FlixyKit/Sources/JellyfinKit/JellyfinBackend+Playback.swift b/Packages/FlixyKit/Sources/JellyfinKit/JellyfinBackend+Playback.swift new file mode 100644 index 0000000..9cfd297 --- /dev/null +++ b/Packages/FlixyKit/Sources/JellyfinKit/JellyfinBackend+Playback.swift @@ -0,0 +1,211 @@ +import Foundation +import MediaKit + +extension JellyfinBackend { + // MARK: - Abspielen + + public func preparePlayback( + _ intent: PlaybackIntent + ) async throws(MediaBackendError) -> PlaybackTarget { + // Jellyfin trennt Fassung und Datei nicht: was die neutrale Form + // „Datei" nennt, ist dort die Quelle, und ihre Kennung wird bei jedem + // weiteren Schritt wieder verlangt. + let sourceID = intent.file.key ?? intent.itemID + + guard let transcode = intent.transcode else { + // `static=true` schaltet jede Umrechnung ab: die Datei kommt so, + // wie sie auf der Platte liegt. Ob das Gerät sie überhaupt + // abspielen kann, ist vorher entschieden worden — und wenn nicht + // der Systemplayer, dann VLC. + guard let url = client.url( + path: "/Videos/\(intent.itemID)/stream", + query: [ + URLQueryItem(name: "static", value: "true"), + URLQueryItem(name: "mediaSourceId", value: sourceID), + ], + includingToken: account.token + ) else { throw .notFound } + + // Auch hier eine Sitzungskennung, und zwar eine selbst vergebene: + // ohne sie ordnet Jellyfin die Fortschrittsmeldungen keiner + // Wiedergabe zu, und der Titel bliebe für den Server ungesehen. + return PlaybackTarget( + url: url, + sessionID: UUID().uuidString, + versionID: sourceID + ) + } + + let info = try await playbackInfo( + itemID: intent.itemID, + sourceID: sourceID, + transcode: transcode + ) + + let source = info.mediaSources?.first { $0.id == sourceID } ?? info.mediaSources?.first + + // Die Adresse kommt vom Server, nicht von uns. Er weiß, welche Spuren + // er kopieren kann und welche er neu rechnen muss; eine selbst + // zusammengebaute Adresse wäre eine Vermutung darüber. + guard let path = source?.transcodingURL, + let url = URL(string: path, relativeTo: client.baseURL)?.absoluteURL + else { + throw .refused(reason: String( + localized: "player.transcodeUnavailable", + bundle: .module + )) + } + + return PlaybackTarget( + url: Self.limiting(height: transcode.resolution, in: url), + sessionID: info.playSessionID, + versionID: source?.id ?? sourceID + ) + } + + public func endPlayback(sessionID: String) async { + // Ohne dieses Signal rechnet der Transcoder weiter, bis der Server von + // selbst merkt, dass niemand mehr Segmente abholt. Bei direkter + // Wiedergabe gibt es nichts anzuhalten — der Aufruf läuft dann ins + // Leere, und das ist billiger als sich zu merken, welcher Fall es war. + _ = try? await client.send(JellyfinRequest( + method: .delete, + path: "/Videos/ActiveEncodings", + query: [ + URLQueryItem(name: "deviceId", value: client.deviceID), + URLQueryItem(name: "playSessionId", value: sessionID), + ], + timeout: .seconds(5) + )) + } + + public func reportProgress(_ report: PlaybackReport) async { + var fields: [String: Any] = [ + "ItemId": report.itemID, + "PositionTicks": report.time.milliseconds.ticksFromMilliseconds, + "IsPaused": report.state == .paused, + "CanSeek": true, + ] + if let sessionID = report.sessionID { fields["PlaySessionId"] = sessionID } + if let versionID = report.versionID { fields["MediaSourceId"] = versionID } + + // Erster und letzter Bericht gehen an eigene Endpunkte. Ohne den + // ersten erscheint der Titel nirgends als „läuft gerade"; ohne den + // letzten bleibt er für immer dort stehen. Ein Fortschritt mit + // derselben Zahl täte weder das eine noch das andere. + let path = switch report.state { + case .started: "/Sessions/Playing" + case .stopped: "/Sessions/Playing/Stopped" + default: "/Sessions/Playing/Progress" + } + + guard let body = try? JSONSerialization.data(withJSONObject: fields) else { return } + _ = try? await client.send(JellyfinRequest( + method: .post, + path: path, + body: body, + timeout: .seconds(10) + )) + } + + // MARK: - Hilfen + + private func playbackInfo( + itemID: String, + sourceID: String, + transcode: TranscodeSettings + ) async throws(MediaBackendError) -> JellyfinPlaybackInfo { + // Jellyfin rechnet in Bit, die App in Kilobit. + let bitsPerSecond = (transcode.maximumBitrate ?? 8_000) * 1_000 + + let profile: [String: Any] = [ + "MaxStreamingBitrate": bitsPerSecond, + // Leer heißt: nichts geht unverändert. An dieser Stelle ist bereits + // entschieden, dass umgerechnet werden soll — dem Server die Wahl + // zu lassen hieße, die Entscheidung zweimal zu treffen. + "DirectPlayProfiles": [], + "TranscodingProfiles": [[ + "Type": "Video", + "Container": "ts", + "Protocol": "hls", + "VideoCodec": "h264", + "AudioCodec": "aac,mp3", + "Context": "Streaming", + "MaxAudioChannels": "2", + "MinSegments": 1, + "BreakOnNonKeyFrames": true, + ]], + "SubtitleProfiles": [ + ["Format": "vtt", "Method": "Hls"], + ["Format": "srt", "Method": "External"], + ], + ] + + let body: Data + do { + body = try JSONSerialization.data(withJSONObject: ["DeviceProfile": profile]) + } catch { + throw .decoding(description: String(describing: error)) + } + + return try await client.send( + JellyfinRequest( + method: .post, + path: "/Items/\(itemID)/PlaybackInfo", + query: [ + URLQueryItem(name: "userId", value: account.userID), + URLQueryItem(name: "mediaSourceId", value: sourceID), + URLQueryItem(name: "maxStreamingBitrate", value: String(bitsPerSecond)), + // **Kein** `startTimeTicks`. Es gäbe zwei Stellen, die + // über den Startpunkt entscheiden: der Server ließe den + // Strom dort beginnen, und der Player spränge anschließend + // noch einmal um dieselbe Strecke vor — bei einundzwanzig + // Minuten Fortschritt landete man bei zweiundvierzig. + // Gesprungen wird an genau einer Stelle, und das ist der + // Player. + URLQueryItem(name: "enableDirectPlay", value: "false"), + URLQueryItem(name: "enableDirectStream", value: "false"), + URLQueryItem(name: "enableTranscoding", value: "true"), + ], + body: body + ), + as: JellyfinPlaybackInfo.self + ) + } + + /// Hängt die gewünschte Bildhöhe an die Adresse des Servers. + /// + /// Die Bitrate versteht Jellyfin über das Geräteprofil, die Bildhöhe nicht + /// ohne Weiteres. Sie wird deshalb nachträglich gesetzt — und eine bereits + /// vorhandene Angabe ersetzt, statt eine zweite danebenzustellen: bei zwei + /// gleichnamigen Werten gilt beim Server der erste, und das wäre dann + /// gerade nicht der gewählte. + /// + /// `resolution` kommt in der Form „1280x720"; gebraucht wird die zweite + /// Zahl. + static func limiting(height resolution: String?, in url: URL) -> URL { + guard let resolution, + let height = resolution.split(separator: "x").last.flatMap({ Int($0) }), + var components = URLComponents(url: url, resolvingAgainstBaseURL: false) + else { return url } + + var items = (components.queryItems ?? []).filter { + let name = $0.name.lowercased() + return name != "maxheight" && name != "maxwidth" + } + items.append(URLQueryItem(name: "maxHeight", value: String(height))) + components.queryItems = items + return components.url ?? url + } +} + +/// Was der Server für eine Wiedergabe vorbereitet hat. +public struct JellyfinPlaybackInfo: Decodable, Sendable { + public let mediaSources: [JellyfinMediaSource]? + public let playSessionID: String? + + private enum CodingKeys: String, CodingKey { + case mediaSources = "MediaSources" + case playSessionID = "PlaySessionId" + } +} diff --git a/Packages/FlixyKit/Sources/JellyfinKit/JellyfinBackend.swift b/Packages/FlixyKit/Sources/JellyfinKit/JellyfinBackend.swift index 119f273..71ee895 100644 --- a/Packages/FlixyKit/Sources/JellyfinKit/JellyfinBackend.swift +++ b/Packages/FlixyKit/Sources/JellyfinKit/JellyfinBackend.swift @@ -3,8 +3,8 @@ import MediaKit /// Jellyfin als `MediaBackend`. public struct JellyfinBackend: MediaBackend { - private let client: JellyfinClient - private let account: JellyfinAccount + let client: JellyfinClient + let account: JellyfinAccount private let images = JellyfinImagePaths() public let serverName: String @@ -251,12 +251,15 @@ public struct JellyfinBackend: MediaBackend { // MARK: - Herunterladen public nonisolated func downloadURL(for file: MediaFile) -> URL? { - guard let sourceID = file.key else { return nil } + // Im Pfad steht der Titel, in der Abfrage die Fassung. Beides zu + // vertauschen fällt bei einem Film mit nur einer Fassung nicht auf — + // die Kennungen sind dann dieselbe — und bei zweien sofort. + guard let itemID = file.itemID ?? file.key else { return nil } return client.url( - path: "/Videos/\(sourceID)/stream", + path: "/Videos/\(itemID)/stream", query: [ URLQueryItem(name: "static", value: "true"), - URLQueryItem(name: "mediaSourceId", value: sourceID), + URLQueryItem(name: "mediaSourceId", value: file.key ?? itemID), ], includingToken: account.token ) diff --git a/Packages/FlixyKit/Sources/JellyfinKit/JellyfinClient.swift b/Packages/FlixyKit/Sources/JellyfinKit/JellyfinClient.swift index 41f8e6e..ad0ea71 100644 --- a/Packages/FlixyKit/Sources/JellyfinKit/JellyfinClient.swift +++ b/Packages/FlixyKit/Sources/JellyfinKit/JellyfinClient.swift @@ -60,6 +60,12 @@ public actor JellyfinClient { self.token = token } + /// Unter welcher Kennung der Server dieses Gerät führt. + /// + /// Wird zum Abräumen gebraucht: eine laufende Umrechnung wird über Gerät + /// **und** Sitzung angehalten, nicht über die Sitzung allein. + public nonisolated var deviceID: String { identity.deviceID } + public nonisolated func authorization(token: String?) -> String { identity.authorizationHeader(token: token) } diff --git a/Packages/FlixyKit/Sources/JellyfinKit/JellyfinItem+Media.swift b/Packages/FlixyKit/Sources/JellyfinKit/JellyfinItem+Media.swift index fa5756b..04a216e 100644 --- a/Packages/FlixyKit/Sources/JellyfinKit/JellyfinItem+Media.swift +++ b/Packages/FlixyKit/Sources/JellyfinKit/JellyfinItem+Media.swift @@ -118,7 +118,8 @@ extension JellyfinMediaSource { // trennt — hier ist die zweite eben einelementig. MediaFile( id: id ?? itemID, - key: id, + key: id ?? itemID, + itemID: itemID, container: container, size: size, duration: runTimeTicks?.millisecondsFromTicks, diff --git a/Packages/FlixyKit/Sources/JellyfinKit/Localizable.xcstrings b/Packages/FlixyKit/Sources/JellyfinKit/Localizable.xcstrings index 762c90e..215aced 100644 --- a/Packages/FlixyKit/Sources/JellyfinKit/Localizable.xcstrings +++ b/Packages/FlixyKit/Sources/JellyfinKit/Localizable.xcstrings @@ -2,6 +2,22 @@ "sourceLanguage": "en", "version": "1.0", "strings": { + "player.transcodeUnavailable": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Der Server hat keine umgerechnete Fassung angeboten." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "The server didn't offer a transcoded version." + } + } + } + }, "row.continueWatching": { "localizations": { "de": { @@ -50,6 +66,22 @@ } } }, + "search.episodes": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Folgen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Episodes" + } + } + } + }, "search.movies": { "localizations": { "de": { @@ -81,22 +113,6 @@ } } } - }, - "search.episodes": { - "localizations": { - "de": { - "stringUnit": { - "state": "translated", - "value": "Folgen" - } - }, - "en": { - "stringUnit": { - "state": "translated", - "value": "Episodes" - } - } - } } } -} \ No newline at end of file +} diff --git a/Packages/FlixyKit/Sources/MediaKit/MediaBackend.swift b/Packages/FlixyKit/Sources/MediaKit/MediaBackend.swift index 402d190..294ccf0 100644 --- a/Packages/FlixyKit/Sources/MediaKit/MediaBackend.swift +++ b/Packages/FlixyKit/Sources/MediaKit/MediaBackend.swift @@ -59,6 +59,28 @@ public protocol MediaBackend: Sendable { resolution: String? ) -> URL? + // MARK: - Abspielen + + /// Lässt den Server eine Wiedergabe vorbereiten und nennt die Adresse. + /// + /// Wirft mit der Begründung des Servers, wenn er ablehnt. Genau daran hing + /// beim Live-TV tagelang alles: der Server hatte im Klartext gesagt, warum + /// er nicht mitspielt, und die Antwort wurde weggeworfen. + func preparePlayback(_ intent: PlaybackIntent) async throws(MediaBackendError) -> PlaybackTarget + + /// Beendet, was der Server für diese Wiedergabe angelegt hat. + /// + /// Ohne dieses Signal rechnet ein Transcoder minutenlang weiter, obwohl + /// niemand mehr zusieht. + func endPlayback(sessionID: String) async + + /// Meldet, wo man gerade steht. + /// + /// 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. + func reportProgress(_ report: PlaybackReport) async + // MARK: - Zustand melden func markWatched(id: String) async throws(MediaBackendError) diff --git a/Packages/FlixyKit/Sources/MediaKit/MediaBackendError.swift b/Packages/FlixyKit/Sources/MediaKit/MediaBackendError.swift index 21e6d50..6289b8f 100644 --- a/Packages/FlixyKit/Sources/MediaKit/MediaBackendError.swift +++ b/Packages/FlixyKit/Sources/MediaKit/MediaBackendError.swift @@ -2,7 +2,7 @@ import Foundation /// Was beim Sprechen mit einem Server schiefgehen kann. /// -/// Dieselben sechs Fälle galten schon für Plex — sie sind nicht serverbezogen, +/// Sechs der sieben Fälle galten schon für Plex — sie sind nicht serverbezogen, /// sondern beschreiben die Lage, in der man steckt. Dass es sie jetzt nur /// einmal gibt, beseitigt nebenbei eine Doppelung: die Übersetzung in /// lesbaren Text stand an zwei Stellen, und die beiden waren bereits @@ -19,4 +19,11 @@ public enum MediaBackendError: Error, Equatable, Sendable { case transport(description: String) /// Der Server hat geantwortet, aber anders als erwartet. case decoding(description: String) + /// Der Server hat verstanden und abgelehnt — mit eigener Begründung. + /// + /// Nicht `.server(status:)`: dort steht eine Zahl, hier ein Satz, den der + /// Server selbst formuliert hat. Beim Live-TV war genau dieser Satz die + /// einzige brauchbare Spur — und er ging verloren, solange es diesen Fall + /// nicht gab. + case refused(reason: String) } diff --git a/Packages/FlixyKit/Sources/MediaKit/MediaItem.swift b/Packages/FlixyKit/Sources/MediaKit/MediaItem.swift index a766bae..91dcc95 100644 --- a/Packages/FlixyKit/Sources/MediaKit/MediaItem.swift +++ b/Packages/FlixyKit/Sources/MediaKit/MediaItem.swift @@ -218,7 +218,7 @@ public struct MediaPerson: Sendable, Equatable, Identifiable { } /// Ein Abschnitt, den man überspringen möchte. -public struct MediaMarker: Sendable, Equatable { +public struct MediaMarker: Sendable, Equatable, Identifiable { public enum Kind: String, Sendable { case intro, credits, commercial, unknown } @@ -236,6 +236,17 @@ public struct MediaMarker: Sendable, Equatable { public func contains(_ milliseconds: Int) -> Bool { milliseconds >= startMilliseconds && milliseconds < endMilliseconds } + + /// Der Anfang taugt als Kennung. + /// + /// Zwei Marken derselben Sendung können nicht an derselben Stelle + /// beginnen, und der Player braucht die Kennung nur, um sich zu merken, + /// was er schon übersprungen hat — ohne dieses Gedächtnis spränge ein + /// Rücksprung ins Intro es sofort wieder weg. + public var id: Int { startMilliseconds } + + public var isIntro: Bool { kind == .intro } + public var isCredits: Bool { kind == .credits } } /// Ein zusätzliches Bild zu einem Titel. diff --git a/Packages/FlixyKit/Sources/MediaKit/MediaPlayback.swift b/Packages/FlixyKit/Sources/MediaKit/MediaPlayback.swift new file mode 100644 index 0000000..08ac434 --- /dev/null +++ b/Packages/FlixyKit/Sources/MediaKit/MediaPlayback.swift @@ -0,0 +1,117 @@ +import Foundation + +/// Was abgespielt werden soll und wie. +/// +/// Die Entscheidung *ob* umgerechnet wird, fällt vorher — im +/// `PlaybackDecider`, aus Profil und Leitung. Hier steht nur noch, was der +/// Server daraufhin bereitstellen soll. Das ist die Trennung, die es vorher +/// nicht gab: der Player hat Plex' Transcoder unmittelbar angesprochen und +/// konnte deshalb mit keinem anderen Server reden. +public struct PlaybackIntent: Sendable, Equatable { + public let itemID: String + /// Welche Fassung — ein Film kann mehrfach vorliegen. + public let versionIndex: Int + /// Die Datei innerhalb der Fassung. Plex adressiert Dateien, Jellyfin + /// Quellen; beide finden sich hierüber wieder. + public let file: MediaFile + /// `nil` heißt: unverändert übertragen. + public let transcode: TranscodeSettings? + public let startTime: Duration + /// Ob der Server im selben Netz steht. Plex rechnet dann anders. + public let isLocalConnection: Bool + + public init( + itemID: String, + versionIndex: Int, + file: MediaFile, + transcode: TranscodeSettings? = nil, + startTime: Duration = .zero, + isLocalConnection: Bool = false + ) { + self.itemID = itemID + self.versionIndex = versionIndex + self.file = file + self.transcode = transcode + self.startTime = startTime + self.isLocalConnection = isLocalConnection + } +} + +/// Wie weit heruntergerechnet werden soll. +public struct TranscodeSettings: Sendable, Equatable { + /// In Kilobit je Sekunde. `nil` heißt „so gut wie möglich". + public let maximumBitrate: Int? + /// Etwa „1280x720". `nil` lässt die Auflösung, wie sie ist. + public let resolution: String? + + public init(maximumBitrate: Int?, resolution: String?) { + self.maximumBitrate = maximumBitrate + self.resolution = resolution + } +} + +/// Was der Server für diese Wiedergabe bereitgestellt hat. +public struct PlaybackTarget: Sendable, Equatable { + public let url: URL + /// Die Sitzung, die der Server dafür angelegt hat. + /// + /// Zum Aufräumen: ohne dieses Kennzeichen rechnet der Transcoder weiter, + /// wenn längst niemand mehr zusieht. + public let sessionID: String? + /// Welche Fassung gespielt wird. + /// + /// Jellyfin will sie bei jeder Fortschrittsmeldung zurückhaben, sonst + /// ordnet es die Meldung keiner Wiedergabe zu. + public let versionID: String? + + public init(url: URL, sessionID: String? = nil, versionID: String? = nil) { + self.url = url + self.sessionID = sessionID + self.versionID = versionID + } +} + +/// In welchem Zustand sich die Wiedergabe befindet. +public enum PlaybackReportState: String, Sendable { + /// Der erste Bericht einer Wiedergabe. + /// + /// Eigener Fall, weil Jellyfin dafür einen eigenen Endpunkt hat: ohne ihn + /// erscheint der Titel nirgends als „läuft gerade", und die Sitzung, unter + /// der die weiteren Meldungen laufen, ist dem Server unbekannt. Plex + /// unterscheidet das nicht und behandelt ihn wie „spielt". + case started + case playing + case paused + /// Beim Verlassen des Players. Erst dieser Wert nimmt den Titel auf dem + /// Server aus „wird gerade abgespielt" heraus. + case stopped + case buffering +} + +/// Wo man gerade steht. +public struct PlaybackReport: Sendable, Equatable { + public let itemID: String + public let sessionID: String? + public let versionID: String? + public let state: PlaybackReportState + public let time: Duration + public let duration: Duration + + public init( + itemID: String, + sessionID: String? = nil, + versionID: String? = nil, + state: PlaybackReportState, + time: Duration, + duration: Duration + ) { + self.itemID = itemID + self.sessionID = sessionID + self.versionID = versionID + self.state = state + self.time = time + self.duration = duration + } +} + +extension PlaybackReportState: Equatable {} diff --git a/Packages/FlixyKit/Sources/MediaKit/MediaVersion.swift b/Packages/FlixyKit/Sources/MediaKit/MediaVersion.swift index 2dcc2bf..5ebac2d 100644 --- a/Packages/FlixyKit/Sources/MediaKit/MediaVersion.swift +++ b/Packages/FlixyKit/Sources/MediaKit/MediaVersion.swift @@ -107,6 +107,14 @@ public struct MediaFile: Sendable, Equatable, Identifiable { public let id: String /// Der Pfad, über den die Datei abgerufen wird. public let key: String? + /// Zu welchem Titel diese Datei gehört. + /// + /// Plex adressiert Dateien unmittelbar und braucht das nicht; Jellyfin + /// adressiert über den Titel und nennt die Datei nur als Beiwerk. Ohne + /// diese Angabe traf die Adresse dort den falschen Weg, sobald ein Titel + /// in mehreren Fassungen vorliegt — und nur dann, was den Fehler zu einem + /// gemacht hätte, den man erst spät findet. + public let itemID: String? public let container: String? /// Größe in Bytes. /// @@ -121,6 +129,7 @@ public struct MediaFile: Sendable, Equatable, Identifiable { public init( id: String, key: String? = nil, + itemID: String? = nil, container: String? = nil, size: Int64? = nil, duration: Int? = nil, @@ -129,6 +138,7 @@ public struct MediaFile: Sendable, Equatable, Identifiable { ) { self.id = id self.key = key + self.itemID = itemID self.container = container self.size = size self.duration = duration diff --git a/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+Playback.swift b/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+Playback.swift index 5b8e052..62bd089 100644 --- a/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+Playback.swift +++ b/Packages/FlixyKit/Sources/PlexKit/Endpoints/PlexServer+Playback.swift @@ -181,7 +181,11 @@ extension PlexServer { query: [ URLQueryItem(name: "ratingKey", value: ratingKey), URLQueryItem(name: "key", value: "/library/metadata/\(ratingKey)"), - URLQueryItem(name: "state", value: state.rawValue), + // Plex kennt keinen eigenen Zustand für den Beginn. + URLQueryItem( + name: "state", + value: state == .started ? "playing" : state.rawValue + ), URLQueryItem(name: "time", value: String(time.milliseconds)), URLQueryItem(name: "duration", value: String(duration.milliseconds)), ], @@ -227,15 +231,6 @@ extension PlexServer { } /// In welchem Zustand sich die Wiedergabe beim Melden befindet. -public enum PlaybackReportState: String, Sendable { - case playing - case paused - /// Beim Verlassen des Players. Erst dieser Wert lässt Plex den Eintrag aus - /// „wird gerade abgespielt" entfernen. - case stopped - case buffering -} - extension PlexServer { /// Die nächste Folge nach der angegebenen. /// diff --git a/Packages/FlixyKit/Sources/PlexKit/PlexBackend.swift b/Packages/FlixyKit/Sources/PlexKit/PlexBackend.swift index 0592bb6..86620ff 100644 --- a/Packages/FlixyKit/Sources/PlexKit/PlexBackend.swift +++ b/Packages/FlixyKit/Sources/PlexKit/PlexBackend.swift @@ -122,6 +122,53 @@ public struct PlexBackend: MediaBackend { ) } + // MARK: - Abspielen + + public func preparePlayback( + _ intent: PlaybackIntent + ) async throws(MediaBackendError) -> PlaybackTarget { + guard let transcode = intent.transcode else { + // Unverändert übertragen: die Datei selbst, ohne Sitzung — es gibt + // nichts anzuhalten, was der Server angelegt hätte. + guard let key = intent.file.key, + let url = server.directPlayURL(part: PlexPart(id: 0, key: key)) + else { throw .notFound } + return PlaybackTarget(url: url) + } + + let request = TranscodeRequest( + ratingKey: intent.itemID, + mediaIndex: intent.versionIndex, + maximumBitrate: transcode.maximumBitrate, + resolution: transcode.resolution, + isLocal: intent.isLocalConnection + ) + + // Vorher fragen, ob der Server überhaupt mitspielt. Sagt er hier schon + // nein, steht der Grund im Klartext da — statt eines schwarzen Bildes + // nach zwanzig Sekunden Warten. + let decision = try await mapping { try await server.transcodeDecision(for: request) } + guard decision.isAllowed else { + throw .refused(reason: decision.explanation ?? "") + } + + guard let url = server.transcodeURL(for: request) else { throw .notFound } + return PlaybackTarget(url: url, sessionID: request.sessionIdentifier) + } + + public func endPlayback(sessionID: String) async { + await server.stopTranscode(sessionIdentifier: sessionID) + } + + public func reportProgress(_ report: PlaybackReport) async { + await server.reportProgress( + ratingKey: report.itemID, + state: report.state, + time: report.time, + duration: report.duration + ) + } + // MARK: - Zustand melden public func markWatched(id: String) async throws(MediaBackendError) { diff --git a/Packages/FlixyKit/Tests/JellyfinKitTests/PlaybackTests.swift b/Packages/FlixyKit/Tests/JellyfinKitTests/PlaybackTests.swift new file mode 100644 index 0000000..98ed857 --- /dev/null +++ b/Packages/FlixyKit/Tests/JellyfinKitTests/PlaybackTests.swift @@ -0,0 +1,208 @@ +import Foundation +import Testing +import MediaKit +@testable import JellyfinKit + +/// Was die App dem Server zum Abspielen schickt — und was sie daraus macht. +/// +/// Adressen und Anfragekörper entstehen ohne Netz und lassen sich deshalb +/// genau prüfen. Genau hier lagen bei Plex die Fehler, die am teuersten waren: +/// ein Wert, der nicht kodiert wurde, ein Feld, das der Server anders nennt. +@Suite("Wiedergabe bei Jellyfin") +struct PlaybackTests { + + private func backend(_ transport: RecordingTransport) -> JellyfinBackend { + let client = JellyfinClient( + baseURL: URL(string: "http://server:8096")!, + identity: JellyfinIdentity( + client: "Flixy", device: "Testgerät", deviceID: "gerät-1", version: "1" + ), + token: "tok", + transport: transport + ) + return JellyfinBackend( + client: client, + account: JellyfinAccount( + userID: "u1", userName: "Test", token: "tok", isAdministrator: false + ), + serverName: "Attrappe" + ) + } + + private var file: MediaFile { + MediaFile(id: "src-1", key: "src-1", container: "mp4") + } + + @Test("Unverändert: die Datei selbst, mit Token in der Adresse") + func buildsDirectPlayURL() async throws { + let target = try await backend(RecordingTransport()) + .preparePlayback(PlaybackIntent(itemID: "film-1", versionIndex: 0, file: file)) + + let url = try #require(URLComponents(url: target.url, resolvingAgainstBaseURL: false)) + #expect(url.path == "/Videos/film-1/stream") + // Der Systemplayer schickt keine Kopfzeilen mit. Steht der Token nicht + // in der Adresse, antwortet der Server mit „nicht angemeldet". + #expect(url.queryItems?.contains(URLQueryItem(name: "ApiKey", value: "tok")) == true) + #expect(url.queryItems?.contains(URLQueryItem(name: "static", value: "true")) == true) + #expect(url.queryItems?.contains(URLQueryItem(name: "mediaSourceId", value: "src-1")) == true) + // Auch ohne Umrechnung eine Sitzung: sonst ordnet Jellyfin den + // Fortschritt keiner Wiedergabe zu. + #expect(target.sessionID != nil) + #expect(target.versionID == "src-1") + } + + @Test("Umgerechnet: die Adresse kommt vom Server") + func usesServerTranscodingURL() async throws { + let transport = RecordingTransport(response: """ + {"MediaSources": [{"Id": "src-1", "TranscodingUrl": "/Videos/film-1/main.m3u8?api_key=tok"}], + "PlaySessionId": "ps-7"} + """) + + let target = try await backend(transport).preparePlayback(PlaybackIntent( + itemID: "film-1", + versionIndex: 0, + file: file, + transcode: TranscodeSettings(maximumBitrate: 4_000, resolution: "1280x720") + )) + + #expect(target.url.absoluteString.hasPrefix("http://server:8096/Videos/film-1/main.m3u8")) + #expect(target.sessionID == "ps-7") + + // Die Bitrate versteht Jellyfin in Bit, nicht in Kilobit. Eine Null zu + // wenig, und aus vier Megabit werden vier Kilobit. + let asked = try #require(transport.lastRequest) + let query = try #require(URLComponents(url: asked.url!, resolvingAgainstBaseURL: false)?.queryItems) + #expect(query.contains(URLQueryItem(name: "maxStreamingBitrate", value: "4000000"))) + #expect(query.contains(URLQueryItem(name: "enableDirectPlay", value: "false"))) + #expect(asked.httpMethod == "POST") + } + + @Test("Lehnt der Server ab, steht der Grund im Fehler") + func refusesWithoutTranscodingURL() async throws { + let transport = RecordingTransport(response: """ + {"MediaSources": [{"Id": "src-1"}], "PlaySessionId": "ps-7"} + """) + + await #expect(throws: MediaBackendError.self) { + try await backend(transport).preparePlayback(PlaybackIntent( + itemID: "film-1", + versionIndex: 0, + file: file, + transcode: TranscodeSettings(maximumBitrate: nil, resolution: nil) + )) + } + } + + @Test("Die Bildhöhe ersetzt eine vorhandene Angabe, statt daneben zu stehen") + func replacesExistingHeight() { + let url = URL(string: "http://s/main.m3u8?maxHeight=1080&videoCodec=h264")! + + let limited = JellyfinBackend.limiting(height: "1280x720", in: url) + let query = URLComponents(url: limited, resolvingAgainstBaseURL: false)?.queryItems ?? [] + + // Bei zwei gleichnamigen Werten gilt beim Server der erste — und das + // wäre dann gerade nicht der gewählte. + #expect(query.filter { $0.name.lowercased() == "maxheight" }.count == 1) + #expect(query.contains(URLQueryItem(name: "maxHeight", value: "720"))) + #expect(query.contains(URLQueryItem(name: "videoCodec", value: "h264"))) + } + + @Test("Ohne gewählte Auflösung bleibt die Adresse, wie sie ist") + func leavesURLAloneWithoutResolution() { + let url = URL(string: "http://s/main.m3u8?videoCodec=h264")! + + #expect(JellyfinBackend.limiting(height: nil, in: url) == url) + } + + @Test("Der Fortschritt geht in Ticks und mit allen Kennungen hinaus") + func reportsProgressInTicks() async throws { + let transport = RecordingTransport() + + await backend(transport).reportProgress(PlaybackReport( + itemID: "film-1", + sessionID: "ps-7", + versionID: "src-1", + state: .playing, + time: .seconds(90), + duration: .seconds(600) + )) + + let asked = try #require(transport.lastRequest) + #expect(asked.url?.path == "/Sessions/Playing/Progress") + + let body = try #require(asked.httpBody) + let fields = try #require( + try JSONSerialization.jsonObject(with: body) as? [String: Any] + ) + // 90 Sekunden sind 900.000.000 Ticks. Zehntausend je Millisekunde. + #expect(fields["PositionTicks"] as? Int == 900_000_000) + #expect(fields["PlaySessionId"] as? String == "ps-7") + #expect(fields["MediaSourceId"] as? String == "src-1") + #expect(fields["IsPaused"] as? Bool == false) + } + + @Test("Der erste Bericht legt die Sitzung an") + func reportsStartSeparately() async throws { + let transport = RecordingTransport() + + await backend(transport).reportProgress(PlaybackReport( + itemID: "film-1", state: .started, time: .zero, duration: .seconds(600) + )) + + // Ohne diese Meldung erscheint der Titel nirgends als „läuft gerade", + // und der Server kennt die Sitzung nicht, unter der alles Weitere + // gemeldet wird. + #expect(transport.lastRequest?.url?.path == "/Sessions/Playing") + } + + @Test("Der letzte Bericht geht an einen eigenen Endpunkt") + func reportsStopSeparately() async throws { + let transport = RecordingTransport() + + await backend(transport).reportProgress(PlaybackReport( + itemID: "film-1", state: .stopped, time: .seconds(5), duration: .seconds(600) + )) + + // „Gestoppt" als Fortschritt zu melden nähme den Titel nicht aus + // „wird gerade abgespielt" heraus. + #expect(transport.lastRequest?.url?.path == "/Sessions/Playing/Stopped") + } + + @Test("Abräumen nennt Gerät und Sitzung") + func endsEncodingWithDeviceAndSession() async throws { + let transport = RecordingTransport() + + await backend(transport).endPlayback(sessionID: "ps-7") + + let asked = try #require(transport.lastRequest) + #expect(asked.httpMethod == "DELETE") + #expect(asked.url?.path == "/Videos/ActiveEncodings") + let query = try #require(URLComponents(url: asked.url!, resolvingAgainstBaseURL: false)?.queryItems) + // Über die Sitzung allein hält Jellyfin nichts an. + #expect(query.contains(URLQueryItem(name: "deviceId", value: "gerät-1"))) + #expect(query.contains(URLQueryItem(name: "playSessionId", value: "ps-7"))) + } +} + +/// Ein Transport, der nichts verschickt und alles behält. +private final class RecordingTransport: JellyfinTransport, @unchecked Sendable { + private let response: String + private let lock = NSLock() + private var recorded: URLRequest? + + init(response: String = "{}") { + self.response = response + } + + var lastRequest: URLRequest? { + lock.withLock { recorded } + } + + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + lock.withLock { recorded = request } + return ( + Data(response.utf8), + HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + ) + } +} diff --git a/Tools/JellyfinStub/README.md b/Tools/JellyfinStub/README.md index d049c47..0435a5b 100644 --- a/Tools/JellyfinStub/README.md +++ b/Tools/JellyfinStub/README.md @@ -18,5 +18,17 @@ entsteht. Das ist Absicht: so lässt sich auf einen Blick erkennen, ob der Aufmacher tatsächlich weiterwandert und ob eine Kachel das Bild zeigt, das zu ihr gehört. +Abgespielt wird `sample.mp4` — zehn Sekunden Farbverlauf, mit einem kleinen +Swift-Programm erzeugt. Der Server beantwortet Bereichsanfragen, weil der +Systemplayer eine Datei nie am Stück holt. + +Was hereinkommt, steht unter `http://127.0.0.1:8096/__reports`: jede +Wiedergabemeldung mit ihrem Anfragekörper. Damit lässt sich prüfen, ob die App +den Beginn meldet, ob der Fortschritt in Ticks ankommt und ob am Ende +abgeräumt wird. + +Mit `--unanalysed` melden die Quellen keine Spuren. Dann entscheidet die App +wie bei einem Server, der eine Datei noch nicht untersucht hat. + Kein echter Server wird dabei angefasst, und es gehen keine Zugangsdaten über die Leitung. diff --git a/Tools/JellyfinStub/sample.mp4 b/Tools/JellyfinStub/sample.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..2d40df79f2f7553cf184eea763e3eb6ea3e6fb0b GIT binary patch literal 28135 zcmeHQ2RxSF+rJ+xdnJVMC{ZXvDSL#BqG@Gh{}3Tk;;{)$l1N7Vj1(#@M3GWvds&e^ zvI-UNx$kGDdY*d!`h4DhulwV^pEIs=oqf*tT<2T|1VI80-hLi#9&)Q72tqk~xw`?g z4BqZ$XX6dr!DCkeKJs+5Qaao9-dY%Fs>Ti8<4|hQ%tApBHH4x@zElve2E+s#;XnGP z%+$vuP#6$!hv*Gqp(gsa%ITner&4G=d1_>Z1n(TA9=4c-8p?11P`B*|4FfJQiLwI+ zDlTZ6=|drlaNqof+GR9$E_kk3-pH4B6gX<4TiLBj3U#Q3wZ51y5A7TBIJq&Lad~NO z-XoJWTiot!WfP=F|CL1}yw zo9ojfaTI7)H@*KR$Pc1T^7H(G>o-|29P;}v4>RgSjPKzLm+w-h-g;_+0-O2gUQ#5< z>!L_v4L4p*aY|ze%E=O#qTA5vqSH5SSf|8S zy!>S2$kqB{QJ(wJm)tHrULQq|8sxq_KPAf4=FhlZ0D?p@OH$2I+%z}Ek%8P(RP z`~P4FfX5jIClGrDI-4%Ev{$Q`vG7Fva?La5pWT9eG`|j^vTYi5)5ICORvqZ+4)3I) zjjPUHBk&y=NT}MN8rHb;ISS&K02BsWZ)p|93kzmG`J};S# zc>}d~S?#v9570xGius^y zUf0qcV?{jKZ^i=R@XW3ZnA83ZNSsd`mNhf`%48oyT&?+$I9KZ;rI@`wN)>IUpe7rz zh9gq`@wmQA$s&_7zV)HIZ=_-gbjT7|qTkDaVlF-YtlIapQUB$=A2sdXwuHY)tvn{| zP0!Gh#G~nEdMrDk%5f2m#AjLu3?zc#R9mH1K)sbkS~KV$24T6M6f`Jn<_BPw;6!I1 zLdu$*J0i}`=={^GEcWBc|0(xSXt|F78E?TBYz9=2CVhJ`b)zFD&`(4yDCcc zwZ3hUVERd!vg;|(=8$`KJzwmSr{rEdd_J#GTA`e&)?}?9uZn)VPh5Tgo5I573jFCY z_Y~7xZ;4lLxy=y?T3K7hle5>?FFfR1j^ZzZ^7|de!@c;$CWVeY%9s>7(J~?$0ve0v zutV~mh91?l5l=RlMR_Ab323~mdsW5mbW(fEIX5!{@6}zeRO`Q-r8zJPJ5z+@II_gI= z)6HMuvrRbEYQJr?RPz`>4JbWymxBKEjqCkk)q%%D_lEUUwXy87in1xZkZQPLqXT`- za}R-ZIZkVx)jG*bBNdcOA1ekoRh@LC$-Axpl|`i{_NbOsm-EL1HEWYOZ?~{dd|mMA zw3mYD!C|ZSf`OWwt~5DUx5R&{3fvM?%V6c7Fv)i?Wn`nJ_Vteh>##yD35&Y>Ds_ID zvcWr=MW@0O!mpp`9NmHC@j%)eXQ~QJ%Z!sm*rsK^8kFm>zB4i@0e(I7KDU=vUyUp* zKC7C3dt{>{&816&Vs%}4#ZMCNNDLk?WG9KKIY>Xy70;y8T)86|pQNNoF;9sreb|f) zZ6WbVi699x$T~kDSY#TP$sr7=m0EPS5?q@57Ss2Z@fR^Ttd8c6^WC{xsWquu-teYy zK-sn|sYRpX)G=~lDyt&dq7A9r#4+O!g|z6FM#ZW5+v>2zqywjb?u9!ojK>l`QFfATdjoG;EfSs4;gklGY|{7CMNjNauXd-v!( zw=nKGuN?ubE9O-t0_OH?S0ehHRR+2vdJhX(?%2WQi(*5_nwTy$%E%d{Wo>|Y3-MIm zXUmhgaLA|y#iDYWue`UE#Ce+`nh> z^@TzC5Tpz#m%X5VI$zj~N}R>o2$ads$BdJ}7iEC%)mlHjz1ThtQ&X6s{O)1ff%mSG zpB2Tq;wA01-(V7gbiEHUx@~=3IJER~U*i4Nsx5kjud+9o!u1K4{fo|HS&q1NKverfu_|K zDN7%1UWIL5?iEDdnX>;BqH>DxjTOCuw57VRT@4q-(}rYiK^h;@P__o>zg+s2X8lz7 zS;yk)MnXIt9=hG!BrLf1Ppll3`;2XR%B%dR4t+*?*u=`W1ulLnf$I+<&DC3QNh`MN z0dk!dq&)j{=gJcd1+!M3{RVU8Icwz!S)vCl)=wMfe>^9@e4d))#(%qvWiRbeIkCA9 z(-AW8!LBZ*G?68fmpXB3#Brc~1ptmF^KS69><_kMQd^J8Wh6k8AB`)V0Kq^)=nKz6 zt)`x=onEQ6X0>8<&9!v>CjK4)QM)&21B@^jqyS9L<%*u>eJF9=!p|u@ka5JUiT0YX z`DFb@F`Z*zR)h(-$r4zhKe(lfVDn)X^K#*n>|0z0z43MDo)!tOP5oTFP_}Jlho*BS z)Dc+x>hnz|3`DgZH9&VmFg1~?o*oN zt0M5|h|7nd>*BRr6^`?+5`2Tr$!T&qK^dZl_kf&iFHUvZTzSp!^0Svf10r^op4PYusTtx2=S%_hQ(9(Co6a^^ z0bCHU7{L};#F4Zui)Gs@9QK%C*h`r8^KNUJ96To@fukK=q(PY5Q-(8!E$^)962T6y z4*i69WP^2diNGQ6#+4T^&Vb>Chu%at5rfhEx+-eAPI zId~^s?Qq%8tC=sE#E(sq#PXx2O=ceb@AYB@%MXZ;j&{LJc@6X&Jv!>TmOZr zPw8PsORRa@t$$mE7N&USba)qtHIMDRZp&&*oc@Ii^4 z%`F!XZb1{a=_ZM{o`s}S`WF*pl@0p%oCLE6m=b`7e?mVug08W>T=pPIg2v4r^Tq@8 zk02Ixc<8D}zoXZlwhKQKaq_NkT5{4}9k~>={Yc{@xlY)uUUTRW%X5B6);8NavQsJQAP7A{? z2>xA#;Y7Uat?7mN$71QeDv!}24!d1ppc|Fu{cPaW>7_=~Gywjx3oTSMMYy6~s5R$0 zt?6BOaR`RVWAT@%5FCDw1__nE*%v|8m`s4F0imKD0<7AY;XGYia@joZYf_k#!=4A? zCP(AW`t!+uno51hYjDLN;QEe(+F$Lmk5Y|1QMK6ijAw_;vz>%-&2$-IJo(&yQztQW z{l#|ddTC8PPyO1J)=8rrJYa#8(1{&M;YC!Jv#q~~lskS6!+*-ZnqW=HI(`MaPJ*M@ z^!hTgwnAX%&>|efgh$kVsn2*U3ffX}f+KcJgInOW=OmRXJxUU<6J1&gabag^K!`Q5 z?6V7+*qBNs@k=d$x<5vfjLwO-NfPu$%9)ukVWhqWZNv=S&JOzBt6Th^0wx0aw%rFl$JjPF}^-%zC$PhIC^wBf!}MYD161ItKXF5y9+P!O!ENp07r{t&;(=~KPP&T8|d*6?p9-VQOpg{5sqr3cyKWFw0 zB{s@x+4~gJKpN~+IP0$_9SOEdRpa*W1dw|6OT~gu(ueZm-zuK+tv@biJinQhA)m2_ zZ1v0x=e1#`3)%QOSyRZ={r9Zc!e%CreB}k38~je=sW^eQBG2&K+$;(Ct;!l&PPx64 z<~gNv|1b0qLWtz)vy^%hfflaRd!i-edfqp*aA(1&{3YsDDWjvYGQCh=zvxEsKY;`YPH~5zKR1 zQc3T3027hU3!-xv$!9|dm9rr#a3~fNd@{^cBT{xOZ{*(2^nwd3dOE$Y$`zG?oB|U4 zsKBY}@t6~0v9hHD_ntXA2*>btZL%4Hpw{;oK(Z6vt&Y#iM_=XM;VHR`hdxCab~?P} z-jSoWr`4n(qG-M}Qy8!K_T_E55EO(t5Oir?)>5^rG)8=7X`~QtrWqv#rvaUFIMjWr zA=^!rO8(ZZ(NhM^_ZHQy8TA?7UG^xc?<6*vH6SM^H-Ffh`t?wB%i`rPnDUwb;o10% z{{uVcA|XJs2$fSuu1dhlsdqid#~dV>B}f2dfqlAj5DTn=XANTY8)#3%L&nv(2L&vL zbGe6>75BXfemJJfswBZD{b`p2$|Hl8@t%b<1hGrAdB~TI(LCVB9HKVi5}w(!NLQJn zsR$q*Fq*5aS*tD3`UkhpmFMi0rxf~w2`JC7m1ucB+6_KGZO_!oq+X~`8jt!W$-__T*9#*M`|LhP7x z#~x|0%vJlnWiC{X&V3c?{#-!A@cFW6=ECc2^bf<2rZIU37`Je^_3wUj-u8rBuv9I;Dfig+OH9Cu`UGu6mq*A*X3L1KN6p@1y`-Pu-*nC|WHMko7Ib@%# z3Ll5CE;fb_N%)H~)~WVr^+#Xoq5zU(@=4&FcKyX?(|)1fx@#f!?VC zpZx)#Hfvp^>ELCDr;U*xq$}03xcYdHGn>gtg)L{?l)caM@UfuEM>#J}oBOm)#>j}h zcS~}3bq)SxF28Gj^8L--$j6X-KX+J~lNs`%7RdSM^uO8IkIw!>B9ZCh$S0~fc|C`g zF&FaD+D=O_uV>@BS?iVDxfc0UmfNA}HP}PpPsp@su{(d|5b`N1C?HyHyd{Zcn`3O- zzNR(U&t?*Hvn7FTt=GN?jryxnOvp$5GM*d+T)`!Z%LvGg12I_~YG2MT)7u<}{m_pY zZrS=w9{Fqn)dm_-)*Z5q2oRK$BS39Jw=F;r|Lb-2sS<`Q=juu>?v?uF!?S@uA&73@ zyaUGn+;^kjO#f!-)5itLbbDe|JUF)tY#C@#Cy0uhMxF6{#^8X%nJZS{ik%LM_7Nzk+_laMTeAye!wgIo_UjVo+x48WaX$- zAzpe5_M5AfiK6C7J`~B`dS1c_;$FCi%IgRCV;>VM2Or@jlweo>YdT?PXk?>iaDZ<0hyp*vvN<{Ypg z86rtnC!}T^u<2Yf2W(~!*qEWa#Ib$$deNsxNV`ljLD)nT@(1W~(jOXHd3+ z%R0^ELaZ=EMfZFA-e4WnEjd?PvsPQw?dTpQxICK*xt8o}P<@b6zBb5Rb7#j_s$q03 zT1~O*Uy=9|CP}xcSC|Jcac@yFf2i(kIp*l&LVYp0e9J+OWx;jP9Umw7pT3F^lj(S( z-F|R)wBwzJyBrOhpy(&(Em{UuuXm%uLdx&%vf1M?US=KR#J}^}o|a>2>_*m+;?i1N zydsRu9JF-w!67v2>cPUPcZePg$!$c-PpM!J+SlhPa>Frj?8+w_DKQ@T?c|e3Lh1R- zJn!av{95#@(tb5Z?#oHajF_kyg$X=pni#$<;GYsRRE~UC8$TwVAAkObb@x4W_~Y@P zSM@jKXDlf+3<9;ngFC;LclNHLdB2t;%_K>IpR`9pwAhBqsv`Z1b*b1z+N@`=A#LL9 zA1A->20x_NeERN(?%y}A4qH;%4d{94AY#6FsmKUweOO;{_bOqqOmo z%SB;-2tIsAHXjY>4>rg$>@Py~;5@{T-Jb+i!%lOcYSus%0I4HDwl}x+H(Og(_2^L# zxIF)&9u3;anD<;8nYBLmD+fh4O=E?;t7L_2cF*5H({B@Q`cWk0>`^r9ZSJt7l11`Qt6nLsPaWL=tP5CRJHRP763;QumQ|8#lS@e5%JoEH|hBet#W@l3gtk=5@$B{sy338l&WA8 z7iAK4$|Ty0DMhsPCD_a0!tG_uL43kMgM;}0;SL4ZhXU%`xMN};eKhyZ`pH`8F08{1 z??bJfR4C)DpblT{qjLVha=-SgdRmLm*%z=VdcHeHHNrAhnO%_y_W7Q+d^tNWnWawF zm8!fjndL$S3#^^pzohXcZG_93Vg^JBR;ENJMq?UFxOtJOwI%S5f@ZRE{(rWMhN4f$wlpz|oLkXI99?w7EbH^Cojl9pOgQ(%U z27;Iyw9Q~m@{*$5hd_(B^{~5wI`hBdfxCH?2NcYzM~fB*Kb5Ug}f z{@5TmAo++~aiccE>df$AyAlk&Rqi`swm6aKWnBlHiXXd}>K4BKSYNZkr(s3ft)iYa zy`lyfo|MGNcbBqm-8`Jrhts0;KVAiEEaSC{A1V<~(kI@=+zzTbK+~KW39B)cs^E9z zfQG3)e=Bi+js9(UhYi76qD4@9Q0_P#@a!mYk1i6gU<4~3IlI3ho{N`nCn$}IPK8~= z?}ow3PZ9r;#H%LHJWS9j86GCaT08HJDKQ&gC+tXcuCEf*Vu4Ch%@|37T3vK^ZO0w< z@29H=T|fkAerr#+t0^CPmz|fXb*HrEM3n#Ti+nt%po6r951uTMiUCw+K%@CjE=zmV z<{5d7pHmL~ZV=4AAgh zxPrUPV^I49@mde(H}bGLB~DxX(_JD~>vDcufhb;rn`wZeWU+1y0VGa^yBsE}j*sYQ zaS$)*h%4pxT8mdcUs>Tz!s6=ne&UI$)5Am+&}oe~qzJA23GsrQv{b%uKZ1hjgr79= z78~k*k~36OUoMl}2J)Kp8XSj7k|S9k3W*BvA48lFC^rJ+k?pmGI@g!}mb)LRR2X>;Gaur7VgjyRzzN+@)Sk!mvU?-DNG>a2=InwcQ*$%tM#tHiIl-{eEjPDpnYCp#5GwTm(bFG7BCrqy zIq*0)Y0lQ9**7PogRhXB9U%w+s{M$N?r%ElAcY=)Rk!DK623h+fM3$!7b=J^tdjul z)f@!csaWJB`ToHBFV5x*204>zYi>y9ZN;?REy!T=S!&J0C~cIxt@0>*cl&Rti-B#r zT&}-in=W??IjjQ?MdofJOJI#2a>nDjKLt^N)aKORA+>oy5k}{}B3s;#(Ic>s3`lrI zm+ww%*d@?%NnC=lr}EzEwwD2;qEBj9l+*YH=*gy>cibcu%pw|@n5acJ@g949t#t={ zg_o_mtIvr6aBY|9mrVF>Tg>>rcH~YlHlBDs!FNY;ufL3kFZD8p3gi*;;xou~wJN!N zG~mWC)KF+6Y6x6+YuCFBzCjE%v;=#bS76GC`C5RH)MWi9_%dCNXGrK$k*9jdSF)N4y;6M0e+#kY!6eLmB@P&f-b7JDn z^EXH{?(YelRc&CD6jl^9Hyi%qY{)Rr&D|Z`&;Yr*`8a{g_8_RETKNO~LJ*Y+1o3!4 z;6IpvKjQO^1vt;Js}U)GWTFE9_~eAQrw#arC6I-;3lb-a7$=?^h`?=DnV-!dUY>PcQFXuE3n?YP00I03fS?R zhj)4!*p~vXnkYL+aJm&@5(B@v+l|DTrnaIW8u0<8{IBEVa3DqpXM0y9Qbt=RPd9`Q zua`Gc$gl*ZeXv%xrqV$MqqvnUTa_mJp%ti zfEHQ|UVK(gHmL zekAE2wm#9{6&E z%LEQbVn=~E5jv7M@#O+p16nfag`N|`Lczcc&x{IQ-nQ@*3_pzEl?2z=XF(9>TcE`U a+JHDU1j+S*>wfCMG${^2#$akQulXMUXg8?< literal 0 HcmV?d00001 diff --git a/Tools/JellyfinStub/server.py b/Tools/JellyfinStub/server.py index 66ee29b..c6707f5 100644 --- a/Tools/JellyfinStub/server.py +++ b/Tools/JellyfinStub/server.py @@ -1,11 +1,18 @@ """Ein Jellyfin-Attrappenserver — gerade so viel, dass die App anmelden und stöbern kann. Nur zum Ansehen der Oberfläche; kein echter Server wird berührt.""" -import json, struct, zlib, hashlib +import json, os, struct, zlib, hashlib from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from urllib.parse import urlparse, parse_qs USER_ID = "user-1" TOKEN = "stub-token" +SAMPLE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "sample.mp4") +# Was an Wiedergabemeldungen hereinkam — damit sich prüfen lässt, ob die App +# den Fortschritt tatsächlich meldet. +REPORTS = [] +# Mit `--unanalysed` melden die Quellen keine Spuren. Dann entscheidet die App +# auf Umrechnen, und der Weg über PlaybackInfo lässt sich prüfen. +UNANALYSED = "--unanalysed" in os.sys.argv TITLES = [ ("m1", "Der weite Himmel", "Movie", "Ein Mann, ein Berg, zwei Stunden Schweigen."), @@ -27,6 +34,22 @@ def item(key, name, kind, overview, *, resume=None, series=None): "BackdropImageTags": ["b-" + key], "Genres": ["Drama"], "UserData": {"PlayCount": 0, "Played": False, "PlaybackPositionTicks": 0}, + "MediaSources": [{ + "Id": "src-" + key, + "Container": "mp4", + "Size": 28135, + "Bitrate": 1_200_000, + "RunTimeTicks": 10 * 10_000_000, + "SupportsDirectPlay": True, + "SupportsDirectStream": True, + "SupportsTranscoding": True, + "MediaStreams": [] if UNANALYSED else [ + {"Index": 0, "Type": "Video", "Codec": "h264", + "Width": 640, "Height": 360}, + {"Index": 1, "Type": "Audio", "Codec": "aac", "Channels": 2, + "Language": "ger", "DisplayTitle": "Deutsch", "IsDefault": True}, + ], + }], } if resume: d["UserData"]["PlaybackPositionTicks"] = int(d["RunTimeTicks"] * resume) @@ -106,6 +129,14 @@ class Handler(BaseHTTPRequestHandler): item("e2", "Das Wrack", "Episode", "Was der Sturm freilegt.", series=("s1", "Küstenwache", 4)), ])) + if p.startswith("/Videos/") and p.endswith("/stream"): + return self.serve_file(SAMPLE, "video/mp4") + if p.startswith(f"/Users/{USER_ID}/Items/") and p.count("/") == 4: + key = p.rsplit("/", 1)[-1] + match = next((t for t in TITLES if t[0] == key), None) + if match: + return self.reply(item(*match)) + return self.reply(item(key, "Unbekannt", "Movie", "Aus dem Nichts.")) if p.startswith("/Items/") and "/Images/" in p: key = p.split("/")[2] return self.reply(png(colour_for(key)), "image/png") @@ -114,20 +145,65 @@ class Handler(BaseHTTPRequestHandler): kind = "Series" if parent == "lib-shows" else "Movie" chosen = [t for t in TITLES if t[2] == kind] or TITLES return self.reply(page([item(*t) for t in chosen])) + if p == "/__reports": + return self.reply(REPORTS) if p == "/Genres": return self.reply(page([{"Id": "g1", "Name": "Drama", "Type": "Genre"}])) return self.reply(page([])) + def serve_file(self, path, content_type): + """Mit Bereichsanfragen — AVPlayer holt eine Datei nie am Stück.""" + with open(path, "rb") as handle: + data = handle.read() + rng = self.headers.get("Range") + start, end = 0, len(data) - 1 + status = 200 + if rng and rng.startswith("bytes="): + piece = rng[6:].split("-") + start = int(piece[0] or 0) + if len(piece) > 1 and piece[1]: + end = min(int(piece[1]), len(data) - 1) + status = 206 + body = data[start:end + 1] + self.send_response(status) + self.send_header("Content-Type", content_type) + self.send_header("Accept-Ranges", "bytes") + self.send_header("Content-Length", str(len(body))) + if status == 206: + self.send_header("Content-Range", f"bytes {start}-{end}/{len(data)}") + self.end_headers() + self.wfile.write(body) + + def do_DELETE(self): + REPORTS.append(("encoding-stopped", self.path)) + self.reply({}) + def do_POST(self): url = urlparse(self.path) length = int(self.headers.get("Content-Length") or 0) - self.rfile.read(length) + body = self.rfile.read(length) if url.path in ("/Users/AuthenticateWithQuickConnect", "/Users/AuthenticateByName"): return self.reply({ "AccessToken": TOKEN, "User": {"Id": USER_ID, "Name": "Testkonto", "Policy": {"IsAdministrator": True}}, }) + if url.path.startswith("/Sessions/Playing"): + REPORTS.append((url.path, body.decode() if body else "")) + return self.reply({}) + if url.path.startswith("/Items/") and url.path.endswith("/PlaybackInfo"): + key = url.path.split("/")[2] + # Keine echte Umrechnung — die Adresse zeigt auf dieselbe Datei. + # Geprüft wird hier der Weg, nicht der Transcoder. + return self.reply({ + "MediaSources": [{ + "Id": "src-" + key, + "Container": "mp4", + "SupportsTranscoding": True, + "TranscodingUrl": f"/Videos/{key}/stream?static=true&ApiKey={TOKEN}", + }], + "PlaySessionId": "ps-" + key, + }) if url.path == "/QuickConnect/Initiate": POLLS["count"] = 0 return self.reply({"Secret": "s3cr3t", "Code": "428193"})