From e1399c808f91fdc281082f5344f24a11b313d831 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Sun, 4 Oct 2026 12:37:55 +0200 Subject: [PATCH] =?UTF-8?q?Die=20Detailseite=20tr=C3=A4gt=20wieder?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drei Dinge, eins davon war mein Fehlbefund: Regie und Drehbuch werden sehr wohl angezeigt — nur standen sie irgendwo zwischen dreissig Gesichtern. Jetzt kommt der Stab zuerst, weil man beim Suchen eher die Regie im Kopf hat als den zwölften Darsteller. Bei Zeichentrick hängt TMDB "(voice)" an den Rollennamen, und so stand es auch da — englisch, mitten im deutschen Text. Daraus wird "Stimme: Gru". Ähnliche Titel als eigene Reihe, nachgeladen, damit die Seite sofort steht. Aus dem eigenen Bestand gerechnet, nicht aus TMDBs Empfehlungsliste: ein Vorschlag, den man nicht abspielen kann, ist Werbung. Gemeinsame Menschen wiegen dreifach gegenüber gemeinsamen Genres — dass zwei Filme "Drama" sind, sagt wenig; dieselbe Regie sagt viel. Kostet keine einzige fremde Anfrage, der Katalog steht ohnehin in der Datenbank. Findet sich nichts, erscheint die Reihe gar nicht. Und der Hintergrund: er begann bei left: 240px, also hinter der Seitenleiste, und war mit opacity .28 und blur(2px) weder Bild noch Fläche. Jetzt über die volle Breite, kräftiger unscharf — er taucht die Seite in die Farbe des Films, so wie bei Plex. Co-Authored-By: Claude Opus 5 --- .../Endpoints/LibraryContentEndpoints.cs | 81 +++++++++++++++++++ web/app/src/api.ts | 4 + web/app/src/style.css | 14 +++- web/app/src/views/detail.ts | 45 ++++++++++- 4 files changed, 138 insertions(+), 6 deletions(-) diff --git a/server/src/Flaro.Api/Endpoints/LibraryContentEndpoints.cs b/server/src/Flaro.Api/Endpoints/LibraryContentEndpoints.cs index 611b2e1..15a77f0 100644 --- a/server/src/Flaro.Api/Endpoints/LibraryContentEndpoints.cs +++ b/server/src/Flaro.Api/Endpoints/LibraryContentEndpoints.cs @@ -189,6 +189,52 @@ public static class LibraryContentEndpoints }).WithName("Scanstand").RequireAuthorization(); } + /// Ähnliche Titel — aus dem eigenen Bestand, nicht aus dem Netz. + /// + /// + /// + /// Nur, was man besitzt. Ein Vorschlag, den man nicht abspielen + /// kann, ist Werbung. TMDB hätte eine Empfehlungsliste, aber die besteht + /// zur Hälfte aus Filmen, die hier nicht liegen. + /// + /// + /// Aus Genres und Menschen, ohne eine einzige fremde Anfrage. Der + /// Katalog steht ohnehin in der Datenbank. Ein gemeinsamer Mensch wiegt + /// schwerer als ein gemeinsames Genre: dass zwei Filme „Drama" sind, sagt + /// wenig; dass beide von derselben Regie stammen, sagt viel. + /// + /// + private static async Task> AehnlicheAsync( + FlaroContext db, Guid filmId, int wieViele) + { + var genres = await db.GenreLinks.Where(g => g.MovieId == filmId) + .Select(g => g.GenreId).ToListAsync(); + var leute = await db.Credits.Where(c => c.MovieId == filmId) + .Select(c => c.PersonId).ToListAsync(); + + if (genres.Count == 0 && leute.Count == 0) return []; + + var bibliothek = await db.Movies.Where(m => m.Id == filmId) + .Select(m => m.LibraryId).FirstOrDefaultAsync(); + + var punkte = await db.Movies + .Where(m => m.Id != filmId && m.LibraryId == bibliothek) + .Select(m => new + { + m.Id, + Punkte = + db.GenreLinks.Count(g => g.MovieId == m.Id && genres.Contains(g.GenreId)) + + db.Credits.Count(c => c.MovieId == m.Id && leute.Contains(c.PersonId)) * 3, + }) + .Where(x => x.Punkte > 0) + .OrderByDescending(x => x.Punkte) + .Take(wieViele) + .Select(x => x.Id) + .ToListAsync(); + + return punkte; + } + /// Die Sprache dieses Benutzers — oder null für die des Servers. /// /// @@ -386,6 +432,41 @@ public static class LibraryContentEndpoints // **Einzelabruf, weil ein Client nicht immer aus einer Liste kommt.** // Ein Link, ein Wiederaufnehmen nach Neustart, ein Eintrag aus // „Weiterschauen" — in all diesen Fällen hat er nur die Kennung. + // Ähnliche Titel als eigener Abruf: die Detailseite soll stehen, + // bevor diese Rechnung durch ist. + app.MapGet("/api/v1/movies/{id:guid}/similar", async ( + Guid id, ClaimsPrincipal wer, FlaroContext db, int take = 12) => + { + var kennungen = await AehnlicheAsync(db, id, Math.Clamp(take, 1, 24)); + if (kennungen.Count == 0) return Results.Ok(new Page([], 0)); + + var sprache = await SpracheVon(wer, db); + var filme = await db.Movies.AsNoTracking() + .Where(m => kennungen.Contains(m.Id)) + .Select(m => new + { + m.Id, + Title = sprache == null ? m.Title + : db.Translations.Where(t => t.MovieId == m.Id && t.Language == sprache) + .Select(t => t.Title).FirstOrDefault() ?? m.Title, + m.OriginalTitle, m.Year, m.PosterImage, m.BackdropImage, m.LogoImage, + m.RuntimeMinutes, m.Rating, m.ContentRating, + }) + .ToListAsync(); + + // In der Reihenfolge der Punkte, nicht in der der Datenbank. + var items = kennungen + .Select(k => filme.FirstOrDefault(f => f.Id == k)) + .Where(f => f != null) + .Select(f => new MovieResponse( + f!.Id, f.Title, f.Year, null, f.PosterImage, f.BackdropImage, + [], null, f.LogoImage, f.RuntimeMinutes, f.Rating, null, f.ContentRating, + OriginalTitle: f.OriginalTitle)) + .ToList(); + + return Results.Ok(new Page(items, items.Count)); + }).RequireAuthorization(); + app.MapGet("/api/v1/movies/{id:guid}", async ( Guid id, ClaimsPrincipal wer, FlaroContext db) => { diff --git a/web/app/src/api.ts b/web/app/src/api.ts index 6aac59a..8263cea 100644 --- a/web/app/src/api.ts +++ b/web/app/src/api.ts @@ -475,6 +475,10 @@ export const uebersetzungenHolen = (language: string) => anfrage('/system/translations', { method: 'POST', body: JSON.stringify({ language }) }); +/** Ähnliche Titel aus dem eigenen Bestand — nie etwas, das man nicht hat. */ +export const aehnliche = (id: string) => + anfrage>(`/movies/${id}/similar?take=12`); + export type Protokolltag = { date: string; bytes: number }; /** Welche Tage protokolliert sind, neueste zuerst. */ diff --git a/web/app/src/style.css b/web/app/src/style.css index 121cdc3..2ea45d9 100644 --- a/web/app/src/style.css +++ b/web/app/src/style.css @@ -417,12 +417,20 @@ svg { width: 20px; height: 20px; display: block; flex: none; } /* **Der Hintergrund füllt die Seite, nicht nur einen Streifen.** Bei Plex liegt er unscharf und abgedunkelt hinter allem und gibt der Seite die Farbe des Films. */ +/* **Über die volle Breite, nicht erst hinter der Seitenleiste.** + * + * Vorher begann das Bild bei `left: 240px` und war mit `opacity: .28` und + * `blur(2px)` weder Bild noch Fläche — ein grau verwischter Rest, der nichts + * tat. Jetzt taucht es die Seite in die Farbe des Films, so wie bei Plex. */ .detail-hintergrund { - position: fixed; top: var(--topbar-h); left: var(--sidebar-w); right: 0; bottom: 0; + position: fixed; inset: 0; z-index: -1; background-size: cover; background-position: center top; - opacity: .28; filter: blur(2px); - mask-image: linear-gradient(180deg, #000 0%, rgba(0,0,0,.45) 55%, transparent 92%); + opacity: .45; filter: blur(28px) saturate(1.1); + /* Weiter hochgezogen als das Bild selbst: die Unschärfe würde sonst am + Rand als harte Kante sichtbar. */ + transform: scale(1.1); + mask-image: linear-gradient(180deg, #000 0%, rgba(0,0,0,.55) 55%, transparent 92%); } .detail-kopf { diff --git a/web/app/src/views/detail.ts b/web/app/src/views/detail.ts index e2aa41b..e2a7bf4 100644 --- a/web/app/src/views/detail.ts +++ b/web/app/src/views/detail.ts @@ -2,10 +2,11 @@ import { film as filmLaden, serie as serieLaden, folgen, lieferung, downloadAdresse, vorschlaege, zuordnen, bildAdresse, istAdmin, fortschritt, gesehen, favorit, weglegen, type Film, type Serie, type Folge, type Datei, type Lieferung, type Stand, - type Mitwirkender, + type Mitwirkender, aehnliche, } from '../api'; import { esc, dauer, groesse, knopf, feld, fehlerzeile, leer, anteil } from '../util'; import { icons, icon } from '../icons'; +import { kachel, alsKachel, reihe } from '../card'; type Art = 'filme' | 'serien'; @@ -32,6 +33,7 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem kopfbereich(eintrag, eintrag.state, eintrag.files[0] ?? null), dateiliste(eintrag.files, eintrag.id, eintrag.state), besetzung(eintrag.credits), + aehnlichereihe(eintrag.id), zuordnungsbereich(eintrag.title, eintrag.year)); } else { const eintrag = await serieLaden(id); @@ -287,6 +289,38 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem /* ---------- Besetzung ---------- */ + /** Ähnliche Titel — nachgeladen, damit die Seite sofort steht. + * + * **Nur, was tatsächlich hier liegt.** Ein Vorschlag, den man nicht + * abspielen kann, ist Werbung. Findet sich nichts, erscheint die Reihe gar + * nicht — eine leere Überschrift ist schlechter als keine. */ + function aehnlichereihe(filmId: string): HTMLElement { + const platz = document.createElement('div'); + + void (async () => { + try { + const seite = await aehnliche(filmId); + if (!seite.items.length) return; + platz.appendChild(reihe('Ähnliche Titel', + seite.items.map(f => kachel(alsKachel(f), + (a, i) => { location.hash = `#/${a}/${i}`; })))); + } catch { /* Ohne Vorschläge ist die Seite vollständig genug. */ } + })(); + + return platz; + } + + /** Aus „Gru (voice)" wird „Stimme: Gru". + * + * **Bei Zeichentrick ist das die eigentliche Auskunft.** TMDB hängt den + * Hinweis in Englisch an den Rollennamen, und so stand er auch in der + * Oberfläche — mitten im deutschen Text. */ + function rollenname(character: string | null): string { + if (!character) return ''; + const stimme = character.match(/^(.*?)\s*\((?:voice|Stimme)\)\s*$/i); + return stimme ? `Stimme: ${stimme[1]!.trim()}` : character; + } + function besetzung(mitwirkende: Mitwirkender[] | null): HTMLElement { const bereich = document.createElement('section'); if (!mitwirkende?.length) return bereich; @@ -297,7 +331,12 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem const reihe = document.createElement('div'); reihe.className = 'besetzung'; - mitwirkende.forEach(m => { + // **Stab zuerst, dann Darsteller.** Wer einen Film sucht, sucht oft die + // Regie — und die stand vorher irgendwo zwischen dreissig Gesichtern. + const ordnung = { Director: 0, Writer: 1 } as Record; + [...mitwirkende] + .sort((a, b) => (ordnung[a.role] ?? 2) - (ordnung[b.role] ?? 2)) + .forEach(m => { const k = document.createElement('div'); k.className = 'kopf-kachel'; @@ -318,7 +357,7 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem const rolle = m.role === 'Director' ? 'Regie' : m.role === 'Writer' ? 'Drehbuch' - : m.character ?? ''; + : rollenname(m.character); k.innerHTML += ''; k.append(portrait);