Die Ansichten sprechen nicht mehr mit Plex

Start, Suche, Mediathek, Detailseiten und Downloads arbeiten jetzt mit
`MediaItem` und dem `MediaBackend`-Vertrag. Damit sind sie fertig für
einen zweiten Server, ohne dass an ihnen noch etwas zu tun wäre.

Dabei sind ein paar Namen ehrlicher geworden: aus `grandparentTitle`
wurde `seriesTitle`, aus `viewedLeafCount` `playedEpisodeCount`. Und
`TagStrip` nahm bisher Plex-Marken mit Kennung und Rolle entgegen, um
davon nur den Namen anzuzeigen — Genres sind jetzt schlicht Zeichenketten.

`Arrangement` ist aus PlexKit heraus und generisch: dieselbe Rechnung
gilt für jede Liste, die jemand selbst zusammenstellt. Phase 3 baut die
konfigurierbare Seitenleiste darauf, statt sie ein zweites Mal — und ein
zweites Mal falsch — zu schreiben.

Die Fehlerübersetzung gibt es nur noch einmal. Es waren zwei, und sie
waren bereits auseinandergelaufen: derselbe Fehler bekam je nach
Bildschirm eine andere Erklärung.

Das Nachladen der Mediathek bleibt unangetastet: eine lange Liste, die
fünf Einträge vor dem Ende nachlädt. `MediaPage` heißt zwar Seite, ist
aber eine technische Portion — Blättern mit „weiter zu Seite 2" gehört
nicht in eine Mediathek, in der man stöbert. Der Kommentar dort sagt das
jetzt ausdrücklich, damit es niemand später „aufräumt".

Player, Live-TV und die Download-Engine sprechen noch unmittelbar mit
Plex. Sie folgen als Nächstes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-13 12:46:52 +02:00
co-authored by Claude Opus 5
parent effdcd4532
commit fe2d730c00
30 changed files with 432 additions and 318 deletions
+36 -35
View File
@@ -1,5 +1,6 @@
import Foundation
import FlixyDownloads
import MediaKit
import PlexKit
/// Übersetzt Titel vom Server in Download-Aufträge.
@@ -79,7 +80,7 @@ enum DownloadQuality: String, CaseIterable, Identifiable {
@MainActor
struct DownloadService {
let server: PlexServer?
let backend: PlexBackend?
let coordinator: DownloadCoordinator
/// Baut den Auftrag für einen einzelnen Titel.
@@ -89,33 +90,33 @@ struct DownloadService {
/// nichts, obwohl der Grund bekannt war — etwa ein Titel, den der Server
/// noch nicht analysiert hat.
func plan(
for item: PlexItem,
for item: MediaItem,
mediaIndex: Int = 0,
quality: DownloadQuality = .original
) throws(DownloadRefusal) -> DownloadPlan {
guard let server else {
guard let backend else {
throw .noDownloadableFile(reason: String(localized: "downloads.refused.noServer"))
}
let media = item.media.indices.contains(mediaIndex)
? item.media[mediaIndex]
: item.preferredMedia
let media = item.versions.indices.contains(mediaIndex)
? item.versions[mediaIndex]
: item.preferredVersion
guard let media else {
throw .noDownloadableFile(reason: String(localized: "downloads.refused.noMedia"))
}
guard let part = media.parts.first else {
guard let part = media.files.first else {
throw .noDownloadableFile(reason: String(localized: "downloads.refused.noPart"))
}
let url: URL?
switch quality {
case .original:
url = server.downloadURL(part: part)
url = backend.downloadURL(for: part)
case .fullHD, .hd, .low:
// Über den Universal-Transcoder. NOCH NICHT AM SERVER GEPRÜFT:
// offen ist, ob Plex eine Content-Length mitschickt — ohne sie
// gibt es keinen Fortschrittsbalken und kein Fortsetzen.
url = server.transcodedDownloadURL(
ratingKey: item.ratingKey,
mediaIndex: mediaIndex,
url = backend.compressedDownloadURL(
itemID: item.id,
versionIndex: mediaIndex,
maximumBitrate: quality.maximumBitrate ?? 8_000,
resolution: quality.resolution
)
@@ -125,13 +126,13 @@ struct DownloadService {
}
return DownloadPlan(
ratingKey: item.ratingKey,
ratingKey: item.id,
title: item.rowTitle,
subtitle: item.downloadSubtitle,
sourceURL: url,
seriesKey: item.grandparentRatingKey,
seriesTitle: item.grandparentTitle,
seasonKey: item.parentRatingKey,
seriesKey: item.seriesID,
seriesTitle: item.seriesTitle,
seasonKey: item.parentID,
seasonIndex: item.parentIndex,
episodeIndex: item.index,
durationMilliseconds: item.duration,
@@ -146,7 +147,7 @@ struct DownloadService {
/// Lädt einen einzelnen Titel.
@discardableResult
func download(
_ item: PlexItem,
_ item: MediaItem,
mediaIndex: Int = 0,
quality: DownloadQuality = .original
) throws(DownloadRefusal) -> DownloadItem {
@@ -159,30 +160,30 @@ struct DownloadService {
/// keine Dateiangaben, und ohne Dateigröße ließe sich weder das
/// Speicherlimit prüfen noch ein Fortschrittsbalken zeigen.
func downloadAll(
in container: PlexItem
) async -> (accepted: Int, refused: [(PlexItem, DownloadRefusal)], unavailable: Int) {
guard let server else { return (0, [], 0) }
in container: MediaItem
) async -> (accepted: Int, refused: [(MediaItem, DownloadRefusal)], unavailable: Int) {
guard let backend else { return (0, [], 0) }
let episodes: [PlexItem]
let episodes: [MediaItem]
do {
episodes = try await allEpisodes(in: container, server: server)
episodes = try await allEpisodes(in: container, backend: backend)
} catch {
return (0, [], 0)
}
var accepted = 0
var refused: [(PlexItem, DownloadRefusal)] = []
var refused: [(MediaItem, DownloadRefusal)] = []
var unavailable = 0
for episode in episodes {
// Folgen, die schon offline sind, werden übersprungen statt
// abgelehnt — bei einer Serie, von der die Hälfte geladen ist,
// wäre eine Liste aus zwanzig Fehlern keine Hilfe.
if coordinator.items.contains(where: { $0.ratingKey == episode.ratingKey }) {
if coordinator.items.contains(where: { $0.ratingKey == episode.id }) {
continue
}
let detailed = (try? await server.metadata(ratingKey: episode.ratingKey)) ?? episode
let detailed = (try? await backend.item(id: episode.id)) ?? episode
do {
_ = try coordinator.enqueue(try plan(for: detailed))
@@ -191,7 +192,7 @@ struct DownloadService {
// Einzelne Folgen ohne Datei stoppen den Stapel nicht — sie
// werden am Ende nur gezählt.
unavailable += 1
} catch {
} catch let error as DownloadRefusal {
refused.append((episode, error))
// Beim Speicherlimit hat es keinen Sinn, die übrigen Folgen
// noch einzeln durchzuprobieren — sie scheitern alle gleich.
@@ -203,14 +204,14 @@ struct DownloadService {
return (accepted, refused, unavailable)
}
private func allEpisodes(in container: PlexItem, server: PlexServer) async throws -> [PlexItem] {
private func allEpisodes(in container: MediaItem, backend: PlexBackend) async throws -> [MediaItem] {
switch container.kind {
case .season:
return try await server.children(of: container.ratingKey)
return try await backend.children(of: container.id)
case .show:
var episodes: [PlexItem] = []
for season in try await server.children(of: container.ratingKey) {
episodes.append(contentsOf: try await server.children(of: season.ratingKey))
var episodes: [MediaItem] = []
for season in try await backend.children(of: container.id) {
episodes.append(contentsOf: try await backend.children(of: season.id))
}
return episodes
default:
@@ -219,7 +220,7 @@ struct DownloadService {
}
/// Der Abzug der Serverangaben, den der Player offline braucht.
private func offlineMedia(for item: PlexItem, media: PlexMedia, part: PlexPart) -> OfflineMedia {
private func offlineMedia(for item: MediaItem, media: MediaVersion, part: MediaFile) -> OfflineMedia {
OfflineMedia(
container: part.container ?? media.container,
videoCodec: media.videoCodec,
@@ -230,16 +231,16 @@ struct DownloadService {
subtitleLanguages: part.subtitleStreams.compactMap(\.language),
markers: item.markers.map {
OfflineMarker(
type: $0.type,
startMilliseconds: $0.startTimeOffset,
endMilliseconds: $0.endTimeOffset
type: $0.kind.rawValue,
startMilliseconds: $0.startMilliseconds,
endMilliseconds: $0.endMilliseconds
)
}
)
}
}
extension PlexItem {
extension MediaItem {
/// Die Zeile unter dem Titel in der Downloads-Ansicht.
var downloadSubtitle: String? {
if kind == .episode, let season = parentIndex, let episode = index {