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