Der Orden des Phönix stand vor dem Stein der Weisen

Alphabetisch stimmt das, nur sieht man sie andersherum. Es gibt jetzt einen
Sortiertitel: „Harry Potter 1", „Harry Potter 2" — und die Reihe steht in
der Ordnung, in der man sie schaut. Leer gelassen bleibt alles wie bisher,
beim Titel ohne Artikel davor.

Er hat einen eigenen Merker, nicht den des Titels. Wer die Reihenfolge
festlegt, soll den Titel trotzdem weiter automatisch bekommen; beides an
einem Schalter hiesse, für das eine auf das andere zu verzichten. Eine
Korrektur der Zuordnung lässt ihn ebenfalls stehen.

Und WALL·E bleibt nicht mehr unter E. Die Kachel wurde nach einer Korrektur
zwar neu beschriftet, blieb aber an ihrem Platz — jetzt rutscht sie dorthin,
wo sie hingehört. Liegt der Platz im noch nicht geladenen Teil der Liste,
geht sie weg und sagt wohin, statt falsch stehenzubleiben.

Dazu ein Dialog, in dem alles zu einem Titel beieinander steht: Titel,
Sortiertitel, Bilder, Zuordnung. Die vier Menüpunkte gab es schon, jeder für
sich richtig — zusammen aber kein Ort. Wer einen Film geraderückt, macht
selten nur eines davon. Die Abkürzungen bleiben.

Beim Bauen gefunden: das Zurücksetzen des Sortiertitels endete in einer 500.
SortTitle.For ist eine C#-Methode, und EF Core übersetzt sie nicht nach SQL.
Es geht um eine Zeile — die wird jetzt geladen und zurückgeschrieben, statt
die Regel ein zweites Mal als SQL zu schreiben.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-05 22:25:31 +02:00
co-authored by Claude Opus 5
parent e70c33f7e2
commit fcf40e4394
16 changed files with 1722 additions and 13 deletions
+32 -2
View File
@@ -283,7 +283,22 @@ public sealed record MovieResponse(
/// etwas sie unterscheidet — und wer einen davon sucht, findet ihn nur
/// über die Prüfliste.
/// </remarks>
DateTimeOffset? PublishAt = null);
DateTimeOffset? PublishAt = null,
/// <summary>Ob der Titel von Hand gesetzt wurde.</summary>
bool TitlePinned = false,
/// <summary>Wonach einsortiert wird, wenn nicht nach dem Titel.</summary>
///
/// <remarks>
/// <b>Damit der Client weiss, wohin ein Titel gehört.</b> Nach einer
/// Korrektur stand „WALL·E" weiter unter E, bis man die Seite neu lud —
/// die Kachel wusste schlicht nicht, dass sie woandershin gehört.
/// </remarks>
string? SortTitle = null,
/// <summary>Ob der Sortiertitel von Hand gesetzt wurde.</summary>
bool SortTitlePinned = false);
public sealed record CreditResponse(
Guid Id, string Name, string Role, string? Character, string? Image);
@@ -330,7 +345,22 @@ public sealed record SeriesResponse(
/// Kachel nicht wissen, ob „Als gesehen markieren" oder „Als ungesehen
/// markieren" darauf gehört — sie bot immer das erste an.
/// </remarks>
ItemStateResponse? State = null);
ItemStateResponse? State = null,
/// <summary>Ob der Titel von Hand gesetzt wurde.</summary>
bool TitlePinned = false,
/// <summary>Wonach einsortiert wird, wenn nicht nach dem Titel.</summary>
///
/// <remarks>
/// <b>Damit der Client weiss, wohin ein Titel gehört.</b> Nach einer
/// Korrektur stand „WALL·E" weiter unter E, bis man die Seite neu lud —
/// die Kachel wusste schlicht nicht, dass sie woandershin gehört.
/// </remarks>
string? SortTitle = null,
/// <summary>Ob der Sortiertitel von Hand gesetzt wurde.</summary>
bool SortTitlePinned = false);
public sealed record EpisodeResponse(
Guid Id, int Season, int Episode, string? Title, string? Overview, string? StillImage,
@@ -364,6 +364,10 @@ public static class LibraryContentEndpoints
m.PosterImage, m.BackdropImage,
m.LogoImage, m.RuntimeMinutes, m.Rating, m.ContentRating,
m.PublishAt,
// **Damit die Kachel weiss, wohin sie gehört.** Nach einer
// Korrektur stand „WALL·E" weiter unter E, bis man die
// Seite neu lud.
m.SortTitleValue, m.TitlePinned, m.SortTitlePinned,
})
.ToListAsync();
@@ -385,7 +389,10 @@ public static class LibraryContentEndpoints
stände.GetValueOrDefault(m.Id),
m.LogoImage, m.RuntimeMinutes, m.Rating, null, m.ContentRating,
OriginalTitle: m.OriginalTitle,
PublishAt: m.PublishAt)).ToList();
PublishAt: m.PublishAt,
TitlePinned: m.TitlePinned,
SortTitle: m.SortTitleValue,
SortTitlePinned: m.SortTitlePinned)).ToList();
return Results.Ok(new Page<MovieResponse>(items, gesamt));
}).RequireAuthorization();
@@ -440,6 +447,7 @@ public static class LibraryContentEndpoints
// ist; steht dort nichts, wartet alles.
PublishAt = db.Episodes.Where(f => f.SeriesId == s.Id)
.Min(f => f.PublishAt),
s.SortTitleValue, s.TitlePinned, s.SortTitlePinned,
})
.ToListAsync();
@@ -465,7 +473,10 @@ public static class LibraryContentEndpoints
s.LogoImage, s.RuntimeMinutes, s.Rating, null, s.ContentRating,
OriginalTitle: s.OriginalTitle,
PublishAt: s.PublishAt,
State: stände.GetValueOrDefault(s.Id));
State: stände.GetValueOrDefault(s.Id),
TitlePinned: s.TitlePinned,
SortTitle: s.SortTitleValue,
SortTitlePinned: s.SortTitlePinned);
}).ToList();
return Results.Ok(new Page<SeriesResponse>(items, gesamt));
@@ -562,7 +573,10 @@ public static class LibraryContentEndpoints
uebersetzt?.Tagline ?? film.Tagline, film.ContentRating,
await GenresZu(db, film: id, serie: null),
await MitwirkendeZu(db, film: id, serie: null),
OriginalTitle: film.OriginalTitle));
OriginalTitle: film.OriginalTitle,
TitlePinned: film.TitlePinned,
SortTitle: film.SortTitleValue,
SortTitlePinned: film.SortTitlePinned));
}).RequireAuthorization();
app.MapGet("/api/v1/series/{id:guid}", async (
@@ -593,7 +607,10 @@ public static class LibraryContentEndpoints
await GenresZu(db, film: null, serie: id),
await MitwirkendeZu(db, film: null, serie: id),
OriginalTitle: serie.OriginalTitle,
State: (await ZuständeZu(db, wer, serien: [id])).GetValueOrDefault(id)));
State: (await ZuständeZu(db, wer, serien: [id])).GetValueOrDefault(id),
TitlePinned: serie.TitlePinned,
SortTitle: serie.SortTitleValue,
SortTitlePinned: serie.SortTitlePinned));
}).RequireAuthorization();
// **Die nächste Folge nach dieser.** Reihenfolge ist Staffel, dann
@@ -51,19 +51,89 @@ public static class MatchEndpoints
["title"] = ["Höchstens 512 Zeichen."],
});
// **Der Sortiertitel bleibt, wenn er von Hand gesetzt wurde.**
// Sonst wäre „Harry Potter 3" nach jeder Titeländerung wieder weg.
var betroffen = art == "movies"
? await db.Movies.Where(m => m.Id == id).ExecuteUpdateAsync(s => s
.SetProperty(m => m.Title, titel)
.SetProperty(m => m.SortTitleValue, SortTitle.For(titel))
.SetProperty(m => m.SortTitleValue,
m => m.SortTitlePinned ? m.SortTitleValue : SortTitle.For(titel))
.SetProperty(m => m.TitlePinned, true))
: await db.Series.Where(x => x.Id == id).ExecuteUpdateAsync(s => s
.SetProperty(x => x.Title, titel)
.SetProperty(x => x.SortTitleValue, SortTitle.For(titel))
.SetProperty(x => x.SortTitleValue,
x => x.SortTitlePinned ? x.SortTitleValue : SortTitle.For(titel))
.SetProperty(x => x.TitlePinned, true));
return betroffen > 0 ? Results.NoContent() : Results.NotFound();
}).RequireAuthorization(FlaroPolicies.Administrator);
// **Der Sortiertitel — wonach einsortiert wird, wenn nicht nach dem Titel.**
//
// Eine Reihe steht alphabetisch durcheinander: „…Orden des Phönix" vor
// „…Stein der Weisen", weil O vor S liegt. Mit „Harry Potter 1",
// „Harry Potter 2" stehen sie so, wie man sie sieht.
app.MapPut("/api/v1/{art:regex(^movies|series$)}/{id:guid}/sorttitle", async (
string art, Guid id, TitelRequest anfrage, FlaroContext db) =>
{
var sortiert = anfrage.Title?.Trim();
if (string.IsNullOrEmpty(sortiert))
{
return Results.Problem(
"Ein Sortiertitel darf nicht leer sein. Zum Zurücksetzen gibt es „Wieder automatisch“.",
statusCode: StatusCodes.Status400BadRequest);
}
if (sortiert.Length > 512)
return Results.Problem("Höchstens 512 Zeichen.",
statusCode: StatusCodes.Status400BadRequest);
var betroffen = art == "movies"
? await db.Movies.Where(m => m.Id == id).ExecuteUpdateAsync(s => s
.SetProperty(m => m.SortTitleValue, sortiert)
.SetProperty(m => m.SortTitlePinned, true))
: await db.Series.Where(x => x.Id == id).ExecuteUpdateAsync(s => s
.SetProperty(x => x.SortTitleValue, sortiert)
.SetProperty(x => x.SortTitlePinned, true));
return betroffen > 0 ? Results.NoContent() : Results.NotFound();
}).RequireAuthorization(FlaroPolicies.Administrator);
// **Zurück zum errechneten Sortiertitel.**
//
// Anders als beim Titel wird er sofort neu gebildet: er steht nirgends
// sichtbar, und ihn bis zum nächsten Auffrischen falsch stehen zu
// lassen hiesse, dass die Liste solange in der falschen Reihenfolge
// steht.
//
// **Geladen und zurückgeschrieben, nicht in einem Rutsch.** Die Regel
// steht in `SortTitle.For` — einer C#-Methode, die EF Core nicht nach
// SQL übersetzen kann. Der Versuch endete in einer 500; dieselbe Regel
// ein zweites Mal als SQL zu schreiben wäre die Variante, bei der
// beide irgendwann auseinanderlaufen. Es geht um eine Zeile.
app.MapDelete("/api/v1/{art:regex(^movies|series$)}/{id:guid}/sorttitle", async (
string art, Guid id, FlaroContext db) =>
{
if (art == "movies")
{
var film = await db.Movies.FirstOrDefaultAsync(m => m.Id == id);
if (film is null) return Results.NotFound();
film.SortTitleValue = SortTitle.For(film.Title);
film.SortTitlePinned = false;
}
else
{
var serie = await db.Series.FirstOrDefaultAsync(x => x.Id == id);
if (serie is null) return Results.NotFound();
serie.SortTitleValue = SortTitle.For(serie.Title);
serie.SortTitlePinned = false;
}
await db.SaveChangesAsync();
return Results.NoContent();
}).RequireAuthorization(FlaroPolicies.Administrator);
// **Zurück zum automatischen Titel.**
//
// Ohne diesen Weg gäbe es keinen zurück, ausser den alten Namen zu
+4 -2
View File
@@ -26,7 +26,9 @@ public sealed class MatchApplier(
film.TmdbId = gefunden.ProviderId;
film.ImdbId = gefunden.ImdbId;
film.Title = gefunden.Title;
film.SortTitleValue = SortTitle.For(gefunden.Title);
// Ein von Hand gesetzter Sortiertitel überlebt auch eine Korrektur der
// Zuordnung — sonst wäre die Reihenfolge nach jedem Geradeziehen hin.
if (!film.SortTitlePinned) film.SortTitleValue = SortTitle.For(gefunden.Title);
film.Year = gefunden.Year ?? film.Year;
film.Overview = gefunden.Overview;
film.PosterImage = await Ablegen(gefunden.Poster, ct);
@@ -51,7 +53,7 @@ public sealed class MatchApplier(
serie.TmdbId = gefunden.ProviderId;
serie.ImdbId = gefunden.ImdbId;
serie.Title = gefunden.Title;
serie.SortTitleValue = SortTitle.For(gefunden.Title);
if (!serie.SortTitlePinned) serie.SortTitleValue = SortTitle.For(gefunden.Title);
serie.Year = gefunden.Year ?? serie.Year;
serie.Overview = gefunden.Overview;
serie.PosterImage = await Ablegen(gefunden.Poster, ct);
@@ -151,6 +151,24 @@ public sealed class Movie : Flaro.Core.Metadata.IBebildert
/// </remarks>
public bool TitlePinned { get; set; }
/// <summary>Der Sortiertitel, wie ihn jemand von Hand gesetzt hat.</summary>
///
/// <remarks>
/// <para>
/// <b>Wofür man ihn braucht:</b> eine Reihe steht alphabetisch
/// durcheinander. „Harry Potter und der Orden des Phönix" kommt vor „Harry
/// Potter und der Stein der Weisen", weil O vor S liegt — obwohl der
/// Stein der erste Teil ist. Mit „Harry Potter 1", „Harry Potter 2" stehen
/// sie in der Reihenfolge, in der man sie sieht.
/// </para>
/// <para>
/// <b>Leer heisst: wie bisher.</b> Ohne Eintrag bleibt es bei dem Wert,
/// den <see cref="SortTitle"/> aus dem Titel errechnet — Artikel vorn
/// abgezogen. Nur wer etwas einträgt, weicht davon ab.
/// </para>
/// </remarks>
public bool SortTitlePinned { get; set; }
/// <summary>In welcher Sprache die Angaben geholt wurden.</summary>
///
/// <remarks>
@@ -314,6 +332,24 @@ public sealed class Series : Flaro.Core.Metadata.IBebildert
/// </remarks>
public bool TitlePinned { get; set; }
/// <summary>Der Sortiertitel, wie ihn jemand von Hand gesetzt hat.</summary>
///
/// <remarks>
/// <para>
/// <b>Wofür man ihn braucht:</b> eine Reihe steht alphabetisch
/// durcheinander. „Harry Potter und der Orden des Phönix" kommt vor „Harry
/// Potter und der Stein der Weisen", weil O vor S liegt — obwohl der
/// Stein der erste Teil ist. Mit „Harry Potter 1", „Harry Potter 2" stehen
/// sie in der Reihenfolge, in der man sie sieht.
/// </para>
/// <para>
/// <b>Leer heisst: wie bisher.</b> Ohne Eintrag bleibt es bei dem Wert,
/// den <see cref="SortTitle"/> aus dem Titel errechnet — Artikel vorn
/// abgezogen. Nur wer etwas einträgt, weicht davon ab.
/// </para>
/// </remarks>
public bool SortTitlePinned { get; set; }
/// <summary>In welcher Sprache die Angaben geholt wurden.</summary>
///
/// <remarks>
+6 -2
View File
@@ -167,7 +167,11 @@ public sealed class MetadataEnricher(
if (!film.TitlePinned)
{
film.Title = gefunden.Title;
film.SortTitleValue = SortTitle.For(gefunden.Title);
// **Der Sortiertitel hat seinen eigenen Merker.** Wer „Harry
// Potter 3" eingetragen hat, will den Titel trotzdem weiter
// automatisch bekommen — beides an einem Schalter hiesse, für
// die Reihenfolge auf die Pflege des Titels zu verzichten.
if (!film.SortTitlePinned) film.SortTitleValue = SortTitle.For(gefunden.Title);
}
film.OriginalTitle = gefunden.OriginalTitle;
film.MetadataLanguage = gefunden.Language;
@@ -290,7 +294,7 @@ public sealed class MetadataEnricher(
if (!serie.TitlePinned)
{
serie.Title = gefunden.Title;
serie.SortTitleValue = SortTitle.For(gefunden.Title);
if (!serie.SortTitlePinned) serie.SortTitleValue = SortTitle.For(gefunden.Title);
}
serie.OriginalTitle = gefunden.OriginalTitle;
serie.MetadataLanguage = gefunden.Language;
@@ -0,0 +1,927 @@
// <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("20261005201126_Sortiertitel")]
partial class Sortiertitel
{
/// <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.Property<bool>("ReviewNewItems")
.HasColumnType("INTEGER");
b.Property<int>("SortOrder")
.HasColumnType("INTEGER");
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>("MetadataLanguage")
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<string>("Overview")
.HasColumnType("TEXT");
b.Property<long?>("PublishAt")
.HasColumnType("INTEGER");
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>("MetadataLanguage")
.HasMaxLength(16)
.HasColumnType("TEXT");
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<long?>("PublishAt")
.HasColumnType("INTEGER");
b.Property<double?>("Rating")
.HasColumnType("REAL");
b.Property<int?>("RuntimeMinutes")
.HasColumnType("INTEGER");
b.Property<bool>("SortTitlePinned")
.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<bool>("TitlePinned")
.HasColumnType("INTEGER");
b.Property<int?>("TmdbId")
.HasColumnType("INTEGER");
b.Property<int?>("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<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>("MetadataLanguage")
.HasMaxLength(16)
.HasColumnType("TEXT");
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<long?>("PublishAt")
.HasColumnType("INTEGER");
b.Property<double?>("Rating")
.HasColumnType("REAL");
b.Property<int?>("RuntimeMinutes")
.HasColumnType("INTEGER");
b.Property<bool>("SortTitlePinned")
.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<bool>("TitlePinned")
.HasColumnType("INTEGER");
b.Property<int?>("TmdbId")
.HasColumnType("INTEGER");
b.Property<int?>("Year")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("LibraryId", "MetadataLanguage");
b.HasIndex("LibraryId", "SortTitleValue");
b.HasIndex("LibraryId", "TmdbId")
.IsUnique()
.HasFilter("\"TmdbId\" IS NOT NULL");
b.ToTable("Series");
});
modelBuilder.Entity("Flaro.Core.Playback.UserItemState", b =>
{
b.Property<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<Guid?>("SeriesId")
.HasColumnType("TEXT");
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("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<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.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
}
}
}
@@ -0,0 +1,40 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Flaro.Data.Migrations
{
/// <inheritdoc />
public partial class Sortiertitel : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "SortTitlePinned",
table: "Series",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "SortTitlePinned",
table: "Movies",
type: "INTEGER",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "SortTitlePinned",
table: "Series");
migrationBuilder.DropColumn(
name: "SortTitlePinned",
table: "Movies");
}
}
}
@@ -508,6 +508,9 @@ namespace Flaro.Data.Migrations
b.Property<int?>("RuntimeMinutes")
.HasColumnType("INTEGER");
b.Property<bool>("SortTitlePinned")
.HasColumnType("INTEGER");
b.Property<string>("SortTitleValue")
.IsRequired()
.HasMaxLength(512)
@@ -637,6 +640,9 @@ namespace Flaro.Data.Migrations
b.Property<int?>("RuntimeMinutes")
.HasColumnType("INTEGER");
b.Property<bool>("SortTitlePinned")
.HasColumnType("INTEGER");
b.Property<string>("SortTitleValue")
.IsRequired()
.HasMaxLength(512)
@@ -249,6 +249,138 @@ public class MatchCorrectionTests
Assert.Fail("Der Scan wurde nicht fertig.");
}
/* ---------- Sortiertitel ---------- */
/// <summary>Der Sortiertitel bestimmt die Reihenfolge, der Titel bleibt stehen.</summary>
///
/// <remarks>
/// <b>Wofür man ihn braucht:</b> eine Reihe steht alphabetisch
/// durcheinander. „…Orden des Phönix" kommt vor „…Stein der Weisen", weil
/// O vor S liegt — obwohl der Stein der erste Teil ist.
/// </remarks>
[Test]
public async Task Ein_Sortiertitel_bestimmt_die_Reihenfolge()
{
var (client, film) = await Eingelesen(beiwerkSchreiben: false);
(await client.PutAsJsonAsync($"/api/v1/movies/{film.Id}/sorttitle",
new { title = "Zzz zuletzt" })).EnsureSuccessStatusCode();
var danach = (await client.GetFromJsonAsync<MovieResponse>($"/api/v1/movies/{film.Id}"))!;
Assert.Multiple(() =>
{
Assert.That(danach.SortTitle, Is.EqualTo("Zzz zuletzt"));
Assert.That(danach.SortTitlePinned, Is.True);
Assert.That(danach.Title, Is.EqualTo(film.Title), "der angezeigte Titel bleibt, wie er war");
});
}
/// <summary>Ein Sortiertitel von Hand überlebt das Auffrischen.</summary>
///
/// <remarks>
/// <b>Sonst tippt man ihn nach jedem Lauf neu</b> — genau wie beim Titel
/// und bei den Bildern. Beides hängt aber an eigenen Merkern: wer die
/// Reihenfolge festlegt, will den Titel trotzdem weiter automatisch
/// bekommen.
/// </remarks>
[Test]
public async Task Ein_Sortiertitel_von_Hand_ueberlebt_eine_Korrektur()
{
var (client, film) = await Eingelesen(beiwerkSchreiben: false);
(await client.PutAsJsonAsync($"/api/v1/movies/{film.Id}/sorttitle",
new { title = "Harry Potter 3" })).EnsureSuccessStatusCode();
// Eine Korrektur schreibt Titel und Sortiertitel neu — den gesetzten nicht.
(await client.PutAsJsonAsync($"/api/v1/movies/{film.Id}/match", new ApplyMatchRequest(70523)))
.EnsureSuccessStatusCode();
var danach = (await client.GetFromJsonAsync<MovieResponse>($"/api/v1/movies/{film.Id}"))!;
Assert.Multiple(() =>
{
Assert.That(danach.Title, Is.EqualTo("Dark"), "der Titel kommt aus der Quelle");
Assert.That(danach.SortTitle, Is.EqualTo("Harry Potter 3"), "die Reihenfolge bleibt von Hand");
});
}
/// <summary>Und auch eine Titeländerung lässt ihn stehen.</summary>
[Test]
public async Task Eine_Titelaenderung_laesst_den_Sortiertitel_stehen()
{
var (client, film) = await Eingelesen(beiwerkSchreiben: false);
(await client.PutAsJsonAsync($"/api/v1/movies/{film.Id}/sorttitle",
new { title = "Harry Potter 3" })).EnsureSuccessStatusCode();
(await client.PutAsJsonAsync($"/api/v1/movies/{film.Id}/title",
new { title = "Ganz anderer Name" })).EnsureSuccessStatusCode();
var danach = (await client.GetFromJsonAsync<MovieResponse>($"/api/v1/movies/{film.Id}"))!;
Assert.Multiple(() =>
{
Assert.That(danach.Title, Is.EqualTo("Ganz anderer Name"));
Assert.That(danach.SortTitle, Is.EqualTo("Harry Potter 3"));
});
}
/// <summary>„Wieder automatisch" bildet ihn sofort neu — nicht erst beim nächsten Lauf.</summary>
///
/// <remarks>
/// <b>Anders als beim Titel, und mit Absicht.</b> Der Titel steht sichtbar
/// da; ihn bis zum Auffrischen stehenzulassen ist besser als eine Lücke.
/// Der Sortiertitel steht nirgends — bliebe er falsch, stünde die Liste
/// solange in der falschen Reihenfolge, und niemand sähe warum.
/// </remarks>
[Test]
public async Task Wieder_automatisch_bildet_den_Sortiertitel_neu()
{
var (client, film) = await Eingelesen(beiwerkSchreiben: false);
(await client.PutAsJsonAsync($"/api/v1/movies/{film.Id}/title",
new { title = "Die Brücke" })).EnsureSuccessStatusCode();
(await client.PutAsJsonAsync($"/api/v1/movies/{film.Id}/sorttitle",
new { title = "Zzz" })).EnsureSuccessStatusCode();
(await client.DeleteAsync($"/api/v1/movies/{film.Id}/sorttitle")).EnsureSuccessStatusCode();
var danach = (await client.GetFromJsonAsync<MovieResponse>($"/api/v1/movies/{film.Id}"))!;
Assert.Multiple(() =>
{
Assert.That(danach.SortTitle, Is.EqualTo("Brücke"), "der Artikel fällt wieder vorn weg");
Assert.That(danach.SortTitlePinned, Is.False);
});
}
[Test]
public async Task Ein_leerer_Sortiertitel_wird_abgelehnt()
{
var (client, film) = await Eingelesen(beiwerkSchreiben: false);
var antwort = await client.PutAsJsonAsync(
$"/api/v1/movies/{film.Id}/sorttitle", new { title = " " });
Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
}
[Test]
public async Task Nur_Administratoren_duerfen_den_Sortiertitel_setzen()
{
var (client, film) = await Eingelesen(beiwerkSchreiben: false);
(await client.PostAsJsonAsync("/api/v1/users",
new CreateUserRequest("gast", "ein-langes-passwort", false))).EnsureSuccessStatusCode();
var gast = _server!.AlsAngemeldeter(
(await _server.SignIn("gast", "ein-langes-passwort", "iPad", "geraet-2")).Token);
var antwort = await gast.PutAsJsonAsync(
$"/api/v1/movies/{film.Id}/sorttitle", new { title = "Egal" });
Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.Forbidden));
}
/// <summary>Die Korrektur überlebt einen Neuaufbau der Datenbank.</summary>
///
/// <remarks>
+22
View File
@@ -51,6 +51,12 @@ export type Film = {
publishAt: string | null;
/** Der Titel in der Sprache des Originals, wenn er abweicht. */
originalTitle: string | null;
/** Ob der Titel von Hand gesetzt wurde. */
titlePinned: boolean;
/** Wonach einsortiert wird — leer in Listen älterer Server. */
sortTitle: string | null;
/** Ob der Sortiertitel von Hand gesetzt wurde. */
sortTitlePinned: boolean;
};
export type Serie = {
id: string; title: string; year: number | null; overview: string | null;
@@ -69,6 +75,9 @@ export type Serie = {
publishAt: string | null;
/** Der Titel in der Sprache des Originals, wenn er abweicht. */
originalTitle: string | null;
titlePinned: boolean;
sortTitle: string | null;
sortTitlePinned: boolean;
};
export type Folge = {
id: string; season: number; episode: number;
@@ -862,6 +871,19 @@ export const titelSetzen = (art: 'movies' | 'series', id: string, titel: string)
export const titelFreigeben = (art: 'movies' | 'series', id: string) =>
anfrage<void>(`/${art}/${id}/title`, { method: 'DELETE' });
/** Wonach einsortiert wird, wenn nicht nach dem Titel.
*
* **Für Reihen.** „…Orden des Phönix" kommt alphabetisch vor „…Stein der
* Weisen", obwohl der Stein der erste Teil ist. Mit „Harry Potter 1",
* „Harry Potter 2" stehen sie so, wie man sie sieht. */
export const sortiertitelSetzen = (art: 'movies' | 'series', id: string, titel: string) =>
anfrage<void>(`/${art}/${id}/sorttitle`,
{ method: 'PUT', body: JSON.stringify({ title: titel }) });
/** Zurück zur errechneten Reihenfolge — Artikel vorn abgezogen. */
export const sortiertitelZuruecksetzen = (art: 'movies' | 'series', id: string) =>
anfrage<void>(`/${art}/${id}/sorttitle`, { method: 'DELETE' });
/** Woher ein Titel stammt — Ordner und ein paar Dateinamen. */
export type Herkunft = { ordner: string | null; dateien: number; beispiele: string[] };
+294
View File
@@ -0,0 +1,294 @@
import {
film as filmLaden, serie as serieLaden, bildAdresse,
titelSetzen, titelFreigeben, sortiertitelSetzen, sortiertitelZuruecksetzen,
type Film, type Serie,
} from './api';
import { esc, knopf, feld, fehlerzeile, meldung, hinweisZeigen } from './util';
import { icons, icon } from './icons';
import { bilddialog } from './bilder';
import { zuordnungsdialog } from './zuordnung';
/** Was sich nach dem Bearbeiten geändert hat. */
export type Bearbeitungsergebnis = {
/** Der angezeigte Titel. */
titel: string;
/** Wonach einsortiert wird — darauf achtet die Liste. */
sortiertitel: string | null;
/** Ob der Eintrag in einem anderen aufgegangen ist. Dann gibt es ihn nicht mehr. */
verschmolzen: boolean;
/** Welcher Eintrag das jetzt ist — nach einem Zusammenlegen ein anderer. */
id: string;
};
/** Alles zu einem Titel an einer Stelle.
*
* **Warum es diesen Dialog gibt, obwohl es die Menüpunkte schon gab.**
*
* Titel ändern, Bilder bearbeiten, Zuordnung korrigieren standen als drei
* Einträge im Kontextmenü — jeder für sich richtig, zusammen aber kein Ort.
* Wer einen Film geraderücken will, macht selten nur eines davon: erst sehen,
* was überhaupt zugeordnet ist, dann das Plakat, dann der Name. Dreimal Menü
* aufklappen für eine zusammenhängende Arbeit.
*
* Die Menüpunkte bleiben als Abkürzung. Wer weiss, was er will, soll nicht
* durch einen Dialog müssen.
*/
export function bearbeiten(
art: 'movies' | 'series',
id: string,
fertig?: (ergebnis: Bearbeitungsergebnis) => void,
): void {
const d = document.createElement('dialog');
d.className = 'dlg dlg-breit';
/** Die Kennung kann sich ändern — beim Zusammenlegen überlebt eine andere. */
let jetzige = id;
let verschmolzen = false;
const kopf = document.createElement('div');
kopf.className = 'dlg-kopf';
const h = document.createElement('h2');
h.textContent = 'Bearbeiten';
const zu = document.createElement('button');
zu.className = 'dlg-zu';
zu.innerHTML = icons.schliessen;
zu.setAttribute('aria-label', 'Schliessen');
zu.onclick = () => d.close();
kopf.append(h, zu);
const koerper = document.createElement('div');
koerper.className = 'dlg-koerper';
koerper.innerHTML = '<div class="spinner"></div>';
d.append(kopf, koerper);
d.addEventListener('close', () => {
d.remove();
if (zuletzt) {
fertig?.({
titel: zuletzt.title,
sortiertitel: zuletzt.sortTitle,
verschmolzen,
id: jetzige,
});
}
});
d.addEventListener('click', e => { if (e.target === d) d.close(); });
document.body.appendChild(d);
d.showModal();
/** Der zuletzt geladene Stand — daraus entsteht die Rückmeldung. */
let zuletzt: Film | Serie | null = null;
void laden();
async function laden(): Promise<void> {
try {
zuletzt = art === 'movies' ? await filmLaden(jetzige) : await serieLaden(jetzige);
} catch (fehler) {
koerper.innerHTML = '';
koerper.appendChild(fehlerzeile(meldung(fehler)));
return;
}
zeichnen(zuletzt);
}
function zeichnen(eintrag: Film | Serie): void {
koerper.innerHTML = '';
h.textContent = `Bearbeiten — ${eintrag.title}`;
koerper.append(
kopfzeile(eintrag),
abschnittTitel(eintrag),
abschnittSortierung(eintrag),
abschnittWeiteres(eintrag),
);
}
/* ---------- Kopf: Plakat und was zugeordnet ist ---------- */
function kopfzeile(eintrag: Film | Serie): HTMLElement {
const reihe = document.createElement('div');
reihe.className = 'bearb-kopf';
const plakat = document.createElement('div');
plakat.className = 'bearb-plakat';
const adresse = bildAdresse(eintrag.posterImage);
if (adresse) {
const bild = document.createElement('img');
bild.src = adresse;
bild.alt = '';
bild.draggable = false;
plakat.appendChild(bild);
}
const text = document.createElement('div');
text.className = 'bearb-kopftext';
text.innerHTML =
`<strong>${esc(eintrag.title)}</strong>`
+ (eintrag.year ? `<span class="winzig">${eintrag.year}</span>` : '')
+ (eintrag.originalTitle && eintrag.originalTitle !== eintrag.title
? `<span class="hinweis">Original: ${esc(eintrag.originalTitle)}</span>` : '');
reihe.append(plakat, text);
return reihe;
}
/* ---------- Titel ---------- */
function abschnittTitel(eintrag: Film | Serie): HTMLElement {
const block = abschnitt('Titel',
'Was in der Mediathek steht. Von Hand gesetzt bleibt er stehen, auch '
+ 'wenn die Metadaten erneut geholt werden.');
const eingabe = feld('Titel');
eingabe.value = eintrag.title;
const speichern = knopf('Speichern', 'btn btn-primary');
const frei = knopf('Wieder automatisch', 'btn');
frei.disabled = !eintrag.titlePinned;
const reihe = document.createElement('div');
reihe.className = 'knopfreihe';
reihe.append(eingabe, speichern, frei);
speichern.onclick = () => void tun(speichern, reihe, async () => {
const neu = eingabe.value.trim();
if (neu.length === 0) throw new Error('Ein Titel darf nicht leer sein.');
await titelSetzen(art, jetzige, neu);
hinweisZeigen('Titel gespeichert.');
await laden();
});
frei.onclick = () => void tun(frei, reihe, async () => {
await titelFreigeben(art, jetzige);
hinweisZeigen('Der Titel kommt beim nächsten Auffrischen wieder aus der Quelle.');
await laden();
});
block.appendChild(reihe);
if (eintrag.titlePinned) block.appendChild(merker('Von Hand gesetzt'));
return block;
}
/* ---------- Sortiertitel ---------- */
function abschnittSortierung(eintrag: Film | Serie): HTMLElement {
const block = abschnitt('Sortiertitel',
'Wonach einsortiert wird, wenn „Nach Titel" sortiert ist. Leer lassen, '
+ 'und es bleibt beim Titel ohne Artikel davor. Für Reihen: „Harry Potter 1", '
+ '„Harry Potter 2" — dann stehen sie so, wie man sie sieht, und nicht '
+ 'alphabetisch durcheinander.');
const eingabe = feld('Sortiertitel');
eingabe.value = eintrag.sortTitle ?? '';
const speichern = knopf('Speichern', 'btn btn-primary');
const frei = knopf('Wieder automatisch', 'btn');
frei.disabled = !eintrag.sortTitlePinned;
const reihe = document.createElement('div');
reihe.className = 'knopfreihe';
reihe.append(eingabe, speichern, frei);
speichern.onclick = () => void tun(speichern, reihe, async () => {
const neu = eingabe.value.trim();
if (neu.length === 0) {
throw new Error('Ein Sortiertitel darf nicht leer sein. '
+ 'Zum Zurücksetzen gibt es „Wieder automatisch".');
}
await sortiertitelSetzen(art, jetzige, neu);
hinweisZeigen(`Wird jetzt unter „${neu}" einsortiert.`);
await laden();
});
frei.onclick = () => void tun(frei, reihe, async () => {
await sortiertitelZuruecksetzen(art, jetzige);
hinweisZeigen('Wird wieder nach dem Titel einsortiert.');
await laden();
});
block.appendChild(reihe);
if (eintrag.sortTitlePinned) block.appendChild(merker('Von Hand gesetzt'));
return block;
}
/* ---------- Bilder und Zuordnung ---------- */
function abschnittWeiteres(eintrag: Film | Serie): HTMLElement {
const block = abschnitt('Bilder und Zuordnung',
'Plakat, Hintergrund und Schriftzug — und welchem Eintrag der '
+ 'Metadatenquelle dieser Titel entspricht.');
const reihe = document.createElement('div');
reihe.className = 'knopfreihe';
const bilder = knopf('Bilder bearbeiten', 'btn');
bilder.prepend(icon('stift'));
// **Der Dialog bleibt offen dahinter.** Wer das Plakat wechselt, ist mit
// dem Bearbeiten meist nicht fertig — ihn hier zuzumachen hiesse, danach
// wieder von vorn anzufangen.
bilder.onclick = () => bilddialog(art, jetzige, eintrag.title, () => void laden());
const zuordnung = knopf('Zuordnung korrigieren', 'btn');
zuordnung.prepend(icon('suche'));
// **Ohne Jahr.** Das angezeigte stammt aus der Zuordnung, die gerade
// korrigiert werden soll — bei einem falschen Treffer grenzt es die Suche
// auf genau das falsche Jahr ein.
zuordnung.onclick = () => zuordnungsdialog(art, jetzige, eintrag.title, null, stand => {
if (stand.merged) {
// Diesen Eintrag gibt es nicht mehr; weiterzubearbeiten hiesse, auf
// ein Loch zu zeigen. Der Dialog folgt dem überlebenden.
verschmolzen = true;
jetzige = stand.id;
hinweisZeigen(`Mit „${stand.title}" zusammengelegt.`);
}
void laden();
});
reihe.append(bilder, zuordnung);
block.appendChild(reihe);
return block;
}
/* ---------- Hilfsbausteine ---------- */
function abschnitt(titel: string, text: string): HTMLElement {
const b = document.createElement('section');
b.className = 'bearb-abschnitt';
const h3 = document.createElement('h3');
h3.textContent = titel;
const p = document.createElement('p');
p.className = 'hinweis';
p.textContent = text;
b.append(h3, p);
return b;
}
function merker(text: string): HTMLElement {
const m = document.createElement('span');
m.className = 'bearb-merker';
m.innerHTML = icons.haken;
m.append(document.createTextNode(text));
return m;
}
/** Führt etwas aus, sperrt solange die Knöpfe und zeigt, was schiefging. */
async function tun(
ausloeser: HTMLButtonElement, reihe: HTMLElement, arbeit: () => Promise<void>,
): Promise<void> {
const alle = [...reihe.querySelectorAll('button')];
alle.forEach(b => { b.disabled = true; });
reihe.parentElement?.querySelector('.fehler')?.remove();
try {
await arbeit();
} catch (fehler) {
reihe.after(fehlerzeile(meldung(fehler)));
alle.forEach(b => { b.disabled = false; });
void ausloeser;
}
}
}
+37 -1
View File
@@ -8,6 +8,7 @@ import { kontextmenue, menueAnZiel, type Menuepunkt } from './menue';
import { bilddialog } from './bilder';
import { zuordnungsdialog } from './zuordnung';
import { titeldialog } from './titel';
import { bearbeiten } from './bearbeiten';
/** Was eine Kachel zeigt — unabhängig davon, woher sie kommt. */
export type Kachel = {
@@ -32,6 +33,10 @@ export type Kachel = {
offen?: number | null;
/** Wie viele Folgen es überhaupt sind — um die Plakette wiederherzustellen. */
gesamt?: number | null;
/** Wonach einsortiert wird. **Damit die Kachel weiss, wohin sie gehört**,
* wenn sich der Titel ändert — sonst stand „WALL·E" nach der Korrektur
* weiter unter E, bis man die Seite neu lud. */
sortTitel?: string | null;
/** `null` heisst: wartet auf Freigabe. Ein Zeitpunkt in der Zukunft: geplant. */
freigabe?: string | null;
};
@@ -47,6 +52,7 @@ export const alsKachel = (e: Film | Serie): Kachel => ({
stand: e.state,
offen: 'unwatchedCount' in e ? e.unwatchedCount : null,
gesamt: 'episodeCount' in e ? e.episodeCount : null,
sortTitel: e.sortTitle,
freigabe: e.publishAt,
});
@@ -319,6 +325,24 @@ function kachelpunkte(
if (istAdmin()) {
const art = k.art === 'serien' ? 'series' : 'movies';
// **Ein Ort für alles, und die Abkürzungen bleiben.** Titel, Sortiertitel,
// Bilder und Zuordnung gehören zusammen: wer einen Film geraderückt, macht
// selten nur eines davon. Wer weiss, was er will, nimmt weiter den
// einzelnen Punkt darunter.
punkte.push({
text: 'Bearbeiten',
icon: 'stift',
tun: () => bearbeiten(art, k.id, ergebnis => {
if (ergebnis.verschmolzen) {
knopf.remove();
hinweisZeigen(`„${ergebnis.titel}" wurde mit einem vorhandenen Eintrag zusammengelegt.`);
return;
}
void kachelAuffrischen(k, knopf);
}),
});
punkte.push({
text: 'Bilder bearbeiten',
icon: 'stift',
@@ -377,14 +401,26 @@ function kachelpunkte(
* Eintrag. Alles neu zu laden kostet einen Netzabruf über mehrere hundert
* Titel und setzt die Ansicht an den Anfang zurück.
*/
async function kachelAuffrischen(k: Kachel, knopf: HTMLElement): Promise<void> {
export async function kachelAuffrischen(k: Kachel, knopf: HTMLElement): Promise<void> {
let frisch: Film | Serie;
try { frisch = k.art === 'serien' ? await serie(k.id) : await film(k.id); }
catch { return; } // Still bleiben: die alte Kachel ist besser als eine Meldung.
const vorher = k.sortTitel;
k.titel = frisch.title;
k.zweite = frisch.year ? String(frisch.year) : null;
k.bild = frisch.posterImage;
k.sortTitel = frisch.sortTitle;
// **Die Liste erfährt, dass die Kachel woandershin gehört.** Sie allein
// weiss, ob der neue Platz überhaupt geladen ist.
if (vorher !== k.sortTitel) {
knopf.dispatchEvent(new CustomEvent('kachel-umsortiert', {
bubbles: true,
detail: { id: k.id, sortTitel: k.sortTitel, titel: k.titel },
}));
}
const text = knopf.querySelector('.kacheltext');
if (text) {
+39
View File
@@ -1543,3 +1543,42 @@ body:has(.auswahlleiste:not(.hidden)) .hinweise { bottom: 104px; }
@media (prefers-reduced-motion: reduce) {
.auswahlleiste:not(.hidden) { animation: none; }
}
/* ---------- Bearbeiten: alles zu einem Titel an einer Stelle ---------- */
.bearb-kopf {
display: flex; gap: var(--s-regular); align-items: center;
padding-bottom: var(--s-regular); border-bottom: 1px solid var(--line);
margin-bottom: var(--s-section);
}
.bearb-plakat {
width: 72px; aspect-ratio: 2 / 3; flex: 0 0 auto;
border-radius: var(--r-control); overflow: hidden; background: var(--placeholder);
}
.bearb-plakat img { width: 100%; height: 100%; object-fit: cover; }
.bearb-kopftext { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.bearb-kopftext strong { font-size: 17px; }
.bearb-abschnitt { margin-bottom: var(--s-section); }
.bearb-abschnitt:last-child { margin-bottom: 0; }
.bearb-abschnitt h3 {
font-size: 14px; font-weight: 600; margin: 0 0 4px;
text-transform: uppercase; letter-spacing: .04em; color: var(--text-dim);
}
.bearb-abschnitt .hinweis { margin: 0 0 var(--s-snug); }
/* Das Eingabefeld nimmt den Platz, die Knöpfe nur, was sie brauchen. */
.bearb-abschnitt .knopfreihe .feld { flex: 1 1 220px; min-width: 0; }
/* **Sagt, dass hier jemand von Hand eingegriffen hat.** Ohne das sieht ein
gesetzter Wert aus wie ein geholter, und niemand weiss, warum das
Auffrischen ihn in Ruhe lässt. */
.bearb-merker {
display: inline-flex; align-items: center; gap: 5px;
margin-top: var(--s-snug);
font-size: 12px; color: var(--accent);
}
.bearb-merker svg { width: 14px; height: 14px; }
@media (max-width: 560px) {
.bearb-abschnitt .knopfreihe .feld { flex: 1 1 100%; }
}
+4
View File
@@ -20,6 +20,7 @@ import { kontextmenue, menueAnZiel, type Menuepunkt } from '../menue';
import { bilddialog } from '../bilder';
import { zuordnungsdialog } from '../zuordnung';
import { titeldialog } from '../titel';
import { bearbeiten } from '../bearbeiten';
/** Die Verwaltung.
*
@@ -337,6 +338,9 @@ function pruefbereich(geaendert: () => void): HTMLElement {
mehr.innerHTML = icons.punkte;
mehr.setAttribute('aria-label', `Mehr zu „${e.title}“`);
const punkte = (): Menuepunkt[] => [
// Auch hier: ein Ort für alles, die Einzelnen bleiben als Abkürzung.
{ text: 'Bearbeiten', icon: 'stift',
tun: () => bearbeiten(art, e.id, () => void laden()) },
{ text: 'Titel ändern', icon: 'stift',
tun: () => titeldialog(art, e.id, e.title, () => void laden()) },
{ text: 'Bilder bearbeiten', icon: 'stift',
+50
View File
@@ -396,6 +396,56 @@ export function mediathek(
nachAuswahl();
}
/* ---------- Umsortieren nach einer Korrektur ----------
*
* **„WALL·E" stand nach der Korrektur weiter unter E.**
*
* Die Kachel wurde zwar neu beschriftet, blieb aber an ihrem Platz — bis
* man die Seite neu lud. Jetzt rutscht sie dorthin, wo sie hingehört.
*
* **Und wenn der Platz gar nicht geladen ist, geht sie.** Bei 447 Titeln
* sind zweihundert da; ein Film, der von E nach W wandert, hat seinen
* neuen Platz oft noch gar nicht vor sich. Ihn ans Ende zu hängen wäre
* falsch, ihn stehenzulassen auch — also sagt die Liste, wohin er ging,
* und nimmt ihn weg.
*/
gitter.addEventListener('kachel-umsortiert', e => {
const { id, sortTitel, titel } = (e as CustomEvent<{
id: string; sortTitel: string | null; titel: string;
}>).detail;
// Nur beim Sortieren nach Titel steht die Reihenfolge auf dem
// Sortiertitel. Nach Jahr oder Zugang entscheidet etwas anderes, und
// das kennt die Kachel nicht.
if (ordnung !== 'title') return;
const k = gitter.querySelector<HTMLElement>(`.kachel[data-id="${CSS.escape(id)}"]`);
if (!k) return;
const schluessel = (sortTitel ?? titel).toLocaleLowerCase('de');
const andere = [...gitter.querySelectorAll<HTMLElement>('.kachel')].filter(x => x !== k);
const davor = andere.find(x => {
const seiner = bekannt.get(x.dataset.id ?? '');
const sein = (seiner?.sortTitel ?? seiner?.titel ?? '').toLocaleLowerCase('de');
return sein.localeCompare(schluessel, 'de') > 0;
});
if (davor) { gitter.insertBefore(k, davor); return; }
// Hinter allem Geladenen — also irgendwo im noch nicht geholten Teil.
if (!amEnde) {
k.remove();
bekannt.delete(id);
gewaehlt.delete(id);
nachAuswahl();
hinweisZeigen(`„${titel}" steht jetzt weiter hinten in der Liste.`);
return;
}
gitter.appendChild(k);
});
/** Streicht man an den Rand, wandert die Seite mit.
*
* **Sonst endet jede Auswahl am unteren Bildrand.** Bei 447 Titeln sind