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