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