„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:
Scarriffle
2026-10-15 14:20:47 +02:00
co-authored by Claude Opus 5
parent f2752e6c05
commit f79ecf2757
6 changed files with 271 additions and 54 deletions
@@ -893,11 +893,12 @@ public static class LibraryContentEndpoints
// Hintergrundlauf statt einer Antwort, auf die jemand wartet. // Hintergrundlauf statt einer Antwort, auf die jemand wartet.
// Dasselbe für einen Film — dort sucht der Server im Bild. // Dasselbe für einen Film — dort sucht der Server im Bild.
app.MapPost("/api/v1/movies/{id:guid}/marken", async ( 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(); if (!await db.Movies.AsNoTracking().AnyAsync(x => x.Id == id)) return Results.NotFound();
_ = erkennung.FilmAsync(id, erneut); _ = erkennung.FilmAsync(id, umfang);
return Results.Accepted(); return Results.Accepted();
}).RequireAuthorization(FlaroPolicies.Administrator); }).RequireAuthorization(FlaroPolicies.Administrator);
@@ -914,12 +915,17 @@ public static class LibraryContentEndpoints
return weg > 0 ? Results.NoContent() : Results.NotFound(); return weg > 0 ? Results.NoContent() : Results.NotFound();
}).RequireAuthorization(FlaroPolicies.Administrator); }).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 ( 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(); 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() ? Results.Accepted()
: Einwand.Belegt("Für diese Serie werden gerade Marken gesucht."); : Einwand.Belegt("Für diese Serie werden gerade Marken gesucht.");
}).RequireAuthorization(FlaroPolicies.Administrator); }).RequireAuthorization(FlaroPolicies.Administrator);
@@ -930,12 +936,13 @@ public static class LibraryContentEndpoints
// einmal. Der Lauf kann Stunden dauern und läuft deshalb im // einmal. Der Lauf kann Stunden dauern und läuft deshalb im
// Hintergrund weiter; die Antwort kommt sofort. // Hintergrund weiter; die Antwort kommt sofort.
app.MapPost("/api/v1/libraries/{id:guid}/marken", async ( 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)) if (!await db.Libraries.AsNoTracking().AnyAsync(x => x.Id == id))
return Results.NotFound(); return Results.NotFound();
erkennung.MediathekStarten(id, erneut); erkennung.MediathekStarten(id, umfang);
return Results.Accepted(); return Results.Accepted();
}).RequireAuthorization(FlaroPolicies.Administrator); }).RequireAuthorization(FlaroPolicies.Administrator);
+90 -22
View File
@@ -7,6 +7,37 @@ using Microsoft.EntityFrameworkCore;
namespace Flaro.Api; 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> /// <summary>Sucht Vorspann und Abspann einer Staffel.</summary>
/// ///
/// <remarks> /// <remarks>
@@ -105,7 +136,7 @@ public sealed class Markenerkennung(
_stand.TryGetValue(mediathek, out var schritt) ? schritt : null; _stand.TryGetValue(mediathek, out var schritt) ? schritt : null;
/// <summary>Startet den Lauf für eine Serie — oder sagt, dass schon einer läuft.</summary> /// <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; if (!_laufend.TryAdd(serieId, true)) return false;
@@ -113,7 +144,7 @@ public sealed class Markenerkennung(
{ {
try try
{ {
await LaufenAsync(serieId, erneut, CancellationToken.None); await LaufenAsync(serieId, umfang, CancellationToken.None);
} }
catch (Exception fehler) catch (Exception fehler)
{ {
@@ -146,7 +177,8 @@ public sealed class Markenerkennung(
/// </para> /// </para>
/// </remarks> /// </remarks>
public async Task NachScanAsync( 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(); using var raum = scopes.CreateScope();
var db = raum.ServiceProvider.GetRequiredService<FlaroContext>(); var db = raum.ServiceProvider.GetRequiredService<FlaroContext>();
@@ -171,7 +203,7 @@ public sealed class Markenerkennung(
if (serien.Count > 0 && await leser.VorhandenAsync(ct)) if (serien.Count > 0 && await leser.VorhandenAsync(ct))
{ {
await SerienAsync(db, mediathekname, serien 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) else if (serien.Count > 0)
{ {
@@ -184,7 +216,7 @@ public sealed class Markenerkennung(
// Stand hier früher `if (serien.Count == 0) return;`, und damit // Stand hier früher `if (serien.Count == 0) return;`, und damit
// kam eine reine Filmmediathek nie bis zu dieser Zeile: der // kam eine reine Filmmediathek nie bis zu dieser Zeile: der
// Abspann eines Films wurde nach einem Scan nie gesucht. // 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); schritt => _stand[mediathek] = schritt);
} }
finally finally
@@ -196,7 +228,7 @@ public sealed class Markenerkennung(
/// <summary>Alle Serien einer Mediathek, eine nach der anderen.</summary> /// <summary>Alle Serien einer Mediathek, eine nach der anderen.</summary>
private async Task SerienAsync( private async Task SerienAsync(
FlaroContext db, string mediathekname, IReadOnlyList<(Guid Id, string Titel)> serien, 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() var folgen = await db.Episodes.AsNoTracking()
.CountAsync(f => db.Series.Any(s => s.Id == f.SeriesId && s.LibraryId == mediathek), ct); .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; if (!_laufend.TryAdd(id, true)) continue;
try try
{ {
var stand = await LaufenAsync(id, erneut, ct, Melden); var stand = await LaufenAsync(id, umfang, ct, Melden);
marken += stand.Marken; marken += stand.Marken;
if (!stand.Angefasst) continue; if (!stand.Angefasst) continue;
angefasst++; angefasst++;
@@ -259,11 +291,11 @@ public sealed class Markenerkennung(
/// Tagesprotokoll — und an den Folgen selbst, bei denen danach eine /// Tagesprotokoll — und an den Folgen selbst, bei denen danach eine
/// Marke steht. /// Marke steht.
/// </remarks> /// </remarks>
public void MediathekStarten(Guid mediathek, bool erneut) public void MediathekStarten(Guid mediathek, Markenumfang umfang)
{ {
_ = Task.Run(async () => _ = Task.Run(async () =>
{ {
try { await NachScanAsync(mediathek, erneut, CancellationToken.None); } try { await NachScanAsync(mediathek, umfang, CancellationToken.None); }
catch (Exception fehler) catch (Exception fehler)
{ {
log.LogWarning(fehler, "Markenlauf für Mediathek {Kennung} abgebrochen: {Grund}", 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. /// läuft dann ins Leere, und das kostet eine Abfrage.
/// </remarks> /// </remarks>
private async Task FilmeAsync( 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) CancellationToken ct, Action<Scanschritt> melden)
{ {
var offen = await db.Movies.AsNoTracking() // **In C# verzweigt, nicht im Ausdruck.** Drei Bedingungen in einem
.Where(m => m.LibraryId == mediathek) // `Where` mit einem Aufzählungswert übersetzt EF Core zwar meist,
// Bei „erneut" auch die, bei denen schon gesucht wurde: genau // aber „meist" ist hier kein Argument: so ist sicher, was in SQL
// dafür drückt jemand den Knopf. // ankommt.
.Where(m => erneut var abfrage = db.Movies.AsNoTracking().Where(m => m.LibraryId == mediathek);
|| !db.Markensuchen.Any(x => x.MovieId == m.Id && x.Art == Markenart.Abspann)) 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 }) .Select(m => new { m.Id, m.Title, m.Year })
.OrderBy(m => m.Title) .OrderBy(m => m.Title)
.ToListAsync(ct); .ToListAsync(ct);
@@ -306,7 +348,7 @@ public sealed class Markenerkennung(
var titel = Filmname(film.Title, film.Year); var titel = Filmname(film.Title, film.Year);
melden(new Scanschritt(Scanphase.Marken, titel, i + 1, offen.Count)); 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) catch (Exception fehler)
{ {
log.LogWarning(fehler, "Abspannsuche für „{Film}“ abgebrochen: {Grund}", 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. /// Sekunden ungenau. Die Kante holt danach eine Handvoll kurzer Sonden.
/// </para> /// </para>
/// </remarks> /// </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(); using var raum = scopes.CreateScope();
var db = raum.ServiceProvider.GetRequiredService<FlaroContext>(); var db = raum.ServiceProvider.GetRequiredService<FlaroContext>();
@@ -372,7 +415,14 @@ public sealed class Markenerkennung(
return false; 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)) .AnyAsync(x => x.MovieId == filmId && x.Art == Markenart.Abspann, ct))
{ {
log.LogInformation("„{Film}“: schon durchsucht — übersprungen.", titel); 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 readonly record struct Serienstand(int Marken, bool Angefasst);
private async Task<Serienstand> LaufenAsync( 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(); using var raum = scopes.CreateScope();
var db = raum.ServiceProvider.GetRequiredService<FlaroContext>(); var db = raum.ServiceProvider.GetRequiredService<FlaroContext>();
@@ -505,6 +556,15 @@ public sealed class Markenerkennung(
.Where(x => x.SeriesId == serieId) .Where(x => x.SeriesId == serieId)
.ToListAsync(ct); .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(); var staffeln = folgen.GroupBy(f => f.SeasonNumber).OrderBy(g => g.Key).ToList();
log.LogInformation("„{Serie}“: {Staffeln} Staffel(n), {Folgen} Folge(n).", 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 // Folge, und Vorspann wie Abspann sind danach zwei Ausschnitte
// aus demselben Abdruck. Das ist nicht nur richtig, sondern // aus demselben Abdruck. Das ist nicht nur richtig, sondern
// billiger als zweimal lesen. // 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 } var fehlt = new[] { Markenart.Intro, Markenart.Abspann }
.Where(art => erneut || gesucht.FirstOrDefault( .Where(art => umfang == Markenumfang.Alles
|| gesucht.FirstOrDefault(
x => x.Staffel == staffel.Key && x.Art == art) is not { } lauf x => x.Staffel == staffel.Key && x.Art == art) is not { } lauf
|| lauf.Folgen != geordnet.Count) || lauf.Folgen != geordnet.Count
|| (umfang == Markenumfang.Luecken && Luecke(art)))
.ToList(); .ToList();
if (fehlt.Count == 0) continue; if (fehlt.Count == 0) continue;
angefasst = true; angefasst = true;
+105
View File
@@ -296,4 +296,109 @@ public class MarkenTests
"der Vermerk bleibt, sonst legte der nächste Scan dieselbe Marke wieder an"); "der Vermerk bleibt, sonst legte der nächste Scan dieselbe Marke wieder an");
}); });
} }
/// <summary>Wie weit ein Lauf greift — drei Umfänge, drei Antworten.</summary>
///
/// <remarks>
/// <para>
/// <b>„Schon durchsucht" und „hat eine Marke" sind zwei verschiedene
/// Dinge.</b> 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.
/// </para>
/// <para>
/// Geprüft wird am Vermerk: fasst der Lauf den Film an, schreibt er
/// <c>GelaufenAm</c> neu. Das ist die einzige Spur, die auch ohne
/// ffmpeg und ohne echten Abspann entsteht.
/// </para>
/// </remarks>
[TestCase("Neue", false, TestName = "Neue überspringt, was schon durchsucht ist")]
[TestCase("Luecken", true, TestName = "Luecken nimmt sich den Film ohne Marke vor")]
[TestCase("Alles", true, TestName = "Alles nimmt sich alles vor")]
public async Task Der_Umfang_entscheidet_bei_einem_Film_ohne_Marke(
string umfang, bool erwartetAngefasst)
{
Datei("Matrix (1999)/Matrix (1999).mkv");
using var server = new FlaroServer();
var client = await Eingelesen(server, "Movies");
var filme = await client.GetFromJsonAsync<Page<MovieResponse>>("/api/v1/movies");
var film = filme!.Items[0].Id;
// Gesucht wurde schon, gefunden nichts: Vermerk ja, Marke nein.
var vorher = new DateTimeOffset(2020, 1, 1, 0, 0, 0, TimeSpan.Zero);
await server.InDerDatenbank(async db =>
{
// **Ohne Laufzeit kommt der Lauf nicht bis zum Vermerk.** Die
// Prüfdatei ist leer; die Laufzeit steht sonst aus ffprobe.
var datei = await db.MediaFiles.FirstAsync(d => d.MovieId == film);
datei.Duration = TimeSpan.FromMinutes(120);
db.Markensuchen.Add(new Markensuche
{
MovieId = film, Art = Markenart.Abspann, Folgen = 1, GelaufenAm = vorher,
});
await db.SaveChangesAsync();
});
(await client.PostAsync($"/api/v1/movies/{film}/marken?umfang={umfang}", null))
.EnsureSuccessStatusCode();
var gelaufen = vorher;
for (var versuch = 0; versuch < 40; versuch++)
{
await server.InDerDatenbank(async db =>
gelaufen = await db.Markensuchen.AsNoTracking()
.Where(x => x.MovieId == film).Select(x => x.GelaufenAm).FirstAsync());
if (gelaufen != vorher) break;
await Task.Delay(50);
}
Assert.That(gelaufen != vorher, Is.EqualTo(erwartetAngefasst),
erwartetAngefasst
? "dieser Umfang muss den Film noch einmal anfassen"
: "dieser Umfang muss ihn überspringen");
}
/// <summary>Ein Film mit Marke bleibt bei „nur Lücken" unangetastet.</summary>
[Test]
public async Task Luecken_laesst_einen_Film_mit_Marke_in_Ruhe()
{
Datei("Matrix (1999)/Matrix (1999).mkv");
using var server = new FlaroServer();
var client = await Eingelesen(server, "Movies");
var filme = await client.GetFromJsonAsync<Page<MovieResponse>>("/api/v1/movies");
var film = filme!.Items[0].Id;
var vorher = new DateTimeOffset(2020, 1, 1, 0, 0, 0, TimeSpan.Zero);
await server.InDerDatenbank(async db =>
{
var datei = await db.MediaFiles.FirstAsync(d => d.MovieId == film);
datei.Duration = TimeSpan.FromMinutes(120);
db.Marken.Add(new Marke
{
MovieId = film, Art = Markenart.Abspann,
VonSekunden = 6900, BisSekunden = 7200, ErkanntAm = vorher,
});
db.Markensuchen.Add(new Markensuche
{
MovieId = film, Art = Markenart.Abspann, Folgen = 1, GelaufenAm = vorher,
});
await db.SaveChangesAsync();
});
(await client.PostAsync($"/api/v1/movies/{film}/marken?umfang=Luecken", null))
.EnsureSuccessStatusCode();
await Task.Delay(400);
var gelaufen = DateTimeOffset.MinValue;
await server.InDerDatenbank(async db =>
gelaufen = await db.Markensuchen.AsNoTracking()
.Where(x => x.MovieId == film).Select(x => x.GelaufenAm).FirstAsync());
Assert.That(gelaufen, Is.EqualTo(vorher),
"wo schon eine Marke steht, ist nichts zu füllen");
}
} }
+20 -7
View File
@@ -659,9 +659,23 @@ export const naechsteFolgen = (take = 20) =>
* *
* Antwortet mit 202: der Lauf dauert Minuten — er liest von jeder Folge * Antwortet mit 202: der Lauf dauert Minuten — er liest von jeder Folge
* einer Staffel ein Stück Ton —, und darauf wartet niemand. */ * einer Staffel ein Stück Ton —, und darauf wartet niemand. */
export const markenSuchen = (serieId: string, erneut = false) => /** Wie weit ein Markenlauf greift.
*
* **„Schon durchsucht" und „hat eine Marke" sind zwei verschiedene
* Dinge.** Ein Titel, 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. */
export type Markenumfang =
/** Nur, wo noch nie gesucht wurde. */
| 'Neue'
/** Auch, wo gesucht und nichts gefunden wurde — nicht, wo schon eine Marke steht. */
| 'Luecken'
/** Alles, auch was längst eine Marke hat. */
| 'Alles';
export const markenSuchen = (serieId: string, umfang: Markenumfang = 'Alles') =>
anfrage<void>( anfrage<void>(
`/series/${serieId}/marken${erneut ? '?erneut=true' : ''}`, { method: 'POST' }); `/series/${serieId}/marken?umfang=${umfang}`, { method: 'POST' });
/** Die Marken eines Titels — Folge oder Film. /** Die Marken eines Titels — Folge oder Film.
* *
@@ -684,17 +698,16 @@ export async function markenHolen(titelId: string): Promise<Marke[]> {
* *
* **Antwortet sofort, arbeitet weiter.** Der Lauf liest je Folge ein Stück * **Antwortet sofort, arbeitet weiter.** Der Lauf liest je Folge ein Stück
* Ton; über fünfundneunzig Serien sind das Stunden. */ * Ton; über fünfundneunzig Serien sind das Stunden. */
export const mediathekMarken = (id: string, erneut = false) => export const mediathekMarken = (id: string, umfang: Markenumfang) =>
anfrage<void>( anfrage<void>(`/libraries/${id}/marken?umfang=${umfang}`, { method: 'POST' });
`/libraries/${id}/marken${erneut ? '?erneut=true' : ''}`, { method: 'POST' });
/** Nimmt die Marken eines Films wieder weg — wenn die Erkennung danebenlag. */ /** Nimmt die Marken eines Films wieder weg — wenn die Erkennung danebenlag. */
export const filmmarkenVerwerfen = (filmId: string) => export const filmmarkenVerwerfen = (filmId: string) =>
anfrage<void>(`/movies/${filmId}/marken`, { method: 'DELETE' }); anfrage<void>(`/movies/${filmId}/marken`, { method: 'DELETE' });
/** Stösst die Abspannsuche für einen Film an. */ /** Stösst die Abspannsuche für einen Film an. */
export const filmmarkenSuchen = (filmId: string, erneut = false) => export const filmmarkenSuchen = (filmId: string, umfang: Markenumfang = 'Alles') =>
anfrage<void>(`/movies/${filmId}/marken${erneut ? '?erneut=true' : ''}`, { method: 'POST' }); anfrage<void>(`/movies/${filmId}/marken?umfang=${umfang}`, { method: 'POST' });
export async function folgeLesen(id: string): Promise<Folge | null> { export async function folgeLesen(id: string): Promise<Folge | null> {
try { try {
+2 -2
View File
@@ -513,7 +513,7 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem
if (melden && istAdmin() && art === 'filme') { if (melden && istAdmin() && art === 'filme') {
reihe.appendChild(rundKnopf('uhr', false, 'Abspann neu suchen', reihe.appendChild(rundKnopf('uhr', false, 'Abspann neu suchen',
async () => { async () => {
await filmmarkenSuchen(eintrag.id, true); await filmmarkenSuchen(eintrag.id, 'Alles');
hinweisZeigen('Der Abspann wird gesucht. Der Server sieht sich dafür ' hinweisZeigen('Der Abspann wird gesucht. Der Server sieht sich dafür '
+ 'die letzten zwanzig Minuten an — das dauert einen Augenblick.'); + 'die letzten zwanzig Minuten an — das dauert einen Augenblick.');
// Kurz warten und nachsehen: anders als bei einer Serie ist das // Kurz warten und nachsehen: anders als bei einer Serie ist das
@@ -536,7 +536,7 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem
if (melden && istAdmin() && art === 'serien') { if (melden && istAdmin() && art === 'serien') {
reihe.appendChild(rundKnopf('uhr', false, 'Vorspann und Abspann neu suchen', reihe.appendChild(rundKnopf('uhr', false, 'Vorspann und Abspann neu suchen',
async () => { async () => {
await markenSuchen(eintrag.id, true); await markenSuchen(eintrag.id, 'Alles');
hinweisZeigen('Vorspann und Abspann werden gesucht. ' hinweisZeigen('Vorspann und Abspann werden gesucht. '
+ 'Der Server liest dafür jede Folge einmal durch — das dauert ein paar Minuten.'); + 'Der Server liest dafür jede Folge einmal durch — das dauert ein paar Minuten.');
})); }));
+39 -15
View File
@@ -1,6 +1,6 @@
import { import {
bibliotheken, bibliothekAnlegen, bibliothekUmbenennen, bibliothekLoeschen, scanStarten, bibliotheken, bibliothekAnlegen, bibliothekUmbenennen, bibliothekLoeschen, scanStarten,
mediathekMarken, mediathekMarken, type Markenumfang,
scanstand, metadatenAuffrischen, vermisste, vermisstesEntfernen, type VermissteDatei, scanstand, metadatenAuffrischen, vermisste, vermisstesEntfernen, type VermissteDatei,
pruefungSetzen, mediathekZuruecksetzen, type Bibliothek, pruefungSetzen, mediathekZuruecksetzen, type Bibliothek,
} from '../../api'; } from '../../api';
@@ -227,22 +227,45 @@ export function bibliotheksbereich(
// Angebot.** Den gibt es auf der Serienseite; hier gilt er für alles, // Angebot.** Den gibt es auf der Serienseite; hier gilt er für alles,
// was in dieser Mediathek liegt — Serien über den Tonabgleich, Filme // was in dieser Mediathek liegt — Serien über den Tonabgleich, Filme
// über das Bild. // über das Bild.
const marken = knopf('Vorspann und Abspann suchen', 'btn'); /** Startet einen Markenlauf und hängt die Standzeile daran. */
marken.title = 'Sucht überspringbare Abschnitte in allen Titeln dieser Mediathek.'; function markenlauf(umfang: Markenumfang): void {
marken.onclick = () => nachfragen({
titel: `Vorspann und Abspann in „${aktuell.name}" suchen?`,
text: 'Der Server liest dafür von jeder Folge einmal die Tonspur und sieht '
+ 'sich bei Filmen die letzten zwanzig Minuten an. Über einen grossen '
+ 'Bestand dauert das Stunden — es läuft im Hintergrund weiter, und im '
+ 'Protokoll von heute steht, wie weit es ist. Schon durchsuchte Titel '
+ 'werden noch einmal geprüft.',
bestaetigen: 'Suchen',
tun: async () => {
laufendSetzen(true); laufendSetzen(true);
await mediathekMarken(aktuell.id, true); void mediathekMarken(aktuell.id, umfang).then(() => {
hinweisZeigen('Die Suche läuft. Der Stand steht hier und im Protokoll von heute.'); hinweisZeigen('Die Suche läuft. Der Stand steht hier und im Protokoll von heute.');
void verfolgen(false); void verfolgen(false);
}, });
}
// **Zwei Knöpfe, weil es zwei Fragen sind.**
//
// „Was fehlt noch?" ist die alltägliche: nach jeder Verbesserung an
// der Erkennung will man die Titel noch einmal, bei denen nichts
// herauskam — und eben nicht die vierhundert, bei denen längst etwas
// steht. „Alles neu" ist die seltene und teure; sie gehört nicht
// hinter denselben Knopf.
const marken = knopf('Fehlende Marken suchen', 'btn');
marken.title = 'Nimmt sich die Titel vor, bei denen keine Marke steht.';
marken.onclick = () => nachfragen({
titel: `Fehlende Marken in „${aktuell.name}" suchen?`,
text: 'Nur, wo noch kein Vorspann oder Abspann steht. Bei einer Serie '
+ 'wird eine Staffel ganz gelesen, sobald einer ihrer Folgen etwas '
+ 'fehlt — der Tonabgleich vergleicht die Folgen miteinander und '
+ 'geht nicht einzeln. Es läuft im Hintergrund weiter.',
bestaetigen: 'Suchen',
tun: async () => markenlauf('Luecken'),
});
const markenAlle = knopf('Alles neu durchsuchen', 'btn');
markenAlle.title = 'Bestimmt auch vorhandene Marken noch einmal.';
markenAlle.onclick = () => nachfragen({
titel: `Alle Marken in „${aktuell.name}" neu bestimmen?`,
text: `Jeder Titel wird noch einmal durchsucht — auch die, bei denen `
+ `längst eine Marke steht. Der Server liest dafür von jeder Folge die `
+ `Tonspur und sieht sich bei jedem Film die letzten zwanzig Minuten an. `
+ `Bei ${aktuell.itemCount ?? 'vielen'} Titeln dauert das Stunden. `
+ `Für „was fehlt noch?" ist der Knopf daneben der richtige.`,
bestaetigen: 'Alles neu',
tun: async () => markenlauf('Alles'),
}); });
const umbenennen = knopf('Umbenennen', 'btn'); const umbenennen = knopf('Umbenennen', 'btn');
@@ -379,7 +402,8 @@ export function bibliotheksbereich(
// und danach nachzusehen. Der Knopf heisst „Seitenleiste bearbeiten". // und danach nachzusehen. Der Knopf heisst „Seitenleiste bearbeiten".
const knoepfe = document.createElement('div'); const knoepfe = document.createElement('div');
knoepfe.className = 'knopfreihe'; knoepfe.className = 'knopfreihe';
knoepfe.append(los, auffrischen, marken, pruefung, umbenennen, zuruecksetzen, entfernen); knoepfe.append(los, auffrischen, marken, markenAlle, pruefung, umbenennen,
zuruecksetzen, entfernen);
// **Nur wenn hier wirklich etwas liegt.** Ein Knopf, der nichts zu tun // **Nur wenn hier wirklich etwas liegt.** Ein Knopf, der nichts zu tun
// hat, ist schlechter als keiner — und die Zahl daneben sagt, worum es // hat, ist schlechter als keiner — und die Zahl daneben sagt, worum es