Der Abschnitt verschwand alle drei Sekunden und kam wieder
`laden()` setzte `innerHTML = ''` und baute die Mediathekenliste neu — während eines Scans alle drei Sekunden. Der ganze Abschnitt flackerte sichtbar, und wer gerade auf einen Knopf zielte, klickte ins Leere. Schlimmer: jeder Neuaufbau startete über `verfolgen(false)` einen weiteren Takt. Nach einer Minute liefen zwanzig davon nebeneinander. Jetzt wird fortgeschrieben statt neu gezeichnet: eine Zeile je Mediathek, die bleibt, und nur Kopfzeile, Prüfungsknopf und Freigabeknopf ändern sich. Belegt im Browser — nach einem Auffrischen sind es dieselben Knoten, nur die Beschriftung ist eine andere. Dieselbe Behandlung für die Seitenleiste. Und weil Seitenleiste und Verwaltung getrennt nachfragten, zeigten sie zwei Momente — 95 hier, 84 dort, während im Hintergrund Doppelte zusammengelegt wurden. Die Verwaltung reicht ihren Stand jetzt weiter: ein Abruf, zwei Anzeigen. Dazu zwei Dinge, die beim Prüfen auffielen. Zurücksetzen und Entfernen prallen jetzt serverseitig mit 409 ab, solange ein Lauf läuft — die Oberfläche sperrt die Knöpfe, aber darauf darf sich der Server nicht verlassen; ein Aufruf von Hand während eines Scans liess den Lauf an einer DbUpdateConcurrencyException sterben. Und in der Fortschrittszeile steht beim Holen der Folgenmetadaten jetzt der Serienname: „Folge 2×13" allein sagt bei 3815 Folgen nichts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@@ -0,0 +1 @@
|
||||
<svg fill="none" height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg"><path clip-rule="evenodd" d="m11.0721 5.13152c.3321-.84203 1.5237-.84203 1.8558 0l1.6803 4.26031 4.2603 1.68027c.842.3321.842 1.5237 0 1.8558l-4.2603 1.6803-1.6803 4.2603c-.3321.842-1.5237.842-1.8558 0l-1.68027-4.2603-4.26031-1.6803c-.84203-.3321-.84203-1.5237 0-1.8558l4.26031-1.68027zm.9279 1.73562-1.2928 3.27806c-.1014.2571-.3049.4606-.562.562l-3.27806 1.2928 3.27806 1.2928c.2571.1014.4606.3049.562.562l1.2928 3.2781 1.2928-3.2781c.1014-.2571.3049-.4606.562-.562l3.2781-1.2928-3.2781-1.2928c-.2571-.1014-.4606-.3049-.562-.562z" fill="rgb(0,0,0)" fill-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 673 B |
@@ -1 +0,0 @@
|
||||
<svg id="Layer_1" height="512" viewBox="0 0 512 512" width="512" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" data-name="Layer 1"><linearGradient id="linear-gradient" gradientUnits="userSpaceOnUse" x1="-52.588" x2="390.826" y1="178.158" y2="434.163"><stop offset="0" stop-color="#55d6ff"/><stop offset="1" stop-color="#ce55ff"/></linearGradient><path d="m485.036 256a54.706 54.706 0 0 1 -27.636 47.868l-347.527 200.644a54.742 54.742 0 0 1 -55.272 0 54.713 54.713 0 0 1 -27.637-47.868v-401.288a54.718 54.718 0 0 1 27.636-47.869 54.75 54.75 0 0 1 55.273 0l347.527 200.645a54.708 54.708 0 0 1 27.636 47.868z" fill="url(#linear-gradient)"/></svg>
|
||||
|
Before Width: | Height: | Size: 675 B |
@@ -2,7 +2,7 @@
|
||||
"fill-specializations" : [
|
||||
{
|
||||
"value" : {
|
||||
"automatic-gradient" : "display-p3:0.44346,0.27892,0.96783,1.00000"
|
||||
"automatic-gradient" : "display-p3:0.56409,0.38672,0.96783,1.00000"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -17,15 +17,15 @@
|
||||
"layers" : [
|
||||
{
|
||||
"fill" : {
|
||||
"automatic-gradient" : "display-p3:0.73918,0.46760,0.96783,1.00000"
|
||||
"automatic-gradient" : "display-p3:0.51566,0.11264,0.96783,1.00000"
|
||||
},
|
||||
"image-name" : "play.svg",
|
||||
"name" : "play",
|
||||
"image-name" : "flare (1).svg",
|
||||
"name" : "flare (1)",
|
||||
"position" : {
|
||||
"scale" : 1.18,
|
||||
"scale" : 100,
|
||||
"translation-in-points" : [
|
||||
49.7265625,
|
||||
1.468530916781674e-05
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -282,11 +282,22 @@ public static class ManagementEndpoints
|
||||
// sie weiss: Titel, Zuordnungen, Fortschritt. Ein Server, der beim
|
||||
// Aufräumen in der Oberfläche fremde Filme löscht, ist ein Server,
|
||||
// den man nicht noch einmal benutzt.
|
||||
bibliotheken.MapDelete("/{id:guid}", async (Guid id, FlaroContext db) =>
|
||||
bibliotheken.MapDelete("/{id:guid}", async (
|
||||
Guid id, FlaroContext db, ScanCoordinator koordinator) =>
|
||||
{
|
||||
var vorhanden = await db.Libraries.FindAsync(id);
|
||||
if (vorhanden is null) return Results.NotFound();
|
||||
|
||||
// Aus demselben Grund wie beim Zurücksetzen — nur dass hier die
|
||||
// ganze Mediathek unter dem laufenden Scan verschwände.
|
||||
if (koordinator.IsRunning(id))
|
||||
{
|
||||
return Results.Problem(
|
||||
"Für diese Mediathek läuft gerade ein Scan. "
|
||||
+ "Entfernen geht erst, wenn er durch ist.",
|
||||
statusCode: StatusCodes.Status409Conflict);
|
||||
}
|
||||
|
||||
db.Libraries.Remove(vorhanden);
|
||||
await db.SaveChangesAsync();
|
||||
return Results.NoContent();
|
||||
@@ -302,11 +313,27 @@ public static class ManagementEndpoints
|
||||
//
|
||||
// Nicht gelöscht wird eine einzige Mediendatei. Entfernt wird allein,
|
||||
// was Flaro über sie weiss.
|
||||
bibliotheken.MapPost("/{id:guid}/reset", async (Guid id, FlaroContext db) =>
|
||||
bibliotheken.MapPost("/{id:guid}/reset", async (
|
||||
Guid id, FlaroContext db, ScanCoordinator koordinator) =>
|
||||
{
|
||||
var vorhanden = await db.Libraries.FindAsync(id);
|
||||
if (vorhanden is null) return Results.NotFound();
|
||||
|
||||
// **Nicht während eines Laufs.**
|
||||
//
|
||||
// Die Oberfläche sperrt den Knopf, aber darauf darf sich der
|
||||
// Server nicht verlassen: ein zweiter Administrator oder ein
|
||||
// Aufruf von Hand genügt. Wer Zeilen löscht, während der Scan
|
||||
// welche schreibt, bekommt einen Lauf, der mitten im Schreiben
|
||||
// abbricht — und einen Bestand, den niemand mehr erklären kann.
|
||||
if (koordinator.IsRunning(id))
|
||||
{
|
||||
return Results.Problem(
|
||||
"Für diese Mediathek läuft gerade ein Scan. "
|
||||
+ "Zurücksetzen geht erst, wenn er durch ist.",
|
||||
statusCode: StatusCodes.Status409Conflict);
|
||||
}
|
||||
|
||||
// In dieser Reihenfolge: erst die Dateien, dann die Titel. Die
|
||||
// Fremdschlüssel zeigen von den Dateien auf Film und Folge.
|
||||
await db.MediaFiles.Where(f => f.LibraryId == id).ExecuteDeleteAsync();
|
||||
|
||||
@@ -57,6 +57,11 @@ public static partial class Fehlertext
|
||||
+ "Oft ist das ein volles Volume oder ein schlafendes Laufwerk.",
|
||||
TaskCanceledException or OperationCanceledException =>
|
||||
"Der Vorgang wurde abgebrochen.",
|
||||
// Tritt auf, wenn jemand dieselben Zeilen gleichzeitig entfernt —
|
||||
// etwa ein „Zurücksetzen" während eines laufenden Scans.
|
||||
Microsoft.EntityFrameworkCore.DbUpdateConcurrencyException =>
|
||||
"Jemand hat dieselben Einträge gleichzeitig geändert. "
|
||||
+ "Der Lauf wurde abgebrochen; starte ihn noch einmal.",
|
||||
HttpRequestException =>
|
||||
"Die Metadatenquelle war nicht erreichbar. "
|
||||
+ "Beim nächsten Lauf wird es erneut versucht.",
|
||||
|
||||
@@ -325,9 +325,12 @@ public sealed class MetadataEnricher(
|
||||
private async Task<int> FolgenZuordnen(
|
||||
Guid libraryId, bool erneut, IProgress<Scanschritt>? fortschritt, CancellationToken ct)
|
||||
{
|
||||
// **Mit Titel, nicht nur mit Kennung.** In der Fortschrittszeile stand
|
||||
// „Hole Metadaten zu Folge 2×13" — und bei 3815 Folgen aus achtzig
|
||||
// Serien sagt eine Folgennummer ohne Serie überhaupt nichts.
|
||||
var serien = await db.Series
|
||||
.Where(s => s.LibraryId == libraryId && s.TmdbId != null)
|
||||
.Select(s => new { s.Id, TmdbId = s.TmdbId!.Value })
|
||||
.Select(s => new { s.Id, TmdbId = s.TmdbId!.Value, s.Title })
|
||||
.ToListAsync(ct);
|
||||
if (serien.Count == 0) return 0;
|
||||
|
||||
@@ -336,16 +339,21 @@ public sealed class MetadataEnricher(
|
||||
.Where(f => kennungen.Contains(f.SeriesId) && (erneut || f.Overview == null))
|
||||
.ToListAsync(ct);
|
||||
|
||||
// Einmal nachschlagbar machen: `First` je Folge wäre bei 3815 Folgen
|
||||
// und achtzig Serien eine Viertelmillion Vergleiche.
|
||||
var nachKennung = serien.ToDictionary(s => s.Id);
|
||||
|
||||
var zugeordnet = 0;
|
||||
var durch = 0;
|
||||
foreach (var folge in offen)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
if (!nachKennung.TryGetValue(folge.SeriesId, out var serie)) continue;
|
||||
|
||||
fortschritt?.Report(new Scanschritt(
|
||||
Scanphase.Holen,
|
||||
$"Folge {folge.SeasonNumber}x{folge.EpisodeNumber:00}", ++durch, offen.Count));
|
||||
|
||||
ct.ThrowIfCancellationRequested();
|
||||
var serie = serien.First(s => s.Id == folge.SeriesId);
|
||||
$"{serie.Title} {folge.SeasonNumber}x{folge.EpisodeNumber:00}",
|
||||
++durch, offen.Count));
|
||||
|
||||
var gefunden = await anbieter.FindEpisodeAsync(serie.TmdbId, folge.SeasonNumber, folge.EpisodeNumber, ct);
|
||||
if (gefunden is null) continue;
|
||||
|
||||
@@ -500,6 +500,32 @@ public class ScanTests
|
||||
Assert.That(offen.Dateien[0].Grund, Does.Contain("keine Folgennummer"));
|
||||
}
|
||||
|
||||
/// <summary>Zurücksetzen und Entfernen prallen ab, solange ein Lauf läuft.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Die Oberfläche sperrt die Knöpfe, aber darauf darf sich der Server nicht
|
||||
/// verlassen.</b> Beim Prüfen von Hand ist genau das passiert: ein
|
||||
/// „Zurücksetzen" per curl während eines Scans, und der Lauf starb an einer
|
||||
/// DbUpdateConcurrencyException. Ein zweiter Administrator genügt dafür auch.
|
||||
/// </remarks>
|
||||
[Test]
|
||||
public async Task Zuruecksetzen_waehrend_eines_Scans_wird_abgelehnt()
|
||||
{
|
||||
for (var i = 1; i <= 400; i++) Datei($"Dark/Staffel 1/Dark - S01E{i:D3}.mkv");
|
||||
|
||||
using var server = new FlaroServer();
|
||||
var (client, bibliothek) = await MitBibliothek(server, "Shows");
|
||||
|
||||
var gestartet = await client.PostAsync($"/api/v1/libraries/{bibliothek}/scan", null);
|
||||
Assert.That(gestartet.StatusCode, Is.EqualTo(HttpStatusCode.Accepted));
|
||||
|
||||
var zurueck = await client.PostAsync($"/api/v1/libraries/{bibliothek}/reset", null);
|
||||
var weg = await client.DeleteAsync($"/api/v1/libraries/{bibliothek}");
|
||||
|
||||
Assert.That(zurueck.StatusCode, Is.EqualTo(HttpStatusCode.Conflict));
|
||||
Assert.That(weg.StatusCode, Is.EqualTo(HttpStatusCode.Conflict));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Die_Suche_grenzt_ein()
|
||||
{
|
||||
|
||||
@@ -5,6 +5,19 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="theme-color" content="#0B0B0D">
|
||||
<title>Flaro</title>
|
||||
|
||||
<!-- **Dasselbe Zeichen wie das App-Icon**, erzeugt aus
|
||||
`Flaro/AppIcon.icon` — gleiche Grafik, gleiche Farben, von Display-P3
|
||||
nach sRGB umgerechnet. Zwei Icons, die sich ähnlich sehen, driften
|
||||
auseinander; eins, das aus demselben Ordner kommt, nicht.
|
||||
|
||||
SVG zuerst, weil es in jeder Grösse scharf bleibt. Das PNG daneben
|
||||
für alles, was kein SVG-Favicon kann — und für den Startbildschirm
|
||||
auf dem Telefon, der ohnehin nur PNG nimmt. -->
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/icon-32.png">
|
||||
<link rel="apple-touch-icon" href="/icon-180.png">
|
||||
<link rel="manifest" href="/manifest.webmanifest">
|
||||
<!-- **Inter, mit der Systemschrift als Rückfall.** `display=swap`, damit der
|
||||
Text sofort steht und nicht erst, wenn die Schrift da ist — ein
|
||||
Heimserver im eigenen Netz soll nicht auf Google warten. -->
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Flaro">
|
||||
<!--
|
||||
Aus Flaro/AppIcon.icon erzeugt, nicht nachgezeichnet: dieselbe Grafik
|
||||
(Assets/flare (1).svg) und dieselben Farben, von Display-P3 nach sRGB
|
||||
umgerechnet. Ändert sich das App-Icon, wird diese Datei neu erzeugt.
|
||||
|
||||
#9860FF ist der helle Hintergrund des Icons, #261653 der dunkle.
|
||||
Der Verlauf ahmt Icon Composers „automatic gradient" nach: oben heller.
|
||||
-->
|
||||
<defs>
|
||||
<linearGradient id="grund" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#B98CFF"/>
|
||||
<stop offset="1" stop-color="#8243F5"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<rect width="64" height="64" rx="14" fill="url(#grund)"/>
|
||||
|
||||
<!--
|
||||
Die Glyphe weiss statt im Originalton (#900AFF). Auf dem Plakat eines
|
||||
App-Icons trägt sie das Glas; bei 16 Bildpunkten im Browsertab wäre
|
||||
Violett auf Violett ein Fleck.
|
||||
Skaliert: 24er-Raster auf 40 Punkte, zentriert.
|
||||
-->
|
||||
<g transform="translate(12 12) scale(1.6667)" fill="#FFFFFF">
|
||||
<path clip-rule="evenodd" fill-rule="evenodd" d="m11.0721 5.13152c.3321-.84203 1.5237-.84203 1.8558 0l1.6803 4.26031 4.2603 1.68027c.842.3321.842 1.5237 0 1.8558l-4.2603 1.6803-1.6803 4.2603c-.3321.842-1.5237.842-1.8558 0l-1.68027-4.2603-4.26031-1.6803c-.84203-.3321-.84203-1.5237 0-1.8558l4.26031-1.68027zm.9279 1.73562-1.2928 3.27806c-.1014.2571-.3049.4606-.562.562l-3.27806 1.2928 3.27806 1.2928c.2571.1014.4606.3049.562.562l1.2928 3.2781 1.2928-3.2781c.1014-.2571.3049-.4606.562-.562l3.2781-1.2928-3.2781-1.2928c-.2571-.1014-.4606-.3049-.562-.562z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 62 KiB |
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "Flaro",
|
||||
"short_name": "Flaro",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#0B0B0D",
|
||||
"theme_color": "#0B0B0D",
|
||||
"icons": [
|
||||
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
|
||||
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" },
|
||||
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
|
||||
]
|
||||
}
|
||||
@@ -86,9 +86,33 @@ export async function mediathekenNeuLaden(): Promise<void> {
|
||||
* Endlosschleife mit zwei Abrufen je Runde, die sich als vier dauernd
|
||||
* drehende Spinner zeigte. Ein neuer Zählwert in der Seitenleiste braucht
|
||||
* keinen Neuaufbau der Seite. */
|
||||
export async function zaehlerAuffrischen(): Promise<void> {
|
||||
mediatheken = await bibliotheken();
|
||||
leisteBauen();
|
||||
export async function zaehlerAuffrischen(bekannt?: Bibliothek[]): Promise<void> {
|
||||
// **Denselben Stand zeigen, nicht denselben Takt haben.**
|
||||
//
|
||||
// Beide Anzeigen fragten getrennt nach — und zeigten damit zwei Momente:
|
||||
// in der Seitenleiste stand 95, in der Verwaltung 84. Während die
|
||||
// Metadaten laufen, legt der Server Doppelte zusammen, die Zahl fällt also
|
||||
// laufend. Wer hier eine Liste mitgibt, hat sie gerade selbst geholt; dann
|
||||
// wird sie benutzt statt eine zweite zu ziehen.
|
||||
const frisch = bekannt ?? await bibliotheken();
|
||||
|
||||
// **Nur die Zahlen, solange die Liste dieselbe ist.**
|
||||
//
|
||||
// `leisteBauen()` ersetzt die ganze Seitenleiste. Im Dreisekundentakt eines
|
||||
// laufenden Scans heisst das: sie verschwindet sichtbar und kommt wieder,
|
||||
// der Mauszeiger steht plötzlich über einem anderen Eintrag, und ein
|
||||
// gerade gezogener Eintrag wird mitten in der Bewegung weggerissen.
|
||||
const gleich = frisch.length === mediatheken.length
|
||||
&& frisch.every((b, i) => b.id === mediatheken[i]!.id && b.name === mediatheken[i]!.name);
|
||||
|
||||
mediatheken = frisch;
|
||||
|
||||
if (!gleich) { leisteBauen(); return; }
|
||||
|
||||
frisch.forEach(b => {
|
||||
const zeile = leiste.querySelector<HTMLElement>(`[data-ziel="#/m/${b.id}"] .zahl`);
|
||||
if (zeile) zeile.textContent = String(b.itemCount ?? '');
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------- Kopfleiste ---------- */
|
||||
@@ -373,11 +397,14 @@ function zeichnen(): void {
|
||||
} else if (teile[0] === 'verwaltung' && istAdmin()) {
|
||||
// **Die Verwaltung auch nicht** — sie zeigt Laufendes. Ihr Takt hängt am
|
||||
// Signal und hört auf, sobald man weggeht.
|
||||
einhaengen(weg, inhalt, signal => verwaltung(() => {
|
||||
einhaengen(weg, inhalt, signal => verwaltung(bekannt => {
|
||||
// Gemerkte Ansichten wegwerfen: der Bestand hat sich geändert.
|
||||
// Die Seitenleiste bekommt neue Zahlen — die Seite selbst bleibt.
|
||||
vergessen();
|
||||
void zaehlerAuffrischen();
|
||||
// `bekannt` ist der Stand, den die Verwaltung gerade geholt hat. Ihn
|
||||
// weiterzureichen heisst: beide zeigen dieselbe Zahl, nicht nur
|
||||
// ähnliche.
|
||||
void zaehlerAuffrischen(bekannt);
|
||||
}, signal));
|
||||
} else {
|
||||
einhaengen(weg, inhalt, () => startseite(mediatheken, oeffnen, alleZeigen),
|
||||
|
||||
@@ -26,7 +26,7 @@ import { icon } from '../icons';
|
||||
* `geaendert` wird gerufen, wenn sich die Mediathekenliste geändert hat; die
|
||||
* Seitenleiste steht auf ihr und muss dann neu gezeichnet werden. */
|
||||
export function verwaltung(
|
||||
geaendert: () => void = () => {},
|
||||
geaendert: (bekannt?: Bibliothek[]) => void = () => {},
|
||||
signal: AbortSignal = new AbortController().signal,
|
||||
): HTMLElement {
|
||||
const wurzel = document.createElement('div');
|
||||
@@ -655,7 +655,15 @@ function vermisstenbereich(geaendert: () => void): HTMLElement {
|
||||
|
||||
/* ---------- Bibliotheken ---------- */
|
||||
|
||||
function bibliotheksbereich(geaendert: () => void, signal: AbortSignal): HTMLElement {
|
||||
/** Eine gezeichnete Mediathekszeile und der Weg, sie fortzuschreiben. */
|
||||
type Mediathekzeile = {
|
||||
wurzel: HTMLElement;
|
||||
aktualisieren: (b: Bibliothek) => void;
|
||||
};
|
||||
|
||||
function bibliotheksbereich(
|
||||
geaendert: (bekannt?: Bibliothek[]) => void, signal: AbortSignal,
|
||||
): HTMLElement {
|
||||
|
||||
const bereich = document.createElement('section');
|
||||
|
||||
@@ -695,57 +703,108 @@ function bibliotheksbereich(geaendert: () => void, signal: AbortSignal): HTMLEle
|
||||
const liste = document.createElement('div');
|
||||
liste.className = 'einstellungen';
|
||||
bereich.appendChild(liste);
|
||||
|
||||
/** Die gezeichneten Zeilen, je Mediathek.
|
||||
*
|
||||
* **Vor dem ersten `laden()`.** Stand die Map darunter, lief der erste
|
||||
* Aufruf in die temporale Totzone von `const` — „Cannot access 'zeilen'
|
||||
* before initialization", und der ganze Abschnitt blieb leer.
|
||||
*/
|
||||
const zeilen = new Map<string, Mediathekzeile>();
|
||||
|
||||
void laden();
|
||||
|
||||
async function laden(): Promise<void> {
|
||||
// **Der Spinner gehört an den ersten Aufbau, nicht an jedes Auffrischen.**
|
||||
// Sonst leert sich die Liste sichtbar, bevor dasselbe wieder erscheint —
|
||||
// und bei einem Takt im Hintergrund blinkt sie dauernd.
|
||||
if (liste.childElementCount === 0) liste.innerHTML = '<div class="spinner"></div>';
|
||||
try {
|
||||
const alle = await bibliotheken();
|
||||
liste.innerHTML = '';
|
||||
if (alle.length === 0) {
|
||||
liste.appendChild(leer('Noch keine Mediathek. Lege oben eine an.'));
|
||||
return;
|
||||
}
|
||||
alle.forEach(b => liste.appendChild(block(b)));
|
||||
} catch (fehler) {
|
||||
liste.innerHTML = '';
|
||||
liste.appendChild(fehlerzeile(meldung(fehler)));
|
||||
/** **Auffrischen heisst fortschreiben, nicht neu zeichnen.**
|
||||
*
|
||||
* Hier stand `liste.innerHTML = ''` und danach ein Neuaufbau — alle drei
|
||||
* Sekunden, während ein Scan läuft. Der ganze Abschnitt verschwand dabei
|
||||
* sichtbar und kam wieder; wer gerade zielte, klickte ins Leere.
|
||||
*
|
||||
* Schlimmer noch: jeder Neuaufbau startete über `verfolgen(false)` einen
|
||||
* weiteren Takt. Nach einer Minute liefen zwanzig davon nebeneinander und
|
||||
* fragten im Sekundentakt denselben Scanstand ab.
|
||||
*/
|
||||
async function laden(): Promise<Bibliothek[] | undefined> {
|
||||
if (zeilen.size === 0 && liste.childElementCount === 0) {
|
||||
liste.innerHTML = '<div class="spinner"></div>';
|
||||
}
|
||||
|
||||
let alle: Bibliothek[];
|
||||
try { alle = await bibliotheken(); }
|
||||
catch (fehler) {
|
||||
// Beim Auffrischen bleibt stehen, was da ist: ein Aussetzer darf keine
|
||||
// Liste wegräumen, die noch stimmt.
|
||||
if (zeilen.size === 0) { liste.innerHTML = ''; liste.appendChild(fehlerzeile(meldung(fehler))); }
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (alle.length === 0) {
|
||||
zeilen.clear();
|
||||
liste.innerHTML = '';
|
||||
liste.appendChild(leer('Noch keine Mediathek. Lege oben eine an.'));
|
||||
return alle;
|
||||
}
|
||||
|
||||
// Spinner oder Hinweis weg — aber nur beim allerersten Mal.
|
||||
if (zeilen.size === 0) liste.innerHTML = '';
|
||||
|
||||
// Was es nicht mehr gibt, verschwindet.
|
||||
[...zeilen.keys()]
|
||||
.filter(id => !alle.some(b => b.id === id))
|
||||
.forEach(id => { zeilen.get(id)!.wurzel.remove(); zeilen.delete(id); });
|
||||
|
||||
alle.forEach((b, i) => {
|
||||
let zeile = zeilen.get(b.id);
|
||||
if (zeile) zeile.aktualisieren(b);
|
||||
else { zeile = block(b); zeilen.set(b.id, zeile); }
|
||||
|
||||
// Reihenfolge angleichen, ohne alles anzufassen: `insertBefore` mit
|
||||
// einem Knoten, der schon dort steht, tut nichts.
|
||||
if (liste.children[i] !== zeile.wurzel) {
|
||||
liste.insertBefore(zeile.wurzel, liste.children[i] ?? null);
|
||||
}
|
||||
});
|
||||
|
||||
return alle;
|
||||
}
|
||||
|
||||
function block(b: Bibliothek): HTMLElement {
|
||||
function block(b: Bibliothek): Mediathekzeile {
|
||||
// Die Knöpfe lesen den Stand über diese Grösse, nicht über `b` — sonst
|
||||
// arbeiteten sie nach dem ersten Auffrischen mit veralteten Werten.
|
||||
let aktuell = b;
|
||||
const d = document.createElement('div');
|
||||
d.className = 'einstellung';
|
||||
|
||||
const serie = b.kind === 'Shows';
|
||||
// „1 Filme" ist der Satz, an dem man sieht, dass niemand hingeschaut hat.
|
||||
const anzahl = b.itemCount == null ? ''
|
||||
: ` · ${b.itemCount} ` + (b.itemCount === 1
|
||||
? (serie ? 'Serie' : 'Film')
|
||||
: (serie ? 'Serien' : 'Filme'));
|
||||
|
||||
// Was wartet, gehört in dieselbe Zeile — sonst erfährt man es nur, wenn
|
||||
// man bis zur Prüfliste weiterscrollt.
|
||||
const wartet = b.pendingCount > 0
|
||||
? ` · ${b.pendingCount} wartet${b.pendingCount === 1 ? '' : 'en'} auf Freigabe`
|
||||
: '';
|
||||
|
||||
const kopf = document.createElement('div');
|
||||
kopf.className = 'zeile';
|
||||
kopf.innerHTML = `<strong>${esc(b.name)}</strong>`
|
||||
+ `<span class="winzig">${esc(b.path)}${esc(anzahl)}${esc(wartet)}</span>`;
|
||||
d.appendChild(kopf);
|
||||
|
||||
function kopfSchreiben(): void {
|
||||
const serie = aktuell.kind === 'Shows';
|
||||
// „1 Filme" ist der Satz, an dem man sieht, dass niemand hingeschaut hat.
|
||||
const anzahl = aktuell.itemCount == null ? ''
|
||||
: ` · ${aktuell.itemCount} ` + (aktuell.itemCount === 1
|
||||
? (serie ? 'Serie' : 'Film')
|
||||
: (serie ? 'Serien' : 'Filme'));
|
||||
|
||||
// Was wartet, gehört in dieselbe Zeile — sonst erfährt man es nur, wenn
|
||||
// man bis zur Prüfliste weiterscrollt.
|
||||
const wartet = aktuell.pendingCount > 0
|
||||
? ` · ${aktuell.pendingCount} wartet${aktuell.pendingCount === 1 ? '' : 'en'} auf Freigabe`
|
||||
: '';
|
||||
|
||||
kopf.innerHTML = `<strong>${esc(aktuell.name)}</strong>`
|
||||
+ `<span class="winzig">${esc(aktuell.path)}${esc(anzahl)}${esc(wartet)}</span>`;
|
||||
}
|
||||
kopfSchreiben();
|
||||
|
||||
const stand = document.createElement('div');
|
||||
stand.className = 'hinweis';
|
||||
|
||||
const los = knopf('Scannen', 'btn');
|
||||
los.onclick = async () => {
|
||||
laufendSetzen(true);
|
||||
try { await scanStarten(b.id); await verfolgen(true); }
|
||||
try { await scanStarten(aktuell.id); await verfolgen(true); }
|
||||
catch (fehler) { stand.textContent = meldung(fehler); laufendSetzen(false); }
|
||||
};
|
||||
|
||||
@@ -777,14 +836,14 @@ function bibliotheksbereich(geaendert: () => void, signal: AbortSignal): HTMLEle
|
||||
auffrischen.title = 'Holt Titel, Bilder und Besetzung erneut — '
|
||||
+ 'nötig nach einer Sprachänderung.';
|
||||
auffrischen.onclick = () => nachfragen({
|
||||
titel: `Metadaten von „${b.name}" auffrischen?`,
|
||||
titel: `Metadaten von „${aktuell.name}" auffrischen?`,
|
||||
text: 'Alle Titel werden erneut bei TMDB geholt — über die bekannte Kennung, '
|
||||
+ 'nicht über eine neue Suche. Zuordnungen von Hand bleiben damit stehen. '
|
||||
+ 'Bei vielen Titeln dauert es.',
|
||||
bestaetigen: 'Auffrischen',
|
||||
tun: async () => {
|
||||
laufendSetzen(true);
|
||||
await metadatenAuffrischen(b.id);
|
||||
await metadatenAuffrischen(aktuell.id);
|
||||
void verfolgen(true);
|
||||
},
|
||||
});
|
||||
@@ -792,10 +851,10 @@ function bibliotheksbereich(geaendert: () => void, signal: AbortSignal): HTMLEle
|
||||
const umbenennen = knopf('Umbenennen', 'btn');
|
||||
umbenennen.onclick = () => dialog({
|
||||
titel: 'Mediathek umbenennen',
|
||||
felder: [{ name: 'name', beschriftung: 'Name', wert: b.name }],
|
||||
felder: [{ name: 'name', beschriftung: 'Name', wert: aktuell.name }],
|
||||
bestaetigen: 'Speichern',
|
||||
tun: async wert => {
|
||||
await bibliothekUmbenennen(b.id, wert('name'));
|
||||
await bibliothekUmbenennen(aktuell.id, wert('name'));
|
||||
await laden();
|
||||
geaendert();
|
||||
},
|
||||
@@ -807,14 +866,14 @@ function bibliotheksbereich(geaendert: () => void, signal: AbortSignal): HTMLEle
|
||||
// einmal; genau das war bisher der einzige Weg.
|
||||
const zuruecksetzen = knopf('Zurücksetzen', 'btn');
|
||||
zuruecksetzen.onclick = () => nachfragen({
|
||||
titel: `„${b.name}" zurücksetzen?`,
|
||||
titel: `„${aktuell.name}" zurücksetzen?`,
|
||||
text: 'Alle erfassten Titel, Zuordnungen und der Sehfortschritt dieser '
|
||||
+ 'Mediathek werden verworfen; Name, Pfad und Einstellungen bleiben. '
|
||||
+ 'Die Mediendateien auf der Platte bleiben unangetastet. Danach einmal '
|
||||
+ 'scannen, und der Bestand entsteht neu.',
|
||||
bestaetigen: 'Zurücksetzen',
|
||||
tun: async () => {
|
||||
await mediathekZuruecksetzen(b.id);
|
||||
await mediathekZuruecksetzen(aktuell.id);
|
||||
await laden();
|
||||
geaendert();
|
||||
},
|
||||
@@ -822,11 +881,11 @@ function bibliotheksbereich(geaendert: () => void, signal: AbortSignal): HTMLEle
|
||||
|
||||
const entfernen = knopf('Entfernen', 'btn');
|
||||
entfernen.onclick = () => nachfragen({
|
||||
titel: `„${b.name}" entfernen?`,
|
||||
titel: `„${aktuell.name}" entfernen?`,
|
||||
text: 'Die Mediendateien auf der Platte bleiben unangetastet. Weg sind die '
|
||||
+ 'Einträge, die Zuordnungen und der Sehfortschritt aller Benutzer dazu.',
|
||||
bestaetigen: 'Entfernen',
|
||||
tun: async () => { await bibliothekLoeschen(b.id); await laden(); geaendert(); },
|
||||
tun: async () => { await bibliothekLoeschen(aktuell.id); await laden(); geaendert(); },
|
||||
});
|
||||
|
||||
/** Fragt nach, bis der Lauf durch ist — er läuft im Hintergrund. */
|
||||
@@ -840,7 +899,7 @@ function bibliotheksbereich(geaendert: () => void, signal: AbortSignal): HTMLEle
|
||||
async function verfolgen(melden: boolean): Promise<void> {
|
||||
for (let i = 0; i < 900; i++) {
|
||||
if (signal.aborted) return;
|
||||
const s = await scanstand(b.id);
|
||||
const s = await scanstand(aktuell.id);
|
||||
// **Was der Server sagt, nicht was wir raten.** Vorher stand hier
|
||||
// minutenlang „Wird durchsucht…", ohne Hinweis, ob etwas passiert.
|
||||
// **Die Sperre richtet sich nach dem Server, nicht nach dem Klick.**
|
||||
@@ -869,7 +928,7 @@ function bibliotheksbereich(geaendert: () => void, signal: AbortSignal): HTMLEle
|
||||
//
|
||||
// Alle drei Runden statt alle fünf: nah genug am Zähler in der
|
||||
// Mediathek, ohne je Sekunde zwei Abrufe zu kosten.
|
||||
if (melden && i > 0 && i % 3 === 0) { await laden(); geaendert(); }
|
||||
if (melden && i > 0 && i % 3 === 0) geaendert(await laden());
|
||||
|
||||
if (s.state === 'finished') {
|
||||
const teile = [`${s.filesFound} Dateien`, `${s.added} neu`, `${s.removed} entfernt`];
|
||||
@@ -895,11 +954,11 @@ function bibliotheksbereich(geaendert: () => void, signal: AbortSignal): HTMLEle
|
||||
|
||||
// Die Prüfung lässt sich je Mediathek umstellen — was beim Anlegen
|
||||
// entschieden wurde, soll kein Urteil fürs Leben sein.
|
||||
const pruefung = knopf(b.reviewNewItems ? 'Prüfung an' : 'Prüfung aus', 'btn');
|
||||
const pruefung = knopf(aktuell.reviewNewItems ? 'Prüfung an' : 'Prüfung aus', 'btn');
|
||||
pruefung.onclick = async () => {
|
||||
pruefung.disabled = true;
|
||||
try {
|
||||
await pruefungSetzen(b.id, !b.reviewNewItems);
|
||||
await pruefungSetzen(aktuell.id, !aktuell.reviewNewItems);
|
||||
await laden();
|
||||
} catch (fehler) {
|
||||
d.appendChild(fehlerzeile(meldung(fehler)));
|
||||
@@ -919,27 +978,47 @@ function bibliotheksbereich(geaendert: () => void, signal: AbortSignal): HTMLEle
|
||||
// **Nur wenn hier wirklich etwas liegt.** Ein Knopf, der nichts zu tun
|
||||
// hat, ist schlechter als keiner — und die Zahl daneben sagt, worum es
|
||||
// geht, ohne dass man erst weiterscrollen muss.
|
||||
if (b.pendingCount > 0) {
|
||||
const freigeben = knopf(
|
||||
`${b.pendingCount} ${b.pendingCount === 1 ? 'Titel' : 'Titel'} freigeben`,
|
||||
'btn btn-primary');
|
||||
freigeben.onclick = async () => {
|
||||
freigeben.disabled = true;
|
||||
try {
|
||||
const bericht = await mediathekFreigeben(b.id);
|
||||
stand.textContent = `${bericht.published} freigegeben.`;
|
||||
await laden();
|
||||
geaendert();
|
||||
} catch (fehler) {
|
||||
d.appendChild(fehlerzeile(meldung(fehler)));
|
||||
freigeben.disabled = false;
|
||||
}
|
||||
};
|
||||
knoepfe.appendChild(freigeben);
|
||||
//
|
||||
// Angelegt wird er trotzdem immer und nur versteckt: ihn beim Auffrischen
|
||||
// anzulegen und wieder wegzuwerfen wäre genau das Flackern, das hier
|
||||
// gerade abgestellt wird.
|
||||
const freigeben = knopf('', 'btn btn-primary');
|
||||
freigeben.onclick = async () => {
|
||||
freigeben.disabled = true;
|
||||
try {
|
||||
const bericht = await mediathekFreigeben(aktuell.id);
|
||||
stand.textContent = `${bericht.published} freigegeben.`;
|
||||
await laden();
|
||||
geaendert();
|
||||
} catch (fehler) {
|
||||
d.appendChild(fehlerzeile(meldung(fehler)));
|
||||
} finally {
|
||||
freigeben.disabled = false;
|
||||
}
|
||||
};
|
||||
knoepfe.appendChild(freigeben);
|
||||
|
||||
function freigabeSchreiben(): void {
|
||||
freigeben.hidden = aktuell.pendingCount === 0;
|
||||
freigeben.textContent = `${aktuell.pendingCount} Titel freigeben`;
|
||||
}
|
||||
freigabeSchreiben();
|
||||
|
||||
d.append(knoepfe, stand);
|
||||
|
||||
// **Genau ein Takt je Mediathek.** Er entsteht hier, beim Anlegen der
|
||||
// Zeile — und weil die Zeile nicht mehr neu gebaut wird, bleibt es dabei.
|
||||
void verfolgen(false);
|
||||
return d;
|
||||
|
||||
return {
|
||||
wurzel: d,
|
||||
aktualisieren: (neu: Bibliothek) => {
|
||||
aktuell = neu;
|
||||
kopfSchreiben();
|
||||
pruefung.textContent = aktuell.reviewNewItems ? 'Prüfung an' : 'Prüfung aus';
|
||||
freigabeSchreiben();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return bereich;
|
||||
|
||||