Neun gemeldete Fehler, und die Frage, welcher Build gerade läuft
Ein grosser Commit, weil die Punkte einander bedingen: ohne lesbare Fehlermeldung war die Serienerkennung nicht zu diagnostizieren, und ohne Build-Kennung nicht einmal festzustellen, ob überhaupt der gebaute Stand läuft. Zwei der neun Meldungen betrafen Funktionen, die seit Stunden im Quelltext stehen. Version und Build. Die Version steht fest; daneben eine Kennung aus sechs Zeichen, die sich bei jedem Docker-Bau ändert. Sie steht in der Verwaltung, unter /health und im Protokoll bei jedem Start. Damit ist "reden wir vom selben Build?" in zwei Sekunden beantwortet statt in einem halben Tag. Serien. Lag eine Datei direkt im Serienordner und trug vor der Folgennummer nichts, wurde der ganze Dateiname zum Serientitel: aus 95 Serien wurden 737, jede mit einer Folge, bei jedem Lauf neu. Dazu lief der Scan in Verweise hinein — derselbe Ordner unter zwei Namen ergab zwei Serien. Und weder neue noch umgezogene Dateien standen in der Pfadliste des Laufs, weshalb ein doppelt gesehener Pfad eine zweite Zeile mit gleichem (LibraryId, Path) anlegte und der Eindeutigkeitsindex den ganzen Speichervorgang scheitern liess. Jede Serienanlage wird jetzt im Tagesprotokoll begründet. Fehlermeldungen. "An error occurred while saving the entity changes" stand englisch in einer deutschen Oberfläche und nannte weder Ursache noch nächsten Schritt. Jetzt geht der vollständige Text ins Protokoll und ein deutscher Satz ohne Fehlercode in die Anzeige — auch für "Failed to fetch", das aus dem Browser kam und nie übersetzt wurde. Protokolle. Die Ansicht holte die ganze Datei und zeigte 800 Zeilen davon; bei zwei Megabyte brach die Verbindung ab. Gelesen wird jetzt serverseitig rückwärts, mit einem Knopf für mehr. Auswahlfelder. Die drei <select> zeichnete das Betriebssystem: ein helles Systemmenü mitten in der dunklen Oberfläche. Ersetzt durch ein eigenes Listenfeld mit Tastaturbedienung. Bildwahl. Drei Raster untereinander in einem scrollenden Dialog hiess zwei Scrollebenen übereinander. Jetzt ein Reiter je Bildtyp. Sprache. Die Serversprache stand in der Serverliste zwischen Name, Metadaten und Fassung — gesucht und nicht gefunden. Sie hat jetzt einen eigenen Abschnitt ganz oben. Dazu: ein Knopf, der den erfassten Inhalt einer Mediathek verwirft, ohne Name und Pfad mitzunehmen; ein Abschnitt Speicher mit den drei Pfaden, ihrer Belegung und dem Leeren des Bildspeichers; und die Korrektur einer Zuordnung frischt nur noch die Kachel auf statt die ganze Seite. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
ab8a66d898
commit
ac04901ccc
@@ -74,6 +74,17 @@ registry_digest() {
|
||||
MARKEN=(-t "$BILD:latest")
|
||||
[ -n "$FASSUNG" ] && MARKEN+=(-t "$BILD:$FASSUNG")
|
||||
|
||||
# **Eine Kennung je Bau.**
|
||||
#
|
||||
# Sechs Zeichen, jedes Mal neu — auch bei unverändertem Quelltext. Sie landet
|
||||
# im Abbild und steht danach in der Oberfläche unter „Einstellungen" sowie
|
||||
# unter `/health`. Damit ist die Frage „läuft auf dem NAS wirklich der Bau von
|
||||
# eben?" in zwei Sekunden beantwortet statt in einem halben Tag.
|
||||
#
|
||||
# Ohne I, O, 0 und 1: diese Kennung wird vorgelesen und abgetippt.
|
||||
BUILD=$(LC_ALL=C tr -dc 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' < /dev/urandom | head -c 6)
|
||||
echo "Build-Kennung dieses Laufs: $BUILD"
|
||||
|
||||
# **Ein eigener Builder.** Der Vorgabe-Builder von Docker Desktop kann kein
|
||||
# Manifest mit zwei Architekturen bauen; `docker buildx create` schon.
|
||||
docker buildx inspect flaro-bauer >/dev/null 2>&1 \
|
||||
@@ -88,6 +99,7 @@ if ! docker buildx build \
|
||||
--builder flaro-bauer \
|
||||
--platform "$PLATTFORMEN" \
|
||||
-f server/Dockerfile \
|
||||
--build-arg "FLARO_BUILD=$BUILD" \
|
||||
"${MARKEN[@]}" \
|
||||
--push . ; then
|
||||
echo
|
||||
@@ -123,5 +135,9 @@ echo
|
||||
echo "Fertig. In der Registry liegt jetzt:"
|
||||
echo " $nachher"
|
||||
echo
|
||||
echo "Build-Kennung: $BUILD"
|
||||
echo " Dieselbe muss nach dem Ziehen unter Einstellungen stehen."
|
||||
echo " Steht dort eine andere, läuft auf dem NAS ein älteres Abbild."
|
||||
echo
|
||||
echo "Auf dem NAS:"
|
||||
echo " image: $BILD:${FASSUNG:-latest}"
|
||||
|
||||
@@ -76,6 +76,22 @@ ENV FLARO_DATA=/config \
|
||||
FLARO_TRANSCODE=/transcode \
|
||||
DOTNET_gcServer=0
|
||||
|
||||
# **Welches Abbild das hier ist.**
|
||||
#
|
||||
# Sechs Zeichen, bei jedem Bau neu gewürfelt — auch dann, wenn am Quelltext
|
||||
# nichts anders ist. Die Version sagt, welcher Stand gemeint ist; diese
|
||||
# Kennung sagt, welches Abbild wirklich läuft.
|
||||
#
|
||||
# Das ist keine Spielerei: zwei Funktionen standen nachweislich im Quelltext
|
||||
# und waren auf dem Server trotzdem nicht zu sehen. Ob dort ein älteres
|
||||
# Abbild lief, war nicht feststellbar, und die Fehlersuche lief einen halben
|
||||
# Tag ins Leere. Seitdem beantwortet ein Blick die erste aller Fragen.
|
||||
#
|
||||
# Ganz unten, weil jede Änderung daran alle folgenden Schichten neu baut —
|
||||
# und das sind hier nur noch die beiden COPY-Zeilen.
|
||||
ARG FLARO_BUILD=DEV
|
||||
ENV FLARO_BUILD=${FLARO_BUILD}
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=build /app .
|
||||
COPY --from=web /web/dist ./wwwroot
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
namespace Flaro.Api;
|
||||
|
||||
/// <summary>Welche Version läuft — und welcher Build genau.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Warum zwei Angaben.</b> Die Version sagt, welcher Stand gemeint ist;
|
||||
/// sie steht fest und ändert sich nur von Hand. Der Build sagt, welches
|
||||
/// Abbild tatsächlich läuft, und ändert sich bei <i>jedem</i> Bau — auch
|
||||
/// dann, wenn am Quelltext nichts anders ist.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Und warum das nötig wurde.</b> Es hat einen halben Tag gekostet: zwei
|
||||
/// Funktionen waren nachweislich im Quelltext und auf dem Server trotzdem
|
||||
/// nicht zu sehen. Ob dort ein älteres Abbild lief, liess sich nicht
|
||||
/// feststellen — der Server sagte es nirgends. Jede Fehlersuche lief damit
|
||||
/// ins Leere, weil die erste Frage unbeantwortbar war: reden wir vom selben
|
||||
/// Build?
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Sechs Zeichen, Grossbuchstaben und Ziffern.</b> Kurz genug, um sie
|
||||
/// vorzulesen und abzutippen, lang genug, um nicht zufällig zweimal
|
||||
/// vorzukommen. Ohne <c>I</c>, <c>O</c>, <c>0</c> und <c>1</c>, weil die
|
||||
/// beim Vorlesen verwechselt werden.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class Buildkennung
|
||||
{
|
||||
/// <summary>Die feste Version. Steigt nur, wenn jemand sie hier ändert.</summary>
|
||||
public const string Version = "1.0.0";
|
||||
|
||||
/// <summary>Die Kennung dieses Abbilds — oder <c>DEV</c> ausserhalb eines Containers.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// Gesetzt wird sie beim Bau des Abbilds über <c>FLARO_BUILD</c>. Fehlt
|
||||
/// sie, läuft der Server aus dem Quellbaum: dann ist <c>DEV</c> die
|
||||
/// ehrlichere Angabe als eine erfundene Kennung, die nichts bezeichnet.
|
||||
/// </remarks>
|
||||
public static string Build { get; } =
|
||||
Environment.GetEnvironmentVariable("FLARO_BUILD") is { Length: > 0 } wert
|
||||
? wert.Trim()
|
||||
: "DEV";
|
||||
|
||||
/// <summary>„1.0.0 · K7F2QX" — für Protokoll und Anzeige.</summary>
|
||||
public static string Beides => $"{Version} · {Build}";
|
||||
}
|
||||
@@ -5,9 +5,21 @@ namespace Flaro.Api.Endpoints;
|
||||
// und bricht Clients, die niemand mehr aktualisiert.
|
||||
|
||||
public sealed record SystemInfo(
|
||||
string Version, string ServerName, bool NeedsSetup,
|
||||
string Version, string Build, string ServerName, bool NeedsSetup,
|
||||
IReadOnlyList<AttributionResponse> Attributions);
|
||||
|
||||
/// <summary>Ein Ablageort des Servers, mitsamt Belegung.</summary>
|
||||
///
|
||||
/// <param name="Variable">
|
||||
/// Die Umgebungsvariable, über die er gesetzt wird. <b>Sie wird mit
|
||||
/// angezeigt</b>, weil sie die Antwort auf die nächste Frage ist: „und wie
|
||||
/// ändere ich das?" Im Betrieb umbiegen lässt sich ein Pfad nicht — dann
|
||||
/// lägen die Dateien woanders als die Datenbank sie vermutet.
|
||||
/// </param>
|
||||
/// <param name="Bilder">Wie viel davon auf den Bildspeicher entfällt; sonst <c>null</c>.</param>
|
||||
public sealed record Speicherort(
|
||||
string Name, string Variable, string Pfad, long Belegt, long? Frei, long? Bilder);
|
||||
|
||||
public sealed record ServerNameRequest(string Name);
|
||||
|
||||
public sealed record SetupRequest(string UserName, string Password);
|
||||
|
||||
@@ -261,6 +261,33 @@ public static class ManagementEndpoints
|
||||
await db.SaveChangesAsync();
|
||||
return Results.NoContent();
|
||||
}).RequireAuthorization(FlaroPolicies.Administrator);
|
||||
|
||||
// **Den Inhalt wegwerfen, die Mediathek behalten.**
|
||||
//
|
||||
// Wenn eine Zuordnungsregel falsch war, steht hinterher ein Bestand da,
|
||||
// den kein Scan mehr geradezieht: die falschen Einträge haben ihre
|
||||
// Dateien, gelten also nicht als verwaist, und bleiben. Die Mediathek
|
||||
// zu löschen und neu anzulegen tut dasselbe — nur verliert man dabei
|
||||
// Namen, Pfad und Einstellungen und tippt sie neu.
|
||||
//
|
||||
// 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) =>
|
||||
{
|
||||
var vorhanden = await db.Libraries.FindAsync(id);
|
||||
if (vorhanden is null) return Results.NotFound();
|
||||
|
||||
// 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();
|
||||
await db.Movies.Where(m => m.LibraryId == id).ExecuteDeleteAsync();
|
||||
await db.Episodes
|
||||
.Where(f => db.Series.Any(s => s.Id == f.SeriesId && s.LibraryId == id))
|
||||
.ExecuteDeleteAsync();
|
||||
await db.Series.Where(s => s.LibraryId == id).ExecuteDeleteAsync();
|
||||
|
||||
return Results.NoContent();
|
||||
}).RequireAuthorization(FlaroPolicies.Administrator);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,7 +28,11 @@ public static class SystemEndpoints
|
||||
{
|
||||
app.MapGet("/api/v1/system/info", async (FlaroContext db) =>
|
||||
Results.Ok(new SystemInfo(
|
||||
Version: typeof(SystemEndpoints).Assembly.GetName().Version?.ToString(3) ?? "0.0.0",
|
||||
// **Beides aus einer Hand.** Die Assembly-Version war hier
|
||||
// immer „1.0.0" und sagte deshalb nichts darüber, welches
|
||||
// Abbild läuft. Der Build daneben tut genau das.
|
||||
Version: Buildkennung.Version,
|
||||
Build: Buildkennung.Build,
|
||||
// **Wie dieser Server heisst.** Ohne ihn zeigen die Clients
|
||||
// den Hostnamen — „localhost", „192.168.1.50" —, und wer zwei
|
||||
// Server hat, unterscheidet sie an einer Zahl.
|
||||
@@ -110,7 +114,7 @@ public static class SystemEndpoints
|
||||
.ToList()))
|
||||
.RequireAuthorization(FlaroPolicies.Administrator);
|
||||
|
||||
app.MapGet("/api/v1/system/logs/{tag}", (string tag, Tagebuch tagebuch) =>
|
||||
app.MapGet("/api/v1/system/logs/{tag}", (string tag, Tagebuch tagebuch, int? tail) =>
|
||||
{
|
||||
// **Selbst zusammengesetzt, nicht aus der Anfrage übernommen.**
|
||||
// Ein Datum, das durch `DateOnly` gegangen ist, kann kein
|
||||
@@ -122,9 +126,19 @@ public static class SystemEndpoints
|
||||
}
|
||||
|
||||
var datei = tagebuch.DateiFuer(tagesdatum);
|
||||
return File.Exists(datei)
|
||||
? Results.File(datei, "text/plain; charset=utf-8", $"flaro-{tag}.log")
|
||||
: Results.NotFound();
|
||||
if (!File.Exists(datei)) return Results.NotFound();
|
||||
|
||||
// **Mit `tail` nur das Ende.** Die Ansicht braucht die letzten
|
||||
// Zeilen; die ganze Datei dafür zu schicken endete bei 2 MB über
|
||||
// eine wackelige Verbindung in „Failed to fetch". Ohne `tail`
|
||||
// kommt weiterhin alles — das ist der Weg zum Herunterladen.
|
||||
if (tail is > 0 and <= 5000)
|
||||
{
|
||||
return Results.Text(
|
||||
tagebuch.Ende(tagesdatum, tail.Value), "text/plain", System.Text.Encoding.UTF8);
|
||||
}
|
||||
|
||||
return Results.File(datei, "text/plain; charset=utf-8", $"flaro-{tag}.log");
|
||||
}).RequireAuthorization(FlaroPolicies.Administrator);
|
||||
|
||||
// **Die eigene Sprache.** Metadaten liegen in der des Servers vor; was
|
||||
@@ -215,6 +229,97 @@ public static class SystemEndpoints
|
||||
|
||||
// Ohne Anmeldung, damit Docker und Reverse Proxies danach fragen können.
|
||||
// Verrät nichts ausser der Tatsache, dass etwas antwortet.
|
||||
app.MapGet("/health", () => Results.Ok(new { status = "ok" })).AllowAnonymous();
|
||||
// **Wo was liegt, und wie viel davon.**
|
||||
//
|
||||
// Die drei Pfade standen nirgends in der Oberfläche; wer wissen wollte,
|
||||
// wohin die Bilder wandern, musste die Compose-Datei lesen. Und wenn
|
||||
// das `cache`-Volume vollläuft, ist das der erste Ort, an dem man
|
||||
// nachsieht — nicht der letzte.
|
||||
app.MapGet("/api/v1/system/storage", (FlaroPaths pfade) =>
|
||||
{
|
||||
Speicherort Messen(string name, string variable, string pfad) =>
|
||||
new(name, variable, pfad, Belegt(pfad), Frei(pfad),
|
||||
name == "Bilder" ? Belegt(Path.Combine(pfad, "images")) : null);
|
||||
|
||||
return Results.Ok(new[]
|
||||
{
|
||||
Messen("Daten", "FLARO_DATA", pfade.Data),
|
||||
Messen("Bilder", "FLARO_CACHE", pfade.Cache),
|
||||
Messen("Umpacken", "FLARO_TRANSCODE", pfade.Transcode),
|
||||
});
|
||||
}).RequireAuthorization(FlaroPolicies.Administrator);
|
||||
|
||||
// **Den Bildspeicher leeren.**
|
||||
//
|
||||
// Ungefährlich: jedes Bild lässt sich erneut holen, und hochgeladene
|
||||
// liegen als Datei genauso wieder da, sobald jemand sie neu wählt.
|
||||
// Darum ohne Umweg über eine Wartungsaktion — es ist ein Cache.
|
||||
app.MapDelete("/api/v1/system/storage/images", (FlaroPaths pfade) =>
|
||||
{
|
||||
var ordner = Path.Combine(pfade.Cache, "images");
|
||||
if (!Directory.Exists(ordner)) return Results.NoContent();
|
||||
|
||||
var befreit = Belegt(ordner);
|
||||
try
|
||||
{
|
||||
Directory.Delete(ordner, recursive: true);
|
||||
Directory.CreateDirectory(ordner);
|
||||
}
|
||||
catch (Exception fehler) when (fehler is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
return Results.Problem(Fehlertext.FuerMenschen(fehler));
|
||||
}
|
||||
|
||||
return Results.Ok(new { freed = befreit });
|
||||
}).RequireAuthorization(FlaroPolicies.Administrator);
|
||||
|
||||
// **Auch hier die Kennung.** Wer auf dem NAS nur „curl" hat, soll nicht
|
||||
// erst die Oberfläche öffnen müssen, um sie zu sehen.
|
||||
app.MapGet("/health", () => Results.Ok(new
|
||||
{
|
||||
status = "ok",
|
||||
version = Buildkennung.Version,
|
||||
build = Buildkennung.Build,
|
||||
})).AllowAnonymous();
|
||||
}
|
||||
|
||||
/// <summary>Wie viel ein Ordner belegt — rekursiv, ohne zu werfen.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// Ein unlesbarer Unterordner darf die Auskunft nicht verhindern; dann
|
||||
/// steht dort eben etwas zu wenig. Eine Zahl, die fast stimmt, ist besser
|
||||
/// als eine Fehlerseite.
|
||||
/// </remarks>
|
||||
private static long Belegt(string pfad)
|
||||
{
|
||||
if (!Directory.Exists(pfad)) return 0;
|
||||
try
|
||||
{
|
||||
return new DirectoryInfo(pfad)
|
||||
.EnumerateFiles("*", new EnumerationOptions
|
||||
{
|
||||
RecurseSubdirectories = true,
|
||||
IgnoreInaccessible = true,
|
||||
})
|
||||
.Sum(d => d.Length);
|
||||
}
|
||||
catch (Exception fehler) when (fehler is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Wie viel auf dem Volume noch frei ist — <c>null</c>, wenn unbekannt.</summary>
|
||||
private static long? Frei(string pfad)
|
||||
{
|
||||
try
|
||||
{
|
||||
return new DriveInfo(Path.GetPathRoot(Path.GetFullPath(pfad)) ?? "/").AvailableFreeSpace;
|
||||
}
|
||||
catch (Exception fehler) when (fehler is ArgumentException or IOException
|
||||
or UnauthorizedAccessException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using Flaro.Data;
|
||||
|
||||
namespace Flaro.Api;
|
||||
|
||||
/// <summary>Macht aus einer Ausnahme einen Satz, den ein Mensch lesen kann.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Warum es das braucht.</b> In der Oberfläche stand tagelang „An error
|
||||
/// occurred while saving the entity changes. See the inner exception for
|
||||
/// details." — englisch, in einem deutschen Server, und mit dem Verweis auf
|
||||
/// eine innere Ausnahme, die niemand zu sehen bekam. Das ist keine Meldung,
|
||||
/// das ist eine Sackgasse: sie nennt weder die Ursache noch einen nächsten
|
||||
/// Schritt.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Zwei Texte, nicht einer.</b> <see cref="FuerMenschen"/> liefert, was in
|
||||
/// der Oberfläche steht: deutsch, ohne Fehlercode, mit einem Hinweis, was zu
|
||||
/// tun ist. <see cref="FuersProtokoll"/> liefert alles — Typ, Meldung, jede
|
||||
/// innere Ausnahme — und landet in der Tagesdatei. Wer den Fehler sucht,
|
||||
/// findet dort die Einzelheiten; wer den Server nur benutzt, wird nicht
|
||||
/// damit behelligt.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Erkannt wird am Text, nicht am Typ.</b> Ein Verweis auf
|
||||
/// <c>SqliteException</c> hiesse, den Datenbanktreiber in dieses Projekt zu
|
||||
/// ziehen, nur um eine Meldung zu formulieren. Die Zeichenkette
|
||||
/// „UNIQUE constraint failed: Tabelle.Spalte" ist seit Jahren stabil und
|
||||
/// kostet nichts.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static partial class Fehlertext
|
||||
{
|
||||
/// <summary>Was in der Oberfläche steht — deutsch, ohne Fehlercode.</summary>
|
||||
public static string FuerMenschen(Exception fehler)
|
||||
{
|
||||
var regel = VerletzteRegel(fehler);
|
||||
if (regel is not null) return regel;
|
||||
|
||||
// Fehler, die ihren eigenen Text schon für Menschen schreiben, dürfen
|
||||
// ihn behalten: eine eigene Ausnahme sagt mehr als jede Verallgemeinerung.
|
||||
if (fehler is BestandVerschwundenException or InvalidOperationException { Source: "Flaro.Data" })
|
||||
return fehler.Message;
|
||||
|
||||
return fehler switch
|
||||
{
|
||||
UnauthorizedAccessException =>
|
||||
"Auf einen Ordner oder eine Datei fehlt das Zugriffsrecht. "
|
||||
+ "Prüfe die Einhängung und die Benutzerkennung des Containers.",
|
||||
DirectoryNotFoundException or FileNotFoundException =>
|
||||
"Ein Ordner oder eine Datei war nicht mehr da. "
|
||||
+ "Ist das Laufwerk noch eingehängt?",
|
||||
IOException =>
|
||||
"Beim Lesen oder Schreiben ist etwas schiefgegangen. "
|
||||
+ "Oft ist das ein volles Volume oder ein schlafendes Laufwerk.",
|
||||
TaskCanceledException or OperationCanceledException =>
|
||||
"Der Vorgang wurde abgebrochen.",
|
||||
HttpRequestException =>
|
||||
"Die Metadatenquelle war nicht erreichbar. "
|
||||
+ "Beim nächsten Lauf wird es erneut versucht.",
|
||||
_ =>
|
||||
"Etwas ist schiefgegangen. Die Einzelheiten stehen im Protokoll "
|
||||
+ "von heute, unter „Protokolle“.",
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Alles, was es über den Fehler zu sagen gibt — für die Tagesdatei.</summary>
|
||||
public static string FuersProtokoll(Exception fehler)
|
||||
{
|
||||
var text = new StringBuilder();
|
||||
var tiefe = 0;
|
||||
|
||||
for (Exception? e = fehler; e is not null; e = e.InnerException, tiefe++)
|
||||
{
|
||||
if (tiefe > 0) text.Append(" ← ");
|
||||
text.Append(e.GetType().Name).Append(": ").Append(e.Message);
|
||||
}
|
||||
|
||||
return text.ToString();
|
||||
}
|
||||
|
||||
/// <summary>Welche Eindeutigkeitsregel gebrochen wurde — in Worten.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Die Tabellen- und Spaltennamen werden übersetzt, nicht
|
||||
/// durchgereicht.</b> „UNIQUE constraint failed: MediaFiles.LibraryId,
|
||||
/// MediaFiles.Path" ist für den, der es gebaut hat, eine vollständige
|
||||
/// Diagnose — und für alle anderen eine Zeichenkette. Was dem Benutzer
|
||||
/// nützt, ist der Satz dahinter: was kollidiert ist und was er dagegen
|
||||
/// tun kann.
|
||||
/// </remarks>
|
||||
private static string? VerletzteRegel(Exception fehler)
|
||||
{
|
||||
string? spalten = null;
|
||||
|
||||
for (Exception? e = fehler; e is not null && spalten is null; e = e.InnerException)
|
||||
{
|
||||
var treffer = Eindeutigkeit().Match(e.Message);
|
||||
if (treffer.Success) spalten = treffer.Groups[1].Value;
|
||||
}
|
||||
|
||||
if (spalten is null) return null;
|
||||
|
||||
// Normalisiert auf „Tabelle.Spalte, Spalte", damit die Zuordnung unten
|
||||
// nicht an einem Leerzeichen scheitert.
|
||||
var schluessel = spalten.Replace(" ", "");
|
||||
|
||||
if (schluessel.Contains("MediaFiles.Path"))
|
||||
{
|
||||
return "Zwei Einträge beanspruchen denselben Dateipfad. "
|
||||
+ "Das deutet auf einen Ordner hin, der doppelt eingehängt ist, "
|
||||
+ "oder auf eine Verknüpfung, die zurück in die Mediathek zeigt. "
|
||||
+ "Es wurde nichts verändert.";
|
||||
}
|
||||
|
||||
if (schluessel.Contains("Episodes.SeasonNumber") || schluessel.Contains("Episodes.EpisodeNumber"))
|
||||
{
|
||||
return "Zwei Dateien beanspruchen dieselbe Folge derselben Staffel. "
|
||||
+ "Sind es zwei Fassungen, gehören sie an dieselbe Folge — "
|
||||
+ "dann hilft „Zuordnung korrigieren“. Es wurde nichts verändert.";
|
||||
}
|
||||
|
||||
if (schluessel.Contains("TmdbId"))
|
||||
{
|
||||
return "Zwei Titel wurden derselben Vorlage zugeordnet. "
|
||||
+ "Unter „Doppelte zusammenführen“ lässt sich das auflösen. "
|
||||
+ "Es wurde nichts verändert.";
|
||||
}
|
||||
|
||||
if (schluessel.Contains("MediaStreams"))
|
||||
{
|
||||
return "Eine Datei meldet dieselbe Spur zweimal. "
|
||||
+ "Beim nächsten Lauf wird sie erneut untersucht.";
|
||||
}
|
||||
|
||||
return "Ein Eintrag gibt es bereits ein zweites Mal. "
|
||||
+ "Es wurde nichts verändert; die Einzelheiten stehen im Protokoll von heute.";
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"UNIQUE constraint failed:\s*([^'""\r\n]+)", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex Eindeutigkeit();
|
||||
}
|
||||
@@ -118,8 +118,10 @@ public sealed class PackageCoordinator(
|
||||
catch (Exception fehler)
|
||||
{
|
||||
log.LogError(fehler, "Das Umpacken von {Kennung} ist gescheitert.", fileId);
|
||||
// Der rohe Ausnahmetext bleibt im Protokoll; angezeigt wird ein Satz.
|
||||
_zustand[fileId] = new PackageState(
|
||||
"failed", 0, _zustand[fileId].StartedAt, clock.GetUtcNow(), fehler.Message);
|
||||
"failed", 0, _zustand[fileId].StartedAt, clock.GetUtcNow(),
|
||||
Fehlertext.FuerMenschen(fehler));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -234,6 +234,22 @@ if (Directory.Exists(oberfläche))
|
||||
}).AllowAnonymous();
|
||||
}
|
||||
|
||||
// **Start und Ende gehören ins Protokoll.**
|
||||
//
|
||||
// Ohne das war nicht zu unterscheiden, ob der Server während eines langen
|
||||
// Scans nur beschäftigt oder zwischendurch neu gestartet war — und genau das
|
||||
// macht den Unterschied zwischen „dauert eben" und „stürzt ab". Steht mitten
|
||||
// im Scan ein Start, weiss man es beim nächsten Blick ins Protokoll.
|
||||
{
|
||||
var tagebuch = app.Services.GetRequiredService<Tagebuch>();
|
||||
var leben = app.Services.GetRequiredService<IHostApplicationLifetime>();
|
||||
|
||||
leben.ApplicationStarted.Register(() =>
|
||||
tagebuch.Schreiben($"Server gestartet — {Buildkennung.Beides}"));
|
||||
leben.ApplicationStopping.Register(() =>
|
||||
tagebuch.Schreiben("Server fährt herunter."));
|
||||
}
|
||||
|
||||
app.Run();
|
||||
|
||||
/// <summary>Damit die Tests den Server hochfahren können.</summary>
|
||||
|
||||
@@ -85,10 +85,17 @@ public sealed class ScanCoordinator(
|
||||
var tagebuch = bereich.ServiceProvider.GetRequiredService<Tagebuch>();
|
||||
var melder = new Progress<Scanschritt>(schritt =>
|
||||
{
|
||||
tagebuch.Schreiben(schritt.Text());
|
||||
|
||||
// **Vermerke stehen nur in der Datei.** Sie erklären, warum
|
||||
// etwas so zugeordnet wurde — in der Fortschrittszeile wäre das
|
||||
// Flackern, in der Tagesdatei ist es die Antwort auf „wieso 737
|
||||
// Serien?".
|
||||
if (schritt.Phase == Scanphase.Vermerk) return;
|
||||
|
||||
_zustand.AddOrUpdate(libraryId,
|
||||
_ => new ScanState("running", clock.GetUtcNow(), null, null, null, null, schritt),
|
||||
(_, alt) => alt with { Schritt = schritt });
|
||||
tagebuch.Schreiben(schritt.Text());
|
||||
});
|
||||
|
||||
var scanner = bereich.ServiceProvider.GetRequiredService<LibraryScanner>();
|
||||
@@ -127,17 +134,21 @@ public sealed class ScanCoordinator(
|
||||
}
|
||||
catch (Exception fehler)
|
||||
{
|
||||
// **Zwei Texte, und zwar absichtlich.** Ins Protokoll geht alles,
|
||||
// bis zur innersten Ausnahme — dort wird gesucht. In die
|
||||
// Oberfläche geht ein deutscher Satz ohne Fehlercode; dort wird
|
||||
// nicht gesucht, dort wird gelesen.
|
||||
log.LogError(fehler, "Scan der Bibliothek {Kennung} ist gescheitert.", libraryId);
|
||||
using (var bereich = scopes.CreateScope())
|
||||
{
|
||||
bereich.ServiceProvider.GetRequiredService<Tagebuch>()
|
||||
.Schreiben($"Scan gescheitert: {fehler.Message}", Tagebuch.Art.Fehler);
|
||||
bereich.ServiceProvider.GetRequiredService<Tagebuch>().Schreiben(
|
||||
$"Scan gescheitert: {Fehlertext.FuersProtokoll(fehler)}", Tagebuch.Art.Fehler);
|
||||
}
|
||||
_zustand[libraryId] = _zustand[libraryId] with
|
||||
{
|
||||
Status = "failed",
|
||||
FinishedAt = clock.GetUtcNow(),
|
||||
Error = fehler.Message,
|
||||
Error = Fehlertext.FuerMenschen(fehler),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,6 +69,65 @@ public sealed class Tagebuch
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>Die letzten Zeilen eines Tages — ohne die ganze Datei zu lesen.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Weil das Ende interessiert, nicht der Anfang.</b> Wer ins Protokoll
|
||||
/// sieht, sucht fast immer das Letzte. Die Oberfläche holte bisher die
|
||||
/// ganze Datei und warf davon 95 % weg — bei 2 MB über eine wackelige
|
||||
/// Verbindung endete das in „Failed to fetch", und nach einem langen Scan
|
||||
/// wären es Dutzende Megabyte.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Rückwärts in Blöcken gelesen, nicht Zeile für Zeile von vorn: sonst
|
||||
/// kostet die Ansicht dasselbe wie das Herunterladen, nur ohne den Nutzen.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public string Ende(DateOnly tag, int zeilen)
|
||||
{
|
||||
var datei = DateiFuer(tag);
|
||||
if (!File.Exists(datei)) return "";
|
||||
|
||||
const int block = 64 * 1024;
|
||||
|
||||
try
|
||||
{
|
||||
using var strom = new FileStream(
|
||||
datei, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
|
||||
|
||||
var gefunden = 0;
|
||||
var position = strom.Length;
|
||||
var puffer = new byte[block];
|
||||
|
||||
while (position > 0 && gefunden <= zeilen)
|
||||
{
|
||||
var lesen = (int)Math.Min(block, position);
|
||||
position -= lesen;
|
||||
strom.Position = position;
|
||||
strom.ReadExactly(puffer, 0, lesen);
|
||||
|
||||
for (var i = lesen - 1; i >= 0 && gefunden <= zeilen; i--)
|
||||
{
|
||||
if (puffer[i] != (byte)'\n') continue;
|
||||
gefunden++;
|
||||
// Eine Zeile zu viel zählen und danach die angebrochene
|
||||
// erste wegwerfen — sonst beginnt die Ausgabe mitten im
|
||||
// Satz.
|
||||
if (gefunden > zeilen) position += i + 1;
|
||||
}
|
||||
}
|
||||
|
||||
strom.Position = position;
|
||||
using var leser = new StreamReader(strom, Encoding.UTF8);
|
||||
return leser.ReadToEnd();
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Hängt eine Zeile an das Protokoll des heutigen Tages.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
|
||||
@@ -5,7 +5,13 @@ namespace Flaro.Core.Library;
|
||||
|
||||
public sealed record MovieName(string Title, int? Year);
|
||||
|
||||
public sealed record EpisodeName(string SeriesTitle, int Season, int Episode, string? EpisodeTitle);
|
||||
/// <param name="AusOrdner">
|
||||
/// <b>Ob der Serientitel vom Ordner kommt oder aus dem Dateinamen.</b> Steht
|
||||
/// nur im Protokoll — aber genau diese Angabe fehlte, als aus 95 Serien 737
|
||||
/// wurden: niemand konnte sagen, welcher der beiden Wege sie angelegt hatte.
|
||||
/// </param>
|
||||
public sealed record EpisodeName(
|
||||
string SeriesTitle, int Season, int Episode, string? EpisodeTitle, bool AusOrdner = false);
|
||||
|
||||
/// <summary>Macht aus einem Dateinamen wieder Titel, Jahr, Staffel und Folge.</summary>
|
||||
///
|
||||
@@ -236,11 +242,29 @@ public static partial class FileNames
|
||||
? SerieAusOrdner(dateiname, wurzel)
|
||||
: serie.Length > 0 ? null : SerieAusOrdner(dateiname, null);
|
||||
|
||||
// **Der Rohname ist keine Serie.**
|
||||
//
|
||||
// Hier stand als letzter Rückfall `Rohname(dateiname)`. Das war der
|
||||
// teuerste Fehler des Projekts: liegt eine Datei direkt in der Wurzel
|
||||
// und trägt vor der Folgennummer nichts Brauchbares, wurde der
|
||||
// komplette Dateiname — samt Folgentitel — zum Serientitel. Aus 95
|
||||
// Serien wurden 737, jede mit genau einer Folge, und jede frisch bei
|
||||
// jedem Lauf.
|
||||
//
|
||||
// Jetzt gilt: entweder der Ordner weiss es, oder der Teil vor der
|
||||
// Folgennummer weiss es — oder niemand weiss es, und dann wird nichts
|
||||
// erfunden. Was hier `null` ergibt, zählt im Scan als unerkannt und
|
||||
// erscheint unter „Nicht zugeordnet"; eine Geisterserie mit einer
|
||||
// Folge ist schlechter als ein ehrliches „weiss ich nicht".
|
||||
var serientitel = ausOrdner ?? (serie.Length > 0 ? serie : null);
|
||||
if (serientitel is null) return null;
|
||||
|
||||
return new EpisodeName(
|
||||
SeriesTitle: ausOrdner ?? (serie.Length > 0 ? serie : Rohname(dateiname)),
|
||||
SeriesTitle: serientitel,
|
||||
Season: staffel,
|
||||
Episode: folge,
|
||||
EpisodeTitle: folgenTitel.Length > 0 ? folgenTitel : null);
|
||||
EpisodeTitle: folgenTitel.Length > 0 ? folgenTitel : null,
|
||||
AusOrdner: ausOrdner is not null);
|
||||
}
|
||||
|
||||
/// <summary>Der Serienname aus dem Pfad.</summary>
|
||||
|
||||
@@ -46,11 +46,44 @@ public static class LibraryWalker
|
||||
return new WalkResult(gefunden, probleme);
|
||||
}
|
||||
|
||||
Absteigen(wurzel, 0, gefunden, probleme);
|
||||
// **Jedes echte Verzeichnis genau einmal.** Siehe <see cref="Echtpfad"/>.
|
||||
var besucht = new HashSet<string>(StringComparer.Ordinal) { Echtpfad(wurzel) };
|
||||
|
||||
Absteigen(wurzel, 0, gefunden, probleme, besucht);
|
||||
return new WalkResult(gefunden, probleme);
|
||||
}
|
||||
|
||||
private static void Absteigen(string ordner, int tiefe, List<ScannedFile> gefunden, List<WalkProblem> probleme)
|
||||
/// <summary>Wohin ein Verzeichnis wirklich zeigt.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Ohne das wird aus einem Ordner zwei.</b> Ein Verweis — ein Symlink,
|
||||
/// ein zweiter Bind Mount derselben Freigabe — macht denselben Inhalt unter
|
||||
/// einem zweiten Namen erreichbar. Der Scan lief beide Wege ab und legte
|
||||
/// „Pingu" und „Pingu-nochmal" als zwei Serien an; dieselbe Datei bekam
|
||||
/// zwei Pfade und damit zwei Zeilen, was am Eindeutigkeitsindex den ganzen
|
||||
/// Speichervorgang zerriss.
|
||||
///
|
||||
/// Lässt sich das Ziel nicht auflösen, gilt der Pfad selbst. Lieber einmal
|
||||
/// zu viel absteigen als wegen eines Rechtefehlers einen echten Ordner
|
||||
/// auslassen.
|
||||
/// </remarks>
|
||||
private static string Echtpfad(string ordner)
|
||||
{
|
||||
try
|
||||
{
|
||||
var ziel = Directory.ResolveLinkTarget(ordner, returnFinalTarget: true);
|
||||
return Path.GetFullPath(ziel?.FullName ?? ordner).TrimEnd(Path.DirectorySeparatorChar);
|
||||
}
|
||||
catch (Exception fehler) when (fehler is IOException or UnauthorizedAccessException
|
||||
or ArgumentException or NotSupportedException)
|
||||
{
|
||||
return Path.GetFullPath(ordner).TrimEnd(Path.DirectorySeparatorChar);
|
||||
}
|
||||
}
|
||||
|
||||
private static void Absteigen(
|
||||
string ordner, int tiefe, List<ScannedFile> gefunden, List<WalkProblem> probleme,
|
||||
HashSet<string> besucht)
|
||||
{
|
||||
if (tiefe > MaximaleTiefe)
|
||||
{
|
||||
@@ -87,7 +120,15 @@ public static class LibraryWalker
|
||||
foreach (var unter in unterordner)
|
||||
{
|
||||
if (MediaExtensions.IsSkippedDirectory(Path.GetFileName(unter))) continue;
|
||||
Absteigen(unter, tiefe + 1, gefunden, probleme);
|
||||
|
||||
if (!besucht.Add(Echtpfad(unter)))
|
||||
{
|
||||
probleme.Add(new WalkProblem(
|
||||
unter, "Zeigt auf einen Ordner, der in diesem Lauf schon erfasst wurde."));
|
||||
continue;
|
||||
}
|
||||
|
||||
Absteigen(unter, tiefe + 1, gefunden, probleme, besucht);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,16 @@ public enum Scanphase
|
||||
|
||||
/// <summary>Verwaistes wird entfernt, Doppeltes zusammengelegt.</summary>
|
||||
Aufraeumen,
|
||||
|
||||
/// <summary>Eine Notiz fürs Protokoll — nicht für die Fortschrittszeile.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Warum getrennt.</b> „Serie ‚Pingu' neu angelegt, aus dem Ordner" ist
|
||||
/// beim Suchen eines Fehlers Gold wert und in einer Zeile, die im
|
||||
/// Sekundentakt wechselt, nur Flackern. Die Oberfläche überspringt diese
|
||||
/// Schritte, die Tagesdatei nimmt sie auf.
|
||||
/// </remarks>
|
||||
Vermerk,
|
||||
}
|
||||
|
||||
/// <summary>Ein Zwischenstand: was gerade passiert, und wie weit.</summary>
|
||||
@@ -60,6 +70,7 @@ public readonly record struct Scanschritt(Scanphase Phase, string Was, int Erled
|
||||
Scanphase.Holen => $"Hole Metadaten zu {Was}{zaehler}",
|
||||
Scanphase.Bilder => $"Lade Bilder zu {Was}{zaehler}",
|
||||
Scanphase.Aufraeumen => "Räume auf und lege Doppeltes zusammen",
|
||||
Scanphase.Vermerk => Was,
|
||||
_ => Was,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -122,9 +122,28 @@ public sealed class LibraryScanner(
|
||||
|
||||
var durch = 0;
|
||||
|
||||
// **Was dieser Lauf schon in der Hand hatte.**
|
||||
//
|
||||
// Ein Pfad darf im selben Lauf nur einmal vorkommen. Kommt er zweimal
|
||||
// — ein Ordner, der doppelt eingehängt ist, oder eine Verknüpfung, die
|
||||
// in die Mediathek zurückzeigt —, entstand bisher eine zweite Zeile
|
||||
// mit demselben `(LibraryId, Path)`. Der Eindeutigkeitsindex liess
|
||||
// daraufhin den **ganzen** Speichervorgang scheitern: der Scan brach
|
||||
// ab, die Zähler blieben stehen, und in der Oberfläche stand eine
|
||||
// englische Meldung über „entity changes".
|
||||
var gesehen = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var datei in ergebnis.Files)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
if (!gesehen.Add(datei.Path))
|
||||
{
|
||||
log.LogInformation(
|
||||
"Scan: {Pfad} kam ein zweites Mal vorbei und wurde übersprungen.", datei.Path);
|
||||
continue;
|
||||
}
|
||||
|
||||
fortschritt?.Report(new Scanschritt(
|
||||
Scanphase.Pruefen, datei.Path, ++durch, ergebnis.Files.Count));
|
||||
|
||||
@@ -147,10 +166,27 @@ public sealed class LibraryScanner(
|
||||
// einer Serie, zu der die Datei nicht gehört, und dort
|
||||
// fände sie niemand je wieder.
|
||||
unerkannt++;
|
||||
log.LogInformation("Scan: {Pfad} trägt keine Folgennummer und bleibt liegen.", datei.Path);
|
||||
log.LogInformation("Scan: {Pfad} liess sich keiner Serie zuordnen.", datei.Path);
|
||||
fortschritt?.Report(new Scanschritt(Scanphase.Vermerk,
|
||||
$"Nicht zugeordnet: {datei.Path} — weder ein Ordner noch der "
|
||||
+ "Dateiname nennen eine Serie."));
|
||||
continue;
|
||||
}
|
||||
var vorher = serien.Count;
|
||||
var serie = SerieFinden(serien, libraryId, name.SeriesTitle);
|
||||
|
||||
// **Jede neue Serie wird begründet.** Ohne diese Zeile liess
|
||||
// sich nicht feststellen, welcher der beiden Wege — Ordner oder
|
||||
// Dateiname — die Geisterserien angelegt hatte, und jede
|
||||
// Fehlersuche blieb Vermutung.
|
||||
if (serien.Count > vorher)
|
||||
{
|
||||
fortschritt?.Report(new Scanschritt(Scanphase.Vermerk,
|
||||
$"Serie „{serie.Title}“ neu angelegt — "
|
||||
+ (name.AusOrdner ? "aus dem Ordner" : "aus dem Dateinamen")
|
||||
+ $", wegen {datei.Path}"));
|
||||
}
|
||||
|
||||
folgeId = FolgeFinden(folgen, serie.Id, name).Id;
|
||||
}
|
||||
|
||||
@@ -189,6 +225,11 @@ public sealed class LibraryScanner(
|
||||
bekannteDateien.Remove(umgezogen.Path);
|
||||
|
||||
umgezogen.Path = datei.Path;
|
||||
// **Unter dem neuen Pfad wieder eintragen.** Der alte Schlüssel
|
||||
// ist weg, der neue fehlte — und damit war der Eintrag für den
|
||||
// Rest des Laufs unauffindbar. Trifft derselbe Pfad später noch
|
||||
// einmal ein, entstünde eine zweite Zeile dafür.
|
||||
bekannteDateien[datei.Path] = umgezogen;
|
||||
umgezogen.MovieId = filmId;
|
||||
umgezogen.EpisodeId = folgeId;
|
||||
umgezogen.SeenAt = laufZeit;
|
||||
@@ -215,6 +256,10 @@ public sealed class LibraryScanner(
|
||||
VersionLabel = FileNames.Fassung(datei.Path),
|
||||
};
|
||||
db.MediaFiles.Add(eintrag);
|
||||
// **Auch Neuzugänge gehören in die Liste.** Sie standen dort
|
||||
// nie — ein Pfad, der im selben Lauf ein zweites Mal auftauchte,
|
||||
// wurde deshalb ein zweites Mal angelegt.
|
||||
bekannteDateien[datei.Path] = eintrag;
|
||||
neu++;
|
||||
mussUntersucht = true;
|
||||
}
|
||||
|
||||
@@ -116,3 +116,67 @@ public class FlaroPathsTests
|
||||
// Zurückgesetzt wird im TearDown, für alle drei Variablen gemeinsam.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Das Ende eines Protokolls lesen, ohne die ganze Datei zu holen.</summary>
|
||||
public class TagebuchEndeTests
|
||||
{
|
||||
private string _wurzel = "";
|
||||
private Tagebuch _tagebuch = null!;
|
||||
|
||||
[SetUp]
|
||||
public void Anlegen()
|
||||
{
|
||||
_wurzel = Directory.CreateDirectory(
|
||||
Path.Combine(Path.GetTempPath(), "flaro-log-" + Guid.CreateVersion7().ToString("N"))).FullName;
|
||||
|
||||
_tagebuch = new Tagebuch(
|
||||
new FlaroPaths { Data = _wurzel, Cache = _wurzel, Transcode = _wurzel },
|
||||
TimeProvider.System);
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void Aufraeumen()
|
||||
{
|
||||
try { Directory.Delete(_wurzel, recursive: true); } catch (IOException) { }
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Liefert_die_letzten_Zeilen()
|
||||
{
|
||||
var heute = DateOnly.FromDateTime(DateTime.Now);
|
||||
for (var i = 1; i <= 5000; i++) _tagebuch.Schreiben($"Zeile {i}");
|
||||
|
||||
var ende = _tagebuch.Ende(heute, 10).TrimEnd('\n').Split('\n');
|
||||
|
||||
Assert.That(ende, Has.Length.EqualTo(10));
|
||||
Assert.That(ende[^1], Does.Contain("Zeile 5000"));
|
||||
Assert.That(ende[0], Does.Contain("Zeile 4991"));
|
||||
}
|
||||
|
||||
/// <summary>Die Ausgabe beginnt nie mitten in einer Zeile.</summary>
|
||||
[Test]
|
||||
public void Faengt_an_einem_Zeilenanfang_an()
|
||||
{
|
||||
var heute = DateOnly.FromDateTime(DateTime.Now);
|
||||
for (var i = 1; i <= 3000; i++) _tagebuch.Schreiben(new string('x', 300) + $" {i}");
|
||||
|
||||
var ende = _tagebuch.Ende(heute, 5).Split('\n')[0];
|
||||
|
||||
Assert.That(ende, Does.Match(@"^\d{2}:\d{2}:\d{2} "),
|
||||
"Sonst beginnt die Anzeige mitten im Satz.");
|
||||
}
|
||||
|
||||
/// <summary>Weniger Zeilen als verlangt ist kein Fehler.</summary>
|
||||
[Test]
|
||||
public void Eine_kurze_Datei_kommt_ganz()
|
||||
{
|
||||
var heute = DateOnly.FromDateTime(DateTime.Now);
|
||||
_tagebuch.Schreiben("Eine einzige Zeile");
|
||||
|
||||
Assert.That(_tagebuch.Ende(heute, 800), Does.Contain("Eine einzige Zeile"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Ein_Tag_ohne_Protokoll_ist_leer() =>
|
||||
Assert.That(_tagebuch.Ende(new DateOnly(2000, 1, 1), 10), Is.Empty);
|
||||
}
|
||||
|
||||
@@ -305,31 +305,30 @@ public class ScanTests
|
||||
/// <summary>Eine Serie, die ihren Namen verliert, bleibt nicht als Leiche stehen.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Der Weg, den ein vorhandener Bestand nach dem Update geht.</b> Vorher
|
||||
/// bestimmte der Dateiname die Serie; eine Datei direkt in der Wurzel
|
||||
/// ergibt das bis heute. Wandert sie in einen Ordner, gehört sie zu diesem
|
||||
/// — und der alte Eintrag muss verschwinden, statt als leere Serie in der
|
||||
/// Mediathek zu bleiben.
|
||||
/// <b>Der Weg, den ein vorhandener Bestand nach dem Update geht.</b> Ohne
|
||||
/// Ordner benennt der Teil vor der Folgennummer die Serie. Wandert die
|
||||
/// Datei in einen Ordner, gehört sie zu diesem — und der alte Eintrag muss
|
||||
/// verschwinden, statt als leere Serie in der Mediathek zu bleiben.
|
||||
/// </remarks>
|
||||
[Test]
|
||||
public async Task Eine_verwaiste_Serie_verschwindet_beim_naechsten_Lauf()
|
||||
{
|
||||
Datei("S01E01 Kapitel 1.mkv");
|
||||
Datei("Tatortreiniger S01E01 Kapitel 1.mkv");
|
||||
|
||||
using var server = new FlaroServer();
|
||||
var (client, bibliothek) = await MitBibliothek(server, "Shows");
|
||||
await Scannen(client, bibliothek);
|
||||
|
||||
var vorher = await client.GetFromJsonAsync<Page<SeriesResponse>>("/api/v1/series");
|
||||
Assert.That(vorher!.Total, Is.EqualTo(1), "Ohne Ordner bleibt nur der Dateiname.");
|
||||
Assert.That(vorher!.Total, Is.EqualTo(1), "Ohne Ordner zählt der Titel vor der Folgennummer.");
|
||||
var alterName = vorher.Items[0].Title;
|
||||
|
||||
// **Verschieben, nicht löschen und neu anlegen.** Beim Verschieben
|
||||
// bleibt der Zeitstempel erhalten — und genau daran erkennt der Scan,
|
||||
// dass es dieselbe Datei ist. Ein Test, der sie neu anlegt, prüft
|
||||
// etwas anderes als das, was ein Mensch tut.
|
||||
var alt = Path.Combine(_bestand, "S01E01 Kapitel 1.mkv");
|
||||
var neuerPfad = Path.Combine(_bestand, "Nachtstreife", "S01E01 Kapitel 1.mkv");
|
||||
var alt = Path.Combine(_bestand, "Tatortreiniger S01E01 Kapitel 1.mkv");
|
||||
var neuerPfad = Path.Combine(_bestand, "Nachtstreife", "Tatortreiniger S01E01 Kapitel 1.mkv");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(neuerPfad)!);
|
||||
File.Move(alt, neuerPfad);
|
||||
|
||||
@@ -342,6 +341,85 @@ public class ScanTests
|
||||
Assert.That(nachher.Items[0].EpisodeCount, Is.EqualTo(1));
|
||||
}
|
||||
|
||||
/// <summary>Aus einem nackten Dateinamen wird keine Serie erfunden.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Der teuerste Fehler des Projekts.</b> Lag eine Datei direkt in der
|
||||
/// Wurzel und trug vor der Folgennummer nichts, wurde der ganze Dateiname
|
||||
/// zum Serientitel — samt Folgentitel. Aus 95 Serien wurden 737, jede mit
|
||||
/// genau einer Folge, und das bei jedem Lauf aufs Neue. Lieber nichts
|
||||
/// behaupten als eine Geisterserie anlegen.
|
||||
/// </remarks>
|
||||
[Test]
|
||||
public async Task Ein_nackter_Dateiname_erfindet_keine_Serie()
|
||||
{
|
||||
Datei("S01E01.mkv");
|
||||
Datei("S01E02.mkv");
|
||||
Datei("S02E01.mkv");
|
||||
|
||||
using var server = new FlaroServer();
|
||||
var (client, bibliothek) = await MitBibliothek(server, "Shows");
|
||||
await Scannen(client, bibliothek);
|
||||
|
||||
var serien = await client.GetFromJsonAsync<Page<SeriesResponse>>("/api/v1/series");
|
||||
Assert.That(serien!.Total, Is.EqualTo(0),
|
||||
"Drei namenlose Dateien ergaben vorher drei Serien.");
|
||||
}
|
||||
|
||||
/// <summary>Lose Dateien derselben Serie landen unter einer Serie.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// Ohne Ordner trägt der Teil vor der Folgennummer den Serientitel. Dass
|
||||
/// dahinter bei jeder Datei ein anderer Folgentitel steht, darf daran
|
||||
/// nichts ändern — genau daraus entstanden die Dutzende Einträge
|
||||
/// „In höchster Not · …", jeder mit einer Folge.
|
||||
/// </remarks>
|
||||
[Test]
|
||||
public async Task Lose_Dateien_mit_Folgentiteln_ergeben_eine_Serie()
|
||||
{
|
||||
Datei("In höchster Not - S01E01 - Bergrettung am Grat.mkv");
|
||||
Datei("In höchster Not - S01E02 - Lawine im Tal.mkv");
|
||||
Datei("In höchster Not - S01E03 - Sturz in die Klamm.mkv");
|
||||
|
||||
using var server = new FlaroServer();
|
||||
var (client, bibliothek) = await MitBibliothek(server, "Shows");
|
||||
await Scannen(client, bibliothek);
|
||||
|
||||
var serien = await client.GetFromJsonAsync<Page<SeriesResponse>>("/api/v1/series");
|
||||
Assert.That(serien!.Total, Is.EqualTo(1));
|
||||
Assert.That(serien.Items[0].EpisodeCount, Is.EqualTo(3));
|
||||
}
|
||||
|
||||
/// <summary>Derselbe Pfad zweimal bringt den ganzen Lauf nicht mehr um.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// Ein doppelt eingehängter Ordner oder eine Verknüpfung zurück in die
|
||||
/// Mediathek liefert denselben Pfad ein zweites Mal. Vorher entstand dabei
|
||||
/// eine zweite Zeile mit gleichem <c>(LibraryId, Path)</c>, der
|
||||
/// Eindeutigkeitsindex brach, und <b>der ganze Scan</b> endete mit
|
||||
/// „An error occurred while saving the entity changes".
|
||||
/// </remarks>
|
||||
[Test]
|
||||
public async Task Ein_doppelter_Pfad_bricht_den_Scan_nicht_ab()
|
||||
{
|
||||
Datei("Pingu/Pingu - S01E01.mkv");
|
||||
|
||||
// Eine Verknüpfung auf den Serienordner — derselbe Inhalt, zweiter Weg.
|
||||
Directory.CreateSymbolicLink(
|
||||
Path.Combine(_bestand, "Pingu-nochmal"), Path.Combine(_bestand, "Pingu"));
|
||||
|
||||
using var server = new FlaroServer();
|
||||
var (client, bibliothek) = await MitBibliothek(server, "Shows");
|
||||
await Scannen(client, bibliothek);
|
||||
|
||||
var zustand = await client.GetFromJsonAsync<ScanStatus>(
|
||||
$"/api/v1/libraries/{bibliothek}/scan");
|
||||
Assert.That(zustand!.State, Is.Not.EqualTo("failed"), zustand.Error ?? "");
|
||||
|
||||
var serien = await client.GetFromJsonAsync<Page<SeriesResponse>>("/api/v1/series");
|
||||
Assert.That(serien!.Total, Is.EqualTo(1), "Zwei Wege, eine Serie.");
|
||||
}
|
||||
|
||||
/// <summary>4K und 1080p sind ein Film mit zwei Dateien, nicht zwei Filme.</summary>
|
||||
[Test]
|
||||
public async Task Zwei_Fassungen_desselben_Films_bleiben_ein_Eintrag()
|
||||
|
||||
+55
-4
@@ -113,7 +113,10 @@ export type Vorschlag = {
|
||||
};
|
||||
export type Hinweis = { text: string; logoUrl: string; url: string };
|
||||
export type Systemauskunft = {
|
||||
version: string; serverName: string | null;
|
||||
version: string;
|
||||
/** Die Kennung dieses Abbilds — bei jedem Bau neu, „DEV" ausserhalb eines Containers. */
|
||||
build: string;
|
||||
serverName: string | null;
|
||||
needsSetup: boolean; attributions: Hinweis[];
|
||||
};
|
||||
|
||||
@@ -169,6 +172,12 @@ export const meineSprache = (): string | null => sitzung.wer?.language ?? null;
|
||||
/* ---------- Anfragen ---------- */
|
||||
|
||||
export class ApiFehler extends Error {
|
||||
// **Der Name wird gesetzt, nicht geerbt.** Nach dem Bündeln heisst die
|
||||
// Klasse anders, und `instanceof` über Modulgrenzen ist nicht verlässlich;
|
||||
// `name` überlebt beides. Daran erkennt `meldung()`, dass der Text vom
|
||||
// Server stammt und bereits deutsch ist.
|
||||
override readonly name = 'ApiFehler';
|
||||
|
||||
constructor(public status: number, nachricht: string) { super(nachricht); }
|
||||
}
|
||||
|
||||
@@ -182,7 +191,24 @@ async function anfrage<T>(pfad: string, opts: RequestInit = {}): Promise<T> {
|
||||
kopf.set('Content-Type', 'application/json');
|
||||
}
|
||||
|
||||
const antwort = await fetch(`/api/v1${pfad}`, { ...opts, headers: kopf });
|
||||
// **Einmal nachfassen, bevor gemeldet wird.**
|
||||
//
|
||||
// Während eines Scans über tausende Dateien kam es vor, dass eine Anfrage
|
||||
// ins Leere lief und der Bilddialog mit „Failed to fetch" stehenblieb —
|
||||
// beim zweiten Versuch von Hand ging es sofort. Ein einzelner Aussetzer ist
|
||||
// kein Fehler, den der Benutzer sehen muss.
|
||||
//
|
||||
// Nur bei einem Netzfehler und nur bei lesenden Anfragen: ein zweites POST
|
||||
// könnte dasselbe zweimal anlegen, und das wäre schlimmer als die Meldung.
|
||||
const lesend = !opts.method || opts.method.toUpperCase() === 'GET';
|
||||
let antwort: Response;
|
||||
try {
|
||||
antwort = await fetch(`/api/v1${pfad}`, { ...opts, headers: kopf });
|
||||
} catch (aussetzer) {
|
||||
if (!lesend || !(aussetzer instanceof TypeError)) throw aussetzer;
|
||||
await new Promise(r => setTimeout(r, 400));
|
||||
antwort = await fetch(`/api/v1${pfad}`, { ...opts, headers: kopf });
|
||||
}
|
||||
|
||||
// **Eine 401 ist noch kein Grund, die Anmeldung wegzuwerfen.**
|
||||
//
|
||||
@@ -580,17 +606,42 @@ export const zurueckziehen = (ids: string[]) =>
|
||||
export const reihenfolgeSetzen = (ids: string[]) =>
|
||||
anfrage<void>('/libraries/order', { method: 'PUT', body: JSON.stringify({ ids }) });
|
||||
|
||||
/** Wirft den erfassten Inhalt einer Mediathek weg — die Dateien bleiben liegen.
|
||||
*
|
||||
* **Wofür.** Steht nach einer falschen Zuordnungsregel ein Bestand da, den kein
|
||||
* Scan mehr geradezieht, ist das der kurze Weg: die Einträge weg, einmal neu
|
||||
* scannen. Mediathek, Pfad und Einstellungen bleiben.
|
||||
*/
|
||||
export const mediathekZuruecksetzen = (bibliothek: string) =>
|
||||
anfrage<void>(`/libraries/${bibliothek}/reset`, { method: 'POST' });
|
||||
|
||||
export const pruefungSetzen = (bibliothek: string, an: boolean) =>
|
||||
anfrage<void>(`/libraries/${bibliothek}/review`,
|
||||
{ method: 'PUT', body: JSON.stringify({ reviewNewItems: an }) });
|
||||
|
||||
export type Speicherort = {
|
||||
name: string; variable: string; pfad: string;
|
||||
belegt: number; frei: number | null; bilder: number | null;
|
||||
};
|
||||
|
||||
/** Wo der Server seine Dateien ablegt und wie viel davon belegt ist. */
|
||||
export const speicherorte = () => anfrage<Speicherort[]>('/system/storage');
|
||||
|
||||
/** Leert den Bildspeicher. Ungefährlich — Bilder werden bei Bedarf neu geholt. */
|
||||
export const bildspeicherLeeren = () =>
|
||||
anfrage<{ freed: number }>('/system/storage/images', { method: 'DELETE' });
|
||||
|
||||
export type Protokolltag = { date: string; bytes: number };
|
||||
|
||||
/** Welche Tage protokolliert sind, neueste zuerst. */
|
||||
export const protokolltage = () => anfrage<Protokolltag[]>('/system/logs');
|
||||
|
||||
/** Die Adresse, unter der ein Protokolltag liegt — zum Lesen und Herunterladen. */
|
||||
export const protokollAdresse = (tag: string) => `/api/v1/system/logs/${tag}`;
|
||||
/** Die Adresse eines Protokolltags.
|
||||
*
|
||||
* Mit `zeilen` kommt nur das Ende — das ist, was die Ansicht braucht. Ohne
|
||||
* kommt alles, und das ist der Weg zum Herunterladen. */
|
||||
export const protokollAdresse = (tag: string, zeilen?: number) =>
|
||||
`/api/v1/system/logs/${tag}` + (zeilen ? `?tail=${zeilen}` : '');
|
||||
|
||||
export type VermissteDatei = {
|
||||
fileId: string; path: string; versionLabel: string | null;
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
import { icons } from './icons';
|
||||
|
||||
export type Option = { wert: string; text: string };
|
||||
|
||||
/** Ein Auswahlfeld, das aussieht wie der Rest der Oberfläche.
|
||||
*
|
||||
* **Warum nicht `<select>`.** Dessen Liste zeichnet das Betriebssystem, und
|
||||
* zwar in seinem eigenen Stil: auf einem Mac erscheint mitten in einer
|
||||
* dunklen Oberfläche ein helles Systemmenü mit abgerundeten Ecken und
|
||||
* blauer Markierung. Es lässt sich nicht gestalten — kein Rahmen, keine
|
||||
* Farbe, keine Radien. In einer Oberfläche, die sonst durchgehend eine
|
||||
* Formensprache spricht, ist es der eine Fleck, der verrät, dass hier nichts
|
||||
* aufeinander abgestimmt wurde.
|
||||
*
|
||||
* **Was dafür selbst gebaut werden muss.** Ein `<select>` bringt viel mit,
|
||||
* das man leicht vergisst: Tastaturbedienung, Rollen für Hilfsmittel, das
|
||||
* Schliessen beim Klick daneben. Steht alles unten — ohne das wäre der
|
||||
* Tausch eine Verschlechterung, nur eine hübschere.
|
||||
*/
|
||||
export type Auswahlfeld = {
|
||||
/** Was in die Seite gehängt wird. */
|
||||
element: HTMLButtonElement;
|
||||
/** Der gewählte Wert — wie bei `select.value`. */
|
||||
wert: string;
|
||||
/** Wird bei jeder Änderung durch den Benutzer gerufen, nicht beim Setzen. */
|
||||
beiWechsel: (fn: () => void) => void;
|
||||
/** Tauscht die Liste aus — für Werte, die erst nachgeladen werden. */
|
||||
setzen: (optionen: Option[]) => void;
|
||||
};
|
||||
|
||||
export function auswahl(optionen: Option[], vorgabe = ''): Auswahlfeld {
|
||||
let liste = [...optionen];
|
||||
let wert = vorgabe;
|
||||
let melden: (() => void) | null = null;
|
||||
|
||||
const knopf = document.createElement('button');
|
||||
knopf.type = 'button';
|
||||
knopf.className = 'auswahl';
|
||||
knopf.setAttribute('aria-haspopup', 'listbox');
|
||||
knopf.setAttribute('aria-expanded', 'false');
|
||||
|
||||
const beschriftung = document.createElement('span');
|
||||
const pfeil = document.createElement('span');
|
||||
pfeil.className = 'auswahl-pfeil icon';
|
||||
pfeil.innerHTML = icons.runter;
|
||||
knopf.append(beschriftung, pfeil);
|
||||
|
||||
const beschriften = () => {
|
||||
beschriftung.textContent = liste.find(o => o.wert === wert)?.text ?? liste[0]?.text ?? '';
|
||||
};
|
||||
beschriften();
|
||||
|
||||
let offen: HTMLElement | null = null;
|
||||
|
||||
function schliessen(): void {
|
||||
offen?.remove();
|
||||
offen = null;
|
||||
knopf.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
|
||||
function waehlen(neu: string): void {
|
||||
const geaendert = neu !== wert;
|
||||
wert = neu;
|
||||
beschriften();
|
||||
schliessen();
|
||||
knopf.focus();
|
||||
if (geaendert) melden?.();
|
||||
}
|
||||
|
||||
function oeffnen(): void {
|
||||
if (offen) { schliessen(); return; }
|
||||
|
||||
const box = document.createElement('div');
|
||||
box.className = 'auswahl-liste';
|
||||
box.setAttribute('role', 'listbox');
|
||||
|
||||
liste.forEach(o => {
|
||||
const e = document.createElement('button');
|
||||
e.type = 'button';
|
||||
e.className = 'auswahl-punkt' + (o.wert === wert ? ' gewaehlt' : '');
|
||||
e.setAttribute('role', 'option');
|
||||
e.setAttribute('aria-selected', String(o.wert === wert));
|
||||
e.textContent = o.text;
|
||||
e.onclick = () => waehlen(o.wert);
|
||||
box.appendChild(e);
|
||||
});
|
||||
|
||||
// **Die Tasten müssen auch in der Liste wirken.** Nach dem Öffnen sitzt
|
||||
// der Fokus auf einem Eintrag, nicht mehr auf dem Knopf — Escape lief
|
||||
// dort ins Leere, und die Liste blieb offen stehen.
|
||||
box.onkeydown = e => {
|
||||
if (e.key === 'Escape') { e.preventDefault(); schliessen(); knopf.focus(); return; }
|
||||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
bewegen(e.key === 'ArrowDown' ? 1 : -1);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Home' || e.key === 'End') {
|
||||
e.preventDefault();
|
||||
const punkte = [...box.querySelectorAll<HTMLElement>('.auswahl-punkt')];
|
||||
(e.key === 'Home' ? punkte[0] : punkte[punkte.length - 1])?.focus();
|
||||
return;
|
||||
}
|
||||
// Tab verlässt die Liste — dann hat sie dort nichts mehr zu suchen.
|
||||
if (e.key === 'Tab') schliessen();
|
||||
};
|
||||
|
||||
document.body.appendChild(box);
|
||||
offen = box;
|
||||
knopf.setAttribute('aria-expanded', 'true');
|
||||
|
||||
// **Erst anhängen, dann messen.** Vorher hat die Liste keine Grösse, und
|
||||
// die Prüfung auf den Bildschirmrand liefe gegen null. Dasselbe Vorgehen
|
||||
// wie im Kontextmenü, und aus demselben Grund.
|
||||
const ab = knopf.getBoundingClientRect();
|
||||
const eigen = box.getBoundingClientRect();
|
||||
box.style.minWidth = `${ab.width}px`;
|
||||
box.style.left = `${Math.max(8, Math.min(ab.left, window.innerWidth - eigen.width - 8))}px`;
|
||||
box.style.top = ab.bottom + eigen.height + 8 > window.innerHeight
|
||||
? `${Math.max(8, ab.top - eigen.height - 4)}px`
|
||||
: `${ab.bottom + 4}px`;
|
||||
|
||||
box.querySelector<HTMLElement>('.gewaehlt, .auswahl-punkt')?.focus();
|
||||
}
|
||||
|
||||
knopf.onclick = e => { e.stopPropagation(); oeffnen(); };
|
||||
|
||||
// **Die Tastatur, die ein `select` mitbrachte.** Hoch und runter wechseln
|
||||
// auch ohne geöffnete Liste — genau so verhält sich das Original, und wer
|
||||
// es gewohnt ist, merkt den Tausch sonst sofort.
|
||||
knopf.onkeydown = e => {
|
||||
const stelle = liste.findIndex(o => o.wert === wert);
|
||||
|
||||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
if (offen) { bewegen(e.key === 'ArrowDown' ? 1 : -1); return; }
|
||||
const ziel = liste[Math.min(liste.length - 1, Math.max(0, stelle + (e.key === 'ArrowDown' ? 1 : -1)))];
|
||||
if (ziel) waehlen(ziel.wert);
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'Home' || e.key === 'End') {
|
||||
e.preventDefault();
|
||||
const ziel = e.key === 'Home' ? liste[0] : liste[liste.length - 1];
|
||||
if (ziel) waehlen(ziel.wert);
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); oeffnen(); return; }
|
||||
if (e.key === 'Escape') { schliessen(); return; }
|
||||
|
||||
// Anfangsbuchstabe — springt zum nächsten passenden Eintrag.
|
||||
if (e.key.length === 1 && /\S/.test(e.key)) {
|
||||
const zeichen = e.key.toLowerCase();
|
||||
const ab = stelle + 1;
|
||||
const treffer = [...liste.slice(ab), ...liste.slice(0, ab)]
|
||||
.find(o => o.text.toLowerCase().startsWith(zeichen));
|
||||
if (treffer) waehlen(treffer.wert);
|
||||
}
|
||||
};
|
||||
|
||||
/** Pfeiltasten in der offenen Liste. */
|
||||
function bewegen(richtung: 1 | -1): void {
|
||||
if (!offen) return;
|
||||
const punkte = [...offen.querySelectorAll<HTMLElement>('.auswahl-punkt')];
|
||||
const jetzt = punkte.indexOf(document.activeElement as HTMLElement);
|
||||
const ziel = punkte[Math.min(punkte.length - 1, Math.max(0, jetzt + richtung))];
|
||||
ziel?.focus();
|
||||
}
|
||||
|
||||
// Ein Klick daneben, Escape oder ein Bildlauf schliessen — wie beim
|
||||
// Kontextmenü. Je Feld registriert und nicht global: es gibt wenige davon,
|
||||
// und beim Entfernen des Feldes gehen sie mit.
|
||||
document.addEventListener('click', e => {
|
||||
if (offen && !offen.contains(e.target as Node) && e.target !== knopf) schliessen();
|
||||
});
|
||||
window.addEventListener('scroll', () => schliessen(), { passive: true, capture: true });
|
||||
|
||||
return {
|
||||
element: knopf,
|
||||
get wert() { return wert; },
|
||||
set wert(neu: string) { wert = neu; beschriften(); },
|
||||
beiWechsel: fn => { melden = fn; },
|
||||
setzen: neue => { liste = [...neue]; beschriften(); },
|
||||
};
|
||||
}
|
||||
+53
-16
@@ -1,5 +1,5 @@
|
||||
import { bildAdresse, bildauswahl, bildSetzen, bildHochladen, type Bildkandidat } from './api';
|
||||
import { knopf, fehlerzeile } from './util';
|
||||
import { knopf, fehlerzeile, meldung } from './util';
|
||||
import { icons } from './icons';
|
||||
|
||||
type Bildart = 'poster' | 'backdrop' | 'logo';
|
||||
@@ -43,6 +43,9 @@ export function bilddialog(art: 'movies' | 'series', id: string, titel: string):
|
||||
document.body.appendChild(d);
|
||||
d.showModal();
|
||||
|
||||
/** Welcher Reiter offen ist — überlebt das Neuzeichnen nach jeder Wahl. */
|
||||
let offen: Bildart = 'poster';
|
||||
|
||||
void laden();
|
||||
|
||||
async function laden(): Promise<void> {
|
||||
@@ -51,7 +54,7 @@ export function bilddialog(art: 'movies' | 'series', id: string, titel: string):
|
||||
auswahl = await bildauswahl(art, id);
|
||||
} catch (fehler) {
|
||||
koerper.innerHTML = '';
|
||||
koerper.appendChild(fehlerzeile((fehler as Error).message));
|
||||
koerper.appendChild(fehlerzeile(meldung(fehler)));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -66,14 +69,51 @@ export function bilddialog(art: 'movies' | 'series', id: string, titel: string):
|
||||
koerper.appendChild(hinweis);
|
||||
}
|
||||
|
||||
// **Ein Reiter je Bildtyp statt drei Blöcke untereinander.**
|
||||
//
|
||||
// Vorher scrollte der Dialogkörper, und jedes der drei Raster darin
|
||||
// scrollte noch einmal. Zwei Ebenen übereinander heisst: das Rad trifft
|
||||
// mal die eine, mal die andere, je nachdem wo der Zeiger steht — und man
|
||||
// landet nie dort, wo man hinwollte. Jetzt ist genau ein Typ sichtbar und
|
||||
// genau eine Fläche scrollt.
|
||||
const reiter = document.createElement('div');
|
||||
reiter.className = 'bildreiter';
|
||||
reiter.setAttribute('role', 'tablist');
|
||||
|
||||
const buehne = document.createElement('div');
|
||||
buehne.className = 'bildbuehne';
|
||||
|
||||
koerper.append(reiter, buehne);
|
||||
|
||||
const bloecke = new Map<Bildart, HTMLElement>();
|
||||
|
||||
ARTEN.forEach(({ art: bildart, titel: name, verhaeltnis }) => {
|
||||
const aktuell = bildart === 'poster' ? auswahl.current.poster
|
||||
: bildart === 'backdrop' ? auswahl.current.backdrop
|
||||
: auswahl.current.logo;
|
||||
|
||||
const passende = auswahl.candidates.filter(k => k.kind === bildart);
|
||||
|
||||
const knopfReiter = document.createElement('button');
|
||||
knopfReiter.type = 'button';
|
||||
knopfReiter.className = 'bildreiter-knopf' + (bildart === offen ? ' an' : '');
|
||||
knopfReiter.setAttribute('role', 'tab');
|
||||
knopfReiter.setAttribute('aria-selected', String(bildart === offen));
|
||||
// Die Zahl dazu: so sieht man vor dem Klicken, ob es dort etwas gibt.
|
||||
knopfReiter.innerHTML = `<span>${name}</span>`
|
||||
+ `<span class="winzig">${passende.length}</span>`;
|
||||
knopfReiter.onclick = () => { offen = bildart; void laden(); };
|
||||
reiter.appendChild(knopfReiter);
|
||||
|
||||
if (bildart !== offen) return;
|
||||
|
||||
const abschnitt = document.createElement('section');
|
||||
abschnitt.className = 'bildblock';
|
||||
|
||||
const kopfzeile = document.createElement('div');
|
||||
kopfzeile.className = 'knopfreihe';
|
||||
const u = document.createElement('h3');
|
||||
u.textContent = name;
|
||||
u.textContent = aktuell ? `${name} — eines ist gesetzt` : name;
|
||||
kopfzeile.appendChild(u);
|
||||
|
||||
// Eigenes Bild — ein verstecktes Dateifeld hinter einem Knopf, weil
|
||||
@@ -91,37 +131,34 @@ export function bilddialog(art: 'movies' | 'series', id: string, titel: string):
|
||||
eigenes.disabled = true;
|
||||
try { await bildHochladen(art, id, bildart, datei); await laden(); }
|
||||
catch (fehler) {
|
||||
abschnitt.appendChild(fehlerzeile((fehler as Error).message));
|
||||
abschnitt.appendChild(fehlerzeile(meldung(fehler)));
|
||||
eigenes.disabled = false;
|
||||
}
|
||||
};
|
||||
kopfzeile.append(eigenes, feld);
|
||||
abschnitt.appendChild(kopfzeile);
|
||||
|
||||
const gitter = document.createElement('div');
|
||||
gitter.className = 'bildgitter';
|
||||
gitter.style.setProperty('--verhaeltnis', verhaeltnis);
|
||||
|
||||
const aktuell = bildart === 'poster' ? auswahl.current.poster
|
||||
: bildart === 'backdrop' ? auswahl.current.backdrop
|
||||
: auswahl.current.logo;
|
||||
|
||||
const passende = auswahl.candidates.filter(k => k.kind === bildart);
|
||||
if (passende.length === 0 && !aktuell) {
|
||||
const leer = document.createElement('p');
|
||||
leer.className = 'winzig';
|
||||
leer.textContent = 'Nichts zur Auswahl.';
|
||||
abschnitt.append(leer);
|
||||
koerper.appendChild(abschnitt);
|
||||
buehne.appendChild(abschnitt);
|
||||
bloecke.set(bildart, abschnitt);
|
||||
return;
|
||||
}
|
||||
|
||||
const gitter = document.createElement('div');
|
||||
gitter.className = 'bildgitter';
|
||||
gitter.style.setProperty('--verhaeltnis', verhaeltnis);
|
||||
|
||||
// Das gesetzte Bild zuerst, damit sichtbar ist, wovon man weggeht.
|
||||
if (aktuell) gitter.appendChild(kachelFuer(null, bildAdresse(aktuell)!, true, bildart));
|
||||
passende.forEach(k => gitter.appendChild(kachelFuer(k, k.thumbUrl, false, bildart)));
|
||||
|
||||
abschnitt.appendChild(gitter);
|
||||
koerper.appendChild(abschnitt);
|
||||
buehne.appendChild(abschnitt);
|
||||
bloecke.set(bildart, abschnitt);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -154,7 +191,7 @@ export function bilddialog(art: 'movies' | 'series', id: string, titel: string):
|
||||
b.disabled = true;
|
||||
try { await bildSetzen(art, id, bildart, kandidat!.url); await laden(); }
|
||||
catch (fehler) {
|
||||
b.after(fehlerzeile((fehler as Error).message));
|
||||
b.after(fehlerzeile(meldung(fehler)));
|
||||
b.disabled = false;
|
||||
}
|
||||
};
|
||||
|
||||
+40
-2
@@ -1,5 +1,5 @@
|
||||
import {
|
||||
bildAdresse, gesehen, favorit, weglegen, istAdmin,
|
||||
bildAdresse, gesehen, favorit, weglegen, istAdmin, film, serie,
|
||||
type Film, type Serie, type Angefangen, type Stand,
|
||||
} from './api';
|
||||
import { esc, anteil } from './util';
|
||||
@@ -274,13 +274,51 @@ function kachelpunkte(
|
||||
// erkannte Titel vor sich hat, soll sie hier geradeziehen können.
|
||||
tun: () => zuordnungsdialog(art, k.id, k.titel,
|
||||
k.zweite && /^\d{4}$/.test(k.zweite) ? Number(k.zweite) : null,
|
||||
() => location.reload()),
|
||||
// **Nur die Kachel, nicht die Seite.** `location.reload()` warf
|
||||
// Bildlauf, Filter und offene Reiter weg — für einen Titel, der sich
|
||||
// geändert hat. Bei zwanzig Korrekturen hintereinander ist das
|
||||
// zwanzigmal von vorn anfangen.
|
||||
() => void kachelAuffrischen(k, knopf)),
|
||||
});
|
||||
}
|
||||
|
||||
return punkte;
|
||||
}
|
||||
|
||||
/** Holt einen Titel neu und schreibt Beschriftung und Plakat fort.
|
||||
*
|
||||
* **Warum nicht die ganze Liste.** Nach einer Korrektur ändert sich genau ein
|
||||
* Eintrag. Alles neu zu laden kostet einen Netzabruf über mehrere hundert
|
||||
* Titel und setzt die Ansicht an den Anfang zurück.
|
||||
*/
|
||||
async function kachelAuffrischen(k: Kachel, knopf: HTMLElement): Promise<void> {
|
||||
let frisch: Film | Serie;
|
||||
try { frisch = k.art === 'serien' ? await serie(k.id) : await film(k.id); }
|
||||
catch { return; } // Still bleiben: die alte Kachel ist besser als eine Meldung.
|
||||
|
||||
k.titel = frisch.title;
|
||||
k.zweite = frisch.year ? String(frisch.year) : null;
|
||||
k.bild = frisch.posterImage;
|
||||
|
||||
const text = knopf.querySelector('.kacheltext');
|
||||
if (text) {
|
||||
text.innerHTML = `<strong>${esc(k.titel)}</strong>`
|
||||
+ (k.zweite ? `<span>${esc(k.zweite)}</span>` : '');
|
||||
}
|
||||
|
||||
const plakat = knopf.querySelector('.plakat');
|
||||
const adresse = bildAdresse(k.bild);
|
||||
plakat?.querySelector('img')?.remove();
|
||||
plakat?.classList.remove('geladen');
|
||||
if (plakat && adresse) {
|
||||
const bild = document.createElement('img');
|
||||
bild.src = adresse;
|
||||
bild.alt = '';
|
||||
bild.onload = () => plakat.classList.add('geladen');
|
||||
plakat.prepend(bild);
|
||||
}
|
||||
}
|
||||
|
||||
/** Setzt oder entfernt den Haken, ohne die Kachel neu zu bauen. */
|
||||
function hakenZeigen(knopf: HTMLElement, an: boolean): void {
|
||||
const plakat = knopf.querySelector('.plakat');
|
||||
|
||||
+14
-17
@@ -1,6 +1,7 @@
|
||||
import { icons } from './icons';
|
||||
import { knopf } from './util';
|
||||
import { knopf, meldung } from './util';
|
||||
import { ordner, type Ordnerstand } from './api';
|
||||
import { auswahl, type Auswahlfeld } from './auswahl';
|
||||
|
||||
/* Dialoge über dem Inhalt statt Blöcken darin.
|
||||
*
|
||||
@@ -71,7 +72,7 @@ export function dialog(optionen: {
|
||||
koerper.appendChild(p);
|
||||
}
|
||||
|
||||
const eingaben = new Map<string, HTMLInputElement | HTMLSelectElement>();
|
||||
const eingaben = new Map<string, HTMLInputElement | Auswahlfeld>();
|
||||
|
||||
(optionen.felder ?? []).forEach(f => {
|
||||
// **Ein `<label>` nur dort, wo genau ein Bedienelement darin sitzt.**
|
||||
@@ -86,16 +87,13 @@ export function dialog(optionen: {
|
||||
block.appendChild(titel);
|
||||
|
||||
if (f.auswahl) {
|
||||
const wahl = document.createElement('select');
|
||||
wahl.className = 'feld';
|
||||
f.auswahl.forEach(o => {
|
||||
const e = document.createElement('option');
|
||||
e.value = o.wert; e.textContent = o.text;
|
||||
wahl.appendChild(e);
|
||||
});
|
||||
if (f.wert) wahl.value = f.wert;
|
||||
// **Eigenes Listenfeld statt `select`.** Dessen Liste zeichnet das
|
||||
// Betriebssystem; mitten im dunklen Dialog erschien ein helles
|
||||
// Systemmenü, das sich nicht gestalten lässt.
|
||||
const wahl = auswahl(f.auswahl, f.wert ?? f.auswahl[0]?.wert ?? '');
|
||||
wahl.element.classList.add('feld');
|
||||
eingaben.set(f.name, wahl);
|
||||
block.appendChild(wahl);
|
||||
block.appendChild(wahl.element);
|
||||
} else if (f.schalter) {
|
||||
const e = document.createElement('input');
|
||||
e.type = 'checkbox';
|
||||
@@ -163,9 +161,8 @@ export function dialog(optionen: {
|
||||
los.disabled = true;
|
||||
const gesammelt = new Map<string, string>();
|
||||
eingaben.forEach((e, name) => {
|
||||
gesammelt.set(name, e instanceof HTMLInputElement && e.type === 'checkbox'
|
||||
? String(e.checked)
|
||||
: e.value.trim());
|
||||
if (!(e instanceof HTMLInputElement)) { gesammelt.set(name, e.wert); return; }
|
||||
gesammelt.set(name, e.type === 'checkbox' ? String(e.checked) : e.value.trim());
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -177,7 +174,7 @@ export function dialog(optionen: {
|
||||
// schreiben, wo gerade niemand hinsieht.
|
||||
const m = document.createElement('div');
|
||||
m.className = 'fehler';
|
||||
m.textContent = (fehler as Error).message;
|
||||
m.textContent = meldung(fehler);
|
||||
fehlerplatz.appendChild(m);
|
||||
los.disabled = false;
|
||||
}
|
||||
@@ -279,7 +276,7 @@ function ordnerfeld(eingabe: HTMLInputElement): HTMLElement {
|
||||
} catch (fehler) {
|
||||
if (dieser !== lauf) return;
|
||||
liste.innerHTML = '';
|
||||
liste.appendChild(hinweiszeile(`Konnte nicht gelesen werden: ${(fehler as Error).message}`));
|
||||
liste.appendChild(hinweiszeile(`Konnte nicht gelesen werden: ${meldung(fehler)}`));
|
||||
return;
|
||||
} finally {
|
||||
window.clearTimeout(langsam);
|
||||
@@ -294,7 +291,7 @@ function ordnerfeld(eingabe: HTMLInputElement): HTMLElement {
|
||||
// **Auch ein Fehler beim Zeichnen muss sichtbar werden.** Vorher blieb
|
||||
// dann der Spinner stehen, und es sah aus, als käme keine Antwort.
|
||||
liste.innerHTML = '';
|
||||
liste.appendChild(hinweiszeile(`Anzeige gescheitert: ${(fehler as Error).message}`));
|
||||
liste.appendChild(hinweiszeile(`Anzeige gescheitert: ${meldung(fehler)}`));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ export const icons = {
|
||||
+ '<circle cx="12" cy="19" r="1.4"/>', true),
|
||||
/** Winkel nach oben — eine Ebene zurück im Baum. */
|
||||
hoch: rahmen('<path d="m6 14 6-6 6 6"/>'),
|
||||
runter: rahmen('<path d="m6 10 6 6 6-6"/>'),
|
||||
/** Filmstreifen — für eine Filmmediathek. */
|
||||
filme: rahmen('<rect x="3" y="4" width="18" height="16" rx="2"/>'
|
||||
+ '<path d="M7 4v16M17 4v16M3 9h4M3 15h4M17 9h4M17 15h4"/>'),
|
||||
|
||||
+2
-2
@@ -12,7 +12,7 @@ import { mediathek } from './views/library';
|
||||
import { detailseite } from './views/detail';
|
||||
import { verwaltung } from './views/admin';
|
||||
import { suchseite } from './views/search';
|
||||
import { esc } from './util';
|
||||
import { esc, meldung } from './util';
|
||||
|
||||
const anmeldeflaeche = document.getElementById('login')!;
|
||||
const kopfleiste = document.getElementById('topbar')!;
|
||||
@@ -55,7 +55,7 @@ async function hochfahren(): Promise<void> {
|
||||
// Lüge — sie würde bei jedem Versuch scheitern.
|
||||
anmeldeflaeche.innerHTML =
|
||||
`<div class="login-card"><h1>Flaro</h1>`
|
||||
+ `<div class="fehler">Der Server antwortet nicht: ${esc((fehler as Error).message)}</div>`
|
||||
+ `<div class="fehler">Der Server antwortet nicht: ${esc(meldung(fehler))}</div>`
|
||||
+ `<div class="hinweis">Läuft er? Stimmt die Adresse?</div></div>`;
|
||||
anmeldeflaeche.classList.remove('hidden');
|
||||
kopfleiste.classList.add('hidden');
|
||||
|
||||
+74
-15
@@ -261,23 +261,53 @@ svg { width: 20px; height: 20px; display: block; flex: none; }
|
||||
.pille {
|
||||
position: relative; display: inline-flex; align-items: center;
|
||||
}
|
||||
.pille select {
|
||||
appearance: none; -webkit-appearance: none;
|
||||
background: none; border: 0; outline: none; cursor: pointer;
|
||||
color: var(--text-dim); font-weight: 500;
|
||||
padding: 6px 26px 6px 11px; border-radius: var(--r-pill);
|
||||
.pille .auswahl {
|
||||
background: none; border: 0; color: var(--text-dim); font-weight: 500;
|
||||
padding: 6px 11px; border-radius: var(--r-pill);
|
||||
}
|
||||
.pille select:hover { background: var(--elevated); color: var(--text); }
|
||||
.pille select:focus-visible { box-shadow: 0 0 0 2px var(--accent); }
|
||||
.pille .auswahl:hover { background: var(--elevated); color: var(--text); }
|
||||
/* Gesetzt heisst farbig — so sieht man ohne Lesen, dass gefiltert wird. */
|
||||
.pille.gesetzt select { color: var(--accent); }
|
||||
.pille::after {
|
||||
content: ''; position: absolute; right: 11px; top: 50%;
|
||||
width: 6px; height: 6px; margin-top: -4px;
|
||||
border-right: 1.6px solid currentColor; border-bottom: 1.6px solid currentColor;
|
||||
transform: rotate(45deg); color: var(--text-faint); pointer-events: none;
|
||||
.pille.gesetzt .auswahl { color: var(--accent); }
|
||||
|
||||
/* ---------- Auswahlfeld ----------
|
||||
*
|
||||
* Ersetzt <select>. Dessen Liste zeichnet das Betriebssystem: mitten in der
|
||||
* dunklen Oberfläche erschien ein helles Systemmenü, das sich weder färben
|
||||
* noch runden lässt. Die Liste hier ist dieselbe Fläche wie das Kontextmenü
|
||||
* — gleicher Rahmen, gleiche Radien, gleicher Schatten.
|
||||
*/
|
||||
.auswahl {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
background: var(--elevated); border: 1px solid var(--line);
|
||||
border-radius: var(--r-knopf); cursor: pointer;
|
||||
color: var(--text-dim); font-size: var(--t-normal); font-weight: 500;
|
||||
padding: 7px 10px; text-align: left;
|
||||
}
|
||||
.pille.gesetzt::after { color: var(--accent); }
|
||||
.auswahl:hover { color: var(--text); }
|
||||
.auswahl > span:first-child {
|
||||
flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.auswahl-pfeil { display: inline-flex; color: var(--text-faint); }
|
||||
.auswahl-pfeil svg { width: 15px; height: 15px; }
|
||||
.auswahl[aria-expanded='true'] .auswahl-pfeil { transform: rotate(180deg); }
|
||||
|
||||
.auswahl-liste {
|
||||
position: fixed; z-index: 120;
|
||||
max-height: min(50vh, 360px); overflow-y: auto;
|
||||
padding: var(--s-tight);
|
||||
background: var(--elevated);
|
||||
border: 1px solid var(--line); border-radius: var(--r-flaeche);
|
||||
box-shadow: var(--schatten-dialog);
|
||||
display: flex; flex-direction: column;
|
||||
}
|
||||
.auswahl-punkt {
|
||||
background: none; border: 0; border-radius: var(--r-knopf);
|
||||
padding: 8px var(--s-regular); text-align: left; white-space: nowrap;
|
||||
color: var(--text-dim); font-size: var(--t-normal);
|
||||
}
|
||||
.auswahl-punkt:hover { background: var(--raised); color: var(--text); }
|
||||
/* Das Gewählte trägt die Akzentfarbe — ohne Haken, der die Breite verschiebt. */
|
||||
.auswahl-punkt.gewaehlt { color: var(--accent); font-weight: 600; }
|
||||
|
||||
.filterzeile .zuruecksetzen {
|
||||
background: none; border: 0; padding: 6px 4px;
|
||||
@@ -612,6 +642,9 @@ h2 { font-size: var(--t-abschnitt); font-weight: 700; letter-spacing: -.015em; m
|
||||
display: flex; flex-direction: column; gap: var(--s-loose);
|
||||
max-height: 60vh; overflow-y: auto;
|
||||
}
|
||||
/* Im Bilddialog ist die Höhe fest: so springt die Fläche beim Reiterwechsel
|
||||
* nicht, und der Reiter bleibt an derselben Stelle. */
|
||||
.dlg-breit .dlg-koerper { height: 62vh; max-height: none; }
|
||||
.dlg-feld { display: flex; flex-direction: column; gap: var(--s-snug); }
|
||||
.dlg-feld.waagerecht {
|
||||
flex-direction: row; align-items: center; gap: var(--s-snug); cursor: pointer;
|
||||
@@ -903,10 +936,36 @@ h2 { font-size: var(--t-abschnitt); font-weight: 700; letter-spacing: -.015em; m
|
||||
.bildblock { display: flex; flex-direction: column; gap: var(--s-snug); }
|
||||
.bildblock h3 { margin: 0; font-size: var(--t-gross); font-weight: 600; flex: 1; }
|
||||
|
||||
/* ---------- Bildwahl: ein Reiter je Typ ----------
|
||||
*
|
||||
* Vorher standen Plakat, Hintergrund und Schriftzug untereinander: der
|
||||
* Dialogkörper scrollte, und jedes Raster darin scrollte noch einmal. Wo das
|
||||
* Rad landet, hing davon ab, über welcher Fläche der Zeiger gerade stand.
|
||||
* Jetzt ist ein Typ sichtbar, und genau eine Fläche scrollt.
|
||||
*/
|
||||
.bildreiter {
|
||||
display: flex; gap: var(--s-tight);
|
||||
border-bottom: 1px solid var(--line);
|
||||
margin: calc(var(--s-loose) * -1) 0 0;
|
||||
position: sticky; top: 0; z-index: 2; background: var(--surface);
|
||||
}
|
||||
.bildreiter-knopf {
|
||||
display: inline-flex; align-items: baseline; gap: 6px;
|
||||
background: none; border: 0; border-bottom: 2px solid transparent;
|
||||
padding: 10px var(--s-regular); margin-bottom: -1px;
|
||||
color: var(--text-muted); font-size: var(--t-normal); font-weight: 600;
|
||||
}
|
||||
.bildreiter-knopf:hover { color: var(--text); }
|
||||
.bildreiter-knopf.an { color: var(--text); border-bottom-color: var(--accent); }
|
||||
.bildreiter-knopf .winzig { color: var(--text-faint); }
|
||||
|
||||
/* **Die eine scrollende Fläche.** Der Dialogkörper selbst wächst nicht mehr
|
||||
* mit, sonst wären es wieder zwei. */
|
||||
.bildbuehne { display: flex; flex-direction: column; min-height: 220px; }
|
||||
|
||||
.bildgitter {
|
||||
display: grid; gap: var(--s-snug);
|
||||
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
|
||||
max-height: 260px; overflow-y: auto;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
|
||||
@@ -63,3 +63,50 @@ export function leer(text: string): HTMLElement {
|
||||
d.textContent = text;
|
||||
return d;
|
||||
}
|
||||
|
||||
/** Was bei einem Fehler in der Oberfläche steht — immer deutsch.
|
||||
*
|
||||
* **Warum nicht einfach `fehler.message`.** Das stand überall, und überall
|
||||
* kam dabei Englisch heraus: „Failed to fetch" vom Browser, „An error
|
||||
* occurred while saving the entity changes" aus der Datenbankschicht. Ein
|
||||
* Server, der auf Deutsch antwortet, darf das nicht durchreichen.
|
||||
*
|
||||
* **Der Server schreibt seine Sätze selbst.** Kommt die Meldung von dort,
|
||||
* ist sie bereits deutsch und sagt, was zu tun ist — die wird übernommen.
|
||||
* Alles andere entsteht hier, weil es im Browser passiert ist und der
|
||||
* Server davon nichts weiss.
|
||||
*/
|
||||
export function meldung(fehler: unknown): string {
|
||||
// Eine Antwort des Servers: sein Text gilt.
|
||||
if (fehler instanceof Error && fehler.name === 'ApiFehler' && fehler.message) {
|
||||
return fehler.message;
|
||||
}
|
||||
|
||||
// **Die Verbindung kam nicht zustande.** `fetch` wirft dafür einen
|
||||
// `TypeError` — und zwar in jedem Browser mit anderem Wortlaut: „Failed to
|
||||
// fetch", „Load failed", „NetworkError when attempting to fetch resource".
|
||||
if (fehler instanceof TypeError) {
|
||||
return 'Der Server war nicht erreichbar. Läuft er noch, und steht die Verbindung?';
|
||||
}
|
||||
|
||||
if (fehler instanceof DOMException && fehler.name === 'AbortError') {
|
||||
return 'Der Vorgang wurde abgebrochen.';
|
||||
}
|
||||
|
||||
const text = fehler instanceof Error ? fehler.message : String(fehler ?? '');
|
||||
|
||||
// Eine reine Statuszahl ist keine Meldung.
|
||||
if (/^\d{3}$/.test(text.trim())) {
|
||||
return `Der Server hat die Anfrage abgelehnt (${text.trim()}). `
|
||||
+ 'Die Einzelheiten stehen im Protokoll von heute.';
|
||||
}
|
||||
|
||||
// **Englisch bleibt draussen.** Was hier ankommt und keine deutschen
|
||||
// Umlaute oder typischen Wörter trägt, stammt fast sicher aus einer
|
||||
// Laufzeitbibliothek — das hilft niemandem weiter.
|
||||
if (text.length === 0 || !/[äöüßÄÖÜ]|\b(der|die|das|ist|wurde|nicht|kein|keine)\b/i.test(text)) {
|
||||
return 'Etwas ist schiefgegangen. Die Einzelheiten stehen im Protokoll von heute.';
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
+304
-102
@@ -4,14 +4,15 @@ import {
|
||||
vermisste, vermisstesEntfernen, type VermissteDatei,
|
||||
protokolltage, protokollAdresse, sprachen, spracheSetzen, uebersetzungenHolen,
|
||||
meineSprache, zuPruefen, veroeffentlichen, zurueckziehen, pruefungSetzen,
|
||||
mediathekFreigeben, reihenfolgeSetzen,
|
||||
mediathekFreigeben, reihenfolgeSetzen, mediathekZuruecksetzen,
|
||||
type Pruefeintrag,
|
||||
benutzer, benutzerAnlegen, metadatenEinstellung, metadatenSetzen,
|
||||
servernameSetzen, systemauskunft,
|
||||
laufendeWiedergaben, serverzustand, bildAdresse,
|
||||
type Bibliothek, type Wiedergabe,
|
||||
speicherorte, bildspeicherLeeren,
|
||||
type Bibliothek, type Wiedergabe, ApiFehler,
|
||||
} from '../api';
|
||||
import { esc, dauer, knopf, fehlerzeile, leer } from '../util';
|
||||
import { esc, dauer, groesse, knopf, fehlerzeile, leer, meldung } from '../util';
|
||||
import { dialog, nachfragen } from '../dialog';
|
||||
import { icon, icons } from '../icons';
|
||||
|
||||
@@ -29,9 +30,9 @@ export function verwaltung(
|
||||
signal: AbortSignal = new AbortController().signal,
|
||||
): HTMLElement {
|
||||
const wurzel = document.createElement('div');
|
||||
wurzel.append(dashboard(signal), pruefbereich(geaendert), vermisstenbereich(geaendert),
|
||||
bibliotheksbereich(geaendert, signal), benutzerbereich(), serverbereich(geaendert),
|
||||
protokollbereich());
|
||||
wurzel.append(dashboard(signal), sprachbereich(), pruefbereich(geaendert),
|
||||
vermisstenbereich(geaendert), bibliotheksbereich(geaendert, signal), benutzerbereich(),
|
||||
serverbereich(geaendert), speicherbereich(), protokollbereich());
|
||||
return wurzel;
|
||||
}
|
||||
|
||||
@@ -99,7 +100,7 @@ function dashboard(signal: AbortSignal): HTMLElement {
|
||||
zustand.textContent = teile.join(' · ');
|
||||
} catch (fehler) {
|
||||
liste.innerHTML = '';
|
||||
liste.appendChild(fehlerzeile((fehler as Error).message));
|
||||
liste.appendChild(fehlerzeile(meldung(fehler)));
|
||||
return; // Nicht weiter nachfragen, wenn es scheitert.
|
||||
}
|
||||
// **Nur weitermachen, solange die Ansicht hängt.** Ohne diese Zeile
|
||||
@@ -238,7 +239,7 @@ function pruefbereich(geaendert: () => void): HTMLElement {
|
||||
await laden();
|
||||
geaendert();
|
||||
} catch (fehler) {
|
||||
liste.prepend(fehlerzeile((fehler as Error).message));
|
||||
liste.prepend(fehlerzeile(meldung(fehler)));
|
||||
} finally {
|
||||
freigeben.disabled = false;
|
||||
planen.disabled = false;
|
||||
@@ -316,7 +317,7 @@ function pruefbereich(geaendert: () => void): HTMLElement {
|
||||
nur.onclick = async () => {
|
||||
nur.disabled = true;
|
||||
try { await veroeffentlichen(e.ids); await laden(); geaendert(); }
|
||||
catch (fehler) { d.appendChild(fehlerzeile((fehler as Error).message)); nur.disabled = false; }
|
||||
catch (fehler) { d.appendChild(fehlerzeile(meldung(fehler))); nur.disabled = false; }
|
||||
};
|
||||
|
||||
const oben = document.createElement('div');
|
||||
@@ -328,7 +329,7 @@ function pruefbereich(geaendert: () => void): HTMLElement {
|
||||
zurueck.onclick = async () => {
|
||||
zurueck.disabled = true;
|
||||
try { await zurueckziehen(e.ids); await laden(); }
|
||||
catch (fehler) { d.appendChild(fehlerzeile((fehler as Error).message)); zurueck.disabled = false; }
|
||||
catch (fehler) { d.appendChild(fehlerzeile(meldung(fehler))); zurueck.disabled = false; }
|
||||
};
|
||||
oben.appendChild(zurueck);
|
||||
}
|
||||
@@ -375,6 +376,11 @@ function protokollbereich(): HTMLElement {
|
||||
anzeige.className = 'protokoll hidden';
|
||||
bereich.appendChild(anzeige);
|
||||
|
||||
// **Blättern statt alles auf einmal.** Die ersten 800 Zeilen beantworten
|
||||
// fast jede Frage; wer weiter zurück muss, verdoppelt mit diesem Knopf.
|
||||
const mehr = knopf('Mehr laden', 'btn hidden');
|
||||
bereich.appendChild(mehr);
|
||||
|
||||
void (async () => {
|
||||
let tage;
|
||||
try { tage = await protokolltage(); } catch { bereich.classList.add('hidden'); return; }
|
||||
@@ -416,25 +422,44 @@ function protokollbereich(): HTMLElement {
|
||||
});
|
||||
})();
|
||||
|
||||
async function zeigen(tag: string, nurFehler: boolean): Promise<void> {
|
||||
/** Wie viele Zeilen zuletzt geholt wurden — der Knopf „Mehr" verdoppelt. */
|
||||
let gezeigt = 0;
|
||||
|
||||
async function zeigen(tag: string, nurFehler: boolean, zeilen = 0): Promise<void> {
|
||||
// **Nur das Ende holen, und davon anfangs wenig.** Vorher kam die ganze
|
||||
// Datei, und davon wurden 95 % weggeworfen — bei zwei Megabyte über eine
|
||||
// wackelige Verbindung endete das in „Failed to fetch". Bei der
|
||||
// Fehlersuche mehr, weil davon die meisten weggefiltert werden.
|
||||
gezeigt = zeilen > 0 ? zeilen : (nurFehler ? 5000 : 800);
|
||||
|
||||
anzeige.classList.remove('hidden');
|
||||
anzeige.textContent = 'Wird geladen…';
|
||||
mehr.classList.add('hidden');
|
||||
|
||||
try {
|
||||
const antwort = await fetch(protokollAdresse(tag));
|
||||
if (!antwort.ok) throw new Error(`${antwort.status}`);
|
||||
const text = await antwort.text();
|
||||
const zeilen = text.split('\n');
|
||||
// **Das Ende ist interessanter als der Anfang.** Ein Protokolltag kann
|
||||
// zehntausend Zeilen haben; wer etwas sucht, sucht meist das Letzte.
|
||||
const antwort = await fetch(protokollAdresse(tag, gezeigt));
|
||||
if (!antwort.ok) throw new ApiFehler(antwort.status, `${antwort.status}`);
|
||||
|
||||
const roh = (await antwort.text()).split('\n');
|
||||
const gefiltert = nurFehler
|
||||
? zeilen.filter(z => z.includes('FEHLER') || z.includes('WARNUNG'))
|
||||
: zeilen;
|
||||
anzeige.textContent = gefiltert.length === 0
|
||||
? (nurFehler ? 'Keine Fehler an diesem Tag.' : 'Leer.')
|
||||
: gefiltert.slice(-800).join('\n');
|
||||
? roh.filter(z => z.includes('FEHLER') || z.includes('WARNUNG'))
|
||||
: roh;
|
||||
|
||||
anzeige.textContent = gefiltert.join('\n').trim().length === 0
|
||||
? (nurFehler ? 'Keine Fehler in den letzten Zeilen.' : 'Leer.')
|
||||
: gefiltert.join('\n');
|
||||
anzeige.scrollTop = anzeige.scrollHeight;
|
||||
|
||||
// **„Mehr" nur, solange es mehr geben kann.** Kamen weniger Zeilen
|
||||
// zurück als verlangt, ist die Datei zu Ende — ein Knopf, der dann
|
||||
// nichts tut, ist schlechter als keiner.
|
||||
if (roh.length >= gezeigt && gezeigt < 20000) {
|
||||
mehr.classList.remove('hidden');
|
||||
mehr.textContent = `Weitere ${Math.min(gezeigt, 20000 - gezeigt)} Zeilen laden`;
|
||||
mehr.onclick = () => void zeigen(tag, nurFehler, Math.min(gezeigt * 2, 20000));
|
||||
}
|
||||
} catch (fehler) {
|
||||
anzeige.textContent = `Konnte nicht gelesen werden: ${(fehler as Error).message}`;
|
||||
anzeige.textContent = `Konnte nicht gelesen werden: ${meldung(fehler)}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -500,7 +525,7 @@ function vermisstenbereich(geaendert: () => void): HTMLElement {
|
||||
weg.onclick = async () => {
|
||||
weg.disabled = true;
|
||||
try { await vermisstesEntfernen(v.fileId); await laden(); geaendert(); }
|
||||
catch (fehler) { d.appendChild(fehlerzeile((fehler as Error).message)); weg.disabled = false; }
|
||||
catch (fehler) { d.appendChild(fehlerzeile(meldung(fehler))); weg.disabled = false; }
|
||||
};
|
||||
oben.appendChild(weg);
|
||||
d.appendChild(oben);
|
||||
@@ -592,7 +617,7 @@ function bibliotheksbereich(geaendert: () => void, signal: AbortSignal): HTMLEle
|
||||
alle.forEach((b, i) => liste.appendChild(block(b, i)));
|
||||
} catch (fehler) {
|
||||
liste.innerHTML = '';
|
||||
liste.appendChild(fehlerzeile((fehler as Error).message));
|
||||
liste.appendChild(fehlerzeile(meldung(fehler)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -626,7 +651,7 @@ function bibliotheksbereich(geaendert: () => void, signal: AbortSignal): HTMLEle
|
||||
los.onclick = async () => {
|
||||
los.disabled = true;
|
||||
try { await scanStarten(b.id); await verfolgen(true); }
|
||||
catch (fehler) { stand.textContent = (fehler as Error).message; }
|
||||
catch (fehler) { stand.textContent = meldung(fehler); }
|
||||
los.disabled = false;
|
||||
};
|
||||
|
||||
@@ -654,6 +679,25 @@ function bibliotheksbereich(geaendert: () => void, signal: AbortSignal): HTMLEle
|
||||
},
|
||||
});
|
||||
|
||||
// **Zurücksetzen steht neben Entfernen, weil es fast dasselbe tut** — nur
|
||||
// ohne Namen, Pfad und Einstellungen mitzunehmen. Wer eine Mediathek
|
||||
// löscht und neu anlegt, um sie sauber zu bekommen, tippt alles noch
|
||||
// einmal; genau das war bisher der einzige Weg.
|
||||
const zuruecksetzen = knopf('Zurücksetzen', 'btn');
|
||||
zuruecksetzen.onclick = () => nachfragen({
|
||||
titel: `„${b.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 laden();
|
||||
geaendert();
|
||||
},
|
||||
});
|
||||
|
||||
const entfernen = knopf('Entfernen', 'btn');
|
||||
entfernen.onclick = () => nachfragen({
|
||||
titel: `„${b.name}" entfernen?`,
|
||||
@@ -687,7 +731,9 @@ function bibliotheksbereich(geaendert: () => void, signal: AbortSignal): HTMLEle
|
||||
if (melden && i > 0 && i % 5 === 0) await laden();
|
||||
else if (s.state === 'finished') {
|
||||
const teile = [`${s.filesFound} Dateien`, `${s.added} neu`, `${s.removed} entfernt`];
|
||||
if (s.unrecognised) teile.push(`${s.unrecognised} ohne Folgennummer`);
|
||||
// „ohne Folgennummer" stimmte nicht mehr: gezählt wird jetzt auch,
|
||||
// was eine Nummer trägt, aber zu keiner Serie gehört.
|
||||
if (s.unrecognised) teile.push(`${s.unrecognised} nicht zugeordnet`);
|
||||
if (s.metadata?.skipped) teile.push('Metadaten übersprungen');
|
||||
else if (s.metadata) teile.push(`${s.metadata.moviesMatched + s.metadata.seriesMatched} zugeordnet`);
|
||||
if (s.metadata?.error) teile.push(s.metadata.error);
|
||||
@@ -709,7 +755,7 @@ function bibliotheksbereich(geaendert: () => void, signal: AbortSignal): HTMLEle
|
||||
await pruefungSetzen(b.id, !b.reviewNewItems);
|
||||
await laden();
|
||||
} catch (fehler) {
|
||||
d.appendChild(fehlerzeile((fehler as Error).message));
|
||||
d.appendChild(fehlerzeile(meldung(fehler)));
|
||||
pruefung.disabled = false;
|
||||
}
|
||||
};
|
||||
@@ -736,7 +782,8 @@ function bibliotheksbereich(geaendert: () => void, signal: AbortSignal): HTMLEle
|
||||
|
||||
const knoepfe = document.createElement('div');
|
||||
knoepfe.className = 'knopfreihe';
|
||||
knoepfe.append(hoch, runter, los, auffrischen, pruefung, umbenennen, entfernen);
|
||||
knoepfe.append(
|
||||
hoch, runter, los, auffrischen, pruefung, umbenennen, zuruecksetzen, entfernen);
|
||||
|
||||
// **Nur wenn hier wirklich etwas liegt.** Ein Knopf, der nichts zu tun
|
||||
// hat, ist schlechter als keiner — und die Zahl daneben sagt, worum es
|
||||
@@ -753,7 +800,7 @@ function bibliotheksbereich(geaendert: () => void, signal: AbortSignal): HTMLEle
|
||||
await laden();
|
||||
geaendert();
|
||||
} catch (fehler) {
|
||||
d.appendChild(fehlerzeile((fehler as Error).message));
|
||||
d.appendChild(fehlerzeile(meldung(fehler)));
|
||||
freigeben.disabled = false;
|
||||
}
|
||||
};
|
||||
@@ -814,7 +861,7 @@ function benutzerbereich(): HTMLElement {
|
||||
});
|
||||
} catch (fehler) {
|
||||
liste.innerHTML = '';
|
||||
liste.appendChild(fehlerzeile((fehler as Error).message));
|
||||
liste.appendChild(fehlerzeile(meldung(fehler)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -823,9 +870,21 @@ function benutzerbereich(): HTMLElement {
|
||||
|
||||
/* ---------- Server ---------- */
|
||||
|
||||
function serverbereich(geaendert: () => void): HTMLElement {
|
||||
/** Sprache — eigener Abschnitt, ganz oben.
|
||||
*
|
||||
* **Warum nicht in der Serverliste.** Dort stand sie, zwischen „Name",
|
||||
* „Metadaten", „Doppelte Einträge" und „Fassung". Gesucht wurde sie
|
||||
* trotzdem, gefunden nicht — und das ist die Antwort: eine Einstellung, die
|
||||
* man sucht und nicht findet, steht am falschen Platz, egal wie gut sie
|
||||
* beschriftet ist.
|
||||
*
|
||||
* **Oben, weil sie alles andere färbt.** In dieser Sprache werden Metadaten
|
||||
* geholt und gespeichert; wer sie spät umstellt, frischt den ganzen Bestand
|
||||
* noch einmal auf.
|
||||
*/
|
||||
function sprachbereich(): HTMLElement {
|
||||
const bereich = document.createElement('section');
|
||||
bereich.appendChild(abschnitt('Server'));
|
||||
bereich.appendChild(abschnitt('Sprache'));
|
||||
|
||||
const liste = document.createElement('div');
|
||||
liste.className = 'einstellungen';
|
||||
@@ -833,74 +892,9 @@ function serverbereich(geaendert: () => void): HTMLElement {
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const [auskunft, stand] = await Promise.all([systemauskunft(), metadatenEinstellung()]);
|
||||
const stand = await metadatenEinstellung();
|
||||
const eigeneSprache = meineSprache();
|
||||
liste.innerHTML = '';
|
||||
|
||||
liste.appendChild(zeile(
|
||||
'Name', auskunft.serverName ?? '—',
|
||||
'So nennen ihn die Apps und die Kopfzeile.',
|
||||
() => dialog({
|
||||
titel: 'Server umbenennen',
|
||||
felder: [{ name: 'name', beschriftung: 'Name', wert: auskunft.serverName ?? '' }],
|
||||
bestaetigen: 'Speichern',
|
||||
tun: async wert => { await servernameSetzen(wert('name')); location.reload(); },
|
||||
})));
|
||||
|
||||
// **Wartung, nicht Alltag.** Nach jedem Scan passiert das von selbst;
|
||||
// dieser Knopf ist für den Fall, dass doch wieder Dubletten auftauchen —
|
||||
// etwa nachdem eine Zuordnung von Hand geändert wurde.
|
||||
const wartung = zeile(
|
||||
'Doppelte Einträge', 'Zusammenlegen, was denselben Film meint',
|
||||
'Zwei Dateien mit leicht verschiedenen Namen konnten früher zwei '
|
||||
+ 'Einträge ergeben. Der Sehfortschritt bleibt dabei erhalten — es '
|
||||
+ 'gewinnt die weitere Stelle.');
|
||||
const legen = knopf('Zusammenlegen', 'btn');
|
||||
legen.onclick = async () => {
|
||||
legen.disabled = true;
|
||||
const vorher = legen.textContent;
|
||||
legen.textContent = 'Läuft…';
|
||||
try {
|
||||
const bericht = await doppelteZusammenlegen();
|
||||
legen.textContent = bericht.gesamt === 0
|
||||
? 'Nichts zu tun'
|
||||
: `${bericht.gesamt} zusammengelegt`;
|
||||
if (bericht.gesamt > 0) geaendert();
|
||||
} catch (fehler) {
|
||||
wartung.appendChild(fehlerzeile((fehler as Error).message));
|
||||
legen.textContent = vorher;
|
||||
legen.disabled = false;
|
||||
}
|
||||
};
|
||||
wartung.querySelector('.knopfreihe')?.appendChild(legen);
|
||||
liste.appendChild(wartung);
|
||||
|
||||
liste.appendChild(zeile(
|
||||
'Metadaten',
|
||||
// Ohne die Sprache: die steht eine Zeile tiefer und gehört dort hin.
|
||||
stand.usingProjectKey ? 'Mitgelieferter TMDB-Zugang'
|
||||
: stand.hasApiKey ? 'Eigener TMDB-Zugang'
|
||||
: 'Kein Zugang hinterlegt',
|
||||
stand.usingProjectKey
|
||||
? 'Ein eigener Zugang löst den mitgelieferten ab.'
|
||||
: 'Nach einer Sprachänderung die Metadaten auffrischen — '
|
||||
+ 'vorhandene Titel ändern sich sonst nicht.',
|
||||
() => dialog({
|
||||
titel: 'Metadaten',
|
||||
felder: [
|
||||
{
|
||||
name: 'schluessel', beschriftung: 'Eigener TMDB-Zugang', art: 'password',
|
||||
platzhalter: 'Read Access Token oder API Key',
|
||||
hinweis: 'Leer lassen, um den mitgelieferten zu behalten.',
|
||||
},
|
||||
],
|
||||
bestaetigen: 'Speichern',
|
||||
tun: async wert => {
|
||||
await metadatenSetzen({ apiKey: wert('schluessel') || undefined });
|
||||
location.reload();
|
||||
},
|
||||
})));
|
||||
|
||||
// **Die Sprache des Servers als eigene Zeile.**
|
||||
//
|
||||
// Sie steckte im Dialog „Metadaten" — zusammen mit dem TMDB-Schlüssel,
|
||||
@@ -973,11 +967,219 @@ function serverbereich(geaendert: () => void): HTMLElement {
|
||||
alert(`${bericht.gesamt} Übersetzungen geholt, ${bericht.fehlend} ohne Treffer.`);
|
||||
},
|
||||
})));
|
||||
|
||||
liste.appendChild(zeile('Fassung', auskunft.version, null));
|
||||
} catch (fehler) {
|
||||
liste.innerHTML = '';
|
||||
liste.appendChild(fehlerzeile((fehler as Error).message));
|
||||
liste.appendChild(fehlerzeile(meldung(fehler)));
|
||||
}
|
||||
})();
|
||||
|
||||
return bereich;
|
||||
|
||||
function zeile(
|
||||
name: string, wert: string, hinweis: string | null, bearbeiten?: () => void,
|
||||
): HTMLElement {
|
||||
const d = document.createElement('div');
|
||||
d.className = 'einstellung';
|
||||
|
||||
const oben = document.createElement('div');
|
||||
oben.className = 'knopfreihe';
|
||||
const text = document.createElement('div');
|
||||
text.className = 'zeile';
|
||||
text.style.flex = '1';
|
||||
text.innerHTML = `<strong>${esc(name)}</strong><span class="winzig">${esc(wert)}</span>`;
|
||||
oben.appendChild(text);
|
||||
|
||||
if (bearbeiten) {
|
||||
const b = knopf('Ändern', 'btn');
|
||||
b.prepend(icon('stift'));
|
||||
b.onclick = bearbeiten;
|
||||
oben.appendChild(b);
|
||||
}
|
||||
d.appendChild(oben);
|
||||
|
||||
if (hinweis) {
|
||||
const h = document.createElement('div');
|
||||
h.className = 'hinweis';
|
||||
h.textContent = hinweis;
|
||||
d.appendChild(h);
|
||||
}
|
||||
return d;
|
||||
}
|
||||
}
|
||||
|
||||
/** Speicher — wo die Dateien des Servers liegen.
|
||||
*
|
||||
* **Warum das in die Oberfläche gehört.** Die drei Pfade standen nur in der
|
||||
* Compose-Datei. Wer wissen wollte, wohin die Bilder wandern oder warum das
|
||||
* `cache`-Volume vollläuft, musste sich auf das NAS verbinden — für eine
|
||||
* Auskunft, die der Server längst hat.
|
||||
*
|
||||
* **Die Pfade bleiben Umgebungsvariablen.** Sie im Betrieb umzubiegen, während
|
||||
* Dateien darin liegen, hiesse: die Datenbank zeigt auf den alten Ort, der
|
||||
* Server schreibt in den neuen, und niemand merkt es, bis ein Bild fehlt.
|
||||
* Angezeigt wird deshalb der Variablenname — damit klar ist, wo man schraubt.
|
||||
*/
|
||||
function speicherbereich(): HTMLElement {
|
||||
const bereich = document.createElement('section');
|
||||
bereich.appendChild(abschnitt('Speicher'));
|
||||
|
||||
const liste = document.createElement('div');
|
||||
liste.className = 'einstellungen';
|
||||
liste.innerHTML = '<div class="spinner"></div>';
|
||||
bereich.appendChild(liste);
|
||||
|
||||
void laden();
|
||||
|
||||
async function laden(): Promise<void> {
|
||||
let orte;
|
||||
try { orte = await speicherorte(); }
|
||||
catch (fehler) {
|
||||
liste.innerHTML = '';
|
||||
liste.appendChild(fehlerzeile(meldung(fehler)));
|
||||
return;
|
||||
}
|
||||
|
||||
liste.innerHTML = '';
|
||||
|
||||
orte.forEach(o => {
|
||||
const d = document.createElement('div');
|
||||
d.className = 'einstellung';
|
||||
|
||||
const oben = document.createElement('div');
|
||||
oben.className = 'knopfreihe';
|
||||
const text = document.createElement('div');
|
||||
text.className = 'zeile';
|
||||
text.style.flex = '1';
|
||||
|
||||
const frei = o.frei === null ? '' : ` · ${groesse(o.frei)} frei`;
|
||||
text.innerHTML = `<strong>${esc(o.name)}</strong>`
|
||||
+ `<span class="winzig">${esc(o.pfad)} — ${groesse(o.belegt)} belegt${esc(frei)}</span>`
|
||||
+ `<span class="winzig">Wird gesetzt über ${esc(o.variable)}.</span>`;
|
||||
oben.appendChild(text);
|
||||
|
||||
// Leeren gibt es nur dort, wo es gefahrlos ist.
|
||||
if (o.bilder !== null) {
|
||||
const leeren = knopf('Bildspeicher leeren', 'btn');
|
||||
leeren.onclick = async () => {
|
||||
leeren.disabled = true;
|
||||
try {
|
||||
const { freed } = await bildspeicherLeeren();
|
||||
await laden();
|
||||
// Erst nach dem Neuladen melden, sonst steht die alte Zahl daneben.
|
||||
const gemeldet = document.createElement('p');
|
||||
gemeldet.className = 'hinweis';
|
||||
gemeldet.textContent = `${groesse(freed)} freigegeben. `
|
||||
+ 'Die Bilder werden beim nächsten Aufruf neu geholt.';
|
||||
liste.prepend(gemeldet);
|
||||
} catch (fehler) {
|
||||
d.appendChild(fehlerzeile(meldung(fehler)));
|
||||
leeren.disabled = false;
|
||||
}
|
||||
};
|
||||
oben.appendChild(leeren);
|
||||
}
|
||||
|
||||
d.appendChild(oben);
|
||||
liste.appendChild(d);
|
||||
});
|
||||
}
|
||||
|
||||
return bereich;
|
||||
}
|
||||
|
||||
function serverbereich(geaendert: () => void): HTMLElement {
|
||||
const bereich = document.createElement('section');
|
||||
bereich.appendChild(abschnitt('Server'));
|
||||
|
||||
const liste = document.createElement('div');
|
||||
liste.className = 'einstellungen';
|
||||
bereich.appendChild(liste);
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const [auskunft, stand] = await Promise.all([systemauskunft(), metadatenEinstellung()]);
|
||||
liste.innerHTML = '';
|
||||
|
||||
liste.appendChild(zeile(
|
||||
'Name', auskunft.serverName ?? '—',
|
||||
'So nennen ihn die Apps und die Kopfzeile.',
|
||||
() => dialog({
|
||||
titel: 'Server umbenennen',
|
||||
felder: [{ name: 'name', beschriftung: 'Name', wert: auskunft.serverName ?? '' }],
|
||||
bestaetigen: 'Speichern',
|
||||
tun: async wert => { await servernameSetzen(wert('name')); location.reload(); },
|
||||
})));
|
||||
|
||||
// **Wartung, nicht Alltag.** Nach jedem Scan passiert das von selbst;
|
||||
// dieser Knopf ist für den Fall, dass doch wieder Dubletten auftauchen —
|
||||
// etwa nachdem eine Zuordnung von Hand geändert wurde.
|
||||
const wartung = zeile(
|
||||
'Doppelte Einträge', 'Zusammenlegen, was denselben Film meint',
|
||||
'Zwei Dateien mit leicht verschiedenen Namen konnten früher zwei '
|
||||
+ 'Einträge ergeben. Der Sehfortschritt bleibt dabei erhalten — es '
|
||||
+ 'gewinnt die weitere Stelle.');
|
||||
const legen = knopf('Zusammenlegen', 'btn');
|
||||
legen.onclick = async () => {
|
||||
legen.disabled = true;
|
||||
const vorher = legen.textContent;
|
||||
legen.textContent = 'Läuft…';
|
||||
try {
|
||||
const bericht = await doppelteZusammenlegen();
|
||||
legen.textContent = bericht.gesamt === 0
|
||||
? 'Nichts zu tun'
|
||||
: `${bericht.gesamt} zusammengelegt`;
|
||||
if (bericht.gesamt > 0) geaendert();
|
||||
} catch (fehler) {
|
||||
wartung.appendChild(fehlerzeile(meldung(fehler)));
|
||||
legen.textContent = vorher;
|
||||
legen.disabled = false;
|
||||
}
|
||||
};
|
||||
wartung.querySelector('.knopfreihe')?.appendChild(legen);
|
||||
liste.appendChild(wartung);
|
||||
|
||||
liste.appendChild(zeile(
|
||||
'Metadaten',
|
||||
// Ohne die Sprache: die steht eine Zeile tiefer und gehört dort hin.
|
||||
stand.usingProjectKey ? 'Mitgelieferter TMDB-Zugang'
|
||||
: stand.hasApiKey ? 'Eigener TMDB-Zugang'
|
||||
: 'Kein Zugang hinterlegt',
|
||||
stand.usingProjectKey
|
||||
? 'Ein eigener Zugang löst den mitgelieferten ab.'
|
||||
: 'Nach einer Sprachänderung die Metadaten auffrischen — '
|
||||
+ 'vorhandene Titel ändern sich sonst nicht.',
|
||||
() => dialog({
|
||||
titel: 'Metadaten',
|
||||
felder: [
|
||||
{
|
||||
name: 'schluessel', beschriftung: 'Eigener TMDB-Zugang', art: 'password',
|
||||
platzhalter: 'Read Access Token oder API Key',
|
||||
hinweis: 'Leer lassen, um den mitgelieferten zu behalten.',
|
||||
},
|
||||
],
|
||||
bestaetigen: 'Speichern',
|
||||
tun: async wert => {
|
||||
await metadatenSetzen({ apiKey: wert('schluessel') || undefined });
|
||||
location.reload();
|
||||
},
|
||||
})));
|
||||
|
||||
// **Fassung und Build nebeneinander.**
|
||||
//
|
||||
// Die Fassung sagt, welcher Stand gemeint ist; der Build sagt, welches
|
||||
// Abbild wirklich läuft. Ohne den zweiten Wert war nicht feststellbar,
|
||||
// ob das NAS den Bau von eben zieht — und eine Fehlersuche, die diese
|
||||
// Frage nicht beantworten kann, dreht sich im Kreis.
|
||||
liste.appendChild(zeile(
|
||||
'Fassung',
|
||||
`${auskunft.version} · Build ${auskunft.build}`,
|
||||
auskunft.build === 'DEV'
|
||||
? 'Läuft aus dem Quellbaum, nicht aus einem Abbild.'
|
||||
: 'Diese Kennung druckt das Veröffentlichungsskript am Ende. '
|
||||
+ 'Stimmt sie nicht überein, läuft hier ein älteres Abbild.'));
|
||||
} catch (fehler) {
|
||||
liste.innerHTML = '';
|
||||
liste.appendChild(fehlerzeile(meldung(fehler)));
|
||||
}
|
||||
})();
|
||||
|
||||
|
||||
+25
-24
@@ -1,10 +1,10 @@
|
||||
import {
|
||||
import { ApiFehler,
|
||||
film as filmLaden, serie as serieLaden, folgen, lieferung, downloadAdresse,
|
||||
vorschlaege, zuordnen, bildAdresse, istAdmin, fortschritt, gesehen, favorit, weglegen,
|
||||
type Film, type Serie, type Folge, type Datei, type Lieferung, type Stand,
|
||||
type Mitwirkender, aehnliche,
|
||||
} from '../api';
|
||||
import { esc, dauer, groesse, knopf, feld, fehlerzeile, leer, anteil } from '../util';
|
||||
import { esc, dauer, groesse, knopf, feld, fehlerzeile, leer, anteil, meldung } from '../util';
|
||||
import { icons, icon } from '../icons';
|
||||
import { kachel, alsKachel, reihe } from '../card';
|
||||
|
||||
@@ -48,9 +48,10 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem
|
||||
}
|
||||
} catch (fehler) {
|
||||
wurzel.innerHTML = '';
|
||||
wurzel.appendChild(fehler instanceof Error && /404/.test(fehler.message)
|
||||
// Am Status, nicht am Text: der Text ist jetzt ein deutscher Satz.
|
||||
wurzel.appendChild(fehler instanceof ApiFehler && fehler.status === 404
|
||||
? leer(art === 'filme' ? 'Diesen Film gibt es nicht mehr.' : 'Diese Serie gibt es nicht mehr.')
|
||||
: fehlerzeile((fehler as Error).message));
|
||||
: fehlerzeile(meldung(fehler)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,25 +192,25 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem
|
||||
function aktionsreihe(eintrag: Film | Serie, stand: Stand | null, datei: Datei | null): HTMLElement {
|
||||
const reihe = document.createElement('div');
|
||||
reihe.className = 'aktionen';
|
||||
const meldung = document.createElement('div');
|
||||
meldung.className = 'hinweis';
|
||||
const hinweisfeld = document.createElement('div');
|
||||
hinweisfeld.className = 'hinweis';
|
||||
|
||||
if (datei) {
|
||||
const stelle = stand?.positionSeconds ?? 0;
|
||||
const los = knopf(stelle > 0 ? `Fortsetzen ab ${dauer(stelle)}` : 'Abspielen', 'btn btn-primary');
|
||||
los.prepend(icon('abspielen'));
|
||||
los.onclick = () => void starten(datei, eintrag.id, stelle, los, meldung);
|
||||
los.onclick = () => void starten(datei, eintrag.id, stelle, los, hinweisfeld);
|
||||
reihe.appendChild(los);
|
||||
|
||||
if (stelle > 0) {
|
||||
const vonVorn = knopf('Von vorn', 'btn');
|
||||
vonVorn.onclick = () => void starten(datei, eintrag.id, 0, vonVorn, meldung);
|
||||
vonVorn.onclick = () => void starten(datei, eintrag.id, 0, vonVorn, hinweisfeld);
|
||||
reihe.appendChild(vonVorn);
|
||||
}
|
||||
|
||||
const laden = knopf('Herunterladen', 'btn');
|
||||
laden.prepend(icon('download'));
|
||||
laden.onclick = () => void herunterladen(datei, laden, meldung);
|
||||
laden.onclick = () => void herunterladen(datei, laden, hinweisfeld);
|
||||
reihe.appendChild(laden);
|
||||
}
|
||||
|
||||
@@ -231,7 +232,7 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem
|
||||
}
|
||||
|
||||
const huelle = document.createElement('div');
|
||||
huelle.append(reihe, meldung);
|
||||
huelle.append(reihe, hinweisfeld);
|
||||
return huelle;
|
||||
}
|
||||
|
||||
@@ -459,10 +460,10 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem
|
||||
/* ---------- Abspielen und Herunterladen ---------- */
|
||||
|
||||
/** Sorgt dafür, dass die Datei abholbereit ist — packt um, falls nötig. */
|
||||
async function bereitstellen(datei: Datei, meldung: HTMLElement): Promise<boolean> {
|
||||
async function bereitstellen(datei: Datei, hinweisfeld: HTMLElement): Promise<boolean> {
|
||||
let auskunft: Lieferung;
|
||||
try { auskunft = await lieferung(datei.id); }
|
||||
catch (fehler) { meldung.textContent = (fehler as Error).message; return false; }
|
||||
catch (fehler) { hinweisfeld.textContent = meldung(fehler); return false; }
|
||||
|
||||
if (auskunft.method === 'directFile') return true;
|
||||
if (auskunft.packageState === 'ready') return true;
|
||||
@@ -471,35 +472,35 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem
|
||||
const gruende: string[] = [];
|
||||
if (auskunft.audioToEncode.length) gruende.push(`${auskunft.audioToEncode.length} Tonspur(en) werden neu gerechnet`);
|
||||
if (auskunft.subtitlesDropped.length) gruende.push(`${auskunft.subtitlesDropped.length} Bilduntertitel können nicht mit`);
|
||||
meldung.textContent = `Wird in eine MP4 umgepackt${gruende.length ? ` — ${gruende.join(', ')}` : ''}…`;
|
||||
hinweisfeld.textContent = `Wird in eine MP4 umgepackt${gruende.length ? ` — ${gruende.join(', ')}` : ''}…`;
|
||||
|
||||
for (let i = 0; i < 600; i++) {
|
||||
const antwort = await fetch(downloadAdresse(datei.id), { method: 'HEAD' });
|
||||
if (antwort.ok) { meldung.textContent = ''; return true; }
|
||||
if (antwort.status !== 202) { meldung.textContent = `Umpacken gescheitert (${antwort.status}).`; return false; }
|
||||
if (antwort.ok) { hinweisfeld.textContent = ''; return true; }
|
||||
if (antwort.status !== 202) { hinweisfeld.textContent = `Umpacken gescheitert (${antwort.status}).`; return false; }
|
||||
|
||||
const wie = await lieferung(datei.id);
|
||||
if (wie.error) { meldung.textContent = wie.error; return false; }
|
||||
meldung.textContent = `Wird umgepackt… ${Math.round(wie.progress * 100)} %`;
|
||||
if (wie.error) { hinweisfeld.textContent = wie.error; return false; }
|
||||
hinweisfeld.textContent = `Wird umgepackt… ${Math.round(wie.progress * 100)} %`;
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
}
|
||||
meldung.textContent = 'Das Umpacken dauert ungewöhnlich lange.';
|
||||
hinweisfeld.textContent = 'Das Umpacken dauert ungewöhnlich lange.';
|
||||
return false;
|
||||
}
|
||||
|
||||
async function starten(
|
||||
datei: Datei, titel: string, ab: number, knopfDavon: HTMLButtonElement, meldung: HTMLElement,
|
||||
datei: Datei, titel: string, ab: number, knopfDavon: HTMLButtonElement, hinweisfeld: HTMLElement,
|
||||
): Promise<void> {
|
||||
knopfDavon.disabled = true;
|
||||
if (await bereitstellen(datei, meldung)) spieler(downloadAdresse(datei.id), titel, ab, laden);
|
||||
if (await bereitstellen(datei, hinweisfeld)) spieler(downloadAdresse(datei.id), titel, ab, laden);
|
||||
knopfDavon.disabled = false;
|
||||
}
|
||||
|
||||
async function herunterladen(
|
||||
datei: Datei, knopfDavon: HTMLButtonElement, meldung: HTMLElement,
|
||||
datei: Datei, knopfDavon: HTMLButtonElement, hinweisfeld: HTMLElement,
|
||||
): Promise<void> {
|
||||
knopfDavon.disabled = true;
|
||||
if (await bereitstellen(datei, meldung)) {
|
||||
if (await bereitstellen(datei, hinweisfeld)) {
|
||||
// Ein echter Link, kein Blob: der Browser lädt im Hintergrund und kann
|
||||
// fortsetzen, statt den ganzen Film in den Speicher zu nehmen.
|
||||
const a = document.createElement('a');
|
||||
@@ -565,13 +566,13 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem
|
||||
try {
|
||||
await zuordnen(art === 'filme' ? 'movies' : 'series', id, v.tmdbId);
|
||||
await laden();
|
||||
} catch (fehler) { treffer.appendChild(fehlerzeile((fehler as Error).message)); }
|
||||
} catch (fehler) { treffer.appendChild(fehlerzeile(meldung(fehler))); }
|
||||
};
|
||||
treffer.appendChild(zeile);
|
||||
});
|
||||
} catch (fehler) {
|
||||
treffer.innerHTML = '';
|
||||
treffer.appendChild(fehlerzeile((fehler as Error).message));
|
||||
treffer.appendChild(fehlerzeile(meldung(fehler)));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import {
|
||||
inhalt, weiterschauen, naechsteFolgen, empfehlungen,
|
||||
type Bibliothek,
|
||||
} from '../api';
|
||||
import { leer, fehlerzeile } from '../util';
|
||||
import { leer, fehlerzeile, meldung } from '../util';
|
||||
import { kachel, alsKachel, angefangenAlsKachel, reihe } from '../card';
|
||||
import { hero, alsHero, type Heroeintrag } from '../hero';
|
||||
|
||||
@@ -104,7 +104,7 @@ export function startseite(
|
||||
}
|
||||
} catch (fehler) {
|
||||
wurzel.innerHTML = '';
|
||||
wurzel.appendChild(fehlerzeile((fehler as Error).message));
|
||||
wurzel.appendChild(fehlerzeile(meldung(fehler)));
|
||||
}
|
||||
})();
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { inhalt, genresVon, empfehlungen as holeEmpfehlungen, type Bibliothek, type Ordnung } from '../api';
|
||||
import { leer, fehlerzeile } from '../util';
|
||||
import { leer, fehlerzeile, meldung } from '../util';
|
||||
import { icons } from '../icons';
|
||||
import { kachel, alsKachel, reihe } from '../card';
|
||||
import { beiRueckkehr } from '../ansichten';
|
||||
import { auswahl, type Auswahlfeld } from '../auswahl';
|
||||
|
||||
type Art = 'filme' | 'serien';
|
||||
type Reiter = 'empfehlungen' | 'mediathek' | 'kategorien';
|
||||
@@ -88,23 +89,20 @@ export function mediathek(
|
||||
werte: [string, string][],
|
||||
gewaehlt: () => string,
|
||||
setzen: (wert: string) => void,
|
||||
): { huelle: HTMLElement; wahl: HTMLSelectElement } {
|
||||
): { huelle: HTMLElement; wahl: Auswahlfeld } {
|
||||
const huelle = document.createElement('div');
|
||||
huelle.className = 'pille';
|
||||
const wahl = document.createElement('select');
|
||||
[[ '', vorgabe ] as [string, string], ...werte].forEach(([w, t]) => {
|
||||
const o = document.createElement('option');
|
||||
o.value = w; o.textContent = t;
|
||||
wahl.appendChild(o);
|
||||
});
|
||||
wahl.onchange = () => {
|
||||
setzen(wahl.value);
|
||||
huelle.classList.toggle('gesetzt', wahl.value !== '');
|
||||
const wahl = auswahl(
|
||||
[{ wert: '', text: vorgabe }, ...werte.map(([w, t]) => ({ wert: w, text: t }))],
|
||||
gewaehlt());
|
||||
wahl.beiWechsel(() => {
|
||||
setzen(wahl.wert);
|
||||
huelle.classList.toggle('gesetzt', wahl.wert !== '');
|
||||
aufraeumen();
|
||||
void laden();
|
||||
};
|
||||
});
|
||||
huelle.classList.toggle('gesetzt', gewaehlt() !== '');
|
||||
huelle.appendChild(wahl);
|
||||
huelle.appendChild(wahl.element);
|
||||
return { huelle, wahl };
|
||||
}
|
||||
|
||||
@@ -118,18 +116,13 @@ export function mediathek(
|
||||
// Die Sortierung ist kein Filter: sie ist immer gesetzt und bleibt grau.
|
||||
const sortierHuelle = document.createElement('div');
|
||||
sortierHuelle.className = 'pille';
|
||||
const sortierung = document.createElement('select');
|
||||
([
|
||||
['title', 'Nach Titel'],
|
||||
['added', 'Zuletzt hinzugefügt'],
|
||||
['year', 'Nach Jahr'],
|
||||
] as [Ordnung, string][]).forEach(([wert, text]) => {
|
||||
const o = document.createElement('option');
|
||||
o.value = wert; o.textContent = text;
|
||||
sortierung.appendChild(o);
|
||||
});
|
||||
sortierung.onchange = () => { ordnung = sortierung.value as Ordnung; void laden(); };
|
||||
sortierHuelle.appendChild(sortierung);
|
||||
const sortierung = auswahl([
|
||||
{ wert: 'title', text: 'Nach Titel' },
|
||||
{ wert: 'added', text: 'Zuletzt hinzugefügt' },
|
||||
{ wert: 'year', text: 'Nach Jahr' },
|
||||
], ordnung);
|
||||
sortierung.beiWechsel(() => { ordnung = sortierung.wert as Ordnung; void laden(); });
|
||||
sortierHuelle.appendChild(sortierung.element);
|
||||
|
||||
// Erscheint nur, wenn es etwas zurückzusetzen gibt.
|
||||
const zuruecksetzen = document.createElement('button');
|
||||
@@ -138,7 +131,7 @@ export function mediathek(
|
||||
zuruecksetzen.hidden = true;
|
||||
zuruecksetzen.onclick = () => {
|
||||
genre = ''; gesehen = '';
|
||||
genrePille.wahl.value = ''; standPille.wahl.value = '';
|
||||
genrePille.wahl.wert = ''; standPille.wahl.wert = '';
|
||||
genrePille.huelle.classList.remove('gesetzt');
|
||||
standPille.huelle.classList.remove('gesetzt');
|
||||
aufraeumen();
|
||||
@@ -173,11 +166,10 @@ export function mediathek(
|
||||
// Die Genres der Mediathek nachladen — die Liste steht erst, wenn der
|
||||
// Server sie genannt hat, und solange bleibt „Alle Genres" allein.
|
||||
void genresVon(bibliothek.id).then(namen => {
|
||||
namen.forEach(n => {
|
||||
const o = document.createElement('option');
|
||||
o.value = n; o.textContent = n;
|
||||
genrePille.wahl.appendChild(o);
|
||||
});
|
||||
genrePille.wahl.setzen([
|
||||
{ wert: '', text: 'Alle Genres' },
|
||||
...namen.map(n => ({ wert: n, text: n })),
|
||||
]);
|
||||
}).catch(() => {});
|
||||
|
||||
void laden();
|
||||
@@ -218,7 +210,7 @@ export function mediathek(
|
||||
uebrig.forEach(e => gitter.appendChild(kachel(alsKachel(e), oeffnen)));
|
||||
} catch (fehler) {
|
||||
gitter.innerHTML = '';
|
||||
gitter.appendChild(fehlerzeile((fehler as Error).message));
|
||||
gitter.appendChild(fehlerzeile(meldung(fehler)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -257,7 +249,7 @@ export function mediathek(
|
||||
beliebt.items.map(e => kachel(alsKachel(e), oeffnen))));
|
||||
} catch (fehler) {
|
||||
ziel.innerHTML = '';
|
||||
ziel.appendChild(fehlerzeile((fehler as Error).message));
|
||||
ziel.appendChild(fehlerzeile(meldung(fehler)));
|
||||
}
|
||||
})();
|
||||
}
|
||||
@@ -301,7 +293,7 @@ export function mediathek(
|
||||
ziel.appendChild(gitter);
|
||||
} catch (fehler) {
|
||||
ziel.innerHTML = '';
|
||||
ziel.appendChild(fehlerzeile((fehler as Error).message));
|
||||
ziel.appendChild(fehlerzeile(meldung(fehler)));
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { anmelden } from '../api';
|
||||
import { knopf, feld, fehlerzeile } from '../util';
|
||||
import { knopf, feld, fehlerzeile, meldung } from '../util';
|
||||
|
||||
export function anmeldung(fertig: () => void): HTMLElement {
|
||||
const karte = document.createElement('div');
|
||||
@@ -34,7 +34,7 @@ export function anmeldung(fertig: () => void): HTMLElement {
|
||||
await anmelden(name.value.trim(), passwort.value, haken.checked);
|
||||
fertig();
|
||||
} catch (fehler) {
|
||||
karte.appendChild(fehlerzeile((fehler as Error).message));
|
||||
karte.appendChild(fehlerzeile(meldung(fehler)));
|
||||
los.disabled = false;
|
||||
passwort.select();
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { filme, serien } from '../api';
|
||||
import { esc, leer, fehlerzeile } from '../util';
|
||||
import { esc, leer, fehlerzeile, meldung } from '../util';
|
||||
import { kachel, alsKachel } from '../card';
|
||||
|
||||
/** Suchergebnisse quer durch alle Mediatheken.
|
||||
@@ -40,7 +40,7 @@ export function suchseite(
|
||||
if (s.total > 0) buehne.appendChild(abschnitt(`Serien · ${s.total}`, s.items));
|
||||
} catch (fehler) {
|
||||
buehne.innerHTML = '';
|
||||
buehne.appendChild(fehlerzeile((fehler as Error).message));
|
||||
buehne.appendChild(fehlerzeile(meldung(fehler)));
|
||||
}
|
||||
})();
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { einrichten, anmelden } from '../api';
|
||||
import { knopf, feld, fehlerzeile } from '../util';
|
||||
import { knopf, feld, fehlerzeile, meldung } from '../util';
|
||||
|
||||
/** Der erste Start: ein Administrator muss angelegt werden.
|
||||
*
|
||||
@@ -32,7 +32,7 @@ export function einrichtung(fertig: () => void): HTMLElement {
|
||||
await anmelden(name.value.trim(), passwort.value);
|
||||
fertig();
|
||||
} catch (fehler) {
|
||||
karte.appendChild(fehlerzeile((fehler as Error).message));
|
||||
karte.appendChild(fehlerzeile(meldung(fehler)));
|
||||
los.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { vorschlaege, zuordnen } from './api';
|
||||
import { esc, knopf, feld, fehlerzeile, leer } from './util';
|
||||
import { esc, knopf, feld, fehlerzeile, leer, meldung } from './util';
|
||||
import { icons } from './icons';
|
||||
|
||||
/** Einen Titel einem anderen Eintrag der Metadatenquelle zuordnen.
|
||||
@@ -90,7 +90,7 @@ export function zuordnungsdialog(
|
||||
d.close();
|
||||
fertig?.();
|
||||
} catch (fehler) {
|
||||
treffer.appendChild(fehlerzeile((fehler as Error).message));
|
||||
treffer.appendChild(fehlerzeile(meldung(fehler)));
|
||||
zeile.disabled = false;
|
||||
}
|
||||
};
|
||||
@@ -98,7 +98,7 @@ export function zuordnungsdialog(
|
||||
});
|
||||
} catch (fehler) {
|
||||
treffer.innerHTML = '';
|
||||
treffer.appendChild(fehlerzeile((fehler as Error).message));
|
||||
treffer.appendChild(fehlerzeile(meldung(fehler)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user