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" : [
|
"fill-specializations" : [
|
||||||
{
|
{
|
||||||
"value" : {
|
"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" : [
|
"layers" : [
|
||||||
{
|
{
|
||||||
"fill" : {
|
"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",
|
"image-name" : "flare (1).svg",
|
||||||
"name" : "play",
|
"name" : "flare (1)",
|
||||||
"position" : {
|
"position" : {
|
||||||
"scale" : 1.18,
|
"scale" : 100,
|
||||||
"translation-in-points" : [
|
"translation-in-points" : [
|
||||||
49.7265625,
|
0,
|
||||||
1.468530916781674e-05
|
0
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -282,11 +282,22 @@ public static class ManagementEndpoints
|
|||||||
// sie weiss: Titel, Zuordnungen, Fortschritt. Ein Server, der beim
|
// sie weiss: Titel, Zuordnungen, Fortschritt. Ein Server, der beim
|
||||||
// Aufräumen in der Oberfläche fremde Filme löscht, ist ein Server,
|
// Aufräumen in der Oberfläche fremde Filme löscht, ist ein Server,
|
||||||
// den man nicht noch einmal benutzt.
|
// 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);
|
var vorhanden = await db.Libraries.FindAsync(id);
|
||||||
if (vorhanden is null) return Results.NotFound();
|
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);
|
db.Libraries.Remove(vorhanden);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
return Results.NoContent();
|
return Results.NoContent();
|
||||||
@@ -302,11 +313,27 @@ public static class ManagementEndpoints
|
|||||||
//
|
//
|
||||||
// Nicht gelöscht wird eine einzige Mediendatei. Entfernt wird allein,
|
// Nicht gelöscht wird eine einzige Mediendatei. Entfernt wird allein,
|
||||||
// was Flaro über sie weiss.
|
// 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);
|
var vorhanden = await db.Libraries.FindAsync(id);
|
||||||
if (vorhanden is null) return Results.NotFound();
|
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
|
// In dieser Reihenfolge: erst die Dateien, dann die Titel. Die
|
||||||
// Fremdschlüssel zeigen von den Dateien auf Film und Folge.
|
// Fremdschlüssel zeigen von den Dateien auf Film und Folge.
|
||||||
await db.MediaFiles.Where(f => f.LibraryId == id).ExecuteDeleteAsync();
|
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.",
|
+ "Oft ist das ein volles Volume oder ein schlafendes Laufwerk.",
|
||||||
TaskCanceledException or OperationCanceledException =>
|
TaskCanceledException or OperationCanceledException =>
|
||||||
"Der Vorgang wurde abgebrochen.",
|
"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 =>
|
HttpRequestException =>
|
||||||
"Die Metadatenquelle war nicht erreichbar. "
|
"Die Metadatenquelle war nicht erreichbar. "
|
||||||
+ "Beim nächsten Lauf wird es erneut versucht.",
|
+ "Beim nächsten Lauf wird es erneut versucht.",
|
||||||
|
|||||||
@@ -325,9 +325,12 @@ public sealed class MetadataEnricher(
|
|||||||
private async Task<int> FolgenZuordnen(
|
private async Task<int> FolgenZuordnen(
|
||||||
Guid libraryId, bool erneut, IProgress<Scanschritt>? fortschritt, CancellationToken ct)
|
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
|
var serien = await db.Series
|
||||||
.Where(s => s.LibraryId == libraryId && s.TmdbId != null)
|
.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);
|
.ToListAsync(ct);
|
||||||
if (serien.Count == 0) return 0;
|
if (serien.Count == 0) return 0;
|
||||||
|
|
||||||
@@ -336,16 +339,21 @@ public sealed class MetadataEnricher(
|
|||||||
.Where(f => kennungen.Contains(f.SeriesId) && (erneut || f.Overview == null))
|
.Where(f => kennungen.Contains(f.SeriesId) && (erneut || f.Overview == null))
|
||||||
.ToListAsync(ct);
|
.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 zugeordnet = 0;
|
||||||
var durch = 0;
|
var durch = 0;
|
||||||
foreach (var folge in offen)
|
foreach (var folge in offen)
|
||||||
{
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
if (!nachKennung.TryGetValue(folge.SeriesId, out var serie)) continue;
|
||||||
|
|
||||||
fortschritt?.Report(new Scanschritt(
|
fortschritt?.Report(new Scanschritt(
|
||||||
Scanphase.Holen,
|
Scanphase.Holen,
|
||||||
$"Folge {folge.SeasonNumber}x{folge.EpisodeNumber:00}", ++durch, offen.Count));
|
$"{serie.Title} {folge.SeasonNumber}x{folge.EpisodeNumber:00}",
|
||||||
|
++durch, offen.Count));
|
||||||
ct.ThrowIfCancellationRequested();
|
|
||||||
var serie = serien.First(s => s.Id == folge.SeriesId);
|
|
||||||
|
|
||||||
var gefunden = await anbieter.FindEpisodeAsync(serie.TmdbId, folge.SeasonNumber, folge.EpisodeNumber, ct);
|
var gefunden = await anbieter.FindEpisodeAsync(serie.TmdbId, folge.SeasonNumber, folge.EpisodeNumber, ct);
|
||||||
if (gefunden is null) continue;
|
if (gefunden is null) continue;
|
||||||
|
|||||||
@@ -500,6 +500,32 @@ public class ScanTests
|
|||||||
Assert.That(offen.Dateien[0].Grund, Does.Contain("keine Folgennummer"));
|
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]
|
[Test]
|
||||||
public async Task Die_Suche_grenzt_ein()
|
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="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
<meta name="theme-color" content="#0B0B0D">
|
<meta name="theme-color" content="#0B0B0D">
|
||||||
<title>Flaro</title>
|
<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
|
<!-- **Inter, mit der Systemschrift als Rückfall.** `display=swap`, damit der
|
||||||
Text sofort steht und nicht erst, wenn die Schrift da ist — ein
|
Text sofort steht und nicht erst, wenn die Schrift da ist — ein
|
||||||
Heimserver im eigenen Netz soll nicht auf Google warten. -->
|
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
|
* Endlosschleife mit zwei Abrufen je Runde, die sich als vier dauernd
|
||||||
* drehende Spinner zeigte. Ein neuer Zählwert in der Seitenleiste braucht
|
* drehende Spinner zeigte. Ein neuer Zählwert in der Seitenleiste braucht
|
||||||
* keinen Neuaufbau der Seite. */
|
* keinen Neuaufbau der Seite. */
|
||||||
export async function zaehlerAuffrischen(): Promise<void> {
|
export async function zaehlerAuffrischen(bekannt?: Bibliothek[]): Promise<void> {
|
||||||
mediatheken = await bibliotheken();
|
// **Denselben Stand zeigen, nicht denselben Takt haben.**
|
||||||
leisteBauen();
|
//
|
||||||
|
// 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 ---------- */
|
/* ---------- Kopfleiste ---------- */
|
||||||
@@ -373,11 +397,14 @@ function zeichnen(): void {
|
|||||||
} else if (teile[0] === 'verwaltung' && istAdmin()) {
|
} else if (teile[0] === 'verwaltung' && istAdmin()) {
|
||||||
// **Die Verwaltung auch nicht** — sie zeigt Laufendes. Ihr Takt hängt am
|
// **Die Verwaltung auch nicht** — sie zeigt Laufendes. Ihr Takt hängt am
|
||||||
// Signal und hört auf, sobald man weggeht.
|
// 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.
|
// Gemerkte Ansichten wegwerfen: der Bestand hat sich geändert.
|
||||||
// Die Seitenleiste bekommt neue Zahlen — die Seite selbst bleibt.
|
// Die Seitenleiste bekommt neue Zahlen — die Seite selbst bleibt.
|
||||||
vergessen();
|
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));
|
}, signal));
|
||||||
} else {
|
} else {
|
||||||
einhaengen(weg, inhalt, () => startseite(mediatheken, oeffnen, alleZeigen),
|
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
|
* `geaendert` wird gerufen, wenn sich die Mediathekenliste geändert hat; die
|
||||||
* Seitenleiste steht auf ihr und muss dann neu gezeichnet werden. */
|
* Seitenleiste steht auf ihr und muss dann neu gezeichnet werden. */
|
||||||
export function verwaltung(
|
export function verwaltung(
|
||||||
geaendert: () => void = () => {},
|
geaendert: (bekannt?: Bibliothek[]) => void = () => {},
|
||||||
signal: AbortSignal = new AbortController().signal,
|
signal: AbortSignal = new AbortController().signal,
|
||||||
): HTMLElement {
|
): HTMLElement {
|
||||||
const wurzel = document.createElement('div');
|
const wurzel = document.createElement('div');
|
||||||
@@ -655,7 +655,15 @@ function vermisstenbereich(geaendert: () => void): HTMLElement {
|
|||||||
|
|
||||||
/* ---------- Bibliotheken ---------- */
|
/* ---------- 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');
|
const bereich = document.createElement('section');
|
||||||
|
|
||||||
@@ -695,49 +703,100 @@ function bibliotheksbereich(geaendert: () => void, signal: AbortSignal): HTMLEle
|
|||||||
const liste = document.createElement('div');
|
const liste = document.createElement('div');
|
||||||
liste.className = 'einstellungen';
|
liste.className = 'einstellungen';
|
||||||
bereich.appendChild(liste);
|
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();
|
void laden();
|
||||||
|
|
||||||
async function laden(): Promise<void> {
|
/** **Auffrischen heisst fortschreiben, nicht neu zeichnen.**
|
||||||
// **Der Spinner gehört an den ersten Aufbau, nicht an jedes Auffrischen.**
|
*
|
||||||
// Sonst leert sich die Liste sichtbar, bevor dasselbe wieder erscheint —
|
* Hier stand `liste.innerHTML = ''` und danach ein Neuaufbau — alle drei
|
||||||
// und bei einem Takt im Hintergrund blinkt sie dauernd.
|
* Sekunden, während ein Scan läuft. Der ganze Abschnitt verschwand dabei
|
||||||
if (liste.childElementCount === 0) liste.innerHTML = '<div class="spinner"></div>';
|
* sichtbar und kam wieder; wer gerade zielte, klickte ins Leere.
|
||||||
try {
|
*
|
||||||
const alle = await bibliotheken();
|
* Schlimmer noch: jeder Neuaufbau startete über `verfolgen(false)` einen
|
||||||
liste.innerHTML = '';
|
* weiteren Takt. Nach einer Minute liefen zwanzig davon nebeneinander und
|
||||||
if (alle.length === 0) {
|
* fragten im Sekundentakt denselben Scanstand ab.
|
||||||
liste.appendChild(leer('Noch keine Mediathek. Lege oben eine an.'));
|
*/
|
||||||
return;
|
async function laden(): Promise<Bibliothek[] | undefined> {
|
||||||
}
|
if (zeilen.size === 0 && liste.childElementCount === 0) {
|
||||||
alle.forEach(b => liste.appendChild(block(b)));
|
liste.innerHTML = '<div class="spinner"></div>';
|
||||||
} catch (fehler) {
|
|
||||||
liste.innerHTML = '';
|
|
||||||
liste.appendChild(fehlerzeile(meldung(fehler)));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function block(b: Bibliothek): HTMLElement {
|
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): 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');
|
const d = document.createElement('div');
|
||||||
d.className = 'einstellung';
|
d.className = 'einstellung';
|
||||||
|
|
||||||
const serie = b.kind === 'Shows';
|
const kopf = document.createElement('div');
|
||||||
|
kopf.className = 'zeile';
|
||||||
|
d.appendChild(kopf);
|
||||||
|
|
||||||
|
function kopfSchreiben(): void {
|
||||||
|
const serie = aktuell.kind === 'Shows';
|
||||||
// „1 Filme" ist der Satz, an dem man sieht, dass niemand hingeschaut hat.
|
// „1 Filme" ist der Satz, an dem man sieht, dass niemand hingeschaut hat.
|
||||||
const anzahl = b.itemCount == null ? ''
|
const anzahl = aktuell.itemCount == null ? ''
|
||||||
: ` · ${b.itemCount} ` + (b.itemCount === 1
|
: ` · ${aktuell.itemCount} ` + (aktuell.itemCount === 1
|
||||||
? (serie ? 'Serie' : 'Film')
|
? (serie ? 'Serie' : 'Film')
|
||||||
: (serie ? 'Serien' : 'Filme'));
|
: (serie ? 'Serien' : 'Filme'));
|
||||||
|
|
||||||
// Was wartet, gehört in dieselbe Zeile — sonst erfährt man es nur, wenn
|
// Was wartet, gehört in dieselbe Zeile — sonst erfährt man es nur, wenn
|
||||||
// man bis zur Prüfliste weiterscrollt.
|
// man bis zur Prüfliste weiterscrollt.
|
||||||
const wartet = b.pendingCount > 0
|
const wartet = aktuell.pendingCount > 0
|
||||||
? ` · ${b.pendingCount} wartet${b.pendingCount === 1 ? '' : 'en'} auf Freigabe`
|
? ` · ${aktuell.pendingCount} wartet${aktuell.pendingCount === 1 ? '' : 'en'} auf Freigabe`
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
const kopf = document.createElement('div');
|
kopf.innerHTML = `<strong>${esc(aktuell.name)}</strong>`
|
||||||
kopf.className = 'zeile';
|
+ `<span class="winzig">${esc(aktuell.path)}${esc(anzahl)}${esc(wartet)}</span>`;
|
||||||
kopf.innerHTML = `<strong>${esc(b.name)}</strong>`
|
}
|
||||||
+ `<span class="winzig">${esc(b.path)}${esc(anzahl)}${esc(wartet)}</span>`;
|
kopfSchreiben();
|
||||||
d.appendChild(kopf);
|
|
||||||
|
|
||||||
const stand = document.createElement('div');
|
const stand = document.createElement('div');
|
||||||
stand.className = 'hinweis';
|
stand.className = 'hinweis';
|
||||||
@@ -745,7 +804,7 @@ function bibliotheksbereich(geaendert: () => void, signal: AbortSignal): HTMLEle
|
|||||||
const los = knopf('Scannen', 'btn');
|
const los = knopf('Scannen', 'btn');
|
||||||
los.onclick = async () => {
|
los.onclick = async () => {
|
||||||
laufendSetzen(true);
|
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); }
|
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 — '
|
auffrischen.title = 'Holt Titel, Bilder und Besetzung erneut — '
|
||||||
+ 'nötig nach einer Sprachänderung.';
|
+ 'nötig nach einer Sprachänderung.';
|
||||||
auffrischen.onclick = () => nachfragen({
|
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, '
|
text: 'Alle Titel werden erneut bei TMDB geholt — über die bekannte Kennung, '
|
||||||
+ 'nicht über eine neue Suche. Zuordnungen von Hand bleiben damit stehen. '
|
+ 'nicht über eine neue Suche. Zuordnungen von Hand bleiben damit stehen. '
|
||||||
+ 'Bei vielen Titeln dauert es.',
|
+ 'Bei vielen Titeln dauert es.',
|
||||||
bestaetigen: 'Auffrischen',
|
bestaetigen: 'Auffrischen',
|
||||||
tun: async () => {
|
tun: async () => {
|
||||||
laufendSetzen(true);
|
laufendSetzen(true);
|
||||||
await metadatenAuffrischen(b.id);
|
await metadatenAuffrischen(aktuell.id);
|
||||||
void verfolgen(true);
|
void verfolgen(true);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -792,10 +851,10 @@ function bibliotheksbereich(geaendert: () => void, signal: AbortSignal): HTMLEle
|
|||||||
const umbenennen = knopf('Umbenennen', 'btn');
|
const umbenennen = knopf('Umbenennen', 'btn');
|
||||||
umbenennen.onclick = () => dialog({
|
umbenennen.onclick = () => dialog({
|
||||||
titel: 'Mediathek umbenennen',
|
titel: 'Mediathek umbenennen',
|
||||||
felder: [{ name: 'name', beschriftung: 'Name', wert: b.name }],
|
felder: [{ name: 'name', beschriftung: 'Name', wert: aktuell.name }],
|
||||||
bestaetigen: 'Speichern',
|
bestaetigen: 'Speichern',
|
||||||
tun: async wert => {
|
tun: async wert => {
|
||||||
await bibliothekUmbenennen(b.id, wert('name'));
|
await bibliothekUmbenennen(aktuell.id, wert('name'));
|
||||||
await laden();
|
await laden();
|
||||||
geaendert();
|
geaendert();
|
||||||
},
|
},
|
||||||
@@ -807,14 +866,14 @@ function bibliotheksbereich(geaendert: () => void, signal: AbortSignal): HTMLEle
|
|||||||
// einmal; genau das war bisher der einzige Weg.
|
// einmal; genau das war bisher der einzige Weg.
|
||||||
const zuruecksetzen = knopf('Zurücksetzen', 'btn');
|
const zuruecksetzen = knopf('Zurücksetzen', 'btn');
|
||||||
zuruecksetzen.onclick = () => nachfragen({
|
zuruecksetzen.onclick = () => nachfragen({
|
||||||
titel: `„${b.name}" zurücksetzen?`,
|
titel: `„${aktuell.name}" zurücksetzen?`,
|
||||||
text: 'Alle erfassten Titel, Zuordnungen und der Sehfortschritt dieser '
|
text: 'Alle erfassten Titel, Zuordnungen und der Sehfortschritt dieser '
|
||||||
+ 'Mediathek werden verworfen; Name, Pfad und Einstellungen bleiben. '
|
+ 'Mediathek werden verworfen; Name, Pfad und Einstellungen bleiben. '
|
||||||
+ 'Die Mediendateien auf der Platte bleiben unangetastet. Danach einmal '
|
+ 'Die Mediendateien auf der Platte bleiben unangetastet. Danach einmal '
|
||||||
+ 'scannen, und der Bestand entsteht neu.',
|
+ 'scannen, und der Bestand entsteht neu.',
|
||||||
bestaetigen: 'Zurücksetzen',
|
bestaetigen: 'Zurücksetzen',
|
||||||
tun: async () => {
|
tun: async () => {
|
||||||
await mediathekZuruecksetzen(b.id);
|
await mediathekZuruecksetzen(aktuell.id);
|
||||||
await laden();
|
await laden();
|
||||||
geaendert();
|
geaendert();
|
||||||
},
|
},
|
||||||
@@ -822,11 +881,11 @@ function bibliotheksbereich(geaendert: () => void, signal: AbortSignal): HTMLEle
|
|||||||
|
|
||||||
const entfernen = knopf('Entfernen', 'btn');
|
const entfernen = knopf('Entfernen', 'btn');
|
||||||
entfernen.onclick = () => nachfragen({
|
entfernen.onclick = () => nachfragen({
|
||||||
titel: `„${b.name}" entfernen?`,
|
titel: `„${aktuell.name}" entfernen?`,
|
||||||
text: 'Die Mediendateien auf der Platte bleiben unangetastet. Weg sind die '
|
text: 'Die Mediendateien auf der Platte bleiben unangetastet. Weg sind die '
|
||||||
+ 'Einträge, die Zuordnungen und der Sehfortschritt aller Benutzer dazu.',
|
+ 'Einträge, die Zuordnungen und der Sehfortschritt aller Benutzer dazu.',
|
||||||
bestaetigen: 'Entfernen',
|
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. */
|
/** 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> {
|
async function verfolgen(melden: boolean): Promise<void> {
|
||||||
for (let i = 0; i < 900; i++) {
|
for (let i = 0; i < 900; i++) {
|
||||||
if (signal.aborted) return;
|
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
|
// **Was der Server sagt, nicht was wir raten.** Vorher stand hier
|
||||||
// minutenlang „Wird durchsucht…", ohne Hinweis, ob etwas passiert.
|
// minutenlang „Wird durchsucht…", ohne Hinweis, ob etwas passiert.
|
||||||
// **Die Sperre richtet sich nach dem Server, nicht nach dem Klick.**
|
// **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
|
// Alle drei Runden statt alle fünf: nah genug am Zähler in der
|
||||||
// Mediathek, ohne je Sekunde zwei Abrufe zu kosten.
|
// 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') {
|
if (s.state === 'finished') {
|
||||||
const teile = [`${s.filesFound} Dateien`, `${s.added} neu`, `${s.removed} entfernt`];
|
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
|
// Die Prüfung lässt sich je Mediathek umstellen — was beim Anlegen
|
||||||
// entschieden wurde, soll kein Urteil fürs Leben sein.
|
// 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.onclick = async () => {
|
||||||
pruefung.disabled = true;
|
pruefung.disabled = true;
|
||||||
try {
|
try {
|
||||||
await pruefungSetzen(b.id, !b.reviewNewItems);
|
await pruefungSetzen(aktuell.id, !aktuell.reviewNewItems);
|
||||||
await laden();
|
await laden();
|
||||||
} catch (fehler) {
|
} catch (fehler) {
|
||||||
d.appendChild(fehlerzeile(meldung(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
|
// **Nur wenn hier wirklich etwas liegt.** Ein Knopf, der nichts zu tun
|
||||||
// hat, ist schlechter als keiner — und die Zahl daneben sagt, worum es
|
// hat, ist schlechter als keiner — und die Zahl daneben sagt, worum es
|
||||||
// geht, ohne dass man erst weiterscrollen muss.
|
// geht, ohne dass man erst weiterscrollen muss.
|
||||||
if (b.pendingCount > 0) {
|
//
|
||||||
const freigeben = knopf(
|
// Angelegt wird er trotzdem immer und nur versteckt: ihn beim Auffrischen
|
||||||
`${b.pendingCount} ${b.pendingCount === 1 ? 'Titel' : 'Titel'} freigeben`,
|
// anzulegen und wieder wegzuwerfen wäre genau das Flackern, das hier
|
||||||
'btn btn-primary');
|
// gerade abgestellt wird.
|
||||||
|
const freigeben = knopf('', 'btn btn-primary');
|
||||||
freigeben.onclick = async () => {
|
freigeben.onclick = async () => {
|
||||||
freigeben.disabled = true;
|
freigeben.disabled = true;
|
||||||
try {
|
try {
|
||||||
const bericht = await mediathekFreigeben(b.id);
|
const bericht = await mediathekFreigeben(aktuell.id);
|
||||||
stand.textContent = `${bericht.published} freigegeben.`;
|
stand.textContent = `${bericht.published} freigegeben.`;
|
||||||
await laden();
|
await laden();
|
||||||
geaendert();
|
geaendert();
|
||||||
} catch (fehler) {
|
} catch (fehler) {
|
||||||
d.appendChild(fehlerzeile(meldung(fehler)));
|
d.appendChild(fehlerzeile(meldung(fehler)));
|
||||||
|
} finally {
|
||||||
freigeben.disabled = false;
|
freigeben.disabled = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
knoepfe.appendChild(freigeben);
|
knoepfe.appendChild(freigeben);
|
||||||
|
|
||||||
|
function freigabeSchreiben(): void {
|
||||||
|
freigeben.hidden = aktuell.pendingCount === 0;
|
||||||
|
freigeben.textContent = `${aktuell.pendingCount} Titel freigeben`;
|
||||||
}
|
}
|
||||||
|
freigabeSchreiben();
|
||||||
|
|
||||||
d.append(knoepfe, stand);
|
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);
|
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;
|
return bereich;
|
||||||
|
|||||||