Verschwundene Dateien werden gemeldet, nicht stillschweigend gelöscht

Vorher räumte der Scan jede Datei weg, die er nicht mehr sah, und mit der
letzten Datei fiel der Titel samt Sehfortschritt aller Benutzer. Für eine
wirklich gelöschte Datei ist das richtig. Für eine Platte im Ruhezustand,
ein noch nicht eingehängtes Volume oder einen umbenannten Ordner ist es
Datenverlust — und für den Scan sehen alle vier Fälle gleich aus.

Jetzt trägt MediaFile ein MissingSince. Der Eintrag bleibt stehen, steht
in der Verwaltung unter "Nicht mehr gefunden", und gelöscht wird auf
Ansage. Taucht die Datei wieder auf, verschwindet die Markierung von
selbst.

Dazu die Notbremse, und sie ist der eigentliche Grund für den ganzen
Punkt: Fehlt der Wurzelpfad, oder liegt dort keine einzige Mediendatei
mehr, obwohl vorher welche da waren, bricht der Lauf ab und sagt warum.
Ohne sie erklärte ein nicht eingehängtes Volume beim Start des NAS den
gesamten Bestand für verschwunden — bei 458 Titeln.

Der Abschnitt in der Verwaltung erscheint nur, wenn es etwas zu melden
gibt. Ein dauerhaft leerer wäre Rauschen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-04 11:46:55 +02:00
co-authored by Claude Opus 5
parent 01ff41fd71
commit 8b67a7ec88
11 changed files with 1122 additions and 13 deletions
+15 -1
View File
@@ -58,6 +58,18 @@ public sealed record RenameLibraryRequest(string Name);
public sealed record CreateLibraryRequest(string Name, string Kind, string Path);
/// <summary>Eine Datei, die der letzte Scan nicht mehr gefunden hat.</summary>
public sealed record VermissteDatei(
Guid FileId,
string Path,
string? VersionLabel,
DateTimeOffset MissingSince,
string LibraryName,
/// <summary>Zu welchem Titel sie gehört — damit klar ist, was fehlen würde.</summary>
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<ScanProblem>? Problems,
string? Error,
MetadataProgress? Metadata);
MetadataProgress? Metadata,
/// <summary>Wie viele Dateien dieser Lauf als verschwunden gemeldet hat.</summary>
int? Missing = null);
/// <summary>Was das Nachschlagen bei der Metadatenquelle ergeben hat.</summary>
public sealed record MetadataProgress(
@@ -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();
}
@@ -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<LibraryKind>(anfrage.Kind, ignoreCase: true, out var art))
@@ -54,6 +54,19 @@ public sealed class MediaFile
/// </remarks>
public DateTimeOffset? ProbedAt { get; set; }
/// <summary>Seit wann die Datei nicht mehr gefunden wird — sonst <c>null</c>.</summary>
///
/// <remarks>
/// <b>Markiert statt gelöscht.</b> 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.
/// </remarks>
public DateTimeOffset? MissingSince { get; set; }
/// <summary>Wie diese Fassung heisst — „4K", „Mobil", „Director's Cut".</summary>
///
/// <remarks>
+43 -8
View File
@@ -12,7 +12,12 @@ public sealed record ScanReport(
int Removed,
int Unrecognised,
int Probed,
IReadOnlyList<WalkProblem> Problems);
IReadOnlyList<WalkProblem> Problems,
/// <summary>Wie viele Dateien dieser Lauf als verschwunden markiert hat.</summary>
int Missing = 0);
/// <summary>Der Scan hat abgebrochen, statt den Bestand für verschwunden zu erklären.</summary>
public sealed class BestandVerschwundenException(string nachricht) : Exception(nachricht);
/// <summary>Gleicht ab, was auf der Platte liegt, mit dem, was in der Datenbank steht.</summary>
///
@@ -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);
}
/// <summary>Liest die Spuren einer Datei und schreibt sie fort.</summary>
@@ -0,0 +1,789 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid?>("ApprovedByUserId")
.HasColumnType("TEXT");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<long?>("ConsumedAt")
.HasColumnType("INTEGER");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("DeviceId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("DeviceName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<long>("ExpiresAt")
.HasColumnType("INTEGER");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("DeviceId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("DeviceName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<long>("LastSeenAt")
.HasColumnType("INTEGER");
b.Property<long?>("RevokedAt")
.HasColumnType("INTEGER");
b.Property<string>("TokenFingerprint")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<Guid>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("Kind")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("Path")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("Libraries");
});
modelBuilder.Entity("Flaro.Core.Library.Credit", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Character")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<Guid?>("MovieId")
.HasColumnType("TEXT");
b.Property<int>("Order")
.HasColumnType("INTEGER");
b.Property<Guid>("PersonId")
.HasColumnType("TEXT");
b.Property<string>("Role")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<Guid?>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("AddedAt")
.HasColumnType("INTEGER");
b.Property<int>("EpisodeNumber")
.HasColumnType("INTEGER");
b.Property<string>("Overview")
.HasColumnType("TEXT");
b.Property<int>("SeasonNumber")
.HasColumnType("INTEGER");
b.Property<Guid>("SeriesId")
.HasColumnType("TEXT");
b.Property<string>("StillImage")
.HasColumnType("TEXT");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid>("GenreId")
.HasColumnType("TEXT");
b.Property<Guid?>("MovieId")
.HasColumnType("TEXT");
b.Property<Guid?>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long?>("BitRate")
.HasColumnType("INTEGER");
b.Property<string>("Container")
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<long?>("Duration")
.HasColumnType("INTEGER");
b.Property<Guid?>("EpisodeId")
.HasColumnType("TEXT");
b.Property<Guid>("LibraryId")
.HasColumnType("TEXT");
b.Property<long?>("MissingSince")
.HasColumnType("INTEGER");
b.Property<long>("ModifiedAt")
.HasColumnType("INTEGER");
b.Property<Guid?>("MovieId")
.HasColumnType("TEXT");
b.Property<string>("Path")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<long?>("ProbedAt")
.HasColumnType("INTEGER");
b.Property<long>("SeenAt")
.HasColumnType("INTEGER");
b.Property<long>("SizeBytes")
.HasColumnType("INTEGER");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<int?>("Channels")
.HasColumnType("INTEGER");
b.Property<string>("Codec")
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int?>("Height")
.HasColumnType("INTEGER");
b.Property<int>("Index")
.HasColumnType("INTEGER");
b.Property<bool>("IsDefault")
.HasColumnType("INTEGER");
b.Property<bool>("IsForced")
.HasColumnType("INTEGER");
b.Property<string>("Kind")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<string>("Language")
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<Guid>("MediaFileId")
.HasColumnType("TEXT");
b.Property<string>("Title")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<int?>("Width")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("MediaFileId", "Index")
.IsUnique();
b.ToTable("MediaStreams");
});
modelBuilder.Entity("Flaro.Core.Library.Movie", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("AddedAt")
.HasColumnType("INTEGER");
b.Property<string>("BackdropImage")
.HasColumnType("TEXT");
b.Property<string>("ContentRating")
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<string>("ImdbId")
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<Guid>("LibraryId")
.HasColumnType("TEXT");
b.Property<string>("LogoImage")
.HasColumnType("TEXT");
b.Property<bool>("MatchLocked")
.HasColumnType("INTEGER");
b.Property<string>("Overview")
.HasColumnType("TEXT");
b.Property<string>("ParsedTitle")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int?>("ParsedYear")
.HasColumnType("INTEGER");
b.Property<string>("PosterImage")
.HasColumnType("TEXT");
b.Property<double?>("Rating")
.HasColumnType("REAL");
b.Property<int?>("RuntimeMinutes")
.HasColumnType("INTEGER");
b.Property<string>("SortTitleValue")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Tagline")
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<int?>("TmdbId")
.HasColumnType("INTEGER");
b.Property<int?>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Image")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<int>("TmdbId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("TmdbId")
.IsUnique();
b.ToTable("People");
});
modelBuilder.Entity("Flaro.Core.Library.Series", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("AddedAt")
.HasColumnType("INTEGER");
b.Property<string>("BackdropImage")
.HasColumnType("TEXT");
b.Property<string>("ContentRating")
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<string>("ImdbId")
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<Guid>("LibraryId")
.HasColumnType("TEXT");
b.Property<string>("LogoImage")
.HasColumnType("TEXT");
b.Property<bool>("MatchLocked")
.HasColumnType("INTEGER");
b.Property<string>("Overview")
.HasColumnType("TEXT");
b.Property<string>("ParsedTitle")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int?>("ParsedYear")
.HasColumnType("INTEGER");
b.Property<string>("PosterImage")
.HasColumnType("TEXT");
b.Property<double?>("Rating")
.HasColumnType("REAL");
b.Property<int?>("RuntimeMinutes")
.HasColumnType("INTEGER");
b.Property<string>("SortTitleValue")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Tagline")
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<int?>("TmdbId")
.HasColumnType("INTEGER");
b.Property<int?>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<bool>("Dismissed")
.HasColumnType("INTEGER");
b.Property<Guid?>("EpisodeId")
.HasColumnType("TEXT");
b.Property<bool>("Favourite")
.HasColumnType("INTEGER");
b.Property<bool>("InProgress")
.HasColumnType("INTEGER");
b.Property<long?>("LastPlayedAt")
.HasColumnType("INTEGER");
b.Property<Guid?>("MovieId")
.HasColumnType("TEXT");
b.Property<int>("PlayCount")
.HasColumnType("INTEGER");
b.Property<long>("Position")
.HasColumnType("INTEGER");
b.Property<int?>("Rating")
.HasColumnType("INTEGER");
b.Property<long?>("Runtime")
.HasColumnType("INTEGER");
b.Property<long>("UpdatedAt")
.HasColumnType("INTEGER");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.Property<bool>("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<string>("Key")
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("Value")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("TEXT");
b.HasKey("Key");
b.ToTable("Settings");
});
modelBuilder.Entity("Flaro.Core.Users.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<bool>("IsAdministrator")
.HasColumnType("INTEGER");
b.Property<string>("PasswordHash")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("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
}
}
}
@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Flaro.Data.Migrations
{
/// <inheritdoc />
public partial class VerschwundeneDateien : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<long>(
name: "MissingSince",
table: "MediaFiles",
type: "INTEGER",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "MissingSince",
table: "MediaFiles");
}
}
}
@@ -285,6 +285,9 @@ namespace Flaro.Data.Migrations
b.Property<Guid>("LibraryId")
.HasColumnType("TEXT");
b.Property<long?>("MissingSince")
.HasColumnType("INTEGER");
b.Property<long>("ModifiedAt")
.HasColumnType("INTEGER");
+85 -2
View File
@@ -107,8 +107,16 @@ public class ScanTests
Assert.That((await client.GetFromJsonAsync<Page<MovieResponse>>("/api/v1/movies"))!.Total, Is.EqualTo(2));
}
/// <summary>Eine verschwundene Datei wird gemeldet, nicht stillschweigend gelöscht.</summary>
///
/// <remarks>
/// <b>Vorher fiel hier der Titel samt Sehfortschritt.</b> 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.
/// </remarks>
[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<Page<MovieResponse>>("/api/v1/movies"))!.Total,
Is.EqualTo(2), "Der Titel bleibt stehen, bis jemand ihn entfernt.");
var vermisst = await client.GetFromJsonAsync<List<VermissteDatei>>("/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<Page<MovieResponse>>("/api/v1/movies");
Assert.That(filme!.Items.Select(m => m.Title), Is.EqualTo(new[] { "The Matrix" }));
}
/// <summary>Kommt die Datei zurück, verschwindet die Markierung von selbst.</summary>
[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<List<VermissteDatei>>("/api/v1/libraries/missing"),
Has.Count.EqualTo(1));
await File.WriteAllBytesAsync(pfad, inhalt);
await Scannen(client, bibliothek);
Assert.That(await client.GetFromJsonAsync<List<VermissteDatei>>("/api/v1/libraries/missing"),
Is.Empty);
}
/// <summary>Die Notbremse: ein leerer Ordner bricht ab, statt alles zu verwerfen.</summary>
///
/// <remarks>
/// <b>Der teuerste denkbare Fehler.</b> 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.
/// </remarks>
[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<ScanStatus>($"/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<Page<MovieResponse>>("/api/v1/movies"))!.Total,
Is.EqualTo(2), "Es darf nichts verändert worden sein.");
Assert.That(await client.GetFromJsonAsync<List<VermissteDatei>>("/api/v1/libraries/missing"),
Is.Empty, "Auch nicht als vermisst markiert.");
}
/// <summary>Das Aufräumen darf nicht wegräumen, was derselbe Lauf schrieb.</summary>
///
/// <remarks>