Einen Knopf je Serie ist bei fünfundneunzig kein Angebot

Es gab ihn: auf der Serienseite, das Uhrsymbol. Nur gilt er für genau
eine Serie — und nach jeder Änderung an der Erkennung will man ohnehin
alles noch einmal. Der Weg dorthin fehlte da, wo man ihn sucht: bei der
Mediathek, neben „Scannen" und „Metadaten auffrischen".

Der Lauf selbst war schon da. `NachScanAsync` macht genau das, Serien
über den Tonabgleich und Filme über das Bild — er hing bloss am Scan und
liess sich nicht von Hand anstossen. Jetzt nimmt er ein „erneut" entgegen
und ist über einen Endpunkt erreichbar. Die Antwort kommt sofort: über
einen grossen Bestand dauert das Stunden, und eine Anfrage so lange offen
zu halten hiesse, sie in ein Zeitlimit laufen zu lassen. Wie weit es ist,
steht im Protokoll von heute.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-13 21:01:11 +02:00
co-authored by Claude Opus 5
parent 1340176bf0
commit ce4da992ea
4 changed files with 79 additions and 8 deletions
@@ -913,6 +913,21 @@ public static class LibraryContentEndpoints
: 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);
// **Eine ganze Mediathek auf einmal.** Je Serie einen Knopf zu
// drücken ist bei fünfundneunzig Serien kein Angebot — und nach
// jeder Änderung an der Erkennung will man ohnehin alles noch
// 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) =>
{
if (!await db.Libraries.AsNoTracking().AnyAsync(x => x.Id == id))
return Results.NotFound();
erkennung.MediathekStarten(id, erneut);
return Results.Accepted();
}).RequireAuthorization(FlaroPolicies.Administrator);
app.MapGet("/api/v1/episodes/{id:guid}/next", (Guid id, ClaimsPrincipal wer, FlaroContext db) => app.MapGet("/api/v1/episodes/{id:guid}/next", (Guid id, ClaimsPrincipal wer, FlaroContext db) =>
Nachbarfolge(id, wer, db, vorwaerts: true)).RequireAuthorization(); Nachbarfolge(id, wer, db, vorwaerts: true)).RequireAuthorization();
+33 -6
View File
@@ -124,7 +124,8 @@ public sealed class Markenerkennung(
/// nichts mehr zu tun und ist in Sekunden vorbei. /// nichts mehr zu tun und ist in Sekunden vorbei.
/// </para> /// </para>
/// </remarks> /// </remarks>
public async Task NachScanAsync(Guid mediathek, CancellationToken ct = default) public async Task NachScanAsync(
Guid mediathek, bool erneut = false, 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>();
@@ -145,7 +146,7 @@ public sealed class Markenerkennung(
{ {
if (ct.IsCancellationRequested) return; if (ct.IsCancellationRequested) return;
if (!_laufend.TryAdd(serie, true)) continue; if (!_laufend.TryAdd(serie, true)) continue;
try { await LaufenAsync(serie, erneut: false, ct); } try { await LaufenAsync(serie, erneut, ct); }
catch (Exception fehler) catch (Exception fehler)
{ {
log.LogWarning(fehler, "Markenerkennung für {Serie} abgebrochen.", serie); log.LogWarning(fehler, "Markenerkennung für {Serie} abgebrochen.", serie);
@@ -153,11 +154,33 @@ public sealed class Markenerkennung(
finally { _laufend.TryRemove(serie, out _); } finally { _laufend.TryRemove(serie, out _); }
} }
await FilmeAsync(db, mediathek, ct); await FilmeAsync(db, mediathek, erneut, ct);
log.LogInformation("Marken für Mediathek {Kennung} durch.", mediathek); log.LogInformation("Marken für Mediathek {Kennung} durch.", mediathek);
} }
/// <summary>Startet den Lauf für eine ganze Mediathek — im Hintergrund.</summary>
///
/// <remarks>
/// <b>Der Knopf darf nicht warten.</b> Über fünfundneunzig Serien
/// dauert das Stunden; eine Anfrage so lange offen zu halten hiesse,
/// sie in ein Zeitlimit laufen zu lassen. Wie es steht, sieht man im
/// Tagesprotokoll — und an den Folgen selbst, bei denen danach eine
/// Marke steht.
/// </remarks>
public void MediathekStarten(Guid mediathek, bool erneut)
{
_ = Task.Run(async () =>
{
try { await NachScanAsync(mediathek, erneut, CancellationToken.None); }
catch (Exception fehler)
{
log.LogWarning(fehler,
"Markenlauf für Mediathek {Kennung} abgebrochen.", mediathek);
}
});
}
/// <summary>Die Filme einer Mediathek, denen noch ein Abspann fehlt.</summary> /// <summary>Die Filme einer Mediathek, denen noch ein Abspann fehlt.</summary>
/// ///
/// <remarks> /// <remarks>
@@ -165,11 +188,15 @@ public sealed class Markenerkennung(
/// eine Mediathek immer nur eines von beidem enthält. Die Schleife /// eine Mediathek immer nur eines von beidem enthält. Die Schleife
/// 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(FlaroContext db, Guid mediathek, CancellationToken ct) private async Task FilmeAsync(
FlaroContext db, Guid mediathek, bool erneut, CancellationToken ct)
{ {
var offen = await db.Movies.AsNoTracking() var offen = await db.Movies.AsNoTracking()
.Where(m => m.LibraryId == mediathek) .Where(m => m.LibraryId == mediathek)
.Where(m => !db.Markensuchen.Any(x => x.MovieId == m.Id && x.Art == Markenart.Abspann)) // 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))
.Select(m => m.Id) .Select(m => m.Id)
.ToListAsync(ct); .ToListAsync(ct);
if (offen.Count == 0) return; if (offen.Count == 0) return;
@@ -180,7 +207,7 @@ public sealed class Markenerkennung(
foreach (var film in offen) foreach (var film in offen)
{ {
if (ct.IsCancellationRequested) return; if (ct.IsCancellationRequested) return;
try { if (await FilmAsync(film, erneut: false, ct)) gefunden++; } try { if (await FilmAsync(film, erneut, ct)) gefunden++; }
catch (Exception fehler) catch (Exception fehler)
{ {
log.LogWarning(fehler, "Abspannsuche für Film {Film} abgebrochen.", film); log.LogWarning(fehler, "Abspannsuche für Film {Film} abgebrochen.", film);
+8
View File
@@ -675,6 +675,14 @@ export async function markenHolen(titelId: string): Promise<Marke[]> {
} }
} }
/** Sucht Vorspann und Abspann in einer ganzen Mediathek.
*
* **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<void>(
`/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' });
+23 -2
View File
@@ -1,9 +1,10 @@
import { import {
bibliotheken, bibliothekAnlegen, bibliothekUmbenennen, bibliothekLoeschen, scanStarten, bibliotheken, bibliothekAnlegen, bibliothekUmbenennen, bibliothekLoeschen, scanStarten,
mediathekMarken,
scanstand, metadatenAuffrischen, vermisste, vermisstesEntfernen, type VermissteDatei, scanstand, metadatenAuffrischen, vermisste, vermisstesEntfernen, type VermissteDatei,
pruefungSetzen, mediathekZuruecksetzen, type Bibliothek, pruefungSetzen, mediathekZuruecksetzen, type Bibliothek,
} from '../../api'; } from '../../api';
import { esc, knopf, fehlerzeile, leer, meldung } from '../../util'; import { esc, knopf, fehlerzeile, leer, meldung, hinweisZeigen} from '../../util';
import { dialog, nachfragen } from '../../dialog'; import { dialog, nachfragen } from '../../dialog';
import { abschnitt } from './teile'; import { abschnitt } from './teile';
@@ -222,6 +223,26 @@ export function bibliotheksbereich(
}, },
}); });
// **Je Serie einen Knopf zu drücken ist bei fünfundneunzig kein
// 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 () => {
await mediathekMarken(aktuell.id, true);
hinweisZeigen('Die Suche läuft. Das Protokoll von heute zeigt den Fortschritt.');
},
});
const umbenennen = knopf('Umbenennen', 'btn'); const umbenennen = knopf('Umbenennen', 'btn');
umbenennen.onclick = () => dialog({ umbenennen.onclick = () => dialog({
titel: 'Mediathek umbenennen', titel: 'Mediathek umbenennen',
@@ -347,7 +368,7 @@ 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, pruefung, umbenennen, zuruecksetzen, entfernen); knoepfe.append(los, auffrischen, marken, 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