diff --git a/server/src/Flaro.Api/Endpoints/AuthEndpoints.cs b/server/src/Flaro.Api/Endpoints/AuthEndpoints.cs
index 9980c1d..39468e2 100644
--- a/server/src/Flaro.Api/Endpoints/AuthEndpoints.cs
+++ b/server/src/Flaro.Api/Endpoints/AuthEndpoints.cs
@@ -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;
diff --git a/server/src/Flaro.Api/Endpoints/Contracts.cs b/server/src/Flaro.Api/Endpoints/Contracts.cs
index 1974cb7..a3168ad 100644
--- a/server/src/Flaro.Api/Endpoints/Contracts.cs
+++ b/server/src/Flaro.Api/Endpoints/Contracts.cs
@@ -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,
+ /// Die bevorzugte Sprache — null heisst: die des Servers.
+ 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);
+
+/// Welche Sprache gilt — und wie weit sie abgedeckt ist.
+public sealed record SprachStand(string? Language, int Translated, int Total);
+
/// Ein Protokolltag.
public sealed record Protokolltag(string Date, long Bytes);
@@ -135,7 +143,10 @@ public sealed record MovieResponse(
/// von denen keiner angezeigt wird.
///
IReadOnlyList? Genres = null,
- IReadOnlyList? Credits = null);
+ IReadOnlyList? Credits = null,
+
+ /// Der Titel in der Sprache des Originals, wenn er abweicht.
+ 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? Genres = null,
- IReadOnlyList? Credits = null);
+ IReadOnlyList? Credits = null,
+
+ /// Der Titel in der Sprache des Originals, wenn er abweicht.
+ string? OriginalTitle = null);
public sealed record EpisodeResponse(
Guid Id, int Season, int Episode, string? Title, string? Overview, string? StillImage,
diff --git a/server/src/Flaro.Api/Endpoints/LibraryContentEndpoints.cs b/server/src/Flaro.Api/Endpoints/LibraryContentEndpoints.cs
index 5ec49c6..b385525 100644
--- a/server/src/Flaro.Api/Endpoints/LibraryContentEndpoints.cs
+++ b/server/src/Flaro.Api/Endpoints/LibraryContentEndpoints.cs
@@ -181,16 +181,38 @@ public static class LibraryContentEndpoints
}).WithName("Scanstand").RequireAuthorization();
}
+ /// Die Sprache dieses Benutzers — oder null für die des Servers.
+ ///
+ ///
+ /// Einmal je Anfrage, nicht je Zeile. Ein Abruf für fünfzig Titel
+ /// wäre fünfzig Abfragen für eine Angabe, die sich dabei nicht ändert.
+ ///
+ private static async Task 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(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(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
diff --git a/server/src/Flaro.Api/Endpoints/SystemEndpoints.cs b/server/src/Flaro.Api/Endpoints/SystemEndpoints.cs
index 169e50d..7b19b30 100644
--- a/server/src/Flaro.Api/Endpoints/SystemEndpoints.cs
+++ b/server/src/Flaro.Api/Endpoints/SystemEndpoints.cs
@@ -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();
diff --git a/server/src/Flaro.Api/Program.cs b/server/src/Flaro.Api/Program.cs
index 90a3394..5af312b 100644
--- a/server/src/Flaro.Api/Program.cs
+++ b/server/src/Flaro.Api/Program.cs
@@ -75,6 +75,7 @@ builder.Services.AddSingleton();
builder.Services.AddScoped();
builder.Services.AddScoped();
+builder.Services.AddScoped();
// Metadaten. Der Anbieter steckt hinter einer Schnittstelle, damit alles, was
// ihn benutzt, ohne Netz und ohne Schlüssel geprüft werden kann.
diff --git a/server/src/Flaro.Core/Library/ItemTranslation.cs b/server/src/Flaro.Core/Library/ItemTranslation.cs
new file mode 100644
index 0000000..19b2a81
--- /dev/null
+++ b/server/src/Flaro.Core/Library/ItemTranslation.cs
@@ -0,0 +1,35 @@
+namespace Flaro.Core.Library;
+
+/// Ein Titel in einer anderen Sprache als der des Servers.
+///
+///
+///
+/// Warum nicht einfach alles in allen Sprachen holen. Das hiesse, den
+/// ganzen Bestand je Sprache abzurufen und zu speichern — für einen Haushalt
+/// mit zwei Sprachen ein Vielfaches an Abrufen und Speicher für etwas, das
+/// fast niemand ansieht.
+///
+///
+/// Also auf Abruf. Öffnet jemand mit abweichender Sprache einen Titel,
+/// wird die Übersetzung einmal geholt und bleibt liegen. Was fehlt, erscheint
+/// in der Sprache des Servers — nicht leer.
+///
+///
+public sealed class ItemTranslation
+{
+ public Guid Id { get; init; } = Guid.CreateVersion7();
+
+ public Guid? MovieId { get; init; }
+ public Guid? SeriesId { get; init; }
+ public Guid? EpisodeId { get; init; }
+
+ /// Das Sprachkürzel, so wie es beim Abruf verwendet wurde.
+ public required string Language { get; init; }
+
+ public string? Title { get; set; }
+ public string? Overview { get; set; }
+ public string? Tagline { get; set; }
+
+ /// Wann sie geholt wurde — für ein späteres Auffrischen.
+ public DateTimeOffset FetchedAt { get; set; }
+}
diff --git a/server/src/Flaro.Core/Library/MediaEntities.cs b/server/src/Flaro.Core/Library/MediaEntities.cs
index 03de496..893077c 100644
--- a/server/src/Flaro.Core/Library/MediaEntities.cs
+++ b/server/src/Flaro.Core/Library/MediaEntities.cs
@@ -110,6 +110,17 @@ public sealed class Movie
/// Ohne führenden Artikel — siehe .
public required string SortTitleValue { get; set; }
+ /// Der Titel in der Sprache des Originals.
+ ///
+ ///
+ /// Damit die Mediathek nicht zweisprachig aussieht. Vorher stand
+ /// nur ein Titel da, und welcher es wurde, entschied die Sprache zum
+ /// Zeitpunkt des Scans — „Despicable Me" neben „Ich einfach
+ /// unverbesserlich". Mit beiden lässt sich zeigen, was passt, und über
+ /// beide suchen.
+ ///
+ public string? OriginalTitle { get; set; }
+
public int? Year { get; set; }
public string? Overview { get; set; }
@@ -203,6 +214,17 @@ public sealed class Series
public required string Title { get; set; }
public required string SortTitleValue { get; set; }
+
+ /// Der Titel in der Sprache des Originals.
+ ///
+ ///
+ /// Damit die Mediathek nicht zweisprachig aussieht. Vorher stand
+ /// nur ein Titel da, und welcher es wurde, entschied die Sprache zum
+ /// Zeitpunkt des Scans — „Despicable Me" neben „Ich einfach
+ /// unverbesserlich". Mit beiden lässt sich zeigen, was passt, und über
+ /// beide suchen.
+ ///
+ public string? OriginalTitle { get; set; }
public int? Year { get; set; }
public string? Overview { get; set; }
public int? TmdbId { get; set; }
diff --git a/server/src/Flaro.Core/Metadata/IMetadataProvider.cs b/server/src/Flaro.Core/Metadata/IMetadataProvider.cs
index 6f79600..738016b 100644
--- a/server/src/Flaro.Core/Metadata/IMetadataProvider.cs
+++ b/server/src/Flaro.Core/Metadata/IMetadataProvider.cs
@@ -53,7 +53,10 @@ public sealed record MovieMetadata(
///
/// Nicht jeder Titel hat eines. Fehlt es, steht der Titel als Text da.
///
- RemoteImage? Logo = null);
+ RemoteImage? Logo = null,
+
+ /// Der Titel in der Sprache des Originals.
+ string? OriginalTitle = null);
public sealed record SeriesMetadata(
int ProviderId, string Title, int? Year, string? Overview,
@@ -74,7 +77,10 @@ public sealed record SeriesMetadata(
///
/// Nicht jeder Titel hat eines. Fehlt es, steht der Titel als Text da.
///
- RemoteImage? Logo = null);
+ RemoteImage? Logo = null,
+
+ /// Der Titel in der Sprache des Originals.
+ string? OriginalTitle = null);
public sealed record EpisodeMetadata(string? Title, string? Overview, RemoteImage? Still);
@@ -119,8 +125,28 @@ public interface IMetadataProvider
///
Task GetMovieAsync(int providerId, CancellationToken ct = default);
+ /// Holt einen Titel in einer bestimmten Sprache.
+ ///
+ ///
+ /// Für die Übersetzungstabelle, nicht für den Scan. Der Scan nimmt
+ /// die Sprache des Servers; dies hier füllt nach, was ein Benutzer mit
+ /// abweichender Sprache sehen will.
+ ///
+ ///
+ /// Mit Vorgabe. Ein Anbieter, der keine Sprachen kennt, liefert
+ /// schlicht die des Servers — das ist ehrlicher als eine leere Antwort und
+ /// erspart jedem Test-Doppelgänger eine Pflichtmethode, die er nicht
+ /// braucht.
+ ///
+ Task GetMovieAsync(int providerId, string sprache, CancellationToken ct = default) =>
+ GetMovieAsync(providerId, ct);
+
Task GetSeriesAsync(int providerId, CancellationToken ct = default);
+ /// Holt eine Serie in einer bestimmten Sprache.
+ Task GetSeriesAsync(int providerId, string sprache, CancellationToken ct = default) =>
+ GetSeriesAsync(providerId, ct);
+
/// Mehrere Vorschläge zur Auswahl — für das Korrigieren von Hand.
Task> SearchMoviesAsync(string title, int? year, CancellationToken ct = default);
diff --git a/server/src/Flaro.Core/Users/User.cs b/server/src/Flaro.Core/Users/User.cs
index a2ade8c..06c62a5 100644
--- a/server/src/Flaro.Core/Users/User.cs
+++ b/server/src/Flaro.Core/Users/User.cs
@@ -19,5 +19,16 @@ public sealed class User
public bool IsAdministrator { get; set; }
+ /// Die bevorzugte Sprache — null heisst: die des Servers.
+ ///
+ ///
+ /// Und sie ist nicht kostenlos. Metadaten werden in der Sprache des
+ /// Servers geholt und gespeichert. Wer eine andere wählt, bekommt, was in
+ /// der Übersetzungstabelle liegt — und für den Rest die Serversprache.
+ /// Darauf wird beim Umstellen deutlich hingewiesen; es stillschweigend
+ /// halb zu übersetzen wäre schlechter als gar nicht.
+ ///
+ public string? Language { get; set; }
+
public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow;
}
diff --git a/server/src/Flaro.Data/FlaroContext.cs b/server/src/Flaro.Data/FlaroContext.cs
index 16b26b8..95795f7 100644
--- a/server/src/Flaro.Data/FlaroContext.cs
+++ b/server/src/Flaro.Data/FlaroContext.cs
@@ -38,6 +38,7 @@ public sealed class FlaroContext(DbContextOptions options) : DbCon
public DbSet GenreLinks => Set();
public DbSet People => Set();
public DbSet Credits => Set();
+ public DbSet Translations => Set();
/// Gilt für jede Zeitangabe im Modell, ohne sie einzeln zu nennen.
///
@@ -62,6 +63,7 @@ public sealed class FlaroContext(DbContextOptions options) : DbCon
// Zwei Konten „Guido" und „guido" wären eine Falle, keine Funktion.
e.HasIndex(x => x.UserName).IsUnique();
e.Property(x => x.UserName).HasMaxLength(64).UseCollation("NOCASE");
+ e.Property(x => x.Language).HasMaxLength(16);
e.Property(x => x.PasswordHash).HasMaxLength(256);
});
@@ -142,6 +144,7 @@ public sealed class FlaroContext(DbContextOptions options) : DbCon
.HasFilter("\"TmdbId\" IS NOT NULL");
e.Property(x => x.Title).HasMaxLength(512);
+ e.Property(x => x.OriginalTitle).HasMaxLength(512);
e.Property(x => x.SortTitleValue).HasMaxLength(512);
e.Property(x => x.ImdbId).HasMaxLength(32);
e.Property(x => x.Tagline).HasMaxLength(512);
@@ -170,6 +173,7 @@ public sealed class FlaroContext(DbContextOptions options) : DbCon
.HasFilter("\"TmdbId\" IS NOT NULL");
e.Property(x => x.Title).HasMaxLength(512);
+ e.Property(x => x.OriginalTitle).HasMaxLength(512);
e.Property(x => x.SortTitleValue).HasMaxLength(512);
e.Property(x => x.ImdbId).HasMaxLength(32);
e.Property(x => x.Tagline).HasMaxLength(512);
@@ -234,6 +238,23 @@ public sealed class FlaroContext(DbContextOptions options) : DbCon
e.HasOne().WithMany().HasForeignKey(x => x.SeriesId).OnDelete(DeleteBehavior.Cascade);
});
+ b.Entity(e =>
+ {
+ e.HasKey(x => x.Id);
+ e.Property(x => x.Language).HasMaxLength(16);
+ e.Property(x => x.Title).HasMaxLength(512);
+ e.Property(x => x.Tagline).HasMaxLength(512);
+
+ // Je Titel und Sprache genau eine Zeile — sonst sammeln sich
+ // Übersetzungen an, und welche gezeigt wird, entscheidet der Zufall.
+ e.HasIndex(x => new { x.MovieId, x.Language }).IsUnique()
+ .HasFilter("\"MovieId\" IS NOT NULL");
+ e.HasIndex(x => new { x.SeriesId, x.Language }).IsUnique()
+ .HasFilter("\"SeriesId\" IS NOT NULL");
+ e.HasIndex(x => new { x.EpisodeId, x.Language }).IsUnique()
+ .HasFilter("\"EpisodeId\" IS NOT NULL");
+ });
+
b.Entity(e =>
{
e.HasKey(x => x.Id);
diff --git a/server/src/Flaro.Data/MetadataEnricher.cs b/server/src/Flaro.Data/MetadataEnricher.cs
index 0def7af..859ccd6 100644
--- a/server/src/Flaro.Data/MetadataEnricher.cs
+++ b/server/src/Flaro.Data/MetadataEnricher.cs
@@ -104,6 +104,7 @@ public sealed class MetadataEnricher(
film.TmdbId = gefunden.ProviderId;
film.ImdbId = gefunden.ImdbId;
film.Title = gefunden.Title;
+ film.OriginalTitle = gefunden.OriginalTitle;
film.SortTitleValue = SortTitle.For(gefunden.Title);
film.Year = gefunden.Year ?? film.Year;
film.Overview = gefunden.Overview;
@@ -180,6 +181,7 @@ public sealed class MetadataEnricher(
serie.TmdbId = gefunden.ProviderId;
serie.ImdbId = gefunden.ImdbId;
serie.Title = gefunden.Title;
+ serie.OriginalTitle = gefunden.OriginalTitle;
serie.SortTitleValue = SortTitle.For(gefunden.Title);
serie.Year = gefunden.Year ?? serie.Year;
serie.Overview = gefunden.Overview;
diff --git a/server/src/Flaro.Data/Migrations/20261004102022_SpracheUndTitel.Designer.cs b/server/src/Flaro.Data/Migrations/20261004102022_SpracheUndTitel.Designer.cs
new file mode 100644
index 0000000..5a6fede
--- /dev/null
+++ b/server/src/Flaro.Data/Migrations/20261004102022_SpracheUndTitel.Designer.cs
@@ -0,0 +1,852 @@
+//
+using System;
+using Flaro.Data;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+
+#nullable disable
+
+namespace Flaro.Data.Migrations
+{
+ [DbContext(typeof(FlaroContext))]
+ [Migration("20261004102022_SpracheUndTitel")]
+ partial class SpracheUndTitel
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder.HasAnnotation("ProductVersion", "10.0.12");
+
+ modelBuilder.Entity("Flaro.Core.Auth.AuthCode", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("ApprovedByUserId")
+ .HasColumnType("TEXT");
+
+ b.Property("Code")
+ .IsRequired()
+ .HasMaxLength(16)
+ .HasColumnType("TEXT");
+
+ b.Property("ConsumedAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("CreatedAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("DeviceId")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("TEXT");
+
+ b.Property("DeviceName")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("TEXT");
+
+ b.Property("ExpiresAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("HandleFingerprint")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Code");
+
+ b.HasIndex("HandleFingerprint")
+ .IsUnique();
+
+ b.ToTable("AuthCodes");
+ });
+
+ modelBuilder.Entity("Flaro.Core.Auth.DeviceToken", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("DeviceId")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("TEXT");
+
+ b.Property("DeviceName")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("TEXT");
+
+ b.Property("LastSeenAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("RevokedAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("TokenFingerprint")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("TEXT");
+
+ b.Property("UserId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("TokenFingerprint")
+ .IsUnique();
+
+ b.HasIndex("UserId");
+
+ b.ToTable("DeviceTokens");
+ });
+
+ modelBuilder.Entity("Flaro.Core.Libraries.Library", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("Kind")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("TEXT");
+
+ b.Property("Path")
+ .IsRequired()
+ .HasMaxLength(1024)
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.ToTable("Libraries");
+ });
+
+ modelBuilder.Entity("Flaro.Core.Library.Credit", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("Character")
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.Property("MovieId")
+ .HasColumnType("TEXT");
+
+ b.Property("Order")
+ .HasColumnType("INTEGER");
+
+ b.Property("PersonId")
+ .HasColumnType("TEXT");
+
+ b.Property("Role")
+ .IsRequired()
+ .HasMaxLength(16)
+ .HasColumnType("TEXT");
+
+ b.Property("SeriesId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("MovieId");
+
+ b.HasIndex("PersonId");
+
+ b.HasIndex("SeriesId");
+
+ b.ToTable("Credits");
+ });
+
+ modelBuilder.Entity("Flaro.Core.Library.Episode", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("AddedAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("EpisodeNumber")
+ .HasColumnType("INTEGER");
+
+ b.Property("Overview")
+ .HasColumnType("TEXT");
+
+ b.Property("SeasonNumber")
+ .HasColumnType("INTEGER");
+
+ b.Property("SeriesId")
+ .HasColumnType("TEXT");
+
+ b.Property("StillImage")
+ .HasColumnType("TEXT");
+
+ b.Property("Title")
+ .HasMaxLength(512)
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("SeriesId", "SeasonNumber", "EpisodeNumber")
+ .IsUnique();
+
+ b.ToTable("Episodes");
+ });
+
+ modelBuilder.Entity("Flaro.Core.Library.Genre", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("TEXT")
+ .UseCollation("NOCASE");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Name")
+ .IsUnique();
+
+ b.ToTable("Genres");
+ });
+
+ modelBuilder.Entity("Flaro.Core.Library.GenreLink", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("GenreId")
+ .HasColumnType("TEXT");
+
+ b.Property("MovieId")
+ .HasColumnType("TEXT");
+
+ b.Property("SeriesId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("MovieId");
+
+ b.HasIndex("SeriesId");
+
+ b.HasIndex("GenreId", "MovieId")
+ .IsUnique()
+ .HasFilter("\"MovieId\" IS NOT NULL");
+
+ b.HasIndex("GenreId", "SeriesId")
+ .IsUnique()
+ .HasFilter("\"SeriesId\" IS NOT NULL");
+
+ b.ToTable("GenreLinks");
+ });
+
+ modelBuilder.Entity("Flaro.Core.Library.ItemTranslation", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("EpisodeId")
+ .HasColumnType("TEXT");
+
+ b.Property("FetchedAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("Language")
+ .IsRequired()
+ .HasMaxLength(16)
+ .HasColumnType("TEXT");
+
+ b.Property("MovieId")
+ .HasColumnType("TEXT");
+
+ b.Property("Overview")
+ .HasColumnType("TEXT");
+
+ b.Property("SeriesId")
+ .HasColumnType("TEXT");
+
+ b.Property("Tagline")
+ .HasMaxLength(512)
+ .HasColumnType("TEXT");
+
+ b.Property("Title")
+ .HasMaxLength(512)
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("EpisodeId", "Language")
+ .IsUnique()
+ .HasFilter("\"EpisodeId\" IS NOT NULL");
+
+ b.HasIndex("MovieId", "Language")
+ .IsUnique()
+ .HasFilter("\"MovieId\" IS NOT NULL");
+
+ b.HasIndex("SeriesId", "Language")
+ .IsUnique()
+ .HasFilter("\"SeriesId\" IS NOT NULL");
+
+ b.ToTable("Translations");
+ });
+
+ modelBuilder.Entity("Flaro.Core.Library.MediaFile", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("BitRate")
+ .HasColumnType("INTEGER");
+
+ b.Property("Container")
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property("Duration")
+ .HasColumnType("INTEGER");
+
+ b.Property("EpisodeId")
+ .HasColumnType("TEXT");
+
+ b.Property("LibraryId")
+ .HasColumnType("TEXT");
+
+ b.Property("MissingSince")
+ .HasColumnType("INTEGER");
+
+ b.Property("ModifiedAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("MovieId")
+ .HasColumnType("TEXT");
+
+ b.Property("Path")
+ .IsRequired()
+ .HasMaxLength(1024)
+ .HasColumnType("TEXT");
+
+ b.Property("ProbedAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("SeenAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("SizeBytes")
+ .HasColumnType("INTEGER");
+
+ b.Property("VersionLabel")
+ .HasMaxLength(64)
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("EpisodeId");
+
+ b.HasIndex("MovieId");
+
+ b.HasIndex("LibraryId", "Path")
+ .IsUnique();
+
+ b.ToTable("MediaFiles");
+ });
+
+ modelBuilder.Entity("Flaro.Core.Library.MediaStream", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("Channels")
+ .HasColumnType("INTEGER");
+
+ b.Property("Codec")
+ .HasMaxLength(64)
+ .HasColumnType("TEXT");
+
+ b.Property("Height")
+ .HasColumnType("INTEGER");
+
+ b.Property("Index")
+ .HasColumnType("INTEGER");
+
+ b.Property("IsDefault")
+ .HasColumnType("INTEGER");
+
+ b.Property("IsForced")
+ .HasColumnType("INTEGER");
+
+ b.Property("Kind")
+ .IsRequired()
+ .HasMaxLength(16)
+ .HasColumnType("TEXT");
+
+ b.Property("Language")
+ .HasMaxLength(16)
+ .HasColumnType("TEXT");
+
+ b.Property("MediaFileId")
+ .HasColumnType("TEXT");
+
+ b.Property("Title")
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.Property("Width")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("MediaFileId", "Index")
+ .IsUnique();
+
+ b.ToTable("MediaStreams");
+ });
+
+ modelBuilder.Entity("Flaro.Core.Library.Movie", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("AddedAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("BackdropImage")
+ .HasColumnType("TEXT");
+
+ b.Property("ContentRating")
+ .HasMaxLength(16)
+ .HasColumnType("TEXT");
+
+ b.Property("ImdbId")
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property("LibraryId")
+ .HasColumnType("TEXT");
+
+ b.Property("LogoImage")
+ .HasColumnType("TEXT");
+
+ b.Property("MatchLocked")
+ .HasColumnType("INTEGER");
+
+ b.Property("OriginalTitle")
+ .HasMaxLength(512)
+ .HasColumnType("TEXT");
+
+ b.Property("Overview")
+ .HasColumnType("TEXT");
+
+ b.Property("ParsedTitle")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("ParsedYear")
+ .HasColumnType("INTEGER");
+
+ b.Property("PosterImage")
+ .HasColumnType("TEXT");
+
+ b.Property("Rating")
+ .HasColumnType("REAL");
+
+ b.Property("RuntimeMinutes")
+ .HasColumnType("INTEGER");
+
+ b.Property("SortTitleValue")
+ .IsRequired()
+ .HasMaxLength(512)
+ .HasColumnType("TEXT");
+
+ b.Property("Tagline")
+ .HasMaxLength(512)
+ .HasColumnType("TEXT");
+
+ b.Property("Title")
+ .IsRequired()
+ .HasMaxLength(512)
+ .HasColumnType("TEXT");
+
+ b.Property("TmdbId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Year")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("LibraryId", "SortTitleValue");
+
+ b.HasIndex("LibraryId", "TmdbId")
+ .IsUnique()
+ .HasFilter("\"TmdbId\" IS NOT NULL");
+
+ b.ToTable("Movies");
+ });
+
+ modelBuilder.Entity("Flaro.Core.Library.Person", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("Image")
+ .HasColumnType("TEXT");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.Property("TmdbId")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("TmdbId")
+ .IsUnique();
+
+ b.ToTable("People");
+ });
+
+ modelBuilder.Entity("Flaro.Core.Library.Series", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("AddedAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("BackdropImage")
+ .HasColumnType("TEXT");
+
+ b.Property("ContentRating")
+ .HasMaxLength(16)
+ .HasColumnType("TEXT");
+
+ b.Property("ImdbId")
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property("LibraryId")
+ .HasColumnType("TEXT");
+
+ b.Property("LogoImage")
+ .HasColumnType("TEXT");
+
+ b.Property("MatchLocked")
+ .HasColumnType("INTEGER");
+
+ b.Property("OriginalTitle")
+ .HasMaxLength(512)
+ .HasColumnType("TEXT");
+
+ b.Property("Overview")
+ .HasColumnType("TEXT");
+
+ b.Property("ParsedTitle")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property("ParsedYear")
+ .HasColumnType("INTEGER");
+
+ b.Property("PosterImage")
+ .HasColumnType("TEXT");
+
+ b.Property("Rating")
+ .HasColumnType("REAL");
+
+ b.Property("RuntimeMinutes")
+ .HasColumnType("INTEGER");
+
+ b.Property("SortTitleValue")
+ .IsRequired()
+ .HasMaxLength(512)
+ .HasColumnType("TEXT");
+
+ b.Property("Tagline")
+ .HasMaxLength(512)
+ .HasColumnType("TEXT");
+
+ b.Property("Title")
+ .IsRequired()
+ .HasMaxLength(512)
+ .HasColumnType("TEXT");
+
+ b.Property("TmdbId")
+ .HasColumnType("INTEGER");
+
+ b.Property("Year")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("LibraryId", "SortTitleValue");
+
+ b.HasIndex("LibraryId", "TmdbId")
+ .IsUnique()
+ .HasFilter("\"TmdbId\" IS NOT NULL");
+
+ b.ToTable("Series");
+ });
+
+ modelBuilder.Entity("Flaro.Core.Playback.UserItemState", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("Dismissed")
+ .HasColumnType("INTEGER");
+
+ b.Property("EpisodeId")
+ .HasColumnType("TEXT");
+
+ b.Property("Favourite")
+ .HasColumnType("INTEGER");
+
+ b.Property("InProgress")
+ .HasColumnType("INTEGER");
+
+ b.Property("LastPlayedAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("MovieId")
+ .HasColumnType("TEXT");
+
+ b.Property("PlayCount")
+ .HasColumnType("INTEGER");
+
+ b.Property("Position")
+ .HasColumnType("INTEGER");
+
+ b.Property("Rating")
+ .HasColumnType("INTEGER");
+
+ b.Property("Runtime")
+ .HasColumnType("INTEGER");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("UserId")
+ .HasColumnType("TEXT");
+
+ b.Property("Watched")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("EpisodeId");
+
+ b.HasIndex("MovieId");
+
+ b.HasIndex("UserId", "EpisodeId")
+ .IsUnique()
+ .HasFilter("\"EpisodeId\" IS NOT NULL");
+
+ b.HasIndex("UserId", "MovieId")
+ .IsUnique()
+ .HasFilter("\"MovieId\" IS NOT NULL");
+
+ b.HasIndex("UserId", "InProgress", "UpdatedAt");
+
+ b.ToTable("UserItemStates");
+ });
+
+ modelBuilder.Entity("Flaro.Core.ServerSetting", b =>
+ {
+ b.Property("Key")
+ .HasMaxLength(128)
+ .HasColumnType("TEXT");
+
+ b.Property("Value")
+ .IsRequired()
+ .HasMaxLength(4096)
+ .HasColumnType("TEXT");
+
+ b.HasKey("Key");
+
+ b.ToTable("Settings");
+ });
+
+ modelBuilder.Entity("Flaro.Core.Users.User", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("CreatedAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("IsAdministrator")
+ .HasColumnType("INTEGER");
+
+ b.Property("Language")
+ .HasMaxLength(16)
+ .HasColumnType("TEXT");
+
+ b.Property("PasswordHash")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.Property("UserName")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("TEXT")
+ .UseCollation("NOCASE");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserName")
+ .IsUnique();
+
+ b.ToTable("Users");
+ });
+
+ modelBuilder.Entity("Flaro.Core.Auth.DeviceToken", b =>
+ {
+ b.HasOne("Flaro.Core.Users.User", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Flaro.Core.Library.Credit", b =>
+ {
+ b.HasOne("Flaro.Core.Library.Movie", null)
+ .WithMany()
+ .HasForeignKey("MovieId")
+ .OnDelete(DeleteBehavior.Cascade);
+
+ b.HasOne("Flaro.Core.Library.Person", null)
+ .WithMany()
+ .HasForeignKey("PersonId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("Flaro.Core.Library.Series", null)
+ .WithMany()
+ .HasForeignKey("SeriesId")
+ .OnDelete(DeleteBehavior.Cascade);
+ });
+
+ modelBuilder.Entity("Flaro.Core.Library.Episode", b =>
+ {
+ b.HasOne("Flaro.Core.Library.Series", null)
+ .WithMany()
+ .HasForeignKey("SeriesId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Flaro.Core.Library.GenreLink", b =>
+ {
+ b.HasOne("Flaro.Core.Library.Genre", null)
+ .WithMany()
+ .HasForeignKey("GenreId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("Flaro.Core.Library.Movie", null)
+ .WithMany()
+ .HasForeignKey("MovieId")
+ .OnDelete(DeleteBehavior.Cascade);
+
+ b.HasOne("Flaro.Core.Library.Series", null)
+ .WithMany()
+ .HasForeignKey("SeriesId")
+ .OnDelete(DeleteBehavior.Cascade);
+ });
+
+ modelBuilder.Entity("Flaro.Core.Library.MediaFile", b =>
+ {
+ b.HasOne("Flaro.Core.Libraries.Library", null)
+ .WithMany()
+ .HasForeignKey("LibraryId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Flaro.Core.Library.MediaStream", b =>
+ {
+ b.HasOne("Flaro.Core.Library.MediaFile", null)
+ .WithMany()
+ .HasForeignKey("MediaFileId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Flaro.Core.Library.Movie", b =>
+ {
+ b.HasOne("Flaro.Core.Libraries.Library", null)
+ .WithMany()
+ .HasForeignKey("LibraryId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Flaro.Core.Library.Series", b =>
+ {
+ b.HasOne("Flaro.Core.Libraries.Library", null)
+ .WithMany()
+ .HasForeignKey("LibraryId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Flaro.Core.Playback.UserItemState", b =>
+ {
+ b.HasOne("Flaro.Core.Library.Episode", null)
+ .WithMany()
+ .HasForeignKey("EpisodeId")
+ .OnDelete(DeleteBehavior.Cascade);
+
+ b.HasOne("Flaro.Core.Library.Movie", null)
+ .WithMany()
+ .HasForeignKey("MovieId")
+ .OnDelete(DeleteBehavior.Cascade);
+
+ b.HasOne("Flaro.Core.Users.User", null)
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/server/src/Flaro.Data/Migrations/20261004102022_SpracheUndTitel.cs b/server/src/Flaro.Data/Migrations/20261004102022_SpracheUndTitel.cs
new file mode 100644
index 0000000..b35ed73
--- /dev/null
+++ b/server/src/Flaro.Data/Migrations/20261004102022_SpracheUndTitel.cs
@@ -0,0 +1,95 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace Flaro.Data.Migrations
+{
+ ///
+ public partial class SpracheUndTitel : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.AddColumn(
+ name: "Language",
+ table: "Users",
+ type: "TEXT",
+ maxLength: 16,
+ nullable: true);
+
+ migrationBuilder.AddColumn(
+ name: "OriginalTitle",
+ table: "Series",
+ type: "TEXT",
+ maxLength: 512,
+ nullable: true);
+
+ migrationBuilder.AddColumn(
+ name: "OriginalTitle",
+ table: "Movies",
+ type: "TEXT",
+ maxLength: 512,
+ nullable: true);
+
+ migrationBuilder.CreateTable(
+ name: "Translations",
+ columns: table => new
+ {
+ Id = table.Column(type: "TEXT", nullable: false),
+ MovieId = table.Column(type: "TEXT", nullable: true),
+ SeriesId = table.Column(type: "TEXT", nullable: true),
+ EpisodeId = table.Column(type: "TEXT", nullable: true),
+ Language = table.Column(type: "TEXT", maxLength: 16, nullable: false),
+ Title = table.Column(type: "TEXT", maxLength: 512, nullable: true),
+ Overview = table.Column(type: "TEXT", nullable: true),
+ Tagline = table.Column(type: "TEXT", maxLength: 512, nullable: true),
+ FetchedAt = table.Column(type: "INTEGER", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_Translations", x => x.Id);
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_Translations_EpisodeId_Language",
+ table: "Translations",
+ columns: new[] { "EpisodeId", "Language" },
+ unique: true,
+ filter: "\"EpisodeId\" IS NOT NULL");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_Translations_MovieId_Language",
+ table: "Translations",
+ columns: new[] { "MovieId", "Language" },
+ unique: true,
+ filter: "\"MovieId\" IS NOT NULL");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_Translations_SeriesId_Language",
+ table: "Translations",
+ columns: new[] { "SeriesId", "Language" },
+ unique: true,
+ filter: "\"SeriesId\" IS NOT NULL");
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "Translations");
+
+ migrationBuilder.DropColumn(
+ name: "Language",
+ table: "Users");
+
+ migrationBuilder.DropColumn(
+ name: "OriginalTitle",
+ table: "Series");
+
+ migrationBuilder.DropColumn(
+ name: "OriginalTitle",
+ table: "Movies");
+ }
+ }
+}
diff --git a/server/src/Flaro.Data/Migrations/FlaroContextModelSnapshot.cs b/server/src/Flaro.Data/Migrations/FlaroContextModelSnapshot.cs
index 86ad9b7..793c220 100644
--- a/server/src/Flaro.Data/Migrations/FlaroContextModelSnapshot.cs
+++ b/server/src/Flaro.Data/Migrations/FlaroContextModelSnapshot.cs
@@ -263,6 +263,57 @@ namespace Flaro.Data.Migrations
b.ToTable("GenreLinks");
});
+ modelBuilder.Entity("Flaro.Core.Library.ItemTranslation", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property("EpisodeId")
+ .HasColumnType("TEXT");
+
+ b.Property("FetchedAt")
+ .HasColumnType("INTEGER");
+
+ b.Property("Language")
+ .IsRequired()
+ .HasMaxLength(16)
+ .HasColumnType("TEXT");
+
+ b.Property("MovieId")
+ .HasColumnType("TEXT");
+
+ b.Property("Overview")
+ .HasColumnType("TEXT");
+
+ b.Property("SeriesId")
+ .HasColumnType("TEXT");
+
+ b.Property("Tagline")
+ .HasMaxLength(512)
+ .HasColumnType("TEXT");
+
+ b.Property("Title")
+ .HasMaxLength(512)
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("EpisodeId", "Language")
+ .IsUnique()
+ .HasFilter("\"EpisodeId\" IS NOT NULL");
+
+ b.HasIndex("MovieId", "Language")
+ .IsUnique()
+ .HasFilter("\"MovieId\" IS NOT NULL");
+
+ b.HasIndex("SeriesId", "Language")
+ .IsUnique()
+ .HasFilter("\"SeriesId\" IS NOT NULL");
+
+ b.ToTable("Translations");
+ });
+
modelBuilder.Entity("Flaro.Core.Library.MediaFile", b =>
{
b.Property("Id")
@@ -405,6 +456,10 @@ namespace Flaro.Data.Migrations
b.Property("MatchLocked")
.HasColumnType("INTEGER");
+ b.Property("OriginalTitle")
+ .HasMaxLength(512)
+ .HasColumnType("TEXT");
+
b.Property("Overview")
.HasColumnType("TEXT");
@@ -509,6 +564,10 @@ namespace Flaro.Data.Migrations
b.Property("MatchLocked")
.HasColumnType("INTEGER");
+ b.Property("OriginalTitle")
+ .HasMaxLength(512)
+ .HasColumnType("TEXT");
+
b.Property("Overview")
.HasColumnType("TEXT");
@@ -651,6 +710,10 @@ namespace Flaro.Data.Migrations
b.Property("IsAdministrator")
.HasColumnType("INTEGER");
+ b.Property("Language")
+ .HasMaxLength(16)
+ .HasColumnType("TEXT");
+
b.Property("PasswordHash")
.IsRequired()
.HasMaxLength(256)
diff --git a/server/src/Flaro.Data/Uebersetzer.cs b/server/src/Flaro.Data/Uebersetzer.cs
new file mode 100644
index 0000000..86962a2
--- /dev/null
+++ b/server/src/Flaro.Data/Uebersetzer.cs
@@ -0,0 +1,122 @@
+using Flaro.Core.Library;
+using Flaro.Core.Metadata;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+
+namespace Flaro.Data;
+
+/// Was ein Durchgang an Übersetzungen geholt hat.
+public sealed record Uebersetzungsbericht(int Filme, int Serien, int Fehlend)
+{
+ public int Gesamt => Filme + Serien;
+}
+
+/// Holt Titel und Beschreibungen in einer zweiten Sprache.
+///
+///
+///
+/// Warum überhaupt zweisprachig. Metadaten werden in der Sprache des
+/// Servers geholt und gespeichert — eine Sprache, ein Satz Daten. Will jemand
+/// im Haushalt Englisch sehen, hilft kein Umschalten in der Oberfläche: die
+/// deutschen Titel sind alles, was da ist.
+///
+///
+/// Als Sammelaktion, nicht nebenbei. Jeden Titel beim Öffnen einzeln
+/// nachzuladen verteilt die Wartezeit über Wochen und trifft immer den, der
+/// gerade etwas sucht. Einmal angestossen, einmal gewartet, danach ist es da.
+///
+///
+/// Was fehlt, erscheint in der Sprache des Servers — nicht leer. Ein
+/// Film ohne englische Übersetzung heisst dann eben weiter „Das Boot"; das
+/// ist richtiger als ein leeres Feld und ehrlicher als ein Platzhalter.
+///
+///
+public sealed class Uebersetzer(
+ FlaroContext db, IMetadataProvider anbieter, TimeProvider uhr, ILogger log)
+{
+ /// Holt für alle Titel, was in dieser Sprache fehlt.
+ public async Task HolenAsync(
+ string sprache, IProgress? fortschritt = null, CancellationToken ct = default)
+ {
+ if (string.IsNullOrWhiteSpace(sprache)) return new Uebersetzungsbericht(0, 0, 0);
+ if (!await anbieter.IsConfiguredAsync(ct)) return new Uebersetzungsbericht(0, 0, 0);
+
+ var filme = await db.Movies
+ .Where(m => m.TmdbId != null
+ && !db.Translations.Any(t => t.MovieId == m.Id && t.Language == sprache))
+ .Select(m => new { m.Id, TmdbId = m.TmdbId!.Value, m.Title })
+ .ToListAsync(ct);
+
+ var serien = await db.Series
+ .Where(s => s.TmdbId != null
+ && !db.Translations.Any(t => t.SeriesId == s.Id && t.Language == sprache))
+ .Select(s => new { s.Id, TmdbId = s.TmdbId!.Value, s.Title })
+ .ToListAsync(ct);
+
+ int fertigFilme = 0, fertigSerien = 0, fehlend = 0, durch = 0;
+ var gesamt = filme.Count + serien.Count;
+
+ foreach (var film in filme)
+ {
+ ct.ThrowIfCancellationRequested();
+ fortschritt?.Report(new Scanschritt(Scanphase.Holen, film.Title, ++durch, gesamt));
+
+ var gefunden = await anbieter.GetMovieAsync(film.TmdbId, sprache, ct);
+ if (gefunden is null) { fehlend++; continue; }
+
+ db.Translations.Add(new ItemTranslation
+ {
+ MovieId = film.Id,
+ Language = sprache,
+ Title = gefunden.Title,
+ Overview = gefunden.Overview,
+ Tagline = gefunden.Tagline,
+ FetchedAt = uhr.GetUtcNow(),
+ });
+ fertigFilme++;
+
+ // In Häppchen schreiben: ein Abbruch nach dreihundert Titeln soll
+ // nicht alles Geholte verwerfen.
+ if (fertigFilme % 25 == 0) await db.SaveChangesAsync(ct);
+ }
+
+ foreach (var serie in serien)
+ {
+ ct.ThrowIfCancellationRequested();
+ fortschritt?.Report(new Scanschritt(Scanphase.Holen, serie.Title, ++durch, gesamt));
+
+ var gefunden = await anbieter.GetSeriesAsync(serie.TmdbId, sprache, ct);
+ if (gefunden is null) { fehlend++; continue; }
+
+ db.Translations.Add(new ItemTranslation
+ {
+ SeriesId = serie.Id,
+ Language = sprache,
+ Title = gefunden.Title,
+ Overview = gefunden.Overview,
+ Tagline = gefunden.Tagline,
+ FetchedAt = uhr.GetUtcNow(),
+ });
+ fertigSerien++;
+ if (fertigSerien % 25 == 0) await db.SaveChangesAsync(ct);
+ }
+
+ await db.SaveChangesAsync(ct);
+
+ log.LogInformation(
+ "Übersetzungen für {Sprache}: {Filme} Filme, {Serien} Serien, {Fehlend} ohne Treffer.",
+ sprache, fertigFilme, fertigSerien, fehlend);
+
+ return new Uebersetzungsbericht(fertigFilme, fertigSerien, fehlend);
+ }
+
+ /// Wie weit eine Sprache abgedeckt ist — für den Hinweis beim Umstellen.
+ public async Task<(int Vorhanden, int Gesamt)> AbdeckungAsync(
+ string sprache, CancellationToken ct = default)
+ {
+ var gesamt = await db.Movies.CountAsync(m => m.TmdbId != null, ct)
+ + await db.Series.CountAsync(s => s.TmdbId != null, ct);
+ var vorhanden = await db.Translations.CountAsync(t => t.Language == sprache, ct);
+ return (vorhanden, gesamt);
+ }
+}
diff --git a/server/src/Flaro.Metadata/TmdbClient.cs b/server/src/Flaro.Metadata/TmdbClient.cs
index 09f2d7c..ab55646 100644
--- a/server/src/Flaro.Metadata/TmdbClient.cs
+++ b/server/src/Flaro.Metadata/TmdbClient.cs
@@ -88,10 +88,19 @@ public sealed class TmdbClient(
return alle.Select(m => Als(m, title)).ToList();
}
- public async Task GetMovieAsync(int providerId, CancellationToken ct = default)
+ public Task GetMovieAsync(int providerId, CancellationToken ct = default) =>
+ FilmHolenAsync(providerId, null, ct);
+
+ public Task GetMovieAsync(int providerId, string sprache, CancellationToken ct = default) =>
+ FilmHolenAsync(providerId, sprache, ct);
+
+ /// Der gemeinsame Weg — mit Sprache oder mit der des Servers.
+ private async Task FilmHolenAsync(
+ int providerId, string? sprache, CancellationToken ct)
{
var einstellung = await einstellungen.GetAsync(ct);
if (string.IsNullOrWhiteSpace(einstellung.ApiKey)) return null;
+ if (sprache is { Length: > 0 }) einstellung = einstellung with { Language = sprache };
// **Alles in einer Anfrage.** `append_to_response` hängt Besetzung,
// Fremdkennungen und Freigaben an den Einzelabruf. Drei getrennte
@@ -127,7 +136,10 @@ public sealed class TmdbClient(
private static MovieMetadata Als(TmdbMovie roh, string rückfall) => new(
roh.Id, roh.Title ?? rückfall, JahrAus(roh.ReleaseDate), Leer(roh.Overview),
- Bild(roh.PosterPath, PosterSize), Bild(roh.BackdropPath, BackdropSize));
+ Bild(roh.PosterPath, PosterSize), Bild(roh.BackdropPath, BackdropSize))
+ {
+ OriginalTitle = Leer(roh.OriginalTitle),
+ };
public async Task FindSeriesAsync(string title, int? year, CancellationToken ct = default)
{
@@ -145,10 +157,18 @@ public sealed class TmdbClient(
return alle.Select(s => Als(s, title)).ToList();
}
- public async Task GetSeriesAsync(int providerId, CancellationToken ct = default)
+ public Task GetSeriesAsync(int providerId, CancellationToken ct = default) =>
+ SerieHolenAsync(providerId, null, ct);
+
+ public Task GetSeriesAsync(int providerId, string sprache, CancellationToken ct = default) =>
+ SerieHolenAsync(providerId, sprache, ct);
+
+ private async Task SerieHolenAsync(
+ int providerId, string? sprache, CancellationToken ct)
{
var einstellung = await einstellungen.GetAsync(ct);
if (string.IsNullOrWhiteSpace(einstellung.ApiKey)) return null;
+ if (sprache is { Length: > 0 }) einstellung = einstellung with { Language = sprache };
var serie = await HolenAsync(
$"tv/{providerId}?language={Uri.EscapeDataString(einstellung.Language)}" +
@@ -174,7 +194,10 @@ public sealed class TmdbClient(
private static SeriesMetadata Als(TmdbSeries roh, string rückfall) => new(
roh.Id, roh.Name ?? rückfall, JahrAus(roh.FirstAirDate), Leer(roh.Overview),
- Bild(roh.PosterPath, PosterSize), Bild(roh.BackdropPath, BackdropSize));
+ Bild(roh.PosterPath, PosterSize), Bild(roh.BackdropPath, BackdropSize))
+ {
+ OriginalTitle = Leer(roh.OriginalName),
+ };
// Der eigene Abruf von `external_ids` ist entfallen: die Kennung hängt
// jetzt über `append_to_response` am Einzelabruf. Das spart eine Anfrage
@@ -428,6 +451,7 @@ public sealed class TmdbClient(
private sealed record TmdbMovie(
[property: JsonPropertyName("id")] int Id,
[property: JsonPropertyName("title")] string? Title,
+ [property: JsonPropertyName("original_title")] string? OriginalTitle,
[property: JsonPropertyName("release_date")] string? ReleaseDate,
[property: JsonPropertyName("overview")] string? Overview,
[property: JsonPropertyName("poster_path")] string? PosterPath,
@@ -445,6 +469,7 @@ public sealed class TmdbClient(
private sealed record TmdbSeries(
[property: JsonPropertyName("id")] int Id,
[property: JsonPropertyName("name")] string? Name,
+ [property: JsonPropertyName("original_name")] string? OriginalName,
[property: JsonPropertyName("first_air_date")] string? FirstAirDate,
[property: JsonPropertyName("overview")] string? Overview,
[property: JsonPropertyName("poster_path")] string? PosterPath,
diff --git a/web/app/src/api.ts b/web/app/src/api.ts
index 80febbc..f0f4165 100644
--- a/web/app/src/api.ts
+++ b/web/app/src/api.ts
@@ -3,7 +3,11 @@
* Eine Datei, weil es eine Schnittstelle ist: wer wissen will, was der Server
* kann, liest hier und nicht in fünf Ansichten nach. */
-export type Rolle = { id: string; userName: string; isAdministrator: boolean };
+export type Rolle = {
+ id: string; userName: string; isAdministrator: boolean;
+ /** Die bevorzugte Sprache — `null` heisst: die des Servers. */
+ language?: string | null;
+};
export type Spur = {
index: number; kind: 'Video' | 'Audio' | 'Subtitle';
codec: string | null; language: string | null; title: string | null;
@@ -39,6 +43,8 @@ export type Film = {
/** Leer in Listen, gefüllt beim Einzelabruf. */
genres: string[] | null;
credits: Mitwirkender[] | null;
+ /** Der Titel in der Sprache des Originals, wenn er abweicht. */
+ originalTitle: string | null;
};
export type Serie = {
id: string; title: string; year: number | null; overview: string | null;
@@ -51,6 +57,8 @@ export type Serie = {
contentRating: string | null;
genres: string[] | null;
credits: Mitwirkender[] | null;
+ /** Der Titel in der Sprache des Originals, wenn er abweicht. */
+ originalTitle: string | null;
};
export type Folge = {
id: string; season: number; episode: number;
@@ -147,6 +155,9 @@ function merken(): void {
export const angemeldet = (): boolean => Boolean(sitzung.token);
export const istAdmin = (): boolean => Boolean(sitzung.wer?.isAdministrator);
+/** Die eingestellte Sprache dieses Benutzers — `null` heisst: die des Servers. */
+export const meineSprache = (): string | null => sitzung.wer?.language ?? null;
+
/* ---------- Anfragen ---------- */
export class ApiFehler extends Error {
@@ -432,6 +443,36 @@ export type Ordnerstand = {
note: string | null;
};
+/** Die Sprachen, die zur Auswahl stehen.
+ *
+ * **Eine Auswahl statt eines Textfelds.** Vorher musste man „de-DE" wissen —
+ * und ein Tippfehler darin nahm TMDB stillschweigend als Englisch an. */
+export const sprachen: { wert: string; text: string }[] = [
+ { wert: 'de-DE', text: 'Deutsch (de-DE)' },
+ { wert: 'en-US', text: 'Englisch (en-US)' },
+ { wert: 'en-GB', text: 'Englisch, UK (en-GB)' },
+ { wert: 'fr-FR', text: 'Französisch (fr-FR)' },
+ { wert: 'it-IT', text: 'Italienisch (it-IT)' },
+ { wert: 'es-ES', text: 'Spanisch (es-ES)' },
+ { wert: 'nl-NL', text: 'Niederländisch (nl-NL)' },
+ { wert: 'pl-PL', text: 'Polnisch (pl-PL)' },
+ { wert: 'pt-BR', text: 'Portugiesisch, Brasilien (pt-BR)' },
+ { wert: 'tr-TR', text: 'Türkisch (tr-TR)' },
+];
+
+export type Sprachstand = { language: string | null; translated: number; total: number };
+
+/** Setzt die eigene Sprache — `null` heisst: die des Servers. */
+export const spracheSetzen = (language: string | null) =>
+ anfrage('/users/me/language', { method: 'PUT', body: JSON.stringify({ language }) });
+
+export type Uebersetzungsbericht = { filme: number; serien: number; fehlend: number; gesamt: number };
+
+/** Holt die fehlenden Übersetzungen für eine Sprache — kostet je Titel eine Anfrage. */
+export const uebersetzungenHolen = (language: string) =>
+ anfrage('/system/translations',
+ { method: 'POST', body: JSON.stringify({ language }) });
+
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 b9b1412..b6efe1b 100644
--- a/web/app/src/style.css
+++ b/web/app/src/style.css
@@ -649,3 +649,10 @@ h2 { font-size: 19px; font-weight: 700; letter-spacing: -.01em; margin: 0 0 var(
white-space: pre-wrap;
word-break: break-word;
}
+
+/* Der Originaltitel unter dem übersetzten — leiser, aber lesbar. */
+.originaltitel {
+ margin-top: 2px;
+ font-size: 15px;
+ color: var(--text-muted);
+}
diff --git a/web/app/src/views/admin.ts b/web/app/src/views/admin.ts
index 73b3359..268057d 100644
--- a/web/app/src/views/admin.ts
+++ b/web/app/src/views/admin.ts
@@ -2,7 +2,8 @@ import {
bibliotheken, bibliothekAnlegen, bibliothekUmbenennen, bibliothekLoeschen,
scanStarten, scanstand, metadatenAuffrischen, doppelteZusammenlegen,
vermisste, vermisstesEntfernen, type VermissteDatei,
- protokolltage, protokollAdresse,
+ protokolltage, protokollAdresse, sprachen, spracheSetzen, uebersetzungenHolen,
+ meineSprache,
benutzer, benutzerAnlegen, metadatenEinstellung, metadatenSetzen,
servernameSetzen, systemauskunft,
laufendeWiedergaben, serverzustand, bildAdresse,
@@ -548,6 +549,7 @@ function serverbereich(geaendert: () => void): HTMLElement {
void (async () => {
try {
const [auskunft, stand] = await Promise.all([systemauskunft(), metadatenEinstellung()]);
+ const eigeneSprache = meineSprache();
liste.innerHTML = '';
liste.appendChild(zeile(
@@ -606,8 +608,12 @@ function serverbereich(geaendert: () => void): HTMLElement {
hinweis: 'Leer lassen, um den mitgelieferten zu behalten.',
},
{
- name: 'sprache', beschriftung: 'Sprache', wert: stand.language,
- hinweis: 'Etwa de-DE. Bestimmt auch, welche Altersfreigabe gilt.',
+ name: 'sprache', beschriftung: 'Sprache der Metadaten',
+ auswahl: sprachen, wert: stand.language,
+ hinweis: 'In dieser Sprache werden Titel und Beschreibungen geholt. '
+ + 'Sie bestimmt auch, welche Altersfreigabe gilt. Vorhandene Titel '
+ + 'behalten ihre alte Sprache — dafür gibt es bei jeder Mediathek '
+ + 'den Knopf „Metadaten auffrischen".',
},
],
bestaetigen: 'Speichern',
@@ -620,6 +626,51 @@ function serverbereich(geaendert: () => void): HTMLElement {
},
})));
+ // **Die eigene Sprache, getrennt von der des Servers.** Die obere
+ // bestimmt, was geholt wird; diese nur, was man selbst zu sehen bekommt.
+ liste.appendChild(zeile(
+ 'Meine Sprache',
+ eigeneSprache ? (sprachen.find(x => x.wert === eigeneSprache)?.text ?? eigeneSprache)
+ : 'Wie der Server',
+ 'Gilt nur für dich. Was nicht übersetzt vorliegt, erscheint weiterhin in '
+ + 'der Sprache des Servers.',
+ () => dialog({
+ titel: 'Meine Sprache',
+ text: 'Die Metadaten liegen in der Sprache des Servers vor. Eine andere '
+ + 'zu wählen heisst: du siehst, was übersetzt vorliegt — und für den '
+ + 'Rest die Serversprache. Ein Administrator kann die Übersetzungen '
+ + 'unten für alle Titel auf einmal holen.',
+ felder: [{
+ name: 'sprache', beschriftung: 'Sprache',
+ auswahl: [{ wert: '', text: 'Wie der Server' }, ...sprachen],
+ wert: eigeneSprache ?? '',
+ }],
+ bestaetigen: 'Übernehmen',
+ tun: async wert => {
+ const neu = wert('sprache');
+ await spracheSetzen(neu || null);
+ location.reload();
+ },
+ })));
+
+ liste.appendChild(zeile(
+ 'Übersetzungen holen',
+ 'Füllt eine zweite Sprache für alle Titel auf',
+ 'Kostet je Titel eine Anfrage an TMDB und dauert entsprechend. '
+ + 'Danach sehen Benutzer mit dieser Sprache die übersetzten Titel.',
+ () => dialog({
+ titel: 'Übersetzungen holen',
+ felder: [{
+ name: 'sprache', beschriftung: 'Für welche Sprache',
+ auswahl: sprachen, wert: eigeneSprache ?? 'en-US',
+ }],
+ bestaetigen: 'Holen',
+ tun: async wert => {
+ const bericht = await uebersetzungenHolen(wert('sprache'));
+ alert(`${bericht.gesamt} Übersetzungen geholt, ${bericht.fehlend} ohne Treffer.`);
+ },
+ })));
+
liste.appendChild(zeile('Fassung', auskunft.version, null));
} catch (fehler) {
liste.innerHTML = '';
diff --git a/web/app/src/views/detail.ts b/web/app/src/views/detail.ts
index 9667ff4..d2100aa 100644
--- a/web/app/src/views/detail.ts
+++ b/web/app/src/views/detail.ts
@@ -113,6 +113,16 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem
text.appendChild(h);
}
+ // **Der Originaltitel darunter, wenn er abweicht.** Die Mediathek ist
+ // zweisprachig, solange nicht alles übersetzt vorliegt; wer „Despicable
+ // Me" im Kopf hat, soll den Film trotzdem wiedererkennen.
+ if (eintrag.originalTitle && eintrag.originalTitle !== eintrag.title) {
+ const o = document.createElement('div');
+ o.className = 'originaltitel';
+ o.textContent = eintrag.originalTitle;
+ text.appendChild(o);
+ }
+
text.appendChild(eckdaten(eintrag));
if (eintrag.tagline) {