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>
175 lines
6.5 KiB
Swift
175 lines
6.5 KiB
Swift
import Foundation
|
|
import SwiftData
|
|
|
|
/// Ein heruntergeladener oder herunterzuladender Titel.
|
|
///
|
|
/// Liegt in SwiftData, weil Downloads die App überleben: iOS überträgt im
|
|
/// Hintergrund weiter, auch wenn die App beendet ist, und beim nächsten Start
|
|
/// muss aus dem gespeicherten Zustand hervorgehen, was zu tun ist.
|
|
@Model
|
|
public final class DownloadItem {
|
|
/// Der Schlüssel des Titels auf dem Server. Eindeutig — derselbe Titel
|
|
/// soll nicht zweimal in der Warteschlange stehen.
|
|
@Attribute(.unique) public var ratingKey: String
|
|
|
|
/// Der Zustand als Zeichenkette. SwiftData kann Aufzählungen speichern,
|
|
/// aber eine Zeichenkette bleibt bei Umbenennungen im Code lesbar — und
|
|
/// ein unlesbarer Zustand hieße: laufende Downloads sind verloren.
|
|
public var stateRaw: String
|
|
|
|
public var title: String
|
|
public var subtitle: String?
|
|
/// Zu welcher Serie und Staffel der Titel gehört. Gruppiert die
|
|
/// Downloads-Ansicht und ist Grundlage für „halte N Folgen offline".
|
|
public var seriesKey: String?
|
|
public var seriesTitle: String?
|
|
public var seasonKey: String?
|
|
public var seasonIndex: Int?
|
|
public var episodeIndex: Int?
|
|
|
|
public var totalBytes: Int64
|
|
public var receivedBytes: Int64
|
|
/// Ob `totalBytes` die **genaue** Größe ist oder eine Schätzung.
|
|
///
|
|
/// **Daran hängt, wann eine Datei als abgerissen gilt.** Beim Original
|
|
/// nennt der Server die Dateigröße in den Metadaten — da ist jedes
|
|
/// fehlende Byte ein Abriss. Bei den umgerechneten Stufen rechnet die App
|
|
/// die Größe aus Bitrate und Dauer hoch, und die weicht von Haus aus ab;
|
|
/// dort wäre dieselbe Strenge falsch.
|
|
///
|
|
/// Ohne diese Unterscheidung galt für beides die Hälfte-Regel — und eine
|
|
/// bei sechzig Prozent abgerissene Datei bekam einen Haken.
|
|
public var expectedIsExact: Bool = false
|
|
|
|
/// Dateiname im Download-Ordner. Nicht der volle Pfad: der Container der
|
|
/// App bekommt bei jedem Systemupdate eine neue Kennung, ein gespeicherter
|
|
/// absoluter Pfad zeigt danach ins Leere.
|
|
public var localFileName: String?
|
|
public var artworkFileName: String?
|
|
|
|
public var addedAt: Date
|
|
public var completedAt: Date?
|
|
/// Wann der Titel durchgesehen wurde — Grundlage fürs automatische Löschen.
|
|
public var watchedAt: Date?
|
|
public var failureMessage: String?
|
|
|
|
/// Woher geladen wird. Enthält den Token, deshalb liegt er im geschützten
|
|
/// App-Container und nicht in den Voreinstellungen.
|
|
public var sourceURLString: String
|
|
public var durationMilliseconds: Int?
|
|
|
|
/// Was der Player offline über die Datei wissen muss: Container, Codecs,
|
|
/// Spuren, Marken. Als JSON, weil die Form von Plex vorgegeben ist und
|
|
/// sich eigene SwiftData-Modelle dafür nicht lohnen.
|
|
public var offlineMediaData: Data?
|
|
|
|
/// Zum Fortsetzen nach dem Pausieren. iOS liefert diesen Datensatz beim
|
|
/// Abbrechen; ohne ihn beginnt ein pausierter Download wieder bei null.
|
|
public var resumeData: Data?
|
|
|
|
public init(
|
|
ratingKey: String,
|
|
title: String,
|
|
subtitle: String? = nil,
|
|
sourceURLString: String,
|
|
state: DownloadState = .queued,
|
|
totalBytes: Int64 = 0,
|
|
seriesKey: String? = nil,
|
|
seriesTitle: String? = nil,
|
|
seasonKey: String? = nil,
|
|
seasonIndex: Int? = nil,
|
|
episodeIndex: Int? = nil,
|
|
durationMilliseconds: Int? = nil,
|
|
offlineMediaData: Data? = nil,
|
|
expectedIsExact: Bool = false
|
|
) {
|
|
self.ratingKey = ratingKey
|
|
self.title = title
|
|
self.subtitle = subtitle
|
|
self.sourceURLString = sourceURLString
|
|
self.stateRaw = state.rawValue
|
|
self.totalBytes = totalBytes
|
|
self.receivedBytes = 0
|
|
self.seriesKey = seriesKey
|
|
self.seriesTitle = seriesTitle
|
|
self.seasonKey = seasonKey
|
|
self.seasonIndex = seasonIndex
|
|
self.episodeIndex = episodeIndex
|
|
self.durationMilliseconds = durationMilliseconds
|
|
self.offlineMediaData = offlineMediaData
|
|
self.expectedIsExact = expectedIsExact
|
|
self.addedAt = Date()
|
|
}
|
|
|
|
public var state: DownloadState {
|
|
get { DownloadState(rawValue: stateRaw) ?? .failed }
|
|
set { stateRaw = newValue.rawValue }
|
|
}
|
|
|
|
/// 0…1, oder `nil` solange die Gesamtgröße unbekannt ist.
|
|
///
|
|
/// Ein Balken bei null Prozent, der sich nie bewegt, sieht aus wie ein
|
|
/// hängender Download — ohne Gesamtgröße ist ein unbestimmter Fortschritt
|
|
/// die ehrlichere Anzeige.
|
|
public var progress: Double? {
|
|
guard totalBytes > 0 else { return nil }
|
|
return min(Double(receivedBytes) / Double(totalBytes), 1)
|
|
}
|
|
|
|
public var isComplete: Bool { state == .completed }
|
|
|
|
/// Wie viel Platz dieser Eintrag gerade belegt.
|
|
public var occupiedBytes: Int64 {
|
|
state.occupiesStorage ? max(receivedBytes, 0) : 0
|
|
}
|
|
}
|
|
|
|
/// Was der Player offline über eine Datei wissen muss.
|
|
///
|
|
/// Ein Abzug der Serverangaben zum Zeitpunkt des Downloads. Offline lässt sich
|
|
/// nichts nachfragen — und der `PlaybackDecider` braucht Container und Codecs,
|
|
/// um zwischen die Engines zu wählen.
|
|
public struct OfflineMedia: Sendable, Codable, Equatable {
|
|
public var container: String?
|
|
public var videoCodec: String?
|
|
public var audioCodec: String?
|
|
public var videoResolution: String?
|
|
public var bitrate: Int?
|
|
public var audioLanguages: [String]
|
|
public var subtitleLanguages: [String]
|
|
/// Intro und Abspann, in Millisekunden.
|
|
public var markers: [OfflineMarker]
|
|
|
|
public init(
|
|
container: String? = nil,
|
|
videoCodec: String? = nil,
|
|
audioCodec: String? = nil,
|
|
videoResolution: String? = nil,
|
|
bitrate: Int? = nil,
|
|
audioLanguages: [String] = [],
|
|
subtitleLanguages: [String] = [],
|
|
markers: [OfflineMarker] = []
|
|
) {
|
|
self.container = container
|
|
self.videoCodec = videoCodec
|
|
self.audioCodec = audioCodec
|
|
self.videoResolution = videoResolution
|
|
self.bitrate = bitrate
|
|
self.audioLanguages = audioLanguages
|
|
self.subtitleLanguages = subtitleLanguages
|
|
self.markers = markers
|
|
}
|
|
}
|
|
|
|
public struct OfflineMarker: Sendable, Codable, Equatable {
|
|
public var type: String
|
|
public var startMilliseconds: Int
|
|
public var endMilliseconds: Int
|
|
|
|
public init(type: String, startMilliseconds: Int, endMilliseconds: Int) {
|
|
self.type = type
|
|
self.startMilliseconds = startMilliseconds
|
|
self.endMilliseconds = endMilliseconds
|
|
}
|
|
}
|