diff --git a/server/src/Flaro.Api/Endpoints/Contracts.cs b/server/src/Flaro.Api/Endpoints/Contracts.cs
index 95fc3b9..7792dc0 100644
--- a/server/src/Flaro.Api/Endpoints/Contracts.cs
+++ b/server/src/Flaro.Api/Endpoints/Contracts.cs
@@ -283,7 +283,22 @@ public sealed record MovieResponse(
/// etwas sie unterscheidet — und wer einen davon sucht, findet ihn nur
/// über die Prüfliste.
///
- DateTimeOffset? PublishAt = null);
+ DateTimeOffset? PublishAt = null,
+
+ /// Ob der Titel von Hand gesetzt wurde.
+ bool TitlePinned = false,
+
+ /// Wonach einsortiert wird, wenn nicht nach dem Titel.
+ ///
+ ///
+ /// Damit der Client weiss, wohin ein Titel gehört. 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.
+ ///
+ string? SortTitle = null,
+
+ /// Ob der Sortiertitel von Hand gesetzt wurde.
+ 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.
///
- ItemStateResponse? State = null);
+ ItemStateResponse? State = null,
+
+ /// Ob der Titel von Hand gesetzt wurde.
+ bool TitlePinned = false,
+
+ /// Wonach einsortiert wird, wenn nicht nach dem Titel.
+ ///
+ ///
+ /// Damit der Client weiss, wohin ein Titel gehört. 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.
+ ///
+ string? SortTitle = null,
+
+ /// Ob der Sortiertitel von Hand gesetzt wurde.
+ bool SortTitlePinned = false);
public sealed record EpisodeResponse(
Guid Id, int Season, int Episode, string? Title, string? Overview, string? StillImage,
diff --git a/server/src/Flaro.Api/Endpoints/LibraryContentEndpoints.cs b/server/src/Flaro.Api/Endpoints/LibraryContentEndpoints.cs
index d9b7aca..8bb8488 100644
--- a/server/src/Flaro.Api/Endpoints/LibraryContentEndpoints.cs
+++ b/server/src/Flaro.Api/Endpoints/LibraryContentEndpoints.cs
@@ -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(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(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
diff --git a/server/src/Flaro.Api/Endpoints/MatchEndpoints.cs b/server/src/Flaro.Api/Endpoints/MatchEndpoints.cs
index d2b829a..1e2924b 100644
--- a/server/src/Flaro.Api/Endpoints/MatchEndpoints.cs
+++ b/server/src/Flaro.Api/Endpoints/MatchEndpoints.cs
@@ -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
diff --git a/server/src/Flaro.Api/MatchApplier.cs b/server/src/Flaro.Api/MatchApplier.cs
index 77bd3e9..d4ef752 100644
--- a/server/src/Flaro.Api/MatchApplier.cs
+++ b/server/src/Flaro.Api/MatchApplier.cs
@@ -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);
diff --git a/server/src/Flaro.Core/Library/MediaEntities.cs b/server/src/Flaro.Core/Library/MediaEntities.cs
index bd5b567..cc03991 100644
--- a/server/src/Flaro.Core/Library/MediaEntities.cs
+++ b/server/src/Flaro.Core/Library/MediaEntities.cs
@@ -151,6 +151,24 @@ public sealed class Movie : Flaro.Core.Metadata.IBebildert
///
public bool TitlePinned { get; set; }
+ /// Der Sortiertitel, wie ihn jemand von Hand gesetzt hat.
+ ///
+ ///
+ ///
+ /// Wofür man ihn braucht: 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.
+ ///
+ ///
+ /// Leer heisst: wie bisher. Ohne Eintrag bleibt es bei dem Wert,
+ /// den aus dem Titel errechnet — Artikel vorn
+ /// abgezogen. Nur wer etwas einträgt, weicht davon ab.
+ ///
+ ///
+ public bool SortTitlePinned { get; set; }
+
/// In welcher Sprache die Angaben geholt wurden.
///
///
@@ -314,6 +332,24 @@ public sealed class Series : Flaro.Core.Metadata.IBebildert
///
public bool TitlePinned { get; set; }
+ /// Der Sortiertitel, wie ihn jemand von Hand gesetzt hat.
+ ///
+ ///
+ ///
+ /// Wofür man ihn braucht: 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.
+ ///
+ ///
+ /// Leer heisst: wie bisher. Ohne Eintrag bleibt es bei dem Wert,
+ /// den aus dem Titel errechnet — Artikel vorn
+ /// abgezogen. Nur wer etwas einträgt, weicht davon ab.
+ ///
+ ///
+ public bool SortTitlePinned { get; set; }
+
/// In welcher Sprache die Angaben geholt wurden.
///
///
diff --git a/server/src/Flaro.Data/MetadataEnricher.cs b/server/src/Flaro.Data/MetadataEnricher.cs
index bf59970..00863f7 100644
--- a/server/src/Flaro.Data/MetadataEnricher.cs
+++ b/server/src/Flaro.Data/MetadataEnricher.cs
@@ -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;
diff --git a/server/src/Flaro.Data/Migrations/20261005201126_Sortiertitel.Designer.cs b/server/src/Flaro.Data/Migrations/20261005201126_Sortiertitel.Designer.cs
new file mode 100644
index 0000000..dc9ff11
--- /dev/null
+++ b/server/src/Flaro.Data/Migrations/20261005201126_Sortiertitel.Designer.cs
@@ -0,0 +1,927 @@
+//
+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
+ {
+ ///
+ 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.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("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("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.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("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.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
+ }
+ }
+}
diff --git a/server/src/Flaro.Data/Migrations/20261005201126_Sortiertitel.cs b/server/src/Flaro.Data/Migrations/20261005201126_Sortiertitel.cs
new file mode 100644
index 0000000..d3eb776
--- /dev/null
+++ b/server/src/Flaro.Data/Migrations/20261005201126_Sortiertitel.cs
@@ -0,0 +1,40 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace Flaro.Data.Migrations
+{
+ ///
+ public partial class Sortiertitel : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.AddColumn(
+ name: "SortTitlePinned",
+ table: "Series",
+ type: "INTEGER",
+ nullable: false,
+ defaultValue: false);
+
+ migrationBuilder.AddColumn(
+ name: "SortTitlePinned",
+ table: "Movies",
+ type: "INTEGER",
+ nullable: false,
+ defaultValue: false);
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropColumn(
+ name: "SortTitlePinned",
+ table: "Series");
+
+ migrationBuilder.DropColumn(
+ name: "SortTitlePinned",
+ table: "Movies");
+ }
+ }
+}
diff --git a/server/src/Flaro.Data/Migrations/FlaroContextModelSnapshot.cs b/server/src/Flaro.Data/Migrations/FlaroContextModelSnapshot.cs
index 4ec2658..5827d17 100644
--- a/server/src/Flaro.Data/Migrations/FlaroContextModelSnapshot.cs
+++ b/server/src/Flaro.Data/Migrations/FlaroContextModelSnapshot.cs
@@ -508,6 +508,9 @@ namespace Flaro.Data.Migrations
b.Property("RuntimeMinutes")
.HasColumnType("INTEGER");
+ b.Property("SortTitlePinned")
+ .HasColumnType("INTEGER");
+
b.Property("SortTitleValue")
.IsRequired()
.HasMaxLength(512)
@@ -637,6 +640,9 @@ namespace Flaro.Data.Migrations
b.Property("RuntimeMinutes")
.HasColumnType("INTEGER");
+ b.Property("SortTitlePinned")
+ .HasColumnType("INTEGER");
+
b.Property("SortTitleValue")
.IsRequired()
.HasMaxLength(512)
diff --git a/server/tests/Flaro.Api.Tests/MatchCorrectionTests.cs b/server/tests/Flaro.Api.Tests/MatchCorrectionTests.cs
index 485ccd8..8826c4b 100644
--- a/server/tests/Flaro.Api.Tests/MatchCorrectionTests.cs
+++ b/server/tests/Flaro.Api.Tests/MatchCorrectionTests.cs
@@ -249,6 +249,138 @@ public class MatchCorrectionTests
Assert.Fail("Der Scan wurde nicht fertig.");
}
+ /* ---------- Sortiertitel ---------- */
+
+ /// Der Sortiertitel bestimmt die Reihenfolge, der Titel bleibt stehen.
+ ///
+ ///
+ /// Wofür man ihn braucht: 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.
+ ///
+ [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($"/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");
+ });
+ }
+
+ /// Ein Sortiertitel von Hand überlebt das Auffrischen.
+ ///
+ ///
+ /// Sonst tippt man ihn nach jedem Lauf neu — 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.
+ ///
+ [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($"/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");
+ });
+ }
+
+ /// Und auch eine Titeländerung lässt ihn stehen.
+ [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($"/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"));
+ });
+ }
+
+ /// „Wieder automatisch" bildet ihn sofort neu — nicht erst beim nächsten Lauf.
+ ///
+ ///
+ /// Anders als beim Titel, und mit Absicht. 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.
+ ///
+ [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($"/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));
+ }
+
/// Die Korrektur überlebt einen Neuaufbau der Datenbank.
///
///
diff --git a/web/app/src/api.ts b/web/app/src/api.ts
index cc25716..e2516d8 100644
--- a/web/app/src/api.ts
+++ b/web/app/src/api.ts
@@ -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(`/${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(`/${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(`/${art}/${id}/sorttitle`, { method: 'DELETE' });
+
/** Woher ein Titel stammt — Ordner und ein paar Dateinamen. */
export type Herkunft = { ordner: string | null; dateien: number; beispiele: string[] };
diff --git a/web/app/src/bearbeiten.ts b/web/app/src/bearbeiten.ts
new file mode 100644
index 0000000..24ec047
--- /dev/null
+++ b/web/app/src/bearbeiten.ts
@@ -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 = '';
+
+ 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 {
+ 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 =
+ `${esc(eintrag.title)}`
+ + (eintrag.year ? `${eintrag.year}` : '')
+ + (eintrag.originalTitle && eintrag.originalTitle !== eintrag.title
+ ? `Original: ${esc(eintrag.originalTitle)}` : '');
+
+ 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,
+ ): Promise {
+ 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;
+ }
+ }
+}
diff --git a/web/app/src/card.ts b/web/app/src/card.ts
index 5484f2a..4a93510 100644
--- a/web/app/src/card.ts
+++ b/web/app/src/card.ts
@@ -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 {
+export async function kachelAuffrischen(k: Kachel, knopf: HTMLElement): Promise {
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) {
diff --git a/web/app/src/style.css b/web/app/src/style.css
index a59742d..c5c18a0 100644
--- a/web/app/src/style.css
+++ b/web/app/src/style.css
@@ -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%; }
+}
diff --git a/web/app/src/views/admin.ts b/web/app/src/views/admin.ts
index 798fccc..eca5490 100644
--- a/web/app/src/views/admin.ts
+++ b/web/app/src/views/admin.ts
@@ -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',
diff --git a/web/app/src/views/library.ts b/web/app/src/views/library.ts
index 83ef6c6..d60b554 100644
--- a/web/app/src/views/library.ts
+++ b/web/app/src/views/library.ts
@@ -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(`.kachel[data-id="${CSS.escape(id)}"]`);
+ if (!k) return;
+
+ const schluessel = (sortTitel ?? titel).toLocaleLowerCase('de');
+ const andere = [...gitter.querySelectorAll('.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