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