„Schon durchsucht" und „hat eine Marke" sind zwei verschiedene Dinge
Genau dazwischen fehlte die Unterscheidung. Ein Film, bei dem die Suche einmal nichts fand, trägt einen Vermerk und keine Marke — und war damit für jeden weiteren Lauf erledigt, obwohl er der interessanteste Fall ist. Es gab nur alles oder nichts, und der Knopf bei der Mediathek schickte immer „alles": 453 Filme mal zwanzig Minuten Schlüsselbilder, auch für die vierhundert, bei denen längst etwas stand. Aus `bool erneut` wird `Markenumfang` mit drei Werten. `Neue` ist, was der Scan tut, und ändert sich nicht. `Luecken` nimmt sich vor, wo keine Marke steht — gleich ob dort schon einmal gesucht wurde. `Alles` ist der alte Holzhammer. Bei der Mediathek jetzt zwei Knöpfe, weil es zwei Fragen sind: „Fehlende Marken suchen" ist die alltägliche, „Alles neu durchsuchen" die seltene und teure, und deren Rückfrage nennt die Zahl der Titel. Auf der Detailseite bleibt es bei einem Knopf mit „alles" — wer ihn bei genau diesem Titel drückt, ist mit genau diesem Titel unzufrieden. Bei einer Serie wird eine Staffel ganz gelesen, sobald einer ihrer Folgen etwas fehlt. Billiger ginge es nur, indem man die Lücke gar nicht erst angeht, und genau dafür drückt jemand den Knopf. Vier Prüfungen am Endpunkt, die messen, ob der Lauf den Film anfasst: `Neue` überspringt den Durchsuchten, `Luecken` nimmt ihn sich vor, `Alles` sowieso, und ein Film mit Marke bleibt bei `Luecken` in Ruhe. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
f2752e6c05
commit
f79ecf2757
@@ -893,11 +893,12 @@ public static class LibraryContentEndpoints
|
||||
// Hintergrundlauf statt einer Antwort, auf die jemand wartet.
|
||||
// Dasselbe für einen Film — dort sucht der Server im Bild.
|
||||
app.MapPost("/api/v1/movies/{id:guid}/marken", async (
|
||||
Guid id, FlaroContext db, Markenerkennung erkennung, bool erneut = false) =>
|
||||
Guid id, FlaroContext db, Markenerkennung erkennung,
|
||||
Markenumfang umfang = Markenumfang.Alles) =>
|
||||
{
|
||||
if (!await db.Movies.AsNoTracking().AnyAsync(x => x.Id == id)) return Results.NotFound();
|
||||
|
||||
_ = erkennung.FilmAsync(id, erneut);
|
||||
_ = erkennung.FilmAsync(id, umfang);
|
||||
return Results.Accepted();
|
||||
}).RequireAuthorization(FlaroPolicies.Administrator);
|
||||
|
||||
@@ -914,12 +915,17 @@ public static class LibraryContentEndpoints
|
||||
return weg > 0 ? Results.NoContent() : Results.NotFound();
|
||||
}).RequireAuthorization(FlaroPolicies.Administrator);
|
||||
|
||||
// **Bei einem einzelnen Titel ist „alles" die Vorgabe.** Wer den
|
||||
// Knopf bei genau dieser Serie drückt, ist mit genau dieser Serie
|
||||
// unzufrieden; ihn dann nach Lücken suchen zu lassen hiesse, ihm
|
||||
// die Antwort zu verweigern, die er haben will.
|
||||
app.MapPost("/api/v1/series/{id:guid}/marken", async (
|
||||
Guid id, FlaroContext db, Markenerkennung erkennung, bool erneut = false) =>
|
||||
Guid id, FlaroContext db, Markenerkennung erkennung,
|
||||
Markenumfang umfang = Markenumfang.Alles) =>
|
||||
{
|
||||
if (!await db.Series.AsNoTracking().AnyAsync(x => x.Id == id)) return Results.NotFound();
|
||||
|
||||
return erkennung.Starten(id, erneut)
|
||||
return erkennung.Starten(id, umfang)
|
||||
? Results.Accepted()
|
||||
: Einwand.Belegt("Für diese Serie werden gerade Marken gesucht.");
|
||||
}).RequireAuthorization(FlaroPolicies.Administrator);
|
||||
@@ -930,12 +936,13 @@ public static class LibraryContentEndpoints
|
||||
// einmal. Der Lauf kann Stunden dauern und läuft deshalb im
|
||||
// Hintergrund weiter; die Antwort kommt sofort.
|
||||
app.MapPost("/api/v1/libraries/{id:guid}/marken", async (
|
||||
Guid id, FlaroContext db, Markenerkennung erkennung, bool erneut = false) =>
|
||||
Guid id, FlaroContext db, Markenerkennung erkennung,
|
||||
Markenumfang umfang = Markenumfang.Neue) =>
|
||||
{
|
||||
if (!await db.Libraries.AsNoTracking().AnyAsync(x => x.Id == id))
|
||||
return Results.NotFound();
|
||||
|
||||
erkennung.MediathekStarten(id, erneut);
|
||||
erkennung.MediathekStarten(id, umfang);
|
||||
return Results.Accepted();
|
||||
}).RequireAuthorization(FlaroPolicies.Administrator);
|
||||
|
||||
|
||||
@@ -7,6 +7,37 @@ using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Flaro.Api;
|
||||
|
||||
/// <summary>Wie weit ein Markenlauf greift.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>„Schon durchsucht" und „hat eine Marke" sind zwei verschiedene
|
||||
/// Dinge</b>, und genau dazwischen fehlte bisher die Unterscheidung. Ein
|
||||
/// Film, bei dem die Suche einmal nichts fand, trägt einen Vermerk und
|
||||
/// keine Marke: der nächste Lauf übersprang ihn, obwohl er der
|
||||
/// interessanteste Fall ist.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Nach jeder Verbesserung an der Erkennung will man genau diese Titel
|
||||
/// noch einmal — und eben nicht die vierhundert, bei denen längst etwas
|
||||
/// steht.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public enum Markenumfang
|
||||
{
|
||||
/// <summary>Nur, wo noch nie gesucht wurde. Der Lauf nach einem Scan.</summary>
|
||||
Neue,
|
||||
|
||||
/// <summary>
|
||||
/// Auch, wo gesucht und nichts gefunden wurde — aber nicht, wo schon
|
||||
/// eine Marke steht.
|
||||
/// </summary>
|
||||
Luecken,
|
||||
|
||||
/// <summary>Alles, auch was längst eine Marke hat.</summary>
|
||||
Alles,
|
||||
}
|
||||
|
||||
/// <summary>Sucht Vorspann und Abspann einer Staffel.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
@@ -105,7 +136,7 @@ public sealed class Markenerkennung(
|
||||
_stand.TryGetValue(mediathek, out var schritt) ? schritt : null;
|
||||
|
||||
/// <summary>Startet den Lauf für eine Serie — oder sagt, dass schon einer läuft.</summary>
|
||||
public bool Starten(Guid serieId, bool erneut = false)
|
||||
public bool Starten(Guid serieId, Markenumfang umfang = Markenumfang.Neue)
|
||||
{
|
||||
if (!_laufend.TryAdd(serieId, true)) return false;
|
||||
|
||||
@@ -113,7 +144,7 @@ public sealed class Markenerkennung(
|
||||
{
|
||||
try
|
||||
{
|
||||
await LaufenAsync(serieId, erneut, CancellationToken.None);
|
||||
await LaufenAsync(serieId, umfang, CancellationToken.None);
|
||||
}
|
||||
catch (Exception fehler)
|
||||
{
|
||||
@@ -146,7 +177,8 @@ public sealed class Markenerkennung(
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public async Task NachScanAsync(
|
||||
Guid mediathek, bool erneut = false, CancellationToken ct = default)
|
||||
Guid mediathek, Markenumfang umfang = Markenumfang.Neue,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
using var raum = scopes.CreateScope();
|
||||
var db = raum.ServiceProvider.GetRequiredService<FlaroContext>();
|
||||
@@ -171,7 +203,7 @@ public sealed class Markenerkennung(
|
||||
if (serien.Count > 0 && await leser.VorhandenAsync(ct))
|
||||
{
|
||||
await SerienAsync(db, mediathekname, serien
|
||||
.Select(s => (s.Id, s.Title)).ToList(), mediathek, erneut, ct);
|
||||
.Select(s => (s.Id, s.Title)).ToList(), mediathek, umfang, ct);
|
||||
}
|
||||
else if (serien.Count > 0)
|
||||
{
|
||||
@@ -184,7 +216,7 @@ public sealed class Markenerkennung(
|
||||
// Stand hier früher `if (serien.Count == 0) return;`, und damit
|
||||
// kam eine reine Filmmediathek nie bis zu dieser Zeile: der
|
||||
// Abspann eines Films wurde nach einem Scan nie gesucht.
|
||||
await FilmeAsync(db, mediathekname, mediathek, erneut, ct,
|
||||
await FilmeAsync(db, mediathekname, mediathek, umfang, ct,
|
||||
schritt => _stand[mediathek] = schritt);
|
||||
}
|
||||
finally
|
||||
@@ -196,7 +228,7 @@ public sealed class Markenerkennung(
|
||||
/// <summary>Alle Serien einer Mediathek, eine nach der anderen.</summary>
|
||||
private async Task SerienAsync(
|
||||
FlaroContext db, string mediathekname, IReadOnlyList<(Guid Id, string Titel)> serien,
|
||||
Guid mediathek, bool erneut, CancellationToken ct)
|
||||
Guid mediathek, Markenumfang umfang, CancellationToken ct)
|
||||
{
|
||||
var folgen = await db.Episodes.AsNoTracking()
|
||||
.CountAsync(f => db.Series.Any(s => s.Id == f.SeriesId && s.LibraryId == mediathek), ct);
|
||||
@@ -226,7 +258,7 @@ public sealed class Markenerkennung(
|
||||
if (!_laufend.TryAdd(id, true)) continue;
|
||||
try
|
||||
{
|
||||
var stand = await LaufenAsync(id, erneut, ct, Melden);
|
||||
var stand = await LaufenAsync(id, umfang, ct, Melden);
|
||||
marken += stand.Marken;
|
||||
if (!stand.Angefasst) continue;
|
||||
angefasst++;
|
||||
@@ -259,11 +291,11 @@ public sealed class Markenerkennung(
|
||||
/// Tagesprotokoll — und an den Folgen selbst, bei denen danach eine
|
||||
/// Marke steht.
|
||||
/// </remarks>
|
||||
public void MediathekStarten(Guid mediathek, bool erneut)
|
||||
public void MediathekStarten(Guid mediathek, Markenumfang umfang)
|
||||
{
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try { await NachScanAsync(mediathek, erneut, CancellationToken.None); }
|
||||
try { await NachScanAsync(mediathek, umfang, CancellationToken.None); }
|
||||
catch (Exception fehler)
|
||||
{
|
||||
log.LogWarning(fehler, "Markenlauf für Mediathek {Kennung} abgebrochen: {Grund}",
|
||||
@@ -280,15 +312,25 @@ public sealed class Markenerkennung(
|
||||
/// läuft dann ins Leere, und das kostet eine Abfrage.
|
||||
/// </remarks>
|
||||
private async Task FilmeAsync(
|
||||
FlaroContext db, string mediathekname, Guid mediathek, bool erneut,
|
||||
FlaroContext db, string mediathekname, Guid mediathek, Markenumfang umfang,
|
||||
CancellationToken ct, Action<Scanschritt> melden)
|
||||
{
|
||||
var offen = await db.Movies.AsNoTracking()
|
||||
.Where(m => m.LibraryId == mediathek)
|
||||
// Bei „erneut" auch die, bei denen schon gesucht wurde: genau
|
||||
// dafür drückt jemand den Knopf.
|
||||
.Where(m => erneut
|
||||
|| !db.Markensuchen.Any(x => x.MovieId == m.Id && x.Art == Markenart.Abspann))
|
||||
// **In C# verzweigt, nicht im Ausdruck.** Drei Bedingungen in einem
|
||||
// `Where` mit einem Aufzählungswert übersetzt EF Core zwar meist,
|
||||
// aber „meist" ist hier kein Argument: so ist sicher, was in SQL
|
||||
// ankommt.
|
||||
var abfrage = db.Movies.AsNoTracking().Where(m => m.LibraryId == mediathek);
|
||||
abfrage = umfang switch
|
||||
{
|
||||
Markenumfang.Alles => abfrage,
|
||||
// Die Lücke: gesucht wurde vielleicht, gefunden nichts.
|
||||
Markenumfang.Luecken => abfrage.Where(m =>
|
||||
!db.Marken.Any(x => x.MovieId == m.Id && x.Art == Markenart.Abspann)),
|
||||
_ => abfrage.Where(m =>
|
||||
!db.Markensuchen.Any(x => x.MovieId == m.Id && x.Art == Markenart.Abspann)),
|
||||
};
|
||||
|
||||
var offen = await abfrage
|
||||
.Select(m => new { m.Id, m.Title, m.Year })
|
||||
.OrderBy(m => m.Title)
|
||||
.ToListAsync(ct);
|
||||
@@ -306,7 +348,7 @@ public sealed class Markenerkennung(
|
||||
var titel = Filmname(film.Title, film.Year);
|
||||
melden(new Scanschritt(Scanphase.Marken, titel, i + 1, offen.Count));
|
||||
|
||||
try { if (await FilmAsync(film.Id, erneut, ct)) gefunden++; }
|
||||
try { if (await FilmAsync(film.Id, umfang, ct)) gefunden++; }
|
||||
catch (Exception fehler)
|
||||
{
|
||||
log.LogWarning(fehler, "Abspannsuche für „{Film}“ abgebrochen: {Grund}",
|
||||
@@ -342,7 +384,8 @@ public sealed class Markenerkennung(
|
||||
/// Sekunden ungenau. Die Kante holt danach eine Handvoll kurzer Sonden.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public async Task<bool> FilmAsync(Guid filmId, bool erneut, CancellationToken ct = default)
|
||||
public async Task<bool> FilmAsync(
|
||||
Guid filmId, Markenumfang umfang, CancellationToken ct = default)
|
||||
{
|
||||
using var raum = scopes.CreateScope();
|
||||
var db = raum.ServiceProvider.GetRequiredService<FlaroContext>();
|
||||
@@ -372,7 +415,14 @@ public sealed class Markenerkennung(
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!erneut && await db.Markensuchen.AsNoTracking()
|
||||
if (umfang == Markenumfang.Luecken && await db.Marken.AsNoTracking()
|
||||
.AnyAsync(m => m.MovieId == filmId && m.Art == Markenart.Abspann, ct))
|
||||
{
|
||||
log.LogInformation("„{Film}“: hat schon einen Abspann — übersprungen.", titel);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (umfang == Markenumfang.Neue && await db.Markensuchen.AsNoTracking()
|
||||
.AnyAsync(x => x.MovieId == filmId && x.Art == Markenart.Abspann, ct))
|
||||
{
|
||||
log.LogInformation("„{Film}“: schon durchsucht — übersprungen.", titel);
|
||||
@@ -468,7 +518,8 @@ public sealed class Markenerkennung(
|
||||
private readonly record struct Serienstand(int Marken, bool Angefasst);
|
||||
|
||||
private async Task<Serienstand> LaufenAsync(
|
||||
Guid serieId, bool erneut, CancellationToken ct, Action<Scanschritt>? melden = null)
|
||||
Guid serieId, Markenumfang umfang, CancellationToken ct,
|
||||
Action<Scanschritt>? melden = null)
|
||||
{
|
||||
using var raum = scopes.CreateScope();
|
||||
var db = raum.ServiceProvider.GetRequiredService<FlaroContext>();
|
||||
@@ -505,6 +556,15 @@ public sealed class Markenerkennung(
|
||||
.Where(x => x.SeriesId == serieId)
|
||||
.ToListAsync(ct);
|
||||
|
||||
// **Und was tatsächlich an den Folgen hängt.** Für „nur die
|
||||
// Lücken" reicht der Vermerk nicht: er sagt, dass gesucht wurde,
|
||||
// nicht ob etwas herauskam.
|
||||
var vorhanden = await db.Marken.AsNoTracking()
|
||||
.Where(m => m.EpisodeId != null
|
||||
&& db.Episodes.Any(f => f.Id == m.EpisodeId && f.SeriesId == serieId))
|
||||
.Select(m => new { Folge = m.EpisodeId!.Value, m.Art })
|
||||
.ToListAsync(ct);
|
||||
|
||||
var staffeln = folgen.GroupBy(f => f.SeasonNumber).OrderBy(g => g.Key).ToList();
|
||||
|
||||
log.LogInformation("„{Serie}“: {Staffeln} Staffel(n), {Folgen} Folge(n).",
|
||||
@@ -528,10 +588,18 @@ public sealed class Markenerkennung(
|
||||
// Folge, und Vorspann wie Abspann sind danach zwei Ausschnitte
|
||||
// aus demselben Abdruck. Das ist nicht nur richtig, sondern
|
||||
// billiger als zweimal lesen.
|
||||
// Eine Art fehlt dieser Staffel, sobald irgendeine ihrer Folgen
|
||||
// sie nicht hat. Der Tonabgleich ist Sache der ganzen Staffel,
|
||||
// nicht der einzelnen Folge — für eine Lücke läuft sie ganz.
|
||||
bool Luecke(Markenart art) => geordnet.Exists(
|
||||
f => !vorhanden.Exists(v => v.Folge == f.Id && v.Art == art));
|
||||
|
||||
var fehlt = new[] { Markenart.Intro, Markenart.Abspann }
|
||||
.Where(art => erneut || gesucht.FirstOrDefault(
|
||||
x => x.Staffel == staffel.Key && x.Art == art) is not { } lauf
|
||||
|| lauf.Folgen != geordnet.Count)
|
||||
.Where(art => umfang == Markenumfang.Alles
|
||||
|| gesucht.FirstOrDefault(
|
||||
x => x.Staffel == staffel.Key && x.Art == art) is not { } lauf
|
||||
|| lauf.Folgen != geordnet.Count
|
||||
|| (umfang == Markenumfang.Luecken && Luecke(art)))
|
||||
.ToList();
|
||||
if (fehlt.Count == 0) continue;
|
||||
angefasst = true;
|
||||
|
||||
Reference in New Issue
Block a user