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>
80 lines
3.3 KiB
Swift
80 lines
3.3 KiB
Swift
import Foundation
|
|
import HaloDownloads
|
|
import MediaKit
|
|
|
|
/// Füllt den Vorrat an ungesehenen Folgen wieder auf.
|
|
///
|
|
/// Die Regel-Engine rechnet aus, *wie viele* Folgen je Serie fehlen; hier wird
|
|
/// entschieden, *welche*. Die Trennung ist Absicht: das Rechnen braucht keinen
|
|
/// Server und ist deshalb prüfbar, das Auswählen braucht ihn zwangsläufig.
|
|
@MainActor
|
|
struct DownloadRefiller {
|
|
let backend: (any MediaBackend)?
|
|
let coordinator: DownloadCoordinator
|
|
|
|
/// Lädt nach, was den Regeln nach fehlt.
|
|
///
|
|
/// Läuft nach dem Ansehen einer Folge und beim Start — nicht auf einem
|
|
/// Zeitgeber. Eine App, die im Hintergrund unaufgefordert Gigabytes lädt,
|
|
/// wäre eine unangenehme Überraschung auf der Mobilfunkrechnung.
|
|
@discardableResult
|
|
func refill() async -> Int {
|
|
guard let backend, coordinator.rules.keepUnwatchedEpisodes != nil else { return 0 }
|
|
|
|
let needed = coordinator.rules.seriesNeedingRefill(in: coordinator.items)
|
|
guard !needed.isEmpty else { return 0 }
|
|
|
|
var added = 0
|
|
let service = DownloadService(backend: backend, coordinator: coordinator)
|
|
|
|
for (seriesKey, count) in needed {
|
|
let candidates = await nextUnwatchedEpisodes(
|
|
inSeries: seriesKey,
|
|
count: count,
|
|
backend: backend
|
|
)
|
|
|
|
for episode in candidates {
|
|
// Die Kinderliste bringt keine Dateiangaben mit — ohne sie
|
|
// ließe sich weder das Speicherlimit prüfen noch ein
|
|
// Fortschritt anzeigen.
|
|
let detailed = (try? await backend.item(id: episode.id)) ?? episode
|
|
// Folgen ohne abrufbare Datei still überspringen: das Nachladen
|
|
// läuft im Hintergrund, da hilft keine Fehlermeldung.
|
|
guard let plan = try? service.plan(for: detailed) else { continue }
|
|
if (try? coordinator.enqueue(plan)) != nil { added += 1 }
|
|
}
|
|
}
|
|
return added
|
|
}
|
|
|
|
/// Die nächsten ungesehenen Folgen einer Serie, die noch nicht offline sind.
|
|
///
|
|
/// In Ausstrahlungsreihenfolge, nicht nach Beliebtheit oder Zufall: wer
|
|
/// drei Folgen vorhalten lässt, will die drei, die als Nächstes dran sind.
|
|
private func nextUnwatchedEpisodes(
|
|
inSeries seriesKey: String,
|
|
count: Int,
|
|
backend: any MediaBackend
|
|
) async -> [MediaItem] {
|
|
guard let seasons = try? await backend.children(of: seriesKey) else { return [] }
|
|
|
|
let offlineKeys = Set(coordinator.items.map(\.ratingKey))
|
|
var result: [MediaItem] = []
|
|
|
|
for season in seasons.sorted(by: { ($0.index ?? 0) < ($1.index ?? 0) }) {
|
|
// Specials führt Plex als Staffel 0. Sie gehören nicht in den
|
|
// Vorrat für „was kommt als Nächstes".
|
|
guard (season.index ?? 0) > 0 else { continue }
|
|
guard let episodes = try? await backend.children(of: season.id) else { continue }
|
|
|
|
for episode in episodes.sorted(by: { ($0.index ?? 0) < ($1.index ?? 0) }) {
|
|
guard !episode.isWatched, !offlineKeys.contains(episode.id) else { continue }
|
|
result.append(episode)
|
|
if result.count >= count { return result }
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
}
|