import Foundation import HaloDownloads import HaloPlayback 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 && !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 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 } 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) } }