Der Stand lag daneben und wurde trotzdem weggeworfen
Der Fortschrittsbalken ist an **einer** Stelle gebaut und wird in jeder
Kachel verwendet — Web wie App. Fehlte er, dann nicht an der Kachel,
sondern weil der Server `State: null` schickte. Drei Listen taten das,
obwohl die Stände in derselben Abfrage danebenlagen:
/home/suggestions — „Weil du Ähnliches gesehen hast"
/home/seasonal — die saisonale Reihe
/movies/{id}/similar — „Ähnliche Titel"
Ausgerechnet die erste entsteht aus dem Sehverhalten: ein halb gesehener
Film sah dort aus wie ein unangetasteter, während dieselbe Kachel in der
Mediathek einen Balken trug. `ZuständeZu` gab es längst, es war nur
privat.
**Und „Als Nächstes" lässt sich jetzt räumen.** Das vorhandene Weglegen
reichte nicht dorthin: die Reihe liest `Watched` und `InProgress`, nie
`Dismissed` — und bei einer nie gestarteten Folge ist `InProgress` ohnehin
`false`. Weggelegt wird deshalb die **Serie**, nicht die Folge: beim
nächsten Laden rückte sonst die übernächste nach, und man legte
zwanzigmal weg. Den Zustand dafür gibt es schon, er wurde nur nie
gelesen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
fa8d1a97e2
commit
fb8ad1c831
@@ -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 <c>null</c>.
|
||||
/// </remarks>
|
||||
private static async Task<Dictionary<Guid, ItemStateResponse>> ZuständeZu(
|
||||
/// <remarks>
|
||||
/// <b>Nicht mehr privat.</b> 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.
|
||||
/// </remarks>
|
||||
internal static async Task<Dictionary<Guid, ItemStateResponse>> ZuständeZu(
|
||||
FlaroContext db, ClaimsPrincipal wer,
|
||||
List<Guid>? filme = null, List<Guid>? folgen = null, List<Guid>? 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();
|
||||
|
||||
|
||||
@@ -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<ContinueEntry>();
|
||||
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<MovieResponse>(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(
|
||||
|
||||
@@ -182,4 +182,102 @@ public sealed class FortschrittTests
|
||||
|
||||
Assert.That(danach.RuntimeSeconds, Is.EqualTo(1000).Within(0.001));
|
||||
}
|
||||
|
||||
// --- Wo der Stand bisher weggeworfen wurde ---
|
||||
|
||||
/// <summary>Auch ein Vorschlag bringt seinen Stand mit.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Drei Listen gaben <c>State: null</c> zurück</b> — 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.
|
||||
/// </remarks>
|
||||
[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<Page<MovieResponse>>(
|
||||
"/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));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Eine weggelegte Serie steht nicht mehr in „Als Nächstes".</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Weggelegt gilt für die Serie, nicht für die Folge.</b> 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.
|
||||
/// </remarks>
|
||||
[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<LibraryResponse>())!;
|
||||
|
||||
(await client.PostAsync($"/api/v1/libraries/{bibliothek.Id}/scan", null)).EnsureSuccessStatusCode();
|
||||
for (var versuch = 0; versuch < 100; versuch++)
|
||||
{
|
||||
var stand = await client.GetFromJsonAsync<ScanStatus>($"/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<Page<SeriesResponse>>("/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<Page<EpisodeResponse>>(
|
||||
$"/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<Page<ContinueEntry>>("/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<Page<ContinueEntry>>("/api/v1/home/next");
|
||||
|
||||
Assert.That(nachher!.Items.Select(x => x.SeriesId), Does.Not.Contain(serie));
|
||||
}
|
||||
}
|
||||
|
||||
+24
-3
@@ -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()) {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user