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(); }