diff --git a/server/src/Flaro.Api/Endpoints/Contracts.cs b/server/src/Flaro.Api/Endpoints/Contracts.cs index ed3dbb9..f893f92 100644 --- a/server/src/Flaro.Api/Endpoints/Contracts.cs +++ b/server/src/Flaro.Api/Endpoints/Contracts.cs @@ -58,7 +58,15 @@ public sealed record CreateUserRequest(string UserName, string Password, bool Is public sealed record LibraryResponse( Guid Id, string Name, string Kind, string Path, int? ItemCount, /// Ob neue Funde erst geprüft werden. - bool ReviewNewItems = false); + bool ReviewNewItems = false, + /// Wie viele Titel dieser Mediathek noch auf Freigabe warten. + /// + /// + /// Hier und nicht nur in der Prüfliste. Wer eine Mediathek + /// scannt, sieht das Ergebnis an ihr — dass zwanzig Titel irgendwo anders + /// warten, erfährt er sonst nur, wenn er weiterscrollt. + /// + int PendingCount = 0); public sealed record RenameLibraryRequest(string Name); @@ -69,6 +77,9 @@ public sealed record CreateLibraryRequest( public sealed record PruefungRequest(bool ReviewNewItems); +/// Die Reihenfolge der Mediatheken, von oben nach unten. +public sealed record ReihenfolgeRequest(IReadOnlyList? Ids); + public sealed record SprachRequest(string? Language); /// Welche Sprache gilt — und wie weit sie abgedeckt ist. @@ -112,7 +123,11 @@ public sealed record Pruefeintrag( /// Worauf eine Freigabe wirkt. IReadOnlyList Ids); -public sealed record FreigabeRequest(IReadOnlyList? Ids, DateTimeOffset? PublishAt); +public sealed record FreigabeRequest( + IReadOnlyList? Ids, + DateTimeOffset? PublishAt, + /// Statt einzelner Kennungen: alles Wartende dieser Mediathek. + Guid? LibraryId = null); /// Ein Protokolltag. public sealed record Protokolltag(string Date, long Bytes); diff --git a/server/src/Flaro.Api/Endpoints/FreigabeEndpoints.cs b/server/src/Flaro.Api/Endpoints/FreigabeEndpoints.cs index faa39d7..d774a88 100644 --- a/server/src/Flaro.Api/Endpoints/FreigabeEndpoints.cs +++ b/server/src/Flaro.Api/Endpoints/FreigabeEndpoints.cs @@ -84,6 +84,25 @@ public static class FreigabeEndpoints { var wann = anfrage.PublishAt ?? uhr.GetUtcNow(); + // Alles Wartende einer einzelnen Mediathek — der Knopf, der dort + // steht, wo man gerade gescannt hat. + if (anfrage.LibraryId is { } mediathek) + { + var jetztDort = uhr.GetUtcNow(); + + var dortFilme = await db.Movies + .Where(m => m.LibraryId == mediathek + && (m.PublishAt == null || m.PublishAt > jetztDort)) + .ExecuteUpdateAsync(s => s.SetProperty(m => m.PublishAt, wann), ct); + + var dortFolgen = await db.Episodes + .Where(f => db.Series.Any(s => s.Id == f.SeriesId && s.LibraryId == mediathek) + && (f.PublishAt == null || f.PublishAt > jetztDort)) + .ExecuteUpdateAsync(s => s.SetProperty(f => f.PublishAt, wann), ct); + + return Results.Ok(new { published = dortFilme + dortFolgen }); + } + // **Alles auf einmal, wenn keine Kennungen dabei sind.** Bei // vierhundert neuen Titeln wäre einzeln Klicken keine Bedienung. if (anfrage.Ids is null or { Count: 0 }) diff --git a/server/src/Flaro.Api/Endpoints/LibraryContentEndpoints.cs b/server/src/Flaro.Api/Endpoints/LibraryContentEndpoints.cs index 15a77f0..08f9334 100644 --- a/server/src/Flaro.Api/Endpoints/LibraryContentEndpoints.cs +++ b/server/src/Flaro.Api/Endpoints/LibraryContentEndpoints.cs @@ -558,9 +558,17 @@ public static class LibraryContentEndpoints var spuren = await SpurenZu(db, dateien.Select(d => d.Id).ToList()); var stände = await ZuständeZu(db, wer, folgen: [naechste.Id]); + // **Ohne eigenes Bild das der Serie.** Eine graue Fläche sagt + // nichts; das Plakat der Serie sagt wenigstens, worum es geht. + // TMDB liefert Folgenbilder nur für einen Teil der Folgen, und + // bei kleineren Serien für gar keine. + var serienBild = await db.Series.AsNoTracking() + .Where(x => x.Id == diese.SeriesId) + .Select(x => x.PosterImage).FirstOrDefaultAsync(); + return Results.Ok(new EpisodeResponse( naechste.Id, naechste.SeasonNumber, naechste.EpisodeNumber, - naechste.Title, naechste.Overview, naechste.StillImage, + naechste.Title, naechste.Overview, naechste.StillImage ?? serienBild, dateien.Select(d => Datei(d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, spuren)).ToList(), stände.GetValueOrDefault(naechste.Id))); }).RequireAuthorization(); @@ -586,8 +594,13 @@ public static class LibraryContentEndpoints var spuren = await SpurenZu(db, dateien.Select(d => d.Id).ToList()); var stände = await ZuständeZu(db, wer, folgen: ids); + var serienBild = await db.Series.AsNoTracking() + .Where(x => x.Id == id) + .Select(x => x.PosterImage).FirstOrDefaultAsync(); + var items = folgen.Select(f => new EpisodeResponse( - f.Id, f.SeasonNumber, f.EpisodeNumber, f.Title, f.Overview, f.StillImage, + f.Id, f.SeasonNumber, f.EpisodeNumber, f.Title, f.Overview, + f.StillImage ?? serienBild, dateien.Where(d => d.EpisodeId == f.Id) .Select(d => Datei(d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, spuren)).ToList(), stände.GetValueOrDefault(f.Id))).ToList(); diff --git a/server/src/Flaro.Api/Endpoints/ManagementEndpoints.cs b/server/src/Flaro.Api/Endpoints/ManagementEndpoints.cs index 2d5527c..d038edc 100644 --- a/server/src/Flaro.Api/Endpoints/ManagementEndpoints.cs +++ b/server/src/Flaro.Api/Endpoints/ManagementEndpoints.cs @@ -51,9 +51,44 @@ public static class ManagementEndpoints // dem sich nach dem Klick nichts findet — da ist der Unterschied // zwischen „noch nicht gescannt" und „falscher Pfad" wenigstens // sichtbar. Zwei Abfragen für alle zusammen, nicht eine je Zeile. - bibliotheken.MapGet("/", async (FlaroContext db) => + bibliotheken.MapGet("/", async (FlaroContext db, TimeProvider uhr) => { - var liste = await db.Libraries.AsNoTracking().OrderBy(l => l.Name).ToListAsync(); + // **Ohne Rücksicht auf Gross- und Kleinschreibung.** + // + // SQLite vergleicht Text sonst zeichengenau, und dort stehen alle + // Grossbuchstaben vor allen kleinen: „Serien" landete über „film", + // obwohl f vor s kommt. Wer eine Mediathek umbenennt, sieht sie + // dann scheinbar grundlos springen — es sah aus, als stünde die + // zuletzt bearbeitete oben. + // **Erst die eigene Reihenfolge, dann der Name.** Wer nie + // umsortiert hat, bekommt das Alphabet; wer es getan hat, seine + // Reihenfolge. Ohne den zweiten Schlüssel stünden alle + // Nie-Sortierten in zufälliger Folge. + var liste = await db.Libraries.AsNoTracking() + .OrderBy(l => l.SortOrder) + .ThenBy(l => EF.Functions.Collate(l.Name, "NOCASE")) + .ToListAsync(); + var jetzt = uhr.GetUtcNow(); + + // **Was noch wartet, je Mediathek.** Wer scannt, sieht das Ergebnis + // an der Mediathek — dass zwanzig Titel irgendwo weiter unten + // warten, erführe er sonst nur durch Weiterscrollen. + // + // Für einen gewöhnlichen Benutzer bleibt die Zahl null: der + // Freigabefilter lässt ihn Unveröffentlichtes gar nicht erst + // zählen, und das ist richtig so. + var wartendeFilme = await db.Movies.AsNoTracking() + .Where(m => m.PublishAt == null || m.PublishAt > jetzt) + .GroupBy(m => m.LibraryId) + .Select(g => new { Id = g.Key, Anzahl = g.Count() }) + .ToDictionaryAsync(x => x.Id, x => x.Anzahl); + + var wartendeFolgen = await db.Episodes.AsNoTracking() + .Where(f => f.PublishAt == null || f.PublishAt > jetzt) + .GroupBy(f => db.Series.Where(s => s.Id == f.SeriesId) + .Select(s => s.LibraryId).First()) + .Select(g => new { Id = g.Key, Anzahl = g.Count() }) + .ToDictionaryAsync(x => x.Id, x => x.Anzahl); var filme = await db.Movies.AsNoTracking() .GroupBy(m => m.LibraryId) @@ -70,7 +105,10 @@ public static class ManagementEndpoints l.Kind == LibraryKind.Shows ? serien.GetValueOrDefault(l.Id) : filme.GetValueOrDefault(l.Id), - l.ReviewNewItems)).ToList()); + l.ReviewNewItems, + l.Kind == LibraryKind.Shows + ? wartendeFolgen.GetValueOrDefault(l.Id) + : wartendeFilme.GetValueOrDefault(l.Id))).ToList()); }).RequireAuthorization(); // **Was nicht mehr da ist, aber noch in der Mediathek steht.** @@ -78,6 +116,29 @@ public static class ManagementEndpoints // Der Scan löscht nichts mehr von selbst — eine kurz nicht eingehängte // Platte hätte sonst Titel samt Sehfortschritt gekostet. Stattdessen // steht es hier, und das Löschen ist eine Entscheidung. + // **Die Reihenfolge der Seitenleiste.** + // + // Als ganze Liste und nicht als „eins hoch": zwei Benutzer, die + // gleichzeitig schieben, kämen sonst in einen Zustand, den keiner von + // beiden wollte. Die Liste beschreibt, wie es danach aussehen soll. + bibliotheken.MapPut("/order", async ( + ReihenfolgeRequest anfrage, FlaroContext db, CancellationToken ct) => + { + if (anfrage.Ids is null or { Count: 0 }) + return Results.BadRequest(new { fehler = "Ohne Reihenfolge gibt es nichts zu ordnen." }); + + // Ab 1 zählen: die 0 bleibt denen, die nie umsortiert wurden. + for (var i = 0; i < anfrage.Ids.Count; i++) + { + var platz = i + 1; + var kennung = anfrage.Ids[i]; + await db.Libraries.Where(l => l.Id == kennung) + .ExecuteUpdateAsync(s => s.SetProperty(l => l.SortOrder, platz), ct); + } + + return Results.NoContent(); + }).RequireAuthorization(FlaroPolicies.Administrator); + // Die Prüfung je Mediathek umstellen. bibliotheken.MapPut("/{id:guid}/review", async ( Guid id, PruefungRequest anfrage, FlaroContext db, CancellationToken ct) => diff --git a/server/src/Flaro.Core/Libraries/Library.cs b/server/src/Flaro.Core/Libraries/Library.cs index 0dc5dc4..3b2b52f 100644 --- a/server/src/Flaro.Core/Libraries/Library.cs +++ b/server/src/Flaro.Core/Libraries/Library.cs @@ -27,4 +27,17 @@ public sealed class Library /// erst zu einem bestimmten Zeitpunkt erscheinen soll. /// public bool ReviewNewItems { get; set; } + + /// Wo sie in der Seitenleiste steht. + /// + /// + /// Weil alphabetisch nicht immer richtig ist. Wer „Filme" und + /// „Kinderfilme" und „Dokumentationen" hat, will nicht nach dem Alphabet + /// sortiert sein, sondern danach, was er am häufigsten öffnet. + /// + /// 0 für alles, was nie umsortiert wurde; dann entscheidet der + /// Name. Damit bleibt eine frisch angelegte Mediathek da, wo man sie + /// erwartet, statt am Ende zu landen. + /// + public int SortOrder { get; set; } } diff --git a/server/src/Flaro.Core/Library/FileNames.cs b/server/src/Flaro.Core/Library/FileNames.cs index 929e60e..3bb0ffb 100644 --- a/server/src/Flaro.Core/Library/FileNames.cs +++ b/server/src/Flaro.Core/Library/FileNames.cs @@ -186,7 +186,11 @@ public static partial class FileNames /// ein Film oder etwas, das niemand benennen kann — in beiden Fällen wäre /// eine geratene Staffel 1 schlimmer als gar keine. /// - public static EpisodeName? ParseEpisode(string dateiname) + /// + /// Der Wurzelpfad der Mediathek. Ist er angegeben, bestimmt der erste + /// Ordner darunter die Serie — und zwar allein. + /// + public static EpisodeName? ParseEpisode(string dateiname, string? wurzel = null) { var roh = Rohname(dateiname); @@ -206,33 +210,67 @@ public static partial class FileNames var (worteDanach, _) = Zerlegen(danach, istDateiname: false); var (folgenTitel, _) = TitelBis(worteDanach, worteDanach.Length); - // **Steht im Dateinamen keine Serie, sagt der Ordner sie.** + // **Der Ordner entscheidet, nicht der Dateiname.** // - // Bei „Dark/Staffel 1/S01E01.mkv" steht vor der Folgennummer nichts — - // und bis hierher wurde dann der ganze Dateiname zum Serientitel. - // Jede so benannte Datei ergab damit eine eigene Serie: aus zwanzig - // Folgen wurden zwanzig Serien namens „S01E01", „S01E02", … + // Was unter „Serien/Pingu/" liegt, gehört zu Pingu — ohne Rücksicht + // darauf, wie die Datei heisst. Vorher entschied der Dateiname, und + // das ging reihenweise schief: Dateien namens „S01E001_Aang.mkv" + // tragen vor der Folgennummer nichts, also wurde der ganze Dateiname + // zum Serientitel. Aus einer Serie mit zwanzig Folgen wurden zwanzig + // Serien. // - // Plex und Jellyfin lesen den Namen seit je aus dem Ordner. Der - // Staffelordner wird dabei übersprungen, sonst hiesse die Serie - // „Staffel 1". - var ausOrdner = serie.Length > 0 ? null : SerieAusOrdner(dateiname); + // Umgekehrt ebenso wichtig: „Doctor Who (2005) - S12E04.mkv" im Ordner + // „Doctor Who" ergab zwei Serien, weil die Jahresangabe im Dateinamen + // stand und im Ordner nicht. Der Ordner ist die verlässlichere Angabe, + // weil ihn ein Mensch angelegt hat. + // + // So macht es Plex seit je, und aus demselben Grund. + // **Mit Wurzel entscheidet der Ordner, ohne Wurzel nur im Notfall.** + // + // Steht die Wurzel fest, weiss man, welcher Ordner die Serie benennt — + // der erste darunter, von einem Menschen angelegt. Fehlt sie, könnte + // über dem Staffelordner irgendetwas stehen; dann ist der Dateiname + // die bessere Angabe, und der Ordner greift nur, wenn er gar nichts + // hergibt. + var ausOrdner = wurzel is { Length: > 0 } + ? SerieAusOrdner(dateiname, wurzel) + : serie.Length > 0 ? null : SerieAusOrdner(dateiname, null); return new EpisodeName( - SeriesTitle: serie.Length > 0 ? serie : ausOrdner ?? Rohname(dateiname), + SeriesTitle: ausOrdner ?? (serie.Length > 0 ? serie : Rohname(dateiname)), Season: staffel, Episode: folge, EpisodeTitle: folgenTitel.Length > 0 ? folgenTitel : null); } - /// Der Serienname aus dem Pfad — Staffelordner übersprungen. + /// Der Serienname aus dem Pfad. /// /// - /// Höchstens zwei Ebenen hoch. Weiter oben steht der Wurzelordner - /// der Mediathek, und „Serien" ist kein Serientitel. + /// + /// Mit Wurzel: der erste Ordner darunter. Alles, was in + /// Serien/Pingu/ liegt, gehört zu Pingu — egal wie tief und egal + /// wie die Datei heisst. Das ist die Regel, die ein Mensch beim Anlegen + /// der Ordner im Kopf hatte. + /// + /// + /// Ohne Wurzel: höchstens zwei Ebenen hoch, Staffelordner + /// übersprungen. Diesen Weg gehen nur die Tests, die mit relativen Pfaden + /// arbeiten. + /// /// - private static string? SerieAusOrdner(string pfad) + private static string? SerieAusOrdner(string pfad, string? wurzel) { + if (wurzel is { Length: > 0 }) + { + var unterhalb = UnterWurzel(pfad, wurzel); + // Liegt die Datei direkt in der Wurzel, gibt es keinen Ordner, der + // sie benennt — dann bleibt nur der Dateiname. + if (unterhalb is null || !unterhalb.Contains('/')) return null; + + var erster = unterhalb[..unterhalb.IndexOf('/')]; + return Saubermachen(erster); + } + var ordner = Path.GetDirectoryName(pfad); for (var ebene = 0; ebene < 2 && !string.IsNullOrEmpty(ordner); ebene++) @@ -240,12 +278,7 @@ public static partial class FileNames var name = Path.GetFileName(ordner); if (name.Length == 0) return null; - if (!IstStaffelordner(name)) - { - var (worte, _) = Zerlegen(name, istDateiname: false); - var (titel, _) = TitelBis(worte, worte.Length); - return titel.Length > 0 ? titel : null; - } + if (!IstStaffelordner(name)) return Saubermachen(name); ordner = Path.GetDirectoryName(ordner); } @@ -253,6 +286,23 @@ public static partial class FileNames return null; } + /// Der Pfad unterhalb der Wurzel, mit „/" getrennt. + private static string? UnterWurzel(string pfad, string wurzel) + { + var p = pfad.Replace('\\', '/'); + var w = wurzel.Replace('\\', '/').TrimEnd('/'); + + if (!p.StartsWith(w + "/", StringComparison.OrdinalIgnoreCase)) return null; + return p[(w.Length + 1)..]; + } + + private static string? Saubermachen(string ordnername) + { + var (worte, _) = Zerlegen(ordnername, istDateiname: false); + var (titel, _) = TitelBis(worte, worte.Length); + return titel.Length > 0 ? titel : null; + } + /// „Staffel 1", „Season 02", „S3" — Ordner, die keine Serie benennen. private static bool IstStaffelordner(string name) => StaffelOrdnerName().IsMatch(name.Trim()); diff --git a/server/src/Flaro.Data/LibraryScanner.cs b/server/src/Flaro.Data/LibraryScanner.cs index df173ae..f992e55 100644 --- a/server/src/Flaro.Data/LibraryScanner.cs +++ b/server/src/Flaro.Data/LibraryScanner.cs @@ -114,7 +114,10 @@ public sealed class LibraryScanner( } else { - var name = FileNames.ParseEpisode(datei.Path); + // **Mit der Wurzel der Mediathek.** Erst damit kann der + // Scanner wissen, welcher Ordner die Serie benennt — und ohne + // das wurde aus jeder Folgendatei eine eigene Serie. + var name = FileNames.ParseEpisode(datei.Path, bibliothek.Path); if (name is null) { // **Nicht raten.** Eine erfundene Staffel 1 landete unter diff --git a/server/src/Flaro.Data/Migrations/20261004121630_Reihenfolge.Designer.cs b/server/src/Flaro.Data/Migrations/20261004121630_Reihenfolge.Designer.cs new file mode 100644 index 0000000..e9381d9 --- /dev/null +++ b/server/src/Flaro.Data/Migrations/20261004121630_Reihenfolge.Designer.cs @@ -0,0 +1,885 @@ +// +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("20261004121630_Reihenfolge")] + partial class Reihenfolge + { + /// + 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("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("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("SortTitleValue") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("TmdbId") + .HasColumnType("INTEGER"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + 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("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("SortTitleValue") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("TmdbId") + .HasColumnType("INTEGER"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + 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("UpdatedAt") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("Watched") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("EpisodeId"); + + b.HasIndex("MovieId"); + + b.HasIndex("UserId", "EpisodeId") + .IsUnique() + .HasFilter("\"EpisodeId\" IS NOT NULL"); + + b.HasIndex("UserId", "MovieId") + .IsUnique() + .HasFilter("\"MovieId\" 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.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/server/src/Flaro.Data/Migrations/20261004121630_Reihenfolge.cs b/server/src/Flaro.Data/Migrations/20261004121630_Reihenfolge.cs new file mode 100644 index 0000000..13f6b22 --- /dev/null +++ b/server/src/Flaro.Data/Migrations/20261004121630_Reihenfolge.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Flaro.Data.Migrations +{ + /// + public partial class Reihenfolge : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "SortOrder", + table: "Libraries", + type: "INTEGER", + nullable: false, + defaultValue: 0); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "SortOrder", + table: "Libraries"); + } + } +} diff --git a/server/src/Flaro.Data/Migrations/FlaroContextModelSnapshot.cs b/server/src/Flaro.Data/Migrations/FlaroContextModelSnapshot.cs index c596ebe..6244a1e 100644 --- a/server/src/Flaro.Data/Migrations/FlaroContextModelSnapshot.cs +++ b/server/src/Flaro.Data/Migrations/FlaroContextModelSnapshot.cs @@ -135,6 +135,9 @@ namespace Flaro.Data.Migrations b.Property("ReviewNewItems") .HasColumnType("INTEGER"); + b.Property("SortOrder") + .HasColumnType("INTEGER"); + b.HasKey("Id"); b.ToTable("Libraries"); diff --git a/server/tests/Flaro.Core.Tests/FileNameTests.cs b/server/tests/Flaro.Core.Tests/FileNameTests.cs index 65f67b4..5e6567d 100644 --- a/server/tests/Flaro.Core.Tests/FileNameTests.cs +++ b/server/tests/Flaro.Core.Tests/FileNameTests.cs @@ -198,3 +198,60 @@ public class SerieAusOrdnerTests Assert.That(name.Episode, Is.EqualTo(5)); } } + +/// Mit Wurzel entscheidet der Ordner — allein. +/// +/// +/// Der Fall, der aus einer Serie siebenhundert machte. Dateien wie +/// „S01E001_Aang.mkv" tragen vor der Folgennummer nichts, also wurde der ganze +/// Dateiname zum Serientitel. Und „Doctor Who (2005) - S12E04.mkv" im Ordner +/// „Doctor Who" ergab zwei Serien, weil die Jahresangabe nur im Dateinamen +/// stand. Der Ordner ist die verlässlichere Angabe: den hat ein Mensch +/// angelegt. +/// +public class SerieAusDerWurzelTests +{ + private const string Wurzel = "/media/Serien"; + + [TestCase("/media/Serien/Pingu/S01E001_Willkommen.mkv", "Pingu")] + [TestCase("/media/Serien/Pingu/Staffel 1/S01E002_Volltreffer.mkv", "Pingu")] + [TestCase("/media/Serien/Avatar/Buch 1/S01E001_Aang.mkv", "Avatar")] + [TestCase("/media/Serien/Nachtstreife/S01E05_Folge 5.mkv", "Nachtstreife")] + public void Alles_im_Serienordner_gehoert_zu_dieser_Serie(string pfad, string erwartet) => + Assert.That(FileNames.ParseEpisode(pfad, Wurzel)?.SeriesTitle, Is.EqualTo(erwartet)); + + /// Auch dann, wenn der Dateiname etwas anderes behauptet. + [Test] + public void Der_Ordner_sticht_den_Dateinamen() => + Assert.That( + FileNames.ParseEpisode("/media/Serien/Doctor Who/Staffel 12/Doctor Who (2005) - S12E04.mkv", Wurzel) + ?.SeriesTitle, + Is.EqualTo("Doctor Who")); + + /// Zwei Schreibweisen im Dateinamen, ein Ordner, eine Serie. + [Test] + public void Zwei_Dateien_desselben_Ordners_ergeben_eine_Serie() + { + var a = FileNames.ParseEpisode("/media/Serien/Dark/S01E01.mkv", Wurzel); + var b = FileNames.ParseEpisode("/media/Serien/Dark/Staffel 1/Dark (2017) - S01E02.mkv", Wurzel); + + Assert.That(a!.SeriesTitle, Is.EqualTo(b!.SeriesTitle)); + Assert.That(a.SeriesTitle, Is.EqualTo("Dark")); + } + + /// Staffel und Folge liest weiterhin der Dateiname. + [Test] + public void Nummern_kommen_weiter_aus_dem_Dateinamen() + { + var name = FileNames.ParseEpisode("/media/Serien/Dark/Staffel 2/S02E05_Titel.mkv", Wurzel); + Assert.That(name!.Season, Is.EqualTo(2)); + Assert.That(name.Episode, Is.EqualTo(5)); + Assert.That(name.EpisodeTitle, Is.EqualTo("Titel")); + } + + /// Liegt die Datei direkt in der Wurzel, gibt es keinen Ordner, der sie benennt. + [Test] + public void Direkt_in_der_Wurzel_gilt_der_Dateiname() => + Assert.That(FileNames.ParseEpisode("/media/Serien/Dark - S01E01.mkv", Wurzel)?.SeriesTitle, + Is.EqualTo("Dark")); +} diff --git a/web/app/src/api.ts b/web/app/src/api.ts index 01d93db..2acd93f 100644 --- a/web/app/src/api.ts +++ b/web/app/src/api.ts @@ -86,6 +86,9 @@ export type Bibliothek = { /** Ob neue Funde erst geprüft werden. */ reviewNewItems: boolean; + + /** Wie viele Titel hier noch auf Freigabe warten. */ + pendingCount: number; }; export type Seite = { items: T[]; total: number }; export type Lieferung = { @@ -540,10 +543,21 @@ export const veroeffentlichen = (ids?: string[], publishAt?: string) => anfrage<{ published: number }>('/review/publish', { method: 'POST', body: JSON.stringify({ ids: ids ?? null, publishAt: publishAt ?? null }) }); +/** Alles Wartende einer einzelnen Mediathek. */ +export const mediathekFreigeben = (libraryId: string, publishAt?: string) => + anfrage<{ published: number }>('/review/publish', { + method: 'POST', + body: JSON.stringify({ ids: null, publishAt: publishAt ?? null, libraryId }), + }); + export const zurueckziehen = (ids: string[]) => anfrage<{ withheld: number }>('/review/withhold', { method: 'POST', body: JSON.stringify({ ids, publishAt: null }) }); +/** Die Reihenfolge der Seitenleiste, von oben nach unten. */ +export const reihenfolgeSetzen = (ids: string[]) => + anfrage('/libraries/order', { method: 'PUT', body: JSON.stringify({ ids }) }); + export const pruefungSetzen = (bibliothek: string, an: boolean) => anfrage(`/libraries/${bibliothek}/review`, { method: 'PUT', body: JSON.stringify({ reviewNewItems: an }) }); diff --git a/web/app/src/card.ts b/web/app/src/card.ts index 0ad4026..486dbc2 100644 --- a/web/app/src/card.ts +++ b/web/app/src/card.ts @@ -4,8 +4,9 @@ import { } from './api'; import { esc, anteil } from './util'; import { icons } from './icons'; -import { kontextmenue, type Menuepunkt } from './menue'; +import { kontextmenue, menueAnZiel, type Menuepunkt } from './menue'; import { bilddialog } from './bilder'; +import { zuordnungsdialog } from './zuordnung'; /** Was eine Kachel zeigt — unabhängig davon, woher sie kommt. */ export type Kachel = { @@ -69,6 +70,29 @@ export function kachel(k: Kachel, oeffnen: (art: 'filme' | 'serien', id: string) // Titeln hiess „die hab ich alle gesehen" vierzig Seitenwechsel. kontextmenue(knopf, () => kachelpunkte(k, knopf, oeffnen)); + // **Ein sichtbarer Griff zum selben Menü.** + // + // Rechtsklick findet nur, wer ihn vermutet, und auf einem Telefon gibt es + // ihn gar nicht. Die drei Punkte erscheinen beim Überfahren und bei + // Tastaturfokus — sie sind ein eigener Knopf, nicht Teil der Kachel, sonst + // öffnete jeder Klick darauf auch die Detailseite. + const mehr = document.createElement('span'); + mehr.className = 'kachel-mehr'; + mehr.setAttribute('role', 'button'); + mehr.setAttribute('tabindex', '0'); + mehr.setAttribute('aria-label', `Mehr zu „${k.titel}“`); + mehr.innerHTML = icons.punkte; + mehr.onclick = e => { + e.stopPropagation(); + menueAnZiel(mehr, kachelpunkte(k, knopf, oeffnen)); + }; + mehr.onkeydown = e => { + if (e.key !== 'Enter' && e.key !== ' ') return; + e.preventDefault(); + e.stopPropagation(); + menueAnZiel(mehr, kachelpunkte(k, knopf, oeffnen)); + }; + const rahmen = document.createElement('div'); rahmen.className = 'plakat'; rahmen.setAttribute('role', 'img'); @@ -119,6 +143,7 @@ export function kachel(k: Kachel, oeffnen: (art: 'filme' | 'serien', id: string) schweben.className = 'schweber'; schweben.innerHTML = icons.abspielen; rahmen.appendChild(schweben); + rahmen.appendChild(mehr); knopf.appendChild(rahmen); @@ -236,10 +261,20 @@ function kachelpunkte( punkte.push({ text: 'Details', icon: 'mehr', tun: () => oeffnen(k.art, k.id) }); if (istAdmin()) { + const art = k.art === 'serien' ? 'series' : 'movies'; punkte.push({ text: 'Bilder bearbeiten', icon: 'stift', - tun: () => bilddialog(k.art === 'serien' ? 'series' : 'movies', k.id, k.titel), + tun: () => bilddialog(art, k.id, k.titel), + }); + punkte.push({ + text: 'Zuordnung korrigieren', + icon: 'suche', + // **Hier und nicht nur auf der Detailseite.** Wer zwanzig falsch + // erkannte Titel vor sich hat, soll sie hier geradeziehen können. + tun: () => zuordnungsdialog(art, k.id, k.titel, + k.zweite && /^\d{4}$/.test(k.zweite) ? Number(k.zweite) : null, + () => location.reload()), }); } diff --git a/web/app/src/dialog.ts b/web/app/src/dialog.ts index 63f9253..d187de6 100644 --- a/web/app/src/dialog.ts +++ b/web/app/src/dialog.ts @@ -255,16 +255,53 @@ function ordnerfeld(eingabe: HTMLInputElement): HTMLElement { let hier: Ordnerstand | null = null; + /** Welcher Abruf gerade gilt — ältere Antworten werden verworfen. */ + let lauf = 0; + async function gehe(ziel?: string): Promise { + const dieser = ++lauf; liste.innerHTML = '
'; + + // **Nach zehn Sekunden sagen, dass es dauert.** Ein Spinner, der ewig + // dreht, sagt nichts — und genau so stand er, als ein Ordner nicht + // antwortete. Jetzt steht wenigstens da, worauf gewartet wird. + const langsam = window.setTimeout(() => { + if (dieser !== lauf) return; + liste.innerHTML = ''; + liste.appendChild(hinweiszeile( + `„${ziel ?? 'Start'}" antwortet seit zehn Sekunden nicht. ` + + 'Schläft die Platte, dauert der erste Zugriff — sonst stimmt etwas nicht.')); + liste.appendChild(spinnerzeile()); + }, 10000); + try { hier = await ordner(ziel); } catch (fehler) { + if (dieser !== lauf) return; liste.innerHTML = ''; - liste.appendChild(hinweiszeile((fehler as Error).message)); + liste.appendChild(hinweiszeile(`Konnte nicht gelesen werden: ${(fehler as Error).message}`)); return; + } finally { + window.clearTimeout(langsam); } - zeichne(); + + // Ein überholter Abruf darf nicht das Bild des neueren überschreiben. + if (dieser !== lauf) return; + + try { + zeichne(); + } catch (fehler) { + // **Auch ein Fehler beim Zeichnen muss sichtbar werden.** Vorher blieb + // dann der Spinner stehen, und es sah aus, als käme keine Antwort. + liste.innerHTML = ''; + liste.appendChild(hinweiszeile(`Anzeige gescheitert: ${(fehler as Error).message}`)); + } + } + + function spinnerzeile(): HTMLElement { + const d = document.createElement('div'); + d.className = 'spinner'; + return d; } function zeichne(): void { diff --git a/web/app/src/icons.ts b/web/app/src/icons.ts index 78c08ee..a43be2f 100644 --- a/web/app/src/icons.ts +++ b/web/app/src/icons.ts @@ -17,6 +17,9 @@ export const icons = { /** Ordner — fürs Durchklicken durch das Dateisystem. */ ordner: rahmen(''), + /** Drei Punkte — der sichtbare Griff zum Kontextmenü. */ + punkte: rahmen('' + + '', true), /** Winkel nach oben — eine Ebene zurück im Baum. */ hoch: rahmen(''), /** Filmstreifen — für eine Filmmediathek. */ diff --git a/web/app/src/menue.ts b/web/app/src/menue.ts index 331ca90..7497507 100644 --- a/web/app/src/menue.ts +++ b/web/app/src/menue.ts @@ -55,6 +55,17 @@ export function kontextmenue(ziel: HTMLElement, punkte: () => Menuepunkt[]): voi }, { passive: true }); } +/** Öffnet das Menü an einem Element — für den sichtbaren Griff. + * + * **Am Element und nicht am Mauszeiger.** Wer die drei Punkte mit der + * Tastatur auslöst, hat keinen Zeiger; das Menü soll trotzdem dort erscheinen, + * wo der Griff ist. + */ +export function menueAnZiel(ziel: HTMLElement, punkte: Menuepunkt[]): void { + const kasten = ziel.getBoundingClientRect(); + oeffnen(kasten.right, kasten.bottom + 4, punkte); +} + /** Das offene Menü — immer höchstens eines. */ let offen: HTMLElement | null = null; diff --git a/web/app/src/style.css b/web/app/src/style.css index aec5134..4848586 100644 --- a/web/app/src/style.css +++ b/web/app/src/style.css @@ -963,3 +963,27 @@ input[type="datetime-local"].feld::-webkit-calendar-picker-indicator:hover { color: var(--text-dim); font-size: var(--t-normal); } .reihe-kopf .knopfreihe .bleiben input { accent-color: var(--accent); width: 16px; height: 16px; } + +/* Der sichtbare Griff zum Kontextmenü auf einer Kachel. + * + * Rechtsklick findet nur, wer ihn vermutet, und auf dem Telefon gibt es ihn + * gar nicht. Beim Überfahren und bei Tastaturfokus erscheinen drei Punkte. */ +.kachel-mehr { + position: absolute; right: 6px; top: 6px; z-index: 4; + display: grid; place-items: center; + width: 28px; height: 28px; border-radius: 50%; + background: rgba(10, 10, 12, .78); + backdrop-filter: blur(6px); + color: var(--text); + opacity: 0; transition: opacity .15s ease; + cursor: pointer; +} +.kachel-mehr svg { width: 16px; height: 16px; } +.kachel:hover .kachel-mehr, +.kachel:focus-within .kachel-mehr, +.kachel-mehr:focus-visible { opacity: 1; } +/* Auf Geräten ohne Mauszeiger steht er dauerhaft da — sonst käme man nie + heran. */ +@media (hover: none) { + .kachel-mehr { opacity: .85; } +} diff --git a/web/app/src/views/admin.ts b/web/app/src/views/admin.ts index d414112..6f01327 100644 --- a/web/app/src/views/admin.ts +++ b/web/app/src/views/admin.ts @@ -4,6 +4,7 @@ import { vermisste, vermisstesEntfernen, type VermissteDatei, protokolltage, protokollAdresse, sprachen, spracheSetzen, uebersetzungenHolen, meineSprache, zuPruefen, veroeffentlichen, zurueckziehen, pruefungSetzen, + mediathekFreigeben, reihenfolgeSetzen, type Pruefeintrag, benutzer, benutzerAnlegen, metadatenEinstellung, metadatenSetzen, servernameSetzen, systemauskunft, @@ -12,7 +13,7 @@ import { } from '../api'; import { esc, dauer, knopf, fehlerzeile, leer } from '../util'; import { dialog, nachfragen } from '../dialog'; -import { icon } from '../icons'; +import { icon, icons } from '../icons'; /** Die Verwaltung. * @@ -519,6 +520,22 @@ function vermisstenbereich(geaendert: () => void): HTMLElement { /* ---------- Bibliotheken ---------- */ function bibliotheksbereich(geaendert: () => void, signal: AbortSignal): HTMLElement { + /** Die Liste, wie sie gerade steht — für das Umsortieren. */ + let alleBibliotheken: Bibliothek[] = []; + + /** Schiebt einen Eintrag und schickt die ganze neue Reihenfolge. */ + async function schieben(von: number, nach: number): Promise { + if (nach < 0 || nach >= alleBibliotheken.length) return; + + const neu = [...alleBibliotheken]; + const [eintrag] = neu.splice(von, 1); + neu.splice(nach, 0, eintrag!); + + await reihenfolgeSetzen(neu.map(b => b.id)); + await laden(); + geaendert(); + } + const bereich = document.createElement('section'); const anlegen = knopf('Mediathek hinzufügen', 'btn btn-primary'); @@ -571,14 +588,15 @@ function bibliotheksbereich(geaendert: () => void, signal: AbortSignal): HTMLEle liste.appendChild(leer('Noch keine Mediathek. Lege oben eine an.')); return; } - alle.forEach(b => liste.appendChild(block(b))); + alleBibliotheken = alle; + alle.forEach((b, i) => liste.appendChild(block(b, i))); } catch (fehler) { liste.innerHTML = ''; liste.appendChild(fehlerzeile((fehler as Error).message)); } } - function block(b: Bibliothek): HTMLElement { + function block(b: Bibliothek, platz: number): HTMLElement { const d = document.createElement('div'); d.className = 'einstellung'; @@ -589,10 +607,16 @@ function bibliotheksbereich(geaendert: () => void, signal: AbortSignal): HTMLEle ? (serie ? 'Serie' : 'Film') : (serie ? 'Serien' : 'Filme')); + // Was wartet, gehört in dieselbe Zeile — sonst erfährt man es nur, wenn + // man bis zur Prüfliste weiterscrollt. + const wartet = b.pendingCount > 0 + ? ` · ${b.pendingCount} wartet${b.pendingCount === 1 ? '' : 'en'} auf Freigabe` + : ''; + const kopf = document.createElement('div'); kopf.className = 'zeile'; kopf.innerHTML = `${esc(b.name)}` - + `${esc(b.path)}${esc(anzahl)}`; + + `${esc(b.path)}${esc(anzahl)}${esc(wartet)}`; d.appendChild(kopf); const stand = document.createElement('div'); @@ -685,9 +709,51 @@ function bibliotheksbereich(geaendert: () => void, signal: AbortSignal): HTMLEle } }; + // **Umsortieren über zwei Pfeile, nicht über Ziehen.** + // + // Ziehen und Ablegen ist schöner, aber mit der Tastatur unbedienbar und + // auf dem Telefon ein Glücksspiel. Zwei Knöpfe kann jeder bedienen, und + // der Server bekommt die ganze Liste statt eines „eins hoch" — zwei + // Benutzer, die gleichzeitig schieben, kämen sonst in einen Zustand, den + // keiner von beiden wollte. + const hoch = knopf('', 'btn rund'); + hoch.innerHTML = icons.hoch; + hoch.setAttribute('aria-label', `„${b.name}“ nach oben`); + hoch.disabled = platz === 0; + hoch.onclick = () => void schieben(platz, platz - 1); + + const runter = knopf('', 'btn rund'); + runter.innerHTML = icons.hoch; + runter.style.transform = 'rotate(180deg)'; + runter.setAttribute('aria-label', `„${b.name}“ nach unten`); + runter.disabled = platz === alleBibliotheken.length - 1; + runter.onclick = () => void schieben(platz, platz + 1); + const knoepfe = document.createElement('div'); knoepfe.className = 'knopfreihe'; - knoepfe.append(los, auffrischen, pruefung, umbenennen, entfernen); + knoepfe.append(hoch, runter, los, auffrischen, pruefung, umbenennen, entfernen); + + // **Nur wenn hier wirklich etwas liegt.** Ein Knopf, der nichts zu tun + // hat, ist schlechter als keiner — und die Zahl daneben sagt, worum es + // geht, ohne dass man erst weiterscrollen muss. + if (b.pendingCount > 0) { + const freigeben = knopf( + `${b.pendingCount} ${b.pendingCount === 1 ? 'Titel' : 'Titel'} freigeben`, + 'btn btn-primary'); + freigeben.onclick = async () => { + freigeben.disabled = true; + try { + const bericht = await mediathekFreigeben(b.id); + stand.textContent = `${bericht.published} freigegeben.`; + await laden(); + geaendert(); + } catch (fehler) { + d.appendChild(fehlerzeile((fehler as Error).message)); + freigeben.disabled = false; + } + }; + knoepfe.appendChild(freigeben); + } d.append(knoepfe, stand); void verfolgen(false); return d; @@ -821,21 +887,38 @@ function serverbereich(geaendert: () => void): HTMLElement { platzhalter: 'Read Access Token oder API Key', hinweis: 'Leer lassen, um den mitgelieferten zu behalten.', }, - { - name: 'sprache', beschriftung: 'Sprache der Metadaten', - auswahl: sprachen, wert: stand.language, - hinweis: 'In dieser Sprache werden Titel und Beschreibungen geholt. ' - + 'Sie bestimmt auch, welche Altersfreigabe gilt. Vorhandene Titel ' - + 'behalten ihre alte Sprache — dafür gibt es bei jeder Mediathek ' - + 'den Knopf „Metadaten auffrischen".', - }, ], bestaetigen: 'Speichern', tun: async wert => { - await metadatenSetzen({ - apiKey: wert('schluessel') || undefined, - language: wert('sprache') || undefined, - }); + await metadatenSetzen({ apiKey: wert('schluessel') || undefined }); + location.reload(); + }, + }))); + + // **Die Sprache des Servers als eigene Zeile.** + // + // Sie steckte im Dialog „Metadaten" — zusammen mit dem TMDB-Schlüssel, + // also dort, wo niemand nach einer Sprache sucht. Wer die Mediathek auf + // Englisch vorfand, sah nur „Meine Sprache" und hielt das für die + // einzige Möglichkeit. + liste.appendChild(zeile( + 'Sprache des Servers', + sprachen.find(x => x.wert === stand.language)?.text ?? stand.language, + 'In dieser Sprache werden Titel und Beschreibungen geholt, und sie ' + + 'bestimmt die Altersfreigabe. Gilt für alle. Vorhandene Titel behalten ' + + 'ihre alte Sprache — dafür gibt es bei jeder Mediathek „Metadaten ' + + 'auffrischen".', + () => dialog({ + titel: 'Sprache des Servers', + text: 'Die Umstellung wirkt auf neue Funde sofort. Für den vorhandenen ' + + 'Bestand drückst du danach bei jeder Mediathek „Metadaten auffrischen" ' + + '— sonst behalten die Titel ihre alte Sprache.', + felder: [{ + name: 'sprache', beschriftung: 'Sprache', auswahl: sprachen, wert: stand.language, + }], + bestaetigen: 'Übernehmen', + tun: async wert => { + await metadatenSetzen({ language: wert('sprache') }); location.reload(); }, }))); diff --git a/web/app/src/zuordnung.ts b/web/app/src/zuordnung.ts new file mode 100644 index 0000000..8342508 --- /dev/null +++ b/web/app/src/zuordnung.ts @@ -0,0 +1,104 @@ +import { vorschlaege, zuordnen } from './api'; +import { esc, knopf, feld, fehlerzeile, leer } from './util'; +import { icons } from './icons'; + +/** Einen Titel einem anderen Eintrag der Metadatenquelle zuordnen. + * + * **Als eigener Dialog und nicht nur auf der Detailseite.** Wer in der + * Mediathek zwanzig falsch erkannte Titel vor sich hat, soll sie dort + * geradeziehen können — vierzig Seitenwechsel sind keine Bedienung. + * + * `fertig` wird gerufen, wenn etwas zugeordnet wurde; der Aufrufer frischt + * damit auf, was er zeigt. + */ +export function zuordnungsdialog( + art: 'movies' | 'series', + id: string, + titel: string, + jahr: number | null, + fertig?: () => void, +): void { + const d = document.createElement('dialog'); + d.className = 'dlg dlg-breit'; + + const kopf = document.createElement('div'); + kopf.className = 'dlg-kopf'; + const h = document.createElement('h2'); + h.textContent = 'Zuordnung korrigieren'; + 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'; + + const hinweis = document.createElement('p'); + hinweis.className = 'hinweis'; + hinweis.textContent = 'Der gewählte Eintrag ersetzt Titel, Beschreibung, Bilder ' + + 'und Besetzung. Von Hand gewählte Bilder bleiben.'; + + const suche = feld('Anderer Suchbegriff'); + suche.value = titel; + const jahrfeld = feld('Jahr', 'number'); + if (jahr) jahrfeld.value = String(jahr); + const suchen = knopf('Suchen', 'btn'); + + const reihe = document.createElement('div'); + reihe.className = 'knopfreihe'; + reihe.append(suche, jahrfeld, suchen); + + const treffer = document.createElement('div'); + treffer.className = 'einstellungen'; + + koerper.append(hinweis, reihe, treffer); + d.append(kopf, koerper); + + d.addEventListener('close', () => d.remove()); + d.addEventListener('click', e => { if (e.target === d) d.close(); }); + // Enter im Suchfeld sucht, statt den Dialog zu schliessen. + suche.onkeydown = e => { if (e.key === 'Enter') { e.preventDefault(); void fragen(); } }; + + document.body.appendChild(d); + d.showModal(); + + suchen.onclick = () => void fragen(); + void fragen(); + + async function fragen(): Promise { + treffer.innerHTML = '
'; + try { + const liste = await vorschlaege(art, id, + suche.value.trim(), jahrfeld.value ? Number(jahrfeld.value) : undefined); + + treffer.innerHTML = ''; + if (liste.length === 0) { treffer.appendChild(leer('Nichts gefunden.')); return; } + + liste.forEach(v => { + const zeile = document.createElement('button'); + zeile.className = 'einstellung waehlbar'; + zeile.innerHTML = + `
${esc(v.title)}` + + `${esc(v.year ?? '')} · TMDB ${v.tmdbId}
` + + `
${esc((v.overview ?? '').slice(0, 160))}
`; + zeile.onclick = async () => { + zeile.disabled = true; + try { + await zuordnen(art, id, v.tmdbId); + d.close(); + fertig?.(); + } catch (fehler) { + treffer.appendChild(fehlerzeile((fehler as Error).message)); + zeile.disabled = false; + } + }; + treffer.appendChild(zeile); + }); + } catch (fehler) { + treffer.innerHTML = ''; + treffer.appendChild(fehlerzeile((fehler as Error).message)); + } + } +}