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 {