Serie für Serie, Film für Film — statt einer Zeile mit einer GUID
Die Markensuche lief stundenlang über 95 Serien, 4213 Folgen und 453
Filme und schrieb dabei fünf Zeilen, in denen Kennungen standen statt
Namen. „Markenerkennung für 4f3a…: 12 Marken." beantwortet keine der
Fragen, die in den letzten Tagen tatsächlich aufkamen — welche Staffel
nichts bekommen hat, warum, und ob die Serie überhaupt dran war.
Jetzt nennt jede Zeile den Titel, und es gibt eine je Serie, je Staffel
und je Folge:
„Biohackers": 2 Staffel(n), 18 Folge(n).
„Biohackers", Staffel 2: Vorspann in 8 von 8 Folge(n), Abspann in 1 von 8.
Biohackers S02E01 · Neuanfang: Vorspann 0:31–1:24, Abspann ab 41:12 (über das Bild).
Biohackers S02E07 · Letzte Fahrt: Vorspann 0:31–1:24, kein Abspann.
„Biohackers" fertig: 14 Marke(n).
Die Folgenzeilen werden **aus der Datenbank gelesen, nicht mitgezählt**:
so steht dort, was danach wirklich an der Folge hängt, und nicht, was
dieser eine Lauf geschrieben hat. Nur das beantwortet „warum kommt bei
dieser Folge kein Knopf?".
**Drei Gründe statt eines `false`.** `FilmAsync` gab für „keine Datei",
„schon durchsucht" und „nichts gefunden" dasselbe zurück, und von aussen
waren sie nicht zu unterscheiden. Jetzt sagt jeder seinen Namen.
**Und eine Live-Zeile bei der Mediathek**, wie der Scan sie hat. Sie
benutzt denselben Mechanismus — `Scanschritt` mit einer neuen Phase,
derselbe Endpunkt, ein eigenes Feld. Ein eigenes, weil der Scan sich auf
„fertig" setzt, sobald er die Markensuche angestossen hat; in sein Feld
geschrieben sähe er wieder laufend aus.
**Zwei Fehler sind dabei herausgefallen.** In `NachScanAsync` stand
`if (serien.Count == 0) return;` vor dem Filmteil — eine reine
Filmmediathek bekam nach einem Scan also nie eine Abspannsuche. Und die
fpcalc-Prüfung stand ganz oben und galt für beides, obwohl der Bildweg
fpcalc gar nicht braucht: ohne fpcalc fand der Server keinen einzigen
Filmabspann.
Dazu ein Suchfeld im Protokoll und tausend Anfangszeilen statt
dreihundert. Ohne das wäre eine Zeile je Folge keine Auskunft, sondern
eine Mauer — genau der Fall, gegen den sich `ScanCoordinator` für den
Scan schon einmal entschieden hat.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
70ab4cc421
commit
767491ef03
@@ -322,7 +322,16 @@ public sealed record ScanStatus(
|
|||||||
/// <summary>Wie viele Dateien dieser Lauf als verschwunden gemeldet hat.</summary>
|
/// <summary>Wie viele Dateien dieser Lauf als verschwunden gemeldet hat.</summary>
|
||||||
int? Missing = null,
|
int? Missing = null,
|
||||||
/// <summary>Woran der Lauf gerade ist, in einem Satz.</summary>
|
/// <summary>Woran der Lauf gerade ist, in einem Satz.</summary>
|
||||||
string? Step = null);
|
string? Step = null,
|
||||||
|
/// <summary>Woran die Markensuche gerade ist — <c>null</c>, wenn keine läuft.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Ein eigenes Feld, nicht <see cref="Step"/>.</b> Die Markensuche
|
||||||
|
/// hängt sich hinter den Scan und läuft weiter, wenn der längst
|
||||||
|
/// „fertig" meldet. In dasselbe Feld geschrieben sähe der Scan wieder
|
||||||
|
/// laufend aus, und die Verwaltung käme nie zur Ruhe.
|
||||||
|
/// </remarks>
|
||||||
|
string? Marken = null);
|
||||||
|
|
||||||
/// <summary>Was das Nachschlagen bei der Metadatenquelle ergeben hat.</summary>
|
/// <summary>Was das Nachschlagen bei der Metadatenquelle ergeben hat.</summary>
|
||||||
public sealed record MetadataProgress(
|
public sealed record MetadataProgress(
|
||||||
|
|||||||
@@ -416,11 +416,21 @@ public static class LibraryContentEndpoints
|
|||||||
statusCode: StatusCodes.Status409Conflict);
|
statusCode: StatusCodes.Status409Conflict);
|
||||||
}).RequireAuthorization(FlaroPolicies.Administrator);
|
}).RequireAuthorization(FlaroPolicies.Administrator);
|
||||||
|
|
||||||
app.MapGet("/api/v1/libraries/{id:guid}/scan", (Guid id, ScanCoordinator koordinator) =>
|
app.MapGet("/api/v1/libraries/{id:guid}/scan",
|
||||||
|
(Guid id, ScanCoordinator koordinator, Markenerkennung erkennung) =>
|
||||||
{
|
{
|
||||||
|
// **Auch ohne Scan kann etwas laufen.** Die Markensuche lässt
|
||||||
|
// sich von Hand anstossen; ohne diese Zeile stünde die
|
||||||
|
// Verwaltung dann auf „idle", während der Server stundenlang
|
||||||
|
// arbeitet.
|
||||||
|
var marken = erkennung.Stand(id)?.Text();
|
||||||
|
|
||||||
var zustand = koordinator.State(id);
|
var zustand = koordinator.State(id);
|
||||||
if (zustand is null)
|
if (zustand is null)
|
||||||
return Results.Ok(new ScanStatus("idle", null, null, null, null, null, null, null, null, null, null, null));
|
return Results.Ok(new ScanStatus(
|
||||||
|
marken is null ? "idle" : "marken",
|
||||||
|
null, null, null, null, null, null, null, null, null, null, null,
|
||||||
|
Marken: marken));
|
||||||
|
|
||||||
var bericht = zustand.Report;
|
var bericht = zustand.Report;
|
||||||
var metadaten = zustand.Enrichment;
|
var metadaten = zustand.Enrichment;
|
||||||
@@ -434,7 +444,8 @@ public static class LibraryContentEndpoints
|
|||||||
metadaten.MoviesMatched, metadaten.SeriesMatched, metadaten.EpisodesMatched,
|
metadaten.MoviesMatched, metadaten.SeriesMatched, metadaten.EpisodesMatched,
|
||||||
metadaten.Skipped, metadaten.Error),
|
metadaten.Skipped, metadaten.Error),
|
||||||
bericht?.Missing,
|
bericht?.Missing,
|
||||||
zustand.Schritt?.Text()));
|
zustand.Schritt?.Text(),
|
||||||
|
marken));
|
||||||
}).WithName("Scanstand").RequireAuthorization();
|
}).WithName("Scanstand").RequireAuthorization();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -84,6 +84,26 @@ public sealed class Markenerkennung(
|
|||||||
|
|
||||||
public bool Laeuft(Guid serieId) => _laufend.ContainsKey(serieId);
|
public bool Laeuft(Guid serieId) => _laufend.ContainsKey(serieId);
|
||||||
|
|
||||||
|
/// <summary>Woran der Lauf je Mediathek gerade ist — für die Verwaltung.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Ein eigener Zustand, nicht der des Scans.</b> Der
|
||||||
|
/// <c>ScanCoordinator</c> setzt seinen Lauf auf „fertig", sobald er die
|
||||||
|
/// Markenerkennung angestossen hat — in dasselbe Feld zu schreiben
|
||||||
|
/// liesse einen beendeten Scan wieder laufend aussehen.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Nur der letzte Schritt</b>, wie bei <c>ScanState.Schritt</c>: eine
|
||||||
|
/// Liste über viertausend Folgen wäre ein Leck, und wer die
|
||||||
|
/// Vorgeschichte braucht, liest das Protokoll.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private readonly ConcurrentDictionary<Guid, Scanschritt> _stand = new();
|
||||||
|
|
||||||
|
public Scanschritt? Stand(Guid mediathek) =>
|
||||||
|
_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, bool erneut = false)
|
||||||
{
|
{
|
||||||
@@ -131,32 +151,97 @@ public sealed class Markenerkennung(
|
|||||||
var db = raum.ServiceProvider.GetRequiredService<FlaroContext>();
|
var db = raum.ServiceProvider.GetRequiredService<FlaroContext>();
|
||||||
var leser = raum.ServiceProvider.GetRequiredService<Tonabdruckleser>();
|
var leser = raum.ServiceProvider.GetRequiredService<Tonabdruckleser>();
|
||||||
|
|
||||||
if (!await leser.VorhandenAsync(ct)) return;
|
var mediathekname = await db.Libraries.AsNoTracking()
|
||||||
|
.Where(x => x.Id == mediathek).Select(x => x.Name).FirstOrDefaultAsync(ct)
|
||||||
|
?? "Mediathek";
|
||||||
|
|
||||||
var serien = await db.Series.AsNoTracking()
|
var serien = await db.Series.AsNoTracking()
|
||||||
.Where(x => x.LibraryId == mediathek)
|
.Where(x => x.LibraryId == mediathek)
|
||||||
.Select(x => x.Id)
|
.Select(x => new { x.Id, x.Title })
|
||||||
|
.OrderBy(x => x.Title)
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
if (serien.Count == 0) return;
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// **Ohne fpcalc nur die Serien überspringen, nicht alles.** Der
|
||||||
|
// Bildweg braucht es nicht; ein Server ohne fpcalc konnte früher
|
||||||
|
// trotzdem keinen einzigen Filmabspann finden, weil diese
|
||||||
|
// Prüfung ganz oben stand und für beides galt.
|
||||||
|
if (serien.Count > 0 && await leser.VorhandenAsync(ct))
|
||||||
|
{
|
||||||
|
await SerienAsync(db, mediathekname, serien
|
||||||
|
.Select(s => (s.Id, s.Title)).ToList(), mediathek, erneut, ct);
|
||||||
|
}
|
||||||
|
else if (serien.Count > 0)
|
||||||
|
{
|
||||||
|
log.LogInformation(
|
||||||
|
"Kein fpcalc vorhanden — in „{Mediathek}“ bleibt der Vorspann ungesucht.",
|
||||||
|
mediathekname);
|
||||||
|
}
|
||||||
|
|
||||||
|
// **Nicht mehr hinter einem `return` für serienlose Mediatheken.**
|
||||||
|
// 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,
|
||||||
|
schritt => _stand[mediathek] = schritt);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_stand.TryRemove(mediathek, out _);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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)
|
||||||
|
{
|
||||||
|
var folgen = await db.Episodes.AsNoTracking()
|
||||||
|
.CountAsync(f => db.Series.Any(s => s.Id == f.SeriesId && s.LibraryId == mediathek), ct);
|
||||||
|
|
||||||
log.LogInformation(
|
log.LogInformation(
|
||||||
"Suche Vorspann und Abspann in {Anzahl} Serien — das kann dauern.", serien.Count);
|
"Markensuche in „{Mediathek}“: {Serien} Serie(n), {Folgen} Folge(n). "
|
||||||
|
+ "Das kann Stunden dauern.", mediathekname, serien.Count, folgen);
|
||||||
|
|
||||||
foreach (var serie in serien)
|
var marken = 0;
|
||||||
|
var ohneFund = 0;
|
||||||
|
var angefasst = 0;
|
||||||
|
|
||||||
|
for (var i = 0; i < serien.Count; i++)
|
||||||
{
|
{
|
||||||
if (ct.IsCancellationRequested) return;
|
if (ct.IsCancellationRequested) return;
|
||||||
if (!_laufend.TryAdd(serie, true)) continue;
|
|
||||||
try { await LaufenAsync(serie, erneut, ct); }
|
var (id, titel) = serien[i];
|
||||||
|
var nummer = i + 1;
|
||||||
|
|
||||||
|
// Der Zähler gehört der Mediathek, der Name der Folge: so steht
|
||||||
|
// in der Zeile beides — wie weit, und woran gerade.
|
||||||
|
void Melden(Scanschritt schritt) =>
|
||||||
|
_stand[mediathek] = schritt with { Erledigt = nummer, Gesamt = serien.Count };
|
||||||
|
|
||||||
|
Melden(new Scanschritt(Scanphase.Marken, titel));
|
||||||
|
|
||||||
|
if (!_laufend.TryAdd(id, true)) continue;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var stand = await LaufenAsync(id, erneut, ct, Melden);
|
||||||
|
marken += stand.Marken;
|
||||||
|
if (!stand.Angefasst) continue;
|
||||||
|
angefasst++;
|
||||||
|
if (stand.Marken == 0) ohneFund++;
|
||||||
|
}
|
||||||
catch (Exception fehler)
|
catch (Exception fehler)
|
||||||
{
|
{
|
||||||
log.LogWarning(fehler, "Markenerkennung für {Serie} abgebrochen.", serie);
|
log.LogWarning(fehler, "Markensuche für „{Serie}“ abgebrochen.", titel);
|
||||||
}
|
}
|
||||||
finally { _laufend.TryRemove(serie, out _); }
|
finally { _laufend.TryRemove(id, out _); }
|
||||||
}
|
}
|
||||||
|
|
||||||
await FilmeAsync(db, mediathek, erneut, ct);
|
log.LogInformation(
|
||||||
|
"Markensuche in „{Mediathek}“ durch: {Marken} Marke(n) in {Angefasst} von "
|
||||||
log.LogInformation("Marken für Mediathek {Kennung} durch.", mediathek);
|
+ "{Serien} Serie(n) gesucht, {Ohne} ohne Fund.",
|
||||||
|
mediathekname, marken, angefasst, serien.Count, ohneFund);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Startet den Lauf für eine ganze Mediathek — im Hintergrund.</summary>
|
/// <summary>Startet den Lauf für eine ganze Mediathek — im Hintergrund.</summary>
|
||||||
@@ -189,7 +274,8 @@ 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, Guid mediathek, bool erneut, CancellationToken ct)
|
FlaroContext db, string mediathekname, Guid mediathek, bool erneut,
|
||||||
|
CancellationToken ct, Action<Scanschritt> melden)
|
||||||
{
|
{
|
||||||
var offen = await db.Movies.AsNoTracking()
|
var offen = await db.Movies.AsNoTracking()
|
||||||
.Where(m => m.LibraryId == mediathek)
|
.Where(m => m.LibraryId == mediathek)
|
||||||
@@ -197,27 +283,41 @@ public sealed class Markenerkennung(
|
|||||||
// dafür drückt jemand den Knopf.
|
// dafür drückt jemand den Knopf.
|
||||||
.Where(m => erneut
|
.Where(m => erneut
|
||||||
|| !db.Markensuchen.Any(x => x.MovieId == m.Id && x.Art == Markenart.Abspann))
|
|| !db.Markensuchen.Any(x => x.MovieId == m.Id && x.Art == Markenart.Abspann))
|
||||||
.Select(m => m.Id)
|
.Select(m => new { m.Id, m.Title, m.Year })
|
||||||
|
.OrderBy(m => m.Title)
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
if (offen.Count == 0) return;
|
if (offen.Count == 0) return;
|
||||||
|
|
||||||
log.LogInformation("Suche den Abspann in {Anzahl} Filmen.", offen.Count);
|
log.LogInformation("Abspannsuche in „{Mediathek}“: {Anzahl} Film(e).",
|
||||||
|
mediathekname, offen.Count);
|
||||||
|
|
||||||
var gefunden = 0;
|
var gefunden = 0;
|
||||||
foreach (var film in offen)
|
for (var i = 0; i < offen.Count; i++)
|
||||||
{
|
{
|
||||||
if (ct.IsCancellationRequested) return;
|
if (ct.IsCancellationRequested) return;
|
||||||
try { if (await FilmAsync(film, erneut, ct)) gefunden++; }
|
|
||||||
|
var film = offen[i];
|
||||||
|
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++; }
|
||||||
catch (Exception fehler)
|
catch (Exception fehler)
|
||||||
{
|
{
|
||||||
log.LogWarning(fehler, "Abspannsuche für Film {Film} abgebrochen.", film);
|
log.LogWarning(fehler, "Abspannsuche für „{Film}“ abgebrochen.", titel);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
log.LogInformation("Abspann in {Gefunden} von {Anzahl} Filmen gefunden.",
|
log.LogInformation("Abspann in {Gefunden} von {Anzahl} Film(en) gefunden.",
|
||||||
gefunden, offen.Count);
|
gefunden, offen.Count);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>„Arrival (2016)" — der Name, unter dem ein Film im Protokoll steht.</summary>
|
||||||
|
private static string Filmname(string? titel, int? jahr)
|
||||||
|
{
|
||||||
|
var name = string.IsNullOrWhiteSpace(titel) ? "Ohne Titel" : titel;
|
||||||
|
return jahr is { } j ? $"{name} ({j})" : name;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Sucht den Abspann eines Films — im Bild.</summary>
|
/// <summary>Sucht den Abspann eines Films — im Bild.</summary>
|
||||||
///
|
///
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
@@ -244,20 +344,38 @@ public sealed class Markenerkennung(
|
|||||||
var film = await db.Movies.AsNoTracking()
|
var film = await db.Movies.AsNoTracking()
|
||||||
.Where(m => m.Id == filmId)
|
.Where(m => m.Id == filmId)
|
||||||
.Join(db.MediaFiles.AsNoTracking(), m => m.Id, d => d.MovieId,
|
.Join(db.MediaFiles.AsNoTracking(), m => m.Id, d => d.MovieId,
|
||||||
(m, d) => new { m.Id, d.Path, d.Duration })
|
(m, d) => new { m.Id, m.Title, m.Year, d.Path, d.Duration })
|
||||||
.FirstOrDefaultAsync(ct);
|
.FirstOrDefaultAsync(ct);
|
||||||
|
|
||||||
// Ohne Datei nichts abzutasten, ohne Laufzeit kein Ende zu finden.
|
// **Drei Gründe, nicht einer.** Hier stand dreimal dasselbe `false`
|
||||||
if (film?.Duration is not { TotalSeconds: > 0 } laufzeit) return false;
|
// — keine Datei, schon durchsucht, nichts gefunden —, und von
|
||||||
|
// aussen waren die drei nicht zu unterscheiden. „Warum hat dieser
|
||||||
|
// Film keinen Abspann?" liess sich damit nicht beantworten.
|
||||||
|
if (film is null)
|
||||||
|
{
|
||||||
|
log.LogInformation("Abspann: zu {Kennung} liegt keine Datei — übersprungen.", filmId);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var titel = Filmname(film.Title, film.Year);
|
||||||
|
|
||||||
|
if (film.Duration is not { TotalSeconds: > 0 } laufzeit)
|
||||||
|
{
|
||||||
|
log.LogInformation("„{Film}“: keine Laufzeit in der Datei — übersprungen.", titel);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (!erneut && await db.Markensuchen.AsNoTracking()
|
if (!erneut && 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);
|
||||||
return false;
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
await VermerkenAsync(db, filmId, Markenart.Abspann, ct);
|
await VermerkenAsync(db, filmId, Markenart.Abspann, ct);
|
||||||
|
|
||||||
var gefunden = await BildabspannAsync(
|
var gefunden = await BildabspannAsync(
|
||||||
db, abtaster, filmId, istFilm: true, film.Path, laufzeit.TotalSeconds, ct);
|
db, abtaster, filmId, istFilm: true, titel, film.Path, laufzeit.TotalSeconds, ct);
|
||||||
|
|
||||||
if (!gefunden)
|
if (!gefunden)
|
||||||
{
|
{
|
||||||
@@ -291,9 +409,14 @@ public sealed class Markenerkennung(
|
|||||||
/// wie jede andere Folge.
|
/// wie jede andere Folge.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
|
/// <param name="name">
|
||||||
|
/// Wie der Titel im Protokoll heissen soll — „Arrival (2016)" oder
|
||||||
|
/// „Biohackers S02E01". Diese Stelle kennt sonst nur den Dateipfad, und
|
||||||
|
/// ein Pfad beantwortet die Frage „welche Folge war das?" nicht.
|
||||||
|
/// </param>
|
||||||
private async Task<bool> BildabspannAsync(
|
private async Task<bool> BildabspannAsync(
|
||||||
FlaroContext db, Bildabtaster abtaster, Guid titelId, bool istFilm,
|
FlaroContext db, Bildabtaster abtaster, Guid titelId, bool istFilm,
|
||||||
string pfad, double laufzeit, CancellationToken ct)
|
string name, string pfad, double laufzeit, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var ab = Math.Max(0, laufzeit - Suchbereich);
|
var ab = Math.Max(0, laufzeit - Suchbereich);
|
||||||
var proben = await abtaster.UeberblickAsync(pfad, ab, laufzeit - ab, ct);
|
var proben = await abtaster.UeberblickAsync(pfad, ab, laufzeit - ab, ct);
|
||||||
@@ -307,9 +430,9 @@ public sealed class Markenerkennung(
|
|||||||
if (Abspannkante.Suchen(proben, regeln) is not { } stelle)
|
if (Abspannkante.Suchen(proben, regeln) is not { } stelle)
|
||||||
{
|
{
|
||||||
log.LogInformation(
|
log.LogInformation(
|
||||||
"Kein Abspann in {Pfad}: {Proben} Proben, Schwelle {Schwelle:0.#}, "
|
"„{Titel}“: kein Abspann — {Proben} Probe(n), Schwelle {Schwelle:0.#}, "
|
||||||
+ "letzte Probe {Letzte:0.#}.",
|
+ "letzte Probe {Letzte:0.#}.",
|
||||||
pfad, proben.Count, Abspannkante.Schwelle(proben, regeln),
|
name, proben.Count, Abspannkante.Schwelle(proben, regeln),
|
||||||
proben.Count > 0 ? proben[^1].Helligkeit : double.NaN);
|
proben.Count > 0 ? proben[^1].Helligkeit : double.NaN);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -319,22 +442,41 @@ public sealed class Markenerkennung(
|
|||||||
hell, stelle, Abspannkante.Schwelle(proben, regeln),
|
hell, stelle, Abspannkante.Schwelle(proben, regeln),
|
||||||
wo => abtaster.HelligkeitAsync(pfad, wo, ct));
|
wo => abtaster.HelligkeitAsync(pfad, wo, ct));
|
||||||
|
|
||||||
|
log.LogInformation("„{Titel}“: Abspann im Bild ab {Stelle:h\\:mm\\:ss}.",
|
||||||
|
name, TimeSpan.FromSeconds(kante));
|
||||||
|
|
||||||
return await SchreibenAsync(db, titelId, istFilm, Markenart.Abspann,
|
return await SchreibenAsync(db, titelId, istFilm, Markenart.Abspann,
|
||||||
kante, laufzeit, ct);
|
kante, laufzeit, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task LaufenAsync(Guid serieId, bool erneut, CancellationToken ct)
|
/// <summary>Was ein Serienlauf ergeben hat — für die Zusammenfassung.</summary>
|
||||||
|
///
|
||||||
|
/// <param name="Marken">Wie viele Marken geschrieben wurden.</param>
|
||||||
|
/// <param name="Angefasst">
|
||||||
|
/// Ob überhaupt gesucht wurde. Eine Serie, deren Staffeln alle schon
|
||||||
|
/// durchsucht sind, zählt nicht als „ohne Fund" — sie wurde gar nicht
|
||||||
|
/// erst gefragt, und beides in einen Topf zu werfen machte die
|
||||||
|
/// Zusammenfassung wertlos.
|
||||||
|
/// </param>
|
||||||
|
private readonly record struct Serienstand(int Marken, bool Angefasst);
|
||||||
|
|
||||||
|
private async Task<Serienstand> LaufenAsync(
|
||||||
|
Guid serieId, bool erneut, 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>();
|
||||||
var leser = raum.ServiceProvider.GetRequiredService<Tonabdruckleser>();
|
var leser = raum.ServiceProvider.GetRequiredService<Tonabdruckleser>();
|
||||||
var abtaster = raum.ServiceProvider.GetRequiredService<Bildabtaster>();
|
var abtaster = raum.ServiceProvider.GetRequiredService<Bildabtaster>();
|
||||||
|
|
||||||
|
var serienname = await db.Series.AsNoTracking()
|
||||||
|
.Where(x => x.Id == serieId).Select(x => x.Title).FirstOrDefaultAsync(ct)
|
||||||
|
?? "Ohne Titel";
|
||||||
|
|
||||||
if (!await leser.VorhandenAsync(ct))
|
if (!await leser.VorhandenAsync(ct))
|
||||||
{
|
{
|
||||||
log.LogInformation(
|
log.LogInformation(
|
||||||
"Kein fpcalc vorhanden — ohne es lässt sich kein Vorspann finden.");
|
"Kein fpcalc vorhanden — ohne es lässt sich kein Vorspann finden.");
|
||||||
return;
|
return new Serienstand(0, Angefasst: false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Datei und Laufzeit je Folge. Ohne beides ist eine Folge für den
|
// Datei und Laufzeit je Folge. Ohne beides ist eine Folge für den
|
||||||
@@ -343,7 +485,10 @@ public sealed class Markenerkennung(
|
|||||||
var folgen = await db.Episodes.AsNoTracking()
|
var folgen = await db.Episodes.AsNoTracking()
|
||||||
.Where(f => f.SeriesId == serieId)
|
.Where(f => f.SeriesId == serieId)
|
||||||
.Join(db.MediaFiles.AsNoTracking(), f => f.Id, d => d.EpisodeId,
|
.Join(db.MediaFiles.AsNoTracking(), f => f.Id, d => d.EpisodeId,
|
||||||
(f, d) => new { f.Id, f.SeasonNumber, f.EpisodeNumber, d.Path, d.Duration })
|
(f, d) => new
|
||||||
|
{
|
||||||
|
f.Id, f.SeasonNumber, f.EpisodeNumber, f.Title, d.Path, d.Duration,
|
||||||
|
})
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
|
|
||||||
// **Was schon einmal gesucht wurde, und mit wie vielen Folgen.**
|
// **Was schon einmal gesucht wurde, und mit wie vielen Folgen.**
|
||||||
@@ -353,10 +498,19 @@ public sealed class Markenerkennung(
|
|||||||
.Where(x => x.SeriesId == serieId)
|
.Where(x => x.SeriesId == serieId)
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
var staffeln = folgen.GroupBy(f => f.SeasonNumber).OrderBy(g => g.Key).ToList();
|
||||||
|
|
||||||
|
log.LogInformation("„{Serie}“: {Staffeln} Staffel(n), {Folgen} Folge(n).",
|
||||||
|
serienname, staffeln.Count, folgen.Count);
|
||||||
|
|
||||||
var gefunden = 0;
|
var gefunden = 0;
|
||||||
foreach (var staffel in folgen.GroupBy(f => f.SeasonNumber).OrderBy(g => g.Key))
|
var angefasst = false;
|
||||||
|
|
||||||
|
foreach (var staffel in staffeln)
|
||||||
{
|
{
|
||||||
var geordnet = staffel.OrderBy(f => f.EpisodeNumber).ToList();
|
var geordnet = staffel.OrderBy(f => f.EpisodeNumber).ToList();
|
||||||
|
string Name(int i) => Folgenname(
|
||||||
|
serienname, geordnet[i].SeasonNumber, geordnet[i].EpisodeNumber, geordnet[i].Title);
|
||||||
|
|
||||||
// **Einmal lesen, zweimal schneiden.** fpcalc kann nicht
|
// **Einmal lesen, zweimal schneiden.** fpcalc kann nicht
|
||||||
// springen — es liest immer von vorn. Also die ganze Spur je
|
// springen — es liest immer von vorn. Also die ganze Spur je
|
||||||
@@ -369,6 +523,7 @@ public sealed class Markenerkennung(
|
|||||||
|| lauf.Folgen != geordnet.Count)
|
|| lauf.Folgen != geordnet.Count)
|
||||||
.ToList();
|
.ToList();
|
||||||
if (fehlt.Count == 0) continue;
|
if (fehlt.Count == 0) continue;
|
||||||
|
angefasst = true;
|
||||||
|
|
||||||
// **Der Tonabgleich braucht etwas zum Vergleichen.** Eine
|
// **Der Tonabgleich braucht etwas zum Vergleichen.** Eine
|
||||||
// Staffel mit einer einzigen Folge — eine laufende Serie, von
|
// Staffel mit einer einzigen Folge — eine laufende Serie, von
|
||||||
@@ -378,12 +533,19 @@ public sealed class Markenerkennung(
|
|||||||
var ganze = new Dictionary<Guid, IReadOnlyList<uint>>();
|
var ganze = new Dictionary<Guid, IReadOnlyList<uint>>();
|
||||||
if (geordnet.Count >= 2)
|
if (geordnet.Count >= 2)
|
||||||
{
|
{
|
||||||
foreach (var folge in geordnet)
|
for (var i = 0; i < geordnet.Count; i++)
|
||||||
{
|
{
|
||||||
var abdruck = await leser.LesenAsync(folge.Path, ct);
|
melden?.Invoke(new Scanschritt(Scanphase.Marken, Name(i)));
|
||||||
if (abdruck.Count > 0) ganze[folge.Id] = abdruck;
|
var abdruck = await leser.LesenAsync(geordnet[i].Path, ct);
|
||||||
|
if (abdruck.Count > 0) ganze[geordnet[i].Id] = abdruck;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
log.LogInformation(
|
||||||
|
"„{Serie}“, Staffel {Staffel}: nur eine Folge — Tonabgleich entfällt, "
|
||||||
|
+ "es bleibt das Bild.", serienname, staffel.Key);
|
||||||
|
}
|
||||||
|
|
||||||
// Ohne zwei Abdrücke gibt es nichts abzugleichen; der Bildweg
|
// Ohne zwei Abdrücke gibt es nichts abzugleichen; der Bildweg
|
||||||
// unten läuft trotzdem.
|
// unten läuft trotzdem.
|
||||||
@@ -432,52 +594,130 @@ public sealed class Markenerkennung(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!fehlt.Contains(Markenart.Abspann)) continue;
|
|
||||||
|
|
||||||
// Auch wenn der Tonabgleich gar nicht erst lief: gesucht wurde,
|
|
||||||
// und ohne Vermerk liefe der Bildweg bei jedem Scan von Neuem.
|
|
||||||
await VermerkenAsync(db, serieId, staffel.Key, Markenart.Abspann,
|
|
||||||
geordnet.Count, ct);
|
|
||||||
|
|
||||||
// **Was der Ton nicht fand, sucht das Bild.**
|
|
||||||
//
|
|
||||||
// Der Abgleich lebt davon, dass der Abspann in jeder Folge
|
|
||||||
// derselbe ist — und beim Staffelfinale stimmt das oft nicht:
|
|
||||||
// anderer Schluss, andere Musik, kein Treffer. Genau dort fiel
|
|
||||||
// die letzte Folge jeder Staffel durch, und nur dort. Das Bild
|
|
||||||
// weiss davon nichts; dort sieht das Finale aus wie jede
|
|
||||||
// andere Folge.
|
|
||||||
//
|
|
||||||
// **Nur für die Lücken.** Der Überblick dekodiert zwanzig
|
|
||||||
// Minuten Schlüsselbilder; bei einer Staffel ohne Lücke läuft
|
|
||||||
// er gar nicht, mit Lücke ein- bis zweimal. Erst speichern,
|
|
||||||
// sonst sieht die Abfrage die eben geschriebenen Marken nicht.
|
|
||||||
await db.SaveChangesAsync(ct);
|
|
||||||
|
|
||||||
var nummern = geordnet.Select(f => f.Id).ToList();
|
var nummern = geordnet.Select(f => f.Id).ToList();
|
||||||
var mitAbspann = await db.Marken.AsNoTracking()
|
var ueberBild = new HashSet<Guid>();
|
||||||
.Where(m => m.Art == Markenart.Abspann
|
|
||||||
&& m.EpisodeId != null && nummern.Contains(m.EpisodeId.Value))
|
|
||||||
.Select(m => m.EpisodeId!.Value)
|
|
||||||
.ToListAsync(ct);
|
|
||||||
|
|
||||||
foreach (var folge in geordnet)
|
if (fehlt.Contains(Markenart.Abspann))
|
||||||
{
|
{
|
||||||
if (mitAbspann.Contains(folge.Id)) continue;
|
// Auch wenn der Tonabgleich gar nicht erst lief: gesucht wurde,
|
||||||
if (folge.Duration is not { TotalSeconds: > 0 } dauer) continue;
|
// und ohne Vermerk liefe der Bildweg bei jedem Scan von Neuem.
|
||||||
|
await VermerkenAsync(db, serieId, staffel.Key, Markenart.Abspann,
|
||||||
|
geordnet.Count, ct);
|
||||||
|
|
||||||
if (await BildabspannAsync(db, abtaster, folge.Id, istFilm: false,
|
// **Was der Ton nicht fand, sucht das Bild.**
|
||||||
folge.Path, dauer.TotalSeconds, ct))
|
//
|
||||||
|
// Der Abgleich lebt davon, dass der Abspann in jeder Folge
|
||||||
|
// derselbe ist — und beim Staffelfinale stimmt das oft nicht:
|
||||||
|
// anderer Schluss, andere Musik, kein Treffer. Genau dort fiel
|
||||||
|
// die letzte Folge jeder Staffel durch, und nur dort. Das Bild
|
||||||
|
// weiss davon nichts; dort sieht das Finale aus wie jede
|
||||||
|
// andere Folge.
|
||||||
|
//
|
||||||
|
// **Nur für die Lücken.** Der Überblick dekodiert zwanzig
|
||||||
|
// Minuten Schlüsselbilder; bei einer Staffel ohne Lücke läuft
|
||||||
|
// er gar nicht, mit Lücke ein- bis zweimal. Erst speichern,
|
||||||
|
// sonst sieht die Abfrage die eben geschriebenen Marken nicht.
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
var mitAbspann = await db.Marken.AsNoTracking()
|
||||||
|
.Where(m => m.Art == Markenart.Abspann
|
||||||
|
&& m.EpisodeId != null && nummern.Contains(m.EpisodeId.Value))
|
||||||
|
.Select(m => m.EpisodeId!.Value)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
for (var i = 0; i < geordnet.Count; i++)
|
||||||
{
|
{
|
||||||
gefunden++;
|
var folge = geordnet[i];
|
||||||
|
if (mitAbspann.Contains(folge.Id)) continue;
|
||||||
|
if (folge.Duration is not { TotalSeconds: > 0 } dauer) continue;
|
||||||
|
|
||||||
|
melden?.Invoke(new Scanschritt(Scanphase.Marken, Name(i)));
|
||||||
|
|
||||||
|
if (await BildabspannAsync(db, abtaster, folge.Id, istFilm: false,
|
||||||
|
Name(i), folge.Path, dauer.TotalSeconds, ct))
|
||||||
|
{
|
||||||
|
ueberBild.Add(folge.Id);
|
||||||
|
gefunden++;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
await StaffelMeldenAsync(db, serienname, staffel.Key, geordnet
|
||||||
|
.Select((f, i) => (f.Id, Name: Name(i))).ToList(), ueberBild, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Immer speichern: auch ein Lauf ohne Fund hinterlässt Vermerke,
|
// Immer speichern: auch ein Lauf ohne Fund hinterlässt Vermerke,
|
||||||
// und genau die verhindern den nächsten vergeblichen Lauf.
|
// und genau die verhindern den nächsten vergeblichen Lauf.
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
log.LogInformation("Markenerkennung für {Serie}: {Anzahl} Marken.", serieId, gefunden);
|
|
||||||
|
if (angefasst)
|
||||||
|
{
|
||||||
|
log.LogInformation("„{Serie}“ fertig: {Anzahl} Marke(n).", serienname, gefunden);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
log.LogInformation("„{Serie}“: nichts zu tun, alle Staffeln schon durchsucht.",
|
||||||
|
serienname);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Serienstand(gefunden, angefasst);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Was eine Staffel ergeben hat — erst zusammengefasst, dann je Folge.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Aus der Datenbank gelesen, nicht mitgezählt.</b> Beim Zählen
|
||||||
|
/// während des Laufs stünde im Protokoll, was dieser Lauf geschrieben
|
||||||
|
/// hat; hier steht, was danach wirklich an der Folge hängt. Nur das
|
||||||
|
/// beantwortet „warum kommt bei dieser Folge kein Knopf?".
|
||||||
|
/// </remarks>
|
||||||
|
private async Task StaffelMeldenAsync(
|
||||||
|
FlaroContext db, string serienname, int staffel,
|
||||||
|
IReadOnlyList<(Guid Id, string Name)> folgen, IReadOnlySet<Guid> ueberBild,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var nummern = folgen.Select(f => f.Id).ToList();
|
||||||
|
var marken = await db.Marken.AsNoTracking()
|
||||||
|
.Where(m => m.EpisodeId != null && nummern.Contains(m.EpisodeId.Value))
|
||||||
|
.Select(m => new { Folge = m.EpisodeId!.Value, m.Art, m.VonSekunden, m.BisSekunden })
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
var mitIntro = marken.Count(m => m.Art == Markenart.Intro);
|
||||||
|
var mitAbspann = marken.Count(m => m.Art == Markenart.Abspann);
|
||||||
|
|
||||||
|
log.LogInformation(
|
||||||
|
"„{Serie}“, Staffel {Staffel}: Vorspann in {Intro} von {Folgen} Folge(n), "
|
||||||
|
+ "Abspann in {Abspann} von {Folgen}.",
|
||||||
|
serienname, staffel, mitIntro, folgen.Count, mitAbspann);
|
||||||
|
|
||||||
|
foreach (var (id, name) in folgen)
|
||||||
|
{
|
||||||
|
var intro = marken.FirstOrDefault(m => m.Folge == id && m.Art == Markenart.Intro);
|
||||||
|
var abspann = marken.FirstOrDefault(m => m.Folge == id && m.Art == Markenart.Abspann);
|
||||||
|
|
||||||
|
log.LogInformation("{Folge}: {Vorspann}, {Abspann}.",
|
||||||
|
name,
|
||||||
|
intro is null ? "kein Vorspann"
|
||||||
|
: $"Vorspann {Zeit(intro.VonSekunden)}–{Zeit(intro.BisSekunden)}",
|
||||||
|
abspann is null ? "kein Abspann"
|
||||||
|
: $"Abspann ab {Zeit(abspann.VonSekunden)}"
|
||||||
|
+ (ueberBild.Contains(id) ? " (über das Bild)" : ""));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>„41:12" oder „1:52:10" — kurz genug für eine Protokollzeile.</summary>
|
||||||
|
private static string Zeit(double sekunden)
|
||||||
|
{
|
||||||
|
var spanne = TimeSpan.FromSeconds(Math.Max(0, sekunden));
|
||||||
|
return spanne.TotalHours >= 1 ? spanne.ToString(@"h\:mm\:ss") : spanne.ToString(@"m\:ss");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>„Biohackers S02E01 · Neuanfang" — so steht eine Folge im Protokoll.</summary>
|
||||||
|
private static string Folgenname(string serie, int staffel, int folge, string? titel)
|
||||||
|
{
|
||||||
|
var kopf = $"{serie} S{staffel:00}E{folge:00}";
|
||||||
|
return string.IsNullOrWhiteSpace(titel) ? kopf : $"{kopf} · {titel}";
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Welcher Ausschnitt des Abdrucks abgetastet wird.</summary>
|
/// <summary>Welcher Ausschnitt des Abdrucks abgetastet wird.</summary>
|
||||||
|
|||||||
@@ -21,6 +21,15 @@ public enum Scanphase
|
|||||||
/// <summary>Verwaistes wird entfernt, Doppeltes zusammengelegt.</summary>
|
/// <summary>Verwaistes wird entfernt, Doppeltes zusammengelegt.</summary>
|
||||||
Aufraeumen,
|
Aufraeumen,
|
||||||
|
|
||||||
|
/// <summary>Vorspann und Abspann werden gesucht.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// Derselbe Weg wie beim Scan, und aus demselben Grund: der Lauf geht
|
||||||
|
/// über Stunden, und ohne eine Zeile, die mitzählt, sieht die
|
||||||
|
/// Verwaltung minutenlang so aus, als täte sich nichts.
|
||||||
|
/// </remarks>
|
||||||
|
Marken,
|
||||||
|
|
||||||
/// <summary>Eine Notiz fürs Protokoll — nicht für die Fortschrittszeile.</summary>
|
/// <summary>Eine Notiz fürs Protokoll — nicht für die Fortschrittszeile.</summary>
|
||||||
///
|
///
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
@@ -70,6 +79,7 @@ public readonly record struct Scanschritt(Scanphase Phase, string Was, int Erled
|
|||||||
Scanphase.Holen => $"Hole Metadaten zu {Was}{zaehler}",
|
Scanphase.Holen => $"Hole Metadaten zu {Was}{zaehler}",
|
||||||
Scanphase.Bilder => $"Lade Bilder zu {Was}{zaehler}",
|
Scanphase.Bilder => $"Lade Bilder zu {Was}{zaehler}",
|
||||||
Scanphase.Aufraeumen => "Räume auf und lege Doppeltes zusammen",
|
Scanphase.Aufraeumen => "Räume auf und lege Doppeltes zusammen",
|
||||||
|
Scanphase.Marken => $"Suche Vorspann und Abspann: {Was}{zaehler}",
|
||||||
Scanphase.Vermerk => Was,
|
Scanphase.Vermerk => Was,
|
||||||
_ => Was,
|
_ => Was,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
using Flaro.Core.Library;
|
||||||
|
|
||||||
|
namespace Flaro.Core.Tests;
|
||||||
|
|
||||||
|
/// <summary>Der Satz, der in der Verwaltung mitläuft.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>Warum das geprüft wird.</b> Die Markensuche läuft über Stunden und
|
||||||
|
/// hat bisher gar nichts gemeldet — in der Verwaltung stand das Ergebnis
|
||||||
|
/// des längst beendeten Scans, während der Server noch jede Folge
|
||||||
|
/// durchhörte. Sie benutzt jetzt denselben Weg wie der Scan, und der Satz
|
||||||
|
/// entsteht hier, nicht in der Oberfläche: die Apps brauchen denselben.
|
||||||
|
/// </remarks>
|
||||||
|
public class ScanschrittTests
|
||||||
|
{
|
||||||
|
/// <summary>Mit Zähler, wenn die Gesamtzahl bekannt ist.</summary>
|
||||||
|
[Test]
|
||||||
|
public void Die_Markensuche_nennt_Titel_und_Zaehler()
|
||||||
|
{
|
||||||
|
var schritt = new Scanschritt(
|
||||||
|
Scanphase.Marken, "Biohackers S02E01 · Neuanfang", 12, 95);
|
||||||
|
|
||||||
|
Assert.That(schritt.Text(),
|
||||||
|
Is.EqualTo("Suche Vorspann und Abspann: Biohackers S02E01 · Neuanfang — 12 von 95"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Ohne Gesamtzahl kein Zähler — „3 von 0" wäre eine Falschauskunft.</summary>
|
||||||
|
[Test]
|
||||||
|
public void Ohne_Gesamtzahl_steht_nur_der_Titel()
|
||||||
|
{
|
||||||
|
var schritt = new Scanschritt(Scanphase.Marken, "Arrival (2016)");
|
||||||
|
|
||||||
|
Assert.That(schritt.Text(), Is.EqualTo("Suche Vorspann und Abspann: Arrival (2016)"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Die vorhandenen Phasen bleiben, wie sie waren.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// Eine neue Phase in der Mitte der Aufzählung hätte die gespeicherten
|
||||||
|
/// Zahlen verschoben — hier steht, dass die Sätze unverändert sind.
|
||||||
|
/// </remarks>
|
||||||
|
[Test]
|
||||||
|
public void Der_Scan_sagt_weiterhin_dasselbe()
|
||||||
|
{
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(new Scanschritt(Scanphase.Pruefen, "a/b/c.mkv", 7, 9).Text(),
|
||||||
|
Is.EqualTo("Prüfe b/c.mkv — 7 von 9"));
|
||||||
|
Assert.That(new Scanschritt(Scanphase.Aufraeumen, "").Text(),
|
||||||
|
Is.EqualTo("Räume auf und lege Doppeltes zusammen"));
|
||||||
|
Assert.That(new Scanschritt(Scanphase.Vermerk, "Serie angelegt").Text(),
|
||||||
|
Is.EqualTo("Serie angelegt"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -196,6 +196,11 @@ export type Scanstand = {
|
|||||||
missing: number | null;
|
missing: number | null;
|
||||||
/** Woran der Lauf gerade ist, in einem Satz. */
|
/** Woran der Lauf gerade ist, in einem Satz. */
|
||||||
step: string | null;
|
step: string | null;
|
||||||
|
/** Woran die Markensuche gerade ist — `null`, wenn keine läuft.
|
||||||
|
*
|
||||||
|
* **Ein eigenes Feld, nicht `step`.** Sie hängt sich hinter den Scan und
|
||||||
|
* läuft weiter, wenn der längst „fertig" meldet. */
|
||||||
|
marken?: string | null;
|
||||||
problems: { path: string; reason: string }[] | null; error: string | null;
|
problems: { path: string; reason: string }[] | null; error: string | null;
|
||||||
metadata: { moviesMatched: number; seriesMatched: number; episodesMatched: number;
|
metadata: { moviesMatched: number; seriesMatched: number; episodesMatched: number;
|
||||||
skipped: boolean; error: string | null } | null;
|
skipped: boolean; error: string | null } | null;
|
||||||
|
|||||||
@@ -238,8 +238,10 @@ export function bibliotheksbereich(
|
|||||||
+ 'werden noch einmal geprüft.',
|
+ 'werden noch einmal geprüft.',
|
||||||
bestaetigen: 'Suchen',
|
bestaetigen: 'Suchen',
|
||||||
tun: async () => {
|
tun: async () => {
|
||||||
|
laufendSetzen(true);
|
||||||
await mediathekMarken(aktuell.id, true);
|
await mediathekMarken(aktuell.id, true);
|
||||||
hinweisZeigen('Die Suche läuft. Das Protokoll von heute zeigt den Fortschritt.');
|
hinweisZeigen('Die Suche läuft. Der Stand steht hier und im Protokoll von heute.');
|
||||||
|
void verfolgen(false);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -310,6 +312,13 @@ export function bibliotheksbereich(
|
|||||||
laufendSetzen(false);
|
laufendSetzen(false);
|
||||||
stand.textContent = s.error ?? 'Gescheitert.';
|
stand.textContent = s.error ?? 'Gescheitert.';
|
||||||
return;
|
return;
|
||||||
|
} else if (s.marken) {
|
||||||
|
// **Die Markensuche hängt sich hinten an und läuft weiter.**
|
||||||
|
// Sie dauert Stunden, wo der Scan Minuten dauert; ohne diesen
|
||||||
|
// Zweig stünde hier längst das Ergebnis des Scans, während der
|
||||||
|
// Server noch jede Folge durchhört.
|
||||||
|
laufendSetzen(true);
|
||||||
|
stand.textContent = s.marken;
|
||||||
}
|
}
|
||||||
|
|
||||||
// **Alle Zähler auf demselben Takt.**
|
// **Alle Zähler auf demselben Takt.**
|
||||||
@@ -325,7 +334,9 @@ export function bibliotheksbereich(
|
|||||||
// Mediathek, ohne je Sekunde zwei Abrufe zu kosten.
|
// Mediathek, ohne je Sekunde zwei Abrufe zu kosten.
|
||||||
if (melden && i > 0 && i % 3 === 0) geaendert(await laden());
|
if (melden && i > 0 && i % 3 === 0) geaendert(await laden());
|
||||||
|
|
||||||
if (s.state === 'finished') {
|
// Ein fertiger Scan ist nicht das Ende, solange die Markensuche
|
||||||
|
// dahinter noch läuft — sonst fiele die Zeile mitten im Lauf weg.
|
||||||
|
if (s.state === 'finished' && !s.marken) {
|
||||||
const teile = [`${s.filesFound} Dateien`, `${s.added} neu`, `${s.removed} entfernt`];
|
const teile = [`${s.filesFound} Dateien`, `${s.added} neu`, `${s.removed} entfernt`];
|
||||||
// „ohne Folgennummer" stimmte nicht mehr: gezählt wird jetzt auch,
|
// „ohne Folgennummer" stimmte nicht mehr: gezählt wird jetzt auch,
|
||||||
// was eine Nummer trägt, aber zu keiner Serie gehört.
|
// was eine Nummer trägt, aber zu keiner Serie gehört.
|
||||||
|
|||||||
@@ -20,8 +20,14 @@ import { abschnitt } from './teile';
|
|||||||
* ihm die Seite aus der Hand nehmen.
|
* ihm die Seite aus der Hand nehmen.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** Wie viele Zeilen anfangs geholt werden. */
|
/** Wie viele Zeilen anfangs geholt werden.
|
||||||
const ANFANG = 300;
|
*
|
||||||
|
* **Tausend statt dreihundert.** Seit die Markensuche je Folge eine Zeile
|
||||||
|
* schreibt, hat ein Tag, an dem sie lief, Tausende davon — mit
|
||||||
|
* dreihundert sah man vom übrigen Tag nichts mehr. Das Suchfeld unten
|
||||||
|
* macht die Menge dann wieder beherrschbar.
|
||||||
|
*/
|
||||||
|
const ANFANG = 1000;
|
||||||
|
|
||||||
/** Wie oft nachgesehen wird, solange die Seite offen ist. */
|
/** Wie oft nachgesehen wird, solange die Seite offen ist. */
|
||||||
const TAKT = 4000;
|
const TAKT = 4000;
|
||||||
@@ -30,6 +36,24 @@ export function protokollbereich(): HTMLElement {
|
|||||||
const bereich = document.createElement('section');
|
const bereich = document.createElement('section');
|
||||||
bereich.appendChild(abschnitt('Protokoll'));
|
bereich.appendChild(abschnitt('Protokoll'));
|
||||||
|
|
||||||
|
// **Ohne Suche ist ein langes Protokoll keine Auskunft.** An einem Tag,
|
||||||
|
// an dem die Markensuche lief, stehen hier Tausende Zeilen; die eine
|
||||||
|
// Serie darin von Hand zu finden hiesse scrollen, bis man aufgibt.
|
||||||
|
const suchreihe = document.createElement('div');
|
||||||
|
suchreihe.className = 'knopfreihe';
|
||||||
|
|
||||||
|
const suche = document.createElement('input');
|
||||||
|
suche.type = 'search';
|
||||||
|
suche.className = 'feld';
|
||||||
|
suche.placeholder = 'Im Protokoll suchen …';
|
||||||
|
suche.setAttribute('aria-label', 'Protokoll durchsuchen');
|
||||||
|
|
||||||
|
const treffer = document.createElement('span');
|
||||||
|
treffer.className = 'winzig';
|
||||||
|
|
||||||
|
suchreihe.append(suche, treffer);
|
||||||
|
bereich.appendChild(suchreihe);
|
||||||
|
|
||||||
const liste = document.createElement('div');
|
const liste = document.createElement('div');
|
||||||
liste.className = 'protokollliste';
|
liste.className = 'protokollliste';
|
||||||
liste.innerHTML = '<div class="spinner"></div>';
|
liste.innerHTML = '<div class="spinner"></div>';
|
||||||
@@ -97,18 +121,36 @@ export function protokollbereich(): HTMLElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Beim Tippen neu zeichnen — die Zeilen liegen schon da, es wird nichts
|
||||||
|
// nachgeholt.
|
||||||
|
suche.oninput = () => zeichnen(zuletzt);
|
||||||
|
|
||||||
function zeichnen(roh: string): void {
|
function zeichnen(roh: string): void {
|
||||||
const zeilenListe = roh.split('\n').filter(z => z.trim().length > 0);
|
const alle = roh.split('\n').filter(z => z.trim().length > 0);
|
||||||
|
const gesucht = suche.value.trim().toLowerCase();
|
||||||
|
const zeilenListe = gesucht
|
||||||
|
? alle.filter(z => z.toLowerCase().includes(gesucht))
|
||||||
|
: alle;
|
||||||
|
|
||||||
|
// **Die Trefferzahl gehört daneben.** Ein leeres Ergebnis ohne Zahl
|
||||||
|
// sieht aus wie ein kaputtes Protokoll statt wie „nichts gefunden".
|
||||||
|
treffer.textContent = gesucht
|
||||||
|
? `${zeilenListe.length} von ${alle.length} Zeilen`
|
||||||
|
: '';
|
||||||
|
|
||||||
// **Vor dem Neuzeichnen merken, ob man unten stand.** Danach ist die
|
// **Vor dem Neuzeichnen merken, ob man unten stand.** Danach ist die
|
||||||
// Höhe eine andere, und die Frage liesse sich nicht mehr beantworten.
|
// Höhe eine andere, und die Frage liesse sich nicht mehr beantworten.
|
||||||
const amEnde = liste.scrollHeight - liste.scrollTop - liste.clientHeight < 40;
|
const amEnde = liste.scrollHeight - liste.scrollTop - liste.clientHeight < 40;
|
||||||
|
|
||||||
liste.innerHTML = '';
|
liste.innerHTML = '';
|
||||||
if (zeilenListe.length === 0) {
|
if (alle.length === 0) {
|
||||||
liste.appendChild(leer('Heute noch nichts protokolliert.'));
|
liste.appendChild(leer('Heute noch nichts protokolliert.'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (zeilenListe.length === 0) {
|
||||||
|
liste.appendChild(leer(`Keine Zeile enthält „${gesucht}".`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
zeilenListe.forEach(z => liste.appendChild(protokollzeile(z)));
|
zeilenListe.forEach(z => liste.appendChild(protokollzeile(z)));
|
||||||
if (amEnde) liste.scrollTop = liste.scrollHeight;
|
if (amEnde) liste.scrollTop = liste.scrollHeight;
|
||||||
|
|||||||
Reference in New Issue
Block a user