From 5dfc22bdcedd7522821a63d4a09d7bea04741261 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Thu, 8 Oct 2026 22:11:55 +0200 Subject: [PATCH] =?UTF-8?q?=E2=80=9EHinterlegt"=20war=20keine=20Auskunft,?= =?UTF-8?q?=20und=20die=20=C3=9Cbersicht=20keine=20Ablage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Man konnte nicht sehen, ob ein Schlüssel wirkt.** Nach dem Eintragen stand „Hinterlegt" da, und das war alles. Ob er stimmt, ob der Dienst antwortet, ob zu einem Film überhaupt etwas zu holen ist — herausfinden liess sich das nur, indem man die Metadaten über achthundert Titel auffrischte und danach nachsah, ob sich etwas geändert hatte. Jetzt gibt es „Verbindung prüfen". Es wird mit „The Matrix" gefragt, nicht mit einem Film aus der Mediathek: der könnte schlicht unbekannt sein, und dann wüsste man wieder nicht, ob es am Schlüssel liegt oder am Film. Die Antwort nennt, was zurückkam — bei fanart.tv die Zahl der Plakate und davon die in der eingestellten Sprache, denn genau dafür ist die Quelle da. Gemessen am laufenden Server: tmdb eingerichtet ok Erreichbar — Probeabruf ergab The Matrix (1999). fanart — — Kein Schlüssel hinterlegt — die Quelle ist aus. omdb — — Kein Schlüssel hinterlegt — die Quelle ist aus. **Und die Übersicht war eine Ablage für alles geworden.** Zu Recht angemerkt: es gibt eine Seite „Metadaten", und die Zugänge standen trotzdem unter „Übersicht" — zwischen Servername, doppelten Einträgen, Fassung und Speicherplatz. Die drei Quellen stehen jetzt dort, wo man sie sucht, jede mit einem Satz dazu, was sie liefert und was sie dafür braucht. Aufmacherhöhe und saisonale Reihen bekommen einen eigenen Bereich „Darstellung". Eine Seite, die „Übersicht" heisst, ist nicht der Ort für alles, was sonst nirgends passt. **Am Telefon tat die Aufmacherhöhe gar nichts.** Gemeldet als „auf 100 % ist es immer noch zu niedrig" — und das stimmte buchstäblich: unter 860 Pixeln Breite stand `height: min(52vh, 420px)` fest und überschrieb die Einstellung vollständig. Ob „niedrig" oder „sehr hoch", es kamen immer dieselben 52 vh heraus. Jetzt hat jede Stufe zwei Höhen, eine fürs breite und eine fürs schmale Bild; am Telefon fällt sie eine Spur flacher aus, weil der Bildschirm hochkant ist und derselbe Anteil der ersten Kachelreihe dort den Platz nähme. Dazu eine fünfte Stufe „Bildschirmhoch" — mehr ginge nicht sinnvoll, darüber ist es kein Aufmacher mehr, sondern die ganze Seite. Eine Prüfung hält ausserdem fest, dass ein Film ohne passendes Stichwort und ohne passendes Genre nicht in der saisonalen Reihe steht. Anlass war die Meldung, dort stünden harmlose Zeichentrickfilme — die Reihe arbeitet richtig, sie macht nur sichtbar, wenn ein Titel auf den falschen Eintrag zugeordnet ist. 765 Tests grün. Co-Authored-By: Claude Opus 5 --- server/src/Flaro.Api/Aufmacherhoehe.cs | 7 +- .../Flaro.Api/Endpoints/SettingsEndpoints.cs | 11 ++ server/src/Flaro.Api/Program.cs | 4 + server/src/Flaro.Api/Quellenprobe.cs | 112 +++++++++++++ server/tests/Flaro.Api.Tests/MetadataTests.cs | 42 +++++ web/app/src/api.ts | 9 ++ web/app/src/style.css | 44 ++++- .../src/views/einstellungen/darstellung.ts | 133 +++++++++++++++ web/app/src/views/einstellungen/index.ts | 6 +- web/app/src/views/einstellungen/metadaten.ts | 123 +++++++++++++- web/app/src/views/einstellungen/uebersicht.ts | 152 +----------------- 11 files changed, 483 insertions(+), 160 deletions(-) create mode 100644 server/src/Flaro.Api/Quellenprobe.cs create mode 100644 web/app/src/views/einstellungen/darstellung.ts diff --git a/server/src/Flaro.Api/Aufmacherhoehe.cs b/server/src/Flaro.Api/Aufmacherhoehe.cs index baf935b..760d45f 100644 --- a/server/src/Flaro.Api/Aufmacherhoehe.cs +++ b/server/src/Flaro.Api/Aufmacherhoehe.cs @@ -26,7 +26,12 @@ public static class Aufmacherhoehe /// Die Mitte — das, was vorher fest verdrahtet war. public const string Vorgabe = "mittel"; - private static readonly string[] Stufen = ["niedrig", "mittel", "hoch", "sehrhoch"]; + /// + /// Die fünfte kam nach. Am Telefon war selbst „sehr hoch" zu + /// flach — dort überschrieb eine feste Regel die Einstellung ganz. Das + /// ist behoben; die zusätzliche Stufe gibt darüber hinaus Luft. + /// + private static readonly string[] Stufen = ["niedrig", "mittel", "hoch", "sehrhoch", "voll"]; /// Macht aus dem Gespeicherten eine der vier Stufen. /// diff --git a/server/src/Flaro.Api/Endpoints/SettingsEndpoints.cs b/server/src/Flaro.Api/Endpoints/SettingsEndpoints.cs index a3f95a8..8d12619 100644 --- a/server/src/Flaro.Api/Endpoints/SettingsEndpoints.cs +++ b/server/src/Flaro.Api/Endpoints/SettingsEndpoints.cs @@ -164,6 +164,17 @@ public static class SettingsEndpoints Kodierstufen.Lesen(anfrage.Encoder).AlsWort(), geaendert)); }).RequireAuthorization(FlaroPolicies.Administrator); + // **Die Quellen einmal fragen und sagen, was zurückkam.** + // + // Vorher stand nach dem Eintragen eines Schlüssels nur „Hinterlegt" + // da. Ob er stimmt und ob der Dienst antwortet, liess sich nur + // herausfinden, indem man die Metadaten über achthundert Titel + // auffrischte und danach nachsah, ob sich etwas geändert hat. + app.MapPost("/api/v1/settings/metadata/test", async ( + Quellenprobe probe, CancellationToken ct) => + Results.Ok(await probe.LaufenAsync(ct))) + .RequireAuthorization(FlaroPolicies.Administrator); + /* ---------- Saisonale Reihen ---------- */ // **Einstellbar und nicht eingebaut.** Halloween und Weihnachten diff --git a/server/src/Flaro.Api/Program.cs b/server/src/Flaro.Api/Program.cs index f874c4a..eef070d 100644 --- a/server/src/Flaro.Api/Program.cs +++ b/server/src/Flaro.Api/Program.cs @@ -124,6 +124,10 @@ builder.Services.AddHttpClient(c => c.Timeout = TimeSpan.FromSeconds(20); }); builder.Services.AddScoped(sp => sp.GetRequiredService()); + +// **Damit man sieht, ob ein Schlüssel wirkt.** „Hinterlegt" ist keine +// Auskunft darüber, ob der Dienst antwortet. +builder.Services.AddScoped(); builder.Services.AddSingleton(new ImageCacheOptions(pfade.Cache)); builder.Services.AddHttpClient(c => c.Timeout = TimeSpan.FromSeconds(30)); builder.Services.AddScoped(sp => sp.GetRequiredService()); diff --git a/server/src/Flaro.Api/Quellenprobe.cs b/server/src/Flaro.Api/Quellenprobe.cs new file mode 100644 index 0000000..d6c61ef --- /dev/null +++ b/server/src/Flaro.Api/Quellenprobe.cs @@ -0,0 +1,112 @@ +using Flaro.Core.Metadata; +using Flaro.Metadata; + +namespace Flaro.Api; + +/// Was eine Quelle auf Nachfrage geantwortet hat. +/// +/// „tmdb", „fanart" oder „omdb". +/// Ob überhaupt ein Zugang hinterlegt ist. +/// Ob die Probe etwas Brauchbares ergeben hat. +/// Ein Satz, der sagt, was herauskam. +public sealed record Quellenbefund(string Source, bool Configured, bool Ok, string Detail); + +/// Fragt die Quellen einmal und sagt, was zurückkam. +/// +/// +/// +/// Der Anlass. Nach dem Eintragen eines Schlüssels stand in der +/// Oberfläche „Hinterlegt" — und das war alles. Ob er stimmt, ob der Dienst +/// antwortet und ob zu einem Film überhaupt etwas zu holen ist, liess sich +/// nicht feststellen; man trug ein, frischte die Metadaten über +/// achthundert Titel auf und sah danach nach, ob sich etwas geändert hatte. +/// +/// +/// Mit einem bekannten Film, nicht mit einem aus der Mediathek. „The +/// Matrix" hat bei allen drei Quellen Einträge; ein Film aus dem eigenen +/// Bestand könnte schlicht unbekannt sein, und dann wüsste man wieder +/// nicht, ob es am Schlüssel liegt oder am Film. +/// +/// +public sealed class Quellenprobe( + TmdbClient tmdb, + FanartClient fanart, + OmdbClient omdb, + ITmdbSettingsSource spracheVon) +{ + /// „The Matrix" — bei allen drei Quellen vorhanden. + private const int ProbeTmdbId = 603; + private const string ProbeImdbId = "tt0133093"; + + public async Task> LaufenAsync(CancellationToken ct = default) => + [ + await TmdbProbe(ct), + await FanartProbe(ct), + await OmdbProbe(ct), + ]; + + private async Task TmdbProbe(CancellationToken ct) + { + if (!await tmdb.IsConfiguredAsync(ct)) + return new("tmdb", false, false, "Kein Zugang hinterlegt."); + + var film = await tmdb.FindMovieAsync("The Matrix", 1999, ct); + + return film is null + ? new("tmdb", true, false, + "Der Dienst hat nicht geantwortet oder den Zugang abgelehnt.") + : new("tmdb", true, true, + $"Erreichbar — Probeabruf ergab {film.Title} ({film.Year})."); + } + + private async Task FanartProbe(CancellationToken ct) + { + if (!await fanart.EingerichtetAsync(ct)) + return new("fanart", false, false, "Kein Schlüssel hinterlegt — die Quelle ist aus."); + + var bilder = await fanart.FilmbilderAsync(ProbeTmdbId, ct); + var plakate = bilder.Where(b => b.Kind == "poster").ToList(); + + if (plakate.Count == 0) + { + return new("fanart", true, false, + "Erreicht, aber nichts bekommen. Stimmt der Schlüssel?"); + } + + // **Die Zahl in der eingestellten Sprache ist die eigentliche + // Auskunft.** „42 Plakate" sagt nur, dass etwas kam; „davon 3 + // deutsche" sagt, ob die Quelle den Zweck erfüllt, für den sie da ist. + var sprache = (await spracheVon.GetAsync(ct)).Language.Split('-')[0]; + var passend = plakate.Count(b => + string.Equals(b.Language, sprache, StringComparison.OrdinalIgnoreCase)); + + return new("fanart", true, true, + $"Erreichbar — {plakate.Count} Plakate zu The Matrix, davon {passend} in {sprache}."); + } + + private async Task OmdbProbe(CancellationToken ct) + { + if (!await omdb.EingerichtetAsync(ct)) + return new("omdb", false, false, "Kein Schlüssel hinterlegt — die Quelle ist aus."); + + var wertungen = await omdb.HolenAsync(ProbeImdbId, ct); + + if (wertungen.Count == 0) + { + return new("omdb", true, false, + "Erreicht, aber nichts bekommen. Stimmt der Schlüssel, " + + "oder ist das Tageskontingent aufgebraucht?"); + } + + var namen = string.Join(", ", wertungen.Select(w => $"{Kurz(w.Quelle)} {w.Anzeige}")); + return new("omdb", true, true, $"Erreichbar — zu The Matrix: {namen}."); + } + + private static string Kurz(string quelle) => quelle switch + { + "imdb" => "IMDb", + "rottenTomatoes" => "RT", + "metacritic" => "MC", + _ => quelle, + }; +} diff --git a/server/tests/Flaro.Api.Tests/MetadataTests.cs b/server/tests/Flaro.Api.Tests/MetadataTests.cs index 121f4d4..11c0818 100644 --- a/server/tests/Flaro.Api.Tests/MetadataTests.cs +++ b/server/tests/Flaro.Api.Tests/MetadataTests.cs @@ -603,6 +603,48 @@ public class MetadataTests }); } + /// Was weder Stichwort noch Genre trifft, steht nicht in der Reihe. + /// + /// + /// Gemeldet vom Betrieb: zu Halloween standen „Die Schöne und das + /// Biest" und ein Feenfilm in der Reihe. Nachgesehen bei TMDB: keiner + /// von beiden trägt eines der Halloween-Stichwörter, und Horror sind sie + /// auch nicht. + /// + [Test] + public async Task Ein_Film_ohne_Treffer_steht_nicht_in_der_Reihe() + { + Datei("Die Schoene und das Biest (1991)/Die Schoene und das Biest (1991).mkv"); + + var anbieter = new VorgegebenerAnbieter + { + Film = new MovieMetadata(10020, "Die Schöne und das Biest", 1991, null, null, null) + { + Genres = ["Familie", "Fantasy"], + Keywords = ["princess", "castle", "rose"], + }, + }; + + using var server = MitAnbieter(anbieter); + var (client, bibliothek) = await Einrichten(server, "Movies"); + await Scannen(client, bibliothek); + + await client.PutAsJsonAsync("/api/v1/settings/seasonal", new[] + { + new + { + name = "Zu Halloween", von = "01-01", bis = "12-31", + stichworte = new[] { "halloween", "zombie" }, + genres = new[] { "Horror" }, + }, + }); + + var reihe = await client.GetFromJsonAsync("/api/v1/home/seasonal"); + + Assert.That(reihe!.Items.Items, Is.Empty, + "weder Stichwort noch Genre passen — der Film gehört nicht in die Reihe"); + } + /// Gilt heute kein Anlass, kommt keine Reihe. /// /// diff --git a/web/app/src/api.ts b/web/app/src/api.ts index 1b98533..38014dd 100644 --- a/web/app/src/api.ts +++ b/web/app/src/api.ts @@ -1292,6 +1292,15 @@ export const metadatenEinstellung = () => hasOmdbKey: boolean; }>('/settings/metadata'); +/** Fragt die Quellen einmal und sagt, was zurückkam. */ +export type Quellenbefund = { + source: 'tmdb' | 'fanart' | 'omdb'; + configured: boolean; ok: boolean; detail: string; +}; + +export const quellenProbe = () => + anfrage('/settings/metadata/test', { method: 'POST' }); + export const metadatenSetzen = (werte: { apiKey?: string; language?: string; fanartApiKey?: string; omdbApiKey?: string; }) => diff --git a/web/app/src/style.css b/web/app/src/style.css index 3b87068..0f5ad20 100644 --- a/web/app/src/style.css +++ b/web/app/src/style.css @@ -1352,10 +1352,39 @@ body:has(.pl.pl-klein) .auswahlleiste { bottom: 92px; } * Die Mitte ist, was vorher fest verdrahtet war. Oben ist Schluss, bevor die * erste Plakatreihe ganz unter den Falz rutscht: sie soll zeigen, dass unten * noch etwas kommt. */ -:root { --hero-hoehe: min(72vh, 640px); } -html[data-aufmacher="niedrig"] { --hero-hoehe: min(62vh, 560px); } -html[data-aufmacher="hoch"] { --hero-hoehe: min(82vh, 720px); } -html[data-aufmacher="sehrhoch"] { --hero-hoehe: min(92vh, 800px); } +/* **Zwei Höhen je Stufe, und das ist der eigentliche Punkt.** + * + * Hier stand nur eine, und weiter unten überschrieb die Telefonregel sie + * mit einem festen Wert. Dadurch tat die Einstellung am Telefon gar nichts: + * ob „niedrig" oder „sehr hoch", es kamen immer dieselben 52 vh heraus — + * gemeldet als „auf 100 % ist es immer noch zu niedrig". + * + * Am Telefon fällt jede Stufe eine Spur flacher aus als am Schreibtisch: + * der Bildschirm ist hochkant, und derselbe Anteil nähme der ersten + * Kachelreihe dort den Platz, den sie am breiten Bildschirm noch hat. */ +:root { + --hero-hoehe: min(72vh, 640px); + --hero-hoehe-schmal: min(52vh, 420px); +} +html[data-aufmacher="niedrig"] { + --hero-hoehe: min(62vh, 560px); + --hero-hoehe-schmal: min(42vh, 340px); +} +html[data-aufmacher="hoch"] { + --hero-hoehe: min(82vh, 720px); + --hero-hoehe-schmal: min(62vh, 500px); +} +html[data-aufmacher="sehrhoch"] { + --hero-hoehe: min(92vh, 800px); + --hero-hoehe-schmal: min(72vh, 580px); +} +/* Die fünfte Stufe, weil die vierte am Telefon nicht reichte. Mehr als das + ginge nicht sinnvoll: darüber steht von der ersten Reihe nichts mehr im + Bild, und dann ist es kein Aufmacher mehr, sondern die ganze Seite. */ +html[data-aufmacher="voll"] { + --hero-hoehe: min(100vh, 900px); + --hero-hoehe-schmal: min(84vh, 700px); +} .hero { position: relative; @@ -1554,10 +1583,9 @@ html[data-aufmacher="sehrhoch"] { --hero-hoehe: min(92vh, 800px); } @media (max-width: 860px) { .hero { - /* Am Telefon eine Stufe tiefer als gewählt: dort ist der Aufmacher im - Verhältnis zum Bildschirm ohnehin höher, und über 60 vh bleibt von der - ersten Reihe nichts mehr übrig. */ - height: min(52vh, 420px); + /* **Die gewählte Stufe, nur flacher.** Hier stand ein fester Wert, und + der machte die Einstellung am Telefon wirkungslos. */ + height: var(--hero-hoehe-schmal); margin: calc(var(--s-loose) * -1) calc(var(--s-loose) * -1) var(--s-section); } .hero-inhalt { padding: var(--s-loose); max-width: none; } diff --git a/web/app/src/views/einstellungen/darstellung.ts b/web/app/src/views/einstellungen/darstellung.ts new file mode 100644 index 0000000..7123267 --- /dev/null +++ b/web/app/src/views/einstellungen/darstellung.ts @@ -0,0 +1,133 @@ +import { + systemauskunft, aufmacherhoeheSetzen, saisonanlaesse, saisonanlaesseSetzen, + type Saisonanlass, +} from '../../api'; +import { fehlerzeile, meldung } from '../../util'; +import { dialog } from '../../dialog'; + +import { abschnitt, zeile } from './teile'; + +/** Wie die Startseite aussieht — Aufmacher und saisonale Reihen. + * + * **Ein eigener Bereich, und das war überfällig.** Beides stand unter + * „Übersicht", zwischen Servername, doppelten Einträgen, Fassung und + * Speicherplatz. Eine Seite, die „Übersicht" heisst, ist keine Ablage für + * alles, was sonst nirgends passt — und wer die Höhe des Aufmachers sucht, + * sucht sie unter „Darstellung". + */ +export function darstellungsbereich(): HTMLElement { + const bereich = document.createElement('section'); + bereich.appendChild(abschnitt('Darstellung')); + + const liste = document.createElement('div'); + liste.className = 'einstellungen'; + liste.innerHTML = '
'; + bereich.appendChild(liste); + + void laden(); + return bereich; + + async function laden(): Promise { + let auskunft; + try { auskunft = await systemauskunft(); } + catch (fehler) { + liste.innerHTML = ''; + liste.appendChild(fehlerzeile(meldung(fehler))); + return; + } + + liste.innerHTML = ''; + + const stufen = [ + { wert: 'niedrig', text: 'Niedrig' }, + { wert: 'mittel', text: 'Mittel' }, + { wert: 'hoch', text: 'Hoch' }, + { wert: 'sehrhoch', text: 'Sehr hoch' }, + { wert: 'voll', text: 'Bildschirmhoch' }, + ]; + const jetzige = auskunft.heroHeight ?? 'mittel'; + liste.appendChild(zeile( + 'Höhe des Aufmachers', + stufen.find(s => s.wert === jetzige)?.text ?? 'Mittel', + 'Das grosse Bild oben auf der Startseite. Gemessen wird in ' + + 'Fensterhöhe, nicht in Bildpunkten — sonst müsste man es an jedem ' + + 'Bildschirm neu einstellen. Am Telefon fällt jede Stufe eine Spur ' + + 'flacher aus — dort wäre von der ersten Plakatreihe sonst nichts ' + + 'mehr zu sehen.', + () => dialog({ + titel: 'Höhe des Aufmachers', + text: 'Gilt für alle auf diesem Server.', + felder: [{ + name: 'stufe', beschriftung: 'Höhe', auswahl: stufen, wert: jetzige, + }], + bestaetigen: 'Übernehmen', + tun: async wert => { await aufmacherhoeheSetzen(wert('stufe')); location.reload(); }, + }))); + + /* ---------- Saisonale Reihen ---------- */ + + // **Eine Zeile je Anlass, als Text.** Ein Listeneditor mit Zeilen zum + // Hinzufügen, Verschieben und Löschen wäre viel Oberfläche für etwas, + // das man dreimal im Jahr anfasst. Vier Felder, durch Striche + // getrennt, kann man lesen und in zehn Sekunden ändern — und es ist + // dieselbe Form, in der es gespeichert wird. + const anlaesse = await saisonanlaesse().catch(() => []); + const heute = new Date(); + const jetzt = `${String(heute.getMonth() + 1).padStart(2, '0')}-${String(heute.getDate()).padStart(2, '0')}`; + const laufend = anlaesse.find(a => gilt(a, jetzt)); + + liste.appendChild(zeile( + 'Saisonale Reihen', + anlaesse.length === 0 + ? 'Keine' + : `${anlaesse.length} Anlässe` + (laufend ? ` · gerade: ${laufend.name}` : ' · gerade keiner'), + 'Eine Reihe auf der Startseite, die nur zu ihrer Zeit dasteht. ' + + 'Gesucht wird nach Stichwörtern der Metadatenquelle — „Stirb langsam" ' + + 'ist kein Familienfilm und trägt trotzdem „christmas".', + () => dialog({ + titel: 'Saisonale Reihen', + felder: [{ + name: 'liste', + beschriftung: 'Ein Anlass je Zeile', + art: 'mehrzeilig', + zeilen: 8, + wert: anlaesse.map(alsZeile).join('\n'), + hinweis: 'Name | von | bis | Stichwörter | Genres — Datum als MM-TT, ' + + 'Stichwörter und Genres mit Komma getrennt. Ein Zeitraum darf über ' + + 'den Jahreswechsel gehen (12-28 bis 01-02). Genres sind freiwillig.', + }], + bestaetigen: 'Übernehmen', + tun: async wert => { + await saisonanlaesseSetzen( + wert('liste').split('\n').map(z => z.trim()).filter(Boolean).map(ausZeile)); + location.reload(); + }, + }))); + + } +} + +/* ---------- Saisonale Anlässe als Text ---------- */ + +/** „Zu Halloween | 10-01 | 11-01 | halloween, witch | Horror" */ +function alsZeile(a: Saisonanlass): string { + return [a.name, a.von, a.bis, a.stichworte.join(', '), a.genres.join(', ')].join(' | '); +} + +/** Und wieder zurück. Fehlende Felder bleiben leer — der Server weist ab, + * was damit unbrauchbar ist, statt es stillschweigend zu schlucken. */ +function ausZeile(z: string): Saisonanlass { + const [name = '', von = '', bis = '', stichworte = '', genres = ''] = z.split('|').map(t => t.trim()); + const teile = (t: string) => t.split(',').map(x => x.trim()).filter(Boolean); + return { name, von, bis, stichworte: teile(stichworte), genres: teile(genres) }; +} + +/** Ob ein Anlass heute gilt — dieselbe Regel wie auf dem Server. + * + * **Auch über den Jahreswechsel.** „28.12. bis 02.01." ist kein Fehler, + * sondern der Normalfall für Silvester. */ +function gilt(a: Saisonanlass, heute: string): boolean { + const zahl = (t: string) => Number(t.replace('-', '')); + const [von, bis, nun] = [zahl(a.von), zahl(a.bis), zahl(heute)]; + return von <= bis ? nun >= von && nun <= bis : nun >= von || nun <= bis; +} diff --git a/web/app/src/views/einstellungen/index.ts b/web/app/src/views/einstellungen/index.ts index 1216089..2853ece 100644 --- a/web/app/src/views/einstellungen/index.ts +++ b/web/app/src/views/einstellungen/index.ts @@ -11,12 +11,13 @@ import { sprachbereich } from './metadaten'; import { benutzerbereich } from './benutzer'; import { protokollbereich } from './protokolle'; import { wartungsbereich } from './wartung'; +import { darstellungsbereich } from './darstellung'; /** Welcher Teil der Einstellungen offen ist. */ export type Bereich = | 'konto' | 'geschaut' | 'uebersicht' | 'mediatheken' | 'pruefung' | 'zuordnung' - | 'wiedergabe' | 'metadaten' | 'benutzer' | 'wartung' | 'protokolle'; + | 'wiedergabe' | 'metadaten' | 'darstellung' | 'benutzer' | 'wartung' | 'protokolle'; type Eintrag = { wert: Bereich; text: string; bild: IconName }; @@ -34,6 +35,7 @@ const SERVER: Eintrag[] = [ { wert: 'zuordnung', text: 'Zuordnung', bild: 'suche' }, { wert: 'wiedergabe', text: 'Wiedergabe', bild: 'stufen' }, { wert: 'metadaten', text: 'Metadaten', bild: 'stift' }, + { wert: 'darstellung', text: 'Darstellung', bild: 'filme' }, { wert: 'benutzer', text: 'Benutzer', bild: 'serien' }, { wert: 'wartung', text: 'Wartung', bild: 'stufen' }, { wert: 'protokolle', text: 'Protokolle', bild: 'protokoll' }, @@ -164,6 +166,8 @@ export function einstellungenseite( return [sprachbereich()]; case 'benutzer': return [benutzerbereich()]; + case 'darstellung': + return [darstellungsbereich()]; case 'wartung': return [wartungsbereich()]; case 'protokolle': diff --git a/web/app/src/views/einstellungen/metadaten.ts b/web/app/src/views/einstellungen/metadaten.ts index e78ce98..9b987b6 100644 --- a/web/app/src/views/einstellungen/metadaten.ts +++ b/web/app/src/views/einstellungen/metadaten.ts @@ -1,6 +1,6 @@ import { sprachen, uebersetzungenHolen, meineSprache, metadatenEinstellung, - metadatenSetzen, sprachverteilung, abweichendeAuffrischen, + metadatenSetzen, sprachverteilung, abweichendeAuffrischen, quellenProbe, } from '../../api'; import { knopf, fehlerzeile, meldung } from '../../util'; import { dialog } from '../../dialog'; @@ -25,6 +25,100 @@ import { abschnitt, zeile } from './teile'; */ export function sprachbereich(): HTMLElement { const bereich = document.createElement('section'); + /* ---------- Woher die Metadaten kommen ---------- */ + // + // **Hierher und nicht in die Übersicht.** Dort standen die Zugänge + // zwischen Servername, doppelten Einträgen und der Fassung — auf einer + // Seite, die „Übersicht" heisst, während es eine Seite „Metadaten" gibt. + // Eine Einstellung, die man dort sucht und woanders findet, steht am + // falschen Platz. + + bereich.appendChild(abschnitt('Quellen', [pruefknopf()])); + + const quellenliste = document.createElement('div'); + quellenliste.className = 'einstellungen'; + quellenliste.innerHTML = '
'; + bereich.appendChild(quellenliste); + void quellenLaden(); + + const befunde = document.createElement('div'); + befunde.className = 'einstellungen'; + bereich.appendChild(befunde); + + /** Ein Knopf, der die Quellen wirklich fragt. + * + * **„Hinterlegt" ist keine Auskunft.** Ob ein Schlüssel stimmt und ob der + * Dienst antwortet, liess sich vorher nur herausfinden, indem man die + * Metadaten über achthundert Titel auffrischte und danach nachsah, ob + * sich etwas geändert hat. */ + function pruefknopf(): HTMLElement { + const b = knopf('Verbindung prüfen', 'btn'); + b.onclick = async () => { + b.disabled = true; + const vorher = b.textContent; + b.textContent = 'Prüft …'; + befunde.innerHTML = ''; + try { + // `zeile()` ist schon ein `.einstellung` — ein zweites darum + // ergäbe einen Kasten im Kasten. + (await quellenProbe()).forEach(q => { + const name = QUELLEN.find(x => x.kennung === q.source)?.name ?? q.source; + const stand = !q.configured ? 'Nicht eingerichtet' : q.ok ? 'Läuft' : 'Antwortet nicht'; + befunde.appendChild(zeile(`${name}: ${stand}`, q.detail, null)); + }); + } catch (fehler) { + befunde.appendChild(fehlerzeile(meldung(fehler))); + } finally { + b.disabled = false; + b.textContent = vorher; + } + }; + return b; + } + + async function quellenLaden(): Promise { + let stand; + try { stand = await metadatenEinstellung(); } + catch (fehler) { + quellenliste.innerHTML = ''; + quellenliste.appendChild(fehlerzeile(meldung(fehler))); + return; + } + + const hinterlegt: Record = { + tmdb: stand.hasApiKey || stand.usingProjectKey, + fanart: stand.hasFanartKey, + omdb: stand.hasOmdbKey, + }; + + quellenliste.innerHTML = ''; + + QUELLEN.forEach(q => { + const wert = q.kennung === 'tmdb' && stand!.usingProjectKey + ? 'Mitgelieferter Zugang' + : hinterlegt[q.kennung] ? 'Eigener Zugang hinterlegt' : 'Nicht eingerichtet'; + + quellenliste.appendChild(zeile(q.name, wert, q.was, () => dialog({ + titel: q.name, + felder: [{ + name: 'schluessel', beschriftung: q.feld, art: 'password', + platzhalter: hinterlegt[q.kennung] ? 'Hinterlegt — leer lassen, um ihn zu behalten' : '', + hinweis: q.hinweis, + }], + bestaetigen: 'Speichern', + tun: async wert => { + const eingabe = wert('schluessel'); + if (!eingabe) return; + await metadatenSetzen( + q.kennung === 'tmdb' ? { apiKey: eingabe } + : q.kennung === 'fanart' ? { fanartApiKey: eingabe } + : { omdbApiKey: eingabe }); + await quellenLaden(); + }, + }))); + }); + } + bereich.appendChild(abschnitt('Sprache')); const liste = document.createElement('div'); @@ -175,3 +269,30 @@ export function sprachbereich(): HTMLElement { } +/* ---------- Die Quellen ---------- */ + +/** Was jede Quelle liefert und was sie dafür braucht. */ +const QUELLEN = [ + { + kennung: 'tmdb' as const, + name: 'TMDB', + was: 'Titel, Beschreibungen, Besetzung, Plakate, Stichwörter. Die Hauptquelle.', + feld: 'Eigener Zugang (Read Access Token oder API Key)', + hinweis: 'Leer lassen, um den mitgelieferten zu behalten.', + }, + { + kennung: 'fanart' as const, + name: 'fanart.tv', + was: 'Plakate mit Sprachangabe — für Filme, zu denen TMDB kein deutsches hat. ' + + 'Nur Filme: der Serienweg dort braucht eine TVDB-Kennung, die Flaro nicht führt.', + feld: 'Persönlicher API-Schlüssel', + hinweis: 'Kostenlos, aber persönlich — ein mitgelieferter wäre ein fremder.', + }, + { + kennung: 'omdb' as const, + name: 'OMDb', + was: 'IMDb, Rotten Tomatoes und Metacritic in einem Abruf, über die IMDb-Kennung.', + feld: 'Persönlicher API-Schlüssel', + hinweis: 'Kostenlos bis tausend Abrufe am Tag — deshalb kein mitgelieferter.', + }, +]; diff --git a/web/app/src/views/einstellungen/uebersicht.ts b/web/app/src/views/einstellungen/uebersicht.ts index c336552..5da9d8f 100644 --- a/web/app/src/views/einstellungen/uebersicht.ts +++ b/web/app/src/views/einstellungen/uebersicht.ts @@ -1,8 +1,7 @@ import { - doppelteZusammenlegen, metadatenEinstellung, metadatenSetzen, servernameSetzen, aufmacherhoeheSetzen, + doppelteZusammenlegen, servernameSetzen, systemauskunft, laufendeWiedergaben, serverzustand, bildAdresse, speicherorte, - bildspeicherLeeren, kratzplatzAufraeumen, saisonanlaesse, saisonanlaesseSetzen, - type Wiedergabe, type Saisonanlass, + bildspeicherLeeren, kratzplatzAufraeumen, type Wiedergabe, } from '../../api'; import { esc, dauer, groesse, knopf, fehlerzeile, leer, meldung } from '../../util'; import { dialog } from '../../dialog'; @@ -132,7 +131,7 @@ export function serverbereich(geaendert: () => void): HTMLElement { void (async () => { try { - const [auskunft, stand] = await Promise.all([systemauskunft(), metadatenEinstellung()]); + const auskunft = await systemauskunft(); liste.innerHTML = ''; liste.appendChild(zeile( @@ -145,70 +144,6 @@ export function serverbereich(geaendert: () => void): HTMLElement { tun: async wert => { await servernameSetzen(wert('name')); location.reload(); }, }))); - const stufen = [ - { wert: 'niedrig', text: 'Niedrig' }, - { wert: 'mittel', text: 'Mittel' }, - { wert: 'hoch', text: 'Hoch' }, - { wert: 'sehrhoch', text: 'Sehr hoch' }, - ]; - const jetzige = auskunft.heroHeight ?? 'mittel'; - liste.appendChild(zeile( - 'Höhe des Aufmachers', - stufen.find(s => s.wert === jetzige)?.text ?? 'Mittel', - 'Das grosse Bild oben auf der Startseite. Gemessen wird in ' - + 'Fensterhöhe, nicht in Bildpunkten — sonst müsste man es an jedem ' - + 'Bildschirm neu einstellen. Am Telefon bleibt es flacher: dort ' - + 'wäre von der ersten Plakatreihe sonst nichts mehr zu sehen.', - () => dialog({ - titel: 'Höhe des Aufmachers', - text: 'Gilt für alle auf diesem Server.', - felder: [{ - name: 'stufe', beschriftung: 'Höhe', auswahl: stufen, wert: jetzige, - }], - bestaetigen: 'Übernehmen', - tun: async wert => { await aufmacherhoeheSetzen(wert('stufe')); location.reload(); }, - }))); - - /* ---------- Saisonale Reihen ---------- */ - - // **Eine Zeile je Anlass, als Text.** Ein Listeneditor mit Zeilen zum - // Hinzufügen, Verschieben und Löschen wäre viel Oberfläche für etwas, - // das man dreimal im Jahr anfasst. Vier Felder, durch Striche - // getrennt, kann man lesen und in zehn Sekunden ändern — und es ist - // dieselbe Form, in der es gespeichert wird. - const anlaesse = await saisonanlaesse().catch(() => []); - const heute = new Date(); - const jetzt = `${String(heute.getMonth() + 1).padStart(2, '0')}-${String(heute.getDate()).padStart(2, '0')}`; - const laufend = anlaesse.find(a => gilt(a, jetzt)); - - liste.appendChild(zeile( - 'Saisonale Reihen', - anlaesse.length === 0 - ? 'Keine' - : `${anlaesse.length} Anlässe` + (laufend ? ` · gerade: ${laufend.name}` : ' · gerade keiner'), - 'Eine Reihe auf der Startseite, die nur zu ihrer Zeit dasteht. ' - + 'Gesucht wird nach Stichwörtern der Metadatenquelle — „Stirb langsam" ' - + 'ist kein Familienfilm und trägt trotzdem „christmas".', - () => dialog({ - titel: 'Saisonale Reihen', - felder: [{ - name: 'liste', - beschriftung: 'Ein Anlass je Zeile', - art: 'mehrzeilig', - zeilen: 8, - wert: anlaesse.map(alsZeile).join('\n'), - hinweis: 'Name | von | bis | Stichwörter | Genres — Datum als MM-TT, ' - + 'Stichwörter und Genres mit Komma getrennt. Ein Zeitraum darf über ' - + 'den Jahreswechsel gehen (12-28 bis 01-02). Genres sind freiwillig.', - }], - bestaetigen: 'Übernehmen', - tun: async wert => { - await saisonanlaesseSetzen( - wert('liste').split('\n').map(z => z.trim()).filter(Boolean).map(ausZeile)); - 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. @@ -237,62 +172,6 @@ export function serverbereich(geaendert: () => void): HTMLElement { 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.hasFanartKey ? ' · fanart.tv für Plakate' : '') - + (stand.hasOmdbKey ? ' · OMDb für Bewertungen' : ''), - 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.', - }, - { - name: 'fanart', beschriftung: 'fanart.tv (Plakate)', art: 'password', - platzhalter: stand.hasFanartKey ? 'Hinterlegt — leer lassen, um ihn zu behalten' : 'Persönlicher API-Schlüssel', - // **Warum es diese zweite Quelle gibt.** TMDB hat nicht zu - // jedem Film ein Plakat in jeder Sprache — zu „365 Tage" etwa - // 26 Stück und kein deutsches. Bei fanart.tv trägt jedes - // Plakat seine Sprache, und die Gemeinschaft dort pflegt - // landessprachliche Fassungen. - hinweis: 'Kostenlos, aber persönlich: ein mitgelieferter Schlüssel ' - + 'wäre ein fremder. Ohne ihn bleibt die Quelle aus. Gilt nur ' - + 'für Filme — der Serienweg von fanart.tv braucht eine ' - + 'TVDB-Kennung, die Flaro nicht führt.', - }, - { - name: 'omdb', beschriftung: 'OMDb (Bewertungen)', art: 'password', - platzhalter: stand.hasOmdbKey ? 'Hinterlegt — leer lassen, um ihn zu behalten' : 'Persönlicher API-Schlüssel', - // IMDbs eigene Schnittstelle kostet Geld, Rotten Tomatoes - // vergibt Zugänge nur an Partner, Letterboxd hat gar keine - // offene. OMDb liefert alle drei über die IMDb-Kennung, die - // ohnehin schon an jedem Film steht. - hinweis: 'Liefert IMDb, Rotten Tomatoes und Metacritic in einem ' - + 'Abruf. Kostenlos bis tausend Abrufe am Tag, deshalb kein ' - + 'mitgelieferter Schlüssel.', - }, - ], - bestaetigen: 'Speichern', - tun: async wert => { - await metadatenSetzen({ - apiKey: wert('schluessel') || undefined, - fanartApiKey: wert('fanart') || undefined, - omdbApiKey: wert('omdb') || undefined, - }); - location.reload(); - }, - }))); - // **Fassung und Build nebeneinander.** // // Die Fassung sagt, welcher Stand gemeint ist; der Build sagt, welches @@ -428,28 +307,3 @@ export function speicherbereich(): HTMLElement { return bereich; } - -/* ---------- Saisonale Anlässe als Text ---------- */ - -/** „Zu Halloween | 10-01 | 11-01 | halloween, witch | Horror" */ -function alsZeile(a: Saisonanlass): string { - return [a.name, a.von, a.bis, a.stichworte.join(', '), a.genres.join(', ')].join(' | '); -} - -/** Und wieder zurück. Fehlende Felder bleiben leer — der Server weist ab, - * was damit unbrauchbar ist, statt es stillschweigend zu schlucken. */ -function ausZeile(z: string): Saisonanlass { - const [name = '', von = '', bis = '', stichworte = '', genres = ''] = z.split('|').map(t => t.trim()); - const teile = (t: string) => t.split(',').map(x => x.trim()).filter(Boolean); - return { name, von, bis, stichworte: teile(stichworte), genres: teile(genres) }; -} - -/** Ob ein Anlass heute gilt — dieselbe Regel wie auf dem Server. - * - * **Auch über den Jahreswechsel.** „28.12. bis 02.01." ist kein Fehler, - * sondern der Normalfall für Silvester. */ -function gilt(a: Saisonanlass, heute: string): boolean { - const zahl = (t: string) => Number(t.replace('-', '')); - const [von, bis, nun] = [zahl(a.von), zahl(a.bis), zahl(heute)]; - return von <= bis ? nun >= von && nun <= bis : nun >= von || nun <= bis; -}