Der Server wusste, was du zuletzt gesehen hast — und sonst nichts

Für „wie viel habe ich letzte Woche geschaut" gab es keine Daten.
`UserItemState` ist eine Zeile je Titel: der letzte Stand. Wer einen Film
zweimal sieht, überschreibt den ersten Durchgang; wann etwas lief, wusste
der Server nur fürs jeweils letzte Mal. Nachträglich erfinden lässt sich
das nicht.

Jetzt gibt es ein Sehprotokoll: **eine Zeile je Nutzer, Tag und Titel.**
Keine Sitzungen — die brauchen ein Ende, und das kommt oft nie an, wenn
der Browser zugemacht oder das Telefon zugeklappt wird. Tagesbündel haben
diesen Lebenslauf nicht, es wird nur addiert.

**Gezählt wird, was wirklich lief.** Der Zuwachs zwischen zwei Meldungen,
gedeckelt auf die Zeit, die dazwischen verstrichen ist (mal zwei als
Toleranz für doppelte Geschwindigkeit und ausgefallene Meldungen). Sonst
ergäbe ein Sprung von Minute 5 auf Minute 90 anderthalb Stunden Sehzeit in
zehn Sekunden. Gemessen an fünf Meldungen im Abstand von elf Sekunden:
88 Sekunden statt 190.

Dabei fiel ein zweiter Fall auf: war **gar keine** Zeit verstrichen, griff
kein Deckel statt eines engen — eine frisch angelegte Zeile hätte ihre
Anfangsstelle voll mitgezählt. Wer einen Film öffnet und sofort auf Minute
fünf springt, hat keine fünf Minuten gesehen.

Im Profil stehen drei Zahlen, und jede sagt, woher sie kommt: die
aufgezeichnete Zeit fängt heute an, die Zahl gesehener Titel steht seit
jeher fest. Dazu eine Wochenkurve, die häufigsten Genres und was zuletzt
lief. **Jeder sieht nur sich selbst, auch ein Administrator** — es gibt
keinen Weg, ein fremdes Konto zu meinen.

Zwei Dinge auf dem Weg dorthin:

**Eine stellbare Uhr für die Tests.** Zwei Minuten Film lassen sich nicht
in fünf Millisekunden sehen, und das ist richtig so — prüfen liess es sich
nur, indem die Zeit vergeht, ohne sie zu verbringen.

**Ein Namenskonflikt im CSS.** Die leeren Wochenbalken trugen die Klasse
`leer`, und die gehört längst der Fläche „hier ist nichts" — mitsamt
24 Pixeln Polster. Jeder leere Balken war dadurch 48 Pixel hoch, egal
welche Höhe dastand.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-06 17:20:01 +02:00
co-authored by Claude Opus 5
parent d8a1633cf4
commit 579a49b73b
13 changed files with 1988 additions and 2 deletions
@@ -687,6 +687,34 @@ public sealed record ContinueEntry(
/// </remarks>
string? SeriesBackdropImage = null);
/// <summary>Was jemand geschaut hat — nur für ihn selbst.</summary>
///
/// <remarks>
/// <b>Zwei Arten Zahl, und der Unterschied gehört dazu.</b>
/// <c>LoggedSeconds</c> kommt aus dem Sehprotokoll und fängt an dem Tag an,
/// an dem es eingebaut wurde. <c>WatchedTitles</c> und
/// <c>WatchedRuntimeSeconds</c> kommen aus den Sehständen und sind für die
/// ganze Vergangenheit vollständig. Beides als eine Zahl auszugeben hiesse,
/// eine davon zu behaupten.
/// </remarks>
public sealed record StatistikResponse(
double LoggedSeconds,
int WatchedTitles,
double WatchedRuntimeSeconds,
DateTimeOffset? Since,
IReadOnlyList<Wochenwert> Weeks,
IReadOnlyList<Zuletztgesehen> Recent,
IReadOnlyList<Genrewert> Genres);
/// <param name="Start">Der Montag dieser Woche.</param>
public sealed record Wochenwert(DateOnly Start, double Seconds);
public sealed record Zuletztgesehen(
Guid Id, string Kind, string Title, string? SubTitle,
string? PosterImage, DateTimeOffset At, bool Watched);
public sealed record Genrewert(string Name, double Seconds);
/// <summary>Eine laufende Wiedergabe — für das Dashboard.</summary>
public sealed record SessionResponse(
string UserName, string DeviceName,
@@ -66,6 +66,10 @@ public static class PlaybackEndpoints
var ergebnis = ProgressRule.Apply(
TimeSpan.FromSeconds(anfrage.PositionSeconds), laenge, regeln);
// **Erst zählen, dann überschreiben.** Der Zuwachs ergibt sich aus
// der alten Stelle; eine Zeile später gibt es die nicht mehr.
await Mitzaehlen(db, benutzer, zustand, ergebnis.Position, clock);
// **Das Häkchen wird gesetzt, nicht genommen.** Wer einen
// gesehenen Film erneut anfängt, hat ihn trotzdem gesehen — und
// soll beides haben: das Häkchen und die neue Stelle.
@@ -524,6 +528,74 @@ public static class PlaybackEndpoints
/// <see cref="UserItemState.InProgress"/> schreibt — gäbe es eine zweite,
/// wäre die Spalte wieder ein zweites Urteil statt eines festgehaltenen.
/// </remarks>
/// <summary>Schreibt die tatsächlich gesehene Zeit ins Protokoll.</summary>
///
/// <remarks>
/// <para>
/// <b>Der Zuwachs, gedeckelt auf die verstrichene Zeit.</b> Zwischen zwei
/// Meldungen liegen zehn Sekunden; wer in dieser Zeit von Minute 5 auf
/// Minute 90 springt, hat nicht 85 Minuten gesehen. Ohne Deckel erfände
/// jedes Vorspulen Sehzeit — und ein Film, den man einmal durchgespult
/// hat, stünde in der Statistik wie zweimal gesehen.
/// </para>
/// <para>
/// <b>Das Doppelte als Toleranz</b>, nicht genau die verstrichene Zeit:
/// eine ausgefallene Meldung, ein kurzer Aussetzer im Netz, doppelte
/// Abspielgeschwindigkeit — all das ist echtes Schauen und soll zählen.
/// Erst darüber wird gekappt.
/// </para>
/// <para>
/// <b>Rückwärts zählt nichts.</b> Wer zurückspringt und dieselbe Stelle
/// noch einmal sieht, bekommt sie nicht abgezogen und auch nicht doppelt
/// gutgeschrieben: der Zuwachs ist dann null, bis er die alte Stelle
/// wieder erreicht. Das ist die freundlichere Ungenauigkeit von beiden.
/// </para>
/// </remarks>
private static async Task Mitzaehlen(
FlaroContext db, Guid benutzer, UserItemState zustand,
TimeSpan neueStelle, TimeProvider clock)
{
if (zustand.MovieId is null && zustand.EpisodeId is null) return;
var jetzt = clock.GetUtcNow();
var verstrichen = jetzt - zustand.UpdatedAt;
var zuwachs = (neueStelle - zustand.Position).TotalSeconds;
// **Ohne verstrichene Zeit gibt es nichts gutzuschreiben.** Das ist
// die erste Meldung einer Zeile — sie legt den Nullpunkt, mehr nicht.
// Stünde hier stattdessen „kein Deckel", zählte eine frisch angelegte
// Zeile ihre Anfangsstelle voll mit: wer einen Film öffnet und sofort
// auf Minute fünf springt, hätte fünf Minuten gesehen.
if (zuwachs <= 0 || verstrichen <= TimeSpan.Zero) return;
zuwachs = Math.Min(zuwachs, verstrichen.TotalSeconds * 2);
if (zuwachs <= 0) return;
// Ortszeit des Servers: wer um halb eins zu Ende sieht, hat das am
// Abend getan.
var tag = DateOnly.FromDateTime(jetzt.ToLocalTime().DateTime);
var zeile = await db.Sehtage.FirstOrDefaultAsync(z =>
z.UserId == benutzer && z.Tag == tag
&& z.MovieId == zustand.MovieId && z.EpisodeId == zustand.EpisodeId);
if (zeile is null)
{
db.Sehtage.Add(new Sehtag
{
UserId = benutzer,
MovieId = zustand.MovieId,
EpisodeId = zustand.EpisodeId,
Tag = tag,
Sekunden = zuwachs,
});
}
else
{
zeile.Sekunden += zuwachs;
}
}
private static void Nachfuehren(UserItemState zustand, Wiedergaberegeln regeln) =>
zustand.InProgress = ProgressRule.BelongsInContinueWatching(zustand, regeln);
@@ -92,6 +92,184 @@ public static class ProfilEndpoints
await db.SaveChangesAsync(ct);
return Results.NoContent();
});
MapStatistik(gruppe);
}
/// <summary>Was jemand geschaut hat.</summary>
///
/// <remarks>
/// <para>
/// <b>Jeder nur sich selbst, auch ein Administrator.</b> Das ist keine
/// Erfindung für diese Seite, sondern die Linie, die im Server schon
/// steht: wer wann welchen Film anschaut, ist eine Auskunft über Menschen
/// und nicht über den Server. Es gibt deshalb keinen Weg, ein fremdes
/// Konto zu meinen — die Kennung kommt aus dem Token.
/// </para>
/// <para>
/// <b>Wochen beginnen am Montag.</b> Nicht am Sonntag und nicht am Tag der
/// Anfrage: eine Kurve, deren Balken je nach Abfragezeitpunkt anders
/// zusammenfallen, lässt sich mit sich selbst nicht vergleichen.
/// </para>
/// </remarks>
private static void MapStatistik(RouteGroupBuilder gruppe)
{
gruppe.MapGet("/statistics", async (
ClaimsPrincipal wer, FlaroContext db, TimeProvider clock,
int wochen = 12, CancellationToken ct = default) =>
{
if (Kennung(wer) is not { } id) return Results.Unauthorized();
wochen = Math.Clamp(wochen, 1, 104);
var heute = DateOnly.FromDateTime(clock.GetUtcNow().ToLocalTime().DateTime);
var ersterMontag = Montag(heute).AddDays(-7 * (wochen - 1));
// ---- Das Protokoll ----
var tage = await db.Sehtage.AsNoTracking()
.Where(z => z.UserId == id)
.Select(z => new { z.Tag, z.Sekunden, z.MovieId, z.EpisodeId })
.ToListAsync(ct);
var gesamt = tage.Sum(z => z.Sekunden);
var seit = tage.Count == 0 ? (DateTimeOffset?)null
: new DateTimeOffset(tage.Min(z => z.Tag).ToDateTime(TimeOnly.MinValue), TimeSpan.Zero);
var jeWoche = tage
.Where(z => z.Tag >= ersterMontag)
.GroupBy(z => Montag(z.Tag))
.ToDictionary(g => g.Key, g => g.Sum(x => x.Sekunden));
var kurve = Enumerable.Range(0, wochen)
.Select(n => ersterMontag.AddDays(7 * n))
.Select(montag => new Wochenwert(montag, jeWoche.GetValueOrDefault(montag)))
.ToList();
// ---- Was ohne Protokoll vollständig ist ----
var staende = await db.UserItemStates.AsNoTracking()
.Where(z => z.UserId == id && (z.MovieId != null || z.EpisodeId != null))
.Select(z => new
{
z.MovieId, z.EpisodeId, z.Watched, z.LastPlayedAt,
Laufzeit = z.Runtime,
})
.ToListAsync(ct);
var gesehene = staende.Where(z => z.Watched).ToList();
// ---- Zuletzt gesehen ----
var letzte = staende
.Where(z => z.LastPlayedAt != null)
.OrderByDescending(z => z.LastPlayedAt)
.Take(12)
.ToList();
var filmIds = letzte.Where(z => z.MovieId != null).Select(z => z.MovieId!.Value).ToList();
var folgenIds = letzte.Where(z => z.EpisodeId != null).Select(z => z.EpisodeId!.Value).ToList();
var filme = await db.Movies.AsNoTracking().Where(m => filmIds.Contains(m.Id))
.Select(m => new { m.Id, m.Title, m.PosterImage }).ToDictionaryAsync(m => m.Id, ct);
var folgen = await db.Episodes.AsNoTracking().Where(f => folgenIds.Contains(f.Id))
.Join(db.Series.AsNoTracking(), f => f.SeriesId, s => s.Id, (f, s) => new
{
f.Id, Folgentitel = f.Title, f.SeasonNumber, f.EpisodeNumber,
Serientitel = s.Title, s.PosterImage, SerienId = s.Id,
})
.ToDictionaryAsync(f => f.Id, ct);
var zuletzt = new List<Zuletztgesehen>();
foreach (var z in letzte)
{
if (z.MovieId is { } film && filme.TryGetValue(film, out var m))
{
zuletzt.Add(new Zuletztgesehen(
m.Id, "movie", m.Title, null, m.PosterImage, z.LastPlayedAt!.Value, z.Watched));
}
else if (z.EpisodeId is { } folge && folgen.TryGetValue(folge, out var f))
{
zuletzt.Add(new Zuletztgesehen(
f.SerienId, "episode", f.Serientitel,
$"S{f.SeasonNumber} · F{f.EpisodeNumber}" +
(f.Folgentitel is { Length: > 0 } t ? $" — {t}" : ""),
f.PosterImage, z.LastPlayedAt!.Value, z.Watched));
}
}
// ---- Genres, aus dem Protokoll ----
var genres = await GenresAus(db, tage.Select(t => (t.MovieId, t.EpisodeId, t.Sekunden)), ct);
return Results.Ok(new StatistikResponse(
gesamt,
gesehene.Count,
gesehene.Sum(z => z.Laufzeit?.TotalSeconds ?? 0),
seit,
kurve,
zuletzt,
genres));
});
}
/// <summary>Der Montag der Woche, in der dieser Tag liegt.</summary>
private static DateOnly Montag(DateOnly tag) =>
tag.AddDays(-((int)tag.DayOfWeek + 6) % 7);
/// <summary>Welche Genres die gesehene Zeit ausmachen.</summary>
///
/// <remarks>
/// Eine Folge hat keine Genres — ihre Serie hat sie. Deshalb wird jede
/// Folge erst zu ihrer Serie aufgelöst; sonst stünden Serien gar nicht in
/// der Auswertung, obwohl sie den grösseren Teil der Zeit ausmachen.
/// </remarks>
private static async Task<List<Genrewert>> GenresAus(
FlaroContext db,
IEnumerable<(Guid? Film, Guid? Folge, double Sekunden)> zeilen,
CancellationToken ct)
{
var liste = zeilen.ToList();
if (liste.Count == 0) return [];
var folgenIds = liste.Where(z => z.Folge != null).Select(z => z.Folge!.Value).Distinct().ToList();
var serieZuFolge = await db.Episodes.AsNoTracking()
.Where(f => folgenIds.Contains(f.Id))
.ToDictionaryAsync(f => f.Id, f => f.SeriesId, ct);
// Sekunden je Titel zusammenziehen, bevor die Genres dazukommen —
// sonst wird derselbe Tag so oft gezählt, wie der Titel Genres hat.
var jeFilm = new Dictionary<Guid, double>();
var jeSerie = new Dictionary<Guid, double>();
foreach (var (film, folge, sekunden) in liste)
{
if (film is { } f) jeFilm[f] = jeFilm.GetValueOrDefault(f) + sekunden;
else if (folge is { } e && serieZuFolge.TryGetValue(e, out var serie))
jeSerie[serie] = jeSerie.GetValueOrDefault(serie) + sekunden;
}
var filmIds = jeFilm.Keys.ToList();
var serienIds = jeSerie.Keys.ToList();
var verweise = await db.GenreLinks.AsNoTracking()
.Where(l => (l.MovieId != null && filmIds.Contains(l.MovieId!.Value))
|| (l.SeriesId != null && serienIds.Contains(l.SeriesId!.Value)))
.Join(db.Genres.AsNoTracking(), l => l.GenreId, g => g.Id,
(l, g) => new { l.MovieId, l.SeriesId, g.Name })
.ToListAsync(ct);
var summen = new Dictionary<string, double>();
foreach (var v in verweise)
{
var sekunden = v.MovieId is { } f ? jeFilm.GetValueOrDefault(f)
: v.SeriesId is { } s ? jeSerie.GetValueOrDefault(s)
: 0;
if (sekunden <= 0) continue;
summen[v.Name] = summen.GetValueOrDefault(v.Name) + sekunden;
}
return summen.OrderByDescending(p => p.Value).Take(6)
.Select(p => new Genrewert(p.Key, p.Value)).ToList();
}
/// <summary>Wer anfragt — aus dem Token, nie aus der Adresse.</summary>