diff --git a/server/src/Flaro.Api/Endpoints/Contracts.cs b/server/src/Flaro.Api/Endpoints/Contracts.cs index 9487870..2eab31e 100644 --- a/server/src/Flaro.Api/Endpoints/Contracts.cs +++ b/server/src/Flaro.Api/Endpoints/Contracts.cs @@ -749,3 +749,46 @@ public sealed record SessionResponse( /// Was der Server gerade tut. public sealed record ServerStatusResponse( int Playing, int Packaging, int Scanning, double Uptime); + +/* ---------- Meldungen ---------- */ + +/// Was jemand an einem Titel gemeldet hat. +public sealed record MeldungRequest( + /// bild, ton, untertitel, daten oder sonstiges. + string Kind, + string Comment, + /// Eine Stelle wie 0:34:12 — oder nichts. + string? Position = null, + /// Nur bei einer Serie: eine einzelne Staffel statt der ganzen. + int? SeasonNumber = null); + +/// Eine Meldung, wie sie gelesen wird. +/// +/// +/// ItemId ist die Kennung der Detailseite, nicht die des +/// Gemeldeten. Bei einer Folge und bei einer Staffel steht hier die Serie — +/// die Detailseite gehört ihr, wie bei „Weiterschauen" auch. Was genau gemeint +/// ist, steht in Target und SubTitle. +/// +/// Title ist leer, wenn der Titel nicht mehr sichtbar ist. Ein +/// Administrator kann einen Titel zurückziehen, nachdem jemand ihn gemeldet +/// hat; die Meldung bleibt dann in der eigenen Liste des Melders stehen, nur +/// ohne Namen. Sie verschwinden zu lassen hiesse, jemandem seine eigene Eingabe +/// wegen einer fremden Entscheidung wegzunehmen. +/// +public sealed record Meldungseintrag( + Guid Id, + /// movie, series, season oder episode. + string Target, + Guid ItemId, + string Title, + /// „Staffel 2" oder „S2 · F5 — Der Pakt" — sonst null. + string? SubTitle, + string? PosterImage, + string Kind, + double? PositionSeconds, + string Comment, + DateTimeOffset ReportedAt, + string ReportedBy, + /// null heisst: offen. + DateTimeOffset? ResolvedAt); diff --git a/server/src/Flaro.Api/Endpoints/MeldungEndpoints.cs b/server/src/Flaro.Api/Endpoints/MeldungEndpoints.cs new file mode 100644 index 0000000..aced4a0 --- /dev/null +++ b/server/src/Flaro.Api/Endpoints/MeldungEndpoints.cs @@ -0,0 +1,308 @@ +using System.Security.Claims; +using Flaro.Core.Library; +using Flaro.Data; +using Microsoft.EntityFrameworkCore; + +namespace Flaro.Api.Endpoints; + +/// Melden, was an einem Titel nicht stimmt — und es abarbeiten. +/// +/// +/// +/// Warum es das gibt. Wer einen Server für andere betreibt, erfährt von +/// Fehlern zuletzt: dass die deutsche Tonspur nach einer halben Stunde +/// aussetzt, merkt der, der den Film sieht, und nicht der, der ihn eingeräumt +/// hat. Bisher gab es dafür keinen Weg ausser dem Zuruf im Wohnzimmer. +/// +/// +/// Melden darf jeder, lesen nur die Verwaltung. Die eine Ausnahme sind +/// die eigenen Meldungen: wer etwas gemeldet hat, soll nachsehen können, ob +/// etwas daraus geworden ist, ohne zu fragen. Das ist dieselbe Linie wie beim +/// Sehprotokoll — eigene Daten ja, fremde nein, auch als Administrator nicht +/// über diesen Weg. +/// +/// +public static class MeldungEndpoints +{ + private const int HoechstensZeichen = 2000; + + /// Wie viele Meldungen eine Liste höchstens zurückgibt. + /// + /// + /// Die offene Liste ist eine Warteschlange. Wer fünfhundert offene + /// Meldungen hat, hat ein anderes Problem als das, das dieser Bildschirm + /// löst. + /// + private const int HoechstensZeilen = 500; + + public static void MapMeldungen(this IEndpointRouteBuilder app) + { + // **Am selben Präfix wie alles, was eine undurchsichtige Titelkennung + // annimmt.** Gesehen, Favorit, Weglegen und Fortschritt liegen auch + // dort; der Client muss dadurch nicht wissen, ob er einen Film, eine + // Serie oder eine Folge in der Hand hält. + var melden = app.MapGroup("/api/v1/items").RequireAuthorization(); + + melden.MapPost("/{id:guid}/reports", async ( + Guid id, MeldungRequest anfrage, ClaimsPrincipal wer, + FlaroContext db, TimeProvider uhr, CancellationToken ct) => + { + if (Kennung(wer) is not { } benutzer) return Results.Unauthorized(); + + var kommentar = anfrage.Comment?.Trim() ?? ""; + if (kommentar.Length == 0) + return Results.BadRequest(new { fehler = "Ohne Beschreibung lässt sich nichts nachsehen." }); + if (kommentar.Length > HoechstensZeichen) + return Results.BadRequest(new { fehler = $"Höchstens {HoechstensZeichen} Zeichen." }); + + if (AlsArt(anfrage.Kind) is not { } art) + { + return Results.BadRequest(new + { + fehler = "Unbekannte Art. Möglich sind: bild, ton, untertitel, daten, sonstiges.", + }); + } + + if (!AlsStelle(anfrage.Position, out var stelle)) + return Results.BadRequest(new { fehler = "Die Stelle muss wie 0:34:12 aussehen." }); + + // **Der Freigabefilter erledigt die Rechteprüfung.** Wer einen + // Titel nicht sehen darf, findet ihn hier nicht — ohne ein + // einziges `Where` und ohne eine Stelle, die man zu pflegen + // vergessen kann. + Guid? film = null, serie = null, folge = null; + if (await db.Movies.AnyAsync(m => m.Id == id, ct)) film = id; + else if (await db.Episodes.AnyAsync(f => f.Id == id, ct)) folge = id; + else if (await db.Series.AnyAsync(s => s.Id == id, ct)) serie = id; + else return Results.BadRequest(new { fehler = "Diesen Titel gibt es nicht." }); + + int? staffel = null; + if (anfrage.SeasonNumber is { } nummer) + { + if (serie is null) + return Results.BadRequest(new { fehler = "Eine Staffel gibt es nur zu einer Serie." }); + if (!await db.Episodes.AnyAsync(f => f.SeriesId == serie && f.SeasonNumber == nummer, ct)) + return Results.BadRequest(new { fehler = "Diese Staffel gibt es in der Serie nicht." }); + staffel = nummer; + } + + var meldung = new Meldung + { + UserId = benutzer, + MovieId = film, + SeriesId = serie, + EpisodeId = folge, + SeasonNumber = staffel, + Art = art, + Stelle = stelle, + Kommentar = kommentar, + GemeldetAm = uhr.GetUtcNow(), + }; + + db.Meldungen.Add(meldung); + await db.SaveChangesAsync(ct); + return Results.Ok(new { id = meldung.Id }); + }); + + // **Die eigenen — jeder nur sich selbst, auch ein Administrator.** + // Steht vor der Verwaltungsgruppe, damit `mine` nicht als Kennung + // gelesen wird; die Verwaltungsrouten tragen zusätzlich `:guid`. + app.MapGroup("/api/v1/reports/mine").RequireAuthorization() + .MapGet("", async (ClaimsPrincipal wer, FlaroContext db, CancellationToken ct) => + { + if (Kennung(wer) is not { } benutzer) return Results.Unauthorized(); + + var roh = await db.Meldungen.AsNoTracking() + .Where(m => m.UserId == benutzer) + .OrderByDescending(m => m.GemeldetAm) + .Take(HoechstensZeilen) + .ToListAsync(ct); + + return Results.Ok(await Anreichern(db, roh, ct)); + }); + + var verwalten = app.MapGroup("/api/v1/reports") + .RequireAuthorization(FlaroPolicies.Administrator); + + verwalten.MapGet("", async ( + FlaroContext db, CancellationToken ct, bool erledigt = false) => + { + // **Offene aufsteigend, erledigte absteigend.** Das eine ist eine + // Warteschlange — dort gehört das Älteste nach oben. Das andere + // ist ein Archiv, und darin sucht man das Letzte. + var abfrage = db.Meldungen.AsNoTracking(); + var roh = erledigt + ? await abfrage.Where(m => m.ErledigtAm != null) + .OrderByDescending(m => m.ErledigtAm).Take(HoechstensZeilen).ToListAsync(ct) + : await abfrage.Where(m => m.ErledigtAm == null) + .OrderBy(m => m.GemeldetAm).Take(HoechstensZeilen).ToListAsync(ct); + + return Results.Ok(await Anreichern(db, roh, ct)); + }); + + // **Abhaken, nicht löschen.** Wiederholt sich ein Problem, ist die + // Vorgeschichte die halbe Antwort. Zweimal abhaken ist kein Fehler, + // sondern zweimal dasselbe Ergebnis. + verwalten.MapPost("/{id:guid}/resolve", (Guid id, ClaimsPrincipal wer, + FlaroContext db, TimeProvider uhr, CancellationToken ct) => + Umschalten(id, wer, db, ct, uhr.GetUtcNow())); + + verwalten.MapPost("/{id:guid}/reopen", (Guid id, ClaimsPrincipal wer, + FlaroContext db, CancellationToken ct) => + Umschalten(id, wer, db, ct, null)); + } + + private static async Task Umschalten( + Guid id, ClaimsPrincipal wer, FlaroContext db, CancellationToken ct, DateTimeOffset? wann) + { + var meldung = await db.Meldungen.FirstOrDefaultAsync(m => m.Id == id, ct); + if (meldung is null) + return Results.NotFound(new { fehler = "Diese Meldung gibt es nicht mehr." }); + + meldung.ErledigtAm = wann; + meldung.ErledigtVon = wann is null ? null : Kennung(wer); + await db.SaveChangesAsync(ct); + return Results.NoContent(); + } + + /// Hängt Titel, Plakat und Melder an die nackten Zeilen. + /// + /// + /// Nachgeschlagen, nicht verbunden. Ein Join würde eine Meldung + /// verschwinden lassen, sobald ihr Titel für den Fragenden unsichtbar wird + /// — und das trifft ausgerechnet die eigene Liste des Melders, wenn ein + /// Administrator den Titel zurückzieht. Hier bleibt die Zeile stehen und + /// der Name fehlt. + /// + /// Und ohne IgnoreQueryFilters. Das wäre die bequeme Lösung + /// und gäbe den Namen eines unveröffentlichten Titels über einen Weg + /// heraus, an den beim nächsten Mal niemand denkt. + /// + private static async Task> Anreichern( + FlaroContext db, List roh, CancellationToken ct) + { + var filmIds = roh.Where(m => m.MovieId != null).Select(m => m.MovieId!.Value).Distinct().ToList(); + var serienIds = roh.Where(m => m.SeriesId != null).Select(m => m.SeriesId!.Value).Distinct().ToList(); + var folgenIds = roh.Where(m => m.EpisodeId != null).Select(m => m.EpisodeId!.Value).Distinct().ToList(); + var leuteIds = roh.Select(m => m.UserId).Distinct().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 serien = await db.Series.AsNoTracking().Where(s => serienIds.Contains(s.Id)) + .Select(s => new { s.Id, s.Title, s.PosterImage }).ToDictionaryAsync(s => s.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 leute = await db.Users.AsNoTracking().Where(u => leuteIds.Contains(u.Id)) + .Select(u => new { u.Id, u.UserName }).ToDictionaryAsync(u => u.Id, ct); + + var fertig = new List(roh.Count); + foreach (var m in roh) + { + var melder = leute.TryGetValue(m.UserId, out var u) ? u.UserName : ""; + var gemeinsam = ( + Kind: AlsWort(m.Art), + Stelle: m.Stelle?.TotalSeconds, + m.Kommentar, m.GemeldetAm, m.ErledigtAm); + + if (m.MovieId is { } film) + { + filme.TryGetValue(film, out var f); + fertig.Add(new Meldungseintrag( + m.Id, "movie", film, f?.Title ?? "", null, f?.PosterImage, + gemeinsam.Kind, gemeinsam.Stelle, gemeinsam.Kommentar, + gemeinsam.GemeldetAm, melder, gemeinsam.ErledigtAm)); + } + else if (m.EpisodeId is { } folge) + { + folgen.TryGetValue(folge, out var f); + fertig.Add(new Meldungseintrag( + m.Id, "episode", f?.SerienId ?? folge, f?.Serientitel ?? "", + f is null ? null + : $"S{f.SeasonNumber} · F{f.EpisodeNumber}" + + (f.Folgentitel is { Length: > 0 } t ? $" — {t}" : ""), + f?.PosterImage, + gemeinsam.Kind, gemeinsam.Stelle, gemeinsam.Kommentar, + gemeinsam.GemeldetAm, melder, gemeinsam.ErledigtAm)); + } + else if (m.SeriesId is { } serie) + { + serien.TryGetValue(serie, out var s); + fertig.Add(new Meldungseintrag( + m.Id, m.SeasonNumber is null ? "series" : "season", serie, + s?.Title ?? "", + m.SeasonNumber is { } nummer ? $"Staffel {nummer}" : null, + s?.PosterImage, + gemeinsam.Kind, gemeinsam.Stelle, gemeinsam.Kommentar, + gemeinsam.GemeldetAm, melder, gemeinsam.ErledigtAm)); + } + } + return fertig; + } + + private static Guid? Kennung(ClaimsPrincipal wer) => + Guid.TryParse(wer.FindFirstValue(ClaimTypes.NameIdentifier), out var id) ? id : null; + + private static Meldungsart? AlsArt(string? wert) => wert?.Trim().ToLowerInvariant() switch + { + "bild" => Meldungsart.Bild, + "ton" => Meldungsart.Ton, + "untertitel" => Meldungsart.Untertitel, + "daten" => Meldungsart.Daten, + "sonstiges" => Meldungsart.Sonstiges, + _ => null, + }; + + /// Das Wort, das über die Leitung geht — nicht der Name im Code. + /// + /// + /// Von Hand und nicht über ToString(): sonst entschiede ein + /// Umbenennen im C# über das Format, das der Client liest. + /// + private static string AlsWort(Meldungsart art) => art switch + { + Meldungsart.Bild => "bild", + Meldungsart.Ton => "ton", + Meldungsart.Untertitel => "untertitel", + Meldungsart.Daten => "daten", + _ => "sonstiges", + }; + + /// „1:02:03", „2:03" oder „123" — genau die Formen, die der Client schreibt. + /// + /// + /// Nicht TimeSpan.Parse: der liest „2:03" als zwei Stunden und drei + /// Minuten. Wer in ein Feld neben einem Film „2:03" schreibt, meint zwei + /// Minuten — so steht es auch unter jedem Fortschrittsbalken. + /// + private static bool AlsStelle(string? text, out TimeSpan? wert) + { + wert = null; + var roh = text?.Trim() ?? ""; + if (roh.Length == 0) return true; + + var teile = roh.Split(':'); + if (teile.Length > 3) return false; + + var zahlen = new List(); + foreach (var teil in teile) + { + if (!int.TryParse(teil, out var zahl) || zahl < 0) return false; + zahlen.Add(zahl); + } + + // Alles ausser dem vordersten Glied zählt in Sechzigern. + if (zahlen.Skip(1).Any(z => z > 59)) return false; + + var sekunden = zahlen.Aggregate(0L, (summe, zahl) => summe * 60 + zahl); + wert = TimeSpan.FromSeconds(sekunden); + return true; + } +} diff --git a/server/src/Flaro.Api/Program.cs b/server/src/Flaro.Api/Program.cs index 4fa9635..8ab5824 100644 --- a/server/src/Flaro.Api/Program.cs +++ b/server/src/Flaro.Api/Program.cs @@ -271,6 +271,7 @@ app.MapMatching(); app.MapArtwork(); app.MapProfil(); app.MapFreigabe(); +app.MapMeldungen(); // **Was unter /api nicht beantwortet wurde, ist nicht da.** Ohne diese Zeile // fängt der Rückfall darunter jeden unbekannten Pfad ab und liefert die diff --git a/server/src/Flaro.Core/Library/Meldung.cs b/server/src/Flaro.Core/Library/Meldung.cs new file mode 100644 index 0000000..ea62149 --- /dev/null +++ b/server/src/Flaro.Core/Library/Meldung.cs @@ -0,0 +1,86 @@ +namespace Flaro.Core.Library; + +/// Worum es bei einer Meldung geht. +/// +/// +/// Fünf Fächer, nicht fünfzig. Sie sind nicht dazu da, das Problem zu +/// beschreiben — das tut der Kommentar —, sondern dazu, eine Liste sortierbar +/// zu machen. „Dreimal Ton an derselben Serie" ist eine Auskunft, die man +/// sieht, ohne zu lesen. +/// +public enum Meldungsart +{ + Bild, + Ton, + Untertitel, + + /// Titel, Jahr, Plakat, Zuordnung — alles, was nicht im Bild ist. + Daten, + + Sonstiges, +} + +/// Was jemandem an einem Titel aufgefallen ist. +/// +/// +/// +/// Vier Ziele, drei Spalten. Film, Serie, Staffel, Folge — und eine +/// Staffel gibt es als Zeile nicht. Sie ist SeriesId plus +/// SeasonNumber, so wie sie überall sonst in diesem Server adressiert +/// wird. Eine Tabelle dafür anzulegen, nur damit eine Meldung irgendwohin +/// zeigen kann, wäre ein Modellumbau für einen Fremdschlüssel. +/// +/// +/// Keine Zeile je Benutzer und Titel. Anders als bei +/// und steht +/// hier kein eindeutiger Index: zwei Meldungen zu demselben Film sind zwei +/// Meldungen. „Der Ton setzt bei 0:30 aus" und „das Plakat gehört zu einem +/// anderen Film" haben miteinander nichts zu tun. +/// +/// +/// Ein Zeitpunkt statt eines Hakens. Wie bei PublishAt und +/// ChangedSince: ein bool Erledigt sagt, dass es vorbei ist, +/// aber nicht wann — und „seit wann liegt das hier" ist die erste Frage an +/// eine Liste von Aufgaben. +/// +/// +public sealed class Meldung +{ + public Guid Id { get; init; } = Guid.CreateVersion7(); + + /// Wer gemeldet hat — aus dem Token, nie aus dem Rumpf. + public required Guid UserId { get; init; } + + /// Entweder ein Film … + public Guid? MovieId { get; init; } + + /// … oder eine Serie, ganz oder mit … + public Guid? SeriesId { get; init; } + + /// … oder eine einzelne Folge. Genau eines von dreien. + /// + /// + /// Ohne die Serie daneben. Welche Serie das ist, weiss die Folge + /// selbst; es hier ein zweites Mal abzulegen hiesse, zwei Wahrheiten zu + /// pflegen, von denen eine irgendwann die ältere ist. + /// + public Guid? EpisodeId { get; init; } + + /// Eine einzelne Staffel — nur zusammen mit . + public int? SeasonNumber { get; init; } + + public required Meldungsart Art { get; init; } + + /// Wo im Titel. null heisst: nicht an einer Stelle. + public TimeSpan? Stelle { get; init; } + + public required string Kommentar { get; init; } + + public required DateTimeOffset GemeldetAm { get; init; } + + /// Wann abgehakt — null heisst: offen. + public DateTimeOffset? ErledigtAm { get; set; } + + /// Wer abgehakt hat. + public Guid? ErledigtVon { get; set; } +} diff --git a/server/src/Flaro.Data/FlaroContext.cs b/server/src/Flaro.Data/FlaroContext.cs index 41b8aad..79c6bfb 100644 --- a/server/src/Flaro.Data/FlaroContext.cs +++ b/server/src/Flaro.Data/FlaroContext.cs @@ -61,6 +61,9 @@ public sealed class FlaroContext(DbContextOptions options) : DbCon /// Wie lange an welchem Tag gesehen wurde — siehe . public DbSet Sehtage => Set(); + /// Was Benutzer an Titeln gemeldet haben — siehe . + public DbSet Meldungen => Set(); + public DbSet MediaFiles => Set(); public DbSet Movies => Set(); public DbSet Series => Set(); @@ -392,6 +395,39 @@ public sealed class FlaroContext(DbContextOptions options) : DbCon e.HasOne().WithMany().HasForeignKey(x => x.EpisodeId).OnDelete(DeleteBehavior.Cascade); }); + b.Entity(e => + { + e.HasKey(x => x.Id); + + // **Als Text, nicht als Zahl.** Eine 2 in der Datenbank sagt keinem + // Menschen etwas, und eine später eingeschobene Art machte aus + // jedem gespeicherten „Ton" stillschweigend „Untertitel". + e.Property(x => x.Art).HasConversion().HasMaxLength(32); + + // Zweitausend Zeichen: Platz für eine Schilderung mit Zeiten und + // Fassungen, zu wenig für einen Aufsatz. Zum Vergleich: „Der + // deutsche Ton setzt nach einer halben Minute aus, Englisch läuft + // weiter" sind 72. + e.Property(x => x.Kommentar).HasMaxLength(2000); + + // Der Weg der Verwaltung: die offenen, älteste zuerst. + e.HasIndex(x => new { x.ErledigtAm, x.GemeldetAm }); + // Der Weg des Melders: seine eigenen, neueste zuerst. + e.HasIndex(x => new { x.UserId, x.GemeldetAm }); + + // **Kein eindeutiger Index.** Zwei Meldungen zu demselben Film sind + // zwei Meldungen; sie zusammenzulegen hiesse, die zweite zu + // verschlucken. + + e.HasOne().WithMany().HasForeignKey(x => x.UserId).OnDelete(DeleteBehavior.Cascade); + + // Verschwindet der Titel, verschwindet die Meldung dazu. Eine + // Aufgabe zu einem Film, den es nicht mehr gibt, ist keine mehr. + e.HasOne().WithMany().HasForeignKey(x => x.MovieId).OnDelete(DeleteBehavior.Cascade); + e.HasOne().WithMany().HasForeignKey(x => x.SeriesId).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/20261006200638_Meldungen.Designer.cs b/server/src/Flaro.Data/Migrations/20261006200638_Meldungen.Designer.cs new file mode 100644 index 0000000..c0de725 --- /dev/null +++ b/server/src/Flaro.Data/Migrations/20261006200638_Meldungen.Designer.cs @@ -0,0 +1,1078 @@ +// +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("20261006200638_Meldungen")] + partial class Meldungen + { + /// + 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.Meldung", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Art") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("EpisodeId") + .HasColumnType("TEXT"); + + b.Property("ErledigtAm") + .HasColumnType("INTEGER"); + + b.Property("ErledigtVon") + .HasColumnType("TEXT"); + + b.Property("GemeldetAm") + .HasColumnType("INTEGER"); + + b.Property("Kommentar") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("MovieId") + .HasColumnType("TEXT"); + + b.Property("SeasonNumber") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("TEXT"); + + b.Property("Stelle") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EpisodeId"); + + b.HasIndex("MovieId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("ErledigtAm", "GemeldetAm"); + + b.HasIndex("UserId", "GemeldetAm"); + + b.ToTable("Meldungen"); + }); + + 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.Meldung", 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(); + }); + + 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/20261006200638_Meldungen.cs b/server/src/Flaro.Data/Migrations/20261006200638_Meldungen.cs new file mode 100644 index 0000000..fad5566 --- /dev/null +++ b/server/src/Flaro.Data/Migrations/20261006200638_Meldungen.cs @@ -0,0 +1,93 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Flaro.Data.Migrations +{ + /// + public partial class Meldungen : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Meldungen", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + UserId = table.Column(type: "TEXT", nullable: false), + MovieId = table.Column(type: "TEXT", nullable: true), + SeriesId = table.Column(type: "TEXT", nullable: true), + EpisodeId = table.Column(type: "TEXT", nullable: true), + SeasonNumber = table.Column(type: "INTEGER", nullable: true), + Art = table.Column(type: "TEXT", maxLength: 32, nullable: false), + Stelle = table.Column(type: "INTEGER", nullable: true), + Kommentar = table.Column(type: "TEXT", maxLength: 2000, nullable: false), + GemeldetAm = table.Column(type: "INTEGER", nullable: false), + ErledigtAm = table.Column(type: "INTEGER", nullable: true), + ErledigtVon = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Meldungen", x => x.Id); + table.ForeignKey( + name: "FK_Meldungen_Episodes_EpisodeId", + column: x => x.EpisodeId, + principalTable: "Episodes", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_Meldungen_Movies_MovieId", + column: x => x.MovieId, + principalTable: "Movies", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_Meldungen_Series_SeriesId", + column: x => x.SeriesId, + principalTable: "Series", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_Meldungen_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_Meldungen_EpisodeId", + table: "Meldungen", + column: "EpisodeId"); + + migrationBuilder.CreateIndex( + name: "IX_Meldungen_ErledigtAm_GemeldetAm", + table: "Meldungen", + columns: new[] { "ErledigtAm", "GemeldetAm" }); + + migrationBuilder.CreateIndex( + name: "IX_Meldungen_MovieId", + table: "Meldungen", + column: "MovieId"); + + migrationBuilder.CreateIndex( + name: "IX_Meldungen_SeriesId", + table: "Meldungen", + column: "SeriesId"); + + migrationBuilder.CreateIndex( + name: "IX_Meldungen_UserId_GemeldetAm", + table: "Meldungen", + columns: new[] { "UserId", "GemeldetAm" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Meldungen"); + } + } +} diff --git a/server/src/Flaro.Data/Migrations/FlaroContextModelSnapshot.cs b/server/src/Flaro.Data/Migrations/FlaroContextModelSnapshot.cs index 573d4af..316a596 100644 --- a/server/src/Flaro.Data/Migrations/FlaroContextModelSnapshot.cs +++ b/server/src/Flaro.Data/Migrations/FlaroContextModelSnapshot.cs @@ -440,6 +440,64 @@ namespace Flaro.Data.Migrations b.ToTable("MediaStreams"); }); + modelBuilder.Entity("Flaro.Core.Library.Meldung", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Art") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("EpisodeId") + .HasColumnType("TEXT"); + + b.Property("ErledigtAm") + .HasColumnType("INTEGER"); + + b.Property("ErledigtVon") + .HasColumnType("TEXT"); + + b.Property("GemeldetAm") + .HasColumnType("INTEGER"); + + b.Property("Kommentar") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("MovieId") + .HasColumnType("TEXT"); + + b.Property("SeasonNumber") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("TEXT"); + + b.Property("Stelle") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EpisodeId"); + + b.HasIndex("MovieId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("ErledigtAm", "GemeldetAm"); + + b.HasIndex("UserId", "GemeldetAm"); + + b.ToTable("Meldungen"); + }); + modelBuilder.Entity("Flaro.Core.Library.Movie", b => { b.Property("Id") @@ -927,6 +985,30 @@ namespace Flaro.Data.Migrations .IsRequired(); }); + modelBuilder.Entity("Flaro.Core.Library.Meldung", 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(); + }); + modelBuilder.Entity("Flaro.Core.Library.Movie", b => { b.HasOne("Flaro.Core.Libraries.Library", null) diff --git a/server/tests/Flaro.Api.Tests/MeldungTests.cs b/server/tests/Flaro.Api.Tests/MeldungTests.cs new file mode 100644 index 0000000..37e3ded --- /dev/null +++ b/server/tests/Flaro.Api.Tests/MeldungTests.cs @@ -0,0 +1,360 @@ +using System.Net; +using System.Net.Http.Json; +using Flaro.Api.Endpoints; + +namespace Flaro.Api.Tests; + +/// Melden darf jeder, lesen nur die Verwaltung — und die eigenen sieht man selbst. +/// +/// +/// Der Test, der hier wirklich zählt, ist der mit dem ungeprüften Titel. +/// Die Rechteprüfung beim Melden steht nirgends als `Where` — sie fällt aus dem +/// Freigabefilter heraus. Das ist elegant und genau deshalb leicht zu +/// zerstören: wer in den Endpunkt einmal `IgnoreQueryFilters` schreibt, um +/// „auch zurückgezogene Titel zu finden", hat sie weggenommen, ohne dass eine +/// Zeile danach aussieht. +/// +public class MeldungTests +{ + private string _bestand = ""; + + [SetUp] + public void Anlegen() => _bestand = Directory.CreateDirectory( + Path.Combine(Path.GetTempPath(), "flaro-meldung-" + Guid.CreateVersion7().ToString("N"))).FullName; + + [TearDown] + public void Aufraeumen() + { + try { Directory.Delete(_bestand, recursive: true); } catch (IOException) { } + } + + private void Datei(string relativ) + { + var voll = Path.Combine(_bestand, relativ); + Directory.CreateDirectory(Path.GetDirectoryName(voll)!); + File.WriteAllBytes(voll, new byte[2048]); + } + + [Test] + public async Task Eine_Meldung_zu_einem_Film_landet_beim_Administrator() + { + Datei("Heat (1995).mkv"); + + using var server = new FlaroServer(); + var alsAdmin = await Aufsetzen(server, "Filme", "Movies"); + var gast = await server.NeuerBenutzer(alsAdmin, "gast", "gastpasswortlang"); + + var film = (await Filme(gast)).Single(); + + var gemeldet = await gast.PostAsJsonAsync($"/api/v1/items/{film.Id}/reports", + new MeldungRequest("ton", "Der deutsche Ton setzt aus.", "1:02:03")); + + var liste = await alsAdmin.GetFromJsonAsync>("/api/v1/reports"); + + Assert.That(gemeldet.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + Assert.That(liste, Has.Count.EqualTo(1)); + Assert.Multiple(() => + { + Assert.That(liste![0].Target, Is.EqualTo("movie")); + Assert.That(liste[0].ItemId, Is.EqualTo(film.Id)); + Assert.That(liste[0].Title, Is.EqualTo("Heat")); + Assert.That(liste[0].Kind, Is.EqualTo("ton")); + Assert.That(liste[0].PositionSeconds, Is.EqualTo(3723)); + Assert.That(liste[0].ReportedBy, Is.EqualTo("gast")); + Assert.That(liste[0].ResolvedAt, Is.Null); + }); + } + + [Test] + public async Task Melden_darf_jeder_die_Verwaltung_bleibt_zu() + { + Datei("Heat (1995).mkv"); + + using var server = new FlaroServer(); + var alsAdmin = await Aufsetzen(server, "Filme", "Movies"); + var gast = await server.NeuerBenutzer(alsAdmin, "gast", "gastpasswortlang"); + var film = (await Filme(gast)).Single(); + + var gemeldet = await gast.PostAsJsonAsync($"/api/v1/items/{film.Id}/reports", + new MeldungRequest("bild", "Das Plakat gehört zu einem anderen Film.")); + var meine = await gast.GetAsync("/api/v1/reports/mine"); + var fremd = await gast.GetAsync("/api/v1/reports"); + + var id = (await alsAdmin.GetFromJsonAsync>("/api/v1/reports"))![0].Id; + var abhaken = await gast.PostAsync($"/api/v1/reports/{id}/resolve", null); + + Assert.Multiple(() => + { + Assert.That(gemeldet.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + Assert.That(meine.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + Assert.That(fremd.StatusCode, Is.EqualTo(HttpStatusCode.Forbidden), + "Die Liste aller Meldungen ist Verwaltung."); + Assert.That(abhaken.StatusCode, Is.EqualTo(HttpStatusCode.Forbidden)); + }); + } + + [Test] + public async Task Jeder_sieht_nur_die_eigenen_Meldungen() + { + Datei("Heat (1995).mkv"); + + using var server = new FlaroServer(); + var alsAdmin = await Aufsetzen(server, "Filme", "Movies"); + var eine = await server.NeuerBenutzer(alsAdmin, "eine", "einepasswortlang"); + var andere = await server.NeuerBenutzer(alsAdmin, "andere", "anderepasswortlang"); + var film = (await Filme(eine)).Single(); + + await eine.PostAsJsonAsync($"/api/v1/items/{film.Id}/reports", + new MeldungRequest("ton", "Von mir.")); + await andere.PostAsJsonAsync($"/api/v1/items/{film.Id}/reports", + new MeldungRequest("bild", "Von jemand anderem.")); + + var meine = await eine.GetFromJsonAsync>("/api/v1/reports/mine"); + var alle = await alsAdmin.GetFromJsonAsync>("/api/v1/reports"); + + Assert.Multiple(() => + { + Assert.That(meine, Has.Count.EqualTo(1)); + Assert.That(meine![0].Comment, Is.EqualTo("Von mir.")); + Assert.That(alle, Has.Count.EqualTo(2), "Der Administrator sieht beide."); + }); + } + + [Test] + public async Task Erledigt_heisst_abgehakt_und_nicht_geloescht() + { + Datei("Heat (1995).mkv"); + + using var server = new FlaroServer(); + var alsAdmin = await Aufsetzen(server, "Filme", "Movies"); + var gast = await server.NeuerBenutzer(alsAdmin, "gast", "gastpasswortlang"); + var film = (await Filme(gast)).Single(); + + await gast.PostAsJsonAsync($"/api/v1/items/{film.Id}/reports", + new MeldungRequest("untertitel", "Die deutsche Spur ist leer.")); + var id = (await alsAdmin.GetFromJsonAsync>("/api/v1/reports"))![0].Id; + + (await alsAdmin.PostAsync($"/api/v1/reports/{id}/resolve", null)).EnsureSuccessStatusCode(); + + var offen = await alsAdmin.GetFromJsonAsync>("/api/v1/reports"); + var erledigt = await alsAdmin.GetFromJsonAsync>("/api/v1/reports?erledigt=true"); + var beimMelder = await gast.GetFromJsonAsync>("/api/v1/reports/mine"); + + Assert.Multiple(() => + { + Assert.That(offen, Is.Empty); + Assert.That(erledigt, Has.Count.EqualTo(1)); + Assert.That(erledigt![0].ResolvedAt, Is.Not.Null); + Assert.That(beimMelder, Has.Count.EqualTo(1), + "Der Melder sieht seine Meldung weiterhin — mitsamt dem Stand."); + Assert.That(beimMelder![0].ResolvedAt, Is.Not.Null); + }); + + // Ein Fehlgriff beim Abhaken darf kein Dauerzustand sein. + (await alsAdmin.PostAsync($"/api/v1/reports/{id}/reopen", null)).EnsureSuccessStatusCode(); + var wiederOffen = await alsAdmin.GetFromJsonAsync>("/api/v1/reports"); + Assert.That(wiederOffen, Has.Count.EqualTo(1)); + } + + [Test] + public async Task Was_man_nicht_sieht_kann_man_nicht_melden() + { + Datei("Geheimsache (2024).mkv"); + + using var server = new FlaroServer(); + var admin = await server.SetUpAndSignIn(); + var alsAdmin = server.AlsAngemeldeter(admin.Token); + + var angelegt = await alsAdmin.PostAsJsonAsync("/api/v1/libraries", + new CreateLibraryRequest("Prüfbestand", "Movies", _bestand, ReviewNewItems: true)); + var bibliothek = (await angelegt.Content.ReadFromJsonAsync())!; + await Scannen(alsAdmin, bibliothek.Id); + + var wartend = await alsAdmin.GetFromJsonAsync>("/api/v1/review"); + var id = wartend![0].Id; + + var gast = await server.NeuerBenutzer(alsAdmin, "gast", "gastpasswortlang"); + var abgelehnt = await gast.PostAsJsonAsync($"/api/v1/items/{id}/reports", + new MeldungRequest("bild", "Sieht komisch aus.")); + var vomAdmin = await alsAdmin.PostAsJsonAsync($"/api/v1/items/{id}/reports", + new MeldungRequest("bild", "Sieht komisch aus.")); + + Assert.Multiple(() => + { + Assert.That(abgelehnt.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest)); + Assert.That(vomAdmin.StatusCode, Is.EqualTo(HttpStatusCode.OK), + "Der Administrator sieht den Titel und darf ihn deshalb melden."); + }); + } + + [Test] + public async Task Eine_Staffel_wird_ueber_Serie_und_Nummer_gemeldet() + { + Datei("Dark/Season 01/Dark - S01E01.mkv"); + Datei("Dark/Season 02/Dark - S02E01.mkv"); + + using var server = new FlaroServer(); + var alsAdmin = await Aufsetzen(server, "Serien", "Shows"); + var gast = await server.NeuerBenutzer(alsAdmin, "gast", "gastpasswortlang"); + + var serien = await gast.GetFromJsonAsync>("/api/v1/series"); + var serie = serien!.Items.Single(); + + var gut = await gast.PostAsJsonAsync($"/api/v1/items/{serie.Id}/reports", + new MeldungRequest("ton", "Ab Folge zwei kein Ton.", null, SeasonNumber: 2)); + var falscheStaffel = await gast.PostAsJsonAsync($"/api/v1/items/{serie.Id}/reports", + new MeldungRequest("ton", "Gibt es nicht.", null, SeasonNumber: 9)); + + var liste = await alsAdmin.GetFromJsonAsync>("/api/v1/reports"); + + Assert.Multiple(() => + { + Assert.That(gut.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + Assert.That(falscheStaffel.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest)); + Assert.That(liste, Has.Count.EqualTo(1)); + Assert.That(liste![0].Target, Is.EqualTo("season")); + Assert.That(liste[0].ItemId, Is.EqualTo(serie.Id)); + Assert.That(liste[0].SubTitle, Is.EqualTo("Staffel 2")); + }); + } + + [Test] + public async Task Eine_Folge_meldet_sich_unter_ihrer_eigenen_Kennung() + { + Datei("Dark/Season 01/Dark - S01E01.mkv"); + + using var server = new FlaroServer(); + var alsAdmin = await Aufsetzen(server, "Serien", "Shows"); + var gast = await server.NeuerBenutzer(alsAdmin, "gast", "gastpasswortlang"); + + var serie = (await gast.GetFromJsonAsync>("/api/v1/series"))!.Items.Single(); + var folgen = await gast.GetFromJsonAsync>( + $"/api/v1/series/{serie.Id}/episodes"); + var folge = folgen!.Items.Single(); + + await gast.PostAsJsonAsync($"/api/v1/items/{folge.Id}/reports", + new MeldungRequest("bild", "Bei 0:34 zwei Minuten schwarz.", "0:34:00")); + + var liste = await alsAdmin.GetFromJsonAsync>("/api/v1/reports"); + + Assert.That(liste, Has.Count.EqualTo(1)); + Assert.Multiple(() => + { + Assert.That(liste![0].Target, Is.EqualTo("episode")); + Assert.That(liste[0].ItemId, Is.EqualTo(serie.Id), + "Die Detailseite gehört der Serie, nicht der Folge."); + Assert.That(liste[0].SubTitle, Does.StartWith("S1 · F1")); + Assert.That(liste[0].PositionSeconds, Is.EqualTo(2040)); + }); + } + + [Test] + public async Task Ohne_Beschreibung_gibt_es_nichts_nachzusehen() + { + Datei("Heat (1995).mkv"); + + using var server = new FlaroServer(); + var alsAdmin = await Aufsetzen(server, "Filme", "Movies"); + var gast = await server.NeuerBenutzer(alsAdmin, "gast", "gastpasswortlang"); + var film = (await Filme(gast)).Single(); + + var leer = await gast.PostAsJsonAsync($"/api/v1/items/{film.Id}/reports", + new MeldungRequest("ton", " ")); + var zuLang = await gast.PostAsJsonAsync($"/api/v1/items/{film.Id}/reports", + new MeldungRequest("ton", new string('a', 2001))); + var unbekannt = await gast.PostAsJsonAsync($"/api/v1/items/{film.Id}/reports", + new MeldungRequest("geruch", "Riecht komisch.")); + + Assert.Multiple(() => + { + Assert.That(leer.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest)); + Assert.That(zuLang.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest)); + Assert.That(unbekannt.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest)); + }); + } + + /// „2:03" sind zwei Minuten, nicht zwei Stunden. + /// + /// + /// TimeSpan.Parse läse es als zwei Stunden und drei Minuten. Wer + /// neben einem Film „2:03" schreibt, meint das, was unter dem + /// Fortschrittsbalken steht. + /// + [TestCase("1:02:03", 3723)] + [TestCase("2:03", 123)] + [TestCase("90", 90)] + public async Task Die_Stelle_kommt_als_Zeit_zurueck(string geschrieben, double sekunden) + { + Datei("Heat (1995).mkv"); + + using var server = new FlaroServer(); + var alsAdmin = await Aufsetzen(server, "Filme", "Movies"); + var film = (await Filme(alsAdmin)).Single(); + + await alsAdmin.PostAsJsonAsync($"/api/v1/items/{film.Id}/reports", + new MeldungRequest("bild", "Hier.", geschrieben)); + var liste = await alsAdmin.GetFromJsonAsync>("/api/v1/reports"); + + Assert.That(liste![0].PositionSeconds, Is.EqualTo(sekunden)); + } + + [Test] + public async Task Eine_unleserliche_Stelle_wird_abgewiesen() + { + Datei("Heat (1995).mkv"); + + using var server = new FlaroServer(); + var alsAdmin = await Aufsetzen(server, "Filme", "Movies"); + var film = (await Filme(alsAdmin)).Single(); + + var krumm = await alsAdmin.PostAsJsonAsync($"/api/v1/items/{film.Id}/reports", + new MeldungRequest("bild", "Hier.", "Mitte")); + var ohne = await alsAdmin.PostAsJsonAsync($"/api/v1/items/{film.Id}/reports", + new MeldungRequest("bild", "Betrifft den ganzen Film.", "")); + + var liste = await alsAdmin.GetFromJsonAsync>("/api/v1/reports"); + + Assert.Multiple(() => + { + Assert.That(krumm.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest)); + Assert.That(ohne.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + Assert.That(liste, Has.Count.EqualTo(1)); + Assert.That(liste![0].PositionSeconds, Is.Null); + }); + } + + /* ---------- Gerüst ---------- */ + + private async Task Aufsetzen(FlaroServer server, string name, string art) + { + var admin = await server.SetUpAndSignIn(); + var alsAdmin = server.AlsAngemeldeter(admin.Token); + + var angelegt = await alsAdmin.PostAsJsonAsync("/api/v1/libraries", + new CreateLibraryRequest(name, art, _bestand, ReviewNewItems: false)); + angelegt.EnsureSuccessStatusCode(); + var bibliothek = (await angelegt.Content.ReadFromJsonAsync())!; + + await Scannen(alsAdmin, bibliothek.Id); + return alsAdmin; + } + + private static async Task> Filme(HttpClient client) => + (await client.GetFromJsonAsync>("/api/v1/movies"))!.Items; + + private static async Task Scannen(HttpClient client, Guid bibliothek) + { + (await client.PostAsync($"/api/v1/libraries/{bibliothek}/scan", null)) + .EnsureSuccessStatusCode(); + + for (var versuch = 0; versuch < 600; versuch++) + { + var stand = await client.GetFromJsonAsync($"/api/v1/libraries/{bibliothek}/scan"); + if (stand!.State is "finished") return; + if (stand.State is "failed") Assert.Fail($"Der Scan ist gescheitert: {stand.Error}"); + await Task.Delay(50); + } + + Assert.Fail("Der Scan wurde nicht fertig."); + } +} diff --git a/web/app/src/api.ts b/web/app/src/api.ts index eb2b77f..a3074d2 100644 --- a/web/app/src/api.ts +++ b/web/app/src/api.ts @@ -810,6 +810,60 @@ export const zurueckziehen = (ids: string[]) => anfrage<{ withheld: number }>('/review/withhold', { method: 'POST', body: JSON.stringify({ ids, publishAt: null }) }); +/* ---------- Meldungen ---------- */ + +export type Meldungsart = 'bild' | 'ton' | 'untertitel' | 'daten' | 'sonstiges'; + +export type Meldung = { + id: string; + /** Worauf sie zeigt. */ + target: 'movie' | 'series' | 'season' | 'episode'; + /** Die Kennung der Detailseite — bei Staffel und Folge die der Serie. */ + itemId: string; + /** Leer, wenn der Titel nicht mehr sichtbar ist. */ + title: string; + subTitle: string | null; + posterImage: string | null; + kind: Meldungsart; + positionSeconds: number | null; + comment: string; + reportedAt: string; + reportedBy: string; + /** `null` heisst: offen. */ + resolvedAt: string | null; +}; + +/** Meldet ein Problem an einem Titel. + * + * **Eine Kennung für alles.** Film, Serie und Folge gehen über denselben Weg + * hinaus wie Häkchen, Favorit und Fortschritt; welche von dreien es ist, + * entscheidet der Server. Nur die Staffel braucht eine Nummer daneben — die + * gibt es als eigene Zeile nicht. */ +export const meldungAnlegen = ( + id: string, kind: Meldungsart, comment: string, position?: string, staffel?: number, +) => + anfrage<{ id: string }>(`/items/${id}/reports`, { + method: 'POST', + body: JSON.stringify({ + kind, comment, + position: position ?? null, + seasonNumber: staffel ?? null, + }), + }); + +/** Alle Meldungen — offene oder erledigte. Nur für Administratoren. */ +export const meldungen = (erledigt = false) => + anfrage(`/reports${erledigt ? '?erledigt=true' : ''}`); + +/** Die eigenen, offene wie erledigte. */ +export const meineMeldungen = () => anfrage('/reports/mine'); + +export const meldungErledigt = (id: string) => + anfrage(`/reports/${id}/resolve`, { method: 'POST' }); + +export const meldungWiederOeffnen = (id: string) => + anfrage(`/reports/${id}/reopen`, { method: 'POST' }); + /** Die Reihenfolge der Seitenleiste, von oben nach unten. */ export const reihenfolgeSetzen = (ids: string[]) => anfrage('/libraries/order', { method: 'PUT', body: JSON.stringify({ ids }) }); diff --git a/web/app/src/dialog.ts b/web/app/src/dialog.ts index 953cba3..4e404e1 100644 --- a/web/app/src/dialog.ts +++ b/web/app/src/dialog.ts @@ -11,7 +11,8 @@ import { auswahl, type Auswahlfeld } from './auswahl'; * Vorlesesoftware kündigt ihn als Dialog an. Jede Zeile, die das selbst * baut, ist eine Zeile, die es schlechter kann. */ -export type Feldart = 'text' | 'password' | 'number' | 'ordner' | 'zeitpunkt'; +export type Feldart = + 'text' | 'password' | 'number' | 'ordner' | 'zeitpunkt' | 'mehrzeilig'; export type Dialogfeld = { name: string; @@ -26,6 +27,8 @@ export type Dialogfeld = { schalter?: boolean; /** Frühester erlaubter Zeitpunkt — nur bei `art: 'zeitpunkt'`. */ min?: string; + /** Wie hoch es aufmacht — nur bei `art: 'mehrzeilig'`. */ + zeilen?: number; }; /** Liest einen Feldwert — nie `undefined`, ein unbekannter Name ergibt „". @@ -74,7 +77,7 @@ export function dialog(optionen: { koerper.appendChild(p); } - const eingaben = new Map(); + const eingaben = new Map(); (optionen.felder ?? []).forEach(f => { // **Ein `