Zwei Fassungen dürfen gewollt sein, und die Startseite darf nicht warten
**Mehrfachfassungen lassen sich abhaken.** Zwei Dateien an einem Film sind meist zwei Fassungen und völlig in Ordnung. In der Liste standen sie trotzdem für immer: sie rechnet jedes Mal frisch, und es gab keinen Zustand. Wer absichtlich zwei Fassungen hält, konnte die Gruppe nicht loswerden — und unter den gewollten ging das Ungewollte unter. Abgehakt wird eine Dateizahl, kein Zeitpunkt. Ein Zeitstempel müsste gegen `MediaFile.ModifiedAt` verglichen werden, und eine alte Datei, frisch hineinkopiert, behält ihre alte Zeit und käme nie wieder hoch. Die Anzahl ist genau die Frage: kommt eine dritte Fassung dazu, meldet sich die Gruppe von selbst zurück. Gerechnet wird sie auf dem Server — ein Client mit veralteter Liste hakte sonst eine Gruppe mit zwei Dateien ab, die inzwischen drei hat. „Ignorierte zeigen" führt zurück, wie bei den erledigten Meldungen; ohne das wäre das Abhaken unumkehrbar und niemand wüsste, was er weggeklickt hat. **Die Startseite wartete dreimal nacheinander.** Erst die Serverauskunft, dann Konto und Mediatheken, dann erst die Startseite — drei Rundreisen, bevor das erste Bild in Auftrag ging. Liegt eine Sitzung im Speicher, laufen die ersten beiden jetzt zugleich los. Nur dann: ohne Sitzung liefen `/auth/me` und `/libraries` vor der Antwort auf `needsSetup` in ein 401, und die Sitzungsprüfung kann daraufhin abmelden. Gemessen am laufenden Server: `users/me`, `system/info` und `libraries` starten alle bei 37 ms, die Startseitenabfragen folgen bei 73 ms. Vorher lagen drei Runden dazwischen. **Sieben Hintergrundbilder auf einmal waren sechs zu viel.** Jede Folie des Aufmachers bekam sofort ihr `w1280`-Bild; sichtbar ist immer nur eine. Jetzt lädt die sichtbare sofort und der Rest in der Leerlaufzeit — und wer weiterschaltet, bevor das lief, bekommt sein Bild dann. **Die Schrift kam von Google.** `display=swap` rettete den Text, nicht den ersten Aufbau: ein Stylesheet blockiert das Zeichnen, und ein Heimserver ohne Internet oder mit träger Namensauflösung wartete davor. Inter liegt jetzt als zwei Dateien von zusammen sechzig Kilobyte im Projekt, variabel für alle Schnitte, mit `unicode-range` getrennt nach latin und latin-ext. Gemessen: null Anfragen an fonts.googleapis.com. Lizenztext liegt dabei. **Und mein Untertitelvorrat bremste mit.** Er startete sofort beim Hochfahren und las die ganze Mediathek — von derselben Platte, von der die Startseite ihre Bilder holt. Jetzt beginnt er eine Minute später und stellt zurück, solange eine Wiedergabe läuft. Die Datei bleibt dabei vorn in der Reihe; verworfen wird nichts. 678 Tests grün, davon zwei neue. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
2809821c9b
commit
1b4784f1db
@@ -46,6 +46,14 @@ public sealed record Unzugeordnet(int Gesamt, IReadOnlyList<UnzugeordneteDatei>
|
||||
public sealed record OhneZuordnung(
|
||||
Guid Id, string Art, string Titel, int? Jahr, int Dateien, string? Beispiel);
|
||||
|
||||
/// <summary>Welche Mehrfachfassungen abgehakt oder wieder hervorgeholt werden.</summary>
|
||||
///
|
||||
/// <param name="Ignorieren">
|
||||
/// <c>true</c> hakt ab, <c>false</c> holt zurück. Ein Weg statt zweier
|
||||
/// Endpunkte: es ist dieselbe Spalte, nur einmal gesetzt und einmal geleert.
|
||||
/// </param>
|
||||
public sealed record FassungenRequest(IReadOnlyList<Guid> Ids, bool Ignorieren = true);
|
||||
|
||||
/// <param name="Titel">Titel mit mehr als einer Datei — dort versteckt sich Falsches.</param>
|
||||
public sealed record Mehrfachbericht(
|
||||
int Gesamt, IReadOnlyList<Mehrfachtitel> Titel);
|
||||
|
||||
@@ -358,13 +358,23 @@ public static class ManagementEndpoints
|
||||
// Metadatenquelle auf denselben Treffer liefen — und dann ist einer
|
||||
// davon lautlos verschwunden. Wer die Liste durchsieht, erkennt das
|
||||
// an den Dateinamen in zwei Sekunden.
|
||||
//
|
||||
// **`ignorierte` zeigt, was abgehakt wurde.** Ohne diesen Schalter
|
||||
// wäre das Abhaken unumkehrbar, und niemand wüsste hinterher, was er
|
||||
// weggeklickt hat — dieselbe Überlegung wie bei den Meldungen.
|
||||
bibliotheken.MapGet("/{id:guid}/versions", async (
|
||||
Guid id, FlaroContext db, int take = 300) =>
|
||||
Guid id, FlaroContext db, int take = 300, bool ignorierte = false) =>
|
||||
{
|
||||
if (!await db.Libraries.AsNoTracking().AnyAsync(l => l.Id == id)) return Results.NotFound();
|
||||
|
||||
var mehrfach = await db.Movies.AsNoTracking()
|
||||
.Where(m => m.LibraryId == id && db.MediaFiles.Count(f => f.MovieId == m.Id) > 1)
|
||||
// Abgehakt ist, wer bei genau dieser Dateizahl abgehakt
|
||||
// wurde. Kommt eine Fassung dazu, stimmt die Zahl nicht mehr
|
||||
// und die Gruppe meldet sich von selbst zurück.
|
||||
.Where(m => ignorierte
|
||||
? m.FassungenGeprueftBei == db.MediaFiles.Count(f => f.MovieId == m.Id)
|
||||
: m.FassungenGeprueftBei != db.MediaFiles.Count(f => f.MovieId == m.Id))
|
||||
.OrderBy(m => m.SortTitleValue)
|
||||
.Take(Math.Clamp(take, 1, 2000))
|
||||
.Select(m => new
|
||||
@@ -384,6 +394,33 @@ public static class ManagementEndpoints
|
||||
return Results.Ok(new Mehrfachbericht(liste.Count, liste));
|
||||
}).RequireAuthorization(FlaroPolicies.Administrator);
|
||||
|
||||
/// Abhaken und wieder hervorholen — beides über denselben Weg.
|
||||
bibliotheken.MapPost("/{id:guid}/versions/acknowledge", async (
|
||||
Guid id, FassungenRequest anfrage, FlaroContext db, CancellationToken ct) =>
|
||||
{
|
||||
if (!await db.Libraries.AsNoTracking().AnyAsync(l => l.Id == id, ct)) return Results.NotFound();
|
||||
if (anfrage.Ids is not { Count: > 0 }) return Einwand.Bitte("Es war kein Titel ausgewählt.");
|
||||
|
||||
var gewaehlt = anfrage.Ids.ToHashSet();
|
||||
|
||||
// **Die Zahl wird hier gerechnet, nicht vom Client geschickt.**
|
||||
// Ein Client, der eine veraltete Liste anzeigt, hakte sonst eine
|
||||
// Gruppe mit zwei Dateien ab, die inzwischen drei hat — und die
|
||||
// dritte verschwände ungesehen.
|
||||
var betroffen = await db.Movies
|
||||
.Where(m => m.LibraryId == id && gewaehlt.Contains(m.Id))
|
||||
.Select(m => new { Film = m, Anzahl = db.MediaFiles.Count(f => f.MovieId == m.Id) })
|
||||
.ToListAsync(ct);
|
||||
|
||||
foreach (var z in betroffen)
|
||||
{
|
||||
z.Film.FassungenGeprueftBei = anfrage.Ignorieren ? z.Anzahl : null;
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
return Results.Ok(new { geaendert = betroffen.Count });
|
||||
}).RequireAuthorization(FlaroPolicies.Administrator);
|
||||
|
||||
bibliotheken.MapGet("/missing", async (FlaroContext db) =>
|
||||
{
|
||||
var liste = await db.MediaFiles.AsNoTracking()
|
||||
|
||||
@@ -322,8 +322,23 @@ if (Directory.Exists(oberfläche))
|
||||
// Das im Augenblick des Einschaltens zu tun hiess: zwei Minuten
|
||||
// „wird geholt", und noch immer kein Text. Hier läuft es einmal je
|
||||
// Datei durch, eine nach der anderen, während niemand wartet.
|
||||
_ = app.Services.GetRequiredService<Untertitelvorrat>()
|
||||
.NachtragenAsync(leben.ApplicationStopping);
|
||||
//
|
||||
// **Und nicht sofort.** Der Lauf liest die ganze Mediathek von
|
||||
// derselben Platte, von der die Startseite ihre Bilder holt. Wer in
|
||||
// dem Augenblick aufmacht, in dem der Server hochkommt — und das ist
|
||||
// der häufige Fall, denn man startet ihn ja, um etwas zu schauen —,
|
||||
// wartete auf beides zugleich. Eine Minute reicht, damit die erste
|
||||
// Seite steht.
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromMinutes(1), leben.ApplicationStopping);
|
||||
await app.Services.GetRequiredService<Untertitelvorrat>()
|
||||
.NachtragenAsync(leben.ApplicationStopping);
|
||||
}
|
||||
catch (OperationCanceledException) { /* Der Server fährt herunter. */ }
|
||||
});
|
||||
});
|
||||
leben.ApplicationStopping.Register(() =>
|
||||
tagebuch.Schreiben("Server fährt herunter."));
|
||||
|
||||
@@ -39,8 +39,21 @@ public sealed class Untertitelvorrat(
|
||||
FlaroPaths pfade,
|
||||
UntertitelLeser leser,
|
||||
IServiceScopeFactory scopes,
|
||||
PlaybackSessions laufende,
|
||||
ILogger<Untertitelvorrat> log)
|
||||
{
|
||||
/// <summary>Wie lange gewartet wird, solange jemand zusieht.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Die laufende Wiedergabe ist wichtiger als der Vorrat.</b> Gelesen
|
||||
/// wird von derselben Platte, von der auch der Film kommt und die
|
||||
/// Startseite ihre Bilder holt — und eine Datei zu lesen heisst hier,
|
||||
/// vierzig Gigabyte zu lesen. Der Vorrat hat Zeit, der Zuschauer nicht.
|
||||
/// Die Wiedergabesitzungen verfallen nach einer Minute ohne Meldung, ein
|
||||
/// halbminütiger Blick trifft das Ende also knapp genug.
|
||||
/// </remarks>
|
||||
private static readonly TimeSpan Rücksicht = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>Was ein Abruf vorfindet.</summary>
|
||||
public enum Stand
|
||||
{
|
||||
@@ -191,6 +204,22 @@ public sealed class Untertitelvorrat(
|
||||
Laufende = naechste;
|
||||
}
|
||||
|
||||
// **Erst nachsehen, ob jemand zusieht.** Zurückgestellt, nicht
|
||||
// verworfen: die Datei bleibt vorn in der Reihe und kommt dran,
|
||||
// sobald der Abspann läuft.
|
||||
while (laufende.Laufende().Count > 0)
|
||||
{
|
||||
lock (_schloss) { _reihe.AddFirst(naechste); Laufende = null; }
|
||||
await Task.Delay(Rücksicht);
|
||||
lock (_schloss)
|
||||
{
|
||||
if (_reihe.First is null) { _arbeitet = false; return; }
|
||||
naechste = _reihe.First.Value;
|
||||
_reihe.RemoveFirst();
|
||||
Laufende = naechste;
|
||||
}
|
||||
}
|
||||
|
||||
try { await Ziehen(naechste); }
|
||||
catch (Exception fehler)
|
||||
{
|
||||
|
||||
@@ -142,6 +142,32 @@ public sealed class Movie : Flaro.Core.Metadata.IBebildert
|
||||
public bool BackdropPinned { get; set; }
|
||||
public bool LogoPinned { get; set; }
|
||||
|
||||
/// <summary>Wie viele Dateien dieser Film hatte, als jemand die Fassungen abhakte.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Wozu.</b> Zwei Dateien an einem Film sind meist zwei Fassungen und
|
||||
/// völlig in Ordnung. In der Liste „Mehrfachfassungen" standen sie
|
||||
/// trotzdem für immer, weil die Liste jedes Mal frisch rechnet und es
|
||||
/// keinen Zustand gab — wer absichtlich zwei Fassungen hält, konnte die
|
||||
/// Gruppe nicht loswerden.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Warum eine Anzahl und kein Zeitstempel.</b> Ein Zeitstempel müsste
|
||||
/// gegen etwas verglichen werden, und das wäre <c>MediaFile.ModifiedAt</c>
|
||||
/// — die Änderungszeit der Quelldatei. Eine alte Datei, frisch
|
||||
/// hineinkopiert, behält ihre alte Zeit und käme nie wieder hoch. Die
|
||||
/// Anzahl ist genau die Frage: kommt eine dritte Fassung dazu, soll die
|
||||
/// Gruppe wieder erscheinen.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Der Preis ist bekannt: wird eine Datei gegen eine andere getauscht,
|
||||
/// bleibt die Gruppe abgehakt. Das ist die seltenere Hälfte, und
|
||||
/// „Ignorierte zeigen" führt zurück.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public int? FassungenGeprueftBei { get; set; }
|
||||
|
||||
/// <summary>Der Titel, wie ihn jemand von Hand gesetzt hat.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
|
||||
+1134
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Flaro.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class Fassungspruefung : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "FassungenGeprueftBei",
|
||||
table: "Movies",
|
||||
type: "INTEGER",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "FassungenGeprueftBei",
|
||||
table: "Movies");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -561,6 +561,9 @@ namespace Flaro.Data.Migrations
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("FassungenGeprueftBei")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ImdbId")
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
@@ -560,6 +560,98 @@ public class MetadataTests
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/// <summary>Eine abgehakte Gruppe verlässt die Liste — und kommt zurück, wenn eine Datei dazukommt.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Der Anlass.</b> Zwei Dateien an einem Film sind meist zwei
|
||||
/// Fassungen und völlig in Ordnung. In der Liste standen sie trotzdem für
|
||||
/// immer: sie rechnet jedes Mal frisch, und es gab keinen Zustand. Wer
|
||||
/// absichtlich zwei Fassungen hält, konnte die Gruppe nicht loswerden —
|
||||
/// und unter den gewollten ging das Ungewollte unter.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Abgehakt wird eine Dateizahl, kein Zeitpunkt.</b> Kommt eine dritte
|
||||
/// Fassung dazu, stimmt die Zahl nicht mehr und die Gruppe meldet sich
|
||||
/// von selbst zurück. Genau das prüft der zweite Teil.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Test]
|
||||
public async Task Eine_abgehakte_Fassungsgruppe_meldet_sich_bei_einer_neuen_Datei_zurueck()
|
||||
{
|
||||
Datei("Hanni und Nanni/Hanni und Nanni.mp4");
|
||||
Datei("Hanni und Nanni/Hanni und Nanni 3.mp4");
|
||||
|
||||
var anbieter = new VorgegebenerAnbieter
|
||||
{
|
||||
Film = new MovieMetadata(225313, "Hanni & Nanni 3", 2013, null, null, null),
|
||||
};
|
||||
|
||||
using var server = MitAnbieter(anbieter);
|
||||
var (client, bibliothek) = await Einrichten(server, "Movies");
|
||||
await Scannen(client, bibliothek);
|
||||
|
||||
var vorher = await client.GetFromJsonAsync<Mehrfachbericht>(
|
||||
$"/api/v1/libraries/{bibliothek}/versions");
|
||||
Assert.That(vorher!.Gesamt, Is.EqualTo(1), "zwei Dateien an einem Titel");
|
||||
|
||||
var film = vorher.Titel[0].Id;
|
||||
(await client.PostAsJsonAsync($"/api/v1/libraries/{bibliothek}/versions/acknowledge",
|
||||
new FassungenRequest([film]))).EnsureSuccessStatusCode();
|
||||
|
||||
var offen = await client.GetFromJsonAsync<Mehrfachbericht>(
|
||||
$"/api/v1/libraries/{bibliothek}/versions");
|
||||
var abgehakt = await client.GetFromJsonAsync<Mehrfachbericht>(
|
||||
$"/api/v1/libraries/{bibliothek}/versions?ignorierte=true");
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(offen!.Gesamt, Is.Zero, "abgehakt heisst: nicht mehr in der offenen Liste");
|
||||
Assert.That(abgehakt!.Gesamt, Is.EqualTo(1), "aber nachsehen muss man können");
|
||||
});
|
||||
|
||||
// Eine dritte Fassung dazu — jetzt stimmt die abgehakte Zahl nicht mehr.
|
||||
Datei("Hanni und Nanni/Hanni und Nanni 2.mp4");
|
||||
await Scannen(client, bibliothek);
|
||||
|
||||
var wieder = await client.GetFromJsonAsync<Mehrfachbericht>(
|
||||
$"/api/v1/libraries/{bibliothek}/versions");
|
||||
|
||||
Assert.That(wieder!.Gesamt, Is.EqualTo(1),
|
||||
"eine neue Datei macht die Gruppe wieder zu einer Frage");
|
||||
}
|
||||
|
||||
/// <summary>Zurückholen macht das Abhaken rückgängig.</summary>
|
||||
[Test]
|
||||
public async Task Eine_abgehakte_Gruppe_laesst_sich_zurueckholen()
|
||||
{
|
||||
Datei("Hanni und Nanni/Hanni und Nanni.mp4");
|
||||
Datei("Hanni und Nanni/Hanni und Nanni 3.mp4");
|
||||
|
||||
var anbieter = new VorgegebenerAnbieter
|
||||
{
|
||||
Film = new MovieMetadata(225313, "Hanni & Nanni 3", 2013, null, null, null),
|
||||
};
|
||||
|
||||
using var server = MitAnbieter(anbieter);
|
||||
var (client, bibliothek) = await Einrichten(server, "Movies");
|
||||
await Scannen(client, bibliothek);
|
||||
|
||||
var film = (await client.GetFromJsonAsync<Mehrfachbericht>(
|
||||
$"/api/v1/libraries/{bibliothek}/versions"))!.Titel[0].Id;
|
||||
|
||||
await client.PostAsJsonAsync($"/api/v1/libraries/{bibliothek}/versions/acknowledge",
|
||||
new FassungenRequest([film]));
|
||||
await client.PostAsJsonAsync($"/api/v1/libraries/{bibliothek}/versions/acknowledge",
|
||||
new FassungenRequest([film], Ignorieren: false));
|
||||
|
||||
var offen = await client.GetFromJsonAsync<Mehrfachbericht>(
|
||||
$"/api/v1/libraries/{bibliothek}/versions");
|
||||
|
||||
Assert.That(offen!.Gesamt, Is.EqualTo(1));
|
||||
}
|
||||
|
||||
/// <summary>Eine einzelne Datei lässt sich nicht herauslösen.</summary>
|
||||
[Test]
|
||||
public async Task Eine_einzelne_Datei_laesst_sich_nicht_herausloesen()
|
||||
|
||||
Reference in New Issue
Block a user