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