Die Detailseite trägt wieder
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
11a44f6a90
commit
e1399c808f
@@ -189,6 +189,52 @@ public static class LibraryContentEndpoints
|
|||||||
}).WithName("Scanstand").RequireAuthorization();
|
}).WithName("Scanstand").RequireAuthorization();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Ähnliche Titel — aus dem eigenen Bestand, nicht aus dem Netz.</summary>
|
||||||
|
///
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Nur, was man besitzt.</b> 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.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Aus Genres und Menschen, ohne eine einzige fremde Anfrage.</b> 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.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private static async Task<List<Guid>> 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;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Die Sprache dieses Benutzers — oder <c>null</c> für die des Servers.</summary>
|
/// <summary>Die Sprache dieses Benutzers — oder <c>null</c> für die des Servers.</summary>
|
||||||
///
|
///
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
@@ -386,6 +432,41 @@ public static class LibraryContentEndpoints
|
|||||||
// **Einzelabruf, weil ein Client nicht immer aus einer Liste kommt.**
|
// **Einzelabruf, weil ein Client nicht immer aus einer Liste kommt.**
|
||||||
// Ein Link, ein Wiederaufnehmen nach Neustart, ein Eintrag aus
|
// Ein Link, ein Wiederaufnehmen nach Neustart, ein Eintrag aus
|
||||||
// „Weiterschauen" — in all diesen Fällen hat er nur die Kennung.
|
// „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<MovieResponse>([], 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<MovieResponse>(items, items.Count));
|
||||||
|
}).RequireAuthorization();
|
||||||
|
|
||||||
app.MapGet("/api/v1/movies/{id:guid}", async (
|
app.MapGet("/api/v1/movies/{id:guid}", async (
|
||||||
Guid id, ClaimsPrincipal wer, FlaroContext db) =>
|
Guid id, ClaimsPrincipal wer, FlaroContext db) =>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -475,6 +475,10 @@ export const uebersetzungenHolen = (language: string) =>
|
|||||||
anfrage<Uebersetzungsbericht>('/system/translations',
|
anfrage<Uebersetzungsbericht>('/system/translations',
|
||||||
{ method: 'POST', body: JSON.stringify({ language }) });
|
{ method: 'POST', body: JSON.stringify({ language }) });
|
||||||
|
|
||||||
|
/** Ähnliche Titel aus dem eigenen Bestand — nie etwas, das man nicht hat. */
|
||||||
|
export const aehnliche = (id: string) =>
|
||||||
|
anfrage<Seite<Film>>(`/movies/${id}/similar?take=12`);
|
||||||
|
|
||||||
export type Protokolltag = { date: string; bytes: number };
|
export type Protokolltag = { date: string; bytes: number };
|
||||||
|
|
||||||
/** Welche Tage protokolliert sind, neueste zuerst. */
|
/** Welche Tage protokolliert sind, neueste zuerst. */
|
||||||
|
|||||||
+11
-3
@@ -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
|
/* **Der Hintergrund füllt die Seite, nicht nur einen Streifen.** Bei Plex
|
||||||
liegt er unscharf und abgedunkelt hinter allem und gibt der Seite die
|
liegt er unscharf und abgedunkelt hinter allem und gibt der Seite die
|
||||||
Farbe des Films. */
|
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 {
|
.detail-hintergrund {
|
||||||
position: fixed; top: var(--topbar-h); left: var(--sidebar-w); right: 0; bottom: 0;
|
position: fixed; inset: 0;
|
||||||
z-index: -1;
|
z-index: -1;
|
||||||
background-size: cover; background-position: center top;
|
background-size: cover; background-position: center top;
|
||||||
opacity: .28; filter: blur(2px);
|
opacity: .45; filter: blur(28px) saturate(1.1);
|
||||||
mask-image: linear-gradient(180deg, #000 0%, rgba(0,0,0,.45) 55%, transparent 92%);
|
/* 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 {
|
.detail-kopf {
|
||||||
|
|||||||
@@ -2,10 +2,11 @@ import {
|
|||||||
film as filmLaden, serie as serieLaden, folgen, lieferung, downloadAdresse,
|
film as filmLaden, serie as serieLaden, folgen, lieferung, downloadAdresse,
|
||||||
vorschlaege, zuordnen, bildAdresse, istAdmin, fortschritt, gesehen, favorit, weglegen,
|
vorschlaege, zuordnen, bildAdresse, istAdmin, fortschritt, gesehen, favorit, weglegen,
|
||||||
type Film, type Serie, type Folge, type Datei, type Lieferung, type Stand,
|
type Film, type Serie, type Folge, type Datei, type Lieferung, type Stand,
|
||||||
type Mitwirkender,
|
type Mitwirkender, aehnliche,
|
||||||
} from '../api';
|
} from '../api';
|
||||||
import { esc, dauer, groesse, knopf, feld, fehlerzeile, leer, anteil } from '../util';
|
import { esc, dauer, groesse, knopf, feld, fehlerzeile, leer, anteil } from '../util';
|
||||||
import { icons, icon } from '../icons';
|
import { icons, icon } from '../icons';
|
||||||
|
import { kachel, alsKachel, reihe } from '../card';
|
||||||
|
|
||||||
type Art = 'filme' | 'serien';
|
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),
|
kopfbereich(eintrag, eintrag.state, eintrag.files[0] ?? null),
|
||||||
dateiliste(eintrag.files, eintrag.id, eintrag.state),
|
dateiliste(eintrag.files, eintrag.id, eintrag.state),
|
||||||
besetzung(eintrag.credits),
|
besetzung(eintrag.credits),
|
||||||
|
aehnlichereihe(eintrag.id),
|
||||||
zuordnungsbereich(eintrag.title, eintrag.year));
|
zuordnungsbereich(eintrag.title, eintrag.year));
|
||||||
} else {
|
} else {
|
||||||
const eintrag = await serieLaden(id);
|
const eintrag = await serieLaden(id);
|
||||||
@@ -287,6 +289,38 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem
|
|||||||
|
|
||||||
/* ---------- Besetzung ---------- */
|
/* ---------- 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 {
|
function besetzung(mitwirkende: Mitwirkender[] | null): HTMLElement {
|
||||||
const bereich = document.createElement('section');
|
const bereich = document.createElement('section');
|
||||||
if (!mitwirkende?.length) return bereich;
|
if (!mitwirkende?.length) return bereich;
|
||||||
@@ -297,7 +331,12 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem
|
|||||||
const reihe = document.createElement('div');
|
const reihe = document.createElement('div');
|
||||||
reihe.className = 'besetzung';
|
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<string, number>;
|
||||||
|
[...mitwirkende]
|
||||||
|
.sort((a, b) => (ordnung[a.role] ?? 2) - (ordnung[b.role] ?? 2))
|
||||||
|
.forEach(m => {
|
||||||
const k = document.createElement('div');
|
const k = document.createElement('div');
|
||||||
k.className = 'kopf-kachel';
|
k.className = 'kopf-kachel';
|
||||||
|
|
||||||
@@ -318,7 +357,7 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem
|
|||||||
|
|
||||||
const rolle = m.role === 'Director' ? 'Regie'
|
const rolle = m.role === 'Director' ? 'Regie'
|
||||||
: m.role === 'Writer' ? 'Drehbuch'
|
: m.role === 'Writer' ? 'Drehbuch'
|
||||||
: m.character ?? '';
|
: rollenname(m.character);
|
||||||
|
|
||||||
k.innerHTML += '';
|
k.innerHTML += '';
|
||||||
k.append(portrait);
|
k.append(portrait);
|
||||||
|
|||||||
Reference in New Issue
Block a user