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(' · ');
}
}