„Hinterlegt" war keine Auskunft, und die Übersicht keine Ablage

**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 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-08 22:11:55 +02:00
co-authored by Claude Opus 5
parent e066f5fe87
commit 5dfc22bdce
11 changed files with 483 additions and 160 deletions
+9
View File
@@ -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<Quellenbefund[]>('/settings/metadata/test', { method: 'POST' });
export const metadatenSetzen = (werte: {
apiKey?: string; language?: string; fanartApiKey?: string; omdbApiKey?: string;
}) =>
+36 -8
View File
@@ -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; }
@@ -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 = '<div class="spinner"></div>';
bereich.appendChild(liste);
void laden();
return bereich;
async function laden(): Promise<void> {
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;
}
+5 -1
View File
@@ -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':
+122 -1
View File
@@ -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 = '<div class="spinner"></div>';
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<void> {
let stand;
try { stand = await metadatenEinstellung(); }
catch (fehler) {
quellenliste.innerHTML = '';
quellenliste.appendChild(fehlerzeile(meldung(fehler)));
return;
}
const hinterlegt: Record<string, boolean> = {
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.',
},
];
+3 -149
View File
@@ -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;
}