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. /// /// 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) 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")) } guard let url = server.downloadURL(part: part) 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: 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 { try coordinator.enqueue(try plan(for: item, mediaIndex: mediaIndex)) } /// 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) } }