diff --git a/server/src/Flaro.Api/Endpoints/Contracts.cs b/server/src/Flaro.Api/Endpoints/Contracts.cs index b99bd36..e3a6bab 100644 --- a/server/src/Flaro.Api/Endpoints/Contracts.cs +++ b/server/src/Flaro.Api/Endpoints/Contracts.cs @@ -46,6 +46,14 @@ public sealed record Unzugeordnet(int Gesamt, IReadOnlyList public sealed record OhneZuordnung( Guid Id, string Art, string Titel, int? Jahr, int Dateien, string? Beispiel); +/// Welche Mehrfachfassungen abgehakt oder wieder hervorgeholt werden. +/// +/// +/// true hakt ab, false holt zurück. Ein Weg statt zweier +/// Endpunkte: es ist dieselbe Spalte, nur einmal gesetzt und einmal geleert. +/// +public sealed record FassungenRequest(IReadOnlyList Ids, bool Ignorieren = true); + /// Titel mit mehr als einer Datei — dort versteckt sich Falsches. public sealed record Mehrfachbericht( int Gesamt, IReadOnlyList Titel); diff --git a/server/src/Flaro.Api/Endpoints/ManagementEndpoints.cs b/server/src/Flaro.Api/Endpoints/ManagementEndpoints.cs index b39ad4b..a16ff14 100644 --- a/server/src/Flaro.Api/Endpoints/ManagementEndpoints.cs +++ b/server/src/Flaro.Api/Endpoints/ManagementEndpoints.cs @@ -358,13 +358,23 @@ public static class ManagementEndpoints // Metadatenquelle auf denselben Treffer liefen — und dann ist einer // davon lautlos verschwunden. Wer die Liste durchsieht, erkennt das // an den Dateinamen in zwei Sekunden. + // + // **`ignorierte` zeigt, was abgehakt wurde.** Ohne diesen Schalter + // wäre das Abhaken unumkehrbar, und niemand wüsste hinterher, was er + // weggeklickt hat — dieselbe Überlegung wie bei den Meldungen. bibliotheken.MapGet("/{id:guid}/versions", async ( - Guid id, FlaroContext db, int take = 300) => + Guid id, FlaroContext db, int take = 300, bool ignorierte = false) => { if (!await db.Libraries.AsNoTracking().AnyAsync(l => l.Id == id)) return Results.NotFound(); var mehrfach = await db.Movies.AsNoTracking() .Where(m => m.LibraryId == id && db.MediaFiles.Count(f => f.MovieId == m.Id) > 1) + // Abgehakt ist, wer bei genau dieser Dateizahl abgehakt + // wurde. Kommt eine Fassung dazu, stimmt die Zahl nicht mehr + // und die Gruppe meldet sich von selbst zurück. + .Where(m => ignorierte + ? m.FassungenGeprueftBei == db.MediaFiles.Count(f => f.MovieId == m.Id) + : m.FassungenGeprueftBei != db.MediaFiles.Count(f => f.MovieId == m.Id)) .OrderBy(m => m.SortTitleValue) .Take(Math.Clamp(take, 1, 2000)) .Select(m => new @@ -384,6 +394,33 @@ public static class ManagementEndpoints return Results.Ok(new Mehrfachbericht(liste.Count, liste)); }).RequireAuthorization(FlaroPolicies.Administrator); + /// Abhaken und wieder hervorholen — beides über denselben Weg. + bibliotheken.MapPost("/{id:guid}/versions/acknowledge", async ( + Guid id, FassungenRequest anfrage, FlaroContext db, CancellationToken ct) => + { + if (!await db.Libraries.AsNoTracking().AnyAsync(l => l.Id == id, ct)) return Results.NotFound(); + if (anfrage.Ids is not { Count: > 0 }) return Einwand.Bitte("Es war kein Titel ausgewählt."); + + var gewaehlt = anfrage.Ids.ToHashSet(); + + // **Die Zahl wird hier gerechnet, nicht vom Client geschickt.** + // Ein Client, der eine veraltete Liste anzeigt, hakte sonst eine + // Gruppe mit zwei Dateien ab, die inzwischen drei hat — und die + // dritte verschwände ungesehen. + var betroffen = await db.Movies + .Where(m => m.LibraryId == id && gewaehlt.Contains(m.Id)) + .Select(m => new { Film = m, Anzahl = db.MediaFiles.Count(f => f.MovieId == m.Id) }) + .ToListAsync(ct); + + foreach (var z in betroffen) + { + z.Film.FassungenGeprueftBei = anfrage.Ignorieren ? z.Anzahl : null; + } + + await db.SaveChangesAsync(ct); + return Results.Ok(new { geaendert = betroffen.Count }); + }).RequireAuthorization(FlaroPolicies.Administrator); + bibliotheken.MapGet("/missing", async (FlaroContext db) => { var liste = await db.MediaFiles.AsNoTracking() diff --git a/server/src/Flaro.Api/Program.cs b/server/src/Flaro.Api/Program.cs index abeb87b..b9756e4 100644 --- a/server/src/Flaro.Api/Program.cs +++ b/server/src/Flaro.Api/Program.cs @@ -322,8 +322,23 @@ if (Directory.Exists(oberfläche)) // Das im Augenblick des Einschaltens zu tun hiess: zwei Minuten // „wird geholt", und noch immer kein Text. Hier läuft es einmal je // Datei durch, eine nach der anderen, während niemand wartet. - _ = app.Services.GetRequiredService() - .NachtragenAsync(leben.ApplicationStopping); + // + // **Und nicht sofort.** Der Lauf liest die ganze Mediathek von + // derselben Platte, von der die Startseite ihre Bilder holt. Wer in + // dem Augenblick aufmacht, in dem der Server hochkommt — und das ist + // der häufige Fall, denn man startet ihn ja, um etwas zu schauen —, + // wartete auf beides zugleich. Eine Minute reicht, damit die erste + // Seite steht. + _ = Task.Run(async () => + { + try + { + await Task.Delay(TimeSpan.FromMinutes(1), leben.ApplicationStopping); + await app.Services.GetRequiredService() + .NachtragenAsync(leben.ApplicationStopping); + } + catch (OperationCanceledException) { /* Der Server fährt herunter. */ } + }); }); leben.ApplicationStopping.Register(() => tagebuch.Schreiben("Server fährt herunter.")); diff --git a/server/src/Flaro.Api/Untertitelvorrat.cs b/server/src/Flaro.Api/Untertitelvorrat.cs index 6dba6e8..e3f85ce 100644 --- a/server/src/Flaro.Api/Untertitelvorrat.cs +++ b/server/src/Flaro.Api/Untertitelvorrat.cs @@ -39,8 +39,21 @@ public sealed class Untertitelvorrat( FlaroPaths pfade, UntertitelLeser leser, IServiceScopeFactory scopes, + PlaybackSessions laufende, ILogger log) { + /// Wie lange gewartet wird, solange jemand zusieht. + /// + /// + /// Die laufende Wiedergabe ist wichtiger als der Vorrat. Gelesen + /// wird von derselben Platte, von der auch der Film kommt und die + /// Startseite ihre Bilder holt — und eine Datei zu lesen heisst hier, + /// vierzig Gigabyte zu lesen. Der Vorrat hat Zeit, der Zuschauer nicht. + /// Die Wiedergabesitzungen verfallen nach einer Minute ohne Meldung, ein + /// halbminütiger Blick trifft das Ende also knapp genug. + /// + private static readonly TimeSpan Rücksicht = TimeSpan.FromSeconds(30); + /// Was ein Abruf vorfindet. public enum Stand { @@ -191,6 +204,22 @@ public sealed class Untertitelvorrat( Laufende = naechste; } + // **Erst nachsehen, ob jemand zusieht.** Zurückgestellt, nicht + // verworfen: die Datei bleibt vorn in der Reihe und kommt dran, + // sobald der Abspann läuft. + while (laufende.Laufende().Count > 0) + { + lock (_schloss) { _reihe.AddFirst(naechste); Laufende = null; } + await Task.Delay(Rücksicht); + lock (_schloss) + { + if (_reihe.First is null) { _arbeitet = false; return; } + naechste = _reihe.First.Value; + _reihe.RemoveFirst(); + Laufende = naechste; + } + } + try { await Ziehen(naechste); } catch (Exception fehler) { diff --git a/server/src/Flaro.Core/Library/MediaEntities.cs b/server/src/Flaro.Core/Library/MediaEntities.cs index dea5003..194b285 100644 --- a/server/src/Flaro.Core/Library/MediaEntities.cs +++ b/server/src/Flaro.Core/Library/MediaEntities.cs @@ -142,6 +142,32 @@ public sealed class Movie : Flaro.Core.Metadata.IBebildert public bool BackdropPinned { get; set; } public bool LogoPinned { get; set; } + /// Wie viele Dateien dieser Film hatte, als jemand die Fassungen abhakte. + /// + /// + /// + /// Wozu. Zwei Dateien an einem Film sind meist zwei Fassungen und + /// völlig in Ordnung. In der Liste „Mehrfachfassungen" standen sie + /// trotzdem für immer, weil die Liste jedes Mal frisch rechnet und es + /// keinen Zustand gab — wer absichtlich zwei Fassungen hält, konnte die + /// Gruppe nicht loswerden. + /// + /// + /// Warum eine Anzahl und kein Zeitstempel. Ein Zeitstempel müsste + /// gegen etwas verglichen werden, und das wäre MediaFile.ModifiedAt + /// — die Änderungszeit der Quelldatei. Eine alte Datei, frisch + /// hineinkopiert, behält ihre alte Zeit und käme nie wieder hoch. Die + /// Anzahl ist genau die Frage: kommt eine dritte Fassung dazu, soll die + /// Gruppe wieder erscheinen. + /// + /// + /// Der Preis ist bekannt: wird eine Datei gegen eine andere getauscht, + /// bleibt die Gruppe abgehakt. Das ist die seltenere Hälfte, und + /// „Ignorierte zeigen" führt zurück. + /// + /// + public int? FassungenGeprueftBei { get; set; } + /// Der Titel, wie ihn jemand von Hand gesetzt hat. /// /// diff --git a/server/src/Flaro.Data/Migrations/20261008154809_Fassungspruefung.Designer.cs b/server/src/Flaro.Data/Migrations/20261008154809_Fassungspruefung.Designer.cs new file mode 100644 index 0000000..10e9603 --- /dev/null +++ b/server/src/Flaro.Data/Migrations/20261008154809_Fassungspruefung.Designer.cs @@ -0,0 +1,1134 @@ +// +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("20261008154809_Fassungspruefung")] + partial class Fassungspruefung + { + /// + 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.Auth.Einladung", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("CreatedByUserId") + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("INTEGER"); + + b.Property("MaxUses") + .HasColumnType("INTEGER"); + + b.Property("RevokedAt") + .HasColumnType("INTEGER"); + + b.Property("TokenFingerprint") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("UsedCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("CreatedByUserId"); + + b.HasIndex("TokenFingerprint") + .IsUnique(); + + b.ToTable("Einladungen"); + }); + + modelBuilder.Entity("Flaro.Core.Libraries.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("ReviewNewItems") + .HasColumnType("INTEGER"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Libraries"); + }); + + modelBuilder.Entity("Flaro.Core.Library.Credit", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Character") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("MovieId") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("TEXT"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MovieId"); + + b.HasIndex("PersonId"); + + b.HasIndex("SeriesId"); + + b.ToTable("Credits"); + }); + + modelBuilder.Entity("Flaro.Core.Library.Episode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AddedAt") + .HasColumnType("INTEGER"); + + b.Property("EpisodeNumber") + .HasColumnType("INTEGER"); + + b.Property("MetadataLanguage") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("Overview") + .HasColumnType("TEXT"); + + b.Property("PublishAt") + .HasColumnType("INTEGER"); + + b.Property("SeasonNumber") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("TEXT"); + + b.Property("StillImage") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId", "SeasonNumber", "EpisodeNumber") + .IsUnique(); + + b.ToTable("Episodes"); + }); + + modelBuilder.Entity("Flaro.Core.Library.Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Genres"); + }); + + modelBuilder.Entity("Flaro.Core.Library.GenreLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("GenreId") + .HasColumnType("TEXT"); + + b.Property("MovieId") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MovieId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("GenreId", "MovieId") + .IsUnique() + .HasFilter("\"MovieId\" IS NOT NULL"); + + b.HasIndex("GenreId", "SeriesId") + .IsUnique() + .HasFilter("\"SeriesId\" IS NOT NULL"); + + b.ToTable("GenreLinks"); + }); + + modelBuilder.Entity("Flaro.Core.Library.ItemTranslation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("EpisodeId") + .HasColumnType("TEXT"); + + b.Property("FetchedAt") + .HasColumnType("INTEGER"); + + b.Property("Language") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("MovieId") + .HasColumnType("TEXT"); + + b.Property("Overview") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Title") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EpisodeId", "Language") + .IsUnique() + .HasFilter("\"EpisodeId\" IS NOT NULL"); + + b.HasIndex("MovieId", "Language") + .IsUnique() + .HasFilter("\"MovieId\" IS NOT NULL"); + + b.HasIndex("SeriesId", "Language") + .IsUnique() + .HasFilter("\"SeriesId\" IS NOT NULL"); + + b.ToTable("Translations"); + }); + + modelBuilder.Entity("Flaro.Core.Library.MediaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("BitRate") + .HasColumnType("INTEGER"); + + b.Property("Container") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Duration") + .HasColumnType("INTEGER"); + + b.Property("EpisodeId") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("TEXT"); + + b.Property("MissingSince") + .HasColumnType("INTEGER"); + + b.Property("ModifiedAt") + .HasColumnType("INTEGER"); + + b.Property("MovieId") + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("ProbedAt") + .HasColumnType("INTEGER"); + + b.Property("SeenAt") + .HasColumnType("INTEGER"); + + b.Property("SizeBytes") + .HasColumnType("INTEGER"); + + b.Property("VersionLabel") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EpisodeId"); + + b.HasIndex("MovieId"); + + b.HasIndex("LibraryId", "Path") + .IsUnique(); + + b.ToTable("MediaFiles"); + }); + + modelBuilder.Entity("Flaro.Core.Library.MediaStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Channels") + .HasColumnType("INTEGER"); + + b.Property("Codec") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("Index") + .HasColumnType("INTEGER"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("IsForced") + .HasColumnType("INTEGER"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("Language") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("MediaFileId") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("MediaFileId", "Index") + .IsUnique(); + + b.ToTable("MediaStreams"); + }); + + modelBuilder.Entity("Flaro.Core.Library.Meldung", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Art") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("EpisodeId") + .HasColumnType("TEXT"); + + b.Property("ErledigtAm") + .HasColumnType("INTEGER"); + + b.Property("ErledigtVon") + .HasColumnType("TEXT"); + + b.Property("GemeldetAm") + .HasColumnType("INTEGER"); + + b.Property("Kommentar") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("MovieId") + .HasColumnType("TEXT"); + + b.Property("SeasonNumber") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("TEXT"); + + b.Property("Stelle") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EpisodeId"); + + b.HasIndex("MovieId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("ErledigtAm", "GemeldetAm"); + + b.HasIndex("UserId", "GemeldetAm"); + + b.ToTable("Meldungen"); + }); + + modelBuilder.Entity("Flaro.Core.Library.Movie", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AddedAt") + .HasColumnType("INTEGER"); + + b.Property("BackdropImage") + .HasColumnType("TEXT"); + + b.Property("BackdropPinned") + .HasColumnType("INTEGER"); + + b.Property("ChangedSince") + .HasColumnType("INTEGER"); + + b.Property("ContentRating") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("FassungenGeprueftBei") + .HasColumnType("INTEGER"); + + b.Property("ImdbId") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("TEXT"); + + b.Property("LogoImage") + .HasColumnType("TEXT"); + + b.Property("LogoPinned") + .HasColumnType("INTEGER"); + + b.Property("MatchLocked") + .HasColumnType("INTEGER"); + + b.Property("MetadataLanguage") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("OriginalTitle") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Overview") + .HasColumnType("TEXT"); + + b.Property("ParsedTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ParsedYear") + .HasColumnType("INTEGER"); + + b.Property("PosterImage") + .HasColumnType("TEXT"); + + b.Property("PosterPinned") + .HasColumnType("INTEGER"); + + b.Property("PublishAt") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("RuntimeMinutes") + .HasColumnType("INTEGER"); + + b.Property("SortTitlePinned") + .HasColumnType("INTEGER"); + + b.Property("SortTitleValue") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("TitlePinned") + .HasColumnType("INTEGER"); + + b.Property("TmdbId") + .HasColumnType("INTEGER"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId", "MetadataLanguage"); + + b.HasIndex("LibraryId", "SortTitleValue"); + + b.HasIndex("LibraryId", "TmdbId") + .IsUnique() + .HasFilter("\"TmdbId\" IS NOT NULL"); + + b.ToTable("Movies"); + }); + + modelBuilder.Entity("Flaro.Core.Library.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Image") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("TmdbId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("TmdbId") + .IsUnique(); + + b.ToTable("People"); + }); + + modelBuilder.Entity("Flaro.Core.Library.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AddedAt") + .HasColumnType("INTEGER"); + + b.Property("BackdropImage") + .HasColumnType("TEXT"); + + b.Property("BackdropPinned") + .HasColumnType("INTEGER"); + + b.Property("ChangedSince") + .HasColumnType("INTEGER"); + + b.Property("ContentRating") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("ImdbId") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("TEXT"); + + b.Property("LogoImage") + .HasColumnType("TEXT"); + + b.Property("LogoPinned") + .HasColumnType("INTEGER"); + + b.Property("MatchLocked") + .HasColumnType("INTEGER"); + + b.Property("MetadataLanguage") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("OriginalTitle") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Overview") + .HasColumnType("TEXT"); + + b.Property("ParsedTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ParsedYear") + .HasColumnType("INTEGER"); + + b.Property("PosterImage") + .HasColumnType("TEXT"); + + b.Property("PosterPinned") + .HasColumnType("INTEGER"); + + b.Property("PublishAt") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("RuntimeMinutes") + .HasColumnType("INTEGER"); + + b.Property("SortTitlePinned") + .HasColumnType("INTEGER"); + + b.Property("SortTitleValue") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("TitlePinned") + .HasColumnType("INTEGER"); + + b.Property("TmdbId") + .HasColumnType("INTEGER"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId", "MetadataLanguage"); + + b.HasIndex("LibraryId", "SortTitleValue"); + + b.HasIndex("LibraryId", "TmdbId") + .IsUnique() + .HasFilter("\"TmdbId\" IS NOT NULL"); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("Flaro.Core.Playback.Sehtag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("EpisodeId") + .HasColumnType("TEXT"); + + b.Property("MovieId") + .HasColumnType("TEXT"); + + b.Property("Sekunden") + .HasColumnType("REAL"); + + b.Property("Tag") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EpisodeId"); + + b.HasIndex("MovieId"); + + b.HasIndex("UserId", "Tag"); + + b.HasIndex("UserId", "Tag", "EpisodeId") + .IsUnique() + .HasFilter("\"EpisodeId\" IS NOT NULL"); + + b.HasIndex("UserId", "Tag", "MovieId") + .IsUnique() + .HasFilter("\"MovieId\" IS NOT NULL"); + + b.ToTable("Sehtage"); + }); + + modelBuilder.Entity("Flaro.Core.Playback.UserItemState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Dismissed") + .HasColumnType("INTEGER"); + + b.Property("EpisodeId") + .HasColumnType("TEXT"); + + b.Property("Favourite") + .HasColumnType("INTEGER"); + + b.Property("InProgress") + .HasColumnType("INTEGER"); + + b.Property("LastPlayedAt") + .HasColumnType("INTEGER"); + + b.Property("MovieId") + .HasColumnType("TEXT"); + + b.Property("PlayCount") + .HasColumnType("INTEGER"); + + b.Property("Position") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("Runtime") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("Watched") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("EpisodeId"); + + b.HasIndex("MovieId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("UserId", "EpisodeId") + .IsUnique() + .HasFilter("\"EpisodeId\" IS NOT NULL"); + + b.HasIndex("UserId", "MovieId") + .IsUnique() + .HasFilter("\"MovieId\" IS NOT NULL"); + + b.HasIndex("UserId", "SeriesId") + .IsUnique() + .HasFilter("\"SeriesId\" IS NOT NULL"); + + b.HasIndex("UserId", "InProgress", "UpdatedAt"); + + b.ToTable("UserItemStates"); + }); + + modelBuilder.Entity("Flaro.Core.ServerSetting", b => + { + b.Property("Key") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("Settings"); + }); + + modelBuilder.Entity("Flaro.Core.Users.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AvatarImage") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("IsAdministrator") + .HasColumnType("INTEGER"); + + b.Property("IsOwner") + .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.Auth.Einladung", b => + { + b.HasOne("Flaro.Core.Users.User", null) + .WithMany() + .HasForeignKey("CreatedByUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Flaro.Core.Library.Credit", b => + { + b.HasOne("Flaro.Core.Library.Movie", null) + .WithMany() + .HasForeignKey("MovieId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Flaro.Core.Library.Person", null) + .WithMany() + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Flaro.Core.Library.Series", null) + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Flaro.Core.Library.Episode", b => + { + b.HasOne("Flaro.Core.Library.Series", null) + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Flaro.Core.Library.GenreLink", b => + { + b.HasOne("Flaro.Core.Library.Genre", null) + .WithMany() + .HasForeignKey("GenreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Flaro.Core.Library.Movie", null) + .WithMany() + .HasForeignKey("MovieId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Flaro.Core.Library.Series", null) + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Flaro.Core.Library.MediaFile", b => + { + b.HasOne("Flaro.Core.Libraries.Library", null) + .WithMany() + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Flaro.Core.Library.MediaStream", b => + { + b.HasOne("Flaro.Core.Library.MediaFile", null) + .WithMany() + .HasForeignKey("MediaFileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Flaro.Core.Library.Meldung", b => + { + b.HasOne("Flaro.Core.Library.Episode", null) + .WithMany() + .HasForeignKey("EpisodeId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Flaro.Core.Library.Movie", null) + .WithMany() + .HasForeignKey("MovieId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Flaro.Core.Library.Series", null) + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Flaro.Core.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Flaro.Core.Library.Movie", b => + { + b.HasOne("Flaro.Core.Libraries.Library", null) + .WithMany() + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Flaro.Core.Library.Series", b => + { + b.HasOne("Flaro.Core.Libraries.Library", null) + .WithMany() + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Flaro.Core.Playback.Sehtag", b => + { + b.HasOne("Flaro.Core.Library.Episode", null) + .WithMany() + .HasForeignKey("EpisodeId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Flaro.Core.Library.Movie", null) + .WithMany() + .HasForeignKey("MovieId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Flaro.Core.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Flaro.Core.Playback.UserItemState", b => + { + b.HasOne("Flaro.Core.Library.Episode", null) + .WithMany() + .HasForeignKey("EpisodeId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Flaro.Core.Library.Movie", null) + .WithMany() + .HasForeignKey("MovieId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Flaro.Core.Library.Series", null) + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Flaro.Core.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/server/src/Flaro.Data/Migrations/20261008154809_Fassungspruefung.cs b/server/src/Flaro.Data/Migrations/20261008154809_Fassungspruefung.cs new file mode 100644 index 0000000..fd4c14e --- /dev/null +++ b/server/src/Flaro.Data/Migrations/20261008154809_Fassungspruefung.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Flaro.Data.Migrations +{ + /// + public partial class Fassungspruefung : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "FassungenGeprueftBei", + table: "Movies", + type: "INTEGER", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "FassungenGeprueftBei", + table: "Movies"); + } + } +} diff --git a/server/src/Flaro.Data/Migrations/FlaroContextModelSnapshot.cs b/server/src/Flaro.Data/Migrations/FlaroContextModelSnapshot.cs index 2822e8f..d7b71e2 100644 --- a/server/src/Flaro.Data/Migrations/FlaroContextModelSnapshot.cs +++ b/server/src/Flaro.Data/Migrations/FlaroContextModelSnapshot.cs @@ -561,6 +561,9 @@ namespace Flaro.Data.Migrations .HasMaxLength(16) .HasColumnType("TEXT"); + b.Property("FassungenGeprueftBei") + .HasColumnType("INTEGER"); + b.Property("ImdbId") .HasMaxLength(32) .HasColumnType("TEXT"); diff --git a/server/tests/Flaro.Api.Tests/MetadataTests.cs b/server/tests/Flaro.Api.Tests/MetadataTests.cs index 4724c06..b3eba45 100644 --- a/server/tests/Flaro.Api.Tests/MetadataTests.cs +++ b/server/tests/Flaro.Api.Tests/MetadataTests.cs @@ -560,6 +560,98 @@ public class MetadataTests }); } + + /// Eine abgehakte Gruppe verlässt die Liste — und kommt zurück, wenn eine Datei dazukommt. + /// + /// + /// + /// Der Anlass. Zwei Dateien an einem Film sind meist zwei + /// Fassungen und völlig in Ordnung. In der Liste standen sie trotzdem für + /// immer: sie rechnet jedes Mal frisch, und es gab keinen Zustand. Wer + /// absichtlich zwei Fassungen hält, konnte die Gruppe nicht loswerden — + /// und unter den gewollten ging das Ungewollte unter. + /// + /// + /// Abgehakt wird eine Dateizahl, kein Zeitpunkt. Kommt eine dritte + /// Fassung dazu, stimmt die Zahl nicht mehr und die Gruppe meldet sich + /// von selbst zurück. Genau das prüft der zweite Teil. + /// + /// + [Test] + public async Task Eine_abgehakte_Fassungsgruppe_meldet_sich_bei_einer_neuen_Datei_zurueck() + { + Datei("Hanni und Nanni/Hanni und Nanni.mp4"); + Datei("Hanni und Nanni/Hanni und Nanni 3.mp4"); + + var anbieter = new VorgegebenerAnbieter + { + Film = new MovieMetadata(225313, "Hanni & Nanni 3", 2013, null, null, null), + }; + + using var server = MitAnbieter(anbieter); + var (client, bibliothek) = await Einrichten(server, "Movies"); + await Scannen(client, bibliothek); + + var vorher = await client.GetFromJsonAsync( + $"/api/v1/libraries/{bibliothek}/versions"); + Assert.That(vorher!.Gesamt, Is.EqualTo(1), "zwei Dateien an einem Titel"); + + var film = vorher.Titel[0].Id; + (await client.PostAsJsonAsync($"/api/v1/libraries/{bibliothek}/versions/acknowledge", + new FassungenRequest([film]))).EnsureSuccessStatusCode(); + + var offen = await client.GetFromJsonAsync( + $"/api/v1/libraries/{bibliothek}/versions"); + var abgehakt = await client.GetFromJsonAsync( + $"/api/v1/libraries/{bibliothek}/versions?ignorierte=true"); + + Assert.Multiple(() => + { + Assert.That(offen!.Gesamt, Is.Zero, "abgehakt heisst: nicht mehr in der offenen Liste"); + Assert.That(abgehakt!.Gesamt, Is.EqualTo(1), "aber nachsehen muss man können"); + }); + + // Eine dritte Fassung dazu — jetzt stimmt die abgehakte Zahl nicht mehr. + Datei("Hanni und Nanni/Hanni und Nanni 2.mp4"); + await Scannen(client, bibliothek); + + var wieder = await client.GetFromJsonAsync( + $"/api/v1/libraries/{bibliothek}/versions"); + + Assert.That(wieder!.Gesamt, Is.EqualTo(1), + "eine neue Datei macht die Gruppe wieder zu einer Frage"); + } + + /// Zurückholen macht das Abhaken rückgängig. + [Test] + public async Task Eine_abgehakte_Gruppe_laesst_sich_zurueckholen() + { + Datei("Hanni und Nanni/Hanni und Nanni.mp4"); + Datei("Hanni und Nanni/Hanni und Nanni 3.mp4"); + + var anbieter = new VorgegebenerAnbieter + { + Film = new MovieMetadata(225313, "Hanni & Nanni 3", 2013, null, null, null), + }; + + using var server = MitAnbieter(anbieter); + var (client, bibliothek) = await Einrichten(server, "Movies"); + await Scannen(client, bibliothek); + + var film = (await client.GetFromJsonAsync( + $"/api/v1/libraries/{bibliothek}/versions"))!.Titel[0].Id; + + await client.PostAsJsonAsync($"/api/v1/libraries/{bibliothek}/versions/acknowledge", + new FassungenRequest([film])); + await client.PostAsJsonAsync($"/api/v1/libraries/{bibliothek}/versions/acknowledge", + new FassungenRequest([film], Ignorieren: false)); + + var offen = await client.GetFromJsonAsync( + $"/api/v1/libraries/{bibliothek}/versions"); + + Assert.That(offen!.Gesamt, Is.EqualTo(1)); + } + /// Eine einzelne Datei lässt sich nicht herauslösen. [Test] public async Task Eine_einzelne_Datei_laesst_sich_nicht_herausloesen() diff --git a/web/app/index.html b/web/app/index.html index f6f584b..a9fa1f0 100644 --- a/web/app/index.html +++ b/web/app/index.html @@ -20,13 +20,19 @@ - - - - + +
diff --git a/web/app/public/schriften/LIZENZ.txt b/web/app/public/schriften/LIZENZ.txt new file mode 100644 index 0000000..9b2ca37 --- /dev/null +++ b/web/app/public/schriften/LIZENZ.txt @@ -0,0 +1,92 @@ +Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION AND CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/web/app/public/schriften/inter-latin-ext.woff2 b/web/app/public/schriften/inter-latin-ext.woff2 new file mode 100644 index 0000000..b6dd1fa Binary files /dev/null and b/web/app/public/schriften/inter-latin-ext.woff2 differ diff --git a/web/app/public/schriften/inter-latin.woff2 b/web/app/public/schriften/inter-latin.woff2 new file mode 100644 index 0000000..91dc3e8 Binary files /dev/null and b/web/app/public/schriften/inter-latin.woff2 differ diff --git a/web/app/src/api.ts b/web/app/src/api.ts index fd74cdd..7e05fbd 100644 --- a/web/app/src/api.ts +++ b/web/app/src/api.ts @@ -1074,8 +1074,18 @@ export type Mehrfachtitel = { export type Mehrfachbericht = { gesamt: number; titel: Mehrfachtitel[] }; /** Titel mit mehr als einer Datei — dort versteckt sich falsch Zusammengelegtes. */ -export const mehrfachfassungen = (bibliothek: string, anzahl = 300) => - anfrage(`/libraries/${bibliothek}/versions?take=${anzahl}`); +/** Hakt Mehrfachfassungen ab — oder holt sie zurück. + * + * **Die Dateizahl rechnet der Server.** Ein Client mit veralteter Liste + * hakte sonst eine Gruppe mit zwei Dateien ab, die inzwischen drei hat, und + * die dritte verschwände ungesehen. */ +export const fassungenAbhaken = (bibliothek: string, ids: string[], ignorieren = true) => + anfrage<{ geaendert: number }>(`/libraries/${bibliothek}/versions/acknowledge`, { + method: 'POST', body: JSON.stringify({ ids, ignorieren }), + }); + +export const mehrfachfassungen = (bibliothek: string, anzahl = 300, ignorierte = false) => + anfrage(`/libraries/${bibliothek}/versions?take=${anzahl}&ignorierte=${ignorierte}`); /** Löst eine Datei aus ihrem Titel heraus — sie wird ein eigener Film. * diff --git a/web/app/src/hero.ts b/web/app/src/hero.ts index b014f82..27de86f 100644 --- a/web/app/src/hero.ts +++ b/web/app/src/hero.ts @@ -69,15 +69,41 @@ export function hero( const buehne = document.createElement('div'); buehne.className = 'hero-buehne'; - const folien = mitBild.map((e, i) => { + const folien = mitBild.map((_, i) => { const folie = document.createElement('div'); folie.className = 'hero-folie' + (i === 0 ? ' an' : ''); - folie.style.backgroundImage = `url("${bildAdresse(e.hintergrund)}")`; folie.setAttribute('aria-hidden', i === 0 ? 'false' : 'true'); buehne.appendChild(folie); return folie; }); + /** Hängt einer Folie ihr Bild an — und nur dieser, und nur einmal. + * + * **Sieben Bilder auf einmal waren sechs zu viel.** Hier wurde jeder + * Folie sofort ihr Hintergrundbild gesetzt; sichtbar ist aber immer nur + * eine, und es sind `w1280`-Bilder. Beim ersten Aufbau der Startseite + * gingen damit sieben grosse Abrufe gleichzeitig hinaus — über dieselbe + * Leitung, auf der auch die Plakate und die Reihen kommen. */ + function bildAnhaengen(i: number): void { + const folie = folien[i]; + const eintrag = mitBild[i]; + if (!folie || !eintrag || folie.style.backgroundImage) return; + folie.style.backgroundImage = `url("${bildAdresse(eintrag.hintergrund)}")`; + } + + bildAnhaengen(0); + + // **Der Rest, sobald der Browser Luft hat.** Nicht erst beim + // Weiterschalten: dann sähe man beim ersten Wechsel einen leeren Kasten. + // `requestIdleCallback` kennt nicht jeder Browser — der Rückfall ist ein + // Zeitgeber, und eine Sekunde reicht für den ersten Aufbau. + const nachtragen = () => mitBild.forEach((_, i) => bildAnhaengen(i)); + const leerlauf = (window as unknown as { + requestIdleCallback?: (f: () => void, o?: { timeout: number }) => void; + }).requestIdleCallback; + if (leerlauf) leerlauf(nachtragen, { timeout: 3000 }); + else window.setTimeout(nachtragen, 1000); + const inhalt = document.createElement('div'); inhalt.className = 'hero-inhalt'; @@ -172,6 +198,9 @@ export function hero( jetzt = ((i % mitBild.length) + mitBild.length) % mitBild.length; const e = mitBild[jetzt]!; + // Wer weiterschaltet, bevor der Nachtrag lief, bekommt sein Bild jetzt. + bildAnhaengen(jetzt); + folien.forEach((f, n) => { f.classList.toggle('an', n === jetzt); f.setAttribute('aria-hidden', n === jetzt ? 'false' : 'true'); diff --git a/web/app/src/main.ts b/web/app/src/main.ts index fe2dcd3..7247f6e 100644 --- a/web/app/src/main.ts +++ b/web/app/src/main.ts @@ -34,6 +34,22 @@ void hochfahren(); async function hochfahren(): Promise { try { + // **Drei Runden waren zwei zu viel.** + // + // Vorher wartete der Start auf die Serverauskunft, dann auf Konto und + // Mediatheken, und erst dann fing die Startseite an zu laden — drei + // Rundreisen nacheinander, bevor das erste Bild in Auftrag ging. + // + // Liegt eine Sitzung im Speicher, laufen die ersten beiden jetzt + // zugleich los. **Nur dann**, und das ist keine Vorsicht um ihrer selbst + // willen: ohne Sitzung liefen `/auth/me` und `/libraries` vor der + // Antwort auf `needsSetup` in ein 401, und die Sitzungsprüfung in + // `api.ts` kann daraufhin abmelden. Ein frischer Server soll nicht + // dadurch langsamer werden, dass er sich selbst hinauswirft. + const vorgeholt = angemeldet() + ? Promise.all([kontoAbgleichen(), bibliotheken()]).catch(() => null) + : null; + const auskunft = await systemauskunft(); hinweise = auskunft.attributions; serverName = auskunft.serverName ?? 'Flaro'; @@ -74,9 +90,11 @@ async function hochfahren(): Promise { // **Das Konto frisch holen, bevor die Kopfleiste steht.** Sie zeigt Bild // und Namen aus der gemerkten Sitzung, und die stammt von der Anmeldung — // ein Bild, das anderswo gesetzt wurde, käme hier nie an. - await Promise.all([kontoAbgleichen(), (async () => { - mediatheken = await bibliotheken(); - })()]); + // Das Vorgeholte nehmen, wenn es durchkam; sonst jetzt holen. Ein + // gescheiterter Vorgriff darf nichts kosten ausser der Wiederholung. + const beides = await vorgeholt + ?? await Promise.all([kontoAbgleichen(), bibliotheken()]); + mediatheken = beides[1]; kopfleisteBauen(); leisteBauen(); diff --git a/web/app/src/style.css b/web/app/src/style.css index 6d771f6..39801d2 100644 --- a/web/app/src/style.css +++ b/web/app/src/style.css @@ -8,6 +8,50 @@ * **Die Farbe nicht.** Plex ist bernsteingelb, Flaro ist Orchidee. Das * Vorbild ist die Anordnung, nicht das Aussehen. */ +/* ---------- Die Schrift, und zwar von hier ---------- + * + * **Warum sie mitgeliefert wird.** Vorher kam sie über ein Stylesheet von + * `fonts.googleapis.com`. Ein Stylesheet blockiert das Zeichnen; ein + * Heimserver ohne Internet oder mit träger Namensauflösung wartete davor, + * bevor das erste Pixel stand. Zwei Dateien, zusammen sechzig Kilobyte, + * nehmen diese Abhängigkeit ganz weg. + * + * **Eine Datei für alle Schnitte.** Inter ist eine variable Schrift: 400 + * bis 700 stecken in derselben Datei. Vier getrennte Schnitte wären vier + * Abrufe für dasselbe. + * + * `latin-ext` ist eigen aufgeführt und nicht eingerechnet: die + * `unicode-range` sorgt dafür, dass sie nur lädt, wenn wirklich ein Zeichen + * daraus vorkommt — bei deutschen Titeln selten, bei polnischen oder + * tschechischen eben doch. + * + * Inter steht unter der SIL Open Font License; der Text liegt bei den + * Dateien unter `public/schriften/LIZENZ.txt`. */ + +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 400 700; + /* Der Text steht sofort in der Systemschrift und wird ersetzt, sobald + Inter da ist — lieber ein Umspringen als ein leeres Bild. */ + font-display: swap; + src: url('/schriften/inter-latin.woff2') format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, + U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, + U+2212, U+2215, U+FEFF, U+FFFD; +} + +@font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 400 700; + font-display: swap; + src: url('/schriften/inter-latin-ext.woff2') format('woff2'); + unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, + U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, + U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; +} + :root { /* --- Akzent ------------------------------------------------------------ * diff --git a/web/app/src/views/einstellungen/zuordnung.ts b/web/app/src/views/einstellungen/zuordnung.ts index e3c7217..59f508d 100644 --- a/web/app/src/views/einstellungen/zuordnung.ts +++ b/web/app/src/views/einstellungen/zuordnung.ts @@ -1,5 +1,5 @@ import { - bibliotheken, unzugeordnet, ohneZuordnung, mehrfachfassungen, dateiHerausloesen, + bibliotheken, unzugeordnet, ohneZuordnung, mehrfachfassungen, fassungenAbhaken, dateiHerausloesen, type Mehrfachtitel, } from '../../api'; import { esc, knopf, meldung, hinweisZeigen } from '../../util'; @@ -24,7 +24,21 @@ import { abschnitt } from './teile'; export function ohneZuordnungBereich(): HTMLElement { const bereich = document.createElement('section'); bereich.classList.add('hidden'); - bereich.appendChild(abschnitt('Ohne Zuordnung')); + /** Ob die abgehakten Mehrfachfassungen gezeigt werden statt der offenen. + * + * **Ohne diesen Schalter wäre das Abhaken unumkehrbar.** Man klickt eine + * Gruppe weg und weiss hinterher nicht mehr, welche es war — dieselbe + * Überlegung wie bei den erledigten Meldungen. */ + let zeigeIgnorierte = false; + + const umschalten = knopf('Ignorierte zeigen', 'btn'); + umschalten.onclick = () => { + zeigeIgnorierte = !zeigeIgnorierte; + umschalten.textContent = zeigeIgnorierte ? 'Offene zeigen' : 'Ignorierte zeigen'; + void laden(); + }; + + bereich.appendChild(abschnitt('Ohne Zuordnung', [umschalten])); const liste = document.createElement('div'); liste.className = 'einstellungen'; @@ -40,7 +54,7 @@ export function ohneZuordnungBereich(): HTMLElement { const ergebnisse = await Promise.all(alle.map(async b => { try { const [offen, mehrfach] = await Promise.all([ - ohneZuordnung(b.id), mehrfachfassungen(b.id), + ohneZuordnung(b.id), mehrfachfassungen(b.id, 300, zeigeIgnorierte), ]); return { b, offen, mehrfach }; } catch { return null; } @@ -50,11 +64,26 @@ export function ohneZuordnungBereich(): HTMLElement { .filter(e => e !== null) .filter(e => e!.offen.length > 0 || e!.mehrfach.gesamt > 0); - if (mitFunden.length === 0) { bereich.classList.add('hidden'); return; } + // **Solange jemand die Ignorierten ansieht, bleibt der Bereich stehen.** + // Sonst verschwände er samt Umschalter in dem Augenblick, in dem die + // letzte abgehakte Gruppe zurückgeholt ist — und niemand fände den Weg + // zurück auf die offenen. + if (mitFunden.length === 0 && !zeigeIgnorierte) { + bereich.classList.add('hidden'); + return; + } bereich.classList.remove('hidden'); liste.innerHTML = ''; + if (mitFunden.length === 0) { + const leer = document.createElement('p'); + leer.className = 'winzig'; + leer.textContent = 'Hier ist nichts abgehakt.'; + liste.appendChild(leer); + return; + } + mitFunden.forEach(e => { const { b, offen, mehrfach } = e!; @@ -76,10 +105,13 @@ export function ohneZuordnungBereich(): HTMLElement { if (mehrfach.gesamt > 0) { liste.appendChild(fassungsgruppe( `${b.name} — ${anzahl(mehrfach.gesamt, 'Titel')} mit mehreren Dateien`, - 'Meist zwei Fassungen desselben Films — dann ist alles in Ordnung. ' - + 'Stehen hier zwei verschiedene Filme, war einer davon nicht mehr zu ' - + 'sehen: „Eigener Film" löst ihn wieder heraus.', - mehrfach.titel)); + zeigeIgnorierte + ? 'Diese Gruppen wurden als geprüft abgehakt. „Wieder zeigen" holt sie zurück.' + : 'Meist zwei Fassungen desselben Films — dann ist alles in Ordnung und ' + + '„Ignorieren" nimmt die Gruppe aus der Liste. Stehen hier zwei ' + + 'verschiedene Filme, war einer davon nicht mehr zu sehen: ' + + '„Eigener Film" löst ihn wieder heraus.', + mehrfach.titel, b.id)); } }); } @@ -90,7 +122,7 @@ export function ohneZuordnungBereich(): HTMLElement { /** Dieselbe Gruppe, aber jede Datei einzeln mit einem Weg heraus. */ function fassungsgruppe( - ueberschrift: string, hinweis: string, titel: Mehrfachtitel[], + ueberschrift: string, hinweis: string, titel: Mehrfachtitel[], bibliothek: string, ): HTMLElement { const d = document.createElement('div'); d.className = 'einstellung'; @@ -108,12 +140,35 @@ export function ohneZuordnungBereich(): HTMLElement { const zeile = document.createElement('div'); zeile.className = 'fassungstitel'; + const kopfzeile = document.createElement('div'); + kopfzeile.className = 'fassungsdatei'; + const name = document.createElement('button'); name.className = 'ohnezuordnung-zeile'; name.innerHTML = `${esc(t.titel + (t.jahr ? ` (${t.jahr})` : ''))}`; name.onclick = () => bearbeiten( t.art === 'series' ? 'series' : 'movies', t.id, () => void laden()); - zeile.appendChild(name); + + // **Für die, die absichtlich zwei Fassungen halten.** Die Liste + // rechnet jedes Mal frisch; ohne das Abhaken stand eine gewollte + // Gruppe hier für immer, und darunter ging das Ungewollte unter. + const abhaken = knopf(zeigeIgnorierte ? 'Wieder zeigen' : 'Ignorieren', 'btn klein'); + abhaken.onclick = async () => { + abhaken.disabled = true; + try { + await fassungenAbhaken(bibliothek, [t.id], !zeigeIgnorierte); + hinweisZeigen(zeigeIgnorierte + ? `„${t.titel}" steht wieder in der Liste.` + : `„${t.titel}" gilt als geprüft. Kommt eine Fassung dazu, meldet er sich wieder.`); + await laden(); + } catch (fehler) { + hinweisZeigen(meldung(fehler), 'fehler'); + abhaken.disabled = false; + } + }; + + kopfzeile.append(name, abhaken); + zeile.appendChild(kopfzeile); t.dateien.forEach(f => { const d2 = document.createElement('div');