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
+12 -11
View File
@@ -1,5 +1,6 @@
import Foundation
import FlixyDownloads
import MediaKit
import PlexKit
/// Füllt den Vorrat an ungesehenen Folgen wieder auf.
@@ -9,7 +10,7 @@ import PlexKit
/// Server und ist deshalb prüfbar, das Auswählen braucht ihn zwangsläufig.
@MainActor
struct DownloadRefiller {
let server: PlexServer?
let backend: PlexBackend?
let coordinator: DownloadCoordinator
/// Lädt nach, was den Regeln nach fehlt.
@@ -19,26 +20,26 @@ struct DownloadRefiller {
/// wäre eine unangenehme Überraschung auf der Mobilfunkrechnung.
@discardableResult
func refill() async -> Int {
guard let server, coordinator.rules.keepUnwatchedEpisodes != nil else { return 0 }
guard let backend, coordinator.rules.keepUnwatchedEpisodes != nil else { return 0 }
let needed = coordinator.rules.seriesNeedingRefill(in: coordinator.items)
guard !needed.isEmpty else { return 0 }
var added = 0
let service = DownloadService(server: server, coordinator: coordinator)
let service = DownloadService(backend: backend, coordinator: coordinator)
for (seriesKey, count) in needed {
let candidates = await nextUnwatchedEpisodes(
inSeries: seriesKey,
count: count,
server: server
backend: backend
)
for episode in candidates {
// Die Kinderliste bringt keine Dateiangaben mit — ohne sie
// ließe sich weder das Speicherlimit prüfen noch ein
// Fortschritt anzeigen.
let detailed = (try? await server.metadata(ratingKey: episode.ratingKey)) ?? episode
let detailed = (try? await backend.item(id: episode.id)) ?? episode
// Folgen ohne abrufbare Datei still überspringen: das Nachladen
// läuft im Hintergrund, da hilft keine Fehlermeldung.
guard let plan = try? service.plan(for: detailed) else { continue }
@@ -55,21 +56,21 @@ struct DownloadRefiller {
private func nextUnwatchedEpisodes(
inSeries seriesKey: String,
count: Int,
server: PlexServer
) async -> [PlexItem] {
guard let seasons = try? await server.children(of: seriesKey) else { return [] }
backend: PlexBackend
) async -> [MediaItem] {
guard let seasons = try? await backend.children(of: seriesKey) else { return [] }
let offlineKeys = Set(coordinator.items.map(\.ratingKey))
var result: [PlexItem] = []
var result: [MediaItem] = []
for season in seasons.sorted(by: { ($0.index ?? 0) < ($1.index ?? 0) }) {
// Specials führt Plex als Staffel 0. Sie gehören nicht in den
// Vorrat für „was kommt als Nächstes".
guard (season.index ?? 0) > 0 else { continue }
guard let episodes = try? await server.children(of: season.ratingKey) else { continue }
guard let episodes = try? await backend.children(of: season.id) else { continue }
for episode in episodes.sorted(by: { ($0.index ?? 0) < ($1.index ?? 0) }) {
guard !episode.isWatched, !offlineKeys.contains(episode.ratingKey) else { continue }
guard !episode.isWatched, !offlineKeys.contains(episode.id) else { continue }
result.append(episode)
if result.count >= count { return result }
}
+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 {
+10
View File
@@ -1,4 +1,5 @@
import Foundation
import MediaKit
import Observation
import PlexKit
@@ -69,6 +70,15 @@ final class FlixySession {
private(set) var phase: Phase = .launching
private(set) var server: PlexServer?
/// Derselbe Server, aber hinter dem neutralen Vertrag.
///
/// Die Ansichten sprechen hierüber, nicht mehr mit Plex unmittelbar. Der
/// Weg darunter bleibt offen, solange Player, Downloads und Live-TV noch
/// nicht umgestellt sind — die drei folgen als Nächstes.
var backend: PlexBackend? { server.map(PlexBackend.init) }
/// Die Mediatheken in neutraler Form.
var mediaLibraries: [MediaLibrary] { libraries.map(\.asMediaLibrary) }
/// Nur die Mediatheken, die Flixy darstellen kann — Musik und Fotos
/// erscheinen gar nicht erst.
private(set) var libraries: [PlexLibrary] = []
+10 -9
View File
@@ -1,11 +1,12 @@
import Foundation
import Observation
import MediaKit
import PlexKit
/// Welche Reihen die Startseite zeigt und in welcher Reihenfolge.
///
/// Hält nur die Einstellung und ihre Speicherung. Das Anordnen selbst macht
/// `HubArrangement` in PlexKit — dort ist es reine Rechnung und ohne Simulator
/// `Arrangement` in MediaKit — dort ist es reine Rechnung und ohne Simulator
/// prüfbar, und genau dort liegen die kniffligen Fälle: neue Reihen vom
/// Server, verschwundene aus der gespeicherten Liste.
@MainActor
@@ -17,11 +18,11 @@ final class HomeLayoutSettings {
}
private let defaults: UserDefaults
private(set) var arrangement: HubArrangement
private(set) var arrangement: Arrangement
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
self.arrangement = HubArrangement(
self.arrangement = Arrangement(
hidden: Set(defaults.stringArray(forKey: Key.hidden) ?? []),
order: defaults.stringArray(forKey: Key.order) ?? []
)
@@ -45,15 +46,15 @@ final class HomeLayoutSettings {
defaults.set(identifiers, forKey: Key.order)
}
func apply(to hubs: [PlexHub]) -> [PlexHub] {
arrangement.apply(to: hubs)
func apply(to rows: [MediaRowGroup]) -> [MediaRowGroup] {
arrangement.apply(to: rows)
}
/// Die Zeilen für den Einstellungsbildschirm.
func rows(from hubs: [PlexHub]) -> [HomeRow] {
arrangement.arrangedIdentifiers(from: hubs).compactMap { identifier in
guard let hub = hubs.first(where: { $0.hubIdentifier == identifier }) else { return nil }
return HomeRow(id: identifier, title: hub.title, isHidden: isHidden(identifier))
func rows(from rows: [MediaRowGroup]) -> [HomeRow] {
arrangement.arrangedIdentifiers(from: rows).compactMap { identifier in
guard let row = rows.first(where: { $0.id == identifier }) else { return nil }
return HomeRow(id: identifier, title: row.title, isHidden: isHidden(identifier))
}
}
}
+6 -12
View File
@@ -1,6 +1,6 @@
import Foundation
import Observation
import PlexKit
import MediaKit
/// Was abgespielt werden soll.
struct PlaybackRequest: Identifiable, Hashable {
@@ -63,9 +63,9 @@ final class PlaybackCoordinator {
private(set) var request: PlaybackRequest?
private(set) var presentation: Presentation = .hidden
func play(_ item: PlexItem, mediaIndex: Int = 0, resuming: Bool = true) {
func play(_ item: MediaItem, mediaIndex: Int = 0, resuming: Bool = true) {
play(PlaybackRequest(
ratingKey: item.ratingKey,
ratingKey: item.id,
title: item.playbackTitle,
mediaIndex: mediaIndex,
startTime: resuming ? item.resumeTime : .zero
@@ -127,24 +127,18 @@ final class PlaybackCoordinator {
}
}
extension PlexItem {
extension MediaItem {
/// Wie der Titel im Player heißt.
///
/// Bei Folgen ist der eigene Titel allein nutzlos — „Die erste Folge"
/// sagt nichts darüber, welche Serie läuft.
var playbackTitle: String {
if kind == .episode, let series = grandparentTitle {
if kind == .episode, let series = seriesTitle {
return "\(series) · \(title)"
}
return title
}
/// Ab welcher Stelle fortgesetzt wird.
var resumeTime: Duration {
guard let viewOffset, viewOffset > 0 else { return .zero }
return .milliseconds(viewOffset)
}
/// Die Beschriftung unter einer Kachel.
///
/// Sie ordnet ein, was darüber steht: zu einer Folge gehört die Serie, zu
@@ -157,7 +151,7 @@ extension PlexItem {
if let season = parentIndex, let episode = index {
code = String(localized: "episode.short \(season) \(episode)")
}
guard let series = grandparentTitle else { return code }
guard let series = seriesTitle else { return code }
return code.map { "\(series) · \($0)" } ?? series
case .season:
// Ohne die Serie darüber wäre „Staffel 10" beliebig — deshalb