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. @MainActor struct DownloadService { let server: PlexServer? let coordinator: DownloadCoordinator /// Baut den Auftrag für einen einzelnen Titel. /// /// `nil`, wenn es keine abspielbare Datei gibt — bei einem Titel, den der /// Server noch nicht analysiert hat, etwa. func plan(for item: PlexItem, mediaIndex: Int = 0) -> DownloadPlan? { guard let server else { return nil } let media = item.media.indices.contains(mediaIndex) ? item.media[mediaIndex] : item.preferredMedia guard let media, let part = media.parts.first, let url = server.downloadURL(part: part) else { return nil } 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: part.size ?? 0, offlineMedia: offlineMedia(for: item, media: media, part: part) ) } /// Lädt einen einzelnen Titel. @discardableResult func download(_ item: PlexItem, mediaIndex: Int = 0) throws(DownloadRefusal) -> DownloadItem? { guard let plan = plan(for: item, mediaIndex: mediaIndex) else { return nil } return try coordinator.enqueue(plan) } /// 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 guard let plan = plan(for: detailed) else { unavailable += 1 continue } do { _ = try coordinator.enqueue(plan) accepted += 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) } }