diff --git a/server/src/Flaro.Api/Endpoints/Contracts.cs b/server/src/Flaro.Api/Endpoints/Contracts.cs index 932f8ce..dc84e3a 100644 --- a/server/src/Flaro.Api/Endpoints/Contracts.cs +++ b/server/src/Flaro.Api/Endpoints/Contracts.cs @@ -25,6 +25,17 @@ public sealed record Unzugeordnet(int Gesamt, IReadOnlyList public sealed record UnzugeordneteDatei(Guid Id, string Pfad, string Grund); +/// Wie viele Titel in welcher Sprache vorliegen. +/// +/// null heisst: vor der Einführung dieser Spalte geholt. +public sealed record Sprachanteil(string? Sprache, int Anzahl); + +/// Wie viele nicht in der Serversprache vorliegen. +public sealed record Sprachverteilung( + string Serversprache, IReadOnlyList Anteile, int Abweichend); + +public sealed record TitelRequest(string? Title); + public sealed record ServerNameRequest(string Name); public sealed record SetupRequest(string UserName, string Password); diff --git a/server/src/Flaro.Api/Endpoints/MatchEndpoints.cs b/server/src/Flaro.Api/Endpoints/MatchEndpoints.cs index 78664b3..153da53 100644 --- a/server/src/Flaro.Api/Endpoints/MatchEndpoints.cs +++ b/server/src/Flaro.Api/Endpoints/MatchEndpoints.cs @@ -25,6 +25,62 @@ public static class MatchEndpoints { public static void MapMatching(this IEndpointRouteBuilder app) { + // **Den Titel von Hand setzen — und festschreiben.** + // + // TMDB kennt nicht jeden Titel so, wie er im Haushalt heisst, und bei + // Mitschnitten gibt es gar keinen. Ohne diesen Weg bleibt nur der + // Dateiname oder ein falscher Treffer. + // + // Festgeschrieben wie Plakat und Hintergrund: sonst überschreibt das + // nächste Auffrischen die Korrektur, und man tippt sie nach jedem Lauf + // neu. + app.MapPut("/api/v1/{art:regex(^movies|series$)}/{id:guid}/title", async ( + string art, Guid id, TitelRequest anfrage, FlaroContext db) => + { + var titel = anfrage.Title?.Trim(); + if (string.IsNullOrEmpty(titel)) + { + return Results.ValidationProblem(new Dictionary + { + ["title"] = ["Ein Titel darf nicht leer sein."], + }); + } + if (titel.Length > 512) + return Results.ValidationProblem(new Dictionary + { + ["title"] = ["Höchstens 512 Zeichen."], + }); + + var betroffen = art == "movies" + ? await db.Movies.Where(m => m.Id == id).ExecuteUpdateAsync(s => s + .SetProperty(m => m.Title, titel) + .SetProperty(m => m.SortTitleValue, SortTitle.For(titel)) + .SetProperty(m => m.TitlePinned, true)) + : await db.Series.Where(x => x.Id == id).ExecuteUpdateAsync(s => s + .SetProperty(x => x.Title, titel) + .SetProperty(x => x.SortTitleValue, SortTitle.For(titel)) + .SetProperty(x => x.TitlePinned, true)); + + return betroffen > 0 ? Results.NoContent() : Results.NotFound(); + }).RequireAuthorization(FlaroPolicies.Administrator); + + // **Zurück zum automatischen Titel.** + // + // Ohne diesen Weg gäbe es keinen zurück, ausser den alten Namen zu + // erraten. Der Titel selbst bleibt stehen, bis das nächste Auffrischen + // ihn holt — sonst stünde dort bis dahin gar nichts. + app.MapDelete("/api/v1/{art:regex(^movies|series$)}/{id:guid}/title", async ( + string art, Guid id, FlaroContext db) => + { + var betroffen = art == "movies" + ? await db.Movies.Where(m => m.Id == id) + .ExecuteUpdateAsync(s => s.SetProperty(m => m.TitlePinned, false)) + : await db.Series.Where(x => x.Id == id) + .ExecuteUpdateAsync(s => s.SetProperty(x => x.TitlePinned, false)); + + return betroffen > 0 ? Results.NoContent() : Results.NotFound(); + }).RequireAuthorization(FlaroPolicies.Administrator); + // **Woher dieser Titel kommt.** // // Steht im Dialog über der Suche. Wenn eine Zuordnung völlig daneben diff --git a/server/src/Flaro.Api/Endpoints/SettingsEndpoints.cs b/server/src/Flaro.Api/Endpoints/SettingsEndpoints.cs index 7607242..998daf9 100644 --- a/server/src/Flaro.Api/Endpoints/SettingsEndpoints.cs +++ b/server/src/Flaro.Api/Endpoints/SettingsEndpoints.cs @@ -30,6 +30,59 @@ public static class SettingsEndpoints UsingProjectKey: aufgelöst.UsingProjectKey)); }).RequireAuthorization(FlaroPolicies.Administrator); + // **In welcher Sprache der Bestand vorliegt.** + // + // Der eigentliche Fehler war nicht, dass Titel englisch blieben — + // sondern dass niemand feststellen konnte, ob sie es sind. „Alles ist + // noch auf Englisch, obwohl Deutsch eingestellt ist" liess sich nicht + // beantworten, nur bestreiten. Hier steht die Zahl. + app.MapGet("/api/v1/settings/metadata/languages", async (FlaroContext db) => + { + var eingestellt = await db.Settings.AsNoTracking() + .Where(x => x.Key == DatabaseTmdbSettings.LanguageName) + .Select(x => x.Value).FirstOrDefaultAsync(); + var serversprache = TmdbSettings.Resolve(null, eingestellt).Language; + + // Nur zugeordnete Titel zählen: was keine Kennung hat, hat auch + // keine Sprache — das ist ein Zuordnungsproblem, kein Sprachproblem. + var filme = await db.Movies.AsNoTracking().Where(m => m.TmdbId != null) + .GroupBy(m => m.MetadataLanguage) + .Select(g => new { Sprache = g.Key, Anzahl = g.Count() }).ToListAsync(); + var serien = await db.Series.AsNoTracking().Where(x => x.TmdbId != null) + .GroupBy(x => x.MetadataLanguage) + .Select(g => new { Sprache = g.Key, Anzahl = g.Count() }).ToListAsync(); + + var zusammen = filme.Concat(serien) + .GroupBy(x => x.Sprache) + .Select(g => new Sprachanteil(g.Key, g.Sum(x => x.Anzahl))) + .OrderByDescending(x => x.Anzahl) + .ToList(); + + return Results.Ok(new Sprachverteilung( + serversprache, + zusammen, + Abweichend: zusammen.Where(x => x.Sprache != serversprache).Sum(x => x.Anzahl))); + }).RequireAuthorization(FlaroPolicies.Administrator); + + // Frischt genau die auf, die nicht zur Serversprache passen. + app.MapPost("/api/v1/settings/metadata/languages/refresh", async ( + FlaroContext db, ScanCoordinator koordinator) => + { + var eingestellt = await db.Settings.AsNoTracking() + .Where(x => x.Key == DatabaseTmdbSettings.LanguageName) + .Select(x => x.Value).FirstOrDefaultAsync(); + var serversprache = TmdbSettings.Resolve(null, eingestellt).Language; + + var mediatheken = await db.Libraries.AsNoTracking().Select(l => l.Id).ToListAsync(); + var gestartet = mediatheken.Count(id => koordinator.TryStart(id, erneut: true, serversprache)); + + return gestartet > 0 + ? Results.Accepted(value: new { started = gestartet, language = serversprache }) + : Results.Problem( + "In jeder Mediathek läuft bereits etwas. Warte, bis es durch ist.", + statusCode: StatusCodes.Status409Conflict); + }).RequireAuthorization(FlaroPolicies.Administrator); + app.MapPut("/api/v1/settings/metadata", async ( MetadataSettingsRequest anfrage, FlaroContext db) => { diff --git a/server/src/Flaro.Api/ScanCoordinator.cs b/server/src/Flaro.Api/ScanCoordinator.cs index 74972b6..7b5a911 100644 --- a/server/src/Flaro.Api/ScanCoordinator.cs +++ b/server/src/Flaro.Api/ScanCoordinator.cs @@ -56,7 +56,14 @@ public sealed class ScanCoordinator( public bool IsRunning(Guid libraryId) => State(libraryId)?.Status == "running"; /// Startet einen Lauf — oder sagt, dass schon einer läuft. - public bool TryStart(Guid libraryId, bool erneut = false) + /// + /// + /// Nur Titel anfassen, die nicht in dieser Sprache vorliegen. Dann wird + /// auch nicht gescannt: nach einer Sprachumstellung hat sich am + /// Dateisystem nichts geändert, und 5884 Dateien noch einmal abzulaufen + /// kostet Minuten für nichts. + /// + public bool TryStart(Guid libraryId, bool erneut = false, string? nurSprache = null) { var start = new ScanState("running", clock.GetUtcNow(), null, null, null, null); @@ -68,11 +75,11 @@ public sealed class ScanCoordinator( // Lief schon einer, steht hier dessen Zustand und nicht unserer. if (!ReferenceEquals(vorher, start)) return false; - _ = Task.Run(() => Laufen(libraryId, erneut)); + _ = Task.Run(() => Laufen(libraryId, erneut, nurSprache)); return true; } - private async Task Laufen(Guid libraryId, bool erneut) + private async Task Laufen(Guid libraryId, bool erneut, string? nurSprache = null) { try { @@ -98,8 +105,15 @@ public sealed class ScanCoordinator( (_, alt) => alt with { Schritt = schritt }); }); - var scanner = bereich.ServiceProvider.GetRequiredService(); - var bericht = await scanner.ScanAsync(libraryId, melder); + // **Nach einer Sprachumstellung wird nicht gescannt.** Am + // Dateisystem hat sich nichts geändert; 5884 Dateien noch einmal + // abzulaufen kostete Minuten für nichts. + ScanReport? bericht = null; + if (nurSprache is null) + { + var scanner = bereich.ServiceProvider.GetRequiredService(); + bericht = await scanner.ScanAsync(libraryId, melder); + } // Der Scan ist durch, die Dateien stehen in der Mediathek. Was // jetzt noch kommt, kann scheitern, ohne dass davon etwas verloren @@ -107,7 +121,8 @@ public sealed class ScanCoordinator( _zustand[libraryId] = _zustand[libraryId] with { Status = "enriching", Report = bericht }; var metadaten = await bereich.ServiceProvider - .GetRequiredService().EnrichAsync(libraryId, erneut, melder); + .GetRequiredService() + .EnrichAsync(libraryId, erneut, melder, nurSprache: nurSprache); // **Erst jetzt lassen sich Dubletten überhaupt erkennen.** // Beim Scan heisst der eine Eintrag „Ich einfach unverbesserlich" diff --git a/server/src/Flaro.Core/Library/MediaEntities.cs b/server/src/Flaro.Core/Library/MediaEntities.cs index 375f371..bd5b567 100644 --- a/server/src/Flaro.Core/Library/MediaEntities.cs +++ b/server/src/Flaro.Core/Library/MediaEntities.cs @@ -142,6 +142,32 @@ public sealed class Movie : Flaro.Core.Metadata.IBebildert public bool BackdropPinned { get; set; } public bool LogoPinned { get; set; } + /// Der Titel, wie ihn jemand von Hand gesetzt hat. + /// + /// + /// Aus demselben Grund wie : ohne diesen Merker + /// überschreibt das nächste Auffrischen die Korrektur, und man tippt sie + /// nach jedem Lauf neu. + /// + public bool TitlePinned { get; set; } + + /// In welcher Sprache die Angaben geholt wurden. + /// + /// + /// + /// Weil der Server es sonst selbst nicht weiss. „Alles ist noch auf + /// Englisch, obwohl der Server auf Deutsch steht" liess sich nicht + /// beantworten, sondern nur bestreiten: nirgends stand, in welcher Sprache + /// ein Titel eigentlich vorliegt. Mit dieser Spalte wird aus der Frage + /// eine Zahl — und aus „frisch mal auf" ein Knopf, der genau die + /// abweichenden anfasst statt aller viertausend. + /// + /// + /// null heisst: vor dieser Änderung geholt, Sprache unbekannt. + /// + /// + public string? MetadataLanguage { get; set; } + /// Der Titel in der Sprache des Originals. /// /// @@ -279,6 +305,32 @@ public sealed class Series : Flaro.Core.Metadata.IBebildert public bool BackdropPinned { get; set; } public bool LogoPinned { get; set; } + /// Der Titel, wie ihn jemand von Hand gesetzt hat. + /// + /// + /// Aus demselben Grund wie : ohne diesen Merker + /// überschreibt das nächste Auffrischen die Korrektur, und man tippt sie + /// nach jedem Lauf neu. + /// + public bool TitlePinned { get; set; } + + /// In welcher Sprache die Angaben geholt wurden. + /// + /// + /// + /// Weil der Server es sonst selbst nicht weiss. „Alles ist noch auf + /// Englisch, obwohl der Server auf Deutsch steht" liess sich nicht + /// beantworten, sondern nur bestreiten: nirgends stand, in welcher Sprache + /// ein Titel eigentlich vorliegt. Mit dieser Spalte wird aus der Frage + /// eine Zahl — und aus „frisch mal auf" ein Knopf, der genau die + /// abweichenden anfasst statt aller viertausend. + /// + /// + /// null heisst: vor dieser Änderung geholt, Sprache unbekannt. + /// + /// + public string? MetadataLanguage { get; set; } + /// Der Titel in der Sprache des Originals. /// /// @@ -362,6 +414,9 @@ public sealed class Episode /// Das Standbild der Folge. public string? StillImage { get; set; } + /// In welcher Sprache die Angaben geholt wurden — siehe . + public string? MetadataLanguage { get; set; } + /// Ab wann diese Folge für alle sichtbar ist — siehe . /// /// diff --git a/server/src/Flaro.Core/Metadata/IMetadataProvider.cs b/server/src/Flaro.Core/Metadata/IMetadataProvider.cs index dcac810..f055fdf 100644 --- a/server/src/Flaro.Core/Metadata/IMetadataProvider.cs +++ b/server/src/Flaro.Core/Metadata/IMetadataProvider.cs @@ -56,7 +56,18 @@ public sealed record MovieMetadata( RemoteImage? Logo = null, /// Der Titel in der Sprache des Originals. - string? OriginalTitle = null); + string? OriginalTitle = null, + + /// In welcher Sprache diese Angaben geholt wurden. + /// + /// + /// Damit der Server es hinterher noch weiss. Die Sprache steht in + /// den Einstellungen und ändert sich; was einmal geholt wurde, trägt sie + /// danach nirgends. „Alles ist noch auf Englisch, obwohl Deutsch + /// eingestellt ist" liess sich deshalb nicht beantworten, sondern nur + /// bestreiten. Die Quelle weiss es — also gibt sie es mit. + /// + string? Language = null); public sealed record SeriesMetadata( int ProviderId, string Title, int? Year, string? Overview, @@ -80,9 +91,21 @@ public sealed record SeriesMetadata( RemoteImage? Logo = null, /// Der Titel in der Sprache des Originals. - string? OriginalTitle = null); + string? OriginalTitle = null, -public sealed record EpisodeMetadata(string? Title, string? Overview, RemoteImage? Still); + /// In welcher Sprache diese Angaben geholt wurden. + /// + /// + /// Damit der Server es hinterher noch weiss. Die Sprache steht in + /// den Einstellungen und ändert sich; was einmal geholt wurde, trägt sie + /// danach nirgends. „Alles ist noch auf Englisch, obwohl Deutsch + /// eingestellt ist" liess sich deshalb nicht beantworten, sondern nur + /// bestreiten. Die Quelle weiss es — also gibt sie es mit. + /// + string? Language = null); + +public sealed record EpisodeMetadata( + string? Title, string? Overview, RemoteImage? Still, string? Language = null); /// Woher Titel, Beschreibungen und Bilder kommen. /// diff --git a/server/src/Flaro.Data/FlaroContext.cs b/server/src/Flaro.Data/FlaroContext.cs index 4a374e3..63b03ce 100644 --- a/server/src/Flaro.Data/FlaroContext.cs +++ b/server/src/Flaro.Data/FlaroContext.cs @@ -179,6 +179,10 @@ public sealed class FlaroContext(DbContextOptions options) : DbCon e.Property(x => x.ImdbId).HasMaxLength(32); e.Property(x => x.Tagline).HasMaxLength(512); e.Property(x => x.ContentRating).HasMaxLength(16); + e.Property(x => x.MetadataLanguage).HasMaxLength(16); + // Gefragt wird danach genau einmal je Ansicht — aber über den + // ganzen Bestand. Ohne Index liest SQLite dafür jede Zeile. + e.HasIndex(x => new { x.LibraryId, x.MetadataLanguage }); e.HasOne().WithMany().HasForeignKey(x => x.LibraryId) .OnDelete(DeleteBehavior.Cascade); }); @@ -208,6 +212,10 @@ public sealed class FlaroContext(DbContextOptions options) : DbCon e.Property(x => x.ImdbId).HasMaxLength(32); e.Property(x => x.Tagline).HasMaxLength(512); e.Property(x => x.ContentRating).HasMaxLength(16); + e.Property(x => x.MetadataLanguage).HasMaxLength(16); + // Gefragt wird danach genau einmal je Ansicht — aber über den + // ganzen Bestand. Ohne Index liest SQLite dafür jede Zeile. + e.HasIndex(x => new { x.LibraryId, x.MetadataLanguage }); e.HasOne().WithMany().HasForeignKey(x => x.LibraryId) .OnDelete(DeleteBehavior.Cascade); }); @@ -343,6 +351,7 @@ public sealed class FlaroContext(DbContextOptions options) : DbCon // und die hängen beide an diesem einen Eintrag. e.HasIndex(x => new { x.SeriesId, x.SeasonNumber, x.EpisodeNumber }).IsUnique(); e.Property(x => x.Title).HasMaxLength(512); + e.Property(x => x.MetadataLanguage).HasMaxLength(16); e.HasOne().WithMany().HasForeignKey(x => x.SeriesId).OnDelete(DeleteBehavior.Cascade); }); } diff --git a/server/src/Flaro.Data/MetadataEnricher.cs b/server/src/Flaro.Data/MetadataEnricher.cs index 4054a8e..0a6ec1f 100644 --- a/server/src/Flaro.Data/MetadataEnricher.cs +++ b/server/src/Flaro.Data/MetadataEnricher.cs @@ -43,9 +43,16 @@ public sealed class MetadataEnricher( /// Nachgeholt wird über die bekannte Kennung, nicht über eine neue /// Suche. Eine Zuordnung von Hand bleibt damit stehen. /// + /// + /// Nur Titel anfassen, die nicht in dieser Sprache vorliegen. Für + /// den Fall nach einer Sprachumstellung: zwölf abweichende Titel + /// aufzufrischen kostet zwölf Anfragen, der ganze Bestand viertausend. + /// null heisst: keine Einschränkung. + /// public async Task EnrichAsync( Guid libraryId, bool erneut = false, - IProgress? fortschritt = null, CancellationToken ct = default) + IProgress? fortschritt = null, CancellationToken ct = default, + string? nurSprache = null) { if (!await anbieter.IsConfiguredAsync(ct)) { @@ -56,9 +63,9 @@ public sealed class MetadataEnricher( int filme = 0, serien = 0, folgen = 0; try { - filme = await FilmeZuordnen(libraryId, erneut, fortschritt, ct); - serien = await SerienZuordnen(libraryId, erneut, fortschritt, ct); - folgen = await FolgenZuordnen(libraryId, erneut, fortschritt, ct); + filme = await FilmeZuordnen(libraryId, erneut, fortschritt, ct, nurSprache); + serien = await SerienZuordnen(libraryId, erneut, fortschritt, ct, nurSprache); + folgen = await FolgenZuordnen(libraryId, erneut, fortschritt, ct, nurSprache); } catch (MetadataCredentialsException fehler) { @@ -74,10 +81,18 @@ public sealed class MetadataEnricher( } private async Task FilmeZuordnen( - Guid libraryId, bool erneut, IProgress? fortschritt, CancellationToken ct) + Guid libraryId, bool erneut, IProgress? fortschritt, CancellationToken ct, + string? nurSprache = null) { var offen = await db.Movies - .Where(m => m.LibraryId == libraryId && (erneut || m.TmdbId == null)) + .Where(m => m.LibraryId == libraryId && (erneut || m.TmdbId == null) + // **Beim Sprachlauf nur Zugeordnetes.** Ein Titel ohne + // Kennung hat keine Sprache, weil er keine Metadaten hat — + // das ist ein Zuordnungsproblem. Ihn hier mitzunehmen hiesse, + // bei jedem Sprachlauf dieselben dreihundert vergeblichen + // Suchen erneut zu starten. + && (nurSprache == null + || (m.TmdbId != null && m.MetadataLanguage != nurSprache))) .ToListAsync(ct); var zugeordnet = 0; var durch = 0; @@ -121,7 +136,17 @@ public sealed class MetadataEnricher( // // Also nicht setzen, sondern vormerken: zusammengelegt wird nach // der Schleife, wenn nichts mehr dazwischenkommt. - if (vergeben.TryGetValue(gefunden.ProviderId, out var schonDa)) + // **Sich selbst ist keine Dublette, und das war der teure Teil.** + // + // `vergeben` enthält jeden Film, der schon eine Kennung hat — beim + // Auffrischen also auch diesen hier. Ohne den Vergleich auf + // Gleichheit fand sich jeder zugeordnete Film als „Dublette seiner + // selbst", wurde übersprungen und nie fortgeschrieben. „Metadaten + // auffrischen" tat damit an allem, was schon zugeordnet war, + // schlicht nichts: Titel, Beschreibung und Sprache blieben für + // immer so, wie sie beim ersten Lauf hereinkamen. Genau das sah + // aus wie „die Spracheinstellung wirkt nicht". + if (vergeben.TryGetValue(gefunden.ProviderId, out var schonDa) && schonDa != film.Id) { zusammenzulegen.Add((Sieger: schonDa, Verlierer: film.Id)); continue; @@ -130,9 +155,15 @@ public sealed class MetadataEnricher( vergeben[gefunden.ProviderId] = film.Id; film.TmdbId = gefunden.ProviderId; film.ImdbId = gefunden.ImdbId; - film.Title = gefunden.Title; + // **Ein Titel von Hand bleibt stehen.** Wie bei den Bildern: ohne + // diese Prüfung tippt man ihn nach jedem Auffrischen neu. + if (!film.TitlePinned) + { + film.Title = gefunden.Title; + film.SortTitleValue = SortTitle.For(gefunden.Title); + } film.OriginalTitle = gefunden.OriginalTitle; - film.SortTitleValue = SortTitle.For(gefunden.Title); + film.MetadataLanguage = gefunden.Language; film.Year = gefunden.Year ?? film.Year; film.Overview = gefunden.Overview; // **Was von Hand gewählt wurde, bleibt.** Ohne diese Prüfung @@ -196,10 +227,13 @@ public sealed class MetadataEnricher( } private async Task SerienZuordnen( - Guid libraryId, bool erneut, IProgress? fortschritt, CancellationToken ct) + Guid libraryId, bool erneut, IProgress? fortschritt, CancellationToken ct, + string? nurSprache = null) { var offen = await db.Series - .Where(s => s.LibraryId == libraryId && (erneut || s.TmdbId == null)) + .Where(s => s.LibraryId == libraryId && (erneut || s.TmdbId == null) + && (nurSprache == null + || (s.TmdbId != null && s.MetadataLanguage != nurSprache))) .ToListAsync(ct); var zugeordnet = 0; var durch = 0; @@ -235,7 +269,8 @@ public sealed class MetadataEnricher( // noch häufiger — derselbe Ordner unter zwei Schreibweisen, oder // ein Staffelordner, der versehentlich als eigene Serie gelesen // wurde. - if (vergeben.TryGetValue(gefunden.ProviderId, out var schonDa)) + // Siehe FilmeZuordnen: sich selbst ist keine Dublette. + if (vergeben.TryGetValue(gefunden.ProviderId, out var schonDa) && schonDa != serie.Id) { zusammenzulegen.Add((Sieger: schonDa, Verlierer: serie.Id)); continue; @@ -244,9 +279,13 @@ public sealed class MetadataEnricher( vergeben[gefunden.ProviderId] = serie.Id; serie.TmdbId = gefunden.ProviderId; serie.ImdbId = gefunden.ImdbId; - serie.Title = gefunden.Title; + if (!serie.TitlePinned) + { + serie.Title = gefunden.Title; + serie.SortTitleValue = SortTitle.For(gefunden.Title); + } serie.OriginalTitle = gefunden.OriginalTitle; - serie.SortTitleValue = SortTitle.For(gefunden.Title); + serie.MetadataLanguage = gefunden.Language; serie.Year = gefunden.Year ?? serie.Year; serie.Overview = gefunden.Overview; // **Was von Hand gewählt wurde, bleibt.** Ohne diese Prüfung @@ -323,7 +362,8 @@ public sealed class MetadataEnricher( /// Folgen nachschlagen — aber nur bei Serien, die zugeordnet sind. private async Task FolgenZuordnen( - Guid libraryId, bool erneut, IProgress? fortschritt, CancellationToken ct) + Guid libraryId, bool erneut, IProgress? fortschritt, CancellationToken ct, + string? nurSprache = null) { // **Mit Titel, nicht nur mit Kennung.** In der Fortschrittszeile stand // „Hole Metadaten zu Folge 2×13" — und bei 3815 Folgen aus achtzig @@ -336,7 +376,9 @@ public sealed class MetadataEnricher( var kennungen = serien.Select(s => s.Id).ToList(); var offen = await db.Episodes - .Where(f => kennungen.Contains(f.SeriesId) && (erneut || f.Overview == null)) + .Where(f => kennungen.Contains(f.SeriesId) && (erneut || f.Overview == null) + && (nurSprache == null + || (f.Overview != null && f.MetadataLanguage != nurSprache))) .ToListAsync(ct); // Einmal nachschlagbar machen: `First` je Folge wäre bei 3815 Folgen @@ -361,6 +403,7 @@ public sealed class MetadataEnricher( // Ein Titel aus dem Dateinamen wird ersetzt: TMDB kennt ihn besser. // Ein leerer Fund überschreibt dagegen nichts. folge.Title = gefunden.Title ?? folge.Title; + folge.MetadataLanguage = gefunden.Language; folge.Overview = gefunden.Overview; folge.StillImage = await BildAblegen(gefunden.Still, ct); zugeordnet++; diff --git a/server/src/Flaro.Data/Migrations/20261004183828_Metadatensprache.Designer.cs b/server/src/Flaro.Data/Migrations/20261004183828_Metadatensprache.Designer.cs new file mode 100644 index 0000000..6a0cac6 --- /dev/null +++ b/server/src/Flaro.Data/Migrations/20261004183828_Metadatensprache.Designer.cs @@ -0,0 +1,907 @@ +// +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("20261004183828_Metadatensprache")] + partial class Metadatensprache + { + /// + 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.Property("ReviewNewItems") + .HasColumnType("INTEGER"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + 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("MetadataLanguage") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("Overview") + .HasColumnType("TEXT"); + + b.Property("PublishAt") + .HasColumnType("INTEGER"); + + 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("BackdropPinned") + .HasColumnType("INTEGER"); + + 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("LogoPinned") + .HasColumnType("INTEGER"); + + b.Property("MatchLocked") + .HasColumnType("INTEGER"); + + b.Property("MetadataLanguage") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + 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("PosterPinned") + .HasColumnType("INTEGER"); + + b.Property("PublishAt") + .HasColumnType("INTEGER"); + + 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("TitlePinned") + .HasColumnType("INTEGER"); + + b.Property("TmdbId") + .HasColumnType("INTEGER"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId", "MetadataLanguage"); + + 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("BackdropPinned") + .HasColumnType("INTEGER"); + + 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("LogoPinned") + .HasColumnType("INTEGER"); + + b.Property("MatchLocked") + .HasColumnType("INTEGER"); + + b.Property("MetadataLanguage") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + 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("PosterPinned") + .HasColumnType("INTEGER"); + + b.Property("PublishAt") + .HasColumnType("INTEGER"); + + 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("TitlePinned") + .HasColumnType("INTEGER"); + + b.Property("TmdbId") + .HasColumnType("INTEGER"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId", "MetadataLanguage"); + + 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/20261004183828_Metadatensprache.cs b/server/src/Flaro.Data/Migrations/20261004183828_Metadatensprache.cs new file mode 100644 index 0000000..23e09d1 --- /dev/null +++ b/server/src/Flaro.Data/Migrations/20261004183828_Metadatensprache.cs @@ -0,0 +1,91 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Flaro.Data.Migrations +{ + /// + public partial class Metadatensprache : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "MetadataLanguage", + table: "Series", + type: "TEXT", + maxLength: 16, + nullable: true); + + migrationBuilder.AddColumn( + name: "TitlePinned", + table: "Series", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "MetadataLanguage", + table: "Movies", + type: "TEXT", + maxLength: 16, + nullable: true); + + migrationBuilder.AddColumn( + name: "TitlePinned", + table: "Movies", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "MetadataLanguage", + table: "Episodes", + type: "TEXT", + maxLength: 16, + nullable: true); + + migrationBuilder.CreateIndex( + name: "IX_Series_LibraryId_MetadataLanguage", + table: "Series", + columns: new[] { "LibraryId", "MetadataLanguage" }); + + migrationBuilder.CreateIndex( + name: "IX_Movies_LibraryId_MetadataLanguage", + table: "Movies", + columns: new[] { "LibraryId", "MetadataLanguage" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Series_LibraryId_MetadataLanguage", + table: "Series"); + + migrationBuilder.DropIndex( + name: "IX_Movies_LibraryId_MetadataLanguage", + table: "Movies"); + + migrationBuilder.DropColumn( + name: "MetadataLanguage", + table: "Series"); + + migrationBuilder.DropColumn( + name: "TitlePinned", + table: "Series"); + + migrationBuilder.DropColumn( + name: "MetadataLanguage", + table: "Movies"); + + migrationBuilder.DropColumn( + name: "TitlePinned", + table: "Movies"); + + migrationBuilder.DropColumn( + name: "MetadataLanguage", + table: "Episodes"); + } + } +} diff --git a/server/src/Flaro.Data/Migrations/FlaroContextModelSnapshot.cs b/server/src/Flaro.Data/Migrations/FlaroContextModelSnapshot.cs index 6244a1e..2ac6b69 100644 --- a/server/src/Flaro.Data/Migrations/FlaroContextModelSnapshot.cs +++ b/server/src/Flaro.Data/Migrations/FlaroContextModelSnapshot.cs @@ -193,6 +193,10 @@ namespace Flaro.Data.Migrations b.Property("EpisodeNumber") .HasColumnType("INTEGER"); + b.Property("MetadataLanguage") + .HasMaxLength(16) + .HasColumnType("TEXT"); + b.Property("Overview") .HasColumnType("TEXT"); @@ -471,6 +475,10 @@ namespace Flaro.Data.Migrations b.Property("MatchLocked") .HasColumnType("INTEGER"); + b.Property("MetadataLanguage") + .HasMaxLength(16) + .HasColumnType("TEXT"); + b.Property("OriginalTitle") .HasMaxLength(512) .HasColumnType("TEXT"); @@ -514,6 +522,9 @@ namespace Flaro.Data.Migrations .HasMaxLength(512) .HasColumnType("TEXT"); + b.Property("TitlePinned") + .HasColumnType("INTEGER"); + b.Property("TmdbId") .HasColumnType("INTEGER"); @@ -522,6 +533,8 @@ namespace Flaro.Data.Migrations b.HasKey("Id"); + b.HasIndex("LibraryId", "MetadataLanguage"); + b.HasIndex("LibraryId", "SortTitleValue"); b.HasIndex("LibraryId", "TmdbId") @@ -591,6 +604,10 @@ namespace Flaro.Data.Migrations b.Property("MatchLocked") .HasColumnType("INTEGER"); + b.Property("MetadataLanguage") + .HasMaxLength(16) + .HasColumnType("TEXT"); + b.Property("OriginalTitle") .HasMaxLength(512) .HasColumnType("TEXT"); @@ -634,6 +651,9 @@ namespace Flaro.Data.Migrations .HasMaxLength(512) .HasColumnType("TEXT"); + b.Property("TitlePinned") + .HasColumnType("INTEGER"); + b.Property("TmdbId") .HasColumnType("INTEGER"); @@ -642,6 +662,8 @@ namespace Flaro.Data.Migrations b.HasKey("Id"); + b.HasIndex("LibraryId", "MetadataLanguage"); + b.HasIndex("LibraryId", "SortTitleValue"); b.HasIndex("LibraryId", "TmdbId") diff --git a/server/src/Flaro.Metadata/TmdbClient.cs b/server/src/Flaro.Metadata/TmdbClient.cs index a4125ac..a89e100 100644 --- a/server/src/Flaro.Metadata/TmdbClient.cs +++ b/server/src/Flaro.Metadata/TmdbClient.cs @@ -145,6 +145,9 @@ public sealed class TmdbClient( Genres = Namen(film.Genres), Credits = Mitwirkende(film.Credits), Logo = Schriftzug(film.Images, Sprachkürzel(einstellung.Language)), + // Die Sprache, in der diese Antwort kam — der Server merkt sie + // sich am Titel, damit später feststeht, was vorliegt. + Language = einstellung.Language, }; } @@ -213,6 +216,9 @@ public sealed class TmdbClient( Genres = Namen(serie.Genres), Credits = Mitwirkende(serie.Credits), Logo = Schriftzug(serie.Images, Sprachkürzel(einstellung.Language)), + // Die Sprache, in der diese Antwort kam — der Server merkt sie + // sich am Titel, damit später feststeht, was vorliegt. + Language = einstellung.Language, }; } @@ -239,7 +245,9 @@ public sealed class TmdbClient( var folge = await HolenAsync(pfad, einstellung, ct); if (folge is null) return null; - return new EpisodeMetadata(Leer(folge.Name), Leer(folge.Overview), Bild(folge.StillPath, StillSize)); + return new EpisodeMetadata( + Leer(folge.Name), Leer(folge.Overview), Bild(folge.StillPath, StillSize), + einstellung.Language); } /// Die ganze Trefferliste — für die Auswahl von Hand. diff --git a/server/tests/Flaro.Api.Tests/MetadataTests.cs b/server/tests/Flaro.Api.Tests/MetadataTests.cs index 29a897b..95660f7 100644 --- a/server/tests/Flaro.Api.Tests/MetadataTests.cs +++ b/server/tests/Flaro.Api.Tests/MetadataTests.cs @@ -223,6 +223,97 @@ public class MetadataTests } /// Was die Quelle nicht kennt, bleibt trotzdem in der Mediathek. + /// „Metadaten auffrischen" muss den Titel wirklich fortschreiben. + /// + /// + /// Der teuerste Fehler der ganzen Sprachgeschichte. Die + /// Dublettenprüfung schlägt jeden Titel nach, der schon eine Kennung hat — + /// beim Auffrischen also auch den gerade bearbeiteten. Ohne den Vergleich + /// auf Gleichheit fand sich jeder zugeordnete Film als „Dublette seiner + /// selbst" und wurde übersprungen: Titel, Beschreibung und Sprache blieben + /// für immer so, wie sie beim ersten Lauf hereinkamen. + /// + /// In der Oberfläche sah das aus, als wirke die Spracheinstellung nicht — + /// und genau so wurde es gemeldet: „eine Sprachänderung und anschliessendes + /// Neuholen bringt gar nichts". + /// + [Test] + public async Task Auffrischen_schreibt_einen_zugeordneten_Titel_fort() + { + Datei("101 Dalmatians (1996).mkv"); + var anbieter = new VorgegebenerAnbieter + { + Film = new MovieMetadata(8741, "101 Dalmatians", 1996, "Englisch.", null, null) + { + Language = "en-US", + }, + }; + + using var server = MitAnbieter(anbieter); + var (client, bibliothek) = await Einrichten(server, "Movies"); + await Scannen(client, bibliothek); + + var vorher = await client.GetFromJsonAsync>("/api/v1/movies"); + Assert.That(vorher!.Items[0].Title, Is.EqualTo("101 Dalmatians")); + + // Dieselbe Kennung, andere Sprache — genau der Fall nach einer Umstellung. + anbieter.Film = new MovieMetadata(8741, "101 Dalmatiner", 1996, "Deutsch.", null, null) + { + Language = "de-DE", + }; + + (await client.PostAsync($"/api/v1/libraries/{bibliothek}/refresh", null)) + .EnsureSuccessStatusCode(); + for (var i = 0; i < 100; i++) + { + var stand = await client.GetFromJsonAsync($"/api/v1/libraries/{bibliothek}/scan"); + if (stand!.State == "finished") break; + await Task.Delay(50); + } + + var nachher = await client.GetFromJsonAsync>("/api/v1/movies"); + Assert.That(nachher!.Total, Is.EqualTo(1), "Es bleibt ein Film, keine Dublette."); + Assert.That(nachher.Items[0].Title, Is.EqualTo("101 Dalmatiner")); + Assert.That(nachher.Items[0].Overview, Is.EqualTo("Deutsch.")); + } + + /// Was von Hand benannt wurde, überlebt das Auffrischen. + [Test] + public async Task Ein_festgeschriebener_Titel_bleibt_beim_Auffrischen_stehen() + { + Datei("Irgendwas (2001).mkv"); + var anbieter = new VorgegebenerAnbieter + { + Film = new MovieMetadata(42, "Automatisch", 2001, null, null, null) { Language = "de-DE" }, + }; + + using var server = MitAnbieter(anbieter); + var (client, bibliothek) = await Einrichten(server, "Movies"); + await Scannen(client, bibliothek); + + var filme = await client.GetFromJsonAsync>("/api/v1/movies"); + var kennung = filme!.Items[0].Id; + + (await client.PutAsJsonAsync($"/api/v1/movies/{kennung}/title", new { title = "Von Hand" })) + .EnsureSuccessStatusCode(); + + anbieter.Film = new MovieMetadata(42, "Automatisch, neu", 2001, null, null, null) + { + Language = "de-DE", + }; + (await client.PostAsync($"/api/v1/libraries/{bibliothek}/refresh", null)) + .EnsureSuccessStatusCode(); + for (var i = 0; i < 100; i++) + { + var stand = await client.GetFromJsonAsync($"/api/v1/libraries/{bibliothek}/scan"); + if (stand!.State == "finished") break; + await Task.Delay(50); + } + + var nachher = await client.GetFromJsonAsync>("/api/v1/movies"); + Assert.That(nachher!.Items[0].Title, Is.EqualTo("Von Hand")); + } + [Test] public async Task Ein_Film_ohne_Treffer_behaelt_seinen_Dateinamen() { diff --git a/web/app/src/api.ts b/web/app/src/api.ts index e4c8f83..9807937 100644 --- a/web/app/src/api.ts +++ b/web/app/src/api.ts @@ -621,6 +621,23 @@ export const pruefungSetzen = (bibliothek: string, an: boolean) => anfrage(`/libraries/${bibliothek}/review`, { method: 'PUT', body: JSON.stringify({ reviewNewItems: an }) }); +export type Sprachanteil = { sprache: string | null; anzahl: number }; +export type Sprachverteilung = { + serversprache: string; + anteile: Sprachanteil[]; + /** Wie viele Titel nicht in der Serversprache vorliegen. */ + abweichend: number; +}; + +/** In welcher Sprache der Bestand vorliegt — die Zahl, die die Frage beendet. */ +export const sprachverteilung = () => + anfrage('/settings/metadata/languages'); + +/** Frischt genau die Titel auf, die nicht zur Serversprache passen. */ +export const abweichendeAuffrischen = () => + anfrage<{ started: number; language: string }>( + '/settings/metadata/languages/refresh', { method: 'POST' }); + export type Speicherort = { name: string; variable: string; pfad: string; belegt: number; frei: number | null; bilder: number | null; @@ -720,6 +737,15 @@ export const metadatenSetzen = (werte: { apiKey?: string; language?: string }) = export const vorschlaege = (art: 'movies' | 'series', id: string, suche?: string, jahr?: number) => anfrage(`/${art}/${id}/matches${abfrage({ query: suche, year: jahr })}`); +/** Setzt den Titel von Hand und schreibt ihn fest. */ +export const titelSetzen = (art: 'movies' | 'series', id: string, titel: string) => + anfrage(`/${art}/${id}/title`, + { method: 'PUT', body: JSON.stringify({ title: titel }) }); + +/** Gibt den Titel wieder frei — das nächste Auffrischen holt ihn neu. */ +export const titelFreigeben = (art: 'movies' | 'series', id: string) => + anfrage(`/${art}/${id}/title`, { method: 'DELETE' }); + /** Woher ein Titel stammt — Ordner und ein paar Dateinamen. */ export type Herkunft = { ordner: string | null; dateien: number; beispiele: string[] }; diff --git a/web/app/src/card.ts b/web/app/src/card.ts index 62c23b0..39e8408 100644 --- a/web/app/src/card.ts +++ b/web/app/src/card.ts @@ -7,6 +7,7 @@ import { icons } from './icons'; import { kontextmenue, menueAnZiel, type Menuepunkt } from './menue'; import { bilddialog } from './bilder'; import { zuordnungsdialog } from './zuordnung'; +import { titeldialog } from './titel'; /** Was eine Kachel zeigt — unabhängig davon, woher sie kommt. */ export type Kachel = { @@ -267,6 +268,17 @@ function kachelpunkte( icon: 'stift', tun: () => bilddialog(art, k.id, k.titel), }); + punkte.push({ + text: 'Titel ändern', + icon: 'stift', + // **Nicht jeder Titel steht bei TMDB.** Ein Mitschnitt hat gar keinen, + // und mancher heisst im Haushalt anders. Bisher blieb nur der Dateiname. + tun: () => titeldialog(art, k.id, k.titel, neu => { + k.titel = neu; + const text = knopf.querySelector('.kacheltext strong'); + if (text) text.textContent = neu; + }), + }); punkte.push({ text: 'Zuordnung korrigieren', icon: 'suche', diff --git a/web/app/src/dialog.ts b/web/app/src/dialog.ts index 398543c..fd954f0 100644 --- a/web/app/src/dialog.ts +++ b/web/app/src/dialog.ts @@ -47,6 +47,8 @@ export function dialog(optionen: { gefaehrlich?: boolean; /** Wird mit den Werten gerufen; wirft sie, bleibt der Dialog offen. */ tun: (wert: Dialogwerte) => Promise; + /** Ein dritter Knopf links, für den Weg zurück — etwa „Wieder automatisch". */ + weiterer?: { text: string; tun: () => Promise }; }): void { const d = document.createElement('dialog'); d.className = 'dlg'; @@ -154,6 +156,25 @@ export function dialog(optionen: { abbrechen.onclick = () => d.close(); const los = knopf(optionen.bestaetigen, optionen.gefaehrlich ? 'btn btn-gefahr' : 'btn btn-primary'); + + // Der dritte Knopf steht links aussen, abgesetzt von Abbrechen und + // Bestätigen — er gehört zu keinem von beiden. + if (optionen.weiterer) { + const dritter = knopf(optionen.weiterer.text, 'btn dlg-weiterer'); + dritter.onclick = async () => { + dritter.disabled = true; + try { await optionen.weiterer!.tun(); d.close(); } + catch (fehler) { + const m = document.createElement('div'); + m.className = 'fehler'; + m.textContent = meldung(fehler); + fehlerplatz.appendChild(m); + dritter.disabled = false; + } + }; + fuss.appendChild(dritter); + } + fuss.append(abbrechen, los); los.onclick = async () => { diff --git a/web/app/src/style.css b/web/app/src/style.css index 15702ed..8f8f064 100644 --- a/web/app/src/style.css +++ b/web/app/src/style.css @@ -1059,6 +1059,10 @@ h2 { font-size: var(--t-abschnitt); font-weight: 700; letter-spacing: -.015em; m * ein Element ohne Höhe kreuzt die Sichtgrenze nie zuverlässig. */ .nachlader { height: 1px; } +/* Der dritte Knopf im Dialogfuss steht links aussen — er gehört weder zu + * Abbrechen noch zu Bestätigen. */ +.dlg-weiterer { margin-right: auto; } + .bildblock { display: flex; flex-direction: column; gap: var(--s-snug); } .bildblock h3 { margin: 0; font-size: var(--t-gross); font-weight: 600; flex: 1; } diff --git a/web/app/src/titel.ts b/web/app/src/titel.ts new file mode 100644 index 0000000..3fe1796 --- /dev/null +++ b/web/app/src/titel.ts @@ -0,0 +1,42 @@ +import { titelSetzen, titelFreigeben } from './api'; +import { dialog } from './dialog'; + +/** Den Titel eines Eintrags von Hand setzen. + * + * **Warum es das braucht.** TMDB kennt nicht jeden Titel so, wie er im + * Haushalt heisst, und bei einem Mitschnitt gibt es gar keinen. Ohne diesen + * Weg bleibt nur der Dateiname oder ein falscher Treffer — und beides liess + * sich bisher nirgends geraderücken. + * + * **Gesetzt heisst festgeschrieben.** Sonst überschreibt das nächste + * „Metadaten auffrischen" die Korrektur, und man tippt sie nach jedem Lauf + * neu. Den Weg zurück gibt es trotzdem: der Knopf unten gibt ihn wieder frei. + */ +export function titeldialog( + art: 'movies' | 'series', + id: string, + jetzt: string, + fertig?: (neu: string) => void, +): void { + dialog({ + titel: 'Titel ändern', + text: 'Ein Titel von Hand bleibt stehen, auch wenn die Metadaten erneut ' + + 'geholt werden. „Wieder automatisch" gibt ihn frei — dann holt ihn der ' + + 'nächste Lauf wieder von der Metadatenquelle.', + felder: [{ name: 'titel', beschriftung: 'Titel', wert: jetzt }], + bestaetigen: 'Speichern', + tun: async wert => { + const neu = wert('titel').trim(); + if (neu.length === 0) throw new Error('Ein Titel darf nicht leer sein.'); + await titelSetzen(art, id, neu); + fertig?.(neu); + }, + weiterer: { + text: 'Wieder automatisch', + tun: async () => { + await titelFreigeben(art, id); + fertig?.(jetzt); + }, + }, + }); +} diff --git a/web/app/src/views/admin.ts b/web/app/src/views/admin.ts index 1487f64..d518fdd 100644 --- a/web/app/src/views/admin.ts +++ b/web/app/src/views/admin.ts @@ -10,11 +10,16 @@ import { servernameSetzen, systemauskunft, laufendeWiedergaben, serverzustand, bildAdresse, speicherorte, bildspeicherLeeren, unzugeordnet, + sprachverteilung, abweichendeAuffrischen, type Bibliothek, type Wiedergabe, ApiFehler, } from '../api'; import { esc, dauer, groesse, knopf, fehlerzeile, leer, meldung } from '../util'; import { dialog, nachfragen } from '../dialog'; -import { icon } from '../icons'; +import { icon, icons } from '../icons'; +import { kontextmenue, menueAnZiel, type Menuepunkt } from '../menue'; +import { bilddialog } from '../bilder'; +import { zuordnungsdialog } from '../zuordnung'; +import { titeldialog } from '../titel'; /** Die Verwaltung. * @@ -321,9 +326,30 @@ function pruefbereich(geaendert: () => void): HTMLElement { catch (fehler) { d.appendChild(fehlerzeile(meldung(fehler))); nur.disabled = false; } }; + // **Dasselbe Dreipunktemenü wie auf einer Kachel.** + // + // Hier stand bisher nur „Veröffentlichen" — ausgerechnet bei dem Titel, + // den man vor der Freigabe geradeziehen will. Bilder, Zuordnung und Titel + // waren an einem wartenden Eintrag überhaupt nicht erreichbar. + const art = e.kind === 'series' ? 'series' : 'movies'; + const mehr = document.createElement('button'); + mehr.className = 'btn rund'; + mehr.innerHTML = icons.punkte; + mehr.setAttribute('aria-label', `Mehr zu „${e.title}“`); + const punkte = (): Menuepunkt[] => [ + { text: 'Titel ändern', icon: 'stift', + tun: () => titeldialog(art, e.id, e.title, () => void laden()) }, + { text: 'Bilder bearbeiten', icon: 'stift', + tun: () => bilddialog(art, e.id, e.title) }, + { text: 'Zuordnung korrigieren', icon: 'suche', + tun: () => zuordnungsdialog(art, e.id, e.title, e.year, () => void laden()) }, + ]; + mehr.onclick = () => menueAnZiel(mehr, punkte()); + kontextmenue(mehr, punkte); + const oben = document.createElement('div'); oben.className = 'knopfreihe'; - oben.append(haken, bild, text, nur); + oben.append(haken, bild, text, mehr, nur); if (geplant) { const zurueck = knopf('Zurückziehen', 'btn'); @@ -1140,6 +1166,45 @@ function sprachbereich(): HTMLElement { // **Die eigene Sprache, getrennt von der des Servers.** Die obere // bestimmt, was geholt wird; diese nur, was man selbst zu sehen bekommt. + // **Was tatsächlich vorliegt — nicht was eingestellt ist.** + // + // Der eigentliche Fehler war nicht, dass Titel englisch blieben, sondern + // dass sich nicht feststellen liess, ob sie es sind: nirgends stand, in + // welcher Sprache ein Titel geholt wurde. Hier steht es, als Zahl. + void sprachverteilung().then(v => { + const benennen = (k: string | null) => k === null + ? 'unbekannt (vor dieser Fassung geholt)' + : sprachen.find(x => x.wert === k)?.text ?? k; + + const zeileBestand = zeile( + 'Bestand', + v.anteile.length === 0 + ? 'Noch nichts zugeordnet' + : v.anteile.map(a => `${a.anzahl} × ${benennen(a.sprache)}`).join(' · '), + v.abweichend === 0 + ? 'Alles liegt in der Serversprache vor.' + : `${v.abweichend} Titel liegen in einer anderen Sprache vor. Das Auffrischen ` + + 'holt genau diese neu — nicht den ganzen Bestand.'); + + if (v.abweichend > 0) { + const los = knopf(`${v.abweichend} auffrischen`, 'btn btn-primary'); + los.onclick = async () => { + los.disabled = true; + try { + await abweichendeAuffrischen(); + los.textContent = 'Läuft…'; + } catch (fehler) { + zeileBestand.appendChild(fehlerzeile(meldung(fehler))); + los.disabled = false; + } + }; + zeileBestand.querySelector('.knopfreihe')?.appendChild(los); + } + + // Direkt unter die Serversprache, denn dort wird die Frage gestellt. + liste.insertBefore(zeileBestand, liste.children[1] ?? null); + }).catch(() => { /* Ohne Verteilung fehlt nur die Zeile. */ }); + // **Weicht sie ab, steht das deutlich dabei.** // // Eine eigene Sprache ändert nicht, was der Server holt — nur was man