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