Der Server wusste, was du zuletzt gesehen hast — und sonst nichts
Für „wie viel habe ich letzte Woche geschaut" gab es keine Daten. `UserItemState` ist eine Zeile je Titel: der letzte Stand. Wer einen Film zweimal sieht, überschreibt den ersten Durchgang; wann etwas lief, wusste der Server nur fürs jeweils letzte Mal. Nachträglich erfinden lässt sich das nicht. Jetzt gibt es ein Sehprotokoll: **eine Zeile je Nutzer, Tag und Titel.** Keine Sitzungen — die brauchen ein Ende, und das kommt oft nie an, wenn der Browser zugemacht oder das Telefon zugeklappt wird. Tagesbündel haben diesen Lebenslauf nicht, es wird nur addiert. **Gezählt wird, was wirklich lief.** Der Zuwachs zwischen zwei Meldungen, gedeckelt auf die Zeit, die dazwischen verstrichen ist (mal zwei als Toleranz für doppelte Geschwindigkeit und ausgefallene Meldungen). Sonst ergäbe ein Sprung von Minute 5 auf Minute 90 anderthalb Stunden Sehzeit in zehn Sekunden. Gemessen an fünf Meldungen im Abstand von elf Sekunden: 88 Sekunden statt 190. Dabei fiel ein zweiter Fall auf: war **gar keine** Zeit verstrichen, griff kein Deckel statt eines engen — eine frisch angelegte Zeile hätte ihre Anfangsstelle voll mitgezählt. Wer einen Film öffnet und sofort auf Minute fünf springt, hat keine fünf Minuten gesehen. Im Profil stehen drei Zahlen, und jede sagt, woher sie kommt: die aufgezeichnete Zeit fängt heute an, die Zahl gesehener Titel steht seit jeher fest. Dazu eine Wochenkurve, die häufigsten Genres und was zuletzt lief. **Jeder sieht nur sich selbst, auch ein Administrator** — es gibt keinen Weg, ein fremdes Konto zu meinen. Zwei Dinge auf dem Weg dorthin: **Eine stellbare Uhr für die Tests.** Zwei Minuten Film lassen sich nicht in fünf Millisekunden sehen, und das ist richtig so — prüfen liess es sich nur, indem die Zeit vergeht, ohne sie zu verbringen. **Ein Namenskonflikt im CSS.** Die leeren Wochenbalken trugen die Klasse `leer`, und die gehört längst der Fläche „hier ist nichts" — mitsamt 24 Pixeln Polster. Jeder leere Balken war dadurch 48 Pixel hoch, egal welche Höhe dastand. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
d8a1633cf4
commit
579a49b73b
@@ -687,6 +687,34 @@ public sealed record ContinueEntry(
|
||||
/// </remarks>
|
||||
string? SeriesBackdropImage = null);
|
||||
|
||||
/// <summary>Was jemand geschaut hat — nur für ihn selbst.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Zwei Arten Zahl, und der Unterschied gehört dazu.</b>
|
||||
/// <c>LoggedSeconds</c> kommt aus dem Sehprotokoll und fängt an dem Tag an,
|
||||
/// an dem es eingebaut wurde. <c>WatchedTitles</c> und
|
||||
/// <c>WatchedRuntimeSeconds</c> kommen aus den Sehständen und sind für die
|
||||
/// ganze Vergangenheit vollständig. Beides als eine Zahl auszugeben hiesse,
|
||||
/// eine davon zu behaupten.
|
||||
/// </remarks>
|
||||
public sealed record StatistikResponse(
|
||||
double LoggedSeconds,
|
||||
int WatchedTitles,
|
||||
double WatchedRuntimeSeconds,
|
||||
DateTimeOffset? Since,
|
||||
IReadOnlyList<Wochenwert> Weeks,
|
||||
IReadOnlyList<Zuletztgesehen> Recent,
|
||||
IReadOnlyList<Genrewert> Genres);
|
||||
|
||||
/// <param name="Start">Der Montag dieser Woche.</param>
|
||||
public sealed record Wochenwert(DateOnly Start, double Seconds);
|
||||
|
||||
public sealed record Zuletztgesehen(
|
||||
Guid Id, string Kind, string Title, string? SubTitle,
|
||||
string? PosterImage, DateTimeOffset At, bool Watched);
|
||||
|
||||
public sealed record Genrewert(string Name, double Seconds);
|
||||
|
||||
/// <summary>Eine laufende Wiedergabe — für das Dashboard.</summary>
|
||||
public sealed record SessionResponse(
|
||||
string UserName, string DeviceName,
|
||||
|
||||
@@ -66,6 +66,10 @@ public static class PlaybackEndpoints
|
||||
var ergebnis = ProgressRule.Apply(
|
||||
TimeSpan.FromSeconds(anfrage.PositionSeconds), laenge, regeln);
|
||||
|
||||
// **Erst zählen, dann überschreiben.** Der Zuwachs ergibt sich aus
|
||||
// der alten Stelle; eine Zeile später gibt es die nicht mehr.
|
||||
await Mitzaehlen(db, benutzer, zustand, ergebnis.Position, clock);
|
||||
|
||||
// **Das Häkchen wird gesetzt, nicht genommen.** Wer einen
|
||||
// gesehenen Film erneut anfängt, hat ihn trotzdem gesehen — und
|
||||
// soll beides haben: das Häkchen und die neue Stelle.
|
||||
@@ -524,6 +528,74 @@ public static class PlaybackEndpoints
|
||||
/// <see cref="UserItemState.InProgress"/> schreibt — gäbe es eine zweite,
|
||||
/// wäre die Spalte wieder ein zweites Urteil statt eines festgehaltenen.
|
||||
/// </remarks>
|
||||
/// <summary>Schreibt die tatsächlich gesehene Zeit ins Protokoll.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Der Zuwachs, gedeckelt auf die verstrichene Zeit.</b> Zwischen zwei
|
||||
/// Meldungen liegen zehn Sekunden; wer in dieser Zeit von Minute 5 auf
|
||||
/// Minute 90 springt, hat nicht 85 Minuten gesehen. Ohne Deckel erfände
|
||||
/// jedes Vorspulen Sehzeit — und ein Film, den man einmal durchgespult
|
||||
/// hat, stünde in der Statistik wie zweimal gesehen.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Das Doppelte als Toleranz</b>, nicht genau die verstrichene Zeit:
|
||||
/// eine ausgefallene Meldung, ein kurzer Aussetzer im Netz, doppelte
|
||||
/// Abspielgeschwindigkeit — all das ist echtes Schauen und soll zählen.
|
||||
/// Erst darüber wird gekappt.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Rückwärts zählt nichts.</b> Wer zurückspringt und dieselbe Stelle
|
||||
/// noch einmal sieht, bekommt sie nicht abgezogen und auch nicht doppelt
|
||||
/// gutgeschrieben: der Zuwachs ist dann null, bis er die alte Stelle
|
||||
/// wieder erreicht. Das ist die freundlichere Ungenauigkeit von beiden.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static async Task Mitzaehlen(
|
||||
FlaroContext db, Guid benutzer, UserItemState zustand,
|
||||
TimeSpan neueStelle, TimeProvider clock)
|
||||
{
|
||||
if (zustand.MovieId is null && zustand.EpisodeId is null) return;
|
||||
|
||||
var jetzt = clock.GetUtcNow();
|
||||
var verstrichen = jetzt - zustand.UpdatedAt;
|
||||
var zuwachs = (neueStelle - zustand.Position).TotalSeconds;
|
||||
|
||||
// **Ohne verstrichene Zeit gibt es nichts gutzuschreiben.** Das ist
|
||||
// die erste Meldung einer Zeile — sie legt den Nullpunkt, mehr nicht.
|
||||
// Stünde hier stattdessen „kein Deckel", zählte eine frisch angelegte
|
||||
// Zeile ihre Anfangsstelle voll mit: wer einen Film öffnet und sofort
|
||||
// auf Minute fünf springt, hätte fünf Minuten gesehen.
|
||||
if (zuwachs <= 0 || verstrichen <= TimeSpan.Zero) return;
|
||||
|
||||
zuwachs = Math.Min(zuwachs, verstrichen.TotalSeconds * 2);
|
||||
if (zuwachs <= 0) return;
|
||||
|
||||
// Ortszeit des Servers: wer um halb eins zu Ende sieht, hat das am
|
||||
// Abend getan.
|
||||
var tag = DateOnly.FromDateTime(jetzt.ToLocalTime().DateTime);
|
||||
|
||||
var zeile = await db.Sehtage.FirstOrDefaultAsync(z =>
|
||||
z.UserId == benutzer && z.Tag == tag
|
||||
&& z.MovieId == zustand.MovieId && z.EpisodeId == zustand.EpisodeId);
|
||||
|
||||
if (zeile is null)
|
||||
{
|
||||
db.Sehtage.Add(new Sehtag
|
||||
{
|
||||
UserId = benutzer,
|
||||
MovieId = zustand.MovieId,
|
||||
EpisodeId = zustand.EpisodeId,
|
||||
Tag = tag,
|
||||
Sekunden = zuwachs,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
zeile.Sekunden += zuwachs;
|
||||
}
|
||||
}
|
||||
|
||||
private static void Nachfuehren(UserItemState zustand, Wiedergaberegeln regeln) =>
|
||||
zustand.InProgress = ProgressRule.BelongsInContinueWatching(zustand, regeln);
|
||||
|
||||
|
||||
@@ -92,6 +92,184 @@ public static class ProfilEndpoints
|
||||
await db.SaveChangesAsync(ct);
|
||||
return Results.NoContent();
|
||||
});
|
||||
|
||||
MapStatistik(gruppe);
|
||||
}
|
||||
|
||||
/// <summary>Was jemand geschaut hat.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Jeder nur sich selbst, auch ein Administrator.</b> Das ist keine
|
||||
/// Erfindung für diese Seite, sondern die Linie, die im Server schon
|
||||
/// steht: wer wann welchen Film anschaut, ist eine Auskunft über Menschen
|
||||
/// und nicht über den Server. Es gibt deshalb keinen Weg, ein fremdes
|
||||
/// Konto zu meinen — die Kennung kommt aus dem Token.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Wochen beginnen am Montag.</b> Nicht am Sonntag und nicht am Tag der
|
||||
/// Anfrage: eine Kurve, deren Balken je nach Abfragezeitpunkt anders
|
||||
/// zusammenfallen, lässt sich mit sich selbst nicht vergleichen.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static void MapStatistik(RouteGroupBuilder gruppe)
|
||||
{
|
||||
gruppe.MapGet("/statistics", async (
|
||||
ClaimsPrincipal wer, FlaroContext db, TimeProvider clock,
|
||||
int wochen = 12, CancellationToken ct = default) =>
|
||||
{
|
||||
if (Kennung(wer) is not { } id) return Results.Unauthorized();
|
||||
|
||||
wochen = Math.Clamp(wochen, 1, 104);
|
||||
var heute = DateOnly.FromDateTime(clock.GetUtcNow().ToLocalTime().DateTime);
|
||||
var ersterMontag = Montag(heute).AddDays(-7 * (wochen - 1));
|
||||
|
||||
// ---- Das Protokoll ----
|
||||
|
||||
var tage = await db.Sehtage.AsNoTracking()
|
||||
.Where(z => z.UserId == id)
|
||||
.Select(z => new { z.Tag, z.Sekunden, z.MovieId, z.EpisodeId })
|
||||
.ToListAsync(ct);
|
||||
|
||||
var gesamt = tage.Sum(z => z.Sekunden);
|
||||
var seit = tage.Count == 0 ? (DateTimeOffset?)null
|
||||
: new DateTimeOffset(tage.Min(z => z.Tag).ToDateTime(TimeOnly.MinValue), TimeSpan.Zero);
|
||||
|
||||
var jeWoche = tage
|
||||
.Where(z => z.Tag >= ersterMontag)
|
||||
.GroupBy(z => Montag(z.Tag))
|
||||
.ToDictionary(g => g.Key, g => g.Sum(x => x.Sekunden));
|
||||
|
||||
var kurve = Enumerable.Range(0, wochen)
|
||||
.Select(n => ersterMontag.AddDays(7 * n))
|
||||
.Select(montag => new Wochenwert(montag, jeWoche.GetValueOrDefault(montag)))
|
||||
.ToList();
|
||||
|
||||
// ---- Was ohne Protokoll vollständig ist ----
|
||||
|
||||
var staende = await db.UserItemStates.AsNoTracking()
|
||||
.Where(z => z.UserId == id && (z.MovieId != null || z.EpisodeId != null))
|
||||
.Select(z => new
|
||||
{
|
||||
z.MovieId, z.EpisodeId, z.Watched, z.LastPlayedAt,
|
||||
Laufzeit = z.Runtime,
|
||||
})
|
||||
.ToListAsync(ct);
|
||||
|
||||
var gesehene = staende.Where(z => z.Watched).ToList();
|
||||
|
||||
// ---- Zuletzt gesehen ----
|
||||
|
||||
var letzte = staende
|
||||
.Where(z => z.LastPlayedAt != null)
|
||||
.OrderByDescending(z => z.LastPlayedAt)
|
||||
.Take(12)
|
||||
.ToList();
|
||||
|
||||
var filmIds = letzte.Where(z => z.MovieId != null).Select(z => z.MovieId!.Value).ToList();
|
||||
var folgenIds = letzte.Where(z => z.EpisodeId != null).Select(z => z.EpisodeId!.Value).ToList();
|
||||
|
||||
var filme = await db.Movies.AsNoTracking().Where(m => filmIds.Contains(m.Id))
|
||||
.Select(m => new { m.Id, m.Title, m.PosterImage }).ToDictionaryAsync(m => m.Id, ct);
|
||||
|
||||
var folgen = await db.Episodes.AsNoTracking().Where(f => folgenIds.Contains(f.Id))
|
||||
.Join(db.Series.AsNoTracking(), f => f.SeriesId, s => s.Id, (f, s) => new
|
||||
{
|
||||
f.Id, Folgentitel = f.Title, f.SeasonNumber, f.EpisodeNumber,
|
||||
Serientitel = s.Title, s.PosterImage, SerienId = s.Id,
|
||||
})
|
||||
.ToDictionaryAsync(f => f.Id, ct);
|
||||
|
||||
var zuletzt = new List<Zuletztgesehen>();
|
||||
foreach (var z in letzte)
|
||||
{
|
||||
if (z.MovieId is { } film && filme.TryGetValue(film, out var m))
|
||||
{
|
||||
zuletzt.Add(new Zuletztgesehen(
|
||||
m.Id, "movie", m.Title, null, m.PosterImage, z.LastPlayedAt!.Value, z.Watched));
|
||||
}
|
||||
else if (z.EpisodeId is { } folge && folgen.TryGetValue(folge, out var f))
|
||||
{
|
||||
zuletzt.Add(new Zuletztgesehen(
|
||||
f.SerienId, "episode", f.Serientitel,
|
||||
$"S{f.SeasonNumber} · F{f.EpisodeNumber}" +
|
||||
(f.Folgentitel is { Length: > 0 } t ? $" — {t}" : ""),
|
||||
f.PosterImage, z.LastPlayedAt!.Value, z.Watched));
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Genres, aus dem Protokoll ----
|
||||
|
||||
var genres = await GenresAus(db, tage.Select(t => (t.MovieId, t.EpisodeId, t.Sekunden)), ct);
|
||||
|
||||
return Results.Ok(new StatistikResponse(
|
||||
gesamt,
|
||||
gesehene.Count,
|
||||
gesehene.Sum(z => z.Laufzeit?.TotalSeconds ?? 0),
|
||||
seit,
|
||||
kurve,
|
||||
zuletzt,
|
||||
genres));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Der Montag der Woche, in der dieser Tag liegt.</summary>
|
||||
private static DateOnly Montag(DateOnly tag) =>
|
||||
tag.AddDays(-((int)tag.DayOfWeek + 6) % 7);
|
||||
|
||||
/// <summary>Welche Genres die gesehene Zeit ausmachen.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// Eine Folge hat keine Genres — ihre Serie hat sie. Deshalb wird jede
|
||||
/// Folge erst zu ihrer Serie aufgelöst; sonst stünden Serien gar nicht in
|
||||
/// der Auswertung, obwohl sie den grösseren Teil der Zeit ausmachen.
|
||||
/// </remarks>
|
||||
private static async Task<List<Genrewert>> GenresAus(
|
||||
FlaroContext db,
|
||||
IEnumerable<(Guid? Film, Guid? Folge, double Sekunden)> zeilen,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var liste = zeilen.ToList();
|
||||
if (liste.Count == 0) return [];
|
||||
|
||||
var folgenIds = liste.Where(z => z.Folge != null).Select(z => z.Folge!.Value).Distinct().ToList();
|
||||
var serieZuFolge = await db.Episodes.AsNoTracking()
|
||||
.Where(f => folgenIds.Contains(f.Id))
|
||||
.ToDictionaryAsync(f => f.Id, f => f.SeriesId, ct);
|
||||
|
||||
// Sekunden je Titel zusammenziehen, bevor die Genres dazukommen —
|
||||
// sonst wird derselbe Tag so oft gezählt, wie der Titel Genres hat.
|
||||
var jeFilm = new Dictionary<Guid, double>();
|
||||
var jeSerie = new Dictionary<Guid, double>();
|
||||
foreach (var (film, folge, sekunden) in liste)
|
||||
{
|
||||
if (film is { } f) jeFilm[f] = jeFilm.GetValueOrDefault(f) + sekunden;
|
||||
else if (folge is { } e && serieZuFolge.TryGetValue(e, out var serie))
|
||||
jeSerie[serie] = jeSerie.GetValueOrDefault(serie) + sekunden;
|
||||
}
|
||||
|
||||
var filmIds = jeFilm.Keys.ToList();
|
||||
var serienIds = jeSerie.Keys.ToList();
|
||||
|
||||
var verweise = await db.GenreLinks.AsNoTracking()
|
||||
.Where(l => (l.MovieId != null && filmIds.Contains(l.MovieId!.Value))
|
||||
|| (l.SeriesId != null && serienIds.Contains(l.SeriesId!.Value)))
|
||||
.Join(db.Genres.AsNoTracking(), l => l.GenreId, g => g.Id,
|
||||
(l, g) => new { l.MovieId, l.SeriesId, g.Name })
|
||||
.ToListAsync(ct);
|
||||
|
||||
var summen = new Dictionary<string, double>();
|
||||
foreach (var v in verweise)
|
||||
{
|
||||
var sekunden = v.MovieId is { } f ? jeFilm.GetValueOrDefault(f)
|
||||
: v.SeriesId is { } s ? jeSerie.GetValueOrDefault(s)
|
||||
: 0;
|
||||
if (sekunden <= 0) continue;
|
||||
summen[v.Name] = summen.GetValueOrDefault(v.Name) + sekunden;
|
||||
}
|
||||
|
||||
return summen.OrderByDescending(p => p.Value).Take(6)
|
||||
.Select(p => new Genrewert(p.Key, p.Value)).ToList();
|
||||
}
|
||||
|
||||
/// <summary>Wer anfragt — aus dem Token, nie aus der Adresse.</summary>
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
namespace Flaro.Core.Playback;
|
||||
|
||||
/// <summary>Wie lange an einem Tag an einem Titel gesehen wurde.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Warum es das überhaupt gibt.</b> <see cref="UserItemState"/> ist eine
|
||||
/// Zeile je Titel und Nutzer — der <i>letzte</i> 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Warum Tagesbündel und keine Sitzungen.</b> Eine Sitzungstabelle braucht
|
||||
/// Anfang <i>und</i> 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Es fängt bei null an.</b> 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 <see cref="UserItemState"/>:
|
||||
/// gesehen ja/nein, wie oft, wann zuletzt.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class Sehtag
|
||||
{
|
||||
public Guid Id { get; init; } = Guid.CreateVersion7();
|
||||
|
||||
public required Guid UserId { get; init; }
|
||||
|
||||
/// <summary>Entweder ein Film …</summary>
|
||||
public Guid? MovieId { get; init; }
|
||||
|
||||
/// <summary>… oder eine Folge. Nie beides, nie keines.</summary>
|
||||
public Guid? EpisodeId { get; init; }
|
||||
|
||||
/// <summary>Der Tag in der Zeitzone des Servers.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Ortszeit, nicht UTC.</b> 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.
|
||||
/// </remarks>
|
||||
public required DateOnly Tag { get; init; }
|
||||
|
||||
/// <summary>Wie viele Sekunden an diesem Tag tatsächlich liefen.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Tatsächlich, nicht übersprungen.</b> 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.
|
||||
/// </remarks>
|
||||
public double Sekunden { get; set; }
|
||||
}
|
||||
@@ -58,6 +58,9 @@ public sealed class FlaroContext(DbContextOptions<FlaroContext> options) : DbCon
|
||||
public DbSet<Library> Libraries => Set<Library>();
|
||||
public DbSet<ServerSetting> Settings => Set<ServerSetting>();
|
||||
|
||||
/// <summary>Wie lange an welchem Tag gesehen wurde — siehe <see cref="Sehtag"/>.</summary>
|
||||
public DbSet<Sehtag> Sehtage => Set<Sehtag>();
|
||||
|
||||
public DbSet<MediaFile> MediaFiles => Set<MediaFile>();
|
||||
public DbSet<Movie> Movies => Set<Movie>();
|
||||
public DbSet<Series> Series => Set<Series>();
|
||||
@@ -360,6 +363,35 @@ public sealed class FlaroContext(DbContextOptions<FlaroContext> options) : DbCon
|
||||
e.HasOne<Series>().WithMany().HasForeignKey(x => x.SeriesId).OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
b.Entity<Sehtag>(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<User>().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<Movie>().WithMany().HasForeignKey(x => x.MovieId).OnDelete(DeleteBehavior.Cascade);
|
||||
e.HasOne<Episode>().WithMany().HasForeignKey(x => x.EpisodeId).OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
b.Entity<Episode>(e =>
|
||||
{
|
||||
e.HasKey(x => x.Id);
|
||||
|
||||
@@ -0,0 +1,996 @@
|
||||
// <auto-generated />
|
||||
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
|
||||
{
|
||||
/// <inheritdoc />
|
||||
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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid?>("ApprovedByUserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Code")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long?>("ConsumedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("DeviceId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("DeviceName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("ExpiresAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("DeviceId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("DeviceName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("LastSeenAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long?>("RevokedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("TokenFingerprint")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Kind")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Path")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("ReviewNewItems")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Libraries");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Flaro.Core.Library.Credit", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Character")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid?>("MovieId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Order")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid>("PersonId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Role")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid?>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("AddedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("EpisodeNumber")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("MetadataLanguage")
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Overview")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long?>("PublishAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("SeasonNumber")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid>("SeriesId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("StillImage")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("GenreId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid?>("MovieId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid?>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid?>("EpisodeId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("FetchedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Language")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid?>("MovieId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Overview")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid?>("SeriesId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Tagline")
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long?>("BitRate")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Container")
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long?>("Duration")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid?>("EpisodeId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("LibraryId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long?>("MissingSince")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("ModifiedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid?>("MovieId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Path")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long?>("ProbedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("SeenAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("SizeBytes")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("Channels")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Codec")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("Height")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Index")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsDefault")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsForced")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Kind")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Language")
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("MediaFileId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("Width")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("MediaFileId", "Index")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("MediaStreams");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Flaro.Core.Library.Movie", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("AddedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("BackdropImage")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("BackdropPinned")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long?>("ChangedSince")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ContentRating")
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ImdbId")
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("LibraryId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("LogoImage")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("LogoPinned")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("MatchLocked")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("MetadataLanguage")
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("OriginalTitle")
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Overview")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ParsedTitle")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("ParsedYear")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("PosterImage")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("PosterPinned")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long?>("PublishAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<double?>("Rating")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.Property<int?>("RuntimeMinutes")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("SortTitlePinned")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("SortTitleValue")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Tagline")
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("TitlePinned")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int?>("TmdbId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int?>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Image")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("TmdbId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TmdbId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("People");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Flaro.Core.Library.Series", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("AddedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("BackdropImage")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("BackdropPinned")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long?>("ChangedSince")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ContentRating")
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ImdbId")
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("LibraryId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("LogoImage")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("LogoPinned")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("MatchLocked")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("MetadataLanguage")
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("OriginalTitle")
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Overview")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ParsedTitle")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("ParsedYear")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("PosterImage")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("PosterPinned")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long?>("PublishAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<double?>("Rating")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.Property<int?>("RuntimeMinutes")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("SortTitlePinned")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("SortTitleValue")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Tagline")
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("TitlePinned")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int?>("TmdbId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int?>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid?>("EpisodeId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid?>("MovieId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<double>("Sekunden")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.Property<int>("Tag")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("Dismissed")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid?>("EpisodeId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("Favourite")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("InProgress")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long?>("LastPlayedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid?>("MovieId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("PlayCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("Position")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int?>("Rating")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long?>("Runtime")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid?>("SeriesId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("UpdatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("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<string>("Key")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.IsRequired()
|
||||
.HasMaxLength(4096)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Key");
|
||||
|
||||
b.ToTable("Settings");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Flaro.Core.Users.User", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("AvatarImage")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsAdministrator")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Language")
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Flaro.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class Sehprotokoll : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Sehtage",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
UserId = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
MovieId = table.Column<Guid>(type: "TEXT", nullable: true),
|
||||
EpisodeId = table.Column<Guid>(type: "TEXT", nullable: true),
|
||||
Tag = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
Sekunden = table.Column<double>(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");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "Sehtage");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -685,6 +685,46 @@ namespace Flaro.Data.Migrations
|
||||
b.ToTable("Series");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Flaro.Core.Playback.Sehtag", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid?>("EpisodeId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid?>("MovieId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<double>("Sekunden")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.Property<int>("Tag")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid>("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<Guid>("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)
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
using System.Net.Http.Json;
|
||||
using Flaro.Api.Endpoints;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Flaro.Api.Tests;
|
||||
|
||||
/// <summary>Das Sehprotokoll und was sich daraus ablesen lässt.</summary>
|
||||
[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]);
|
||||
}
|
||||
|
||||
/// <summary>Die Uhr des Servers — stellbar, damit Zeit vergehen kann.</summary>
|
||||
private Stelluhr _uhr = new(DateTimeOffset.UtcNow);
|
||||
|
||||
/// <summary>Ein Server, dessen Uhr dieser Test stellt.</summary>
|
||||
private FlaroServer MitUhr()
|
||||
{
|
||||
_uhr = new Stelluhr(DateTimeOffset.UtcNow);
|
||||
return new FlaroServer(dienste => dienste.AddSingleton<TimeProvider>(_uhr));
|
||||
}
|
||||
|
||||
private async Task<HttpClient> 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<LibraryResponse>())!;
|
||||
|
||||
(await client.PostAsync($"/api/v1/libraries/{bibliothek.Id}/scan", null)).EnsureSuccessStatusCode();
|
||||
for (var versuch = 0; versuch < 100; versuch++)
|
||||
{
|
||||
var stand = await client.GetFromJsonAsync<ScanStatus>($"/api/v1/libraries/{bibliothek.Id}/scan");
|
||||
if (stand!.State is "finished") 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<Guid> ErsterFilm(HttpClient client)
|
||||
{
|
||||
var seite = await client.GetFromJsonAsync<Page<MovieResponse>>("/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<StatistikResponse?> Statistik(HttpClient client) =>
|
||||
client.GetFromJsonAsync<StatistikResponse>("/api/v1/users/me/statistics");
|
||||
|
||||
/// <summary>Geschaute Zeit landet im Protokoll.</summary>
|
||||
[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");
|
||||
}
|
||||
|
||||
/// <summary>Vorspulen ist kein Schauen.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Der Grund für den Deckel.</b> Zwischen zwei Meldungen liegen zehn
|
||||
/// Sekunden. Wer in dieser Zeit von Minute 5 auf Minute 90 springt, hat
|
||||
/// nicht 85 Minuten gesehen — ohne Deckel stünde ein einmal
|
||||
/// durchgespulter Film in der Statistik wie zweimal angeschaut.
|
||||
/// </remarks>
|
||||
[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");
|
||||
}
|
||||
|
||||
/// <summary>Zurückspringen zieht nichts ab.</summary>
|
||||
[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));
|
||||
}
|
||||
|
||||
/// <summary>Die Wochenkurve hat so viele Punkte wie verlangt.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// Auch leere Wochen stehen darin. Eine Kurve, die nur die Wochen zeigt,
|
||||
/// in denen etwas lief, behauptet eine Gleichmässigkeit, die es nicht gab.
|
||||
/// </remarks>
|
||||
[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<StatistikResponse>(
|
||||
"/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));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Was ohne Protokoll vollständig ist, steht auch da.</summary>
|
||||
[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");
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Jeder sieht nur seine eigenen Zahlen.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Auch der Administrator.</b> 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.
|
||||
/// </remarks>
|
||||
[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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
namespace Flaro.Api.Tests;
|
||||
|
||||
/// <summary>Eine Uhr, die man weiterstellt, statt zu warten.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Weil manche Regeln an der verstrichenen Zeit hängen.</b> 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.
|
||||
/// </remarks>
|
||||
public sealed class Stelluhr(DateTimeOffset start) : TimeProvider
|
||||
{
|
||||
private DateTimeOffset _jetzt = start;
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _jetzt;
|
||||
|
||||
/// <summary>Lässt Zeit vergehen.</summary>
|
||||
public void Weiter(TimeSpan spanne)
|
||||
{
|
||||
if (spanne < TimeSpan.Zero) throw new ArgumentOutOfRangeException(nameof(spanne));
|
||||
_jetzt += spanne;
|
||||
}
|
||||
}
|
||||
@@ -853,6 +853,27 @@ export const wiedergabeSetzen = (werte: Partial<Wiedergabeeinstellung>) =>
|
||||
}),
|
||||
});
|
||||
|
||||
/** 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<Statistik>(`/users/me/statistics${abfrage({ wochen })}`);
|
||||
|
||||
export type Speicherort = {
|
||||
name: string; variable: string; pfad: string;
|
||||
belegt: number; frei: number | null; bilder: number | null;
|
||||
|
||||
@@ -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
|
||||
|
||||
+139
-2
@@ -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<void> {
|
||||
ziel.innerHTML = '<div class="spinner"></div>';
|
||||
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 = `<span class="genre-name">${esc(g.name)}</span>`
|
||||
+ `<span class="genre-balken"><i style="width:${Math.round((g.seconds / groesste) * 100)}%"></i></span>`
|
||||
+ `<span class="genre-wert">${esc(stunden(g.seconds))}</span>`;
|
||||
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 ? `<img src="${adresse}" alt="">` : '<span class="platzhalter"></span>')
|
||||
+ `<span class="zuletzt-text"><strong>${esc(e.title)}</strong>`
|
||||
+ (e.subTitle ? `<span class="winzig">${esc(e.subTitle)}</span>` : '')
|
||||
+ `</span>`
|
||||
+ `<span class="winzig">${esc(new Date(e.at).toLocaleDateString('de-CH', { dateStyle: 'medium' }))}</span>`;
|
||||
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 = `<strong>${esc(wert)}</strong><span>${esc(was)}</span>`
|
||||
+ `<span class="winzig">${esc(hinweis)}</span>`;
|
||||
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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user