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>
146 lines
6.0 KiB
Swift
146 lines
6.0 KiB
Swift
import Foundation
|
|
|
|
/// Verwaltet die heruntergeladenen Dateien auf der Platte.
|
|
///
|
|
/// Getrennt von der Datenbank, damit beides einzeln prüfbar bleibt: hier geht
|
|
/// es nur um Dateien, Pfade und Platz.
|
|
public struct DownloadFileStore: Sendable {
|
|
private let root: URL
|
|
|
|
/// Nicht gespeichert, sondern bei jedem Zugriff frisch geholt.
|
|
/// `FileManager` ist nicht Sendable; `.default` ist aber
|
|
/// threadsicher für die hier verwendeten Operationen, und ein eigener
|
|
/// Verweis pro Aufruf kostet nichts.
|
|
private var fileManager: FileManager { .default }
|
|
|
|
public init(root: URL? = nil) throws {
|
|
if let root {
|
|
self.root = root
|
|
} else {
|
|
// „Application Support", nicht „Caches": iOS räumt den Zwischen-
|
|
// speicher bei knappem Platz eigenmächtig leer. Ein Film, der auf
|
|
// dem Flug verschwindet, wäre das Gegenteil von Offline-Nutzung.
|
|
let base = try FileManager.default.url(
|
|
for: .applicationSupportDirectory,
|
|
in: .userDomainMask,
|
|
appropriateFor: nil,
|
|
create: true
|
|
)
|
|
self.root = base.appending(path: "Downloads", directoryHint: .isDirectory)
|
|
}
|
|
try createRootIfNeeded()
|
|
}
|
|
|
|
private func createRootIfNeeded() throws {
|
|
guard !fileManager.fileExists(atPath: root.path(percentEncoded: false)) else { return }
|
|
try fileManager.createDirectory(at: root, withIntermediateDirectories: true)
|
|
|
|
// Ein 40-GB-Remux gehört nicht in die iCloud-Sicherung. Ohne diese
|
|
// Markierung zählt Apple ihn zum Speicherbedarf des Kontos — und die
|
|
// Sicherung selbst würde tagelang laufen.
|
|
var resourceValues = URLResourceValues()
|
|
resourceValues.isExcludedFromBackup = true
|
|
var mutableRoot = root
|
|
try mutableRoot.setResourceValues(resourceValues)
|
|
}
|
|
|
|
public func url(forFileName name: String) -> URL {
|
|
root.appending(path: name)
|
|
}
|
|
|
|
/// Ein Dateiname aus Titelschlüssel und ursprünglicher Endung.
|
|
///
|
|
/// Der Schlüssel statt des Titels: Titel enthalten Schrägstriche,
|
|
/// Doppelpunkte und Umlaute, und die Endung entscheidet mit darüber, ob
|
|
/// AVFoundation die Datei überhaupt zu öffnen versucht.
|
|
public func fileName(forRatingKey key: String, sourceURL: URL) -> String {
|
|
let ext = sourceURL.pathExtension.isEmpty ? "mp4" : sourceURL.pathExtension
|
|
return "\(key).\(ext)"
|
|
}
|
|
|
|
/// Verschiebt die fertige Datei aus dem temporären Ordner an ihren Platz.
|
|
///
|
|
/// Verschieben, nicht kopieren: iOS löscht die temporäre Datei, sobald der
|
|
/// Rückruf zurückkehrt. Ein Kopiervorgang für 40 GB wäre zudem ein zweites
|
|
/// Mal derselbe Platzbedarf.
|
|
public func adopt(temporaryFile: URL, as name: String) throws -> URL {
|
|
let destination = url(forFileName: name)
|
|
if fileManager.fileExists(atPath: destination.path(percentEncoded: false)) {
|
|
try fileManager.removeItem(at: destination)
|
|
}
|
|
try fileManager.moveItem(at: temporaryFile, to: destination)
|
|
return destination
|
|
}
|
|
|
|
public func remove(fileName: String?) {
|
|
guard let fileName else { return }
|
|
try? fileManager.removeItem(at: url(forFileName: fileName))
|
|
}
|
|
|
|
public func exists(fileName: String?) -> Bool {
|
|
guard let fileName else { return false }
|
|
return fileManager.fileExists(atPath: url(forFileName: fileName).path(percentEncoded: false))
|
|
}
|
|
|
|
public func size(ofFileName fileName: String) -> Int64 {
|
|
let attributes = try? fileManager.attributesOfItem(
|
|
atPath: url(forFileName: fileName).path(percentEncoded: false)
|
|
)
|
|
return (attributes?[.size] as? NSNumber)?.int64Value ?? 0
|
|
}
|
|
|
|
/// Was die Downloads insgesamt belegen.
|
|
public func totalSize() -> Int64 {
|
|
guard let entries = try? fileManager.contentsOfDirectory(
|
|
at: root,
|
|
includingPropertiesForKeys: [.fileSizeKey]
|
|
) else { return 0 }
|
|
|
|
return entries.reduce(into: Int64(0)) { total, entry in
|
|
let values = try? entry.resourceValues(forKeys: [.fileSizeKey])
|
|
total += Int64(values?.fileSize ?? 0)
|
|
}
|
|
}
|
|
|
|
/// Wie viel auf dem Gerät noch frei ist.
|
|
///
|
|
/// Vor jedem Download geprüft: ein Download, der das Gerät vollschreibt,
|
|
/// legt auch alles andere lahm.
|
|
///
|
|
/// **Auf tvOS gibt es die Auskunft nicht** —
|
|
/// `volumeAvailableCapacityForImportantUsage` ist dort als nicht
|
|
/// verfügbar markiert, und ein Apple TV hat auch keinen Speicher, auf den
|
|
/// man etwas legt. Das Paket wird dort trotzdem mitverlinkt, damit
|
|
/// `PlayerModel` den Koordinator **nennen** darf, ohne dass zweitausend
|
|
/// Zeilen Wiedergabelogik von `#if` durchzogen werden. Angelegt wird nie
|
|
/// einer; diese Zeile läuft also nie. Null heisst „kein Platz", und das
|
|
/// ist die richtige Antwort für ein Gerät, auf dem nichts liegen soll.
|
|
public func availableCapacity() -> Int64 {
|
|
#if os(tvOS)
|
|
0
|
|
#else
|
|
let values = try? root.resourceValues(forKeys: [.volumeAvailableCapacityForImportantUsageKey])
|
|
return values?.volumeAvailableCapacityForImportantUsage ?? 0
|
|
#endif
|
|
}
|
|
|
|
/// Entfernt Dateien, zu denen es keinen Eintrag mehr gibt.
|
|
///
|
|
/// Verwaiste Dateien entstehen, wenn die App während des Abschlusses
|
|
/// beendet wird: die Datei liegt schon da, der Eintrag ist noch nicht
|
|
/// aktualisiert. Ohne Aufräumen belegen sie dauerhaft Platz, ohne dass
|
|
/// die Downloads-Ansicht sie zeigt.
|
|
public func removeOrphans(keeping knownFileNames: Set<String>) -> Int {
|
|
guard let entries = try? fileManager.contentsOfDirectory(
|
|
at: root,
|
|
includingPropertiesForKeys: nil
|
|
) else { return 0 }
|
|
|
|
var removed = 0
|
|
for entry in entries where !knownFileNames.contains(entry.lastPathComponent) {
|
|
if (try? fileManager.removeItem(at: entry)) != nil { removed += 1 }
|
|
}
|
|
return removed
|
|
}
|
|
}
|