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" } } /// 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 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) } }