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