Ein Download sah aus wie eine Videodatei, nicht wie ein Film

Vier Dinge, die beim Ausprobieren auffielen.

**Die Plakate blieben grau.** Sie lagen auf der Platte, der Lader wollte
sie aber nicht: er verlangte von jeder Antwort eine HTTPURLResponse, und
eine Datei antwortet nicht so. Jede lokale Adresse kam als `nil` zurück.

**Eine Serie hatte ohnehin nie ein Plakat.** Heruntergeladen wird nie die
Serie, nur ihre Folgen — und deren Bild ist das Szenenbild, quer. Jetzt
kommt das Serienplakat unter dem Serienschlüssel mit und geht mit der
letzten Folge wieder.

**Zwanzig Folgen waren zwanzig Kacheln.** Jetzt steht die Serie einmal
da, darunter wie viele Folgen bereitliegen; welche genau, zeigt ihre
Seite — auch ohne Server, dann aus dem, was auf dem Gerät liegt.

**Das Bild wurde schwarz, sobald jemand auf „Bildfüllend" wechselte.**
Daran war ein eigener ViewModifier schuld, der zwischen
`ignoresSafeArea()` und gar nichts umschaltete. Genau das ist in SwiftUI
ein Zweig: der Baum darunter wurde neu gebaut, die Bildebene mit ihm.
`edges: []` ignoriert nichts und bleibt doch derselbe Modifikator.

Dazu „Als Nächstes": die Reihe begann am Anfang der Serie statt dort, wo
der Benutzer steht. Wer bei Folge 2 war und Folge 1 nie abgehakt hatte,
bekam Folge 1 vorgeschlagen — und das Abhaken half nicht, weil die
Kachel sich nur selbst einen Haken malte, statt zu verschwinden.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-12 20:20:27 +02:00
co-authored by Claude Opus 5
parent 8f0568cb6c
commit 79d7d04583
14 changed files with 388 additions and 63 deletions
+33 -16
View File
@@ -1241,6 +1241,22 @@
} }
} }
}, },
"downloads.episodeCount %lld" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "%lld Folgen geladen"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "%lld episodes downloaded"
}
}
}
},
"downloads.failed" : { "downloads.failed" : {
"extractionState" : "stale", "extractionState" : "stale",
"localizations" : { "localizations" : {
@@ -1887,6 +1903,7 @@
} }
}, },
"filter.downloadedOnly" : { "filter.downloadedOnly" : {
"extractionState" : "stale",
"localizations" : { "localizations" : {
"de" : { "de" : {
"stringUnit" : { "stringUnit" : {
@@ -2146,22 +2163,6 @@
} }
} }
}, },
"home.offline" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Kein Server erreichbar. Was hier steht, stammt vom letzten Mal — ansehen lässt sich nur, was auf dem Gerät liegt."
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "No server reachable. What you see is from last time — only what is on this device can be played."
}
}
}
},
"home.downloaded" : { "home.downloaded" : {
"localizations" : { "localizations" : {
"de" : { "de" : {
@@ -2395,6 +2396,22 @@
} }
} }
}, },
"home.offline" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Kein Server erreichbar. Was hier steht, stammt vom letzten Mal — ansehen lässt sich nur, was auf dem Gerät liegt."
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "No server reachable. What you see is from last time — only what is on this device can be played."
}
}
}
},
"home.rowsHeader" : { "home.rowsHeader" : {
"localizations" : { "localizations" : {
"de" : { "de" : {
+25 -2
View File
@@ -213,10 +213,24 @@ struct DownloadService {
/// Nebenher und ohne Fehlerweg: scheitert es, fehlt ein Bild. Den /// Nebenher und ohne Fehlerweg: scheitert es, fehlt ein Bild. Den
/// Download deshalb scheitern zu lassen wäre die schlechtere Hälfte. /// Download deshalb scheitern zu lassen wäre die schlechtere Hälfte.
func plakatHolen(for item: MediaItem) { func plakatHolen(for item: MediaItem) {
guard let adresse = backend?.rawImageURL(path: item.thumb) else { return } if let adresse = backend?.rawImageURL(path: item.thumb) {
Task { await coordinator.fetchArtwork(for: item.id, from: adresse) } Task { await coordinator.fetchArtwork(for: item.id, from: adresse) }
} }
// **Und das Plakat der Serie, unter deren Schlüssel.**
//
// `thumb` ist bei einer Folge ihr Szenenbild — quer, und hochkant
// beschnitten zeigt es einen zufälligen Ausschnitt. Die Downloads
// stehen aber als Serie in der Reihe, und eine Serienkachel ist ein
// Plakat. Ohne diesen zweiten Griff gäbe es für die Serie gar kein
// Bild: heruntergeladen wird sie nie, nur ihre Folgen.
if let serie = item.seriesID,
let pfad = item.seriesThumb ?? item.parentThumb,
let adresse = backend?.rawImageURL(path: pfad) {
Task { await coordinator.fetchArtwork(for: serie, from: adresse) }
}
}
/// Holt Plakate nach, die früheren Downloads fehlen. /// Holt Plakate nach, die früheren Downloads fehlen.
/// ///
/// **Weil bis vor Kurzem gar keines mitkam.** Der Dateiname dafür stand /// **Weil bis vor Kurzem gar keines mitkam.** Der Dateiname dafür stand
@@ -228,12 +242,21 @@ struct DownloadService {
/// findet die Schleife nichts mehr. /// findet die Schleife nichts mehr.
func plakateNachholen() async { func plakateNachholen() async {
guard let backend else { return } guard let backend else { return }
for eintrag in coordinator.items where eintrag.artworkFileName == nil { for eintrag in coordinator.items where fehltEinBild(eintrag) {
guard let titel = try? await backend.item(id: eintrag.ratingKey) else { continue } guard let titel = try? await backend.item(id: eintrag.ratingKey) else { continue }
plakatHolen(for: titel) plakatHolen(for: titel)
} }
} }
/// Ob zu diesem Eintrag noch ein Bild fehlt — seines oder das seiner
/// Serie.
private func fehltEinBild(_ eintrag: DownloadItem) -> Bool {
if coordinator.artworkURL(forRatingKey: eintrag.ratingKey) == nil { return true }
if let serie = eintrag.seriesKey,
coordinator.artworkURL(forRatingKey: serie) == nil { return true }
return false
}
/// Ob der Server beim Herunterladen alle Spuren mitgibt. /// Ob der Server beim Herunterladen alle Spuren mitgibt.
/// ///
/// **Der Unterschied, um dessentwillen Flaro gebaut wurde.** Flaros /// **Der Unterschied, um dessentwillen Flaro gebaut wurde.** Flaros
+72
View File
@@ -26,6 +26,78 @@ enum Heruntergeladenes {
.sorted { ($0.completedAt ?? $0.addedAt) > ($1.completedAt ?? $1.addedAt) } .sorted { ($0.completedAt ?? $0.addedAt) > ($1.completedAt ?? $1.addedAt) }
} }
/// Was auf dem Gerät liegt — Filme einzeln, Serien als **eine** Kachel.
///
/// **Zwanzig Folgen sind keine zwanzig Titel.** Vorher stand jede
/// einzeln in der Reihe, und eine heruntergeladene Staffel schob alles
/// andere aus dem Bild. Was man im Kopf hat, ist die Serie; welche
/// Folgen davon da sind, ist die Frage danach — und die beantwortet die
/// Seite der Serie.
static func reihe(in coordinator: DownloadCoordinator) -> [MediaItem] {
var ergebnis: [MediaItem] = []
var gesehen = Set<String>()
for eintrag in fertige(in: coordinator) {
guard let serie = eintrag.seriesKey else {
ergebnis.append(titel(eintrag, in: coordinator))
continue
}
// Die Reihenfolge der Folgen ist die der Serie: die zuletzt
// fertige Folge bestimmt, wo die Serie steht.
guard gesehen.insert(serie).inserted else { continue }
ergebnis.append(self.serie(serie, in: coordinator))
}
return ergebnis
}
/// Eine Serie, von der Folgen auf dem Gerät liegen.
static func serie(_ schluessel: String, in coordinator: DownloadCoordinator) -> MediaItem {
let folgen = folgenEintraege(derSerie: schluessel, in: coordinator)
return MediaItem(
id: schluessel,
kind: .show,
title: folgen.first?.seriesTitle ?? folgen.first?.title ?? "",
// **Erst das Plakat der Serie, dann irgendein Folgenbild.** Ein
// Szenenbild hochkant ist ein zufälliger Ausschnitt — aber immer
// noch besser als eine graue Fläche.
thumb: coordinator.artworkURL(forRatingKey: schluessel)?.absoluteString
?? folgen.lazy
.compactMap { coordinator.artworkURL(forRatingKey: $0.ratingKey)?.absoluteString }
.first
)
}
/// Wie viele Folgen dieser Serie hier liegen — als Zeile unter der
/// Kachel.
///
/// `nil` für alles, was keine Serie mit Downloads ist: dann gilt, was
/// sonst unter einer Kachel steht.
static func untertitel(für titel: MediaItem, in coordinator: DownloadCoordinator) -> String? {
guard titel.kind == .show else { return nil }
let anzahl = folgenEintraege(derSerie: titel.id, in: coordinator).count
guard anzahl > 0 else { return nil }
return String(localized: "downloads.episodeCount \(anzahl)")
}
/// Die heruntergeladenen Folgen einer Serie, aufsteigend sortiert.
///
/// Für die Seite der Serie, wenn kein Server antwortet: dann ist das
/// hier die ganze Liste, die es gibt.
static func folgen(derSerie schluessel: String, in coordinator: DownloadCoordinator) -> [MediaItem] {
folgenEintraege(derSerie: schluessel, in: coordinator)
.sorted {
($0.seasonIndex ?? 0, $0.episodeIndex ?? 0) < ($1.seasonIndex ?? 0, $1.episodeIndex ?? 0)
}
.map { titel($0, in: coordinator) }
}
private static func folgenEintraege(
derSerie schluessel: String,
in coordinator: DownloadCoordinator
) -> [DownloadItem] {
fertige(in: coordinator).filter { $0.seriesKey == schluessel }
}
/// Ein Download als Titel. /// Ein Download als Titel.
/// ///
/// **Das Bild kommt von der Platte, nicht vom Server.** `thumb` ist sonst /// **Das Bild kommt von der Platte, nicht vom Server.** `thumb` ist sonst
+14 -3
View File
@@ -76,9 +76,20 @@ struct MediaDetailView: View {
} }
/// Was über diesen Titel auf dem Gerät liegt. /// Was über diesen Titel auf dem Gerät liegt.
///
/// **Auch eine Serie**, obwohl keine Serie je heruntergeladen wird: in
/// der Downloadreihe steht sie als eine Kachel, und ein Tipp darauf
/// führt hierher. Ohne diesen Zweig endete er ohne Verbindung im
/// Nichts.
private var vomGeraet: MediaItem? { private var vomGeraet: MediaItem? {
downloads.items if let eintrag = downloads.items.first(
.first { $0.isComplete && $0.ratingKey == route.ratingKey } where: { $0.isComplete && $0.ratingKey == route.ratingKey }
.map { Heruntergeladenes.titel($0, in: downloads) } ) {
return Heruntergeladenes.titel(eintrag, in: downloads)
}
guard downloads.items.contains(
where: { $0.isComplete && $0.seriesKey == route.ratingKey }
) else { return nil }
return Heruntergeladenes.serie(route.ratingKey, in: downloads)
} }
} }
+21 -1
View File
@@ -470,11 +470,31 @@ struct SeriesDetailView: View {
return pieces.isEmpty ? nil : pieces.joined(separator: " · ") return pieces.isEmpty ? nil : pieces.joined(separator: " · ")
} }
/// Holt die Staffeln oder Folgen — und sonst, was auf dem Gerät liegt.
///
/// **Sonst wäre die Seite einer heruntergeladenen Serie leer.** Sie ist
/// das Ziel, auf das die Downloadreihe zeigt; dort anzukommen und einen
/// Fehler zu lesen, während die Folgen daneben auf der Platte liegen,
/// wäre die Umkehrung dessen, wofür man sie geladen hat.
private func loadChildren() async { private func loadChildren() async {
guard let server = session.backend else { return } guard let server = session.backend else {
children = vomGeraet.isEmpty
? .failed(message: String(localized: "error.serverUnreachable"))
: .loaded(vomGeraet)
return
}
children = await LoadState.load { children = await LoadState.load {
try await server.children(of: item.id) try await server.children(of: item.id)
} }
if case .failed = children, !vomGeraet.isEmpty {
children = .loaded(vomGeraet)
}
}
/// Die Folgen dieser Serie, die auf dem Gerät liegen.
private var vomGeraet: [MediaItem] {
guard item.kind == .show else { return [] }
return Heruntergeladenes.folgen(derSerie: item.id, in: downloads)
} }
} }
+1 -3
View File
@@ -22,9 +22,7 @@ struct HomeView: View {
/// `DownloadCoordinator` beobachtet wird — eine fertige Folge steht /// `DownloadCoordinator` beobachtet wird — eine fertige Folge steht
/// ohne Neuladen der Startseite da. /// ohne Neuladen der Startseite da.
private var heruntergeladen: [MediaItem] { private var heruntergeladen: [MediaItem] {
Heruntergeladenes.fertige(in: downloads).map { Heruntergeladenes.reihe(in: downloads)
Heruntergeladenes.titel($0, in: downloads)
}
} }
var body: some View { var body: some View {
+18 -2
View File
@@ -1,5 +1,6 @@
import SwiftUI import SwiftUI
import FlaroDesign import FlaroDesign
import FlaroDownloads
import MediaKit import MediaKit
/// Eine waagerechte Reihe von Titeln. /// Eine waagerechte Reihe von Titeln.
@@ -14,6 +15,7 @@ struct MediaRow: View {
@Environment(FlaroSession.self) private var session @Environment(FlaroSession.self) private var session
@Environment(HomeLayoutSettings.self) private var layout @Environment(HomeLayoutSettings.self) private var layout
@Environment(DownloadCoordinator.self) private var downloads
/// Breit genug, dass die übernächste Kachel angeschnitten ist — das zeigt, /// Breit genug, dass die übernächste Kachel angeschnitten ist — das zeigt,
/// dass es weitergeht, ohne einen Pfeil dafür zu brauchen. /// dass es weitergeht, ohne einen Pfeil dafür zu brauchen.
@@ -58,7 +60,7 @@ struct MediaRow: View {
if row.isContinueWatching { if row.isContinueWatching {
LandscapeCard( LandscapeCard(
title: item.rowTitle, title: item.rowTitle,
subtitle: item.cardSubtitle, subtitle: untertitel(for: item),
artworkURL: Heruntergeladenes.bildAdresse(item) ?? session.backend?.artworkURL( artworkURL: Heruntergeladenes.bildAdresse(item) ?? session.backend?.artworkURL(
path: item.rowArtworkPath, path: item.rowArtworkPath,
width: 440, width: 440,
@@ -70,7 +72,7 @@ struct MediaRow: View {
} else { } else {
PosterCard( PosterCard(
title: item.rowTitle, title: item.rowTitle,
subtitle: item.cardSubtitle, subtitle: untertitel(for: item),
artworkURL: Heruntergeladenes.bildAdresse(item) ?? session.backend?.artworkURL( artworkURL: Heruntergeladenes.bildAdresse(item) ?? session.backend?.artworkURL(
path: item.posterArtworkPath, path: item.posterArtworkPath,
width: 300, width: 300,
@@ -81,4 +83,18 @@ struct MediaRow: View {
.frame(width: posterWidth) .frame(width: posterWidth)
} }
} }
/// Was unter der Kachel steht.
///
/// **In der Downloadreihe zählt, wie viel da ist.** Eine Serie steht
/// dort als eine Kachel; „2019" darunter beantwortet nicht die Frage,
/// die man an dieser Stelle hat — nämlich wie viele Folgen man dabei
/// hat.
private func untertitel(for item: MediaItem) -> String? {
if row.id == HomeFeed.downloadsRow,
let zahl = Heruntergeladenes.untertitel(für: item, in: downloads) {
return zahl
}
return item.cardSubtitle
}
} }
+7 -6
View File
@@ -124,7 +124,10 @@ struct LibraryView: View {
QuickPlayTile(item: item) { QuickPlayTile(item: item) {
PosterCard( PosterCard(
title: item.title, title: item.title,
subtitle: item.cardSubtitle, subtitle: nurHeruntergeladene
? Heruntergeladenes.untertitel(für: item, in: downloads)
?? item.cardSubtitle
: item.cardSubtitle,
artworkURL: Heruntergeladenes.bildAdresse(item) artworkURL: Heruntergeladenes.bildAdresse(item)
?? session.backend?.artworkURL(path: item.thumb, width: 300, height: 450), ?? session.backend?.artworkURL(path: item.thumb, width: 300, height: 450),
progress: item.resumeProgress progress: item.resumeProgress
@@ -360,15 +363,13 @@ struct LibraryView: View {
/// shortcut: zwei Filmmediatheken bekämen beide dieselbe Liste — erst /// shortcut: zwei Filmmediatheken bekämen beide dieselbe Liste — erst
/// wenn `DownloadItem` die Mediathek mitführt, lässt sich das trennen. /// wenn `DownloadItem` die Mediathek mitführt, lässt sich das trennen.
private var geraeteliste: [MediaItem] { private var geraeteliste: [MediaItem] {
Heruntergeladenes.fertige(in: downloads) Heruntergeladenes.reihe(in: downloads).filter { titel in
.filter { eintrag in
switch library.kind { switch library.kind {
case .movie: eintrag.seriesKey == nil case .movie: titel.kind != .show
case .show: eintrag.seriesKey != nil case .show: titel.kind == .show
default: true default: true
} }
} }
.map { Heruntergeladenes.titel($0, in: downloads) }
} }
/// Lädt nach, sobald die Ansicht sich dem Ende der Liste nähert. /// Lädt nach, sobald die Ansicht sich dem Ende der Liste nähert.
+12 -14
View File
@@ -117,7 +117,18 @@ struct PlayerView: View {
// Anspruch gerade, dass nichts fehlt — dann darf auch // Anspruch gerade, dass nichts fehlt — dann darf auch
// nichts verdeckt sein. Plex macht es genauso. // nichts verdeckt sein. Plex macht es genauso.
Color.clear Color.clear
.modifier(Randlos(an: model.videoFit == .fill)) //
// **Ein Modifikator mit leeren Kanten, kein `if`.**
// Hier stand ein eigener `ViewModifier`, der
// zwischen `ignoresSafeArea()` und gar nichts
// umschaltete — und genau das ist in SwiftUI ein
// Zweig: beim Umschalten wurde der Baum darunter
// neu aufgebaut, die Bildebene mit ihm, und der
// Spieler stand ohne sie da. Schwarz, sobald
// jemand auf „Bildfüllend" wechselte. `edges: []`
// ignoriert nichts und ist doch derselbe
// Modifikator — die Ansicht bleibt dieselbe.
.ignoresSafeArea(.container, edges: model.videoFit == .fill ? .all : [])
.overlay { .overlay {
surface(for: model) surface(for: model)
.contentShape(.rect) .contentShape(.rect)
@@ -1059,16 +1070,3 @@ extension Duration {
return String(format: "%d:%02d", minutes, seconds) return String(format: "%d:%02d", minutes, seconds)
} }
} }
/// Lässt eine Fläche über die Sicherheitsränder hinausreichen — oder nicht.
///
/// **Ein eigener Modifikator, weil `ignoresSafeArea` keinen Schalter hat.**
/// Ein `if` im Baum wäre zwei verschiedene Ansichten, und SwiftUI baut die
/// beim Umschalten neu auf — die Wiedergabe begänne von vorn.
private struct Randlos: ViewModifier {
let an: Bool
func body(content: Content) -> some View {
if an { content.ignoresSafeArea() } else { content }
}
}
@@ -101,6 +101,19 @@ public actor ArtworkLoader {
using session: URLSession, using session: URLSession,
attempts: Int = 3 attempts: Int = 3
) async -> PlatformImage? { ) async -> PlatformImage? {
// **Eine Datei auf der Platte ist kein Server.**
//
// Hier stand nur der Weg über HTTP, und die Antwort einer
// `file:`-Adresse ist keine `HTTPURLResponse` — die Prüfung darunter
// gab also `nil` zurück, immer. Sichtbar wurde das erst, als die
// Downloads ihre Plakate mitbrachten: sie lagen auf der Platte, und
// die Kacheln blieben trotzdem grau. Kein Wiederholen, kein
// Zeitablauf: eine Datei ist entweder da oder nicht.
if url.isFileURL {
guard let data = try? Data(contentsOf: url) else { return nil }
return PlatformImage(data: data)
}
for attempt in 1...attempts { for attempt in 1...attempts {
if Task.isCancelled { return nil } if Task.isCancelled { return nil }
@@ -395,9 +395,16 @@ public final class DownloadCoordinator {
forgetTransfer(item.ratingKey) forgetTransfer(item.ratingKey)
fileStore.remove(fileName: item.localFileName) fileStore.remove(fileName: item.localFileName)
fileStore.remove(fileName: item.artworkFileName) fileStore.remove(fileName: item.artworkFileName)
let serie = item.seriesKey
context.delete(item) context.delete(item)
save() save()
reloadItems() reloadItems()
// **Das Serienplakat geht mit der letzten Folge.** Es hängt an
// keinem Eintrag — ohne diese Zeile bliebe es für immer liegen.
if let serie, !items.contains(where: { $0.seriesKey == serie }) {
fileStore.remove(fileName: fileStore.artworkFileName(forRatingKey: serie))
}
refreshUsage() refreshUsage()
} }
@@ -427,10 +434,15 @@ public final class DownloadCoordinator {
/// **Weil eine Reihe ohne Bilder keine Reihe ist.** Ohne Verbindung gibt /// **Weil eine Reihe ohne Bilder keine Reihe ist.** Ohne Verbindung gibt
/// es keine Adresse, von der ein Plakat nachzuladen wäre; was nicht beim /// es keine Adresse, von der ein Plakat nachzuladen wäre; was nicht beim
/// Herunterladen mitkam, bleibt eine graue Fläche. /// Herunterladen mitkam, bleibt eine graue Fläche.
/// **Nach der Datei gefragt, nicht nach dem Eintrag.** Hier wurde erst
/// der Download gesucht und dann sein Dateiname gelesen — damit gab es
/// nur Bilder zu Dingen, die selbst heruntergeladen wurden. Das Plakat
/// einer **Serie** gehört aber keiner einzelnen Folge; es liegt unter
/// dem Serienschlüssel, zu dem es keinen Eintrag gibt und nie geben
/// wird.
public func artworkURL(forRatingKey key: String) -> URL? { public func artworkURL(forRatingKey key: String) -> URL? {
guard let name = items.first(where: { $0.ratingKey == key })?.artworkFileName, let name = fileStore.artworkFileName(forRatingKey: key)
fileStore.exists(fileName: name) guard fileStore.exists(fileName: name) else { return nil }
else { return nil }
return fileStore.url(forFileName: name) return fileStore.url(forFileName: name)
} }
@@ -439,19 +451,32 @@ public final class DownloadCoordinator {
/// **Beim Anlegen, nicht erst beim Fertigwerden.** Die Liste zeigt den /// **Beim Anlegen, nicht erst beim Fertigwerden.** Die Liste zeigt den
/// Eintrag, sobald er in der Warteschlange steht; bis dahin wäre sonst /// Eintrag, sobald er in der Warteschlange steht; bis dahin wäre sonst
/// nichts zu sehen. Es sind ein paar Kilobyte. /// nichts zu sehen. Es sind ein paar Kilobyte.
/// Der Schlüssel muss zu keinem Download gehören: unter dem Schlüssel
/// einer Serie liegt ihr Plakat, obwohl die Serie selbst nie
/// heruntergeladen wird.
public func fetchArtwork(for ratingKey: String, from url: URL) async { public func fetchArtwork(for ratingKey: String, from url: URL) async {
guard let item = items.first(where: { $0.ratingKey == ratingKey }), let name = fileStore.artworkFileName(forRatingKey: ratingKey)
item.artworkFileName == nil guard !fileStore.exists(fileName: name) else {
else { return } vermerken(name, bei: ratingKey)
return
}
guard let (daten, antwort) = try? await URLSession.shared.data(from: url), guard let (daten, antwort) = try? await URLSession.shared.data(from: url),
(antwort as? HTTPURLResponse).map({ (200..<300).contains($0.statusCode) }) ?? true, (antwort as? HTTPURLResponse).map({ (200..<300).contains($0.statusCode) }) ?? true,
!daten.isEmpty !daten.isEmpty
else { return } else { return }
let name = fileStore.artworkFileName(forRatingKey: ratingKey)
guard fileStore.store(daten, as: name) != nil else { return } guard fileStore.store(daten, as: name) != nil else { return }
vermerken(name, bei: ratingKey)
}
/// Hält am Eintrag fest, dass es ein Bild gibt — damit es beim Entfernen
/// mitgeht. Zum Serienschlüssel gibt es keinen Eintrag; dann bleibt die
/// Datei stehen, bis die letzte Folge der Serie geht.
private func vermerken(_ name: String, bei ratingKey: String) {
guard let item = items.first(where: { $0.ratingKey == ratingKey }),
item.artworkFileName != name
else { return }
item.artworkFileName = name item.artworkFileName = name
save() save()
} }
@@ -363,15 +363,35 @@ public static class PlaybackEndpoints
{ {
if (serienzustaende.GetValueOrDefault(x.SeriesId) is { Dismissed: true }) continue; if (serienzustaende.GetValueOrDefault(x.SeriesId) is { Dismissed: true }) continue;
var naechste = folgen var derSerie = folgen
.Where(f => f.SeriesId == x.SeriesId) .Where(f => f.SeriesId == x.SeriesId)
.OrderBy(f => f.SeasonNumber).ThenBy(f => f.EpisodeNumber) .OrderBy(f => f.SeasonNumber).ThenBy(f => f.EpisodeNumber)
.ToList();
// **Die Reihe fängt dort an, wo der Benutzer steht — nicht
// am Anfang der Serie.**
//
// Hier stand „die erste Folge ohne Häkchen". Wer bei Folge 2
// mittendrin ist und Folge 1 nie abgehakt hat — weil er sie
// nicht ganz zu Ende gesehen hat, oder weil er gleich bei
// Folge 2 eingestiegen ist —, bekam Folge 1 vorgeschlagen.
// Und das Abhaken von Folge 1 half nicht: dann kam Folge 2,
// obwohl die längst in „Weiterschauen" steht.
//
// Massgeblich ist die **letzte** angefasste Folge. Ist sie
// noch offen, läuft sie — dann gehört die Serie nach
// „Weiterschauen" und nicht hierher. Ist sie durch, ist die
// Folge danach dran.
var letzte = derSerie.FindLastIndex(
f => Angefasst(zustände.GetValueOrDefault(f.Id)));
if (letzte < 0) continue;
if (zustände.GetValueOrDefault(derSerie[letzte].Id) is not { Watched: true }) continue;
var naechste = derSerie.Skip(letzte + 1)
.FirstOrDefault(f => zustände.GetValueOrDefault(f.Id) is not { Watched: true }); .FirstOrDefault(f => zustände.GetValueOrDefault(f.Id) is not { Watched: true });
// Nichts offen — die Serie ist durch. // Nichts offen — die Serie ist durch.
if (naechste is null) continue; if (naechste is null) continue;
// Mittendrin: steht bereits in „Weiterschauen".
if (zustände.GetValueOrDefault(naechste.Id) is { InProgress: true }) continue;
if (!serien.TryGetValue(x.SeriesId, out var serie)) continue; if (!serien.TryGetValue(x.SeriesId, out var serie)) continue;
var zustand = zustände.GetValueOrDefault(naechste.Id); var zustand = zustände.GetValueOrDefault(naechste.Id);
@@ -564,6 +584,18 @@ public static class PlaybackEndpoints
/// Folge" zeigt überwiegend Folgen ohne Stand, und ein Client, der für /// Folge" zeigt überwiegend Folgen ohne Stand, und ein Client, der für
/// jede davon einen Sonderfall braucht, vergisst ihn irgendwo. /// jede davon einen Sonderfall braucht, vergisst ihn irgendwo.
/// </remarks> /// </remarks>
/// <summary>Ob der Benutzer diese Folge überhaupt schon angefasst hat.</summary>
///
/// <remarks>
/// Eine Zeile allein genügt nicht: sie entsteht auch, wenn jemand nur
/// einen Stern vergibt. Angefasst heisst gesehen, angefangen oder
/// ausdrücklich weggelegt.
/// </remarks>
private static bool Angefasst(UserItemState? zustand) =>
zustand is not null
&& (zustand.Watched || zustand.InProgress || zustand.Dismissed
|| zustand.Position > TimeSpan.Zero);
private static ItemStateResponse Leer() => new(0, null, false, 0, false, null, false, null); private static ItemStateResponse Leer() => new(0, null, false, 0, false, null, false, null);
/// <summary>Macht aus Zuständen Einträge mit Titel und Bild.</summary> /// <summary>Macht aus Zuständen Einträge mit Titel und Bild.</summary>
@@ -764,6 +764,92 @@ public class PlaybackTests
}); });
} }
/// <summary>Wer bei Folge 2 steht, bekommt nicht Folge 1 angeboten.</summary>
///
/// <remarks>
/// <b>Der Fall aus dem Gebrauch.</b> Folge 1 halb gesehen und liegen
/// gelassen, dann bei Folge 2 weitergemacht — und „Als Nächstes" schlug
/// Folge 1 vor, weil sie die erste ohne Häkchen war. Die Reihe sagt aber
/// nicht, was man übersprungen hat, sondern was als Nächstes dran ist.
/// </remarks>
[Test]
public async Task Wer_bei_Folge_zwei_steht_bekommt_nicht_Folge_eins_vorgeschlagen()
{
Datei("Dark/Season 01/Dark - S01E01 - Geheimnisse.mkv");
Datei("Dark/Season 01/Dark - S01E02 - Luegen.mkv");
Datei("Dark/Season 01/Dark - S01E03 - Gestern.mkv");
using var server = new FlaroServer();
var client = await Eingelesen(server, "Shows");
var serien = await client.GetFromJsonAsync<Page<SeriesResponse>>("/api/v1/series");
var folgen = await client.GetFromJsonAsync<Page<EpisodeResponse>>(
$"/api/v1/series/{serien!.Items[0].Id}/episodes");
// Folge 1 angefangen und liegen gelassen, Folge 2 läuft gerade.
await Melden(client, folgen!.Items[0].Id, 300, 2700);
await Melden(client, folgen.Items[1].Id, 900, 2700);
var naechste = await client.GetFromJsonAsync<Page<ContinueEntry>>("/api/v1/home/next");
Assert.That(naechste!.Items, Is.Empty,
"Folge 2 läuft und steht in Weiterschauen, also hat Als Naechstes nichts anzubieten");
}
/// <summary>Und abhaken holt die Serie nicht zurück.</summary>
///
/// <remarks>
/// Wer Folge 1 nachträglich abhakt, hat damit nicht gesagt, dass er
/// Folge 2 noch einmal anfangen will — die läuft ja.
/// </remarks>
[Test]
public async Task Folge_eins_abhaken_holt_die_Serie_nicht_zurueck()
{
Datei("Dark/Season 01/Dark - S01E01 - Geheimnisse.mkv");
Datei("Dark/Season 01/Dark - S01E02 - Luegen.mkv");
using var server = new FlaroServer();
var client = await Eingelesen(server, "Shows");
var serien = await client.GetFromJsonAsync<Page<SeriesResponse>>("/api/v1/series");
var folgen = await client.GetFromJsonAsync<Page<EpisodeResponse>>(
$"/api/v1/series/{serien!.Items[0].Id}/episodes");
await Melden(client, folgen!.Items[1].Id, 900, 2700);
await client.PutAsJsonAsync($"/api/v1/items/{folgen.Items[0].Id}/watched",
new WatchedRequest(true));
var naechste = await client.GetFromJsonAsync<Page<ContinueEntry>>("/api/v1/home/next");
Assert.That(naechste!.Items, Is.Empty);
}
/// <summary>Nach der letzten angefassten Folge geht es weiter.</summary>
///
/// <remarks>
/// Folge 1 und 2 durch, Folge 3 nie angefasst: dann ist Folge 3 dran —
/// und nicht Folge 1, nur weil dort irgendwann einmal ein Häkchen
/// fehlte.
/// </remarks>
[Test]
public async Task Nach_der_letzten_gesehenen_Folge_kommt_die_naechste()
{
Datei("Dark/Season 01/Dark - S01E01 - Geheimnisse.mkv");
Datei("Dark/Season 01/Dark - S01E02 - Luegen.mkv");
Datei("Dark/Season 01/Dark - S01E03 - Gestern.mkv");
using var server = new FlaroServer();
var client = await Eingelesen(server, "Shows");
var serien = await client.GetFromJsonAsync<Page<SeriesResponse>>("/api/v1/series");
var folgen = await client.GetFromJsonAsync<Page<EpisodeResponse>>(
$"/api/v1/series/{serien!.Items[0].Id}/episodes");
await client.PutAsJsonAsync($"/api/v1/items/{folgen!.Items[1].Id}/watched",
new WatchedRequest(true));
var naechste = await client.GetFromJsonAsync<Page<ContinueEntry>>("/api/v1/home/next");
Assert.That(naechste!.Items.Single().Episode, Is.EqualTo(3));
}
/// <summary>Was in „Weiterschauen" steht, steht nicht nochmal daneben.</summary> /// <summary>Was in „Weiterschauen" steht, steht nicht nochmal daneben.</summary>
[Test] [Test]
public async Task Eine_angefangene_Folge_erscheint_nur_in_einer_Reihe() public async Task Eine_angefangene_Folge_erscheint_nur_in_einer_Reihe()
+15 -2
View File
@@ -296,8 +296,21 @@ function kachelpunkte(
// Serie zieht das Häkchen alle Folgen mit; was dabei herauskommt, // Serie zieht das Häkchen alle Folgen mit; was dabei herauskommt,
// weiss nur der Server. // weiss nur der Server.
k.stand = await gesehen(k.standId ?? k.id, !istGesehen); k.stand = await gesehen(k.standId ?? k.id, !istGesehen);
// **Die Kachel spiegelt den neuen Zustand selbst.** Die Liste neu zu
// laden verlöre Bildlauf und Filter — für einen Haken. // **In „Als Nächstes" ist ein Haken ein Abschied.**
//
// Die Reihe zeigt, was dran ist. Eine abgehakte Folge ist nicht mehr
// dran — sie mit einem Haken stehen zu lassen sah aus, als hätte das
// Abhaken nicht gewirkt. Gemessen: der Server lieferte beim nächsten
// Laden längst die richtige Folge, nur sah man das nie, weil die
// Reihe nicht neu geladen wurde.
if (k.reihe === 'naechste' && !istGesehen) {
knopf.remove();
return;
}
// **Sonst spiegelt die Kachel den neuen Zustand selbst.** Die Liste
// neu zu laden verlöre Bildlauf und Filter — für einen Haken.
k.offen = istGesehen ? (k.gesamt ?? k.offen) : 0; k.offen = istGesehen ? (k.gesamt ?? k.offen) : 0;
hakenZeigen(knopf, k, !istGesehen); hakenZeigen(knopf, k, !istGesehen);
}, },