Flixy heißt ab hier Halo
Reine Umbenennung, kein Verhalten geändert — bewusst als eigener Commit, damit „umbenannt" und „umgebaut" im Nachhinein auseinanderzuhalten sind. 712 mal `Flixy`, 778 mal `flixy`, 7 mal `FLIXY` in 169 Dateien, dazu 25 Pfade. Ziele: `Halo`, `HaloTV`, `HaloTopShelf`, `HaloUITests`, `HaloKit` mit `HaloDesign`, `HaloDownloads`, `HaloPlayback` und `HaloProbe`. `MediaKit`, `JellyfinKit` und `DownloadWidget` tragen den Namen nicht und bleiben. `docs/superpowers/specs/` ist ausgenommen. Datierte Entwurfsdokumente sind ein Protokoll getroffener Entscheidungen; sie nachträglich umzuschreiben hiesse, die Vergangenheit zu glätten. **Die Bundle-ID wandert mit** — ausdrücklich so gewollt, mit bekanntem Preis: `com.scarriffleservices.halo` ist für iOS eine *andere* App. Die installierte Fassung bleibt daneben stehen, mit Downloads und Anmeldung in ihrem Container; Halo startet leer. Einmal neu anmelden, heruntergeladene Folgen einmal neu laden. Dasselbe gilt für die App-Gruppe, die sich TV-App und Top Shelf teilen. **Eine Folge davon, die beim Entscheiden noch nicht auf dem Tisch lag:** der Aufräumschritt aus dem vorigen Commit kommt an die alten Plex-Token nicht mehr heran. Der Schlüsselbund grenzt nach Team und Bundle-Kennung ab — was unter `…flixy` liegt, ist für `…halo` unerreichbar. Erledigt wird es trotzdem, nur anders: **die alte App löschen.** iOS räumt deren Schlüsselbund-Einträge beim Deinstallieren mit ab. Der Code bleibt stehen, weil er für jeden Stand vor dieser Umbenennung richtig ist und im Jahr einen Startvergleich kostet. Die Projektdatei ist aus `project.yml` neu erzeugt, der alte Ordner gelöscht statt umbenannt — sie steht ohnehin nicht im Repo. Das Verzeichnis des Repos selbst heißt weiter `Flixy`; das umzubenennen ist ein Handgriff ausserhalb des Projekts. 368 Tests grün. iOS und tvOS bauen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
2fcf6821c8
commit
4b9a1e7b67
@@ -0,0 +1,383 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user