Phase 4 und Teile von Phase 5
Download-Regeln - Automatisches Löschen nach dem Ansehen, mit Frist statt sofort: wer die letzte Szene noch einmal sucht, soll sie nicht schon verloren haben. „Nie" ist die Voreinstellung. - „Halte N ungesehene Folgen offline": wartende und laufende Downloads zählen mit, sonst würde bei jedem Durchlauf nachgelegt, bis die ganze Serie auf dem Gerät liegt. - Beides als reine Rechnung ohne Zugriff auf Datenbank oder Dateien — bei einer Regel, die Filme löscht, ist Prüfbarkeit keine Formsache. 13 Tests. Qualitätswahl beim Download - transcodedDownloadURL über den Universal-Transcoder mit protocol=http statt über die umständliche Sync-API. NOCH NICHT AM SERVER GEPRÜFT: offen ist, ob Plex eine Content-Length mitschickt (sonst kein Fortschritt und kein Fortsetzen) und ob die Datei durchsuchbar ist. Steht so im Quelltext; Original bleibt Voreinstellung. Erscheinungsbild - Akzentfarbe: zehn kuratierte Kacheln plus freie Hex-Eingabe. Bei einer eigenen Farbe greift die Kontrastkorrektur und sagt es auch — sonst wirkt sie wie ein Fehler. - Vorschaufläche mit Knopf, Symbol und Fortschrittsbalken. Serveraktionen - Gesehen-Status umschalten und Sterne setzen über ein Menü auf der Detailseite. - Suchlauf der Mediathek aus der Werkzeugleiste. 115 Tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -90,9 +90,19 @@ public final class DownloadCoordinator {
|
||||
/// eine fertige Folge mehr wert als zehn halbe.
|
||||
public var maximumConcurrentDownloads = 2
|
||||
|
||||
/// Die Regeln fürs automatische Löschen und Nachladen.
|
||||
public var rules: DownloadRules {
|
||||
didSet {
|
||||
defaults.set(rules.deleteWatchedAfter?.seconds ?? -1, forKey: Key.deleteAfter)
|
||||
defaults.set(rules.keepUnwatchedEpisodes ?? -1, forKey: Key.keepUnwatched)
|
||||
}
|
||||
}
|
||||
|
||||
private enum Key {
|
||||
static let wifiOnly = "flixy.downloads.wifiOnly"
|
||||
static let storageLimit = "flixy.downloads.storageLimit"
|
||||
static let deleteAfter = "flixy.downloads.deleteWatchedAfter"
|
||||
static let keepUnwatched = "flixy.downloads.keepUnwatched"
|
||||
}
|
||||
|
||||
/// Der Container, nicht nur der Kontext.
|
||||
@@ -138,6 +148,16 @@ public final class DownloadCoordinator {
|
||||
let storedLimit = defaults.object(forKey: Key.storageLimit) as? Int64 ?? 0
|
||||
self.storageLimitBytes = storedLimit > 0 ? storedLimit : nil
|
||||
|
||||
// -1 steht für „abgeschaltet". Ein fehlender Schlüssel liefert 0, was
|
||||
// bei der Löschfrist „sofort" hieße — der gefährlichste Wert als
|
||||
// stille Voreinstellung.
|
||||
let deleteAfter = defaults.object(forKey: Key.deleteAfter) as? Double ?? -1
|
||||
let keepUnwatched = defaults.object(forKey: Key.keepUnwatched) as? Int ?? -1
|
||||
self.rules = DownloadRules(
|
||||
deleteWatchedAfter: deleteAfter >= 0 ? .seconds(deleteAfter) : nil,
|
||||
keepUnwatchedEpisodes: keepUnwatched > 0 ? keepUnwatched : nil
|
||||
)
|
||||
|
||||
self.session = URLSession(
|
||||
configuration: sessionConfiguration
|
||||
?? Self.backgroundConfiguration(allowsCellularAccess: !isWiFiOnly),
|
||||
@@ -175,9 +195,22 @@ public final class DownloadCoordinator {
|
||||
|
||||
save()
|
||||
refreshUsage()
|
||||
applyRules()
|
||||
await startNextIfPossible()
|
||||
}
|
||||
|
||||
/// Wendet die Regeln auf den aktuellen Bestand an.
|
||||
///
|
||||
/// Nur das Löschen — das Nachladen braucht den Server und liegt deshalb
|
||||
/// eine Schicht höher. Läuft beim Start und nach dem Ansehen, nicht auf
|
||||
/// einem Zeitgeber: eine App, die im Hintergrund Filme löscht, wäre
|
||||
/// schwer nachvollziehbar.
|
||||
public func applyRules() {
|
||||
let doomed = rules.itemsToDelete(from: items)
|
||||
guard !doomed.isEmpty else { return }
|
||||
for item in doomed { remove(item) }
|
||||
}
|
||||
|
||||
// MARK: - Hinzufügen
|
||||
|
||||
@discardableResult
|
||||
@@ -305,6 +338,10 @@ public final class DownloadCoordinator {
|
||||
guard let item = items.first(where: { $0.ratingKey == ratingKey }) else { return }
|
||||
item.watchedAt = Date()
|
||||
save()
|
||||
// Bei der Frist „sofort" wird der Titel hier bereits entfernt. Das ist
|
||||
// gewollt: wer das einstellt, will nach dem Abspann keinen Platz mehr
|
||||
// belegen.
|
||||
applyRules()
|
||||
}
|
||||
|
||||
/// Die lokale Datei eines fertigen Downloads.
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import Foundation
|
||||
import PlexKit
|
||||
|
||||
/// Die Regeln, nach denen Downloads von selbst entstehen und verschwinden.
|
||||
///
|
||||
/// Bewusst als reine Rechnung ohne Zugriff auf Datenbank, Dateien oder Netz:
|
||||
/// sie bekommt den Bestand hereingereicht und gibt zurück, was zu tun wäre.
|
||||
/// Dadurch lässt sich das Verhalten prüfen, ohne etwas zu löschen — und beim
|
||||
/// automatischen Löschen von Filmen ist das keine akademische Vorsicht.
|
||||
public struct DownloadRules: Sendable, Equatable {
|
||||
/// Gesehene Titel nach dieser Frist entfernen. `nil` schaltet es ab.
|
||||
///
|
||||
/// Eine Frist statt sofortigem Löschen: wer eine Folge zu Ende sieht und
|
||||
/// gleich noch einmal die letzte Szene sucht, soll sie nicht schon
|
||||
/// verloren haben.
|
||||
public var deleteWatchedAfter: Duration?
|
||||
|
||||
/// Wie viele ungesehene Folgen je Serie vorgehalten werden. `nil`
|
||||
/// schaltet das Nachladen ab.
|
||||
public var keepUnwatchedEpisodes: Int?
|
||||
|
||||
public init(deleteWatchedAfter: Duration? = nil, keepUnwatchedEpisodes: Int? = nil) {
|
||||
self.deleteWatchedAfter = deleteWatchedAfter
|
||||
self.keepUnwatchedEpisodes = keepUnwatchedEpisodes
|
||||
}
|
||||
|
||||
public static let disabled = DownloadRules()
|
||||
|
||||
/// Welche Einträge nach den Regeln zu entfernen sind.
|
||||
public func itemsToDelete(
|
||||
from items: [DownloadItem],
|
||||
now: Date = Date()
|
||||
) -> [DownloadItem] {
|
||||
guard let deleteWatchedAfter else { return [] }
|
||||
|
||||
return items.filter { item in
|
||||
// Nur Fertiges: ein laufender Download hat nichts, was gesehen
|
||||
// worden sein könnte.
|
||||
guard item.state == .completed, let watchedAt = item.watchedAt else { return false }
|
||||
return now.timeIntervalSince(watchedAt) >= deleteWatchedAfter.seconds
|
||||
}
|
||||
}
|
||||
|
||||
/// Wie viele Folgen je Serie noch fehlen, um den Vorrat zu füllen.
|
||||
///
|
||||
/// Gezählt wird, was offline und ungesehen ist. Gesehene Folgen zählen
|
||||
/// nicht mit — sonst bliebe der Vorrat bei drei gesehenen Folgen stehen
|
||||
/// und es käme nie etwas Neues nach.
|
||||
public func missingEpisodeCount(
|
||||
forSeries seriesKey: String,
|
||||
in items: [DownloadItem]
|
||||
) -> Int {
|
||||
guard let keepUnwatchedEpisodes else { return 0 }
|
||||
|
||||
let unwatchedOffline = items.filter {
|
||||
$0.seriesKey == seriesKey
|
||||
&& $0.watchedAt == nil
|
||||
// Wartende und laufende zählen mit: sonst würde bei jedem
|
||||
// Aufruf erneut nachgelegt, bis die ganze Serie auf dem Gerät
|
||||
// liegt.
|
||||
&& $0.state != .failed
|
||||
}.count
|
||||
|
||||
return max(0, keepUnwatchedEpisodes - unwatchedOffline)
|
||||
}
|
||||
|
||||
/// Die Serien, für die nachgeladen werden müsste.
|
||||
public func seriesNeedingRefill(in items: [DownloadItem]) -> [String: Int] {
|
||||
guard keepUnwatchedEpisodes != nil else { return [:] }
|
||||
|
||||
let seriesKeys = Set(items.compactMap(\.seriesKey))
|
||||
var result: [String: Int] = [:]
|
||||
for key in seriesKeys {
|
||||
let missing = missingEpisodeCount(forSeries: key, in: items)
|
||||
if missing > 0 { result[key] = missing }
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
extension Duration {
|
||||
/// Gängige Fristen fürs automatische Löschen.
|
||||
public static let immediately = Duration.zero
|
||||
public static let afterOneDay = Duration.seconds(24 * 60 * 60)
|
||||
public static let afterOneWeek = Duration.seconds(7 * 24 * 60 * 60)
|
||||
}
|
||||
@@ -189,3 +189,56 @@ extension PlexServer {
|
||||
.min(by: { ($0.index ?? 0) < ($1.index ?? 0) })
|
||||
}
|
||||
}
|
||||
|
||||
extension PlexServer {
|
||||
/// Die Adresse für einen heruntergerechneten Download.
|
||||
///
|
||||
/// **Noch nicht an einem echten Server geprüft.** Der Weg nutzt den
|
||||
/// Universal-Transcoder mit `protocol=http`, der einen fortlaufenden Strom
|
||||
/// statt HLS liefert — anders als der offizielle Sync-Weg von Plex, der
|
||||
/// umständlich und schlecht dokumentiert ist.
|
||||
///
|
||||
/// Zwei Dinge sind dabei offen und gehören am eigenen Server geprüft,
|
||||
/// bevor die Einstellung angeboten wird:
|
||||
/// 1. Ob Plex eine `Content-Length` mitschickt. Ohne sie gibt es keinen
|
||||
/// Fortschrittsbalken und kein Fortsetzen nach dem Pausieren.
|
||||
/// 2. Ob die entstehende Datei durchsuchbar ist. Ein Strom, der von vorn
|
||||
/// gedacht ist, muss es nicht sein — dann ließe sich im Download nicht
|
||||
/// springen.
|
||||
///
|
||||
/// Bis das geklärt ist, bleibt der Download in Originalqualität die
|
||||
/// Voreinstellung.
|
||||
public nonisolated func transcodedDownloadURL(
|
||||
ratingKey: String,
|
||||
mediaIndex: Int = 0,
|
||||
maximumBitrate: Int,
|
||||
resolution: String?
|
||||
) -> URL? {
|
||||
guard let baseURL = connection.url,
|
||||
var components = URLComponents(
|
||||
url: baseURL.appending(path: "/video/:/transcode/universal/start"),
|
||||
resolvingAgainstBaseURL: false
|
||||
)
|
||||
else { return nil }
|
||||
|
||||
let request = TranscodeRequest(
|
||||
ratingKey: ratingKey,
|
||||
mediaIndex: mediaIndex,
|
||||
maximumBitrate: maximumBitrate,
|
||||
resolution: resolution,
|
||||
// Ein Download ist kein Abspielen im Heimnetz: `wan` lässt Plex
|
||||
// konservativer puffern, was für eine Datei egal ist.
|
||||
isLocal: false
|
||||
)
|
||||
|
||||
components.queryItems = request.queryItems
|
||||
// Überschreibt `protocol=hls` aus der Anfrage: für eine Datei
|
||||
// brauchen wir einen fortlaufenden Strom, keine Segmentliste.
|
||||
.filter { $0.name != "protocol" && $0.name != "fastSeek" && $0.name != "copyts" }
|
||||
+ [
|
||||
URLQueryItem(name: "protocol", value: "http"),
|
||||
URLQueryItem(name: "X-Plex-Token", value: token),
|
||||
]
|
||||
return components.url
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user