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; -}