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 0000000..2d40df7 Binary files /dev/null and b/Tools/JellyfinStub/sample.mp4 differ 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"})