diff --git a/server/src/Flaro.Api/Endpoints/Contracts.cs b/server/src/Flaro.Api/Endpoints/Contracts.cs index 2eab31e..7baae0b 100644 --- a/server/src/Flaro.Api/Endpoints/Contracts.cs +++ b/server/src/Flaro.Api/Endpoints/Contracts.cs @@ -278,6 +278,17 @@ public sealed record MediaStreamResponse( /// stehen weiterhin daneben; wer sie braucht, hat sie. /// string? Display = null, + /// Die Auflösung als Wort — „4K", „Full HD", „HD", „SD". + /// + /// + /// Eigenes Feld, damit der Client nichts zerlegen muss. Die + /// Oberfläche las die Stufe bisher mit display.split(' · ')[0] + /// heraus — richtig, solange vor der Auflösung nie etwas anderes steht, + /// und falsch an dem Tag, an dem doch. + /// + string? ResolutionLabel = null, + /// Dieselbe Auflösung genau — „1080p". Gehört in den Tooltip. + string? ResolutionExact = null, /// Die Sprache als Wort — „Deutsch" statt „deu". /// diff --git a/server/src/Flaro.Api/Endpoints/LibraryContentEndpoints.cs b/server/src/Flaro.Api/Endpoints/LibraryContentEndpoints.cs index 10dc95a..31da721 100644 --- a/server/src/Flaro.Api/Endpoints/LibraryContentEndpoints.cs +++ b/server/src/Flaro.Api/Endpoints/LibraryContentEndpoints.cs @@ -95,7 +95,11 @@ public static class LibraryContentEndpoints MediaStreamKind.Audio => Benennung.Tonspur(s.Language, s.Codec, s.Channels, s.Title), _ => Benennung.Untertitelspur(s.Language, s.IsForced, fuerHoergeschaedigte: false), }, - LanguageName: Benennung.Sprache(s.Language))); + LanguageName: Benennung.Sprache(s.Language), + ResolutionLabel: s.Kind == MediaStreamKind.Video + ? Benennung.Aufloesungswort(s.Width, s.Height) : null, + ResolutionExact: s.Kind == MediaStreamKind.Video + ? Benennung.Aufloesung(s.Width, s.Height) : null)); } private static MediaFileResponse Datei( @@ -104,12 +108,19 @@ public static class LibraryContentEndpoints string? mediathekspfad = null) => new(id, System.IO.Path.GetFileName(pfad), bytes, schachtel, dauer?.TotalSeconds, spuren[id].ToList(), - // Steht sie nicht in der Datenbank, wird sie aus dem Pfad gelesen: - // ein Bestand von vor dieser Änderung soll nicht erst neu gescannt - // werden müssen, um „Mobil" zu zeigen. - fassung ?? FileNames.Fassung(pfad), + // **Drei Quellen, in dieser Reihenfolge.** Was in der Datenbank + // steht, kam aus dem Dateinamen und ist das Genaueste. Sonst wird + // der Pfad gelesen — ein Bestand von vor dieser Änderung soll + // nicht erst neu gescannt werden müssen, um „Mobil" zu zeigen. + // Und sonst die Messung: hier gerechnet und nicht gespeichert, + // damit ein neues Wort nicht erst einen Scan braucht. + fassung ?? FileNames.Fassung(pfad) ?? AusDenSpuren(spuren[id]), Ordner(pfad, mediathekspfad)); + /// Die Fassung aus der gemessenen Bildgrösse — „Full HD", „SD". + private static string? AusDenSpuren(IEnumerable spuren) => + spuren.FirstOrDefault(s => s.Kind == nameof(MediaStreamKind.Video))?.ResolutionLabel; + /// Der Pfad der Mediathek — nur für Administratoren. /// /// diff --git a/server/src/Flaro.Core/Library/FileNames.cs b/server/src/Flaro.Core/Library/FileNames.cs index cc3c4fc..74353cb 100644 --- a/server/src/Flaro.Core/Library/FileNames.cs +++ b/server/src/Flaro.Core/Library/FileNames.cs @@ -166,24 +166,6 @@ public static partial class FileNames private static bool IstFassungsordner(string name) => IstFassungsname(name) || Schlank(name).Equals("plexversions", StringComparison.OrdinalIgnoreCase); - /// Macht aus einer gemessenen Höhe einen Namen: 2160 wird „4K". - /// - /// - /// Der Rückfall, wenn der Dateiname nichts hergibt. Zwei Dateien ohne - /// Zusatz hiessen sonst beide gleich, und in der Auswahl stünde zweimal - /// dasselbe. - /// - public static string? FassungAusHoehe(int? hoehe) => hoehe switch - { - null or <= 0 => null, - >= 2000 => "4K", - >= 1400 => "1440p", - >= 1000 => "1080p", - >= 700 => "720p", - >= 500 => "576p", - _ => "SD", - }; - private static bool IstFassungsname(string wort) { var blank = wort.Trim(); @@ -207,7 +189,8 @@ public static partial class FileNames /// Eine geschlossene Liste. Nähme man jedes Wort hinter dem letzten /// Bindestrich, hiesse eine Fassung irgendwann „YIFY" oder „GRP" — das /// sind Veröffentlichungsgruppen, keine Fassungen. Lieber kein Name als - /// ein falscher: ohne Treffer greift . + /// ein falscher: ohne Treffer rechnet die Antwort sie aus der gemessenen + /// Bildgrösse — Benennung.Aufloesungswort. /// private static readonly Dictionary Fassungsnamen = new(StringComparer.OrdinalIgnoreCase) diff --git a/server/src/Flaro.Core/Media/Benennung.cs b/server/src/Flaro.Core/Media/Benennung.cs index 07c1a44..49a6db0 100644 --- a/server/src/Flaro.Core/Media/Benennung.cs +++ b/server/src/Flaro.Core/Media/Benennung.cs @@ -138,6 +138,42 @@ public static class Benennung _ => $"{hoehe}p", }; + /// Dieselbe Messung als Wort: „4K", „Full HD", „HD", „SD". + /// + /// + /// + /// Wer schauen will, wählt keine Zeilenzahl. „1080p" ist eine Angabe + /// für den, der eine Stufe einstellt; „Full HD" ist eine für den, der sich + /// fragt, ob es sich lohnt, dafür den Fernseher anzumachen. Der genaue Wert + /// geht nicht verloren — liefert ihn + /// weiter, und die Oberfläche zeigt ihn beim Überfahren. + /// + /// + /// 1440p heisst hier Full HD. „QHD" kennt kaum jemand, und ein + /// fünftes Wort für eine Auflösung, die zwischen zwei bekannten liegt, + /// hilft niemandem beim Entscheiden. „8K" steht nur da, damit eine + /// 8K-Datei nicht als 4K gemeldet wird. + /// + /// + /// Gerechnet wird mit Breite und Höhe, aus demselben Grund wie oben: + /// ein Film im Kinoformat hat 1920×800 und ist Full HD, nicht HD. + /// + /// + public static string? Aufloesungswort(int? breite, int? hoehe) + { + var a = Stufe(hoehe); + var b = Stufe(breite * 9 / 16); + return Math.Max(a ?? 0, b ?? 0) switch + { + <= 0 => null, + >= 3500 => "8K", + >= 2000 => "4K", + >= 1000 => "Full HD", + >= 700 => "HD", + _ => "SD", + }; + } + /// Die ganze Zeile zu einer Tonspur. public static string Tonspur(string? sprache, string? codec, int? kanaele, string? titel) { @@ -177,7 +213,8 @@ public static class Benennung public static string Bildspur(string? codec, int? breite, int? hoehe) { var teile = new List(); - if (Aufloesung(breite, hoehe) is { } a) teile.Add(a); + // Das Wort, nicht die Zahl — siehe `Aufloesungswort`. + if (Aufloesungswort(breite, hoehe) is { } a) teile.Add(a); if (Codec(codec) is { } c) teile.Add(c); return teile.Count > 0 ? string.Join(" · ", teile) : "Bild"; } diff --git a/server/src/Flaro.Data/LibraryScanner.cs b/server/src/Flaro.Data/LibraryScanner.cs index c3719f3..a3380d8 100644 --- a/server/src/Flaro.Data/LibraryScanner.cs +++ b/server/src/Flaro.Data/LibraryScanner.cs @@ -351,11 +351,12 @@ public sealed class LibraryScanner( datei.BitRate = gelesen.BitRate; datei.ProbedAt = laufZeit; - // **Der Rückfall für den Fassungsnamen.** Steht im Dateinamen nichts, - // taugt die gemessene Bildhöhe: zwei Dateien ohne Zusatz hiessen sonst - // beide gleich, und in der Auswahl stünde zweimal dasselbe. - datei.VersionLabel ??= FileNames.FassungAusHoehe( - gelesen.Streams.FirstOrDefault(s => s.Kind == MediaStreamKind.Video)?.Height); + // **Der Rückfall aus der Bildgrösse wird nicht mehr gespeichert.** + // Er stand in der Spalte und war damit das, was beim letzten Scan + // gegolten hat: als aus „576p" ein „SD" wurde, blieb in jeder + // bestehenden Zeile das alte Wort stehen. Jetzt rechnet die Antwort + // ihn aus den Spuren — siehe `LibraryContentEndpoints.Datei`. In der + // Spalte steht nur noch, was im Dateinamen stand. await db.SaveChangesAsync(ct); diff --git a/server/src/Flaro.Data/Migrations/20261007150601_Fassungsnamen.Designer.cs b/server/src/Flaro.Data/Migrations/20261007150601_Fassungsnamen.Designer.cs new file mode 100644 index 0000000..1cb705d --- /dev/null +++ b/server/src/Flaro.Data/Migrations/20261007150601_Fassungsnamen.Designer.cs @@ -0,0 +1,1078 @@ +// +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("20261007150601_Fassungsnamen")] + partial class Fassungsnamen + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.12"); + + modelBuilder.Entity("Flaro.Core.Auth.AuthCode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ApprovedByUserId") + .HasColumnType("TEXT"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("ConsumedAt") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("DeviceName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("INTEGER"); + + b.Property("HandleFingerprint") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Code"); + + b.HasIndex("HandleFingerprint") + .IsUnique(); + + b.ToTable("AuthCodes"); + }); + + modelBuilder.Entity("Flaro.Core.Auth.DeviceToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("DeviceName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("LastSeenAt") + .HasColumnType("INTEGER"); + + b.Property("RevokedAt") + .HasColumnType("INTEGER"); + + b.Property("TokenFingerprint") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TokenFingerprint") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("DeviceTokens"); + }); + + modelBuilder.Entity("Flaro.Core.Libraries.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("ReviewNewItems") + .HasColumnType("INTEGER"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Libraries"); + }); + + modelBuilder.Entity("Flaro.Core.Library.Credit", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Character") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("MovieId") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("TEXT"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MovieId"); + + b.HasIndex("PersonId"); + + b.HasIndex("SeriesId"); + + b.ToTable("Credits"); + }); + + modelBuilder.Entity("Flaro.Core.Library.Episode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AddedAt") + .HasColumnType("INTEGER"); + + b.Property("EpisodeNumber") + .HasColumnType("INTEGER"); + + b.Property("MetadataLanguage") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("Overview") + .HasColumnType("TEXT"); + + b.Property("PublishAt") + .HasColumnType("INTEGER"); + + b.Property("SeasonNumber") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("TEXT"); + + b.Property("StillImage") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId", "SeasonNumber", "EpisodeNumber") + .IsUnique(); + + b.ToTable("Episodes"); + }); + + modelBuilder.Entity("Flaro.Core.Library.Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Genres"); + }); + + modelBuilder.Entity("Flaro.Core.Library.GenreLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("GenreId") + .HasColumnType("TEXT"); + + b.Property("MovieId") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MovieId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("GenreId", "MovieId") + .IsUnique() + .HasFilter("\"MovieId\" IS NOT NULL"); + + b.HasIndex("GenreId", "SeriesId") + .IsUnique() + .HasFilter("\"SeriesId\" IS NOT NULL"); + + b.ToTable("GenreLinks"); + }); + + modelBuilder.Entity("Flaro.Core.Library.ItemTranslation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("EpisodeId") + .HasColumnType("TEXT"); + + b.Property("FetchedAt") + .HasColumnType("INTEGER"); + + b.Property("Language") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("MovieId") + .HasColumnType("TEXT"); + + b.Property("Overview") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Title") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EpisodeId", "Language") + .IsUnique() + .HasFilter("\"EpisodeId\" IS NOT NULL"); + + b.HasIndex("MovieId", "Language") + .IsUnique() + .HasFilter("\"MovieId\" IS NOT NULL"); + + b.HasIndex("SeriesId", "Language") + .IsUnique() + .HasFilter("\"SeriesId\" IS NOT NULL"); + + b.ToTable("Translations"); + }); + + modelBuilder.Entity("Flaro.Core.Library.MediaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("BitRate") + .HasColumnType("INTEGER"); + + b.Property("Container") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Duration") + .HasColumnType("INTEGER"); + + b.Property("EpisodeId") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("TEXT"); + + b.Property("MissingSince") + .HasColumnType("INTEGER"); + + b.Property("ModifiedAt") + .HasColumnType("INTEGER"); + + b.Property("MovieId") + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("ProbedAt") + .HasColumnType("INTEGER"); + + b.Property("SeenAt") + .HasColumnType("INTEGER"); + + b.Property("SizeBytes") + .HasColumnType("INTEGER"); + + b.Property("VersionLabel") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EpisodeId"); + + b.HasIndex("MovieId"); + + b.HasIndex("LibraryId", "Path") + .IsUnique(); + + b.ToTable("MediaFiles"); + }); + + modelBuilder.Entity("Flaro.Core.Library.MediaStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Channels") + .HasColumnType("INTEGER"); + + b.Property("Codec") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("Index") + .HasColumnType("INTEGER"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("IsForced") + .HasColumnType("INTEGER"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("Language") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("MediaFileId") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("MediaFileId", "Index") + .IsUnique(); + + b.ToTable("MediaStreams"); + }); + + modelBuilder.Entity("Flaro.Core.Library.Meldung", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Art") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("EpisodeId") + .HasColumnType("TEXT"); + + b.Property("ErledigtAm") + .HasColumnType("INTEGER"); + + b.Property("ErledigtVon") + .HasColumnType("TEXT"); + + b.Property("GemeldetAm") + .HasColumnType("INTEGER"); + + b.Property("Kommentar") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("MovieId") + .HasColumnType("TEXT"); + + b.Property("SeasonNumber") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("TEXT"); + + b.Property("Stelle") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EpisodeId"); + + b.HasIndex("MovieId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("ErledigtAm", "GemeldetAm"); + + b.HasIndex("UserId", "GemeldetAm"); + + b.ToTable("Meldungen"); + }); + + modelBuilder.Entity("Flaro.Core.Library.Movie", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AddedAt") + .HasColumnType("INTEGER"); + + b.Property("BackdropImage") + .HasColumnType("TEXT"); + + b.Property("BackdropPinned") + .HasColumnType("INTEGER"); + + b.Property("ChangedSince") + .HasColumnType("INTEGER"); + + b.Property("ContentRating") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("ImdbId") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("TEXT"); + + b.Property("LogoImage") + .HasColumnType("TEXT"); + + b.Property("LogoPinned") + .HasColumnType("INTEGER"); + + b.Property("MatchLocked") + .HasColumnType("INTEGER"); + + b.Property("MetadataLanguage") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("OriginalTitle") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Overview") + .HasColumnType("TEXT"); + + b.Property("ParsedTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ParsedYear") + .HasColumnType("INTEGER"); + + b.Property("PosterImage") + .HasColumnType("TEXT"); + + b.Property("PosterPinned") + .HasColumnType("INTEGER"); + + b.Property("PublishAt") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("RuntimeMinutes") + .HasColumnType("INTEGER"); + + b.Property("SortTitlePinned") + .HasColumnType("INTEGER"); + + b.Property("SortTitleValue") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("TitlePinned") + .HasColumnType("INTEGER"); + + b.Property("TmdbId") + .HasColumnType("INTEGER"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId", "MetadataLanguage"); + + b.HasIndex("LibraryId", "SortTitleValue"); + + b.HasIndex("LibraryId", "TmdbId") + .IsUnique() + .HasFilter("\"TmdbId\" IS NOT NULL"); + + b.ToTable("Movies"); + }); + + modelBuilder.Entity("Flaro.Core.Library.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Image") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("TmdbId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("TmdbId") + .IsUnique(); + + b.ToTable("People"); + }); + + modelBuilder.Entity("Flaro.Core.Library.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AddedAt") + .HasColumnType("INTEGER"); + + b.Property("BackdropImage") + .HasColumnType("TEXT"); + + b.Property("BackdropPinned") + .HasColumnType("INTEGER"); + + b.Property("ChangedSince") + .HasColumnType("INTEGER"); + + b.Property("ContentRating") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("ImdbId") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("TEXT"); + + b.Property("LogoImage") + .HasColumnType("TEXT"); + + b.Property("LogoPinned") + .HasColumnType("INTEGER"); + + b.Property("MatchLocked") + .HasColumnType("INTEGER"); + + b.Property("MetadataLanguage") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("OriginalTitle") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Overview") + .HasColumnType("TEXT"); + + b.Property("ParsedTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ParsedYear") + .HasColumnType("INTEGER"); + + b.Property("PosterImage") + .HasColumnType("TEXT"); + + b.Property("PosterPinned") + .HasColumnType("INTEGER"); + + b.Property("PublishAt") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("RuntimeMinutes") + .HasColumnType("INTEGER"); + + b.Property("SortTitlePinned") + .HasColumnType("INTEGER"); + + b.Property("SortTitleValue") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("TitlePinned") + .HasColumnType("INTEGER"); + + b.Property("TmdbId") + .HasColumnType("INTEGER"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId", "MetadataLanguage"); + + b.HasIndex("LibraryId", "SortTitleValue"); + + b.HasIndex("LibraryId", "TmdbId") + .IsUnique() + .HasFilter("\"TmdbId\" IS NOT NULL"); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("Flaro.Core.Playback.Sehtag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("EpisodeId") + .HasColumnType("TEXT"); + + b.Property("MovieId") + .HasColumnType("TEXT"); + + b.Property("Sekunden") + .HasColumnType("REAL"); + + b.Property("Tag") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EpisodeId"); + + b.HasIndex("MovieId"); + + b.HasIndex("UserId", "Tag"); + + b.HasIndex("UserId", "Tag", "EpisodeId") + .IsUnique() + .HasFilter("\"EpisodeId\" IS NOT NULL"); + + b.HasIndex("UserId", "Tag", "MovieId") + .IsUnique() + .HasFilter("\"MovieId\" IS NOT NULL"); + + b.ToTable("Sehtage"); + }); + + modelBuilder.Entity("Flaro.Core.Playback.UserItemState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Dismissed") + .HasColumnType("INTEGER"); + + b.Property("EpisodeId") + .HasColumnType("TEXT"); + + b.Property("Favourite") + .HasColumnType("INTEGER"); + + b.Property("InProgress") + .HasColumnType("INTEGER"); + + b.Property("LastPlayedAt") + .HasColumnType("INTEGER"); + + b.Property("MovieId") + .HasColumnType("TEXT"); + + b.Property("PlayCount") + .HasColumnType("INTEGER"); + + b.Property("Position") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("Runtime") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("Watched") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("EpisodeId"); + + b.HasIndex("MovieId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("UserId", "EpisodeId") + .IsUnique() + .HasFilter("\"EpisodeId\" IS NOT NULL"); + + b.HasIndex("UserId", "MovieId") + .IsUnique() + .HasFilter("\"MovieId\" IS NOT NULL"); + + b.HasIndex("UserId", "SeriesId") + .IsUnique() + .HasFilter("\"SeriesId\" IS NOT NULL"); + + b.HasIndex("UserId", "InProgress", "UpdatedAt"); + + b.ToTable("UserItemStates"); + }); + + modelBuilder.Entity("Flaro.Core.ServerSetting", b => + { + b.Property("Key") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("Settings"); + }); + + modelBuilder.Entity("Flaro.Core.Users.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AvatarImage") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("IsAdministrator") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UserName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.HasKey("Id"); + + b.HasIndex("UserName") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Flaro.Core.Auth.DeviceToken", b => + { + b.HasOne("Flaro.Core.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Flaro.Core.Library.Credit", b => + { + b.HasOne("Flaro.Core.Library.Movie", null) + .WithMany() + .HasForeignKey("MovieId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Flaro.Core.Library.Person", null) + .WithMany() + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Flaro.Core.Library.Series", null) + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Flaro.Core.Library.Episode", b => + { + b.HasOne("Flaro.Core.Library.Series", null) + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Flaro.Core.Library.GenreLink", b => + { + b.HasOne("Flaro.Core.Library.Genre", null) + .WithMany() + .HasForeignKey("GenreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Flaro.Core.Library.Movie", null) + .WithMany() + .HasForeignKey("MovieId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Flaro.Core.Library.Series", null) + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Flaro.Core.Library.MediaFile", b => + { + b.HasOne("Flaro.Core.Libraries.Library", null) + .WithMany() + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Flaro.Core.Library.MediaStream", b => + { + b.HasOne("Flaro.Core.Library.MediaFile", null) + .WithMany() + .HasForeignKey("MediaFileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Flaro.Core.Library.Meldung", 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.Library.Series", null) + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Flaro.Core.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .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.Sehtag", 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(); + }); + + 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.Library.Series", null) + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Flaro.Core.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/server/src/Flaro.Data/Migrations/20261007150601_Fassungsnamen.cs b/server/src/Flaro.Data/Migrations/20261007150601_Fassungsnamen.cs new file mode 100644 index 0000000..388d376 --- /dev/null +++ b/server/src/Flaro.Data/Migrations/20261007150601_Fassungsnamen.cs @@ -0,0 +1,45 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Flaro.Data.Migrations +{ + /// + public partial class Fassungsnamen : Migration + { + /// Räumt die gespeicherten Fassungsnamen weg, die aus einer + /// Messung stammen. + /// + /// + /// + /// Kein Schemawechsel, ein Datenwechsel. Der Fassungsname stand + /// bis jetzt in der Spalte, auch wenn er nur aus der Bildhöhe + /// gerechnet war — und damit stand dort, was beim letzten Scan galt. + /// Als aus „576p" ein „SD" wurde, erreichte das keine bestehende + /// Zeile. + /// + /// + /// Künftig rechnet die Antwort ihn aus den Spuren, und die Spalte hält + /// nur noch, was im Dateinamen stand. Diese Zeilen hier sind die + /// Altlast: alles, was wie eine gerechnete Fassung aussieht, wird + /// geleert. Ein „4K", das wirklich im Dateinamen stand, verschwindet + /// dabei auch — und kommt aus der Messung sofort als „4K" zurück. + /// + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql( + """ + UPDATE MediaFiles SET VersionLabel = NULL + WHERE VersionLabel GLOB '[0-9][0-9][0-9]p' + OR VersionLabel GLOB '[0-9][0-9][0-9][0-9]p' + OR VersionLabel IN ('4K', '8K', 'SD'); + """); + } + + /// Nichts zurückzunehmen: die alten Werte waren abgeleitet. + protected override void Down(MigrationBuilder migrationBuilder) + { + } + } +} diff --git a/server/tests/Flaro.Core.Tests/BenennungTests.cs b/server/tests/Flaro.Core.Tests/BenennungTests.cs index a18c44e..82153e4 100644 --- a/server/tests/Flaro.Core.Tests/BenennungTests.cs +++ b/server/tests/Flaro.Core.Tests/BenennungTests.cs @@ -51,6 +51,31 @@ public class BenennungTests public void Bildhoehen_werden_zu_Stufen(int hoehe, string erwartet) => Assert.That(Benennung.Aufloesung(hoehe), Is.EqualTo(erwartet)); + /// Dieselbe Messung als Wort — das, was in der Zeile steht. + [TestCase(3840, 2160, "4K")] + [TestCase(1920, 1080, "Full HD")] + [TestCase(2560, 1440, "Full HD")] + [TestCase(1280, 720, "HD")] + [TestCase(1024, 576, "SD")] + [TestCase(720, 480, "SD")] + [TestCase(null, null, null)] + public void Bildgroessen_werden_zu_Woertern(int? breite, int? hoehe, string? erwartet) => + Assert.That(Benennung.Aufloesungswort(breite, hoehe), Is.EqualTo(erwartet)); + + /// Kinoformat: 800 Zeilen, und trotzdem Full HD. + /// + /// + /// Derselbe Grund wie eine Stufe höher: gerechnet wird mit Breite und + /// Höhe. Nur an der Höhe gemessen hiesse dieser Film „HD", und daneben + /// stünde in der Mediathek ein 1280×720 mit demselben Wort. + /// + [Test] + public void Ein_beschnittenes_Breitbild_bleibt_Full_HD() + { + Assert.That(Benennung.Aufloesungswort(1920, 800), Is.EqualTo("Full HD")); + Assert.That(Benennung.Aufloesungswort(1280, 720), Is.EqualTo("HD")); + } + /// Der Fall aus der Oberfläche, Wort für Wort. [Test] public void Die_ganze_Tonspur() @@ -81,7 +106,7 @@ public class BenennungTests [Test] public void Die_ganze_Bildspur() => - Assert.That(Benennung.Bildspur("h264", 1040), Is.EqualTo("1080p · H.264")); + Assert.That(Benennung.Bildspur("h264", 1040), Is.EqualTo("Full HD · H.264")); } /// Nachtrag: Titel, die nichts hinzufügen. diff --git a/server/tests/Flaro.Core.Tests/TitelschluesselTests.cs b/server/tests/Flaro.Core.Tests/TitelschluesselTests.cs index 9b0883f..2252c69 100644 --- a/server/tests/Flaro.Core.Tests/TitelschluesselTests.cs +++ b/server/tests/Flaro.Core.Tests/TitelschluesselTests.cs @@ -69,14 +69,6 @@ public class FassungTests [TestCase("Filme/The Matrix (1999).mkv")] public void Raet_nicht(string pfad) => Assert.That(FileNames.Fassung(pfad), Is.Null); - [TestCase(2160, "4K")] - [TestCase(1080, "1080p")] - [TestCase(720, "720p")] - [TestCase(480, "SD")] - [TestCase(null, null)] - public void Bildet_die_Fassung_aus_der_Hoehe(int? hoehe, string? erwartet) => - Assert.That(FileNames.FassungAusHoehe(hoehe), Is.EqualTo(erwartet)); - /// Der Fassungszusatz darf nie im Titel landen. [Test] public void Die_Fassung_bleibt_aus_dem_Titel_heraus() diff --git a/web/app/src/api.ts b/web/app/src/api.ts index a3074d2..9491f75 100644 --- a/web/app/src/api.ts +++ b/web/app/src/api.ts @@ -19,6 +19,10 @@ export type Spur = { display: string | null; /** Die Sprache als Wort — „Deutsch" statt „deu". */ languageName: string | null; + /** Nur bei Bild: die Auflösung als Wort — „4K", „Full HD", „HD", „SD". */ + resolutionLabel?: string | null; + /** Nur bei Bild: dieselbe Auflösung genau — „1080p". */ + resolutionExact?: string | null; }; export type Datei = { id: string; fileName: string; sizeBytes: number; diff --git a/web/app/src/views/detail.ts b/web/app/src/views/detail.ts index 527001b..d690849 100644 --- a/web/app/src/views/detail.ts +++ b/web/app/src/views/detail.ts @@ -396,14 +396,20 @@ export function detailseite(art: Art, id: string, zurueck: () => void): HTMLElem if (datei.durationSeconds) zeile('Laufzeit', laufzeit(datei.durationSeconds)); if (bild) { - // Nur die Stufe, nicht der Codec: „H.264" ist für den, der schauen - // will, kein Unterschied, den er trifft. - const stufe = bild.display?.split(' · ')[0] ?? null; + // **Das Wort, nicht die Zahl.** „Full HD" sagt dem, der schauen will, + // ob es sich lohnt; „1080p" ist eine Angabe für den, der eine Stufe + // einstellt. Beides kommt fertig vom Server — vorher stand hier + // `display.split(' · ')[0]`, also die Annahme, dass vor der Auflösung + // nie etwas anderes steht. + const stufe = bild.resolutionLabel ?? null; if (stufe) { const v = zeile('Auflösung', stufe); - // Die gemessene Grösse gehört in den Tooltip: 1920×800 ist kein + // Genau und gemessen gehören in den Tooltip: 1920×800 ist kein // Fehler, sondern Kinoformat. - if (bild.width && bild.height) v.title = `${bild.width} × ${bild.height}`; + v.title = [ + bild.resolutionExact, + bild.width && bild.height ? `${bild.width} × ${bild.height}` : null, + ].filter(Boolean).join(' · '); } }