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>
252 lines
9.3 KiB
Swift
252 lines
9.3 KiB
Swift
import Foundation
|
|
import FlixyDownloads
|
|
import MediaKit
|
|
import PlexKit
|
|
|
|
/// Übersetzt Titel vom Server in Download-Aufträge.
|
|
///
|
|
/// Die Trennung ist Absicht: `DownloadCoordinator` kennt nur Adressen, Bytes
|
|
/// und Zustände — nichts von Plex. Dadurch bleibt die Engine ohne Server
|
|
/// testbar, und dieser Typ ist die einzige Stelle, die beides kennt.
|
|
/// In welcher Qualität heruntergeladen wird.
|
|
enum DownloadQuality: String, CaseIterable, Identifiable {
|
|
/// Die Datei, wie sie auf dem Server liegt.
|
|
case original
|
|
case fullHD
|
|
case hd
|
|
case low
|
|
|
|
var id: String { rawValue }
|
|
|
|
var maximumBitrate: Int? {
|
|
switch self {
|
|
case .original: nil
|
|
case .fullHD: 8_000
|
|
case .hd: 4_000
|
|
case .low: 2_000
|
|
}
|
|
}
|
|
|
|
var resolution: String? {
|
|
switch self {
|
|
case .original: nil
|
|
case .fullHD: "1920x1080"
|
|
case .hd: "1280x720"
|
|
case .low: "854x480"
|
|
}
|
|
}
|
|
|
|
/// Die Bildhöhe, auf die diese Stufe herunterrechnet.
|
|
var pixelHeight: Int? {
|
|
switch self {
|
|
case .original: nil
|
|
case .fullHD: 1080
|
|
case .hd: 720
|
|
case .low: 480
|
|
}
|
|
}
|
|
|
|
/// Welche Stufen für eine Datei überhaupt sinnvoll sind.
|
|
///
|
|
/// Eine 720p-Datei auf „Full HD" hochzurechnen bringt nichts außer einer
|
|
/// größeren Datei — und im Auswahldialog standen dann drei Einträge mit
|
|
/// derselben geschätzten Größe untereinander, was aussah, als wäre die
|
|
/// Schätzung kaputt. Sie war es nicht; die Stufen waren es.
|
|
static func available(forSourceHeight height: Int?) -> [DownloadQuality] {
|
|
guard let height else { return allCases }
|
|
// Zehn Prozent Spielraum: 1080p-Dateien sind oft 1072 oder 1088 hoch.
|
|
let threshold = Double(height) * 0.9
|
|
return allCases.filter { quality in
|
|
guard let target = quality.pixelHeight else { return true }
|
|
return Double(target) < threshold
|
|
}
|
|
}
|
|
|
|
/// Grobe Schätzung der Dateigröße bei dieser Stufe.
|
|
///
|
|
/// Aus Bitrate mal Laufzeit. Nicht genau — der Transcoder arbeitet mit
|
|
/// variabler Bitrate —, aber genau genug für die Frage, ob es aufs Gerät
|
|
/// passt. Bei `original` steht die echte Größe zur Verfügung.
|
|
func estimatedBytes(durationMilliseconds: Int?, originalBytes: Int64) -> Int64 {
|
|
guard let maximumBitrate, let durationMilliseconds, durationMilliseconds > 0 else {
|
|
return originalBytes
|
|
}
|
|
let seconds = Double(durationMilliseconds) / 1000
|
|
let estimate = Int64(Double(maximumBitrate) * 1000 / 8 * seconds)
|
|
// Nie mehr schätzen als das Original groß ist.
|
|
return originalBytes > 0 ? min(estimate, originalBytes) : estimate
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
struct DownloadService {
|
|
let backend: PlexBackend?
|
|
let coordinator: DownloadCoordinator
|
|
|
|
/// Baut den Auftrag für einen einzelnen Titel.
|
|
///
|
|
/// Wirft mit Begründung, statt still `nil` zurückzugeben. Der Unterschied
|
|
/// ist nicht akademisch: mit `nil` sah der Download-Knopf aus, als täte er
|
|
/// nichts, obwohl der Grund bekannt war — etwa ein Titel, den der Server
|
|
/// noch nicht analysiert hat.
|
|
func plan(
|
|
for item: MediaItem,
|
|
mediaIndex: Int = 0,
|
|
quality: DownloadQuality = .original
|
|
) throws(DownloadRefusal) -> DownloadPlan {
|
|
guard let backend else {
|
|
throw .noDownloadableFile(reason: String(localized: "downloads.refused.noServer"))
|
|
}
|
|
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.files.first else {
|
|
throw .noDownloadableFile(reason: String(localized: "downloads.refused.noPart"))
|
|
}
|
|
let url: URL?
|
|
switch quality {
|
|
case .original:
|
|
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 = backend.compressedDownloadURL(
|
|
itemID: item.id,
|
|
versionIndex: mediaIndex,
|
|
maximumBitrate: quality.maximumBitrate ?? 8_000,
|
|
resolution: quality.resolution
|
|
)
|
|
}
|
|
guard let url else {
|
|
throw .noDownloadableFile(reason: String(localized: "downloads.refused.noURL"))
|
|
}
|
|
|
|
return DownloadPlan(
|
|
ratingKey: item.id,
|
|
title: item.rowTitle,
|
|
subtitle: item.downloadSubtitle,
|
|
sourceURL: url,
|
|
seriesKey: item.seriesID,
|
|
seriesTitle: item.seriesTitle,
|
|
seasonKey: item.parentID,
|
|
seasonIndex: item.parentIndex,
|
|
episodeIndex: item.index,
|
|
durationMilliseconds: item.duration,
|
|
expectedBytes: quality.estimatedBytes(
|
|
durationMilliseconds: item.duration,
|
|
originalBytes: part.size ?? 0
|
|
),
|
|
offlineMedia: offlineMedia(for: item, media: media, part: part)
|
|
)
|
|
}
|
|
|
|
/// Lädt einen einzelnen Titel.
|
|
@discardableResult
|
|
func download(
|
|
_ item: MediaItem,
|
|
mediaIndex: Int = 0,
|
|
quality: DownloadQuality = .original
|
|
) throws(DownloadRefusal) -> DownloadItem {
|
|
try coordinator.enqueue(try plan(for: item, mediaIndex: mediaIndex, quality: quality))
|
|
}
|
|
|
|
/// Lädt alle Folgen einer Staffel oder Serie.
|
|
///
|
|
/// Holt die Metadaten jeder Folge einzeln nach: die Kinderliste enthält
|
|
/// keine Dateiangaben, und ohne Dateigröße ließe sich weder das
|
|
/// Speicherlimit prüfen noch ein Fortschrittsbalken zeigen.
|
|
func downloadAll(
|
|
in container: MediaItem
|
|
) async -> (accepted: Int, refused: [(MediaItem, DownloadRefusal)], unavailable: Int) {
|
|
guard let backend else { return (0, [], 0) }
|
|
|
|
let episodes: [MediaItem]
|
|
do {
|
|
episodes = try await allEpisodes(in: container, backend: backend)
|
|
} catch {
|
|
return (0, [], 0)
|
|
}
|
|
|
|
var accepted = 0
|
|
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.id }) {
|
|
continue
|
|
}
|
|
|
|
let detailed = (try? await backend.item(id: episode.id)) ?? episode
|
|
|
|
do {
|
|
_ = try coordinator.enqueue(try plan(for: detailed))
|
|
accepted += 1
|
|
} catch DownloadRefusal.noDownloadableFile {
|
|
// Einzelne Folgen ohne Datei stoppen den Stapel nicht — sie
|
|
// werden am Ende nur gezählt.
|
|
unavailable += 1
|
|
} 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.
|
|
if case .storageLimitReached = error { break }
|
|
if case .insufficientDeviceSpace = error { break }
|
|
}
|
|
}
|
|
|
|
return (accepted, refused, unavailable)
|
|
}
|
|
|
|
private func allEpisodes(in container: MediaItem, backend: PlexBackend) async throws -> [MediaItem] {
|
|
switch container.kind {
|
|
case .season:
|
|
return try await backend.children(of: container.id)
|
|
case .show:
|
|
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:
|
|
return [container]
|
|
}
|
|
}
|
|
|
|
/// Der Abzug der Serverangaben, den der Player offline braucht.
|
|
private func offlineMedia(for item: MediaItem, media: MediaVersion, part: MediaFile) -> OfflineMedia {
|
|
OfflineMedia(
|
|
container: part.container ?? media.container,
|
|
videoCodec: media.videoCodec,
|
|
audioCodec: part.selectedAudioStream?.codec ?? media.audioCodec,
|
|
videoResolution: media.videoResolution,
|
|
bitrate: media.bitrate,
|
|
audioLanguages: part.audioStreams.compactMap(\.language),
|
|
subtitleLanguages: part.subtitleStreams.compactMap(\.language),
|
|
markers: item.markers.map {
|
|
OfflineMarker(
|
|
type: $0.kind.rawValue,
|
|
startMilliseconds: $0.startMilliseconds,
|
|
endMilliseconds: $0.endMilliseconds
|
|
)
|
|
}
|
|
)
|
|
}
|
|
}
|
|
|
|
extension MediaItem {
|
|
/// Die Zeile unter dem Titel in der Downloads-Ansicht.
|
|
var downloadSubtitle: String? {
|
|
if kind == .episode, let season = parentIndex, let episode = index {
|
|
return "\(String(localized: "episode.short \(season) \(episode)")) · \(title)"
|
|
}
|
|
return year.map(String.init)
|
|
}
|
|
}
|