diff --git a/server/src/Flaro.Api/Endpoints/LibraryContentEndpoints.cs b/server/src/Flaro.Api/Endpoints/LibraryContentEndpoints.cs index 21c71f0..6047724 100644 --- a/server/src/Flaro.Api/Endpoints/LibraryContentEndpoints.cs +++ b/server/src/Flaro.Api/Endpoints/LibraryContentEndpoints.cs @@ -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); diff --git a/server/src/Flaro.Api/Markenerkennung.cs b/server/src/Flaro.Api/Markenerkennung.cs index 998ebff..a32266e 100644 --- a/server/src/Flaro.Api/Markenerkennung.cs +++ b/server/src/Flaro.Api/Markenerkennung.cs @@ -7,6 +7,37 @@ using Microsoft.EntityFrameworkCore; namespace Flaro.Api; +/// Wie weit ein Markenlauf greift. +/// +/// +/// +/// „Schon durchsucht" und „hat eine Marke" sind zwei verschiedene +/// Dinge, 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. +/// +/// +/// Nach jeder Verbesserung an der Erkennung will man genau diese Titel +/// noch einmal — und eben nicht die vierhundert, bei denen längst etwas +/// steht. +/// +/// +public enum Markenumfang +{ + /// Nur, wo noch nie gesucht wurde. Der Lauf nach einem Scan. + Neue, + + /// + /// Auch, wo gesucht und nichts gefunden wurde — aber nicht, wo schon + /// eine Marke steht. + /// + Luecken, + + /// Alles, auch was längst eine Marke hat. + Alles, +} + /// Sucht Vorspann und Abspann einer Staffel. /// /// @@ -105,7 +136,7 @@ public sealed class Markenerkennung( _stand.TryGetValue(mediathek, out var schritt) ? schritt : null; /// Startet den Lauf für eine Serie — oder sagt, dass schon einer läuft. - 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( /// /// 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(); @@ -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( /// Alle Serien einer Mediathek, eine nach der anderen. 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. /// - 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. /// private async Task FilmeAsync( - FlaroContext db, string mediathekname, Guid mediathek, bool erneut, + FlaroContext db, string mediathekname, Guid mediathek, Markenumfang umfang, CancellationToken ct, Action 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. /// /// - public async Task FilmAsync(Guid filmId, bool erneut, CancellationToken ct = default) + public async Task FilmAsync( + Guid filmId, Markenumfang umfang, CancellationToken ct = default) { using var raum = scopes.CreateScope(); var db = raum.ServiceProvider.GetRequiredService(); @@ -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 LaufenAsync( - Guid serieId, bool erneut, CancellationToken ct, Action? melden = null) + Guid serieId, Markenumfang umfang, CancellationToken ct, + Action? melden = null) { using var raum = scopes.CreateScope(); var db = raum.ServiceProvider.GetRequiredService(); @@ -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; diff --git a/server/tests/Flaro.Api.Tests/MarkenTests.cs b/server/tests/Flaro.Api.Tests/MarkenTests.cs index 18a488d..10fdca5 100644 --- a/server/tests/Flaro.Api.Tests/MarkenTests.cs +++ b/server/tests/Flaro.Api.Tests/MarkenTests.cs @@ -296,4 +296,109 @@ public class MarkenTests "der Vermerk bleibt, sonst legte der nächste Scan dieselbe Marke wieder an"); }); } + + /// Wie weit ein Lauf greift — drei Umfänge, drei Antworten. + /// + /// + /// + /// „Schon durchsucht" und „hat eine Marke" sind zwei verschiedene + /// Dinge. 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. + /// + /// + /// Geprüft wird am Vermerk: fasst der Lauf den Film an, schreibt er + /// GelaufenAm neu. Das ist die einzige Spur, die auch ohne + /// ffmpeg und ohne echten Abspann entsteht. + /// + /// + [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>("/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"); + } + + /// Ein Film mit Marke bleibt bei „nur Lücken" unangetastet. + [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>("/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"); + } } diff --git a/web/app/src/api.ts b/web/app/src/api.ts index 8e9ad68..17efbaf 100644 --- a/web/app/src/api.ts +++ b/web/app/src/api.ts @@ -659,9 +659,23 @@ export const naechsteFolgen = (take = 20) => * * Antwortet mit 202: der Lauf dauert Minuten — er liest von jeder Folge * 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( - `/series/${serieId}/marken${erneut ? '?erneut=true' : ''}`, { method: 'POST' }); + `/series/${serieId}/marken?umfang=${umfang}`, { method: 'POST' }); /** Die Marken eines Titels — Folge oder Film. * @@ -684,17 +698,16 @@ export async function markenHolen(titelId: string): Promise { * * **Antwortet sofort, arbeitet weiter.** Der Lauf liest je Folge ein Stück * Ton; über fünfundneunzig Serien sind das Stunden. */ -export const mediathekMarken = (id: string, erneut = false) => - anfrage( - `/libraries/${id}/marken${erneut ? '?erneut=true' : ''}`, { method: 'POST' }); +export const mediathekMarken = (id: string, umfang: Markenumfang) => + anfrage(`/libraries/${id}/marken?umfang=${umfang}`, { method: 'POST' }); /** Nimmt die Marken eines Films wieder weg — wenn die Erkennung danebenlag. */ export const filmmarkenVerwerfen = (filmId: string) => anfrage(`/movies/${filmId}/marken`, { method: 'DELETE' }); /** Stösst die Abspannsuche für einen Film an. */ -export const filmmarkenSuchen = (filmId: string, erneut = false) => - anfrage(`/movies/${filmId}/marken${erneut ? '?erneut=true' : ''}`, { method: 'POST' }); +export const filmmarkenSuchen = (filmId: string, umfang: Markenumfang = 'Alles') => + anfrage(`/movies/${filmId}/marken?umfang=${umfang}`, { method: 'POST' }); export async function folgeLesen(id: string): Promise { try { diff --git a/web/app/src/views/detail.ts b/web/app/src/views/detail.ts index b4d5ad4..e601860 100644 --- a/web/app/src/views/detail.ts +++ b/web/app/src/views/detail.ts @@ -513,7 +513,7 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem if (melden && istAdmin() && art === 'filme') { reihe.appendChild(rundKnopf('uhr', false, 'Abspann neu suchen', async () => { - await filmmarkenSuchen(eintrag.id, true); + await filmmarkenSuchen(eintrag.id, 'Alles'); hinweisZeigen('Der Abspann wird gesucht. Der Server sieht sich dafür ' + 'die letzten zwanzig Minuten an — das dauert einen Augenblick.'); // 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') { reihe.appendChild(rundKnopf('uhr', false, 'Vorspann und Abspann neu suchen', async () => { - await markenSuchen(eintrag.id, true); + await markenSuchen(eintrag.id, 'Alles'); hinweisZeigen('Vorspann und Abspann werden gesucht. ' + 'Der Server liest dafür jede Folge einmal durch — das dauert ein paar Minuten.'); })); diff --git a/web/app/src/views/einstellungen/mediatheken.ts b/web/app/src/views/einstellungen/mediatheken.ts index 598da84..d1298d9 100644 --- a/web/app/src/views/einstellungen/mediatheken.ts +++ b/web/app/src/views/einstellungen/mediatheken.ts @@ -1,6 +1,6 @@ import { bibliotheken, bibliothekAnlegen, bibliothekUmbenennen, bibliothekLoeschen, scanStarten, - mediathekMarken, + mediathekMarken, type Markenumfang, scanstand, metadatenAuffrischen, vermisste, vermisstesEntfernen, type VermissteDatei, pruefungSetzen, mediathekZuruecksetzen, type Bibliothek, } from '../../api'; @@ -227,22 +227,45 @@ export function bibliotheksbereich( // Angebot.** Den gibt es auf der Serienseite; hier gilt er für alles, // was in dieser Mediathek liegt — Serien über den Tonabgleich, Filme // über das Bild. - const marken = knopf('Vorspann und Abspann suchen', 'btn'); - marken.title = 'Sucht überspringbare Abschnitte in allen Titeln dieser Mediathek.'; - 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); - await mediathekMarken(aktuell.id, true); + /** Startet einen Markenlauf und hängt die Standzeile daran. */ + function markenlauf(umfang: Markenumfang): void { + laufendSetzen(true); + void mediathekMarken(aktuell.id, umfang).then(() => { hinweisZeigen('Die Suche läuft. Der Stand steht hier und im Protokoll von heute.'); 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'); @@ -379,7 +402,8 @@ export function bibliotheksbereich( // und danach nachzusehen. Der Knopf heisst „Seitenleiste bearbeiten". const knoepfe = document.createElement('div'); 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 // hat, ist schlechter als keiner — und die Zahl daneben sagt, worum es