`FlaroServerKit` steht neben `JellyfinKit`, nicht darin — genau auf dem Steckplatz, den Plex geräumt hat. Dass `MediaBackend` zwei Server zugleich trägt, war schon einmal bewiesen; die Naht hat gehalten, und keine einzige Ansicht musste dafür neu geschrieben werden. `ServerKind` ist zurück. Es wurde beim Entfernen von Plex gestrichen, weil ein Aufzählungstyp mit einem Fall ein umständliches Bool ist. Jetzt gibt es wieder zwei, und es ist wieder eine Unterscheidung — einer zur Zeit, nicht beide nebeneinander. **Flaro steht oben auf dem Anmeldebildschirm.** Nicht aus Eitelkeit: dort bringt ein Download alle Tonspuren mit und der Fortschritt wird ab der ersten Sekunde gemerkt. Wer Jellyfin benutzt, sucht gezielt danach — wer neu anfängt, soll nicht in der schwächeren Hälfte landen, weil ihr Knopf auffälliger war. Am Fernseher steht bei beiden der sechsstellige Code voran. --- **Der Zugang geht als Cookie, nicht in der Adresse.** Jellyfin hängt sein Token an jede Bildadresse, weil eine Bildansicht keine Kopfzeilen schickt; es steht damit in jedem Zugriffsprotokoll und jedem weitergegebenen Link. Flaro tut das nicht, und deshalb legt der Client sein Token selbst im gemeinsamen Keksspeicher ab — auch beim Wiederaufnehmen aus dem Schlüsselbund, wo gar keine Anmeldung stattfindet und der Server es nie setzen könnte. `URLSession` schickt es von allein mit; **AVFoundation nicht**. Der Systemplayer bekommt die Kekse jetzt ausdrücklich. Serverneutral: wo keine liegen, ist die Liste leer. **`BackendCapability.allTracksDownload`** statt einer Spurwahl, die ins Leere führt. Die Frage „welche Tonspur?" gibt es nur, weil Jellyfins Transcoder eine je Sitzung abbildet. Bei Flaro führen beide Antworten zum selben Ergebnis — also wird nicht gefragt, und das Umpacken mit einer einzigen Spur entfällt gleich mit. **Die Adressraterei wandert nach `MediaKit`.** Sechzig Zeilen sorgfältig begründeter Reihenfolge — IP-Adressen sprechen selten HTTPS, Domänennamen fast immer — unterscheiden sich zwischen beiden Servern nur im Standardport. Zweimal hingeschrieben wäre die eine Fassung beim nächsten Sonderfall nachgezogen worden und die andere nicht. Jellyfins Prüfungen laufen unverändert gegen die verschobene Fassung; das ist der Beweis. **Nebenbei zwei Altlasten:** `player.jellyfinPending` war unerreichbar — der Player zeigt diese Meldung nur, wenn gar kein Server da ist, dann aber nie die mit Jellyfin. Und `signIn.jellyfin.userName` hiess „Benutzername"; der Schlüssel heisst jetzt so neutral wie der Text. --- **Der Fehler, den erst der Simulator zeigte:** „Stelle wird wiederhergestellt", 0:00, für immer. Muss der Server umpacken, antwortet er auf den Download mit 202 und fängt an. AVPlayer bekommt darauf kein Video, sondern eine leere Antwort — und wartet, unbegrenzt, ohne Fehler. Die Weboberfläche fragt in einer Schleife nach; `preparePlayback` tat es nicht. Jetzt wartet es, mit Obergrenze und einer Begründung, wenn sie reisst. Mit Prüfung, die zwei Runden erzwingt. 413 Tests grün. Im Simulator gegen den echten Server nachgewiesen: Adresse eintippen, Server gefunden, angemeldet, Mediathek mit Plakaten, Neustart ohne erneute Anmeldung — und eine MKV abgespielt, die der Server dafür erst umpacken musste. Danach stand auf dem Server: Matrix, 2,0 s von 2,0 s, gesehen, ein Durchlauf. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
402 lines
17 KiB
Swift
402 lines
17 KiB
Swift
import Foundation
|
|
import FlaroDownloads
|
|
import FlaroPlayback
|
|
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
|
|
/// Welche Sprache gewünscht ist — nur fürs Umpacken.
|
|
///
|
|
/// **Weil beim Umpacken eine Tonspur übrig bleibt.** Online wählt man im
|
|
/// Player; offline gibt es nach dem Herunterladen nichts mehr zu wählen,
|
|
/// also fällt die Entscheidung hier. Dieselbe Abwägung wie beim Abspielen
|
|
/// (`AudioTrackChooser`) — eine zweite Regel liefe irgendwann auseinander.
|
|
///
|
|
/// Optional, damit Aufrufer ohne Spracheinstellungen weiter bauen: dann
|
|
/// gilt, was der Server als Standard meldet.
|
|
var languages: LanguagePreferences?
|
|
|
|
/// 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,
|
|
audioStream: MediaStream? = nil
|
|
) 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"))
|
|
}
|
|
// **Was offline liegt, muss der Systemplayer öffnen können.** Online
|
|
// springt für eine Matroska das Umpacken ein; auf dem Gerät gibt es
|
|
// hinterher niemanden mehr, der das nachholen könnte — und seit es
|
|
// keine zweite Engine mehr gibt, wäre die Datei schlicht stumm und
|
|
// schwarz. Also wird schon beim Herunterladen umgepackt.
|
|
let umpacken = quality == .original && !kannAlleSpuren
|
|
&& !systemPlayerOpens(media: media, part: part)
|
|
|
|
let url: URL?
|
|
switch quality {
|
|
case .original where umpacken:
|
|
// **Gewählt schlägt geraten.** Hier stand nur die Abwägung aus
|
|
// `AudioTrackChooser` — und die hat bei einer Aufnahme mit
|
|
// Audiodeskription die falsche Spur genommen, ohne zu fragen.
|
|
// Nach dem Download gibt es nichts mehr zu korrigieren; drei
|
|
// Gigabyte sind dann weg. Jetzt entscheidet, wer gefragt wurde,
|
|
// und die Abwägung ist nur noch der Vorschlag.
|
|
let ton = audioStream ?? preferredAudioStream(in: part, of: item)
|
|
let untertitel = part.subtitleStreams.first { $0.isSelected }
|
|
?? part.subtitleStreams.first
|
|
|
|
url = backend.remuxDownloadURL(
|
|
itemID: item.id,
|
|
versionIndex: mediaIndex,
|
|
audioStreamIndex: ton?.index,
|
|
subtitleStreamIndex: untertitel?.index
|
|
) ?? backend.downloadURL(for: part)
|
|
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
|
|
),
|
|
// **Nur beim Original ist die Größe eine Tatsache.** Sie steht in
|
|
// den Metadaten des Servers. Die umgerechneten Stufen sind aus
|
|
// Bitrate und Dauer hochgerechnet — und was hochgerechnet ist,
|
|
// darf abweichen, ohne dass die Datei kaputt wäre.
|
|
// Umgepackt stimmt die Größe des Servers nicht mehr: dieselben
|
|
// Ströme in einer anderen Hülle wiegen anders. Dann ist sie wieder
|
|
// eine Schätzung — und die weiche Prüfung die richtige.
|
|
expectedIsExact: quality == .original && !umpacken && part.size != nil,
|
|
offlineMedia: offlineMedia(for: item, media: media, part: part, remuxed: umpacken)
|
|
)
|
|
}
|
|
|
|
/// Lädt einen einzelnen Titel.
|
|
@discardableResult
|
|
func download(
|
|
_ item: MediaItem,
|
|
mediaIndex: Int = 0,
|
|
quality: DownloadQuality = .original,
|
|
audioStream: MediaStream? = nil
|
|
) throws(DownloadRefusal) -> DownloadItem {
|
|
try coordinator.enqueue(
|
|
try plan(for: item, mediaIndex: mediaIndex, quality: quality, audioStream: audioStream)
|
|
)
|
|
}
|
|
|
|
/// Ob der Server beim Herunterladen alle Spuren mitgibt.
|
|
///
|
|
/// **Der Unterschied, um dessentwillen Flaro gebaut wurde.** Flaros
|
|
/// Download liefert eine MP4 mit jeder Ton- und Textspur, auch wenn dafür
|
|
/// umgepackt werden muss. Dann entfällt die Spurwahl, und das Umpacken
|
|
/// mit genau einer Spur entfällt gleich mit — es gäbe nichts zu
|
|
/// verbessern, nur etwas zu verlieren.
|
|
private var kannAlleSpuren: Bool {
|
|
backend?.capabilities.contains(.allTracksDownload) == true
|
|
}
|
|
|
|
/// Ob beim Herunterladen eine Tonspur verlorengeht — und deshalb gefragt
|
|
/// werden muss.
|
|
///
|
|
/// **Die Frage gehört vor den Download, nicht danach.** Wird umgepackt,
|
|
/// legt der Server genau eine Tonspur in die neue Hülle; was nicht
|
|
/// mitkommt, ist offline nicht mehr zu holen. Automatisch zu wählen hiess
|
|
/// hier: dem Nutzer die Entscheidung abnehmen und ihn mit dem Ergebnis
|
|
/// leben lassen — bei einer Aufnahme mit Audiodeskription genau der
|
|
/// falsche Weg.
|
|
func needsAudioChoice(for item: MediaItem, mediaIndex: Int = 0, quality: DownloadQuality) -> Bool {
|
|
guard quality == .original else { return false }
|
|
// **Kann der Server alles mitgeben, gibt es nichts zu entscheiden.**
|
|
// Die Frage ist nur deshalb da, weil Jellyfins Transcoder eine
|
|
// Tonspur je Sitzung abbildet. Wo das nicht gilt, wäre sie eine
|
|
// Entscheidung, die der Nutzer treffen soll, obwohl beide Antworten
|
|
// zum selben Ergebnis führen.
|
|
guard !kannAlleSpuren else { return false }
|
|
guard let media = mediaIndex < item.versions.count
|
|
? item.versions[mediaIndex] : item.preferredVersion,
|
|
let part = media.files.first
|
|
else { return false }
|
|
return !systemPlayerOpens(media: media, part: part) && part.audioStreams.count > 1
|
|
}
|
|
|
|
/// Was ein Stapel ergeben hat.
|
|
typealias BatchResult = (
|
|
accepted: Int,
|
|
refused: [(MediaItem, DownloadRefusal)],
|
|
unavailable: Int
|
|
)
|
|
|
|
/// Lädt alle Folgen einer Staffel oder Serie.
|
|
func downloadAll(
|
|
in container: MediaItem,
|
|
quality: DownloadQuality = .original
|
|
) async -> BatchResult {
|
|
guard let backend else { return (0, [], 0) }
|
|
guard let episodes = try? await allEpisodes(in: container, backend: backend) else {
|
|
return (0, [], 0)
|
|
}
|
|
return await download(episodes, quality: quality)
|
|
}
|
|
|
|
/// Lädt genau die übergebenen Folgen.
|
|
///
|
|
/// **Holt die Metadaten jeder Folge einzeln nach.** Die Kinderliste einer
|
|
/// Staffel enthält keine Dateiangaben; ohne Dateigröße ließe sich weder
|
|
/// das Speicherlimit prüfen noch ein Fortschrittsbalken zeigen — und eine
|
|
/// erwartete Größe von null überspringt die Prüfung auf freien Platz
|
|
/// stillschweigend.
|
|
func download(
|
|
_ episodes: [MediaItem],
|
|
quality: DownloadQuality = .original,
|
|
audioStreamIndex: Int? = nil
|
|
) async -> BatchResult {
|
|
guard let backend else { 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 {
|
|
// Die Nummer, nicht die Spur: innerhalb einer Staffel sind
|
|
// die Spuren gleich angeordnet, und die Folge selbst kennt
|
|
// ihre eigene Liste.
|
|
let ton = audioStreamIndex.flatMap { nummer in
|
|
detailed.preferredVersion?.files.first?.audioStreams
|
|
.first { $0.index == nummer }
|
|
}
|
|
_ = try coordinator.enqueue(
|
|
try plan(for: detailed, quality: quality, audioStream: ton)
|
|
)
|
|
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.
|
|
/// Welche Tonspur vorgeschlagen wird.
|
|
///
|
|
/// **Die gemerkte der Serie zuerst.** Wer eine Folge schon einmal auf
|
|
/// Deutsch gesehen hat, will sie nicht auf Englisch herunterladen — und
|
|
/// die App merkt sich diese Entscheidung ohnehin schon fürs Abspielen
|
|
/// (`LanguagePreferences.rememberedTrack`). Dieselbe Abwägung also, über
|
|
/// denselben Weg: `wish` trägt das Gemerkte, die Serieneinstellung und
|
|
/// die allgemeine Sprache in dieser Reihenfolge.
|
|
///
|
|
/// Die Mediathek bleibt aussen vor: sie nachzuschlagen kostet eine
|
|
/// Anfrage je Folge, und die Ebenen darüber tragen den Wunsch ohnehin.
|
|
func preferredAudioStream(in part: MediaFile, of item: MediaItem) -> MediaStream? {
|
|
guard let languages else { return part.selectedAudioStream }
|
|
return AudioTrackChooser.choose(
|
|
from: part.streams,
|
|
wish: languages.wish(seriesID: item.seriesID, libraryID: nil)
|
|
) ?? part.selectedAudioStream
|
|
}
|
|
|
|
/// Ob AVFoundation die Datei so, wie sie auf dem Server liegt, öffnet.
|
|
private func systemPlayerOpens(media: MediaVersion, part: MediaFile) -> Bool {
|
|
DeviceCapabilities.avFoundation.opens(
|
|
container: part.container ?? media.container,
|
|
videoCodec: media.videoCodec,
|
|
audioCodec: part.selectedAudioStream?.codec ?? media.audioCodec
|
|
)
|
|
}
|
|
|
|
private func offlineMedia(
|
|
for item: MediaItem,
|
|
media: MediaVersion,
|
|
part: MediaFile,
|
|
remuxed: Bool
|
|
) -> OfflineMedia {
|
|
OfflineMedia(
|
|
// Umgepackt liegt eine MP4 auf dem Gerät, keine Matroska. Stünde
|
|
// hier weiter die alte Schachtel, entschiede der Player anhand
|
|
// einer Datei, die es so nicht mehr gibt.
|
|
container: remuxed ? "mp4" : (part.container ?? media.container),
|
|
videoCodec: media.videoCodec,
|
|
// **Beim Umpacken wissen wir es nicht.** Der Server rechnet nur
|
|
// um, was nicht durchpasst — ob aus DTS dann AAC oder AC3 wird,
|
|
// sagt er vorher nicht. Eine Behauptung wäre schlechter als die
|
|
// Lücke: der Player liest die Spuren ohnehin aus der Datei.
|
|
audioCodec: remuxed ? nil : (part.selectedAudioStream?.codec ?? media.audioCodec),
|
|
videoResolution: media.videoResolution,
|
|
bitrate: media.bitrate,
|
|
// **Beim Umpacken bleibt je eine übrig.** Alle zu notieren wäre
|
|
// ein Versprechen, das die Datei nicht hält — und offline ist es
|
|
// nicht nachprüfbar, bevor der Player sie öffnet.
|
|
audioLanguages: remuxed ? [] : part.audioStreams.compactMap(\.language),
|
|
subtitleLanguages: remuxed ? [] : 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)
|
|
}
|
|
}
|