Files
Flaro/Packages/FlixyKit/Sources/FlixyDownloads/Engine/DownloadFileStore.swift
T
ScarriffleandClaude Opus 5 6010229b80 Die Pakete wissen jetzt, was ein Fernseher ist
Schritt eins zur Apple-TV-Fassung: nichts davon ist sichtbar, aber ohne
das lässt sich für tvOS nicht einmal übersetzen.

**Der stille Fehler zuerst.** `FlixyPlatform.isMac` hatte einen
`#else`-Zweig, der `true` zurückgab — eine Antwort auf eine Frage, die
niemand gestellt hatte. Auf tvOS fiel er dort hindurch: kein
Übersetzungsfehler, keine Warnung, nur ein Fernseher, der sich für einen
Mac hielt. Jetzt steht dort `#error`. Die nächste Plattform bricht den
Bau ab und verlangt eine Entscheidung, statt sich eine auszusuchen. Ein
Test hält fest, dass nicht beides zugleich gelten darf; er hätte genau
diesen Fehler gefangen.

**Ein echter Übersetzungsfehler**, und er lag an der Reihenfolge:
`UIPasteboard` steht hinter `canImport(UIKit)`, und das ist auf tvOS
wahr. Der tvOS-Zweig steht deshalb jetzt davor — ein Fernseher hat
ohnehin nichts, wohin man etwas einfügen könnte.

**Drei Vermutungen waren falsch**, nachgeschlagen im tvOS-SDK statt
geraten: `presentationDetents` gibt es dort seit tvOS 16 (übersetzt,
bewirkt nur nichts — jetzt steht es ausdrücklich da), `.topBarTrailing`
seit 14.0, und `isMac` hatte acht Fundstellen in fünf Dateien, nicht
fünfzig in siebenundzwanzig.

**Schrift und Abstände** bekommen einen eigenen Zweig statt durchzufallen.
Nicht feste Grössen: Dynamic Type gibt es auf tvOS auch, und dass `body`
dort grösser ausfällt, ist der Sinn der Sache. Falsch war nur die
Zuordnung der Rollen — die Skala ist anders gespreizt, und eine
Beschriftung in `subheadline` unter einem Plakat ist aus drei Metern
Zierrat. Der Aufmacher geht auf `.title`, weil UIKit `largeTitle` auf
tvOS gar nicht kennt und nirgends steht, was SwiftUI daraus macht.
`screenMargin` steht auf 48 und nicht auf 90: die neunzig Punkte des
Überscan-Bereichs liefert das System als Sicherheitsrand, und wer sie
hier aufschlägt, hat hundertachtzig.

**Zwei Dinge, die sich erst beim Bauen zeigten.** `FlixyDownloads`
übersetzte nicht — `volumeAvailableCapacityForImportantUsage` gibt es
auf tvOS nicht. 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.
Und `FlixyProbe` liess das ganze Paket scheitern: ein
Kommandozeilenwerkzeug mit `getpass` und `Host`, das auf einem Fernseher
nie laufen sollte, hielt den Bau für alle anderen auf. Es ist jetzt
ausdrücklich macOS-only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-29 17:09:18 +02:00

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
}
}