Zwei Titel je Film, und die Sprache lässt sich wählen
Die Mediathek sah zweisprachig aus — "Despicable Me" neben "Ich einfach unverbesserlich". Es gab nur ein Titelfeld, und welche Sprache darin landete, entschied die Einstellung zum Zeitpunkt des Scans. Movie und Series tragen jetzt zusätzlich OriginalTitle. Gesucht wird über beide: wer "Despicable Me" tippt, findet "Ich einfach unverbesserlich". Auf der Detailseite steht der Originaltitel unter dem übersetzten, wenn er abweicht. Die Sprache der Metadaten ist in der Verwaltung eine Auswahl statt eines Textfelds. Vorher musste man "de-DE" wissen, und ein Tippfehler darin nahm TMDB stillschweigend als Englisch an. Dazu eine eigene Sprache je Benutzer. Das Ehrliche daran steht im Dialog: die Daten liegen in einer Sprache vor, und wer eine andere wählt, bekommt, was übersetzt vorliegt — für den Rest die Serversprache, nicht ein leeres Feld. ItemTranslation hält die zweite Sprache, gefüllt über eine Sammelaktion. Nicht nebenbei beim Öffnen: das verteilte die Wartezeit über Wochen und träfe immer den, der gerade etwas sucht. Alle Sprachen für alle Titel vorzuhalten wäre der Bestand mal Sprachen an Abrufen und Speicher, für einen Haushalt mit zweien. Deshalb auf Ansage. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
f359807561
commit
921533050b
@@ -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;
|
||||
|
||||
@@ -26,7 +26,10 @@ public sealed record LoginRequest(
|
||||
string UserName, string Password, string DeviceName, string DeviceId,
|
||||
bool StaySignedIn = true);
|
||||
|
||||
public sealed record AuthenticatedUser(Guid Id, string UserName, bool IsAdministrator);
|
||||
public sealed record AuthenticatedUser(
|
||||
Guid Id, string UserName, bool IsAdministrator,
|
||||
/// <summary>Die bevorzugte Sprache — <c>null</c> heisst: die des Servers.</summary>
|
||||
string? Language = null);
|
||||
|
||||
public sealed record LoginResponse(string Token, AuthenticatedUser User);
|
||||
|
||||
@@ -58,6 +61,11 @@ public sealed record RenameLibraryRequest(string Name);
|
||||
|
||||
public sealed record CreateLibraryRequest(string Name, string Kind, string Path);
|
||||
|
||||
public sealed record SprachRequest(string? Language);
|
||||
|
||||
/// <summary>Welche Sprache gilt — und wie weit sie abgedeckt ist.</summary>
|
||||
public sealed record SprachStand(string? Language, int Translated, int Total);
|
||||
|
||||
/// <summary>Ein Protokolltag.</summary>
|
||||
public sealed record Protokolltag(string Date, long Bytes);
|
||||
|
||||
@@ -135,7 +143,10 @@ public sealed record MovieResponse(
|
||||
/// von denen keiner angezeigt wird.
|
||||
/// </remarks>
|
||||
IReadOnlyList<string>? Genres = null,
|
||||
IReadOnlyList<CreditResponse>? Credits = null);
|
||||
IReadOnlyList<CreditResponse>? Credits = null,
|
||||
|
||||
/// <summary>Der Titel in der Sprache des Originals, wenn er abweicht.</summary>
|
||||
string? OriginalTitle = null);
|
||||
|
||||
public sealed record CreditResponse(
|
||||
Guid Id, string Name, string Role, string? Character, string? Image);
|
||||
@@ -158,7 +169,10 @@ public sealed record SeriesResponse(
|
||||
string? Tagline = null,
|
||||
string? ContentRating = null,
|
||||
IReadOnlyList<string>? Genres = null,
|
||||
IReadOnlyList<CreditResponse>? Credits = null);
|
||||
IReadOnlyList<CreditResponse>? Credits = null,
|
||||
|
||||
/// <summary>Der Titel in der Sprache des Originals, wenn er abweicht.</summary>
|
||||
string? OriginalTitle = null);
|
||||
|
||||
public sealed record EpisodeResponse(
|
||||
Guid Id, int Season, int Episode, string? Title, string? Overview, string? StillImage,
|
||||
|
||||
@@ -181,16 +181,38 @@ public static class LibraryContentEndpoints
|
||||
}).WithName("Scanstand").RequireAuthorization();
|
||||
}
|
||||
|
||||
/// <summary>Die Sprache dieses Benutzers — oder <c>null</c> für die des Servers.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Einmal je Anfrage, nicht je Zeile.</b> Ein Abruf für fünfzig Titel
|
||||
/// wäre fünfzig Abfragen für eine Angabe, die sich dabei nicht ändert.
|
||||
/// </remarks>
|
||||
private static async Task<string?> SpracheVon(ClaimsPrincipal wer, FlaroContext db)
|
||||
{
|
||||
if (PlaybackEndpoints.Benutzer(wer) is not { } kennung) return null;
|
||||
return await db.Users.Where(u => u.Id == kennung).Select(u => u.Language).FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
private static void MapBrowse(IEndpointRouteBuilder app)
|
||||
{
|
||||
app.MapGet("/api/v1/movies", async (
|
||||
ClaimsPrincipal wer, FlaroContext db,
|
||||
Guid? library, string? search, string? sort, string? genre, int skip = 0, int take = 50) =>
|
||||
{
|
||||
var sprache = await SpracheVon(wer, db);
|
||||
var abfrage = db.Movies.AsNoTracking();
|
||||
if (library is { } kennung) abfrage = abfrage.Where(m => m.LibraryId == kennung);
|
||||
// **Über beide Titel.** Wer „Despicable Me" tippt, soll „Ich einfach
|
||||
// unverbesserlich" finden — und umgekehrt. Nur über den
|
||||
// angezeigten zu suchen hiesse, die halbe Mediathek unauffindbar
|
||||
// zu machen, sobald jemand den anderen Namen im Kopf hat.
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
abfrage = abfrage.Where(m => EF.Functions.Like(m.Title, $"%{search.Trim()}%"));
|
||||
{
|
||||
var muster = $"%{search.Trim()}%";
|
||||
abfrage = abfrage.Where(m =>
|
||||
EF.Functions.Like(m.Title, muster)
|
||||
|| (m.OriginalTitle != null && EF.Functions.Like(m.OriginalTitle, muster)));
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(genre))
|
||||
{
|
||||
var gesucht = genre.Trim();
|
||||
@@ -213,7 +235,22 @@ public static class LibraryContentEndpoints
|
||||
.Skip(Math.Max(0, skip)).Take(Math.Clamp(take, 1, HöchstensJeSeite))
|
||||
.Select(m => new
|
||||
{
|
||||
m.Id, m.Title, m.Year, m.Overview, m.PosterImage, m.BackdropImage,
|
||||
m.Id,
|
||||
// **Die Übersetzung sticht, wenn es eine gibt.** Fehlt sie,
|
||||
// steht der Titel in der Sprache des Servers da — nicht
|
||||
// leer. Ein Film ohne englische Übersetzung heisst dann
|
||||
// eben weiter „Das Boot", und das ist richtiger als eine
|
||||
// Lücke.
|
||||
Title = sprache == null ? m.Title
|
||||
: db.Translations
|
||||
.Where(t => t.MovieId == m.Id && t.Language == sprache)
|
||||
.Select(t => t.Title).FirstOrDefault() ?? m.Title,
|
||||
m.OriginalTitle, m.Year,
|
||||
Overview = sprache == null ? m.Overview
|
||||
: db.Translations
|
||||
.Where(t => t.MovieId == m.Id && t.Language == sprache)
|
||||
.Select(t => t.Overview).FirstOrDefault() ?? m.Overview,
|
||||
m.PosterImage, m.BackdropImage,
|
||||
m.LogoImage, m.RuntimeMinutes, m.Rating, m.ContentRating,
|
||||
})
|
||||
.ToListAsync();
|
||||
@@ -234,7 +271,8 @@ public static class LibraryContentEndpoints
|
||||
dateien.Where(f => f.MovieId == m.Id)
|
||||
.Select(f => Datei(f.Id, f.Path, f.SizeBytes, f.Container, f.Duration, spuren)).ToList(),
|
||||
stände.GetValueOrDefault(m.Id),
|
||||
m.LogoImage, m.RuntimeMinutes, m.Rating, null, m.ContentRating)).ToList();
|
||||
m.LogoImage, m.RuntimeMinutes, m.Rating, null, m.ContentRating,
|
||||
OriginalTitle: m.OriginalTitle)).ToList();
|
||||
|
||||
return Results.Ok(new Page<MovieResponse>(items, gesamt));
|
||||
}).RequireAuthorization();
|
||||
@@ -243,10 +281,16 @@ public static class LibraryContentEndpoints
|
||||
ClaimsPrincipal wer, FlaroContext db,
|
||||
Guid? library, string? search, string? sort, string? genre, int skip = 0, int take = 50) =>
|
||||
{
|
||||
var sprache = await SpracheVon(wer, db);
|
||||
var abfrage = db.Series.AsNoTracking();
|
||||
if (library is { } kennung) abfrage = abfrage.Where(s => s.LibraryId == kennung);
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
abfrage = abfrage.Where(s => EF.Functions.Like(s.Title, $"%{search.Trim()}%"));
|
||||
{
|
||||
var muster = $"%{search.Trim()}%";
|
||||
abfrage = abfrage.Where(x =>
|
||||
EF.Functions.Like(x.Title, muster)
|
||||
|| (x.OriginalTitle != null && EF.Functions.Like(x.OriginalTitle, muster)));
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(genre))
|
||||
{
|
||||
var gesucht = genre.Trim();
|
||||
@@ -266,7 +310,17 @@ public static class LibraryContentEndpoints
|
||||
.Skip(Math.Max(0, skip)).Take(Math.Clamp(take, 1, HöchstensJeSeite))
|
||||
.Select(s => new
|
||||
{
|
||||
s.Id, s.Title, s.Year, s.Overview, s.PosterImage, s.BackdropImage,
|
||||
s.Id,
|
||||
Title = sprache == null ? s.Title
|
||||
: db.Translations
|
||||
.Where(t => t.SeriesId == s.Id && t.Language == sprache)
|
||||
.Select(t => t.Title).FirstOrDefault() ?? s.Title,
|
||||
s.OriginalTitle, s.Year,
|
||||
Overview = sprache == null ? s.Overview
|
||||
: db.Translations
|
||||
.Where(t => t.SeriesId == s.Id && t.Language == sprache)
|
||||
.Select(t => t.Overview).FirstOrDefault() ?? s.Overview,
|
||||
s.PosterImage, s.BackdropImage,
|
||||
s.LogoImage, s.RuntimeMinutes, s.Rating, s.ContentRating,
|
||||
})
|
||||
.ToListAsync();
|
||||
@@ -289,7 +343,8 @@ public static class LibraryContentEndpoints
|
||||
s.Id, s.Title, s.Year, s.Overview, s.PosterImage, s.BackdropImage, meine.Count,
|
||||
meine.Select(f => f.SeasonNumber).Distinct().Order().ToList(),
|
||||
meine.Count(f => !gesehen.Contains(f.Id)),
|
||||
s.LogoImage, s.RuntimeMinutes, s.Rating, null, s.ContentRating);
|
||||
s.LogoImage, s.RuntimeMinutes, s.Rating, null, s.ContentRating,
|
||||
OriginalTitle: s.OriginalTitle);
|
||||
}).ToList();
|
||||
|
||||
return Results.Ok(new Page<SeriesResponse>(items, gesamt));
|
||||
@@ -338,13 +393,20 @@ public static class LibraryContentEndpoints
|
||||
var spuren = await SpurenZu(db, dateien.Select(f => f.Id).ToList());
|
||||
var stände = await ZuständeZu(db, wer, filme: [id]);
|
||||
|
||||
var sprache = await SpracheVon(wer, db);
|
||||
var uebersetzt = sprache == null ? null : await db.Translations.AsNoTracking()
|
||||
.FirstOrDefaultAsync(t => t.MovieId == id && t.Language == sprache);
|
||||
|
||||
return Results.Ok(new MovieResponse(
|
||||
film.Id, film.Title, film.Year, film.Overview, film.PosterImage, film.BackdropImage,
|
||||
film.Id, uebersetzt?.Title ?? film.Title, film.Year,
|
||||
uebersetzt?.Overview ?? film.Overview, film.PosterImage, film.BackdropImage,
|
||||
dateien.Select(f => Datei(f.Id, f.Path, f.SizeBytes, f.Container, f.Duration, spuren)).ToList(),
|
||||
stände.GetValueOrDefault(id),
|
||||
film.LogoImage, film.RuntimeMinutes, film.Rating, film.Tagline, film.ContentRating,
|
||||
film.LogoImage, film.RuntimeMinutes, film.Rating,
|
||||
uebersetzt?.Tagline ?? film.Tagline, film.ContentRating,
|
||||
await GenresZu(db, film: id, serie: null),
|
||||
await MitwirkendeZu(db, film: id, serie: null)));
|
||||
await MitwirkendeZu(db, film: id, serie: null),
|
||||
OriginalTitle: film.OriginalTitle));
|
||||
}).RequireAuthorization();
|
||||
|
||||
app.MapGet("/api/v1/series/{id:guid}", async (
|
||||
@@ -361,13 +423,20 @@ public static class LibraryContentEndpoints
|
||||
.ToListAsync();
|
||||
var gesehen = await GeseheneFolgen(db, wer, folgen.Select(f => f.Id).ToList());
|
||||
|
||||
var sprache = await SpracheVon(wer, db);
|
||||
var uebersetzt = sprache == null ? null : await db.Translations.AsNoTracking()
|
||||
.FirstOrDefaultAsync(t => t.SeriesId == id && t.Language == sprache);
|
||||
|
||||
return Results.Ok(new SeriesResponse(
|
||||
serie.Id, serie.Title, serie.Year, serie.Overview, serie.PosterImage, serie.BackdropImage,
|
||||
serie.Id, uebersetzt?.Title ?? serie.Title, serie.Year,
|
||||
uebersetzt?.Overview ?? serie.Overview, serie.PosterImage, serie.BackdropImage,
|
||||
folgen.Count, folgen.Select(f => f.SeasonNumber).Distinct().Order().ToList(),
|
||||
folgen.Count(f => !gesehen.Contains(f.Id)),
|
||||
serie.LogoImage, serie.RuntimeMinutes, serie.Rating, serie.Tagline, serie.ContentRating,
|
||||
serie.LogoImage, serie.RuntimeMinutes, serie.Rating,
|
||||
uebersetzt?.Tagline ?? serie.Tagline, serie.ContentRating,
|
||||
await GenresZu(db, film: null, serie: id),
|
||||
await MitwirkendeZu(db, film: null, serie: id)));
|
||||
await MitwirkendeZu(db, film: null, serie: id),
|
||||
OriginalTitle: serie.OriginalTitle));
|
||||
}).RequireAuthorization();
|
||||
|
||||
// **Die nächste Folge nach dieser.** Reihenfolge ist Staffel, dann
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Security.Claims;
|
||||
using Flaro.Core.Users;
|
||||
using Flaro.Core;
|
||||
using Flaro.Metadata;
|
||||
@@ -126,6 +127,44 @@ public static class SystemEndpoints
|
||||
: Results.NotFound();
|
||||
}).RequireAuthorization(FlaroPolicies.Administrator);
|
||||
|
||||
// **Die eigene Sprache.** Metadaten liegen in der des Servers vor; was
|
||||
// hier eingestellt wird, greift nur, soweit Übersetzungen da sind.
|
||||
// Deshalb kommt die Abdeckung gleich mit — ohne sie wäre „Englisch"
|
||||
// ein Versprechen, das der Server nicht hält.
|
||||
app.MapPut("/api/v1/users/me/language", async (
|
||||
SprachRequest anfrage, ClaimsPrincipal wer, FlaroContext db,
|
||||
Uebersetzer uebersetzer, CancellationToken ct) =>
|
||||
{
|
||||
if (PlaybackEndpoints.Benutzer(wer) is not { } kennung) return Results.Unauthorized();
|
||||
var benutzer = await db.Users.FirstOrDefaultAsync(u => u.Id == kennung, ct);
|
||||
if (benutzer is null) return Results.NotFound();
|
||||
|
||||
var sprache = anfrage.Language?.Trim();
|
||||
benutzer.Language = string.IsNullOrEmpty(sprache) ? null : sprache;
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
if (benutzer.Language is null) return Results.Ok(new SprachStand(null, 0, 0));
|
||||
|
||||
var abdeckung = await uebersetzer.AbdeckungAsync(benutzer.Language, ct);
|
||||
return Results.Ok(new SprachStand(benutzer.Language, abdeckung.Vorhanden, abdeckung.Gesamt));
|
||||
}).RequireAuthorization();
|
||||
|
||||
// Die Sammelaktion dazu — Administratoren, weil sie je Titel eine
|
||||
// Anfrage an TMDB kostet.
|
||||
app.MapPost("/api/v1/system/translations", async (
|
||||
SprachRequest anfrage, Uebersetzer uebersetzer, Tagebuch tagebuch, CancellationToken ct) =>
|
||||
{
|
||||
var sprache = anfrage.Language?.Trim();
|
||||
if (string.IsNullOrEmpty(sprache))
|
||||
return Results.BadRequest(new { fehler = "Ohne Sprache gibt es nichts zu holen." });
|
||||
|
||||
tagebuch.Schreiben($"Übersetzungen für {sprache} werden geholt.");
|
||||
var bericht = await uebersetzer.HolenAsync(sprache, null, ct);
|
||||
tagebuch.Schreiben(
|
||||
$"Übersetzungen für {sprache}: {bericht.Gesamt} geholt, {bericht.Fehlend} ohne Treffer.");
|
||||
return Results.Ok(bericht);
|
||||
}).RequireAuthorization(FlaroPolicies.Administrator);
|
||||
|
||||
app.MapPut("/api/v1/system/name", async (ServerNameRequest anfrage, FlaroContext db) =>
|
||||
{
|
||||
var name = anfrage.Name.Trim();
|
||||
|
||||
@@ -75,6 +75,7 @@ builder.Services.AddSingleton<ISidecarStore, SidecarWriter>();
|
||||
|
||||
builder.Services.AddScoped<LibraryScanner>();
|
||||
builder.Services.AddScoped<Zusammenlegung>();
|
||||
builder.Services.AddScoped<Uebersetzer>();
|
||||
|
||||
// Metadaten. Der Anbieter steckt hinter einer Schnittstelle, damit alles, was
|
||||
// ihn benutzt, ohne Netz und ohne Schlüssel geprüft werden kann.
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
namespace Flaro.Core.Library;
|
||||
|
||||
/// <summary>Ein Titel in einer anderen Sprache als der des Servers.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Warum nicht einfach alles in allen Sprachen holen.</b> 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Also auf Abruf.</b> Ö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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
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; }
|
||||
|
||||
/// <summary>Das Sprachkürzel, so wie es beim Abruf verwendet wurde.</summary>
|
||||
public required string Language { get; init; }
|
||||
|
||||
public string? Title { get; set; }
|
||||
public string? Overview { get; set; }
|
||||
public string? Tagline { get; set; }
|
||||
|
||||
/// <summary>Wann sie geholt wurde — für ein späteres Auffrischen.</summary>
|
||||
public DateTimeOffset FetchedAt { get; set; }
|
||||
}
|
||||
@@ -110,6 +110,17 @@ public sealed class Movie
|
||||
/// <summary>Ohne führenden Artikel — siehe <see cref="SortTitle"/>.</summary>
|
||||
public required string SortTitleValue { get; set; }
|
||||
|
||||
/// <summary>Der Titel in der Sprache des Originals.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Damit die Mediathek nicht zweisprachig aussieht.</b> 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.
|
||||
/// </remarks>
|
||||
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; }
|
||||
|
||||
/// <summary>Der Titel in der Sprache des Originals.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Damit die Mediathek nicht zweisprachig aussieht.</b> 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.
|
||||
/// </remarks>
|
||||
public string? OriginalTitle { get; set; }
|
||||
public int? Year { get; set; }
|
||||
public string? Overview { get; set; }
|
||||
public int? TmdbId { get; set; }
|
||||
|
||||
@@ -53,7 +53,10 @@ public sealed record MovieMetadata(
|
||||
///
|
||||
/// Nicht jeder Titel hat eines. Fehlt es, steht der Titel als Text da.
|
||||
/// </remarks>
|
||||
RemoteImage? Logo = null);
|
||||
RemoteImage? Logo = null,
|
||||
|
||||
/// <summary>Der Titel in der Sprache des Originals.</summary>
|
||||
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.
|
||||
/// </remarks>
|
||||
RemoteImage? Logo = null);
|
||||
RemoteImage? Logo = null,
|
||||
|
||||
/// <summary>Der Titel in der Sprache des Originals.</summary>
|
||||
string? OriginalTitle = null);
|
||||
|
||||
public sealed record EpisodeMetadata(string? Title, string? Overview, RemoteImage? Still);
|
||||
|
||||
@@ -119,8 +125,28 @@ public interface IMetadataProvider
|
||||
/// </remarks>
|
||||
Task<MovieMetadata?> GetMovieAsync(int providerId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Holt einen Titel in einer bestimmten Sprache.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Für die Übersetzungstabelle, nicht für den Scan.</b> Der Scan nimmt
|
||||
/// die Sprache des Servers; dies hier füllt nach, was ein Benutzer mit
|
||||
/// abweichender Sprache sehen will.
|
||||
/// </remarks>
|
||||
/// <remarks>
|
||||
/// <b>Mit Vorgabe.</b> 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.
|
||||
/// </remarks>
|
||||
Task<MovieMetadata?> GetMovieAsync(int providerId, string sprache, CancellationToken ct = default) =>
|
||||
GetMovieAsync(providerId, ct);
|
||||
|
||||
Task<SeriesMetadata?> GetSeriesAsync(int providerId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Holt eine Serie in einer bestimmten Sprache.</summary>
|
||||
Task<SeriesMetadata?> GetSeriesAsync(int providerId, string sprache, CancellationToken ct = default) =>
|
||||
GetSeriesAsync(providerId, ct);
|
||||
|
||||
/// <summary>Mehrere Vorschläge zur Auswahl — für das Korrigieren von Hand.</summary>
|
||||
Task<IReadOnlyList<MovieMetadata>> SearchMoviesAsync(string title, int? year, CancellationToken ct = default);
|
||||
|
||||
|
||||
@@ -19,5 +19,16 @@ public sealed class User
|
||||
|
||||
public bool IsAdministrator { get; set; }
|
||||
|
||||
/// <summary>Die bevorzugte Sprache — <c>null</c> heisst: die des Servers.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Und sie ist nicht kostenlos.</b> 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.
|
||||
/// </remarks>
|
||||
public string? Language { get; set; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ public sealed class FlaroContext(DbContextOptions<FlaroContext> options) : DbCon
|
||||
public DbSet<GenreLink> GenreLinks => Set<GenreLink>();
|
||||
public DbSet<Person> People => Set<Person>();
|
||||
public DbSet<Credit> Credits => Set<Credit>();
|
||||
public DbSet<ItemTranslation> Translations => Set<ItemTranslation>();
|
||||
|
||||
/// <summary>Gilt für jede Zeitangabe im Modell, ohne sie einzeln zu nennen.</summary>
|
||||
///
|
||||
@@ -62,6 +63,7 @@ public sealed class FlaroContext(DbContextOptions<FlaroContext> 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<FlaroContext> 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<FlaroContext> 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<FlaroContext> options) : DbCon
|
||||
e.HasOne<Series>().WithMany().HasForeignKey(x => x.SeriesId).OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
b.Entity<ItemTranslation>(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<UserItemState>(e =>
|
||||
{
|
||||
e.HasKey(x => x.Id);
|
||||
|
||||
@@ -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;
|
||||
|
||||
+852
@@ -0,0 +1,852 @@
|
||||
// <auto-generated />
|
||||
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
|
||||
{
|
||||
/// <inheritdoc />
|
||||
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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid?>("ApprovedByUserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Code")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long?>("ConsumedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("DeviceId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("DeviceName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("ExpiresAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("DeviceId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("DeviceName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("LastSeenAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long?>("RevokedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("TokenFingerprint")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Kind")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Path")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Libraries");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Flaro.Core.Library.Credit", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Character")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid?>("MovieId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Order")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid>("PersonId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Role")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid?>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("AddedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("EpisodeNumber")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Overview")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("SeasonNumber")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid>("SeriesId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("StillImage")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("GenreId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid?>("MovieId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid?>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid?>("EpisodeId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("FetchedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Language")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid?>("MovieId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Overview")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid?>("SeriesId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Tagline")
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long?>("BitRate")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Container")
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long?>("Duration")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid?>("EpisodeId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("LibraryId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long?>("MissingSince")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("ModifiedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid?>("MovieId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Path")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long?>("ProbedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("SeenAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("SizeBytes")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("Channels")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Codec")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("Height")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Index")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsDefault")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsForced")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Kind")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Language")
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("MediaFileId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("Width")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("MediaFileId", "Index")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("MediaStreams");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Flaro.Core.Library.Movie", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("AddedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("BackdropImage")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ContentRating")
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ImdbId")
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("LibraryId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("LogoImage")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("MatchLocked")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("OriginalTitle")
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Overview")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ParsedTitle")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("ParsedYear")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("PosterImage")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<double?>("Rating")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.Property<int?>("RuntimeMinutes")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("SortTitleValue")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Tagline")
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("TmdbId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int?>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Image")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("TmdbId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TmdbId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("People");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Flaro.Core.Library.Series", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("AddedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("BackdropImage")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ContentRating")
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ImdbId")
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("LibraryId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("LogoImage")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("MatchLocked")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("OriginalTitle")
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Overview")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ParsedTitle")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("ParsedYear")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("PosterImage")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<double?>("Rating")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b.Property<int?>("RuntimeMinutes")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("SortTitleValue")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Tagline")
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("TmdbId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int?>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("Dismissed")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid?>("EpisodeId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("Favourite")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("InProgress")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long?>("LastPlayedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid?>("MovieId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("PlayCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("Position")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int?>("Rating")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long?>("Runtime")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("UpdatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("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<string>("Key")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.IsRequired()
|
||||
.HasMaxLength(4096)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Key");
|
||||
|
||||
b.ToTable("Settings");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Flaro.Core.Users.User", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsAdministrator")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Language")
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Flaro.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class SpracheUndTitel : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Language",
|
||||
table: "Users",
|
||||
type: "TEXT",
|
||||
maxLength: 16,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "OriginalTitle",
|
||||
table: "Series",
|
||||
type: "TEXT",
|
||||
maxLength: 512,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "OriginalTitle",
|
||||
table: "Movies",
|
||||
type: "TEXT",
|
||||
maxLength: 512,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Translations",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
MovieId = table.Column<Guid>(type: "TEXT", nullable: true),
|
||||
SeriesId = table.Column<Guid>(type: "TEXT", nullable: true),
|
||||
EpisodeId = table.Column<Guid>(type: "TEXT", nullable: true),
|
||||
Language = table.Column<string>(type: "TEXT", maxLength: 16, nullable: false),
|
||||
Title = table.Column<string>(type: "TEXT", maxLength: 512, nullable: true),
|
||||
Overview = table.Column<string>(type: "TEXT", nullable: true),
|
||||
Tagline = table.Column<string>(type: "TEXT", maxLength: 512, nullable: true),
|
||||
FetchedAt = table.Column<long>(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");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -263,6 +263,57 @@ namespace Flaro.Data.Migrations
|
||||
b.ToTable("GenreLinks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Flaro.Core.Library.ItemTranslation", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid?>("EpisodeId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("FetchedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Language")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid?>("MovieId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Overview")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid?>("SeriesId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Tagline")
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("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<Guid>("Id")
|
||||
@@ -405,6 +456,10 @@ namespace Flaro.Data.Migrations
|
||||
b.Property<bool>("MatchLocked")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("OriginalTitle")
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Overview")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
@@ -509,6 +564,10 @@ namespace Flaro.Data.Migrations
|
||||
b.Property<bool>("MatchLocked")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("OriginalTitle")
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Overview")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
@@ -651,6 +710,10 @@ namespace Flaro.Data.Migrations
|
||||
b.Property<bool>("IsAdministrator")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Language")
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
using Flaro.Core.Library;
|
||||
using Flaro.Core.Metadata;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Flaro.Data;
|
||||
|
||||
/// <summary>Was ein Durchgang an Übersetzungen geholt hat.</summary>
|
||||
public sealed record Uebersetzungsbericht(int Filme, int Serien, int Fehlend)
|
||||
{
|
||||
public int Gesamt => Filme + Serien;
|
||||
}
|
||||
|
||||
/// <summary>Holt Titel und Beschreibungen in einer zweiten Sprache.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Warum überhaupt zweisprachig.</b> 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Als Sammelaktion, nicht nebenbei.</b> 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Was fehlt, erscheint in der Sprache des Servers</b> — 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class Uebersetzer(
|
||||
FlaroContext db, IMetadataProvider anbieter, TimeProvider uhr, ILogger<Uebersetzer> log)
|
||||
{
|
||||
/// <summary>Holt für alle Titel, was in dieser Sprache fehlt.</summary>
|
||||
public async Task<Uebersetzungsbericht> HolenAsync(
|
||||
string sprache, IProgress<Scanschritt>? 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);
|
||||
}
|
||||
|
||||
/// <summary>Wie weit eine Sprache abgedeckt ist — für den Hinweis beim Umstellen.</summary>
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -88,10 +88,19 @@ public sealed class TmdbClient(
|
||||
return alle.Select(m => Als(m, title)).ToList();
|
||||
}
|
||||
|
||||
public async Task<MovieMetadata?> GetMovieAsync(int providerId, CancellationToken ct = default)
|
||||
public Task<MovieMetadata?> GetMovieAsync(int providerId, CancellationToken ct = default) =>
|
||||
FilmHolenAsync(providerId, null, ct);
|
||||
|
||||
public Task<MovieMetadata?> GetMovieAsync(int providerId, string sprache, CancellationToken ct = default) =>
|
||||
FilmHolenAsync(providerId, sprache, ct);
|
||||
|
||||
/// <summary>Der gemeinsame Weg — mit Sprache oder mit der des Servers.</summary>
|
||||
private async Task<MovieMetadata?> 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<SeriesMetadata?> 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<SeriesMetadata?> GetSeriesAsync(int providerId, CancellationToken ct = default)
|
||||
public Task<SeriesMetadata?> GetSeriesAsync(int providerId, CancellationToken ct = default) =>
|
||||
SerieHolenAsync(providerId, null, ct);
|
||||
|
||||
public Task<SeriesMetadata?> GetSeriesAsync(int providerId, string sprache, CancellationToken ct = default) =>
|
||||
SerieHolenAsync(providerId, sprache, ct);
|
||||
|
||||
private async Task<SeriesMetadata?> 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<TmdbSeries>(
|
||||
$"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,
|
||||
|
||||
Reference in New Issue
Block a user