iPad meldete sich nie an Die Ursache ist unangenehm speziell: Wer im Browser schon bei Plex angemeldet ist, wird von app.plex.tv/auth sofort auf watch.plex.tv weitergeschickt — OHNE dass der Code verknüpft wird. Man sieht sich angemeldet, die App wartet vergeblich, und es gibt keinerlei Hinweis. Jetzt wird plex.tv/link mit eingetragenem Code geöffnet. Diese Seite tut nur eine Sache: den Code bestätigen. Die Gerätekennung steckt ohnehin schon im PIN. Dazu ein Ausweg: bleibt es nach fünfzehn Sekunden ohne Token, erscheint der Code im Anmeldebildschirm zum Abtippen. Schließt man das Fenster ohne Erfolg, bleibt er stehen statt abzumelden — er gilt weiter. Download fragte nicht nach der Qualität Jetzt Original, Full HD, HD oder Sparsam, jeweils mit geschätzter Größe. Die Größe ist das eigentliche Entscheidungskriterium — „Full HD" allein sagt nicht, ob es aufs Telefon passt. Die komprimierten Stufen laufen über den Universal-Transcoder und sind weiterhin NICHT am Server geprüft; steht so im Quelltext. Schwarzes Bild ohne Anzeige Der Player kannte nur „spielt" und „spielt nicht". Wartete er auf Daten, stand man vor einem Standbild ohne Hinweis. Es gibt jetzt einen eigenen Pufferzustand mit Anzeige. Aufmacher war beschnitten Er stand auf 3:4, Plex liefert als `art` aber 16:9 — mit scaledToFill ging links und rechts je ein Viertel verloren, ohne dass man es dem Bild ansah. Die Seitenverhältnisse liegen jetzt an einer Stelle in FlixyAspectRatio. Schwarze Leiste über dem Aufmacher .ultraThinMaterial statt undurchsichtigem Grund: das Bild läuft unscharf hinter der Leiste weiter, statt an einer harten Kante zu enden. Weichgezeichnet, nicht abgedunkelt. 139 Tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
225 lines
8.3 KiB
Swift
225 lines
8.3 KiB
Swift
import Foundation
|
|
import FlixyDownloads
|
|
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"
|
|
}
|
|
}
|
|
|
|
/// 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 server: PlexServer?
|
|
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: PlexItem,
|
|
mediaIndex: Int = 0,
|
|
quality: DownloadQuality = .original
|
|
) throws(DownloadRefusal) -> DownloadPlan {
|
|
guard let server else {
|
|
throw .noDownloadableFile(reason: String(localized: "downloads.refused.noServer"))
|
|
}
|
|
let media = item.media.indices.contains(mediaIndex)
|
|
? item.media[mediaIndex]
|
|
: item.preferredMedia
|
|
guard let media else {
|
|
throw .noDownloadableFile(reason: String(localized: "downloads.refused.noMedia"))
|
|
}
|
|
guard let part = media.parts.first else {
|
|
throw .noDownloadableFile(reason: String(localized: "downloads.refused.noPart"))
|
|
}
|
|
let url: URL?
|
|
switch quality {
|
|
case .original:
|
|
url = server.downloadURL(part: 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,
|
|
maximumBitrate: quality.maximumBitrate ?? 8_000,
|
|
resolution: quality.resolution
|
|
)
|
|
}
|
|
guard let url else {
|
|
throw .noDownloadableFile(reason: String(localized: "downloads.refused.noURL"))
|
|
}
|
|
|
|
return DownloadPlan(
|
|
ratingKey: item.ratingKey,
|
|
title: item.rowTitle,
|
|
subtitle: item.downloadSubtitle,
|
|
sourceURL: url,
|
|
seriesKey: item.grandparentRatingKey,
|
|
seriesTitle: item.grandparentTitle,
|
|
seasonKey: item.parentRatingKey,
|
|
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: PlexItem,
|
|
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: PlexItem
|
|
) async -> (accepted: Int, refused: [(PlexItem, DownloadRefusal)], unavailable: Int) {
|
|
guard let server else { return (0, [], 0) }
|
|
|
|
let episodes: [PlexItem]
|
|
do {
|
|
episodes = try await allEpisodes(in: container, server: server)
|
|
} catch {
|
|
return (0, [], 0)
|
|
}
|
|
|
|
var accepted = 0
|
|
var refused: [(PlexItem, 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 }) {
|
|
continue
|
|
}
|
|
|
|
let detailed = (try? await server.metadata(ratingKey: episode.ratingKey)) ?? 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 {
|
|
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: PlexItem, server: PlexServer) async throws -> [PlexItem] {
|
|
switch container.kind {
|
|
case .season:
|
|
return try await server.children(of: container.ratingKey)
|
|
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))
|
|
}
|
|
return episodes
|
|
default:
|
|
return [container]
|
|
}
|
|
}
|
|
|
|
/// Der Abzug der Serverangaben, den der Player offline braucht.
|
|
private func offlineMedia(for item: PlexItem, media: PlexMedia, part: PlexPart) -> 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.type,
|
|
startMilliseconds: $0.startTimeOffset,
|
|
endMilliseconds: $0.endTimeOffset
|
|
)
|
|
}
|
|
)
|
|
}
|
|
}
|
|
|
|
extension PlexItem {
|
|
/// 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)
|
|
}
|
|
}
|