diff --git a/server/src/Flaro.Api/Endpoints/Contracts.cs b/server/src/Flaro.Api/Endpoints/Contracts.cs index 458e6eb..53429e7 100644 --- a/server/src/Flaro.Api/Endpoints/Contracts.cs +++ b/server/src/Flaro.Api/Endpoints/Contracts.cs @@ -58,6 +58,18 @@ public sealed record RenameLibraryRequest(string Name); public sealed record CreateLibraryRequest(string Name, string Kind, string Path); +/// Eine Datei, die der letzte Scan nicht mehr gefunden hat. +public sealed record VermissteDatei( + Guid FileId, + string Path, + string? VersionLabel, + DateTimeOffset MissingSince, + string LibraryName, + /// Zu welchem Titel sie gehört — damit klar ist, was fehlen würde. + string? Title, + Guid? MovieId, + Guid? EpisodeId); + // --- Stufe 2: Bibliothek ------------------------------------------------- public sealed record ScanProblem(string Path, string Reason); @@ -74,7 +86,9 @@ public sealed record ScanStatus( int? Probed, IReadOnlyList? Problems, string? Error, - MetadataProgress? Metadata); + MetadataProgress? Metadata, + /// Wie viele Dateien dieser Lauf als verschwunden gemeldet hat. + int? Missing = null); /// Was das Nachschlagen bei der Metadatenquelle ergeben hat. public sealed record MetadataProgress( diff --git a/server/src/Flaro.Api/Endpoints/LibraryContentEndpoints.cs b/server/src/Flaro.Api/Endpoints/LibraryContentEndpoints.cs index e4ddefc..495ea95 100644 --- a/server/src/Flaro.Api/Endpoints/LibraryContentEndpoints.cs +++ b/server/src/Flaro.Api/Endpoints/LibraryContentEndpoints.cs @@ -175,7 +175,8 @@ public static class LibraryContentEndpoints zustand.Error, metadaten is null ? null : new MetadataProgress( metadaten.MoviesMatched, metadaten.SeriesMatched, metadaten.EpisodesMatched, - metadaten.Skipped, metadaten.Error))); + metadaten.Skipped, metadaten.Error), + bericht?.Missing)); }).WithName("Scanstand").RequireAuthorization(); } diff --git a/server/src/Flaro.Api/Endpoints/ManagementEndpoints.cs b/server/src/Flaro.Api/Endpoints/ManagementEndpoints.cs index ed01063..a83c6dd 100644 --- a/server/src/Flaro.Api/Endpoints/ManagementEndpoints.cs +++ b/server/src/Flaro.Api/Endpoints/ManagementEndpoints.cs @@ -72,6 +72,57 @@ public static class ManagementEndpoints : filme.GetValueOrDefault(l.Id))).ToList()); }).RequireAuthorization(); + // **Was nicht mehr da ist, aber noch in der Mediathek steht.** + // + // 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. + bibliotheken.MapGet("/missing", async (FlaroContext db) => + { + var liste = await db.MediaFiles.AsNoTracking() + .Where(f => f.MissingSince != null) + .OrderBy(f => f.Path) + .Select(f => new VermissteDatei( + f.Id, f.Path, f.VersionLabel, f.MissingSince!.Value, + db.Libraries.Where(l => l.Id == f.LibraryId).Select(l => l.Name).First(), + f.MovieId != null + ? db.Movies.Where(m => m.Id == f.MovieId).Select(m => m.Title).FirstOrDefault() + : db.Episodes.Where(e => e.Id == f.EpisodeId) + .Select(e => e.Title ?? ("Folge " + e.EpisodeNumber)).FirstOrDefault(), + f.MovieId, f.EpisodeId)) + .ToListAsync(); + + return Results.Ok(liste); + }).RequireAuthorization(FlaroPolicies.Administrator); + + // Endgültig weg — mitsamt dem Titel, wenn es seine letzte Datei war. + bibliotheken.MapDelete("/missing", async (Guid? fileId, FlaroContext db) => + { + var betroffen = db.MediaFiles.Where(f => f.MissingSince != null); + if (fileId is { } eine) betroffen = betroffen.Where(f => f.Id == eine); + + // Die Titel merken, bevor die Dateien weg sind — danach lässt sich + // nicht mehr feststellen, welche verwaist zurückbleiben. + var filme = await betroffen.Where(f => f.MovieId != null) + .Select(f => f.MovieId!.Value).Distinct().ToListAsync(); + var folgen = await betroffen.Where(f => f.EpisodeId != null) + .Select(f => f.EpisodeId!.Value).Distinct().ToListAsync(); + + var weg = await betroffen.ExecuteDeleteAsync(); + + await db.Movies + .Where(m => filme.Contains(m.Id) && !db.MediaFiles.Any(f => f.MovieId == m.Id)) + .ExecuteDeleteAsync(); + await db.Episodes + .Where(e => folgen.Contains(e.Id) && !db.MediaFiles.Any(f => f.EpisodeId == e.Id)) + .ExecuteDeleteAsync(); + await db.Series + .Where(x => !db.Episodes.Any(e => e.SeriesId == x.Id)) + .ExecuteDeleteAsync(); + + return Results.Ok(new { removed = weg }); + }).RequireAuthorization(FlaroPolicies.Administrator); + bibliotheken.MapPost("/", async (CreateLibraryRequest anfrage, FlaroContext db) => { if (!Enum.TryParse(anfrage.Kind, ignoreCase: true, out var art)) diff --git a/server/src/Flaro.Core/Library/MediaEntities.cs b/server/src/Flaro.Core/Library/MediaEntities.cs index f1fa825..03de496 100644 --- a/server/src/Flaro.Core/Library/MediaEntities.cs +++ b/server/src/Flaro.Core/Library/MediaEntities.cs @@ -54,6 +54,19 @@ public sealed class MediaFile /// public DateTimeOffset? ProbedAt { get; set; } + /// Seit wann die Datei nicht mehr gefunden wird — sonst null. + /// + /// + /// Markiert statt gelöscht. Vorher räumte der Scan Dateien weg, + /// die er nicht mehr sah, und mit der letzten Datei fiel der Titel samt + /// Sehfortschritt aller Benutzer. Ein kurz nicht eingehängtes Volume + /// genügte dafür. Jetzt bleibt der Eintrag stehen und wird dem + /// Administrator gemeldet; gelöscht wird nur auf Ansage. + /// + /// Taucht die Datei wieder auf, verschwindet die Markierung von selbst. + /// + public DateTimeOffset? MissingSince { get; set; } + /// Wie diese Fassung heisst — „4K", „Mobil", „Director's Cut". /// /// diff --git a/server/src/Flaro.Data/LibraryScanner.cs b/server/src/Flaro.Data/LibraryScanner.cs index a9f29f5..84bbd67 100644 --- a/server/src/Flaro.Data/LibraryScanner.cs +++ b/server/src/Flaro.Data/LibraryScanner.cs @@ -12,7 +12,12 @@ public sealed record ScanReport( int Removed, int Unrecognised, int Probed, - IReadOnlyList Problems); + IReadOnlyList Problems, + /// Wie viele Dateien dieser Lauf als verschwunden markiert hat. + int Missing = 0); + +/// Der Scan hat abgebrochen, statt den Bestand für verschwunden zu erklären. +public sealed class BestandVerschwundenException(string nachricht) : Exception(nachricht); /// Gleicht ab, was auf der Platte liegt, mit dem, was in der Datenbank steht. /// @@ -39,8 +44,32 @@ public sealed class LibraryScanner( ?? throw new InvalidOperationException($"Keine Bibliothek mit der Kennung {libraryId}."); var laufZeit = clock.GetUtcNow(); + + // **Die Notbremse, und sie steht vor allem anderen.** + // + // Ist das Volume beim Start des NAS noch nicht eingehängt, liegt der + // Ordner leer oder gar nicht da. Ohne diese Prüfung erklärte der Lauf + // den gesamten Bestand für verschwunden — bei 458 Titeln samt + // Sehfortschritt aller Benutzer. Lieber gar kein Scan als der. + var warVorherDa = await db.MediaFiles.CountAsync(f => f.LibraryId == libraryId, ct); + + if (!Directory.Exists(bibliothek.Path)) + { + throw new BestandVerschwundenException( + $"Den Ordner {bibliothek.Path} gibt es nicht. Ist das Volume eingehängt? " + + "Es wurde nichts verändert."); + } + var ergebnis = LibraryWalker.Walk(bibliothek.Path); + if (ergebnis.Files.Count == 0 && warVorherDa > 0) + { + throw new BestandVerschwundenException( + $"In {bibliothek.Path} liegt keine einzige Mediendatei mehr, vorher waren es " + + $"{warVorherDa}. Der Lauf wurde abgebrochen, damit nicht der ganze Bestand " + + "als verschwunden gilt. Es wurde nichts verändert."); + } + foreach (var problem in ergebnis.Problems) log.LogWarning("Scan: {Pfad} übersprungen — {Grund}", problem.Path, problem.Reason); @@ -109,6 +138,8 @@ public sealed class LibraryScanner( bekannt.MovieId = filmId; bekannt.EpisodeId = folgeId; bekannt.SeenAt = laufZeit; + // Wieder da — die Markierung fällt von selbst weg. + bekannt.MissingSince = null; bekannt.VersionLabel = FileNames.Fassung(datei.Path) ?? bekannt.VersionLabel; if (vorher != (datei.SizeBytes, datei.ModifiedAt, filmId, folgeId)) geändert++; eintrag = bekannt; @@ -157,20 +188,24 @@ public sealed class LibraryScanner( await db.SaveChangesAsync(ct); - // Was dieser Lauf nicht gesehen hat, liegt nicht mehr da. - var entfernt = await db.MediaFiles - .Where(f => f.LibraryId == libraryId && f.SeenAt < laufZeit) - .ExecuteDeleteAsync(ct); + // **Was dieser Lauf nicht gesehen hat, wird markiert — nicht gelöscht.** + // Eine umbenannte Festplatte, ein Tippfehler im Pfad, ein Laufwerk im + // Ruhezustand: all das sah vorher aus wie „der Film ist weg", und mit + // ihm ging der Sehfortschritt aller Benutzer. + var verschwunden = await db.MediaFiles + .Where(f => f.LibraryId == libraryId && f.SeenAt < laufZeit && f.MissingSince == null) + .ExecuteUpdateAsync(s => s.SetProperty(f => f.MissingSince, laufZeit), ct); await VerwaisteAufraeumen(libraryId, ct); log.LogInformation( "Scan von „{Name}“ fertig: {Gefunden} Dateien, {Neu} neu, {Geändert} geändert, " + - "{Entfernt} entfernt, {Unerkannt} unerkannt, {Untersucht} untersucht.", - bibliothek.Name, ergebnis.Files.Count, neu, geändert, entfernt, unerkannt, untersucht); + "{Verschwunden} nicht mehr gefunden, {Unerkannt} unerkannt, {Untersucht} untersucht.", + bibliothek.Name, ergebnis.Files.Count, neu, geändert, verschwunden, unerkannt, untersucht); return new ScanReport( - ergebnis.Files.Count, neu, geändert, entfernt, unerkannt, untersucht, ergebnis.Problems); + ergebnis.Files.Count, neu, geändert, 0, unerkannt, untersucht, ergebnis.Problems, + Missing: verschwunden); } /// Liest die Spuren einer Datei und schreibt sie fort. diff --git a/server/src/Flaro.Data/Migrations/20261004094257_VerschwundeneDateien.Designer.cs b/server/src/Flaro.Data/Migrations/20261004094257_VerschwundeneDateien.Designer.cs new file mode 100644 index 0000000..af758fe --- /dev/null +++ b/server/src/Flaro.Data/Migrations/20261004094257_VerschwundeneDateien.Designer.cs @@ -0,0 +1,789 @@ +// +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("20261004094257_VerschwundeneDateien")] + partial class VerschwundeneDateien + { + /// + 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.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("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.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("ContentRating") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("ImdbId") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("TEXT"); + + b.Property("LogoImage") + .HasColumnType("TEXT"); + + b.Property("MatchLocked") + .HasColumnType("INTEGER"); + + b.Property("Overview") + .HasColumnType("TEXT"); + + b.Property("ParsedTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ParsedYear") + .HasColumnType("INTEGER"); + + b.Property("PosterImage") + .HasColumnType("TEXT"); + + 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("ContentRating") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("ImdbId") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("TEXT"); + + b.Property("LogoImage") + .HasColumnType("TEXT"); + + b.Property("MatchLocked") + .HasColumnType("INTEGER"); + + b.Property("Overview") + .HasColumnType("TEXT"); + + b.Property("ParsedTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ParsedYear") + .HasColumnType("INTEGER"); + + b.Property("PosterImage") + .HasColumnType("TEXT"); + + 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("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/20261004094257_VerschwundeneDateien.cs b/server/src/Flaro.Data/Migrations/20261004094257_VerschwundeneDateien.cs new file mode 100644 index 0000000..9393ac3 --- /dev/null +++ b/server/src/Flaro.Data/Migrations/20261004094257_VerschwundeneDateien.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Flaro.Data.Migrations +{ + /// + public partial class VerschwundeneDateien : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "MissingSince", + table: "MediaFiles", + type: "INTEGER", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "MissingSince", + table: "MediaFiles"); + } + } +} diff --git a/server/src/Flaro.Data/Migrations/FlaroContextModelSnapshot.cs b/server/src/Flaro.Data/Migrations/FlaroContextModelSnapshot.cs index c284c2d..86ad9b7 100644 --- a/server/src/Flaro.Data/Migrations/FlaroContextModelSnapshot.cs +++ b/server/src/Flaro.Data/Migrations/FlaroContextModelSnapshot.cs @@ -285,6 +285,9 @@ namespace Flaro.Data.Migrations b.Property("LibraryId") .HasColumnType("TEXT"); + b.Property("MissingSince") + .HasColumnType("INTEGER"); + b.Property("ModifiedAt") .HasColumnType("INTEGER"); diff --git a/server/tests/Flaro.Api.Tests/ScanTests.cs b/server/tests/Flaro.Api.Tests/ScanTests.cs index 62ed69c..a323949 100644 --- a/server/tests/Flaro.Api.Tests/ScanTests.cs +++ b/server/tests/Flaro.Api.Tests/ScanTests.cs @@ -107,8 +107,16 @@ public class ScanTests Assert.That((await client.GetFromJsonAsync>("/api/v1/movies"))!.Total, Is.EqualTo(2)); } + /// Eine verschwundene Datei wird gemeldet, nicht stillschweigend gelöscht. + /// + /// + /// Vorher fiel hier der Titel samt Sehfortschritt. Das ist für eine + /// wirklich gelöschte Datei richtig — und falsch für jeden anderen Grund, + /// aus dem eine Datei kurz nicht da ist. Beides sieht für den Scan gleich + /// aus, also entscheidet jetzt der Administrator. + /// [Test] - public async Task Eine_geloeschte_Datei_verschwindet_aus_der_Mediathek() + public async Task Eine_verschwundene_Datei_wird_gemeldet_statt_geloescht() { Datei("The Matrix (1999).mkv"); Datei("Inception (2010).mkv"); @@ -120,11 +128,86 @@ public class ScanTests File.Delete(Path.Combine(_bestand, "Inception (2010).mkv")); var danach = await Scannen(client, bibliothek); - Assert.That(danach.Removed, Is.EqualTo(1)); + Assert.That(danach.Missing, Is.EqualTo(1)); + Assert.That((await client.GetFromJsonAsync>("/api/v1/movies"))!.Total, + Is.EqualTo(2), "Der Titel bleibt stehen, bis jemand ihn entfernt."); + + var vermisst = await client.GetFromJsonAsync>("/api/v1/libraries/missing"); + Assert.That(vermisst, Has.Count.EqualTo(1)); + Assert.That(vermisst![0].Title, Is.EqualTo("Inception")); + + // Und erst auf Ansage ist er wirklich weg. + (await client.DeleteAsync("/api/v1/libraries/missing")).EnsureSuccessStatusCode(); var filme = await client.GetFromJsonAsync>("/api/v1/movies"); Assert.That(filme!.Items.Select(m => m.Title), Is.EqualTo(new[] { "The Matrix" })); } + /// Kommt die Datei zurück, verschwindet die Markierung von selbst. + [Test] + public async Task Eine_zurueckgekehrte_Datei_gilt_wieder_als_da() + { + // Zwei Dateien, damit der Ordner nicht leer wird — sonst greift die + // Notbremse aus dem Test darunter, und zwar zu Recht. + Datei("Inception (2010).mkv"); + Datei("The Matrix (1999).mkv"); + + using var server = new FlaroServer(); + var (client, bibliothek) = await MitBibliothek(server, "Movies"); + await Scannen(client, bibliothek); + + var pfad = Path.Combine(_bestand, "Inception (2010).mkv"); + var inhalt = await File.ReadAllBytesAsync(pfad); + File.Delete(pfad); + await Scannen(client, bibliothek); + Assert.That(await client.GetFromJsonAsync>("/api/v1/libraries/missing"), + Has.Count.EqualTo(1)); + + await File.WriteAllBytesAsync(pfad, inhalt); + await Scannen(client, bibliothek); + + Assert.That(await client.GetFromJsonAsync>("/api/v1/libraries/missing"), + Is.Empty); + } + + /// Die Notbremse: ein leerer Ordner bricht ab, statt alles zu verwerfen. + /// + /// + /// Der teuerste denkbare Fehler. Ist das Volume beim Start des NAS + /// noch nicht eingehängt, liegt der Ordner leer da — und ohne diese Prüfung + /// gälte der gesamte Bestand als verschwunden. + /// + [Test] + public async Task Ein_ploetzlich_leerer_Ordner_bricht_den_Scan_ab() + { + Datei("The Matrix (1999).mkv"); + Datei("Inception (2010).mkv"); + + using var server = new FlaroServer(); + var (client, bibliothek) = await MitBibliothek(server, "Movies"); + await Scannen(client, bibliothek); + + foreach (var d in Directory.EnumerateFiles(_bestand, "*", SearchOption.AllDirectories)) + File.Delete(d); + + var angestossen = await client.PostAsync($"/api/v1/libraries/{bibliothek}/scan", null); + Assert.That(angestossen.StatusCode, Is.EqualTo(HttpStatusCode.Accepted)); + + ScanStatus? stand = null; + for (var versuch = 0; versuch < 600; versuch++) + { + stand = await client.GetFromJsonAsync($"/api/v1/libraries/{bibliothek}/scan"); + if (stand!.State is "failed" or "finished") break; + await Task.Delay(50); + } + + Assert.That(stand!.State, Is.EqualTo("failed")); + Assert.That(stand.Error, Does.Contain("abgebrochen")); + Assert.That((await client.GetFromJsonAsync>("/api/v1/movies"))!.Total, + Is.EqualTo(2), "Es darf nichts verändert worden sein."); + Assert.That(await client.GetFromJsonAsync>("/api/v1/libraries/missing"), + Is.Empty, "Auch nicht als vermisst markiert."); + } + /// Das Aufräumen darf nicht wegräumen, was derselbe Lauf schrieb. /// /// diff --git a/web/app/src/api.ts b/web/app/src/api.ts index 7318120..13a9c87 100644 --- a/web/app/src/api.ts +++ b/web/app/src/api.ts @@ -428,6 +428,20 @@ export type Ordnerstand = { note: string | null; }; +export type VermissteDatei = { + fileId: string; path: string; versionLabel: string | null; + missingSince: string; libraryName: string; + title: string | null; movieId: string | null; episodeId: string | null; +}; + +/** Dateien, die der letzte Scan nicht mehr gefunden hat. */ +export const vermisste = () => anfrage('/libraries/missing'); + +/** Entfernt eine vermisste Datei endgültig — ohne Kennung alle. */ +export const vermisstesEntfernen = (fileId?: string) => + anfrage<{ removed: number }>('/libraries/missing' + (fileId ? `?fileId=${fileId}` : ''), + { method: 'DELETE' }); + export type Zusammenlegungsbericht = { filme: number; serien: number; gesamt: number }; /** Legt Titel zusammen, die denselben TMDB-Eintrag meinen. */ diff --git a/web/app/src/views/admin.ts b/web/app/src/views/admin.ts index 786eb74..3fab1c4 100644 --- a/web/app/src/views/admin.ts +++ b/web/app/src/views/admin.ts @@ -1,6 +1,7 @@ import { bibliotheken, bibliothekAnlegen, bibliothekUmbenennen, bibliothekLoeschen, scanStarten, scanstand, metadatenAuffrischen, doppelteZusammenlegen, + vermisste, vermisstesEntfernen, type VermissteDatei, benutzer, benutzerAnlegen, metadatenEinstellung, metadatenSetzen, servernameSetzen, systemauskunft, laufendeWiedergaben, serverzustand, bildAdresse, @@ -21,7 +22,8 @@ import { icon } from '../icons'; * Seitenleiste steht auf ihr und muss dann neu gezeichnet werden. */ export function verwaltung(geaendert: () => void = () => {}): HTMLElement { const wurzel = document.createElement('div'); - wurzel.append(dashboard(), bibliotheksbereich(geaendert), benutzerbereich(), serverbereich(geaendert)); + wurzel.append(dashboard(), vermisstenbereich(geaendert), bibliotheksbereich(geaendert), + benutzerbereich(), serverbereich(geaendert)); return wurzel; } @@ -149,6 +151,82 @@ function laufzeit(sekunden: number): string { return `${minuten} Min.`; } +/* ---------- Nicht mehr gefunden ---------- */ + +/** Was in der Mediathek steht, aber nicht mehr auf der Platte liegt. + * + * **Erscheint nur, wenn es etwas zu melden gibt.** Ein dauerhaft leerer + * Abschnitt wäre Rauschen; ein gefüllter ist eine Nachricht. + * + * Der Scan löscht nichts mehr von selbst. Eine kurz nicht eingehängte Platte + * sah vorher aus wie „die Filme sind weg" und kostete Titel samt + * Sehfortschritt aller Benutzer. Beides ist für den Scan nicht zu + * unterscheiden — also entscheidet hier ein Mensch. */ +function vermisstenbereich(geaendert: () => void): HTMLElement { + const bereich = document.createElement('section'); + bereich.className = 'hidden'; + + const liste = document.createElement('div'); + liste.className = 'einstellungen'; + + const alle = knopf('Alle entfernen', 'btn btn-gefahr'); + alle.onclick = () => nachfragen({ + titel: 'Alle vermissten Einträge entfernen', + text: 'Entfernt alle Dateien, die nicht mehr gefunden werden, aus der ' + + 'Mediathek — mitsamt den Titeln, von denen keine Datei übrig bleibt. ' + + 'Auf der Platte wird nichts gelöscht.', + bestaetigen: 'Entfernen', + tun: async () => { await vermisstesEntfernen(); await laden(); geaendert(); }, + }); + + bereich.append(abschnitt('Nicht mehr gefunden', [alle]), liste); + void laden(); + + async function laden(): Promise { + let fehlend: VermissteDatei[]; + try { fehlend = await vermisste(); } catch { bereich.classList.add('hidden'); return; } + + if (fehlend.length === 0) { bereich.classList.add('hidden'); return; } + bereich.classList.remove('hidden'); + + liste.innerHTML = ''; + fehlend.forEach(v => { + const d = document.createElement('div'); + d.className = 'einstellung'; + + const oben = document.createElement('div'); + oben.className = 'knopfreihe'; + const text = document.createElement('div'); + text.className = 'zeile'; + text.style.flex = '1'; + const seit = new Date(v.missingSince).toLocaleDateString('de-CH', + { day: '2-digit', month: 'long', year: 'numeric' }); + text.innerHTML = `${esc(v.title ?? '(ohne Titel)')}` + + `${v.versionLabel ? ` · ${esc(v.versionLabel)}` : ''}` + + `${esc(v.libraryName)} · vermisst seit ${esc(seit)}`; + oben.appendChild(text); + + const weg = knopf('Entfernen', 'btn'); + weg.onclick = async () => { + weg.disabled = true; + try { await vermisstesEntfernen(v.fileId); await laden(); geaendert(); } + catch (fehler) { d.appendChild(fehlerzeile((fehler as Error).message)); weg.disabled = false; } + }; + oben.appendChild(weg); + d.appendChild(oben); + + const pfad = document.createElement('div'); + pfad.className = 'winzig'; + pfad.textContent = v.path; + d.appendChild(pfad); + + liste.appendChild(d); + }); + } + + return bereich; +} + /* ---------- Bibliotheken ---------- */ function bibliotheksbereich(geaendert: () => void): HTMLElement {