Der Anmeldebildschirm kannte nur Plex. JellyfinKit war fertig, aber von der App aus nicht erreichbar — „ich sehe nix von Jellyfin". Quick Connect steht vorn: der Server nennt einen sechsstelligen Code, den man in einem bereits angemeldeten Client bestätigt. Das Passwort erreicht Flixy dabei nie, dieselbe Zusage wie bei Plex. Benutzername und Passwort sind der Rückfall für Server, auf denen Quick Connect abgeschaltet ist; das Passwort wird dann für die Dauer einer Anfrage gehalten und danach weggeworfen. Die Adresse wird nicht erraten, sondern durchprobiert. „192.168.1.50" kann alles Mögliche heißen, und die Reihenfolge ist der Kern von JellyfinAddress: eine IP-Adresse im eigenen Netz spricht fast nie HTTPS, ein Domänenname fast immer. Andersherum wartet man bei jedem Server ohne Zertifikat erst auf eine Zeitüberschreitung. Die Sitzung hält von hier an einen Server hinter dem neutralen Vertrag, nicht mehr einen Plex-Server. Die Ansichten haben das nicht gemerkt — genau dafür war MediaKit da. Dabei fällt ab, was Jellyfin kann und Plex nicht: Favoriten stehen im Aktionsmenü, sobald der Server sie kennt, Sterne nur dort, wo es sie gibt. Was noch nicht geht und deshalb auch nicht behauptet wird: Wiedergabe und Live-TV sprechen weiter unmittelbar mit Plex. Bei Jellyfin verschwindet der Live-TV-Eintrag aus der Seitenleiste, und der Player sagt, dass die Wiedergabe der nächste Schritt ist, statt „Server nicht erreichbar" zu melden — er ist ja erreichbar. Nebenbei zwei Stellen, die der Übersetzer aus Plex hinterlassen hat: `catch is CancellationError` griff seit den getippten Fehlern nie mehr, weil eine abgebrochene Netzanfrage als Übertragungsfehler zurückkommt; und das Ersatz-Hintergrundbild einer Folge war toter Code, weil die linke Seite des `??` gar nicht optional war. TokenStore wandert nach MediaKit — Tokens sind nichts Plex-Eigenes. Die Konten sind nach Server getrennt, damit ein späteres Umschalten nicht jedes Mal eine neue Anmeldung kostet; „Abmelden" räumt trotzdem beide ab. 248 Tests grün. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
251 lines
9.3 KiB
Swift
251 lines
9.3 KiB
Swift
import Foundation
|
|
import FlixyDownloads
|
|
import MediaKit
|
|
|
|
/// Ü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: (any MediaBackend)?
|
|
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 {
|
|
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: any MediaBackend) 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)
|
|
}
|
|
}
|