diff --git a/server/src/Flaro.Api/Endpoints/LibraryContentEndpoints.cs b/server/src/Flaro.Api/Endpoints/LibraryContentEndpoints.cs
index 359cd79..20042be 100644
--- a/server/src/Flaro.Api/Endpoints/LibraryContentEndpoints.cs
+++ b/server/src/Flaro.Api/Endpoints/LibraryContentEndpoints.cs
@@ -207,7 +207,14 @@ public static class LibraryContentEndpoints
/// Stand — das ist kein Fehler, sondern der Normalfall für alles, was
/// noch niemand angefasst hat, und die Antwort trägt dort null.
///
- private static async Task> ZuständeZu(
+ ///
+ /// Nicht mehr privat. Drei Listen ausserhalb dieser Klasse gaben
+ /// `State: null` zurück, obwohl die Stände in derselben Datenbank
+ /// liegen: Vorschläge, saisonale Reihe und ähnliche Titel. Ein halb
+ /// gesehener Film stand dort ohne Balken, während dieselbe Kachel in der
+ /// Mediathek einen hatte.
+ ///
+ internal static async Task> ZuständeZu(
FlaroContext db, ClaimsPrincipal wer,
List? filme = null, List? folgen = null, List? serien = null)
{
@@ -608,13 +615,16 @@ public static class LibraryContentEndpoints
})
.ToListAsync();
+ var ständeÄhnliche = await ZuständeZu(db, wer, filme: kennungen.ToList());
+
// In der Reihenfolge der Punkte, nicht in der der Datenbank.
var items = kennungen
.Select(k => filme.FirstOrDefault(f => f.Id == k))
.Where(f => f != null)
.Select(f => new MovieResponse(
f!.Id, f.Title, f.Year, null, f.PosterImage, f.BackdropImage,
- [], null, f.LogoImage, f.RuntimeMinutes, f.Rating, null, f.ContentRating,
+ [], ständeÄhnliche.GetValueOrDefault(f.Id),
+ f.LogoImage, f.RuntimeMinutes, f.Rating, null, f.ContentRating,
OriginalTitle: f.OriginalTitle))
.ToList();
diff --git a/server/src/Flaro.Api/Endpoints/PlaybackEndpoints.cs b/server/src/Flaro.Api/Endpoints/PlaybackEndpoints.cs
index 64d4b2b..88e6d03 100644
--- a/server/src/Flaro.Api/Endpoints/PlaybackEndpoints.cs
+++ b/server/src/Flaro.Api/Endpoints/PlaybackEndpoints.cs
@@ -346,9 +346,23 @@ public static class PlaybackEndpoints
.Select(x => new { x.Id, x.Title, x.PosterImage, x.BackdropImage })
.ToDictionaryAsync(x => x.Id);
+ // **Weggelegt gilt hier für die ganze Serie.**
+ //
+ // Das Weglegen einer einzelnen Folge nützt in dieser Reihe
+ // nichts: beim nächsten Laden rückt die übernächste nach, und
+ // man legt zwanzigmal weg. Gemeint ist ohnehin „diese Serie
+ // nicht vorschlagen". Der Zustand dafür gibt es schon — eine
+ // Zeile mit `SeriesId` —, er wurde hier nur nie gelesen.
+ var serienzustaende = await db.UserItemStates.AsNoTracking()
+ .Where(z => z.UserId == benutzer && z.SeriesId != null
+ && serienIds.Contains(z.SeriesId!.Value))
+ .ToDictionaryAsync(z => z.SeriesId!.Value);
+
var liste = new List();
foreach (var x in angefasst)
{
+ if (serienzustaende.GetValueOrDefault(x.SeriesId) is { Dismissed: true }) continue;
+
var naechste = folgen
.Where(f => f.SeriesId == x.SeriesId)
.OrderBy(f => f.SeasonNumber).ThenBy(f => f.EpisodeNumber)
@@ -450,6 +464,13 @@ public static class PlaybackEndpoints
.Select(d => new { d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, d.MovieId, d.VersionLabel })
.ToListAsync();
+ // **Mit Stand.** Hier stand `null`, und damit fehlte der
+ // Fortschrittsbalken ausgerechnet in einer Reihe, die aus dem
+ // Sehverhalten gebaut wird — ein halb gesehener Film sah dort
+ // aus wie ein unangetasteter.
+ var vorschlagsstände = await LibraryContentEndpoints.ZuständeZu(
+ db, wer, filme: vorschlagIds);
+
var items = treffer.Select(m => new MovieResponse(
m.Id, m.Title, m.Year, m.Overview, m.PosterImage, m.BackdropImage,
vorschlagsdateien.Where(d => d.MovieId == m.Id)
@@ -457,7 +478,8 @@ public static class PlaybackEndpoints
d.Id, Path.GetFileName(d.Path), d.SizeBytes, d.Container,
d.Duration?.TotalSeconds, [], d.VersionLabel))
.ToList(),
- null, m.LogoImage, m.RuntimeMinutes, m.Rating, null, m.ContentRating,
+ vorschlagsstände.GetValueOrDefault(m.Id),
+ m.LogoImage, m.RuntimeMinutes, m.Rating, null, m.ContentRating,
PublishAt: m.PublishAt)).ToList();
return Results.Ok(new Page(items, items.Count));
@@ -516,6 +538,9 @@ public static class PlaybackEndpoints
.Select(d => new { d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, d.MovieId, d.VersionLabel })
.ToListAsync(ct);
+ var saisonstände = await LibraryContentEndpoints.ZuständeZu(
+ db, wer, filme: saisonIds);
+
var saisonitems = treffer.Select(m => new MovieResponse(
m.Id, m.Title, m.Year, m.Overview, m.PosterImage, m.BackdropImage,
saisondateien.Where(d => d.MovieId == m.Id)
@@ -523,7 +548,8 @@ public static class PlaybackEndpoints
d.Id, Path.GetFileName(d.Path), d.SizeBytes, d.Container,
d.Duration?.TotalSeconds, [], d.VersionLabel))
.ToList(),
- null, m.LogoImage, m.RuntimeMinutes, m.Rating, null, m.ContentRating,
+ saisonstände.GetValueOrDefault(m.Id),
+ m.LogoImage, m.RuntimeMinutes, m.Rating, null, m.ContentRating,
PublishAt: m.PublishAt)).ToList();
return Results.Ok(new Saisonreihe(
diff --git a/server/tests/Flaro.Api.Tests/FortschrittTests.cs b/server/tests/Flaro.Api.Tests/FortschrittTests.cs
index 70db830..097b7d6 100644
--- a/server/tests/Flaro.Api.Tests/FortschrittTests.cs
+++ b/server/tests/Flaro.Api.Tests/FortschrittTests.cs
@@ -182,4 +182,102 @@ public sealed class FortschrittTests
Assert.That(danach.RuntimeSeconds, Is.EqualTo(1000).Within(0.001));
}
+
+ // --- Wo der Stand bisher weggeworfen wurde ---
+
+ /// Auch ein Vorschlag bringt seinen Stand mit.
+ ///
+ ///
+ /// Drei Listen gaben State: null zurück — Vorschläge, die
+ /// saisonale Reihe und die ähnlichen Titel —, obwohl die Stände in
+ /// derselben Datenbank liegen. Ein halb gesehener Film sah dort aus wie
+ /// ein unangetasteter, während dieselbe Kachel in der Mediathek einen
+ /// Balken trug. Die Kachel wird an einer Stelle gebaut; sie kann nur
+ /// zeichnen, was ankommt.
+ ///
+ /// Geprüft wird hier die Vorschlagsreihe: sie entsteht aus dem
+ /// Sehverhalten, also genügt ein angefangener Film, damit sie überhaupt
+ /// etwas enthält — und ausgerechnet dieser Film steht dann darin.
+ ///
+ [Test]
+ public async Task Ein_Vorschlag_bringt_seinen_Stand_mit()
+ {
+ using var server = new FlaroServer();
+ var (client, film) = await MitEinemFilm(server);
+
+ await Melden(client, film, 300, 600);
+
+ var seite = await client.GetFromJsonAsync>(
+ "/api/v1/home/suggestions");
+
+ var eintrag = seite!.Items.FirstOrDefault(x => x.Id == film);
+ Assume.That(eintrag, Is.Not.Null, "ohne Genres schlägt der Server nichts vor");
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(eintrag!.State, Is.Not.Null, "ohne Stand kein Balken");
+ Assert.That(eintrag.State!.PositionSeconds, Is.EqualTo(300).Within(0.001));
+ });
+ }
+
+ /// Eine weggelegte Serie steht nicht mehr in „Als Nächstes".
+ ///
+ ///
+ /// Weggelegt gilt für die Serie, nicht für die Folge. Eine
+ /// einzelne Folge dort wegzulegen nützte nichts: beim nächsten Laden
+ /// rückte die übernächste nach, und man legte zwanzigmal weg. Gemeint
+ /// ist „diese Serie nicht mehr vorschlagen" — und genau dieser Zustand
+ /// wurde in der Reihe nie gelesen.
+ ///
+ [Test]
+ public async Task Eine_weggelegte_Serie_verschwindet_aus_Als_Naechstes()
+ {
+ using var server = new FlaroServer();
+
+ foreach (var name in new[] { "S01E01", "S01E02" })
+ {
+ var voll = Path.Combine(_bestand, "Probe (2026)", "Season 01", $"Probe - {name}.mkv");
+ Directory.CreateDirectory(Path.GetDirectoryName(voll)!);
+ await File.WriteAllBytesAsync(voll, new byte[2048]);
+ }
+
+ var admin = await server.SetUpAndSignIn();
+ var client = server.AlsAngemeldeter(admin.Token);
+
+ var angelegt = await client.PostAsJsonAsync("/api/v1/libraries",
+ new CreateLibraryRequest("Prüfbestand", "Shows", _bestand));
+ angelegt.EnsureSuccessStatusCode();
+ var bibliothek = (await angelegt.Content.ReadFromJsonAsync())!;
+
+ (await client.PostAsync($"/api/v1/libraries/{bibliothek.Id}/scan", null)).EnsureSuccessStatusCode();
+ for (var versuch = 0; versuch < 100; versuch++)
+ {
+ var stand = await client.GetFromJsonAsync($"/api/v1/libraries/{bibliothek.Id}/scan");
+ if (stand!.State is "finished") break;
+ if (stand.State is "failed") Assert.Fail($"Der Scan ist gescheitert: {stand.Error}");
+ await Task.Delay(50);
+ }
+
+ var serien = await client.GetFromJsonAsync>("/api/v1/series");
+ Assume.That(serien!.Items, Is.Not.Empty, "der Scan hat keine Serie gefunden");
+ var serie = serien.Items[0].Id;
+
+ var folgen = await client.GetFromJsonAsync>(
+ $"/api/v1/series/{serie}/episodes");
+
+ // Erst die erste Folge abhaken — sonst hat der Benutzer die Serie nie
+ // angefasst, und sie stünde ohnehin nicht in der Reihe.
+ (await client.PutAsJsonAsync($"/api/v1/items/{folgen!.Items[0].Id}/watched",
+ new WatchedRequest(true))).EnsureSuccessStatusCode();
+
+ var vorher = await client.GetFromJsonAsync>("/api/v1/home/next");
+ Assume.That(vorher!.Items.Select(x => x.SeriesId), Does.Contain(serie),
+ "ohne Eintrag gibt es nichts wegzulegen");
+
+ (await client.PostAsync($"/api/v1/items/{serie}/dismiss", null)).EnsureSuccessStatusCode();
+
+ var nachher = await client.GetFromJsonAsync>("/api/v1/home/next");
+
+ Assert.That(nachher!.Items.Select(x => x.SeriesId), Does.Not.Contain(serie));
+ }
}
diff --git a/web/app/src/card.ts b/web/app/src/card.ts
index 3ee3872..8390799 100644
--- a/web/app/src/card.ts
+++ b/web/app/src/card.ts
@@ -18,6 +18,11 @@ export type Kachel = {
* eine Folge aus „Fortsetzen" öffnet die Serie, abgehakt wird aber die
* Folge. */
standId?: string;
+ /** Aus welcher Reihe die Kachel stammt — nur dort, wo es etwas ändert.
+ *
+ * **Bisher nur für „Als Nächstes".** Dort heisst Weglegen etwas anderes
+ * als in „Fortsetzen": nicht diese Folge, sondern diese Serie. */
+ reihe?: 'naechste';
art: 'filme' | 'serien';
/** Die obere, fette Zeile. Bei einer Folge die Serie. */
titel: string;
@@ -69,20 +74,21 @@ export const alsKachel = (e: Film | Serie): Kachel => ({
* „Als gesehen markieren" hätte dann die ganze Serie abgehakt statt der
* einen Folge, die man gerade zur Hälfte gesehen hat.
*/
-export const angefangenAlsKachel = (e: Angefangen): Kachel => e.kind === 'episode'
+export const angefangenAlsKachel = (e: Angefangen, reihe?: Kachel['reihe']): Kachel =>
+ e.kind === 'episode'
? {
id: e.seriesId ?? e.id, art: 'serien',
standId: e.id,
titel: e.title,
zweite: e.subTitle,
dritte: `S${e.season} · F${e.episode}`,
- bild: e.posterImage, stand: e.state,
+ bild: e.posterImage, stand: e.state, reihe,
}
: {
id: e.id, art: 'filme',
titel: e.title,
zweite: e.year ? String(e.year) : null,
- bild: e.posterImage, stand: e.state,
+ bild: e.posterImage, stand: e.state, reihe,
};
/* ---------- Die Kachel selbst ---------- */
@@ -322,6 +328,21 @@ function kachelpunkte(
});
}
+ // **In „Als Nächstes" wird die Serie weggelegt, nicht die Folge.**
+ //
+ // Eine einzelne Folge dort wegzulegen nützt nichts: beim nächsten Laden
+ // rückt die übernächste nach. Gemeint ist „diese Serie nicht mehr
+ // vorschlagen" — und `k.id` ist bei einer Folgenkachel ohnehin die Serie.
+ if (k.reihe === 'naechste') {
+ punkte.push({
+ text: 'Serie aus Als Nächstes entfernen',
+ tun: async () => {
+ await weglegen(k.id);
+ knopf.remove();
+ },
+ });
+ }
+
punkte.push({ text: 'Details', icon: 'mehr', tun: () => oeffnen(k.art, k.id) });
if (istAdmin()) {
diff --git a/web/app/src/views/home.ts b/web/app/src/views/home.ts
index 55ab51d..ce92f83 100644
--- a/web/app/src/views/home.ts
+++ b/web/app/src/views/home.ts
@@ -101,7 +101,7 @@ export function startseite(
}
if (naechste.items.length) {
wurzel.appendChild(reihe('Nächste Folge',
- naechste.items.map(e => kachel(angefangenAlsKachel(e), oeffnen))));
+ naechste.items.map(e => kachel(angefangenAlsKachel(e, 'naechste'), oeffnen))));
}
// **Weit oben, weil sie nur jetzt gilt.** „Zu Halloween" unter den