Ein Kontextmenü auf jeder Kachel — mitsamt Bildauswahl
Gesehen, Favorit und Weglegen gab es ausschliesslich auf der Detailseite. In einer Übersicht mit zwanzig Titeln hiess "die hab ich alle gesehen" vierzig Seitenwechsel. Im ganzen Quelltext stand kein einziger contextmenu-Zuhörer. Drei Wege hinein, weil es drei Arten gibt zu bedienen: Rechtsklick für die Maus, Shift+F10 und die Kontextmenü-Taste für die Tastatur, langer Druck für den Finger. Nur den Rechtsklick anzubieten hiesse, das Menü auf dem Telefon zu verstecken. Der lange Druck bricht bei jeder Bewegung ab, sonst löst Scrollen ihn aus. Die Aktionen wirken auf der Kachel selbst. Die Liste neu zu laden verlöre Bildlauf und Filter — für einen Haken. Dazu die Bildauswahl. Es gab genau einen Bild-Endpunkt, und der lieferte nur aus: wählte TMDB ein schlechtes Plakat, blieb es. Jetzt stehen Plakat, Hintergrund und Schriftzug zur Wahl, dazu eigene Bilder bis 10 MB. Die Kandidaten kosten nichts — TMDB liefert sie beim Einzelabruf ohnehin mit, und bisher wurde alles ausser dem Schriftzug weggeworfen. Drei Merker je Titel, nicht einer: man kann ein eigenes Plakat wollen und trotzdem automatische Hintergründe. Ohne sie überschriebe das nächste Auffrischen jede Handauswahl, und wer einmal ein besseres Plakat gesucht hat, suchte es jedes Mal neu. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
e1399c808f
commit
eb63271e92
@@ -0,0 +1,133 @@
|
||||
using Flaro.Core.Metadata;
|
||||
using Flaro.Data;
|
||||
using Flaro.Metadata;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Flaro.Api.Endpoints;
|
||||
|
||||
/// <summary>Plakat, Hintergrund und Schriftzug von Hand wählen.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Wofür.</b> Es gab genau einen Bild-Endpunkt, und der lieferte nur aus.
|
||||
/// Wählte die Metadatenquelle ein schlechtes Plakat, blieb es — und die
|
||||
/// einzige Abhilfe war, den ganzen Titel einem anderen Eintrag zuzuordnen.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Die Kandidaten kosten nichts.</b> TMDB liefert sie beim Einzelabruf
|
||||
/// ohnehin mit; bisher wurde alles ausser dem Schriftzug weggeworfen.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class ArtworkEndpoints
|
||||
{
|
||||
/// <summary>Höchstens so gross darf ein eigenes Bild sein.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// Zehn Megabyte reichen für jedes Plakat. Ohne Grenze lädt irgendwann
|
||||
/// jemand ein Foto hoch, und der Cache eines NAS ist nicht dafür da.
|
||||
/// </remarks>
|
||||
private const long HoechstensBytes = 10 * 1024 * 1024;
|
||||
|
||||
public static void MapArtwork(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var gruppe = app.MapGroup("/api/v1")
|
||||
.RequireAuthorization(FlaroPolicies.Administrator);
|
||||
|
||||
gruppe.MapGet("/{art:regex(^movies|series$)}/{id:guid}/artwork", async (
|
||||
string art, Guid id, FlaroContext db, TmdbClient tmdb, CancellationToken ct) =>
|
||||
{
|
||||
var serie = art == "series";
|
||||
var kennung = serie
|
||||
? await db.Series.Where(s => s.Id == id).Select(s => s.TmdbId).FirstOrDefaultAsync(ct)
|
||||
: await db.Movies.Where(m => m.Id == id).Select(m => m.TmdbId).FirstOrDefaultAsync(ct);
|
||||
|
||||
var aktuell = serie
|
||||
? await db.Series.Where(s => s.Id == id)
|
||||
.Select(s => new Bildstand(s.PosterImage, s.BackdropImage, s.LogoImage,
|
||||
s.PosterPinned, s.BackdropPinned, s.LogoPinned)).FirstOrDefaultAsync(ct)
|
||||
: await db.Movies.Where(m => m.Id == id)
|
||||
.Select(m => new Bildstand(m.PosterImage, m.BackdropImage, m.LogoImage,
|
||||
m.PosterPinned, m.BackdropPinned, m.LogoPinned)).FirstOrDefaultAsync(ct);
|
||||
|
||||
if (aktuell is null) return Results.NotFound();
|
||||
|
||||
// Ohne Zuordnung gibt es nichts zu wählen — dann ist die
|
||||
// Zuordnung das Problem, nicht das Bild.
|
||||
var kandidaten = kennung is { } k
|
||||
? await tmdb.BilderAsync(k, serie, ct)
|
||||
: [];
|
||||
|
||||
return Results.Ok(new Bildauswahl(aktuell, kandidaten));
|
||||
});
|
||||
|
||||
gruppe.MapPut("/{art:regex(^movies|series$)}/{id:guid}/artwork", async (
|
||||
string art, Guid id, BildWahlRequest anfrage,
|
||||
FlaroContext db, IImageStore ablage, CancellationToken ct) =>
|
||||
{
|
||||
if (anfrage.Kind is not ("poster" or "backdrop" or "logo"))
|
||||
return Results.BadRequest(new { fehler = "Erwartet: poster, backdrop oder logo." });
|
||||
|
||||
// Das Bild wandert in den Cache, bevor irgendetwas gesetzt wird —
|
||||
// sonst zeigt der Eintrag auf etwas, das es hier nicht gibt.
|
||||
var schluessel = string.IsNullOrWhiteSpace(anfrage.Url)
|
||||
? null
|
||||
: await ablage.StoreAsync(new RemoteImage(anfrage.Url), ct);
|
||||
|
||||
if (anfrage.Url is { Length: > 0 } && schluessel is null)
|
||||
return Results.Problem("Das Bild liess sich nicht laden.", statusCode: 502);
|
||||
|
||||
var gesetzt = art == "series"
|
||||
? await Setzen(db.Series.Where(s => s.Id == id), anfrage.Kind, schluessel, ct)
|
||||
: await Setzen(db.Movies.Where(m => m.Id == id), anfrage.Kind, schluessel, ct);
|
||||
|
||||
return gesetzt ? Results.NoContent() : Results.NotFound();
|
||||
});
|
||||
|
||||
gruppe.MapPost("/{art:regex(^movies|series$)}/{id:guid}/artwork", async (
|
||||
string art, Guid id, string kind, HttpRequest anfrage,
|
||||
FlaroContext db, IImageStore ablage, CancellationToken ct) =>
|
||||
{
|
||||
if (kind is not ("poster" or "backdrop" or "logo"))
|
||||
return Results.BadRequest(new { fehler = "Erwartet: poster, backdrop oder logo." });
|
||||
if (!anfrage.HasFormContentType) return Results.BadRequest(new { fehler = "Kein Formular." });
|
||||
|
||||
var form = await anfrage.ReadFormAsync(ct);
|
||||
var datei = form.Files.FirstOrDefault();
|
||||
if (datei is null || datei.Length == 0)
|
||||
return Results.BadRequest(new { fehler = "Keine Datei dabei." });
|
||||
if (datei.Length > HoechstensBytes)
|
||||
return Results.BadRequest(new { fehler = "Höchstens 10 MB." });
|
||||
|
||||
await using var strom = datei.OpenReadStream();
|
||||
var schluessel = await ablage.StoreStreamAsync(strom, datei.ContentType, ct);
|
||||
if (schluessel is null) return Results.Problem("Das Bild liess sich nicht ablegen.", statusCode: 502);
|
||||
|
||||
var gesetzt = art == "series"
|
||||
? await Setzen(db.Series.Where(s => s.Id == id), kind, schluessel, ct)
|
||||
: await Setzen(db.Movies.Where(m => m.Id == id), kind, schluessel, ct);
|
||||
|
||||
return gesetzt ? Results.Ok(new { image = schluessel }) : Results.NotFound();
|
||||
}).DisableAntiforgery();
|
||||
}
|
||||
|
||||
/// <summary>Setzt ein Bild und merkt sich, dass es von Hand kam.</summary>
|
||||
private static async Task<bool> Setzen<T>(
|
||||
IQueryable<T> abfrage, string art, string? schluessel, CancellationToken ct)
|
||||
where T : class, IBebildert
|
||||
{
|
||||
var betroffen = art switch
|
||||
{
|
||||
"poster" => await abfrage.ExecuteUpdateAsync(s => s
|
||||
.SetProperty(x => x.PosterImage, schluessel)
|
||||
.SetProperty(x => x.PosterPinned, schluessel != null), ct),
|
||||
"backdrop" => await abfrage.ExecuteUpdateAsync(s => s
|
||||
.SetProperty(x => x.BackdropImage, schluessel)
|
||||
.SetProperty(x => x.BackdropPinned, schluessel != null), ct),
|
||||
_ => await abfrage.ExecuteUpdateAsync(s => s
|
||||
.SetProperty(x => x.LogoImage, schluessel)
|
||||
.SetProperty(x => x.LogoPinned, schluessel != null), ct),
|
||||
};
|
||||
|
||||
return betroffen > 0;
|
||||
}
|
||||
}
|
||||
@@ -66,6 +66,16 @@ 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>Welche Bilder gerade gesetzt sind — und welche von Hand.</summary>
|
||||
public sealed record Bildstand(
|
||||
string? Poster, string? Backdrop, string? Logo,
|
||||
bool PosterPinned, bool BackdropPinned, bool LogoPinned);
|
||||
|
||||
public sealed record Bildauswahl(
|
||||
Bildstand Current, IReadOnlyList<Flaro.Core.Metadata.Bildkandidat> Candidates);
|
||||
|
||||
public sealed record BildWahlRequest(string Kind, string? Url);
|
||||
|
||||
/// <summary>Ein Protokolltag.</summary>
|
||||
public sealed record Protokolltag(string Date, long Bytes);
|
||||
|
||||
|
||||
@@ -185,6 +185,7 @@ app.MapImages();
|
||||
app.MapDelivery();
|
||||
app.MapPlayback();
|
||||
app.MapMatching();
|
||||
app.MapArtwork();
|
||||
|
||||
// **Was unter /api nicht beantwortet wurde, ist nicht da.** Ohne diese Zeile
|
||||
// fängt der Rückfall darunter jeden unbekannten Pfad ab und liefert die
|
||||
|
||||
@@ -78,7 +78,7 @@ public sealed class MediaFile
|
||||
public string? VersionLabel { get; set; }
|
||||
}
|
||||
|
||||
public sealed class Movie
|
||||
public sealed class Movie : Flaro.Core.Metadata.IBebildert
|
||||
{
|
||||
public Guid Id { get; init; } = Guid.CreateVersion7();
|
||||
public required Guid LibraryId { get; init; }
|
||||
@@ -110,6 +110,20 @@ public sealed class Movie
|
||||
/// <summary>Ohne führenden Artikel — siehe <see cref="SortTitle"/>.</summary>
|
||||
public required string SortTitleValue { get; set; }
|
||||
|
||||
/// <summary>Von Hand gewählt — ein Auffrischen überschreibt es nicht.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Getrennt je Bild, nicht als ein Schalter.</b> Man kann ein eigenes
|
||||
/// Plakat wollen und trotzdem automatische Hintergründe; ein gemeinsamer
|
||||
/// Merker zwänge zu einer Entscheidung für alle drei.
|
||||
///
|
||||
/// Ohne das überschreibt der nächste Lauf von
|
||||
/// <c>EnrichAsync(…, erneut: true)</c> jede Handauswahl wieder.
|
||||
/// </remarks>
|
||||
public bool PosterPinned { get; set; }
|
||||
public bool BackdropPinned { get; set; }
|
||||
public bool LogoPinned { get; set; }
|
||||
|
||||
/// <summary>Der Titel in der Sprache des Originals.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
@@ -185,7 +199,7 @@ public sealed class Movie
|
||||
public DateTimeOffset AddedAt { get; init; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
public sealed class Series
|
||||
public sealed class Series : Flaro.Core.Metadata.IBebildert
|
||||
{
|
||||
public Guid Id { get; init; } = Guid.CreateVersion7();
|
||||
public required Guid LibraryId { get; init; }
|
||||
@@ -215,6 +229,20 @@ public sealed class Series
|
||||
public required string Title { get; set; }
|
||||
public required string SortTitleValue { get; set; }
|
||||
|
||||
/// <summary>Von Hand gewählt — ein Auffrischen überschreibt es nicht.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Getrennt je Bild, nicht als ein Schalter.</b> Man kann ein eigenes
|
||||
/// Plakat wollen und trotzdem automatische Hintergründe; ein gemeinsamer
|
||||
/// Merker zwänge zu einer Entscheidung für alle drei.
|
||||
///
|
||||
/// Ohne das überschreibt der nächste Lauf von
|
||||
/// <c>EnrichAsync(…, erneut: true)</c> jede Handauswahl wieder.
|
||||
/// </remarks>
|
||||
public bool PosterPinned { get; set; }
|
||||
public bool BackdropPinned { get; set; }
|
||||
public bool LogoPinned { get; set; }
|
||||
|
||||
/// <summary>Der Titel in der Sprache des Originals.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
|
||||
@@ -174,6 +174,38 @@ public interface IImageStore
|
||||
{
|
||||
/// <summary>Holt das Bild und gibt seinen Schlüssel zurück — oder <c>null</c>.</summary>
|
||||
Task<string?> StoreAsync(RemoteImage bild, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Legt ein hochgeladenes Bild ab.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// Für eigene Plakate. Dieselbe Ablage wie für geholte — damit ein Bild
|
||||
/// unter derselben Adresse liegt, egal woher es stammt.
|
||||
/// </remarks>
|
||||
/// <remarks>
|
||||
/// <b>Mit Vorgabe.</b> Eine Ablage, die nur Geholtes kennt, lehnt das
|
||||
/// Hochladen ab — das ist ehrlicher als eine Pflichtmethode, die jeder
|
||||
/// Test-Doppelgänger nachbauen müsste, obwohl er sie nie braucht.
|
||||
/// </remarks>
|
||||
Task<string?> StoreStreamAsync(Stream inhalt, string? medientyp, CancellationToken ct = default) =>
|
||||
Task.FromResult<string?>(null);
|
||||
}
|
||||
|
||||
/// <summary>Was Bilder trägt — Film oder Serie.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Damit das Setzen eines Plakats nur einmal geschrieben werden muss.</b>
|
||||
/// Film und Serie tragen dieselben sechs Eigenschaften; ohne gemeinsame
|
||||
/// Schnittstelle stünde derselbe Code zweimal da und liefe irgendwann
|
||||
/// auseinander.
|
||||
/// </remarks>
|
||||
public interface IBebildert
|
||||
{
|
||||
string? PosterImage { get; set; }
|
||||
string? BackdropImage { get; set; }
|
||||
string? LogoImage { get; set; }
|
||||
bool PosterPinned { get; set; }
|
||||
bool BackdropPinned { get; set; }
|
||||
bool LogoPinned { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Ein Bild aus der Ablage wieder herausgeben.</summary>
|
||||
@@ -186,3 +218,12 @@ public interface IImageReader
|
||||
{
|
||||
Task<Stream?> OpenAsync(string schlüssel, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
/// <summary>Ein Bild zur Auswahl — Plakat, Hintergrund oder Schriftzug.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Mit Vorschau und Sprache.</b> Wer ein Plakat aussucht, sucht meist eins
|
||||
/// in seiner Sprache, und er will es sehen, bevor er es nimmt — nicht danach.
|
||||
/// </remarks>
|
||||
public sealed record Bildkandidat(
|
||||
string Kind, string Url, string ThumbUrl, string? Language, double? Vote);
|
||||
|
||||
@@ -108,9 +108,12 @@ public sealed class MetadataEnricher(
|
||||
film.SortTitleValue = SortTitle.For(gefunden.Title);
|
||||
film.Year = gefunden.Year ?? film.Year;
|
||||
film.Overview = gefunden.Overview;
|
||||
film.PosterImage = await BildAblegen(gefunden.Poster, ct);
|
||||
film.BackdropImage = await BildAblegen(gefunden.Backdrop, ct);
|
||||
film.LogoImage = await BildAblegen(gefunden.Logo, ct);
|
||||
// **Was von Hand gewählt wurde, bleibt.** Ohne diese Prüfung
|
||||
// überschreibt jedes Auffrischen die Auswahl wieder — und wer
|
||||
// einmal ein besseres Plakat gesucht hat, sucht es jedes Mal neu.
|
||||
if (!film.PosterPinned) film.PosterImage = await BildAblegen(gefunden.Poster, ct);
|
||||
if (!film.BackdropPinned) film.BackdropImage = await BildAblegen(gefunden.Backdrop, ct);
|
||||
if (!film.LogoPinned) film.LogoImage = await BildAblegen(gefunden.Logo, ct);
|
||||
film.RuntimeMinutes = gefunden.RuntimeMinutes;
|
||||
film.Rating = gefunden.Rating;
|
||||
film.Tagline = gefunden.Tagline;
|
||||
@@ -185,9 +188,12 @@ public sealed class MetadataEnricher(
|
||||
serie.SortTitleValue = SortTitle.For(gefunden.Title);
|
||||
serie.Year = gefunden.Year ?? serie.Year;
|
||||
serie.Overview = gefunden.Overview;
|
||||
serie.PosterImage = await BildAblegen(gefunden.Poster, ct);
|
||||
serie.BackdropImage = await BildAblegen(gefunden.Backdrop, ct);
|
||||
serie.LogoImage = await BildAblegen(gefunden.Logo, ct);
|
||||
// **Was von Hand gewählt wurde, bleibt.** Ohne diese Prüfung
|
||||
// überschreibt jedes Auffrischen die Auswahl wieder — und wer
|
||||
// einmal ein besseres Plakat gesucht hat, sucht es jedes Mal neu.
|
||||
if (!serie.PosterPinned) serie.PosterImage = await BildAblegen(gefunden.Poster, ct);
|
||||
if (!serie.BackdropPinned) serie.BackdropImage = await BildAblegen(gefunden.Backdrop, ct);
|
||||
if (!serie.LogoPinned) serie.LogoImage = await BildAblegen(gefunden.Logo, ct);
|
||||
serie.RuntimeMinutes = gefunden.RuntimeMinutes;
|
||||
serie.Rating = gefunden.Rating;
|
||||
serie.Tagline = gefunden.Tagline;
|
||||
|
||||
@@ -0,0 +1,870 @@
|
||||
// <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("20261004103857_Bildauswahl")]
|
||||
partial class Bildauswahl
|
||||
{
|
||||
/// <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<bool>("BackdropPinned")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
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>("LogoPinned")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
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<bool>("PosterPinned")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
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<bool>("BackdropPinned")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
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>("LogoPinned")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
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<bool>("PosterPinned")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
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,84 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Flaro.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class Bildauswahl : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "BackdropPinned",
|
||||
table: "Series",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "LogoPinned",
|
||||
table: "Series",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "PosterPinned",
|
||||
table: "Series",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "BackdropPinned",
|
||||
table: "Movies",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "LogoPinned",
|
||||
table: "Movies",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "PosterPinned",
|
||||
table: "Movies",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "BackdropPinned",
|
||||
table: "Series");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "LogoPinned",
|
||||
table: "Series");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "PosterPinned",
|
||||
table: "Series");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "BackdropPinned",
|
||||
table: "Movies");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "LogoPinned",
|
||||
table: "Movies");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "PosterPinned",
|
||||
table: "Movies");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -439,6 +439,9 @@ namespace Flaro.Data.Migrations
|
||||
b.Property<string>("BackdropImage")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("BackdropPinned")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ContentRating")
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("TEXT");
|
||||
@@ -453,6 +456,9 @@ namespace Flaro.Data.Migrations
|
||||
b.Property<string>("LogoImage")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("LogoPinned")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("MatchLocked")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
@@ -473,6 +479,9 @@ namespace Flaro.Data.Migrations
|
||||
b.Property<string>("PosterImage")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("PosterPinned")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<double?>("Rating")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
@@ -547,6 +556,9 @@ namespace Flaro.Data.Migrations
|
||||
b.Property<string>("BackdropImage")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("BackdropPinned")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ContentRating")
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("TEXT");
|
||||
@@ -561,6 +573,9 @@ namespace Flaro.Data.Migrations
|
||||
b.Property<string>("LogoImage")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("LogoPinned")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("MatchLocked")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
@@ -581,6 +596,9 @@ namespace Flaro.Data.Migrations
|
||||
b.Property<string>("PosterImage")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("PosterPinned")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<double?>("Rating")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
|
||||
@@ -31,6 +31,52 @@ public sealed class ImageCache(
|
||||
/// <summary>Mehr als das ist kein Plakat, sondern ein Versehen.</summary>
|
||||
private const long HöchstensBytes = 16 * 1024 * 1024;
|
||||
|
||||
/// <summary>Legt ein hochgeladenes Bild ab.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Derselbe Ort wie für geholte Bilder.</b> Ein eigenes Plakat liegt
|
||||
/// damit unter derselben Adresse wie eins von TMDB, und nichts weiter im
|
||||
/// Programm muss wissen, woher es kam.
|
||||
///
|
||||
/// Der Schlüssel kommt aus dem Inhalt, nicht aus einem Zufallswert:
|
||||
/// dasselbe Bild zweimal hochzuladen ergibt dieselbe Datei.
|
||||
/// </remarks>
|
||||
public async Task<string?> StoreStreamAsync(
|
||||
Stream inhalt, string? medientyp, CancellationToken ct = default)
|
||||
{
|
||||
using var zwischen = new MemoryStream();
|
||||
await inhalt.CopyToAsync(zwischen, ct);
|
||||
if (zwischen.Length == 0 || zwischen.Length > HöchstensBytes) return null;
|
||||
|
||||
var daten = zwischen.ToArray();
|
||||
var endung = medientyp switch
|
||||
{
|
||||
"image/png" => ".png",
|
||||
"image/webp" => ".webp",
|
||||
_ => ".jpg",
|
||||
};
|
||||
|
||||
var schlüssel = Convert.ToHexStringLower(System.Security.Cryptography.SHA256.HashData(daten))[..32]
|
||||
+ endung;
|
||||
var ziel = PfadFür(schlüssel);
|
||||
if (ziel is null) return null;
|
||||
if (File.Exists(ziel)) return schlüssel;
|
||||
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(ziel)!);
|
||||
var vorlaeufig = ziel + ".teil";
|
||||
await File.WriteAllBytesAsync(vorlaeufig, daten, ct);
|
||||
File.Move(vorlaeufig, ziel, overwrite: true);
|
||||
return schlüssel;
|
||||
}
|
||||
catch (IOException fehler)
|
||||
{
|
||||
log.LogWarning(fehler, "Hochgeladenes Bild liess sich nicht ablegen.");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<string?> StoreAsync(RemoteImage bild, CancellationToken ct = default)
|
||||
{
|
||||
var schlüssel = Schlüssel(bild.Url);
|
||||
|
||||
@@ -352,6 +352,54 @@ public sealed class TmdbClient(
|
||||
/// innerhalb einer Stufe entscheidet die Bewertung bei TMDB, weil es dort
|
||||
/// um Bildqualität geht und nicht mehr um die Sprache.
|
||||
/// </remarks>
|
||||
/// <summary>Alle Bilder zu einem Titel — für die Auswahl von Hand.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Die Liste kommt ohnehin mit.</b> <c>append_to_response=…,images</c>
|
||||
/// hängt sie an den Einzelabruf, und bisher wurde alles ausser dem
|
||||
/// Schriftzug weggeworfen. Die Auswahl kostet also keine zusätzliche
|
||||
/// Anfrage.
|
||||
/// </remarks>
|
||||
public async Task<IReadOnlyList<Bildkandidat>> BilderAsync(
|
||||
int providerId, bool serie, CancellationToken ct = default)
|
||||
{
|
||||
var einstellung = await einstellungen.GetAsync(ct);
|
||||
if (string.IsNullOrWhiteSpace(einstellung.ApiKey)) return [];
|
||||
|
||||
var sprache = Sprachkürzel(einstellung.Language);
|
||||
var weg = serie ? "tv" : "movie";
|
||||
var antwort = await HolenAsync<TmdbBilderAntwort>(
|
||||
$"{weg}/{providerId}/images?include_image_language={Uri.EscapeDataString(sprache)},en,null",
|
||||
einstellung, ct);
|
||||
|
||||
if (antwort is null) return [];
|
||||
|
||||
var liste = new List<Bildkandidat>();
|
||||
Sammeln(antwort.Posters, "poster", PosterSize);
|
||||
Sammeln(antwort.Backdrops, "backdrop", BackdropSize);
|
||||
Sammeln(antwort.Logos, "logo", "w500");
|
||||
return liste;
|
||||
|
||||
void Sammeln(List<TmdbImage>? bilder, string art, string groesse)
|
||||
{
|
||||
foreach (var b in (bilder ?? []).Where(b => !string.IsNullOrWhiteSpace(b.FilePath))
|
||||
.OrderByDescending(b => b.VoteAverage ?? 0)
|
||||
.Take(24))
|
||||
{
|
||||
liste.Add(new Bildkandidat(
|
||||
art, Bild(b.FilePath, groesse)!.Url,
|
||||
Bild(b.FilePath, "w185")!.Url,
|
||||
string.IsNullOrEmpty(b.Language) ? null : b.Language,
|
||||
b.VoteAverage));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record TmdbBilderAntwort(
|
||||
[property: JsonPropertyName("posters")] List<TmdbImage>? Posters,
|
||||
[property: JsonPropertyName("backdrops")] List<TmdbImage>? Backdrops,
|
||||
[property: JsonPropertyName("logos")] List<TmdbImage>? Logos);
|
||||
|
||||
private static RemoteImage? Schriftzug(TmdbImages? bilder, string sprache)
|
||||
{
|
||||
var alle = bilder?.Logos?.Where(l => !string.IsNullOrWhiteSpace(l.FilePath)).ToList();
|
||||
@@ -486,7 +534,9 @@ public sealed class TmdbClient(
|
||||
private sealed record TmdbGenre([property: JsonPropertyName("name")] string? Name);
|
||||
|
||||
private sealed record TmdbImages(
|
||||
[property: JsonPropertyName("logos")] List<TmdbImage>? Logos);
|
||||
[property: JsonPropertyName("logos")] List<TmdbImage>? Logos,
|
||||
[property: JsonPropertyName("posters")] List<TmdbImage>? Posters = null,
|
||||
[property: JsonPropertyName("backdrops")] List<TmdbImage>? Backdrops = null);
|
||||
|
||||
private sealed record TmdbImage(
|
||||
[property: JsonPropertyName("file_path")] string? FilePath,
|
||||
|
||||
Reference in New Issue
Block a user