Zwei Titel je Film, und die Sprache lässt sich wählen

Die Mediathek sah zweisprachig aus — "Despicable Me" neben "Ich einfach
unverbesserlich". Es gab nur ein Titelfeld, und welche Sprache darin
landete, entschied die Einstellung zum Zeitpunkt des Scans.

Movie und Series tragen jetzt zusätzlich OriginalTitle. Gesucht wird über
beide: wer "Despicable Me" tippt, findet "Ich einfach unverbesserlich".
Auf der Detailseite steht der Originaltitel unter dem übersetzten, wenn er
abweicht.

Die Sprache der Metadaten ist in der Verwaltung eine Auswahl statt eines
Textfelds. Vorher musste man "de-DE" wissen, und ein Tippfehler darin nahm
TMDB stillschweigend als Englisch an.

Dazu eine eigene Sprache je Benutzer. Das Ehrliche daran steht im Dialog:
die Daten liegen in einer Sprache vor, und wer eine andere wählt, bekommt,
was übersetzt vorliegt — für den Rest die Serversprache, nicht ein leeres
Feld. ItemTranslation hält die zweite Sprache, gefüllt über eine
Sammelaktion. Nicht nebenbei beim Öffnen: das verteilte die Wartezeit über
Wochen und träfe immer den, der gerade etwas sucht.

Alle Sprachen für alle Titel vorzuhalten wäre der Bestand mal Sprachen an
Abrufen und Speicher, für einen Haushalt mit zweien. Deshalb auf Ansage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-04 12:23:47 +02:00
co-authored by Claude Opus 5
parent f359807561
commit 921533050b
20 changed files with 1533 additions and 26 deletions
@@ -190,7 +190,8 @@ public static class AuthEndpoints
LastSeenAt = jetzt,
});
return new LoginResponse(token, new AuthenticatedUser(benutzer.Id, benutzer.UserName, benutzer.IsAdministrator));
return new LoginResponse(token, new AuthenticatedUser(
benutzer.Id, benutzer.UserName, benutzer.IsAdministrator, benutzer.Language));
}
private const string FlaroTokenCookie = TokenAuthenticationHandler.CookieName;
+17 -3
View File
@@ -26,7 +26,10 @@ public sealed record LoginRequest(
string UserName, string Password, string DeviceName, string DeviceId,
bool StaySignedIn = true);
public sealed record AuthenticatedUser(Guid Id, string UserName, bool IsAdministrator);
public sealed record AuthenticatedUser(
Guid Id, string UserName, bool IsAdministrator,
/// <summary>Die bevorzugte Sprache — <c>null</c> heisst: die des Servers.</summary>
string? Language = null);
public sealed record LoginResponse(string Token, AuthenticatedUser User);
@@ -58,6 +61,11 @@ public sealed record RenameLibraryRequest(string Name);
public sealed record CreateLibraryRequest(string Name, string Kind, string Path);
public sealed record SprachRequest(string? Language);
/// <summary>Welche Sprache gilt — und wie weit sie abgedeckt ist.</summary>
public sealed record SprachStand(string? Language, int Translated, int Total);
/// <summary>Ein Protokolltag.</summary>
public sealed record Protokolltag(string Date, long Bytes);
@@ -135,7 +143,10 @@ public sealed record MovieResponse(
/// von denen keiner angezeigt wird.
/// </remarks>
IReadOnlyList<string>? Genres = null,
IReadOnlyList<CreditResponse>? Credits = null);
IReadOnlyList<CreditResponse>? Credits = null,
/// <summary>Der Titel in der Sprache des Originals, wenn er abweicht.</summary>
string? OriginalTitle = null);
public sealed record CreditResponse(
Guid Id, string Name, string Role, string? Character, string? Image);
@@ -158,7 +169,10 @@ public sealed record SeriesResponse(
string? Tagline = null,
string? ContentRating = null,
IReadOnlyList<string>? Genres = null,
IReadOnlyList<CreditResponse>? Credits = null);
IReadOnlyList<CreditResponse>? Credits = null,
/// <summary>Der Titel in der Sprache des Originals, wenn er abweicht.</summary>
string? OriginalTitle = null);
public sealed record EpisodeResponse(
Guid Id, int Season, int Episode, string? Title, string? Overview, string? StillImage,
@@ -181,16 +181,38 @@ public static class LibraryContentEndpoints
}).WithName("Scanstand").RequireAuthorization();
}
/// <summary>Die Sprache dieses Benutzers — oder <c>null</c> für die des Servers.</summary>
///
/// <remarks>
/// <b>Einmal je Anfrage, nicht je Zeile.</b> Ein Abruf für fünfzig Titel
/// wäre fünfzig Abfragen für eine Angabe, die sich dabei nicht ändert.
/// </remarks>
private static async Task<string?> SpracheVon(ClaimsPrincipal wer, FlaroContext db)
{
if (PlaybackEndpoints.Benutzer(wer) is not { } kennung) return null;
return await db.Users.Where(u => u.Id == kennung).Select(u => u.Language).FirstOrDefaultAsync();
}
private static void MapBrowse(IEndpointRouteBuilder app)
{
app.MapGet("/api/v1/movies", async (
ClaimsPrincipal wer, FlaroContext db,
Guid? library, string? search, string? sort, string? genre, int skip = 0, int take = 50) =>
{
var sprache = await SpracheVon(wer, db);
var abfrage = db.Movies.AsNoTracking();
if (library is { } kennung) abfrage = abfrage.Where(m => m.LibraryId == kennung);
// **Über beide Titel.** Wer „Despicable Me" tippt, soll „Ich einfach
// unverbesserlich" finden — und umgekehrt. Nur über den
// angezeigten zu suchen hiesse, die halbe Mediathek unauffindbar
// zu machen, sobald jemand den anderen Namen im Kopf hat.
if (!string.IsNullOrWhiteSpace(search))
abfrage = abfrage.Where(m => EF.Functions.Like(m.Title, $"%{search.Trim()}%"));
{
var muster = $"%{search.Trim()}%";
abfrage = abfrage.Where(m =>
EF.Functions.Like(m.Title, muster)
|| (m.OriginalTitle != null && EF.Functions.Like(m.OriginalTitle, muster)));
}
if (!string.IsNullOrWhiteSpace(genre))
{
var gesucht = genre.Trim();
@@ -213,7 +235,22 @@ public static class LibraryContentEndpoints
.Skip(Math.Max(0, skip)).Take(Math.Clamp(take, 1, HöchstensJeSeite))
.Select(m => new
{
m.Id, m.Title, m.Year, m.Overview, m.PosterImage, m.BackdropImage,
m.Id,
// **Die Übersetzung sticht, wenn es eine gibt.** Fehlt sie,
// steht der Titel in der Sprache des Servers da — nicht
// leer. Ein Film ohne englische Übersetzung heisst dann
// eben weiter „Das Boot", und das ist richtiger als eine
// Lücke.
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,
Overview = sprache == null ? m.Overview
: db.Translations
.Where(t => t.MovieId == m.Id && t.Language == sprache)
.Select(t => t.Overview).FirstOrDefault() ?? m.Overview,
m.PosterImage, m.BackdropImage,
m.LogoImage, m.RuntimeMinutes, m.Rating, m.ContentRating,
})
.ToListAsync();
@@ -234,7 +271,8 @@ public static class LibraryContentEndpoints
dateien.Where(f => f.MovieId == m.Id)
.Select(f => Datei(f.Id, f.Path, f.SizeBytes, f.Container, f.Duration, spuren)).ToList(),
stände.GetValueOrDefault(m.Id),
m.LogoImage, m.RuntimeMinutes, m.Rating, null, m.ContentRating)).ToList();
m.LogoImage, m.RuntimeMinutes, m.Rating, null, m.ContentRating,
OriginalTitle: m.OriginalTitle)).ToList();
return Results.Ok(new Page<MovieResponse>(items, gesamt));
}).RequireAuthorization();
@@ -243,10 +281,16 @@ public static class LibraryContentEndpoints
ClaimsPrincipal wer, FlaroContext db,
Guid? library, string? search, string? sort, string? genre, int skip = 0, int take = 50) =>
{
var sprache = await SpracheVon(wer, db);
var abfrage = db.Series.AsNoTracking();
if (library is { } kennung) abfrage = abfrage.Where(s => s.LibraryId == kennung);
if (!string.IsNullOrWhiteSpace(search))
abfrage = abfrage.Where(s => EF.Functions.Like(s.Title, $"%{search.Trim()}%"));
{
var muster = $"%{search.Trim()}%";
abfrage = abfrage.Where(x =>
EF.Functions.Like(x.Title, muster)
|| (x.OriginalTitle != null && EF.Functions.Like(x.OriginalTitle, muster)));
}
if (!string.IsNullOrWhiteSpace(genre))
{
var gesucht = genre.Trim();
@@ -266,7 +310,17 @@ public static class LibraryContentEndpoints
.Skip(Math.Max(0, skip)).Take(Math.Clamp(take, 1, HöchstensJeSeite))
.Select(s => new
{
s.Id, s.Title, s.Year, s.Overview, s.PosterImage, s.BackdropImage,
s.Id,
Title = sprache == null ? s.Title
: db.Translations
.Where(t => t.SeriesId == s.Id && t.Language == sprache)
.Select(t => t.Title).FirstOrDefault() ?? s.Title,
s.OriginalTitle, s.Year,
Overview = sprache == null ? s.Overview
: db.Translations
.Where(t => t.SeriesId == s.Id && t.Language == sprache)
.Select(t => t.Overview).FirstOrDefault() ?? s.Overview,
s.PosterImage, s.BackdropImage,
s.LogoImage, s.RuntimeMinutes, s.Rating, s.ContentRating,
})
.ToListAsync();
@@ -289,7 +343,8 @@ public static class LibraryContentEndpoints
s.Id, s.Title, s.Year, s.Overview, s.PosterImage, s.BackdropImage, meine.Count,
meine.Select(f => f.SeasonNumber).Distinct().Order().ToList(),
meine.Count(f => !gesehen.Contains(f.Id)),
s.LogoImage, s.RuntimeMinutes, s.Rating, null, s.ContentRating);
s.LogoImage, s.RuntimeMinutes, s.Rating, null, s.ContentRating,
OriginalTitle: s.OriginalTitle);
}).ToList();
return Results.Ok(new Page<SeriesResponse>(items, gesamt));
@@ -338,13 +393,20 @@ public static class LibraryContentEndpoints
var spuren = await SpurenZu(db, dateien.Select(f => f.Id).ToList());
var stände = await ZuständeZu(db, wer, filme: [id]);
var sprache = await SpracheVon(wer, db);
var uebersetzt = sprache == null ? null : await db.Translations.AsNoTracking()
.FirstOrDefaultAsync(t => t.MovieId == id && t.Language == sprache);
return Results.Ok(new MovieResponse(
film.Id, film.Title, film.Year, film.Overview, film.PosterImage, film.BackdropImage,
film.Id, uebersetzt?.Title ?? film.Title, film.Year,
uebersetzt?.Overview ?? film.Overview, film.PosterImage, film.BackdropImage,
dateien.Select(f => Datei(f.Id, f.Path, f.SizeBytes, f.Container, f.Duration, spuren)).ToList(),
stände.GetValueOrDefault(id),
film.LogoImage, film.RuntimeMinutes, film.Rating, film.Tagline, film.ContentRating,
film.LogoImage, film.RuntimeMinutes, film.Rating,
uebersetzt?.Tagline ?? film.Tagline, film.ContentRating,
await GenresZu(db, film: id, serie: null),
await MitwirkendeZu(db, film: id, serie: null)));
await MitwirkendeZu(db, film: id, serie: null),
OriginalTitle: film.OriginalTitle));
}).RequireAuthorization();
app.MapGet("/api/v1/series/{id:guid}", async (
@@ -361,13 +423,20 @@ public static class LibraryContentEndpoints
.ToListAsync();
var gesehen = await GeseheneFolgen(db, wer, folgen.Select(f => f.Id).ToList());
var sprache = await SpracheVon(wer, db);
var uebersetzt = sprache == null ? null : await db.Translations.AsNoTracking()
.FirstOrDefaultAsync(t => t.SeriesId == id && t.Language == sprache);
return Results.Ok(new SeriesResponse(
serie.Id, serie.Title, serie.Year, serie.Overview, serie.PosterImage, serie.BackdropImage,
serie.Id, uebersetzt?.Title ?? serie.Title, serie.Year,
uebersetzt?.Overview ?? serie.Overview, serie.PosterImage, serie.BackdropImage,
folgen.Count, folgen.Select(f => f.SeasonNumber).Distinct().Order().ToList(),
folgen.Count(f => !gesehen.Contains(f.Id)),
serie.LogoImage, serie.RuntimeMinutes, serie.Rating, serie.Tagline, serie.ContentRating,
serie.LogoImage, serie.RuntimeMinutes, serie.Rating,
uebersetzt?.Tagline ?? serie.Tagline, serie.ContentRating,
await GenresZu(db, film: null, serie: id),
await MitwirkendeZu(db, film: null, serie: id)));
await MitwirkendeZu(db, film: null, serie: id),
OriginalTitle: serie.OriginalTitle));
}).RequireAuthorization();
// **Die nächste Folge nach dieser.** Reihenfolge ist Staffel, dann
@@ -1,3 +1,4 @@
using System.Security.Claims;
using Flaro.Core.Users;
using Flaro.Core;
using Flaro.Metadata;
@@ -126,6 +127,44 @@ public static class SystemEndpoints
: Results.NotFound();
}).RequireAuthorization(FlaroPolicies.Administrator);
// **Die eigene Sprache.** Metadaten liegen in der des Servers vor; was
// hier eingestellt wird, greift nur, soweit Übersetzungen da sind.
// Deshalb kommt die Abdeckung gleich mit — ohne sie wäre „Englisch"
// ein Versprechen, das der Server nicht hält.
app.MapPut("/api/v1/users/me/language", async (
SprachRequest anfrage, ClaimsPrincipal wer, FlaroContext db,
Uebersetzer uebersetzer, CancellationToken ct) =>
{
if (PlaybackEndpoints.Benutzer(wer) is not { } kennung) return Results.Unauthorized();
var benutzer = await db.Users.FirstOrDefaultAsync(u => u.Id == kennung, ct);
if (benutzer is null) return Results.NotFound();
var sprache = anfrage.Language?.Trim();
benutzer.Language = string.IsNullOrEmpty(sprache) ? null : sprache;
await db.SaveChangesAsync(ct);
if (benutzer.Language is null) return Results.Ok(new SprachStand(null, 0, 0));
var abdeckung = await uebersetzer.AbdeckungAsync(benutzer.Language, ct);
return Results.Ok(new SprachStand(benutzer.Language, abdeckung.Vorhanden, abdeckung.Gesamt));
}).RequireAuthorization();
// Die Sammelaktion dazu — Administratoren, weil sie je Titel eine
// Anfrage an TMDB kostet.
app.MapPost("/api/v1/system/translations", async (
SprachRequest anfrage, Uebersetzer uebersetzer, Tagebuch tagebuch, CancellationToken ct) =>
{
var sprache = anfrage.Language?.Trim();
if (string.IsNullOrEmpty(sprache))
return Results.BadRequest(new { fehler = "Ohne Sprache gibt es nichts zu holen." });
tagebuch.Schreiben($"Übersetzungen für {sprache} werden geholt.");
var bericht = await uebersetzer.HolenAsync(sprache, null, ct);
tagebuch.Schreiben(
$"Übersetzungen für {sprache}: {bericht.Gesamt} geholt, {bericht.Fehlend} ohne Treffer.");
return Results.Ok(bericht);
}).RequireAuthorization(FlaroPolicies.Administrator);
app.MapPut("/api/v1/system/name", async (ServerNameRequest anfrage, FlaroContext db) =>
{
var name = anfrage.Name.Trim();