diff --git a/server/src/Flaro.Api/Endpoints/Contracts.cs b/server/src/Flaro.Api/Endpoints/Contracts.cs
index ee100a6..bc44708 100644
--- a/server/src/Flaro.Api/Endpoints/Contracts.cs
+++ b/server/src/Flaro.Api/Endpoints/Contracts.cs
@@ -687,6 +687,34 @@ public sealed record ContinueEntry(
///
string? SeriesBackdropImage = null);
+/// Was jemand geschaut hat — nur für ihn selbst.
+///
+///
+/// Zwei Arten Zahl, und der Unterschied gehört dazu.
+/// LoggedSeconds kommt aus dem Sehprotokoll und fängt an dem Tag an,
+/// an dem es eingebaut wurde. WatchedTitles und
+/// WatchedRuntimeSeconds kommen aus den Sehständen und sind für die
+/// ganze Vergangenheit vollständig. Beides als eine Zahl auszugeben hiesse,
+/// eine davon zu behaupten.
+///
+public sealed record StatistikResponse(
+ double LoggedSeconds,
+ int WatchedTitles,
+ double WatchedRuntimeSeconds,
+ DateTimeOffset? Since,
+ IReadOnlyList Weeks,
+ IReadOnlyList Recent,
+ IReadOnlyList Genres);
+
+/// Der Montag dieser Woche.
+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);
+
/// Eine laufende Wiedergabe — für das Dashboard.
public sealed record SessionResponse(
string UserName, string DeviceName,
diff --git a/server/src/Flaro.Api/Endpoints/PlaybackEndpoints.cs b/server/src/Flaro.Api/Endpoints/PlaybackEndpoints.cs
index f730127..045f5c6 100644
--- a/server/src/Flaro.Api/Endpoints/PlaybackEndpoints.cs
+++ b/server/src/Flaro.Api/Endpoints/PlaybackEndpoints.cs
@@ -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
/// schreibt — gäbe es eine zweite,
/// wäre die Spalte wieder ein zweites Urteil statt eines festgehaltenen.
///
+ /// Schreibt die tatsächlich gesehene Zeit ins Protokoll.
+ ///
+ ///
+ ///
+ /// Der Zuwachs, gedeckelt auf die verstrichene Zeit. 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.
+ ///
+ ///
+ /// Das Doppelte als Toleranz, 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.
+ ///
+ ///
+ /// Rückwärts zählt nichts. 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.
+ ///
+ ///
+ 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);
diff --git a/server/src/Flaro.Api/Endpoints/ProfilEndpoints.cs b/server/src/Flaro.Api/Endpoints/ProfilEndpoints.cs
index 1cd4a1f..f526b41 100644
--- a/server/src/Flaro.Api/Endpoints/ProfilEndpoints.cs
+++ b/server/src/Flaro.Api/Endpoints/ProfilEndpoints.cs
@@ -92,6 +92,184 @@ public static class ProfilEndpoints
await db.SaveChangesAsync(ct);
return Results.NoContent();
});
+
+ MapStatistik(gruppe);
+ }
+
+ /// Was jemand geschaut hat.
+ ///
+ ///
+ ///
+ /// Jeder nur sich selbst, auch ein Administrator. 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.
+ ///
+ ///
+ /// Wochen beginnen am Montag. 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.
+ ///
+ ///
+ 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();
+ 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));
+ });
+ }
+
+ /// Der Montag der Woche, in der dieser Tag liegt.
+ private static DateOnly Montag(DateOnly tag) =>
+ tag.AddDays(-((int)tag.DayOfWeek + 6) % 7);
+
+ /// Welche Genres die gesehene Zeit ausmachen.
+ ///
+ ///
+ /// 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.
+ ///
+ private static async Task> 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();
+ var jeSerie = new Dictionary();
+ 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();
+ 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();
}
/// Wer anfragt — aus dem Token, nie aus der Adresse.
diff --git a/server/src/Flaro.Core/Playback/Sehtag.cs b/server/src/Flaro.Core/Playback/Sehtag.cs
new file mode 100644
index 0000000..5775513
--- /dev/null
+++ b/server/src/Flaro.Core/Playback/Sehtag.cs
@@ -0,0 +1,60 @@
+namespace Flaro.Core.Playback;
+
+/// Wie lange an einem Tag an einem Titel gesehen wurde.
+///
+///
+///
+/// Warum es das überhaupt gibt. ist eine
+/// Zeile je Titel und Nutzer — der letzte Stand. Wer einen Film zweimal
+/// sieht, überschreibt den ersten Durchgang; wann etwas lief, weiß der Server
+/// nur für das jeweils letzte Mal. Für „wie viel habe ich letzte Woche
+/// geschaut" reicht das nicht, und raten kann man es nicht.
+///
+///
+/// Warum Tagesbündel und keine Sitzungen. Eine Sitzungstabelle braucht
+/// Anfang und Ende, und das Ende kommt oft nie an: der Browser wird
+/// zugemacht, das Telefon schläft ein. Dann stehen offene Sitzungen herum, die
+/// niemand schliesst, und jede Auswertung muss raten, was davon zählt.
+/// Tagesbündel haben diesen Lebenslauf nicht — es wird nur addiert. Für die
+/// Fragen, um die es geht (wie viel insgesamt, wie viel je Woche, was
+/// zuletzt), reicht das vollständig.
+///
+///
+/// Es fängt bei null an. Was vor dem Einbau lief, steht nicht darin —
+/// die Daten gibt es nicht und lassen sich nicht nachträglich erfinden. Was
+/// sofort vollständig ist, steht weiter in :
+/// gesehen ja/nein, wie oft, wann zuletzt.
+///
+///
+public sealed class Sehtag
+{
+ public Guid Id { get; init; } = Guid.CreateVersion7();
+
+ public required Guid UserId { get; init; }
+
+ /// Entweder ein Film …
+ public Guid? MovieId { get; init; }
+
+ /// … oder eine Folge. Nie beides, nie keines.
+ public Guid? EpisodeId { get; init; }
+
+ /// Der Tag in der Zeitzone des Servers.
+ ///
+ ///
+ /// Ortszeit, nicht UTC. Wer um halb eins nachts einen Film zu Ende
+ /// sieht, hat das am Abend getan und nicht am nächsten Morgen — eine
+ /// Wochenkurve nach UTC verschöbe jeden späten Abend auf den Folgetag.
+ /// Der Server hat eine Zeitzone, und die ist die des Haushalts.
+ ///
+ public required DateOnly Tag { get; init; }
+
+ /// Wie viele Sekunden an diesem Tag tatsächlich liefen.
+ ///
+ ///
+ /// Tatsächlich, nicht übersprungen. Gezählt wird der Zuwachs
+ /// zwischen zwei Meldungen, gedeckelt auf die Zeit, die dazwischen
+ /// verstrichen ist. Ein Sprung von Minute 5 auf Minute 90 ergäbe sonst
+ /// 85 Minuten Sehzeit in zehn Sekunden.
+ ///
+ public double Sekunden { get; set; }
+}
diff --git a/server/src/Flaro.Data/FlaroContext.cs b/server/src/Flaro.Data/FlaroContext.cs
index 02409aa..41b8aad 100644
--- a/server/src/Flaro.Data/FlaroContext.cs
+++ b/server/src/Flaro.Data/FlaroContext.cs
@@ -58,6 +58,9 @@ public sealed class FlaroContext(DbContextOptions options) : DbCon
public DbSet Libraries => Set();
public DbSet Settings => Set();
+ /// Wie lange an welchem Tag gesehen wurde — siehe .
+ public DbSet Sehtage => Set();
+
public DbSet MediaFiles => Set();
public DbSet Movies => Set();
public DbSet Series => Set();
@@ -360,6 +363,35 @@ public sealed class FlaroContext(DbContextOptions options) : DbCon
e.HasOne().WithMany().HasForeignKey(x => x.SeriesId).OnDelete(DeleteBehavior.Cascade);
});
+ b.Entity(e =>
+ {
+ e.HasKey(x => x.Id);
+
+ // Je Benutzer, Tag und Titel eine Zeile — sonst addierte sich
+ // derselbe Nachmittag in zwei Zeilen, und keine davon wäre falsch.
+ e.HasIndex(x => new { x.UserId, x.Tag, x.MovieId }).IsUnique()
+ .HasFilter("\"MovieId\" IS NOT NULL");
+ e.HasIndex(x => new { x.UserId, x.Tag, x.EpisodeId }).IsUnique()
+ .HasFilter("\"EpisodeId\" IS NOT NULL");
+
+ // Der Weg jeder Auswertung: die Tage eines Benutzers in einem
+ // Zeitraum. Ohne Index ein vollständiger Durchlauf je Aufruf.
+ e.HasIndex(x => new { x.UserId, x.Tag });
+
+ // **Als Zahl, nicht als Text.** Aus demselben Grund wie bei den
+ // Zeitpunkten: „2026-10-6" sortierte sich als Text vor
+ // „2026-10-31", und eine Wochenkurve stünde durcheinander.
+ e.Property(x => x.Tag).HasConversion(
+ tag => tag.DayNumber, zahl => DateOnly.FromDayNumber(zahl));
+
+ e.HasOne().WithMany().HasForeignKey(x => x.UserId).OnDelete(DeleteBehavior.Cascade);
+ // Verschwindet der Titel, verschwindet seine Sehzeit mit. Eine
+ // Statistik über Filme, die es nicht mehr gibt, zählt Stunden auf
+ // nichts.
+ e.HasOne().WithMany().HasForeignKey(x => x.MovieId).OnDelete(DeleteBehavior.Cascade);
+ e.HasOne().WithMany().HasForeignKey(x => x.EpisodeId).OnDelete(DeleteBehavior.Cascade);
+ });
+
b.Entity(e =>
{
e.HasKey(x => x.Id);
diff --git a/server/src/Flaro.Data/Migrations/20261006150904_Sehprotokoll.Designer.cs b/server/src/Flaro.Data/Migrations/20261006150904_Sehprotokoll.Designer.cs
new file mode 100644
index 0000000..cba521f
--- /dev/null
+++ b/server/src/Flaro.Data/Migrations/20261006150904_Sehprotokoll.Designer.cs
@@ -0,0 +1,996 @@
+//
+using System;
+using Flaro.Data;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+
+#nullable disable
+
+namespace Flaro.Data.Migrations
+{
+ [DbContext(typeof(FlaroContext))]
+ [Migration("20261006150904_Sehprotokoll")]
+ partial class Sehprotokoll
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder.HasAnnotation("ProductVersion", "10.0.12");
+
+ modelBuilder.Entity("Flaro.Core.Auth.AuthCode", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("ApprovedByUserId")
+ .HasColumnType("TEXT");
+
+ b.Property("Code")
+ .IsRequired()
+ .HasMaxLength(16)
+ .HasColumnType("TEXT");
+
+ b.Property("ConsumedAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("CreatedAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("DeviceId")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("TEXT");
+
+ b.Property("DeviceName")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("TEXT");
+
+ b.Property("ExpiresAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("HandleFingerprint")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Code");
+
+ b.HasIndex("HandleFingerprint")
+ .IsUnique();
+
+ b.ToTable("AuthCodes");
+ });
+
+ modelBuilder.Entity("Flaro.Core.Auth.DeviceToken", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("DeviceId")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("TEXT");
+
+ b.Property("DeviceName")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("TEXT");
+
+ b.Property("LastSeenAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("RevokedAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("TokenFingerprint")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("TEXT");
+
+ b.Property("UserId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("TokenFingerprint")
+ .IsUnique();
+
+ b.HasIndex("UserId");
+
+ b.ToTable("DeviceTokens");
+ });
+
+ modelBuilder.Entity("Flaro.Core.Libraries.Library", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("Kind")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("TEXT");
+
+ b.Property("Path")
+ .IsRequired()
+ .HasMaxLength(1024)
+ .HasColumnType("TEXT");
+
+ b.Property("ReviewNewItems")
+ .HasColumnType("INTEGER");
+
+ b.Property("SortOrder")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.ToTable("Libraries");
+ });
+
+ modelBuilder.Entity("Flaro.Core.Library.Credit", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("Character")
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.Property("MovieId")
+ .HasColumnType("TEXT");
+
+ b.Property("Order")
+ .HasColumnType("INTEGER");
+
+ b.Property("PersonId")
+ .HasColumnType("TEXT");
+
+ b.Property("Role")
+ .IsRequired()
+ .HasMaxLength(16)
+ .HasColumnType("TEXT");
+
+ b.Property("SeriesId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("MovieId");
+
+ b.HasIndex("PersonId");
+
+ b.HasIndex("SeriesId");
+
+ b.ToTable("Credits");
+ });
+
+ modelBuilder.Entity("Flaro.Core.Library.Episode", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("AddedAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("EpisodeNumber")
+ .HasColumnType("INTEGER");
+
+ b.Property("MetadataLanguage")
+ .HasMaxLength(16)
+ .HasColumnType("TEXT");
+
+ b.Property("Overview")
+ .HasColumnType("TEXT");
+
+ b.Property("PublishAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("SeasonNumber")
+ .HasColumnType("INTEGER");
+
+ b.Property("SeriesId")
+ .HasColumnType("TEXT");
+
+ b.Property("StillImage")
+ .HasColumnType("TEXT");
+
+ b.Property("Title")
+ .HasMaxLength(512)
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("SeriesId", "SeasonNumber", "EpisodeNumber")
+ .IsUnique();
+
+ b.ToTable("Episodes");
+ });
+
+ modelBuilder.Entity("Flaro.Core.Library.Genre", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("TEXT")
+ .UseCollation("NOCASE");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Name")
+ .IsUnique();
+
+ b.ToTable("Genres");
+ });
+
+ modelBuilder.Entity("Flaro.Core.Library.GenreLink", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("GenreId")
+ .HasColumnType("TEXT");
+
+ b.Property("MovieId")
+ .HasColumnType("TEXT");
+
+ b.Property("SeriesId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("MovieId");
+
+ b.HasIndex("SeriesId");
+
+ b.HasIndex("GenreId", "MovieId")
+ .IsUnique()
+ .HasFilter("\"MovieId\" IS NOT NULL");
+
+ b.HasIndex("GenreId", "SeriesId")
+ .IsUnique()
+ .HasFilter("\"SeriesId\" IS NOT NULL");
+
+ b.ToTable("GenreLinks");
+ });
+
+ modelBuilder.Entity("Flaro.Core.Library.ItemTranslation", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("EpisodeId")
+ .HasColumnType("TEXT");
+
+ b.Property("FetchedAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("Language")
+ .IsRequired()
+ .HasMaxLength(16)
+ .HasColumnType("TEXT");
+
+ b.Property("MovieId")
+ .HasColumnType("TEXT");
+
+ b.Property("Overview")
+ .HasColumnType("TEXT");
+
+ b.Property("SeriesId")
+ .HasColumnType("TEXT");
+
+ b.Property("Tagline")
+ .HasMaxLength(512)
+ .HasColumnType("TEXT");
+
+ b.Property("Title")
+ .HasMaxLength(512)
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("EpisodeId", "Language")
+ .IsUnique()
+ .HasFilter("\"EpisodeId\" IS NOT NULL");
+
+ b.HasIndex("MovieId", "Language")
+ .IsUnique()
+ .HasFilter("\"MovieId\" IS NOT NULL");
+
+ b.HasIndex("SeriesId", "Language")
+ .IsUnique()
+ .HasFilter("\"SeriesId\" IS NOT NULL");
+
+ b.ToTable("Translations");
+ });
+
+ modelBuilder.Entity("Flaro.Core.Library.MediaFile", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("BitRate")
+ .HasColumnType("INTEGER");
+
+ b.Property("Container")
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property("Duration")
+ .HasColumnType("INTEGER");
+
+ b.Property("EpisodeId")
+ .HasColumnType("TEXT");
+
+ b.Property("LibraryId")
+ .HasColumnType("TEXT");
+
+ b.Property("MissingSince")
+ .HasColumnType("INTEGER");
+
+ b.Property("ModifiedAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("MovieId")
+ .HasColumnType("TEXT");
+
+ b.Property("Path")
+ .IsRequired()
+ .HasMaxLength(1024)
+ .HasColumnType("TEXT");
+
+ b.Property("ProbedAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("SeenAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("SizeBytes")
+ .HasColumnType("INTEGER");
+
+ b.Property("VersionLabel")
+ .HasMaxLength(64)
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("EpisodeId");
+
+ b.HasIndex("MovieId");
+
+ b.HasIndex("LibraryId", "Path")
+ .IsUnique();
+
+ b.ToTable("MediaFiles");
+ });
+
+ modelBuilder.Entity("Flaro.Core.Library.MediaStream", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("Channels")
+ .HasColumnType("INTEGER");
+
+ b.Property("Codec")
+ .HasMaxLength(64)
+ .HasColumnType("TEXT");
+
+ b.Property("Height")
+ .HasColumnType("INTEGER");
+
+ b.Property("Index")
+ .HasColumnType("INTEGER");
+
+ b.Property("IsDefault")
+ .HasColumnType("INTEGER");
+
+ b.Property("IsForced")
+ .HasColumnType("INTEGER");
+
+ b.Property("Kind")
+ .IsRequired()
+ .HasMaxLength(16)
+ .HasColumnType("TEXT");
+
+ b.Property("Language")
+ .HasMaxLength(16)
+ .HasColumnType("TEXT");
+
+ b.Property("MediaFileId")
+ .HasColumnType("TEXT");
+
+ b.Property("Title")
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.Property("Width")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("MediaFileId", "Index")
+ .IsUnique();
+
+ b.ToTable("MediaStreams");
+ });
+
+ modelBuilder.Entity("Flaro.Core.Library.Movie", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("AddedAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("BackdropImage")
+ .HasColumnType("TEXT");
+
+ b.Property("BackdropPinned")
+ .HasColumnType("INTEGER");
+
+ b.Property("ChangedSince")
+ .HasColumnType("INTEGER");
+
+ b.Property("ContentRating")
+ .HasMaxLength(16)
+ .HasColumnType("TEXT");
+
+ b.Property("ImdbId")
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property("LibraryId")
+ .HasColumnType("TEXT");
+
+ b.Property("LogoImage")
+ .HasColumnType("TEXT");
+
+ b.Property("LogoPinned")
+ .HasColumnType("INTEGER");
+
+ b.Property("MatchLocked")
+ .HasColumnType("INTEGER");
+
+ b.Property("MetadataLanguage")
+ .HasMaxLength(16)
+ .HasColumnType("TEXT");
+
+ b.Property("OriginalTitle")
+ .HasMaxLength(512)
+ .HasColumnType("TEXT");
+
+ b.Property("Overview")
+ .HasColumnType("TEXT");
+
+ b.Property("ParsedTitle")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("ParsedYear")
+ .HasColumnType("INTEGER");
+
+ b.Property("PosterImage")
+ .HasColumnType("TEXT");
+
+ b.Property("PosterPinned")
+ .HasColumnType("INTEGER");
+
+ b.Property("PublishAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("Rating")
+ .HasColumnType("REAL");
+
+ b.Property("RuntimeMinutes")
+ .HasColumnType("INTEGER");
+
+ b.Property("SortTitlePinned")
+ .HasColumnType("INTEGER");
+
+ b.Property("SortTitleValue")
+ .IsRequired()
+ .HasMaxLength(512)
+ .HasColumnType("TEXT");
+
+ b.Property("Tagline")
+ .HasMaxLength(512)
+ .HasColumnType("TEXT");
+
+ b.Property("Title")
+ .IsRequired()
+ .HasMaxLength(512)
+ .HasColumnType("TEXT");
+
+ b.Property("TitlePinned")
+ .HasColumnType("INTEGER");
+
+ b.Property("TmdbId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Year")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("LibraryId", "MetadataLanguage");
+
+ b.HasIndex("LibraryId", "SortTitleValue");
+
+ b.HasIndex("LibraryId", "TmdbId")
+ .IsUnique()
+ .HasFilter("\"TmdbId\" IS NOT NULL");
+
+ b.ToTable("Movies");
+ });
+
+ modelBuilder.Entity("Flaro.Core.Library.Person", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("Image")
+ .HasColumnType("TEXT");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.Property("TmdbId")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("TmdbId")
+ .IsUnique();
+
+ b.ToTable("People");
+ });
+
+ modelBuilder.Entity("Flaro.Core.Library.Series", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("AddedAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("BackdropImage")
+ .HasColumnType("TEXT");
+
+ b.Property("BackdropPinned")
+ .HasColumnType("INTEGER");
+
+ b.Property("ChangedSince")
+ .HasColumnType("INTEGER");
+
+ b.Property("ContentRating")
+ .HasMaxLength(16)
+ .HasColumnType("TEXT");
+
+ b.Property("ImdbId")
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property("LibraryId")
+ .HasColumnType("TEXT");
+
+ b.Property("LogoImage")
+ .HasColumnType("TEXT");
+
+ b.Property("LogoPinned")
+ .HasColumnType("INTEGER");
+
+ b.Property("MatchLocked")
+ .HasColumnType("INTEGER");
+
+ b.Property("MetadataLanguage")
+ .HasMaxLength(16)
+ .HasColumnType("TEXT");
+
+ b.Property("OriginalTitle")
+ .HasMaxLength(512)
+ .HasColumnType("TEXT");
+
+ b.Property("Overview")
+ .HasColumnType("TEXT");
+
+ b.Property("ParsedTitle")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("ParsedYear")
+ .HasColumnType("INTEGER");
+
+ b.Property("PosterImage")
+ .HasColumnType("TEXT");
+
+ b.Property("PosterPinned")
+ .HasColumnType("INTEGER");
+
+ b.Property("PublishAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("Rating")
+ .HasColumnType("REAL");
+
+ b.Property("RuntimeMinutes")
+ .HasColumnType("INTEGER");
+
+ b.Property("SortTitlePinned")
+ .HasColumnType("INTEGER");
+
+ b.Property("SortTitleValue")
+ .IsRequired()
+ .HasMaxLength(512)
+ .HasColumnType("TEXT");
+
+ b.Property("Tagline")
+ .HasMaxLength(512)
+ .HasColumnType("TEXT");
+
+ b.Property("Title")
+ .IsRequired()
+ .HasMaxLength(512)
+ .HasColumnType("TEXT");
+
+ b.Property("TitlePinned")
+ .HasColumnType("INTEGER");
+
+ b.Property("TmdbId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Year")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("LibraryId", "MetadataLanguage");
+
+ b.HasIndex("LibraryId", "SortTitleValue");
+
+ b.HasIndex("LibraryId", "TmdbId")
+ .IsUnique()
+ .HasFilter("\"TmdbId\" IS NOT NULL");
+
+ b.ToTable("Series");
+ });
+
+ modelBuilder.Entity("Flaro.Core.Playback.Sehtag", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("EpisodeId")
+ .HasColumnType("TEXT");
+
+ b.Property("MovieId")
+ .HasColumnType("TEXT");
+
+ b.Property("Sekunden")
+ .HasColumnType("REAL");
+
+ b.Property("Tag")
+ .HasColumnType("INTEGER");
+
+ b.Property("UserId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("EpisodeId");
+
+ b.HasIndex("MovieId");
+
+ b.HasIndex("UserId", "Tag");
+
+ b.HasIndex("UserId", "Tag", "EpisodeId")
+ .IsUnique()
+ .HasFilter("\"EpisodeId\" IS NOT NULL");
+
+ b.HasIndex("UserId", "Tag", "MovieId")
+ .IsUnique()
+ .HasFilter("\"MovieId\" IS NOT NULL");
+
+ b.ToTable("Sehtage");
+ });
+
+ modelBuilder.Entity("Flaro.Core.Playback.UserItemState", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("Dismissed")
+ .HasColumnType("INTEGER");
+
+ b.Property("EpisodeId")
+ .HasColumnType("TEXT");
+
+ b.Property("Favourite")
+ .HasColumnType("INTEGER");
+
+ b.Property("InProgress")
+ .HasColumnType("INTEGER");
+
+ b.Property("LastPlayedAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("MovieId")
+ .HasColumnType("TEXT");
+
+ b.Property("PlayCount")
+ .HasColumnType("INTEGER");
+
+ b.Property("Position")
+ .HasColumnType("INTEGER");
+
+ b.Property("Rating")
+ .HasColumnType("INTEGER");
+
+ b.Property("Runtime")
+ .HasColumnType("INTEGER");
+
+ b.Property("SeriesId")
+ .HasColumnType("TEXT");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("UserId")
+ .HasColumnType("TEXT");
+
+ b.Property("Watched")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("EpisodeId");
+
+ b.HasIndex("MovieId");
+
+ b.HasIndex("SeriesId");
+
+ b.HasIndex("UserId", "EpisodeId")
+ .IsUnique()
+ .HasFilter("\"EpisodeId\" IS NOT NULL");
+
+ b.HasIndex("UserId", "MovieId")
+ .IsUnique()
+ .HasFilter("\"MovieId\" IS NOT NULL");
+
+ b.HasIndex("UserId", "SeriesId")
+ .IsUnique()
+ .HasFilter("\"SeriesId\" IS NOT NULL");
+
+ b.HasIndex("UserId", "InProgress", "UpdatedAt");
+
+ b.ToTable("UserItemStates");
+ });
+
+ modelBuilder.Entity("Flaro.Core.ServerSetting", b =>
+ {
+ b.Property("Key")
+ .HasMaxLength(128)
+ .HasColumnType("TEXT");
+
+ b.Property("Value")
+ .IsRequired()
+ .HasMaxLength(4096)
+ .HasColumnType("TEXT");
+
+ b.HasKey("Key");
+
+ b.ToTable("Settings");
+ });
+
+ modelBuilder.Entity("Flaro.Core.Users.User", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("AvatarImage")
+ .HasMaxLength(64)
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("IsAdministrator")
+ .HasColumnType("INTEGER");
+
+ b.Property("Language")
+ .HasMaxLength(16)
+ .HasColumnType("TEXT");
+
+ b.Property("PasswordHash")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.Property("UserName")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("TEXT")
+ .UseCollation("NOCASE");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserName")
+ .IsUnique();
+
+ b.ToTable("Users");
+ });
+
+ modelBuilder.Entity("Flaro.Core.Auth.DeviceToken", b =>
+ {
+ b.HasOne("Flaro.Core.Users.User", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Flaro.Core.Library.Credit", b =>
+ {
+ b.HasOne("Flaro.Core.Library.Movie", null)
+ .WithMany()
+ .HasForeignKey("MovieId")
+ .OnDelete(DeleteBehavior.Cascade);
+
+ b.HasOne("Flaro.Core.Library.Person", null)
+ .WithMany()
+ .HasForeignKey("PersonId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("Flaro.Core.Library.Series", null)
+ .WithMany()
+ .HasForeignKey("SeriesId")
+ .OnDelete(DeleteBehavior.Cascade);
+ });
+
+ modelBuilder.Entity("Flaro.Core.Library.Episode", b =>
+ {
+ b.HasOne("Flaro.Core.Library.Series", null)
+ .WithMany()
+ .HasForeignKey("SeriesId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Flaro.Core.Library.GenreLink", b =>
+ {
+ b.HasOne("Flaro.Core.Library.Genre", null)
+ .WithMany()
+ .HasForeignKey("GenreId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("Flaro.Core.Library.Movie", null)
+ .WithMany()
+ .HasForeignKey("MovieId")
+ .OnDelete(DeleteBehavior.Cascade);
+
+ b.HasOne("Flaro.Core.Library.Series", null)
+ .WithMany()
+ .HasForeignKey("SeriesId")
+ .OnDelete(DeleteBehavior.Cascade);
+ });
+
+ modelBuilder.Entity("Flaro.Core.Library.MediaFile", b =>
+ {
+ b.HasOne("Flaro.Core.Libraries.Library", null)
+ .WithMany()
+ .HasForeignKey("LibraryId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Flaro.Core.Library.MediaStream", b =>
+ {
+ b.HasOne("Flaro.Core.Library.MediaFile", null)
+ .WithMany()
+ .HasForeignKey("MediaFileId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Flaro.Core.Library.Movie", b =>
+ {
+ b.HasOne("Flaro.Core.Libraries.Library", null)
+ .WithMany()
+ .HasForeignKey("LibraryId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Flaro.Core.Library.Series", b =>
+ {
+ b.HasOne("Flaro.Core.Libraries.Library", null)
+ .WithMany()
+ .HasForeignKey("LibraryId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Flaro.Core.Playback.Sehtag", b =>
+ {
+ b.HasOne("Flaro.Core.Library.Episode", null)
+ .WithMany()
+ .HasForeignKey("EpisodeId")
+ .OnDelete(DeleteBehavior.Cascade);
+
+ b.HasOne("Flaro.Core.Library.Movie", null)
+ .WithMany()
+ .HasForeignKey("MovieId")
+ .OnDelete(DeleteBehavior.Cascade);
+
+ b.HasOne("Flaro.Core.Users.User", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Flaro.Core.Playback.UserItemState", b =>
+ {
+ b.HasOne("Flaro.Core.Library.Episode", null)
+ .WithMany()
+ .HasForeignKey("EpisodeId")
+ .OnDelete(DeleteBehavior.Cascade);
+
+ b.HasOne("Flaro.Core.Library.Movie", null)
+ .WithMany()
+ .HasForeignKey("MovieId")
+ .OnDelete(DeleteBehavior.Cascade);
+
+ b.HasOne("Flaro.Core.Library.Series", null)
+ .WithMany()
+ .HasForeignKey("SeriesId")
+ .OnDelete(DeleteBehavior.Cascade);
+
+ b.HasOne("Flaro.Core.Users.User", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/server/src/Flaro.Data/Migrations/20261006150904_Sehprotokoll.cs b/server/src/Flaro.Data/Migrations/20261006150904_Sehprotokoll.cs
new file mode 100644
index 0000000..a87b839
--- /dev/null
+++ b/server/src/Flaro.Data/Migrations/20261006150904_Sehprotokoll.cs
@@ -0,0 +1,85 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace Flaro.Data.Migrations
+{
+ ///
+ public partial class Sehprotokoll : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "Sehtage",
+ columns: table => new
+ {
+ Id = table.Column(type: "TEXT", nullable: false),
+ UserId = table.Column(type: "TEXT", nullable: false),
+ MovieId = table.Column(type: "TEXT", nullable: true),
+ EpisodeId = table.Column(type: "TEXT", nullable: true),
+ Tag = table.Column(type: "INTEGER", nullable: false),
+ Sekunden = table.Column(type: "REAL", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_Sehtage", x => x.Id);
+ table.ForeignKey(
+ name: "FK_Sehtage_Episodes_EpisodeId",
+ column: x => x.EpisodeId,
+ principalTable: "Episodes",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Cascade);
+ table.ForeignKey(
+ name: "FK_Sehtage_Movies_MovieId",
+ column: x => x.MovieId,
+ principalTable: "Movies",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Cascade);
+ table.ForeignKey(
+ name: "FK_Sehtage_Users_UserId",
+ column: x => x.UserId,
+ principalTable: "Users",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_Sehtage_EpisodeId",
+ table: "Sehtage",
+ column: "EpisodeId");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_Sehtage_MovieId",
+ table: "Sehtage",
+ column: "MovieId");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_Sehtage_UserId_Tag",
+ table: "Sehtage",
+ columns: new[] { "UserId", "Tag" });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_Sehtage_UserId_Tag_EpisodeId",
+ table: "Sehtage",
+ columns: new[] { "UserId", "Tag", "EpisodeId" },
+ unique: true,
+ filter: "\"EpisodeId\" IS NOT NULL");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_Sehtage_UserId_Tag_MovieId",
+ table: "Sehtage",
+ columns: new[] { "UserId", "Tag", "MovieId" },
+ unique: true,
+ filter: "\"MovieId\" IS NOT NULL");
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "Sehtage");
+ }
+ }
+}
diff --git a/server/src/Flaro.Data/Migrations/FlaroContextModelSnapshot.cs b/server/src/Flaro.Data/Migrations/FlaroContextModelSnapshot.cs
index b2187e3..573d4af 100644
--- a/server/src/Flaro.Data/Migrations/FlaroContextModelSnapshot.cs
+++ b/server/src/Flaro.Data/Migrations/FlaroContextModelSnapshot.cs
@@ -685,6 +685,46 @@ namespace Flaro.Data.Migrations
b.ToTable("Series");
});
+ modelBuilder.Entity("Flaro.Core.Playback.Sehtag", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("EpisodeId")
+ .HasColumnType("TEXT");
+
+ b.Property("MovieId")
+ .HasColumnType("TEXT");
+
+ b.Property("Sekunden")
+ .HasColumnType("REAL");
+
+ b.Property("Tag")
+ .HasColumnType("INTEGER");
+
+ b.Property("UserId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("EpisodeId");
+
+ b.HasIndex("MovieId");
+
+ b.HasIndex("UserId", "Tag");
+
+ b.HasIndex("UserId", "Tag", "EpisodeId")
+ .IsUnique()
+ .HasFilter("\"EpisodeId\" IS NOT NULL");
+
+ b.HasIndex("UserId", "Tag", "MovieId")
+ .IsUnique()
+ .HasFilter("\"MovieId\" IS NOT NULL");
+
+ b.ToTable("Sehtage");
+ });
+
modelBuilder.Entity("Flaro.Core.Playback.UserItemState", b =>
{
b.Property("Id")
@@ -905,6 +945,25 @@ namespace Flaro.Data.Migrations
.IsRequired();
});
+ modelBuilder.Entity("Flaro.Core.Playback.Sehtag", b =>
+ {
+ b.HasOne("Flaro.Core.Library.Episode", null)
+ .WithMany()
+ .HasForeignKey("EpisodeId")
+ .OnDelete(DeleteBehavior.Cascade);
+
+ b.HasOne("Flaro.Core.Library.Movie", null)
+ .WithMany()
+ .HasForeignKey("MovieId")
+ .OnDelete(DeleteBehavior.Cascade);
+
+ b.HasOne("Flaro.Core.Users.User", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
modelBuilder.Entity("Flaro.Core.Playback.UserItemState", b =>
{
b.HasOne("Flaro.Core.Library.Episode", null)
diff --git a/server/tests/Flaro.Api.Tests/StatistikTests.cs b/server/tests/Flaro.Api.Tests/StatistikTests.cs
new file mode 100644
index 0000000..20d70a5
--- /dev/null
+++ b/server/tests/Flaro.Api.Tests/StatistikTests.cs
@@ -0,0 +1,233 @@
+using System.Net.Http.Json;
+using Flaro.Api.Endpoints;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Flaro.Api.Tests;
+
+/// Das Sehprotokoll und was sich daraus ablesen lässt.
+[TestFixture]
+public sealed class StatistikTests
+{
+ private string _bestand = "";
+
+ [SetUp]
+ public void Anlegen() => _bestand = Directory.CreateDirectory(
+ Path.Combine(Path.GetTempPath(), "flaro-stat-" + Guid.CreateVersion7().ToString("N"))).FullName;
+
+ [TearDown]
+ public void Aufraeumen()
+ {
+ try { Directory.Delete(_bestand, recursive: true); } catch (IOException) { /* Kür */ }
+ }
+
+ private void Datei(string relativ)
+ {
+ var voll = Path.Combine(_bestand, relativ);
+ Directory.CreateDirectory(Path.GetDirectoryName(voll)!);
+ File.WriteAllBytes(voll, new byte[2048]);
+ }
+
+ /// Die Uhr des Servers — stellbar, damit Zeit vergehen kann.
+ private Stelluhr _uhr = new(DateTimeOffset.UtcNow);
+
+ /// Ein Server, dessen Uhr dieser Test stellt.
+ private FlaroServer MitUhr()
+ {
+ _uhr = new Stelluhr(DateTimeOffset.UtcNow);
+ return new FlaroServer(dienste => dienste.AddSingleton(_uhr));
+ }
+
+ private async Task Eingelesen(FlaroServer server)
+ {
+ var admin = await server.SetUpAndSignIn();
+ var client = server.AlsAngemeldeter(admin.Token);
+
+ var angelegt = await client.PostAsJsonAsync("/api/v1/libraries",
+ new CreateLibraryRequest("Prüfbestand", "Movies", _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") return client;
+ if (stand.State is "failed") Assert.Fail($"Der Scan ist gescheitert: {stand.Error}");
+ await Task.Delay(50);
+ }
+
+ Assert.Fail("Der Scan wurde nicht fertig.");
+ return client;
+ }
+
+ private static async Task ErsterFilm(HttpClient client)
+ {
+ var seite = await client.GetFromJsonAsync>("/api/v1/movies");
+ return seite!.Items[0].Id;
+ }
+
+ private static Task Melden(HttpClient client, Guid id, double stelle, double? laenge) =>
+ client.PostAsJsonAsync($"/api/v1/items/{id}/progress",
+ new ProgressRequest(stelle, laenge, null));
+
+ private static Task Statistik(HttpClient client) =>
+ client.GetFromJsonAsync("/api/v1/users/me/statistics");
+
+ /// Geschaute Zeit landet im Protokoll.
+ [Test]
+ public async Task Was_laeuft_wird_mitgeschrieben()
+ {
+ Datei("Matrix (1999)/Matrix (1999).mkv");
+ using var server = MitUhr();
+ var client = await Eingelesen(server);
+ var film = await ErsterFilm(client);
+
+ var leer = await Statistik(client);
+ Assert.That(leer!.LoggedSeconds, Is.Zero, "vor der ersten Wiedergabe steht nichts drin");
+
+ // Zweimal eine Minute schauen — mit einer Uhr, die mitläuft.
+ await Melden(client, film, 0, 7200);
+ _uhr.Weiter(TimeSpan.FromMinutes(1));
+ await Melden(client, film, 60, 7200);
+ _uhr.Weiter(TimeSpan.FromMinutes(1));
+ await Melden(client, film, 120, 7200);
+
+ var danach = await Statistik(client);
+ Assert.That(danach!.LoggedSeconds, Is.EqualTo(120).Within(1),
+ "zwei Minuten gesehen, zwei Minuten im Protokoll");
+ }
+
+ /// Vorspulen ist kein Schauen.
+ ///
+ ///
+ /// Der Grund für den Deckel. Zwischen zwei Meldungen liegen zehn
+ /// Sekunden. Wer in dieser Zeit von Minute 5 auf Minute 90 springt, hat
+ /// nicht 85 Minuten gesehen — ohne Deckel stünde ein einmal
+ /// durchgespulter Film in der Statistik wie zweimal angeschaut.
+ ///
+ [Test]
+ public async Task Vorspulen_zaehlt_nicht_als_Sehzeit()
+ {
+ Datei("Matrix (1999)/Matrix (1999).mkv");
+ using var server = MitUhr();
+ var client = await Eingelesen(server);
+ var film = await ErsterFilm(client);
+
+ await Melden(client, film, 300, 7200);
+ // Ein Sprung um 85 Minuten, zehn Sekunden später.
+ _uhr.Weiter(TimeSpan.FromSeconds(10));
+ await Melden(client, film, 5700, 7200);
+
+ var stand = await Statistik(client);
+
+ Assert.That(stand!.LoggedSeconds, Is.LessThan(60),
+ "der Sprung darf nicht als anderthalb Stunden zählen");
+ }
+
+ /// Zurückspringen zieht nichts ab.
+ [Test]
+ public async Task Zuruecksprigen_zaehlt_weder_vor_noch_zurueck()
+ {
+ Datei("Matrix (1999)/Matrix (1999).mkv");
+ using var server = MitUhr();
+ var client = await Eingelesen(server);
+ var film = await ErsterFilm(client);
+
+ await Melden(client, film, 0, 7200);
+ _uhr.Weiter(TimeSpan.FromMinutes(2));
+ await Melden(client, film, 100, 7200);
+ var vorher = (await Statistik(client))!.LoggedSeconds;
+
+ _uhr.Weiter(TimeSpan.FromMinutes(1));
+ await Melden(client, film, 20, 7200);
+ var nachher = (await Statistik(client))!.LoggedSeconds;
+
+ Assert.That(nachher, Is.EqualTo(vorher).Within(0.001));
+ }
+
+ /// Die Wochenkurve hat so viele Punkte wie verlangt.
+ ///
+ ///
+ /// Auch leere Wochen stehen darin. Eine Kurve, die nur die Wochen zeigt,
+ /// in denen etwas lief, behauptet eine Gleichmässigkeit, die es nicht gab.
+ ///
+ [Test]
+ public async Task Die_Kurve_hat_auch_leere_Wochen()
+ {
+ Datei("Matrix (1999)/Matrix (1999).mkv");
+ using var server = MitUhr();
+ var client = await Eingelesen(server);
+ var film = await ErsterFilm(client);
+
+ await Melden(client, film, 0, 7200);
+ _uhr.Weiter(TimeSpan.FromMinutes(2));
+ await Melden(client, film, 90, 7200);
+
+ var stand = await client.GetFromJsonAsync(
+ "/api/v1/users/me/statistics?wochen=6");
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(stand!.Weeks, Has.Count.EqualTo(6));
+ Assert.That(stand.Weeks.Select(w => w.Start.DayOfWeek), Is.All.EqualTo(DayOfWeek.Monday),
+ "Wochen beginnen am Montag, sonst lässt sich die Kurve nicht vergleichen");
+ Assert.That(stand.Weeks[^1].Seconds, Is.GreaterThan(0), "die laufende Woche ist die letzte");
+ Assert.That(stand.Weeks.Sum(w => w.Seconds), Is.EqualTo(stand.LoggedSeconds).Within(0.001));
+ });
+ }
+
+ /// Was ohne Protokoll vollständig ist, steht auch da.
+ [Test]
+ public async Task Gesehene_Titel_zaehlen_auch_ohne_Protokoll()
+ {
+ Datei("Matrix (1999)/Matrix (1999).mkv");
+ using var server = MitUhr();
+ var client = await Eingelesen(server);
+ var film = await ErsterFilm(client);
+
+ await client.PutAsJsonAsync($"/api/v1/items/{film}/watched", new WatchedRequest(true));
+
+ var stand = await Statistik(client);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(stand!.WatchedTitles, Is.EqualTo(1));
+ Assert.That(stand.Recent, Has.Count.EqualTo(1));
+ Assert.That(stand.Recent[0].Title, Is.EqualTo("Matrix"));
+ Assert.That(stand.Recent[0].Watched, Is.True);
+ Assert.That(stand.LoggedSeconds, Is.Zero,
+ "abhaken ist kein Schauen — das Protokoll bleibt leer");
+ });
+ }
+
+ /// Jeder sieht nur seine eigenen Zahlen.
+ ///
+ ///
+ /// Auch der Administrator. Wer wann welchen Film anschaut, ist eine
+ /// Auskunft über Menschen und nicht über den Server — es gibt deshalb gar
+ /// keinen Weg, ein fremdes Konto zu meinen.
+ ///
+ [Test]
+ public async Task Die_Zahlen_des_einen_sind_nicht_die_des_anderen()
+ {
+ Datei("Matrix (1999)/Matrix (1999).mkv");
+ using var server = MitUhr();
+ var alsAdmin = await Eingelesen(server);
+ var film = await ErsterFilm(alsAdmin);
+ var gast = await server.NeuerBenutzer(alsAdmin, "gast", "gastpasswortlang");
+
+ await Melden(alsAdmin, film, 0, 7200);
+ _uhr.Weiter(TimeSpan.FromMinutes(3));
+ await Melden(alsAdmin, film, 120, 7200);
+
+ var meine = await Statistik(alsAdmin);
+ var fremde = await Statistik(gast);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(meine!.LoggedSeconds, Is.GreaterThan(0));
+ Assert.That(fremde!.LoggedSeconds, Is.Zero);
+ Assert.That(fremde.Recent, Is.Empty);
+ });
+ }
+}
diff --git a/server/tests/Flaro.Api.Tests/Stelluhr.cs b/server/tests/Flaro.Api.Tests/Stelluhr.cs
new file mode 100644
index 0000000..ec87c58
--- /dev/null
+++ b/server/tests/Flaro.Api.Tests/Stelluhr.cs
@@ -0,0 +1,26 @@
+namespace Flaro.Api.Tests;
+
+/// Eine Uhr, die man weiterstellt, statt zu warten.
+///
+///
+/// Weil manche Regeln an der verstrichenen Zeit hängen. Das
+/// Sehprotokoll deckelt den Zuwachs auf die Zeit zwischen zwei Meldungen —
+/// zwei Minuten Film lassen sich nicht in fünf Millisekunden sehen, und das
+/// ist richtig so. Prüfen lässt es sich damit nur, wenn der Test die Zeit
+/// vergehen lassen kann, ohne sie zu verbringen.
+///
+/// Nur vorwärts: eine Uhr, die zurückläuft, prüft nichts, was vorkommt.
+///
+public sealed class Stelluhr(DateTimeOffset start) : TimeProvider
+{
+ private DateTimeOffset _jetzt = start;
+
+ public override DateTimeOffset GetUtcNow() => _jetzt;
+
+ /// Lässt Zeit vergehen.
+ public void Weiter(TimeSpan spanne)
+ {
+ if (spanne < TimeSpan.Zero) throw new ArgumentOutOfRangeException(nameof(spanne));
+ _jetzt += spanne;
+ }
+}
diff --git a/web/app/src/api.ts b/web/app/src/api.ts
index 705abcd..91b5ca1 100644
--- a/web/app/src/api.ts
+++ b/web/app/src/api.ts
@@ -853,6 +853,27 @@ export const wiedergabeSetzen = (werte: Partial) =>
}),
});
+/** Was jemand geschaut hat — nur für ihn selbst. */
+export type Statistik = {
+ /** Aus dem Sehprotokoll. Fängt an dem Tag an, an dem es eingebaut wurde. */
+ loggedSeconds: number;
+ /** Aus den Sehständen — für die ganze Vergangenheit vollständig. */
+ watchedTitles: number;
+ watchedRuntimeSeconds: number;
+ /** Seit wann protokolliert wird. `null`, solange nichts darin steht. */
+ since: string | null;
+ weeks: { start: string; seconds: number }[];
+ recent: {
+ id: string; kind: 'movie' | 'episode';
+ title: string; subTitle: string | null;
+ posterImage: string | null; at: string; watched: boolean;
+ }[];
+ genres: { name: string; seconds: number }[];
+};
+
+export const statistik = (wochen = 12) =>
+ anfrage(`/users/me/statistics${abfrage({ wochen })}`);
+
export type Speicherort = {
name: string; variable: string; pfad: string;
belegt: number; frei: number | null; bilder: number | null;
diff --git a/web/app/src/style.css b/web/app/src/style.css
index aa1cd75..53d513b 100644
--- a/web/app/src/style.css
+++ b/web/app/src/style.css
@@ -1265,6 +1265,65 @@ body:has(.pl.pl-klein) .auswahlleiste { bottom: 92px; }
.profil-kopf { flex-direction: column; align-items: flex-start; gap: var(--s-loose); }
}
+/* Zahlen im Profil. Drei Kacheln, eine Wochenkurve, zwei Listen — alles von
+ Hand gezeichnet. Für zwölf Balken eine Diagrammbibliothek zu laden hiesse,
+ dreihundert Kilobyte für etwas mitzuschleppen, das aus Rechtecken besteht. */
+
+.profil-zahlen { margin-bottom: var(--s-section); }
+.profil h3 { font-size: 13px; font-weight: 600; color: var(--text-dim); margin: 0 0 var(--s-snug); }
+
+.zahlenreihe {
+ display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
+ gap: var(--s-regular); margin-bottom: var(--s-section);
+}
+.zahlkachel {
+ display: flex; flex-direction: column; gap: 2px;
+ padding: var(--s-loose); border-radius: var(--r-karte); background: var(--elevated);
+}
+.zahlkachel strong { font-size: 22px; font-weight: 700; letter-spacing: -.02em; }
+.zahlkachel > span { font-size: var(--t-normal); color: var(--text-dim); }
+.zahlkachel .winzig { color: var(--text-faint); }
+
+.wochen {
+ display: flex; align-items: flex-end; gap: 6px;
+ margin-bottom: var(--s-section);
+}
+/* Hundert Pixel für den höchsten Balken, zwanzig für die Beschriftung — die
+ Höhe steht in `profil.ts` und kommt in Pixeln, nicht in Prozent. */
+.woche { flex: 1; display: flex; flex-direction: column; justify-content: flex-end; }
+.woche-balken {
+ flex: none; border-radius: 3px 3px 0 0;
+ background: var(--accent); min-height: 2px;
+}
+/* Eine Woche ohne Sehzeit bleibt sichtbar — sonst sähe die Kurve aus, als
+ fehlten dort Daten, statt dass nichts lief. */
+.woche-balken.woche-leer { background: var(--line); }
+.woche-marke {
+ flex: none; font-size: 10px; color: var(--text-faint); text-align: center;
+ padding-top: 4px; font-variant-numeric: tabular-nums;
+}
+
+.genres { display: flex; flex-direction: column; gap: var(--s-snug); margin-bottom: var(--s-section); }
+.genre { display: flex; align-items: center; gap: var(--s-regular); font-size: var(--t-normal); }
+.genre-name { flex: 0 0 110px; color: var(--text-dim); }
+.genre-balken { flex: 1; height: 8px; border-radius: 4px; background: var(--elevated); overflow: hidden; }
+.genre-balken i { display: block; height: 100%; background: var(--accent); border-radius: 4px; }
+.genre-wert { flex: none; color: var(--text-faint); font-variant-numeric: tabular-nums; }
+
+.zuletzt { display: flex; flex-direction: column; gap: 2px; }
+.zuletzt-zeile {
+ display: flex; align-items: center; gap: var(--s-regular);
+ background: none; border: 0; text-align: left; color: var(--text);
+ padding: var(--s-snug); border-radius: var(--r-control);
+}
+.zuletzt-zeile:hover { background: var(--elevated); }
+.zuletzt-zeile img, .zuletzt-zeile .platzhalter {
+ flex: none; width: 32px; height: 48px; border-radius: 4px;
+ object-fit: cover; background: var(--placeholder);
+}
+.zuletzt-text { flex: 1; min-width: 0; display: flex; flex-direction: column; }
+.zuletzt-text strong { font-size: var(--t-normal); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+
/* --- Verwaltung ----------------------------------------------------------
*
* **Eine zweite Leiste, keine zweite Seite.** Die Verwaltung war elf
diff --git a/web/app/src/views/profil.ts b/web/app/src/views/profil.ts
index 2120641..b95428a 100644
--- a/web/app/src/views/profil.ts
+++ b/web/app/src/views/profil.ts
@@ -1,6 +1,7 @@
import {
sitzung, meinKonto, profilbildSetzen, profilbildEntfernen, bildAdresse,
- spracheSetzen, sprachen, abmelden, istAdmin, type Rolle,
+ spracheSetzen, sprachen, abmelden, istAdmin, statistik,
+ type Rolle, type Statistik,
} from '../api';
import { esc, knopf, fehlerzeile, meldung, hinweisZeigen } from '../util';
import { dialog, nachfragen } from '../dialog';
@@ -40,7 +41,135 @@ export function profilseite(): HTMLElement {
if (sitzung.wer) Object.assign(sitzung.wer, konto);
wurzel.innerHTML = '';
- wurzel.append(kopf(konto), einstellungen(konto), abmeldeteil());
+ const zahlen = document.createElement('section');
+ zahlen.className = 'profil-zahlen';
+ wurzel.append(kopf(konto), zahlen, einstellungen(konto), abmeldeteil());
+ void zahlenLaden(zahlen);
+ }
+
+ /* ---------- Was man geschaut hat ---------- */
+
+ async function zahlenLaden(ziel: HTMLElement): Promise {
+ ziel.innerHTML = '';
+ let stand: Statistik;
+ try {
+ stand = await statistik();
+ } catch (fehler) {
+ ziel.innerHTML = '';
+ ziel.appendChild(fehlerzeile(meldung(fehler)));
+ return;
+ }
+
+ ziel.innerHTML = '';
+ const h = document.createElement('h2');
+ h.textContent = 'Geschaut';
+ ziel.appendChild(h);
+
+ // **Drei Zahlen, und jede sagt, woher sie kommt.**
+ //
+ // Die Gesamtzeit stammt aus dem Protokoll und fängt an dem Tag an, an dem
+ // es eingebaut wurde. Die Zahl gesehener Titel steht seit jeher fest. Das
+ // zu einer Zahl zu verrühren hiesse, die eine zu behaupten.
+ const kacheln = document.createElement('div');
+ kacheln.className = 'zahlenreihe';
+ kacheln.append(
+ zahl(stunden(stand.loggedSeconds), 'aufgezeichnet',
+ stand.since
+ ? `seit ${new Date(stand.since).toLocaleDateString('de-CH', { dateStyle: 'medium' })}`
+ : 'noch nichts aufgezeichnet'),
+ zahl(String(stand.watchedTitles), stand.watchedTitles === 1 ? 'Titel gesehen' : 'Titel gesehen',
+ 'mit Häkchen, auch von früher'),
+ zahl(stunden(stand.watchedRuntimeSeconds), 'Laufzeit davon',
+ 'die Länge aller abgehakten Titel'),
+ );
+ ziel.appendChild(kacheln);
+
+ ziel.appendChild(wochenkurve(stand));
+
+ if (stand.genres.length > 0) ziel.appendChild(genreliste(stand));
+ if (stand.recent.length > 0) ziel.appendChild(zuletztliste(stand));
+ }
+
+ /** Die Wochenkurve als Balken — zwölf Werte brauchen keine Bibliothek. */
+ function wochenkurve(stand: Statistik): HTMLElement {
+ const block = document.createElement('div');
+ block.className = 'wochen';
+
+ const hoechstwert = Math.max(...stand.weeks.map(w => w.seconds), 1);
+ stand.weeks.forEach(w => {
+ const saeule = document.createElement('div');
+ saeule.className = 'woche';
+ const balken = document.createElement('div');
+ balken.className = 'woche-balken';
+ // **In Pixeln, nicht in Prozent.** Eine Prozenthöhe in einer Flexspalte
+ // löst sich nicht gegen das auf, was man erwartet — gemessen: aus „2 %"
+ // wurden 48 Pixel. Hundert Pixel sind der Platz, der da ist.
+ // Mindestens zwei, damit eine leere Woche als leer zu sehen ist und
+ // nicht als fehlend.
+ balken.style.height = `${Math.max(2, Math.round((w.seconds / hoechstwert) * 100))}px`;
+ // **`woche-leer`, nicht `leer`.** Den kurzen Namen gibt es schon: er
+ // gehört der Fläche „hier ist nichts" und bringt 24 Pixel Polster mit.
+ // Der Balken wurde dadurch 48 Pixel hoch, egal welche Höhe dastand.
+ if (w.seconds === 0) balken.classList.add('woche-leer');
+ const datum = new Date(w.start);
+ saeule.title = `Woche ab ${datum.toLocaleDateString('de-CH', { dateStyle: 'medium' })}: `
+ + (w.seconds > 0 ? stunden(w.seconds) : 'nichts');
+ const marke = document.createElement('span');
+ marke.className = 'woche-marke';
+ marke.textContent = datum.toLocaleDateString('de-CH', { day: '2-digit', month: '2-digit' });
+ saeule.append(balken, marke);
+ block.appendChild(saeule);
+ });
+
+ return block;
+ }
+
+ function genreliste(stand: Statistik): HTMLElement {
+ const block = document.createElement('div');
+ block.className = 'genres';
+ const groesste = Math.max(...stand.genres.map(g => g.seconds), 1);
+ stand.genres.forEach(g => {
+ const zeileG = document.createElement('div');
+ zeileG.className = 'genre';
+ zeileG.innerHTML = `${esc(g.name)}`
+ + ``
+ + `${esc(stunden(g.seconds))}`;
+ block.appendChild(zeileG);
+ });
+ return block;
+ }
+
+ function zuletztliste(stand: Statistik): HTMLElement {
+ const block = document.createElement('div');
+ block.className = 'zuletzt';
+
+ const h = document.createElement('h3');
+ h.textContent = 'Zuletzt gesehen';
+ block.appendChild(h);
+
+ stand.recent.slice(0, 8).forEach(e => {
+ const k = document.createElement('button');
+ k.type = 'button';
+ k.className = 'zuletzt-zeile';
+ const adresse = bildAdresse(e.posterImage);
+ k.innerHTML = (adresse ? `
` : '')
+ + `${esc(e.title)}`
+ + (e.subTitle ? `${esc(e.subTitle)}` : '')
+ + ``
+ + `${esc(new Date(e.at).toLocaleDateString('de-CH', { dateStyle: 'medium' }))}`;
+ k.onclick = () => { location.hash = `#/${e.kind === 'episode' ? 'serien' : 'filme'}/${e.id}`; };
+ block.appendChild(k);
+ });
+
+ return block;
+ }
+
+ function zahl(wert: string, was: string, hinweis: string): HTMLElement {
+ const d = document.createElement('div');
+ d.className = 'zahlkachel';
+ d.innerHTML = `${esc(wert)}${esc(was)}`
+ + `${esc(hinweis)}`;
+ return d;
}
/* ---------- Bild und Name ---------- */
@@ -199,6 +328,14 @@ function kopfleisteAuffrischen(): void {
window.dispatchEvent(new CustomEvent('flaro:konto'));
}
+/** „12 Std. 30 Min." — oder „noch nichts", solange nichts dasteht. */
+function stunden(sekunden: number): string {
+ if (sekunden < 60) return '0 Min.';
+ const h = Math.floor(sekunden / 3600);
+ const m = Math.round((sekunden % 3600) / 60);
+ return h > 0 ? `${h} Std. ${m} Min.` : `${m} Min.`;
+}
+
export function anfangsbuchstabe(name: string): string {
return (name.slice(0, 1) || '?').toUpperCase();
}