Aus einer Serie wurden siebenhundert. Dateien wie "S01E001_Aang.mkv" tragen vor der Folgennummer nichts, also wurde der ganze Dateiname zum Serientitel — jede Folge eine eigene Serie. Und umgekehrt ergab "Doctor Who (2005) - S12E04.mkv" im Ordner "Doctor Who" zwei Serien, weil die Jahresangabe nur im Dateinamen stand. Jetzt gilt: was unter Serien/Pingu/ liegt, gehört zu Pingu. Egal wie tief, egal wie die Datei heisst. Der Ordner ist die verlässlichere Angabe, weil ihn ein Mensch angelegt hat; so macht es Plex seit je und aus demselben Grund. Staffel und Folge liest weiterhin der Dateiname. Ohne bekannte Wurzel bleibt es beim Dateinamen — dort könnte über dem Staffelordner irgendetwas stehen. Diesen Weg gehen nur die Tests. Dazu vier Dinge aus der Benutzung: Die Mediatheken standen scheinbar in zufälliger Reihenfolge. SQLite vergleicht Text zeichengenau, und dort stehen alle Grossbuchstaben vor allen kleinen: "Serien" landete über "film". Jetzt ohne Rücksicht auf Gross- und Kleinschreibung — und umsortierbar über zwei Pfeile. Nicht über Ziehen: das ist schöner, aber mit der Tastatur unbedienbar und auf dem Telefon ein Glücksspiel. Die Sprache des Servers steckte im Dialog "Metadaten", zusammen mit dem TMDB-Schlüssel — also dort, wo niemand nach einer Sprache sucht. Wer die Mediathek auf Englisch vorfand, sah nur "Meine Sprache" und hielt das für die einzige Möglichkeit. Jetzt eine eigene Zeile. Das Kontextmenü gab es nur per Rechtsklick, und den findet nur, wer ihn vermutet — auf dem Telefon gibt es ihn gar nicht. Jetzt drei Punkte auf jeder Kachel, beim Überfahren und bei Tastaturfokus, auf Geräten ohne Zeiger dauerhaft. Und "Zuordnung korrigieren" steht endlich darin: wer zwanzig falsch erkannte Titel vor sich hat, soll sie dort geradeziehen können statt über vierzig Seitenwechsel. Folgen ohne eigenes Bild nehmen das der Serie. TMDB liefert Folgenbilder nur für einen Teil der Folgen und bei kleineren Serien für gar keine; eine graue Fläche sagt nichts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
153 lines
7.1 KiB
C#
153 lines
7.1 KiB
C#
using Flaro.Data;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace Flaro.Api.Endpoints;
|
|
|
|
/// <summary>Neue Funde prüfen, veröffentlichen oder auf einen Zeitpunkt legen.</summary>
|
|
public static class FreigabeEndpoints
|
|
{
|
|
public static void MapFreigabe(this IEndpointRouteBuilder app)
|
|
{
|
|
var gruppe = app.MapGroup("/api/v1/review")
|
|
.RequireAuthorization(FlaroPolicies.Administrator);
|
|
|
|
// **Was noch wartet, und was schon geplant ist.**
|
|
//
|
|
// Beides in einer Liste: ein geplanter Titel ist nicht erledigt, er
|
|
// hat nur schon einen Termin. Ihn verschwinden zu lassen hiesse, dass
|
|
// man ihn nur findet, wenn man sich den Termin gemerkt hat.
|
|
gruppe.MapGet("/", async (FlaroContext db, TimeProvider uhr, CancellationToken ct) =>
|
|
{
|
|
var jetzt = uhr.GetUtcNow();
|
|
|
|
var filme = await db.Movies.AsNoTracking()
|
|
.Where(m => m.PublishAt == null || m.PublishAt > jetzt)
|
|
.OrderBy(m => m.SortTitleValue)
|
|
.Select(m => new Pruefeintrag(
|
|
"movie", m.Id, m.Title, m.Year, m.PosterImage, m.PublishAt,
|
|
db.MediaFiles.Where(f => f.MovieId == m.Id).Select(f => f.Path).FirstOrDefault(),
|
|
m.TmdbId != null, 1, new List<Guid> { m.Id }))
|
|
.ToListAsync(ct);
|
|
|
|
// **Folgen werden nach Serie gebündelt.** Zwanzig neue Folgen
|
|
// derselben Serie sind eine Entscheidung, nicht zwanzig.
|
|
var offeneFolgen = await db.Episodes.AsNoTracking()
|
|
.Where(f => f.PublishAt == null || f.PublishAt > jetzt)
|
|
.Select(f => new
|
|
{
|
|
f.Id, f.SeriesId, f.SeasonNumber, f.EpisodeNumber, f.PublishAt,
|
|
Pfad = db.MediaFiles.Where(d => d.EpisodeId == f.Id)
|
|
.Select(d => d.Path).FirstOrDefault(),
|
|
})
|
|
.ToListAsync(ct);
|
|
|
|
var serienIds = offeneFolgen.Select(f => f.SeriesId).Distinct().ToList();
|
|
var serien = await db.Series.AsNoTracking()
|
|
.Where(s => serienIds.Contains(s.Id))
|
|
.Select(s => new { s.Id, s.Title, s.Year, s.PosterImage, Zugeordnet = s.TmdbId != null })
|
|
.ToDictionaryAsync(s => s.Id, ct);
|
|
|
|
var gebuendelt = offeneFolgen
|
|
.GroupBy(f => f.SeriesId)
|
|
.Select(g =>
|
|
{
|
|
var serie = serien.GetValueOrDefault(g.Key);
|
|
var termine = g.Select(f => f.PublishAt).Distinct().ToList();
|
|
|
|
return new Pruefeintrag(
|
|
"series", g.Key,
|
|
serie?.Title ?? "Unbekannte Serie",
|
|
serie?.Year,
|
|
serie?.PosterImage,
|
|
// Nur wenn alle denselben Termin tragen — sonst wäre es
|
|
// eine Behauptung über Folgen, die anders liegen.
|
|
termine.Count == 1 ? termine[0] : null,
|
|
Pfad(g.Select(f => f.Pfad).FirstOrDefault()),
|
|
serie?.Zugeordnet ?? false,
|
|
g.Count(),
|
|
g.OrderBy(f => f.SeasonNumber).ThenBy(f => f.EpisodeNumber)
|
|
.Select(f => f.Id).ToList());
|
|
})
|
|
.OrderBy(e => e.Title)
|
|
.ToList();
|
|
|
|
return Results.Ok(filme.Concat(gebuendelt).ToList());
|
|
|
|
// Bei einer Serie sagt der Ordner mehr als die erste Folgendatei.
|
|
static string? Pfad(string? datei) =>
|
|
datei is null ? null : System.IO.Path.GetDirectoryName(datei);
|
|
});
|
|
|
|
// Veröffentlichen oder planen. Ohne Zeitpunkt heisst: jetzt.
|
|
gruppe.MapPost("/publish", async (
|
|
FreigabeRequest anfrage, FlaroContext db, TimeProvider uhr, CancellationToken ct) =>
|
|
{
|
|
var wann = anfrage.PublishAt ?? uhr.GetUtcNow();
|
|
|
|
// Alles Wartende einer einzelnen Mediathek — der Knopf, der dort
|
|
// steht, wo man gerade gescannt hat.
|
|
if (anfrage.LibraryId is { } mediathek)
|
|
{
|
|
var jetztDort = uhr.GetUtcNow();
|
|
|
|
var dortFilme = await db.Movies
|
|
.Where(m => m.LibraryId == mediathek
|
|
&& (m.PublishAt == null || m.PublishAt > jetztDort))
|
|
.ExecuteUpdateAsync(s => s.SetProperty(m => m.PublishAt, wann), ct);
|
|
|
|
var dortFolgen = await db.Episodes
|
|
.Where(f => db.Series.Any(s => s.Id == f.SeriesId && s.LibraryId == mediathek)
|
|
&& (f.PublishAt == null || f.PublishAt > jetztDort))
|
|
.ExecuteUpdateAsync(s => s.SetProperty(f => f.PublishAt, wann), ct);
|
|
|
|
return Results.Ok(new { published = dortFilme + dortFolgen });
|
|
}
|
|
|
|
// **Alles auf einmal, wenn keine Kennungen dabei sind.** Bei
|
|
// vierhundert neuen Titeln wäre einzeln Klicken keine Bedienung.
|
|
if (anfrage.Ids is null or { Count: 0 })
|
|
{
|
|
// **Alles, was noch nicht sichtbar ist** — Wartendes und
|
|
// Geplantes. Nur das Wartende zu fassen hiesse: ein auf
|
|
// nächste Woche gelegter Titel bliebe liegen, obwohl er in
|
|
// derselben Liste stand und „Alle veröffentlichen" gedrückt
|
|
// wurde.
|
|
var jetzt = uhr.GetUtcNow();
|
|
|
|
var alleFilme = await db.Movies
|
|
.Where(m => m.PublishAt == null || m.PublishAt > jetzt)
|
|
.ExecuteUpdateAsync(s => s.SetProperty(m => m.PublishAt, wann), ct);
|
|
var alleFolgen = await db.Episodes
|
|
.Where(f => f.PublishAt == null || f.PublishAt > jetzt)
|
|
.ExecuteUpdateAsync(s => s.SetProperty(f => f.PublishAt, wann), ct);
|
|
|
|
return Results.Ok(new { published = alleFilme + alleFolgen });
|
|
}
|
|
|
|
var ids = anfrage.Ids;
|
|
var filme = await db.Movies.Where(m => ids.Contains(m.Id))
|
|
.ExecuteUpdateAsync(s => s.SetProperty(m => m.PublishAt, wann), ct);
|
|
var folgen = await db.Episodes.Where(f => ids.Contains(f.Id))
|
|
.ExecuteUpdateAsync(s => s.SetProperty(f => f.PublishAt, wann), ct);
|
|
|
|
return Results.Ok(new { published = filme + folgen });
|
|
});
|
|
|
|
// Zurückziehen — wieder in die Prüfung.
|
|
gruppe.MapPost("/withhold", async (
|
|
FreigabeRequest anfrage, FlaroContext db, CancellationToken ct) =>
|
|
{
|
|
if (anfrage.Ids is null or { Count: 0 })
|
|
return Results.BadRequest(new { fehler = "Ohne Kennungen gibt es nichts zurückzuziehen." });
|
|
|
|
var ids = anfrage.Ids;
|
|
var filme = await db.Movies.Where(m => ids.Contains(m.Id))
|
|
.ExecuteUpdateAsync(s => s.SetProperty(m => m.PublishAt, (DateTimeOffset?)null), ct);
|
|
var folgen = await db.Episodes.Where(f => ids.Contains(f.Id))
|
|
.ExecuteUpdateAsync(s => s.SetProperty(f => f.PublishAt, (DateTimeOffset?)null), ct);
|
|
|
|
return Results.Ok(new { withheld = filme + folgen });
|
|
});
|
|
}
|
|
}
|