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:
co-authored by
Claude Opus 5
parent
effdcd4532
commit
fe2d730c00
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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] = []
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user