diff --git a/server/src/Halo.Api/Endpoints/Contracts.cs b/server/src/Halo.Api/Endpoints/Contracts.cs index 533ae68..9acbe3e 100644 --- a/server/src/Halo.Api/Endpoints/Contracts.cs +++ b/server/src/Halo.Api/Endpoints/Contracts.cs @@ -31,3 +31,31 @@ public sealed record CreateUserRequest(string UserName, string Password, bool Is public sealed record LibraryResponse(Guid Id, string Name, string Kind, string Path); public sealed record CreateLibraryRequest(string Name, string Kind, string Path); + +// --- Stufe 2: Bibliothek ------------------------------------------------- + +public sealed record ScanProblem(string Path, string Reason); + +public sealed record ScanStatus( + string State, + DateTimeOffset? StartedAt, + DateTimeOffset? FinishedAt, + int? FilesFound, + int? Added, + int? Updated, + int? Removed, + int? Unrecognised, + IReadOnlyList? Problems, + string? Error); + +public sealed record MediaFileResponse(Guid Id, string Path, long SizeBytes); + +public sealed record MovieResponse( + Guid Id, string Title, int? Year, string? Overview, IReadOnlyList Files); + +public sealed record SeriesResponse(Guid Id, string Title, int? Year, int EpisodeCount, IReadOnlyList Seasons); + +public sealed record EpisodeResponse( + Guid Id, int Season, int Episode, string? Title, IReadOnlyList Files); + +public sealed record Page(IReadOnlyList Items, int Total); diff --git a/server/src/Halo.Api/Endpoints/LibraryContentEndpoints.cs b/server/src/Halo.Api/Endpoints/LibraryContentEndpoints.cs new file mode 100644 index 0000000..babbb10 --- /dev/null +++ b/server/src/Halo.Api/Endpoints/LibraryContentEndpoints.cs @@ -0,0 +1,144 @@ +using Halo.Data; +using Microsoft.EntityFrameworkCore; + +namespace Halo.Api.Endpoints; + +public static class LibraryContentEndpoints +{ + /// Mehr als das liefert keine Anfrage — auch wenn sie danach fragt. + /// + /// + /// Ohne Obergrenze holt ein ?take=100000 die ganze Mediathek in + /// einem Zug, und zwar sowohl in den Arbeitsspeicher des Servers als auch + /// über die Leitung. Eine Grenze, die der Aufrufer setzt, ist keine. + /// + private const int HöchstensJeSeite = 200; + + public static void MapLibraryContent(this IEndpointRouteBuilder app) + { + MapScan(app); + MapBrowse(app); + } + + private static void MapScan(IEndpointRouteBuilder app) + { + app.MapPost("/api/v1/libraries/{id:guid}/scan", async ( + Guid id, HaloContext db, ScanCoordinator koordinator) => + { + if (!await db.Libraries.AnyAsync(l => l.Id == id)) return Results.NotFound(); + + return koordinator.TryStart(id) + ? Results.AcceptedAtRoute("Scanstand", new { id }) + : Results.Problem( + "Für diese Bibliothek läuft bereits ein Scan.", + statusCode: StatusCodes.Status409Conflict); + }).RequireAuthorization(HaloPolicies.Administrator); + + app.MapGet("/api/v1/libraries/{id:guid}/scan", (Guid id, ScanCoordinator koordinator) => + { + var zustand = koordinator.State(id); + if (zustand is null) + return Results.Ok(new ScanStatus("idle", null, null, null, null, null, null, null, null, null)); + + var bericht = zustand.Report; + return Results.Ok(new ScanStatus( + zustand.Status, zustand.StartedAt, zustand.FinishedAt, + bericht?.FilesFound, bericht?.Added, bericht?.Updated, bericht?.Removed, bericht?.Unrecognised, + bericht?.Problems.Select(p => new ScanProblem(p.Path, p.Reason)).ToList(), + zustand.Error)); + }).WithName("Scanstand").RequireAuthorization(); + } + + private static void MapBrowse(IEndpointRouteBuilder app) + { + app.MapGet("/api/v1/movies", async ( + HaloContext db, Guid? library, string? search, int skip = 0, int take = 50) => + { + var abfrage = db.Movies.AsNoTracking(); + if (library is { } kennung) abfrage = abfrage.Where(m => m.LibraryId == kennung); + if (!string.IsNullOrWhiteSpace(search)) + abfrage = abfrage.Where(m => EF.Functions.Like(m.Title, $"%{search.Trim()}%")); + + var gesamt = await abfrage.CountAsync(); + var kennungen = await abfrage + .OrderBy(m => m.SortTitleValue).ThenBy(m => m.Year) + .Skip(Math.Max(0, skip)).Take(Math.Clamp(take, 1, HöchstensJeSeite)) + .Select(m => new { m.Id, m.Title, m.Year, m.Overview }) + .ToListAsync(); + + // Dateien in **einer** Abfrage nachholen statt je Film einer. + // Andernfalls kostet eine Seite mit fünfzig Titeln einundfünfzig + // Abfragen, und SQLite führt die nacheinander aus. + var ids = kennungen.Select(m => m.Id).ToList(); + var dateien = await db.MediaFiles.AsNoTracking() + .Where(f => f.MovieId != null && ids.Contains(f.MovieId!.Value)) + .Select(f => new { f.Id, f.Path, f.SizeBytes, f.MovieId }) + .ToListAsync(); + + var items = kennungen.Select(m => new MovieResponse( + m.Id, m.Title, m.Year, m.Overview, + dateien.Where(f => f.MovieId == m.Id) + .Select(f => new MediaFileResponse(f.Id, f.Path, f.SizeBytes)).ToList())).ToList(); + + return Results.Ok(new Page(items, gesamt)); + }).RequireAuthorization(); + + app.MapGet("/api/v1/series", async ( + HaloContext db, Guid? library, string? search, int skip = 0, int take = 50) => + { + var abfrage = db.Series.AsNoTracking(); + if (library is { } kennung) abfrage = abfrage.Where(s => s.LibraryId == kennung); + if (!string.IsNullOrWhiteSpace(search)) + abfrage = abfrage.Where(s => EF.Functions.Like(s.Title, $"%{search.Trim()}%")); + + var gesamt = await abfrage.CountAsync(); + var serien = await abfrage + .OrderBy(s => s.SortTitleValue) + .Skip(Math.Max(0, skip)).Take(Math.Clamp(take, 1, HöchstensJeSeite)) + .Select(s => new { s.Id, s.Title, s.Year }) + .ToListAsync(); + + var ids = serien.Select(s => s.Id).ToList(); + var folgen = await db.Episodes.AsNoTracking() + .Where(f => ids.Contains(f.SeriesId)) + .Select(f => new { f.SeriesId, f.SeasonNumber }) + .ToListAsync(); + + var items = serien.Select(s => + { + var meine = folgen.Where(f => f.SeriesId == s.Id).ToList(); + return new SeriesResponse( + s.Id, s.Title, s.Year, meine.Count, + meine.Select(f => f.SeasonNumber).Distinct().Order().ToList()); + }).ToList(); + + return Results.Ok(new Page(items, gesamt)); + }).RequireAuthorization(); + + app.MapGet("/api/v1/series/{id:guid}/episodes", async (Guid id, HaloContext db, int? season) => + { + if (!await db.Series.AnyAsync(s => s.Id == id)) return Results.NotFound(); + + var abfrage = db.Episodes.AsNoTracking().Where(f => f.SeriesId == id); + if (season is { } staffel) abfrage = abfrage.Where(f => f.SeasonNumber == staffel); + + var folgen = await abfrage + .OrderBy(f => f.SeasonNumber).ThenBy(f => f.EpisodeNumber) + .Select(f => new { f.Id, f.SeasonNumber, f.EpisodeNumber, f.Title }) + .ToListAsync(); + + var ids = folgen.Select(f => f.Id).ToList(); + var dateien = await db.MediaFiles.AsNoTracking() + .Where(d => d.EpisodeId != null && ids.Contains(d.EpisodeId!.Value)) + .Select(d => new { d.Id, d.Path, d.SizeBytes, d.EpisodeId }) + .ToListAsync(); + + var items = folgen.Select(f => new EpisodeResponse( + f.Id, f.SeasonNumber, f.EpisodeNumber, f.Title, + dateien.Where(d => d.EpisodeId == f.Id) + .Select(d => new MediaFileResponse(d.Id, d.Path, d.SizeBytes)).ToList())).ToList(); + + return Results.Ok(new Page(items, items.Count)); + }).RequireAuthorization(); + } +} diff --git a/server/src/Halo.Api/Program.cs b/server/src/Halo.Api/Program.cs index 1b005c8..effb42f 100644 --- a/server/src/Halo.Api/Program.cs +++ b/server/src/Halo.Api/Program.cs @@ -57,6 +57,8 @@ builder.Services.AddRateLimiter(o => _ => new FixedWindowRateLimiterOptions { PermitLimit = beimNachfragen, Window = TimeSpan.FromMinutes(1) })); }); +builder.Services.AddScoped(); +builder.Services.AddSingleton(); builder.Services.AddProblemDetails(); var app = builder.Build(); @@ -77,6 +79,7 @@ app.UseAuthorization(); app.MapSystem(); app.MapAuth(); app.MapManagement(); +app.MapLibraryContent(); app.Run(); diff --git a/server/src/Halo.Api/ScanCoordinator.cs b/server/src/Halo.Api/ScanCoordinator.cs new file mode 100644 index 0000000..e0174cd --- /dev/null +++ b/server/src/Halo.Api/ScanCoordinator.cs @@ -0,0 +1,87 @@ +using System.Collections.Concurrent; +using Halo.Data; + +namespace Halo.Api; + +public sealed record ScanState( + string Status, + DateTimeOffset StartedAt, + DateTimeOffset? FinishedAt, + ScanReport? Report, + string? Error); + +/// Lässt Scans im Hintergrund laufen und weiss, wie weit sie sind. +/// +/// +/// +/// Nicht in der Anfrage. Ein Bestand mit 40 000 Titeln braucht Minuten; +/// eine HTTP-Anfrage, die so lange offen steht, läuft hinter jedem Reverse +/// Proxy ins Zeitlimit — und der Anwender sieht einen Fehler, während der Scan +/// in Wahrheit tadellos weiterläuft. +/// +/// +/// Einer je Bibliothek, nicht mehr. Zwei gleichzeitige Läufe über +/// dasselbe Verzeichnis würden sich beim Aufräumen gegenseitig die Einträge +/// löschen: der eine schreibt seine Startzeit, der andere hält sie für alt. +/// +/// +/// Der Zustand liegt im Arbeitsspeicher und nicht in der Datenbank. Ein +/// Neustart bricht den Scan ohnehin ab, und ein „läuft" aus einem früheren +/// Leben wäre dann eine Lüge, die niemand mehr aufräumt. +/// +/// +public sealed class ScanCoordinator( + IServiceScopeFactory scopes, + TimeProvider clock, + ILogger log) +{ + private readonly ConcurrentDictionary _zustand = new(); + + public ScanState? State(Guid libraryId) => _zustand.GetValueOrDefault(libraryId); + + public bool IsRunning(Guid libraryId) => State(libraryId)?.Status == "running"; + + /// Startet einen Lauf — oder sagt, dass schon einer läuft. + public bool TryStart(Guid libraryId) + { + var start = new ScanState("running", clock.GetUtcNow(), null, null, null); + + var vorher = _zustand.AddOrUpdate( + libraryId, + start, + (_, alt) => alt.Status == "running" ? alt : start); + + // Lief schon einer, steht hier dessen Zustand und nicht unserer. + if (!ReferenceEquals(vorher, start)) return false; + + _ = Task.Run(() => Laufen(libraryId)); + return true; + } + + private async Task Laufen(Guid libraryId) + { + try + { + using var bereich = scopes.CreateScope(); + var scanner = bereich.ServiceProvider.GetRequiredService(); + var bericht = await scanner.ScanAsync(libraryId); + + _zustand[libraryId] = _zustand[libraryId] with + { + Status = "finished", + FinishedAt = clock.GetUtcNow(), + Report = bericht, + }; + } + catch (Exception fehler) + { + log.LogError(fehler, "Scan der Bibliothek {Kennung} ist gescheitert.", libraryId); + _zustand[libraryId] = _zustand[libraryId] with + { + Status = "failed", + FinishedAt = clock.GetUtcNow(), + Error = fehler.Message, + }; + } + } +} diff --git a/server/src/Halo.Core/Library/FileNames.cs b/server/src/Halo.Core/Library/FileNames.cs new file mode 100644 index 0000000..376d5dd --- /dev/null +++ b/server/src/Halo.Core/Library/FileNames.cs @@ -0,0 +1,211 @@ +using System.Globalization; +using System.Text.RegularExpressions; + +namespace Halo.Core.Library; + +public sealed record MovieName(string Title, int? Year); + +public sealed record EpisodeName(string SeriesTitle, int Season, int Episode, string? EpisodeTitle); + +/// Macht aus einem Dateinamen wieder Titel, Jahr, Staffel und Folge. +/// +/// +/// +/// Hier scheitern Medienserver in der Praxis. Was auf der Platte liegt, +/// heisst nicht „Der Fall", sondern +/// Notruf.Hafenkante.S01E01.German.1080p.WEB-DL.x264-GRP.mkv, und was +/// davon Titel ist und was Beiwerk, steht nirgends. Rät man falsch, sucht die +/// Metadatenquelle nach „Notruf Hafenkante German 1080p" und findet nichts — +/// der Titel bleibt dann für immer der Dateiname. +/// +/// +/// Das Verfahren: abschneiden, nicht herauslösen. Alles ab dem ersten +/// Beiwerk-Merkmal fliegt weg. Einzelne Tags zu entfernen und den Rest +/// zusammenzuschieben klingt gründlicher, lässt aber bei jedem unbekannten Tag +/// Bruchstücke im Titel stehen — und unbekannte Tags gibt es immer. +/// +/// +public static partial class FileNames +{ + /// Merkmale, ab denen nur noch Technik kommt. + /// + /// + /// Nicht vollständig und kann es nicht sein. Es muss nur das erste treffen + /// — alles dahinter fällt ohnehin weg. + /// + private static readonly HashSet Beiwerk = new(StringComparer.OrdinalIgnoreCase) + { + // Quelle + "bluray", "blu-ray", "bdrip", "brrip", "bdremux", "remux", "dvdrip", "dvd", "dvdr", + "webrip", "web-dl", "webdl", "web", "hdtv", "pdtv", "hdrip", "cam", "ts", "tc", "r5", + "uhdbd", "uhd", "hddvd", + // Bild + "x264", "x265", "h264", "h265", "hevc", "avc", "xvid", "divx", "av1", "mpeg2", "vc1", + "hdr", "hdr10", "hdr10plus", "dolbyvision", "dovi", "sdr", "10bit", "8bit", + // Ton + "aac", "ac3", "eac3", "dts", "dtshd", "dts-hd", "truehd", "atmos", "flac", "opus", + "mp3", "dd", "ddp", "dd5", "ddp5", "lpcm", "pcm", + // Fassung + "proper", "repack", "rerip", "extended", "unrated", "uncut", "remastered", "imax", + "theatrical", "limited", "internal", "complete", "retail", "subbed", "dubbed", + // Sprache und Herkunft, wie die Szene sie schreibt + "german", "englisch", "english", "multi", "dual", "dl", "ger", "eng", "vo", "vost", + "omu", "subs", "sub", + }; + + /// Auflösungen wie 1080p, 2160p, 720i — und 4K. + [GeneratedRegex(@"^\d{3,4}[pi]$|^[48]k$", RegexOptions.IgnoreCase)] + private static partial Regex Auflösung(); + + /// S01E02, s1e2, S01.E02, S01E02E03 — die erste Folge zählt. + [GeneratedRegex(@"\bS(?\d{1,3})[\s._-]*E(?\d{1,4})", RegexOptions.IgnoreCase)] + private static partial Regex StaffelUndFolge(); + + /// 1x02, 01x02 — die ältere Schreibweise. + [GeneratedRegex(@"(?\d{1,2})x(?\d{2,3})(?![\dx])", RegexOptions.IgnoreCase)] + private static partial Regex KreuzSchreibweise(); + + /// Ein Jahr in Klammern: (1999) oder [1999]. + [GeneratedRegex(@"[\(\[](?(?:18|19|20)\d{2})[\)\]]")] + private static partial Regex JahrInKlammern(); + + [GeneratedRegex(@"\s+")] + private static partial Regex Leerraum(); + + /// Liest Titel und Jahr aus einem Filmdateinamen. + public static MovieName ParseMovie(string dateiname) + { + var (worte, jahrAusKlammern) = Zerlegen(dateiname); + var (titel, jahr) = TitelBis(worte, grenze: worte.Length); + return new MovieName(titel.Length > 0 ? titel : Rohname(dateiname), jahrAusKlammern ?? jahr); + } + + /// Liest Serie, Staffel, Folge und Folgentitel — oder null. + /// + /// + /// null heisst: hier steht keine Folgennummer. Dann ist es entweder + /// ein Film oder etwas, das niemand benennen kann — in beiden Fällen wäre + /// eine geratene Staffel 1 schlimmer als gar keine. + /// + public static EpisodeName? ParseEpisode(string dateiname) + { + var roh = Rohname(dateiname); + + var treffer = StaffelUndFolge().Match(roh); + if (!treffer.Success) treffer = KreuzSchreibweise().Match(roh); + if (!treffer.Success) return null; + + if (!int.TryParse(treffer.Groups["staffel"].Value, out var staffel) || + !int.TryParse(treffer.Groups["folge"].Value, out var folge)) return null; + + var davor = roh[..treffer.Index]; + var danach = roh[(treffer.Index + treffer.Length)..]; + + var (worteDavor, _) = Zerlegen(davor, istDateiname: false); + var (serie, _) = TitelBis(worteDavor, worteDavor.Length); + + var (worteDanach, _) = Zerlegen(danach, istDateiname: false); + var (folgenTitel, _) = TitelBis(worteDanach, worteDanach.Length); + + return new EpisodeName( + SeriesTitle: serie.Length > 0 ? serie : Rohname(dateiname), + Season: staffel, + Episode: folge, + EpisodeTitle: folgenTitel.Length > 0 ? folgenTitel : null); + } + + /// Nimmt Worte, bis das erste Beiwerk oder eine Jahreszahl kommt. + private static (string Titel, int? Jahr) TitelBis(string[] worte, int grenze) + { + var genommen = new List(); + int? jahr = null; + + for (var i = 0; i < grenze; i++) + { + var wort = worte[i]; + + if (IstBeiwerk(wort)) break; + + // Eine Jahreszahl beendet den Titel — aber nur, wenn schon etwas + // davorsteht. Sonst verlöre „2012" seinen ganzen Namen. + if (genommen.Count > 0 && IstPlausiblesJahr(wort, out var gelesen)) + { + jahr = gelesen; + break; + } + + genommen.Add(wort); + } + + return (Säubern(string.Join(' ', genommen)), jahr); + } + + private static bool IstBeiwerk(string wort) + { + var blank = wort.Trim('[', ']', '(', ')', '{', '}', '-', '_'); + if (blank.Length == 0) return false; + if (Beiwerk.Contains(blank) || Auflösung().IsMatch(blank)) return true; + + // „x264-GRP" und „DD5.1" hängen am Beiwerk fest. + var vorStrich = blank.Split('-', '.')[0]; + return vorStrich.Length > 1 && (Beiwerk.Contains(vorStrich) || Auflösung().IsMatch(vorStrich)); + } + + /// Vier Ziffern allein machen noch kein Jahr. + /// + /// + /// „Blade Runner 2049" ist der Fall, an dem sich das entscheidet. + /// Eine Obergrenze knapp über heute trennt eine Jahreszahl von einer Zahl + /// im Titel — alles, was weit in der Zukunft liegt, gehört zum Namen. + /// + private static bool IstPlausiblesJahr(string wort, out int jahr) + { + jahr = 0; + var blank = wort.Trim('[', ']', '(', ')'); + if (blank.Length != 4 || !int.TryParse(blank, NumberStyles.None, CultureInfo.InvariantCulture, out var zahl)) + return false; + + if (zahl < 1880 || zahl > DateTime.UtcNow.Year + 2) return false; + jahr = zahl; + return true; + } + + /// Dateiname ohne Pfad und ohne Endung. + private static string Rohname(string dateiname) => Path.GetFileNameWithoutExtension(dateiname); + + /// Zerlegt in Worte und holt ein eingeklammertes Jahr heraus. + /// + /// + /// Punkte werden nur dann zu Leerzeichen, wenn sonst keine da sind. + /// „The.Matrix.1999" braucht es; „S.W.A.T. (2003)" verlöre dadurch seinen + /// Namen. Dass im Namen überhaupt Leerzeichen stehen, heisst: hier hat + /// jemand von Hand benannt, und seine Punkte sind gewollt. + /// + private static (string[] Worte, int? JahrInKlammern) Zerlegen(string eingabe, bool istDateiname = true) + { + var text = istDateiname ? Rohname(eingabe) : eingabe; + + int? jahr = null; + var geklammert = JahrInKlammern().Match(text); + if (geklammert.Success && int.TryParse(geklammert.Groups["jahr"].Value, out var gelesen)) + { + jahr = gelesen; + text = text.Remove(geklammert.Index, geklammert.Length); + } + + text = text.Replace('_', ' '); + if (!text.Contains(' ')) text = text.Replace('.', ' '); + + return (Leerraum().Split(text.Trim()).Where(w => w.Length > 0).ToArray(), jahr); + } + + /// Räumt Trennzeichen weg, die am Rand übrig bleiben. + /// + /// + /// Der Punkt steht ausdrücklich nicht dabei. Aus „S.W.A.T." würde + /// sonst „S.W.A.T" — und ein Punkt am Titelende ist ohnehin selten, weil + /// Trennpunkte schon beim Zerlegen zu Leerzeichen werden. + /// + private static string Säubern(string roh) => + Leerraum().Replace(roh.Replace('_', ' '), " ").Trim(' ', '-', '–', ',', '[', ']', '(', ')'); +} diff --git a/server/src/Halo.Core/Library/LibraryWalker.cs b/server/src/Halo.Core/Library/LibraryWalker.cs new file mode 100644 index 0000000..109aa4e --- /dev/null +++ b/server/src/Halo.Core/Library/LibraryWalker.cs @@ -0,0 +1,93 @@ +namespace Halo.Core.Library; + +/// Eine gefundene Datei, so wie das Dateisystem sie beschreibt. +public sealed record ScannedFile(string Path, long SizeBytes, DateTimeOffset ModifiedAt); + +/// Was beim Durchlaufen schiefging, ohne den Lauf abzubrechen. +public sealed record WalkProblem(string Path, string Reason); + +public sealed record WalkResult(IReadOnlyList Files, IReadOnlyList Problems); + +/// Läuft ein Bibliotheksverzeichnis ab und sammelt Videodateien. +/// +/// +/// +/// Von Hand rekursiv statt mit AllDirectories. Die bequeme +/// Fassung läuft auch in @eaDir hinein — Synologys Vorschauablage, die +/// eine Kopie jeder Datei enthält. Wer sie mitnimmt, hat seine +/// Mediathek doppelt, zur Hälfte mit unbrauchbaren Dateien. Äste beschneiden +/// kann nur, wer selbst absteigt. +/// +/// +/// Ein gesperrtes Unterverzeichnis bricht den Lauf nicht ab, sondern +/// wird vermerkt. Sonst entschiede ein einzelner falsch gesetzter Ordner +/// darüber, ob überhaupt etwas gefunden wird — und die Mediathek bliebe leer, +/// ohne dass jemand sagen könnte, warum. +/// +/// +public static class LibraryWalker +{ + /// Tiefer als das ist kein Bestand, sondern eine Schleife. + /// + /// + /// Verweise im Dateisystem können im Kreis zeigen. Ohne Grenze liefe der + /// Scan, bis der Arbeitsspeicher voll ist. + /// + private const int MaximaleTiefe = 12; + + public static WalkResult Walk(string wurzel) + { + var gefunden = new List(); + var probleme = new List(); + + if (!Directory.Exists(wurzel)) + { + probleme.Add(new WalkProblem(wurzel, "Das Verzeichnis gibt es nicht.")); + return new WalkResult(gefunden, probleme); + } + + Absteigen(wurzel, 0, gefunden, probleme); + return new WalkResult(gefunden, probleme); + } + + private static void Absteigen(string ordner, int tiefe, List gefunden, List probleme) + { + if (tiefe > MaximaleTiefe) + { + probleme.Add(new WalkProblem(ordner, $"Tiefer als {MaximaleTiefe} Ebenen — hier wird nicht weitergesucht.")); + return; + } + + string[] dateien, unterordner; + try + { + dateien = Directory.GetFiles(ordner); + unterordner = Directory.GetDirectories(ordner); + } + catch (Exception fehler) when (fehler is UnauthorizedAccessException or IOException) + { + probleme.Add(new WalkProblem(ordner, fehler.Message)); + return; + } + + foreach (var datei in dateien) + { + if (!MediaExtensions.IsVideo(datei)) continue; + try + { + var angaben = new FileInfo(datei); + gefunden.Add(new ScannedFile(datei, angaben.Length, angaben.LastWriteTimeUtc)); + } + catch (Exception fehler) when (fehler is UnauthorizedAccessException or IOException) + { + probleme.Add(new WalkProblem(datei, fehler.Message)); + } + } + + foreach (var unter in unterordner) + { + if (MediaExtensions.IsSkippedDirectory(Path.GetFileName(unter))) continue; + Absteigen(unter, tiefe + 1, gefunden, probleme); + } + } +} diff --git a/server/src/Halo.Core/Library/MediaEntities.cs b/server/src/Halo.Core/Library/MediaEntities.cs new file mode 100644 index 0000000..dbdfe48 --- /dev/null +++ b/server/src/Halo.Core/Library/MediaEntities.cs @@ -0,0 +1,79 @@ +namespace Halo.Core.Library; + +/// Eine Datei auf der Platte, so wie der Scan sie vorgefunden hat. +/// +/// +/// +/// Die Datei zeigt auf den Titel, nicht umgekehrt. Das sieht +/// herumgedreht aus und ist Absicht: ein Film liegt oft zweimal vor, als 4K +/// und als 1080p. Trüge der Film den Verweis auf seine eine Datei, wäre die +/// zweite Fassung ein Umbau am Datenmodell — so ist sie eine Zeile mehr. +/// +/// +/// Genau dasselbe gilt für Folgen. +/// +/// +public sealed class MediaFile +{ + public Guid Id { get; init; } = Guid.CreateVersion7(); + public required Guid LibraryId { get; init; } + + /// Der Pfad, wie der Server ihn sieht — im Container, nicht auf dem Wirt. + public required string Path { get; set; } + + public long SizeBytes { get; set; } + public DateTimeOffset ModifiedAt { get; set; } + + /// Wann der letzte Scan diese Datei noch vorgefunden hat. + /// + /// + /// Daran hängt das Aufräumen: Was ein Lauf nicht gesehen hat, liegt nicht + /// mehr da. Ohne diese Angabe müsste der Scan sich merken, was er gesehen + /// hat, und das wächst mit dem Bestand. + /// + public DateTimeOffset SeenAt { get; set; } + + public Guid? MovieId { get; set; } + public Guid? EpisodeId { get; set; } +} + +public sealed class Movie +{ + public Guid Id { get; init; } = Guid.CreateVersion7(); + public required Guid LibraryId { get; init; } + public required string Title { get; set; } + + /// Ohne führenden Artikel — siehe . + public required string SortTitleValue { get; set; } + + public int? Year { get; set; } + public string? Overview { get; set; } + + /// Die Kennung bei TMDB, sobald zugeordnet. + public int? TmdbId { get; set; } + + public DateTimeOffset AddedAt { get; init; } = DateTimeOffset.UtcNow; +} + +public sealed class Series +{ + public Guid Id { get; init; } = Guid.CreateVersion7(); + public required Guid LibraryId { get; init; } + public required string Title { get; set; } + public required string SortTitleValue { get; set; } + public int? Year { get; set; } + public string? Overview { get; set; } + public int? TmdbId { get; set; } + public DateTimeOffset AddedAt { get; init; } = DateTimeOffset.UtcNow; +} + +public sealed class Episode +{ + public Guid Id { get; init; } = Guid.CreateVersion7(); + public required Guid SeriesId { get; init; } + public required int SeasonNumber { get; init; } + public required int EpisodeNumber { get; init; } + public string? Title { get; set; } + public string? Overview { get; set; } + public DateTimeOffset AddedAt { get; init; } = DateTimeOffset.UtcNow; +} diff --git a/server/src/Halo.Core/Library/MediaExtensions.cs b/server/src/Halo.Core/Library/MediaExtensions.cs new file mode 100644 index 0000000..a0d7afa --- /dev/null +++ b/server/src/Halo.Core/Library/MediaExtensions.cs @@ -0,0 +1,52 @@ +namespace Halo.Core.Library; + +/// Welche Dateien überhaupt als Medien gelten. +/// +/// +/// Eine Erlaubnisliste, keine Verbotsliste. Ein Bestand enthält +/// Bildchen, Untertitel, `.nfo`, `.DS_Store`, Papierkörbe und angefangene +/// Downloads. Alles aufzunehmen, was nicht ausdrücklich verboten ist, hiesse, +/// in der Mediathek irgendwann eine `Thumbs.db` als Film zu finden. +/// +public static class MediaExtensions +{ + private static readonly HashSet Video = new(StringComparer.OrdinalIgnoreCase) + { + ".mkv", ".mp4", ".m4v", ".avi", ".mov", ".ts", ".m2ts", ".mpg", ".mpeg", + ".wmv", ".flv", ".webm", ".vob", ".iso", ".divx", ".ogv", ".3gp", + }; + + /// Was unterwegs oder unvollständig ist und deshalb nicht zählt. + /// + /// + /// Ein laufender Download heisst oft `.mkv.part` oder liegt in einem + /// Ordner, der mit einem Punkt anfängt. Ihn aufzunehmen hiesse: ein Titel + /// erscheint, verschwindet wieder, und beim Abspielen bricht er mittendrin + /// ab. + /// + private static readonly string[] UnfertigeEndungen = [".part", ".!qb", ".crdownload", ".tmp", ".partial"]; + + public static bool IsVideo(string pfad) + { + var name = Path.GetFileName(pfad); + if (name.Length == 0 || name[0] == '.') return false; + if (UnfertigeEndungen.Any(e => name.EndsWith(e, StringComparison.OrdinalIgnoreCase))) return false; + return Video.Contains(Path.GetExtension(name)); + } + + /// Verzeichnisse, die in einem Bestand nichts Abspielbares enthalten. + /// + /// + /// `@eaDir` ist Synologys Vorschaubild-Ablage und enthält Kopien **jeder** + /// Datei — wer sie mitscannt, verdoppelt seine Mediathek mit unbrauchbaren + /// Dateien. Die übrigen sind Papierkörbe und Wiederherstellungspunkte. + /// + private static readonly HashSet ÜbersprungeneOrdner = new(StringComparer.OrdinalIgnoreCase) + { + "@eaDir", "#recycle", "$RECYCLE.BIN", "lost+found", ".Trashes", ".AppleDouble", + "System Volume Information", "extras", "featurettes", "behind the scenes", "trailers", + }; + + public static bool IsSkippedDirectory(string name) => + name.Length > 0 && (name[0] == '.' || ÜbersprungeneOrdner.Contains(name)); +} diff --git a/server/src/Halo.Core/Library/SortTitle.cs b/server/src/Halo.Core/Library/SortTitle.cs new file mode 100644 index 0000000..e13758f --- /dev/null +++ b/server/src/Halo.Core/Library/SortTitle.cs @@ -0,0 +1,28 @@ +namespace Halo.Core.Library; + +/// Der Titel, nach dem sortiert wird — ohne den Artikel davor. +/// +/// +/// Sonst stünde die halbe Mediathek unter „D": „Der Untergang", „Die Welle", +/// „Das Boot". Gezeigt wird weiterhin der richtige Titel; nur die Reihenfolge +/// richtet sich nach dem, was danach kommt. +/// +public static class SortTitle +{ + /// Deutsch und Englisch — die beiden Sprachen, in denen Bestände hier vorliegen. + private static readonly string[] Artikel = ["der ", "die ", "das ", "the ", "a ", "an ", "ein ", "eine "]; + + public static string For(string titel) + { + var blank = titel.Trim(); + foreach (var artikel in Artikel) + { + if (!blank.StartsWith(artikel, StringComparison.OrdinalIgnoreCase)) continue; + + var rest = blank[artikel.Length..].TrimStart(); + // „Die" allein ist ein Titel, kein Artikel — dann bliebe nichts übrig. + if (rest.Length > 0) return rest; + } + return blank; + } +} diff --git a/server/src/Halo.Data/HaloContext.cs b/server/src/Halo.Data/HaloContext.cs index 382042c..ab17745 100644 --- a/server/src/Halo.Data/HaloContext.cs +++ b/server/src/Halo.Data/HaloContext.cs @@ -1,6 +1,7 @@ using Halo.Core; using Halo.Core.Auth; using Halo.Core.Libraries; +using Halo.Core.Library; using Halo.Core.Users; using Microsoft.EntityFrameworkCore; @@ -26,6 +27,11 @@ public sealed class HaloContext(DbContextOptions options) : DbConte public DbSet Libraries => Set(); public DbSet Settings => Set(); + public DbSet MediaFiles => Set(); + public DbSet Movies => Set(); + public DbSet Series => Set(); + public DbSet Episodes => Set(); + /// Gilt für jede Zeitangabe im Modell, ohne sie einzeln zu nennen. /// /// @@ -90,5 +96,50 @@ public sealed class HaloContext(DbContextOptions options) : DbConte e.Property(x => x.Key).HasMaxLength(128); e.Property(x => x.Value).HasMaxLength(4096); }); + + b.Entity(e => + { + e.HasKey(x => x.Id); + // Ein Pfad je Bibliothek, und zwar zwingend: ohne diese Regel legt + // ein zweimal angestossener Scan jede Datei zweimal an, und die + // Mediathek wächst bei jedem Lauf. + e.HasIndex(x => new { x.LibraryId, x.Path }).IsUnique(); + e.HasIndex(x => x.MovieId); + e.HasIndex(x => x.EpisodeId); + e.Property(x => x.Path).HasMaxLength(1024); + e.HasOne().WithMany().HasForeignKey(x => x.LibraryId) + .OnDelete(DeleteBehavior.Cascade); + }); + + b.Entity(e => + { + e.HasKey(x => x.Id); + e.HasIndex(x => new { x.LibraryId, x.SortTitleValue }); + e.Property(x => x.Title).HasMaxLength(512); + e.Property(x => x.SortTitleValue).HasMaxLength(512); + e.HasOne().WithMany().HasForeignKey(x => x.LibraryId) + .OnDelete(DeleteBehavior.Cascade); + }); + + b.Entity(e => + { + e.HasKey(x => x.Id); + e.HasIndex(x => new { x.LibraryId, x.SortTitleValue }); + e.Property(x => x.Title).HasMaxLength(512); + e.Property(x => x.SortTitleValue).HasMaxLength(512); + e.HasOne().WithMany().HasForeignKey(x => x.LibraryId) + .OnDelete(DeleteBehavior.Cascade); + }); + + b.Entity(e => + { + e.HasKey(x => x.Id); + // Eine Folgennummer gibt es in einer Staffel genau einmal. Liegen + // zwei Dateien dafür vor, sind es zwei Fassungen derselben Folge — + // und die hängen beide an diesem einen Eintrag. + e.HasIndex(x => new { x.SeriesId, x.SeasonNumber, x.EpisodeNumber }).IsUnique(); + e.Property(x => x.Title).HasMaxLength(512); + e.HasOne().WithMany().HasForeignKey(x => x.SeriesId).OnDelete(DeleteBehavior.Cascade); + }); } } diff --git a/server/src/Halo.Data/LibraryScanner.cs b/server/src/Halo.Data/LibraryScanner.cs new file mode 100644 index 0000000..5d53c25 --- /dev/null +++ b/server/src/Halo.Data/LibraryScanner.cs @@ -0,0 +1,221 @@ +using Halo.Core.Libraries; +using Halo.Core.Library; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Halo.Data; + +public sealed record ScanReport( + int FilesFound, + int Added, + int Updated, + int Removed, + int Unrecognised, + IReadOnlyList Problems); + +/// Gleicht ab, was auf der Platte liegt, mit dem, was in der Datenbank steht. +/// +/// +/// +/// Der Scan ist beliebig oft wiederholbar. Zweimal laufen zu lassen +/// muss dasselbe Ergebnis haben wie einmal — sonst wächst die Mediathek bei +/// jedem Lauf. Dafür sorgt der eindeutige Index auf Bibliothek und Pfad und +/// das Nachschlagen statt Anlegen. +/// +/// +/// Weggeräumt wird über den Zeitstempel. Jede vorgefundene Datei +/// bekommt die Startzeit des Laufs eingetragen; was danach eine ältere Zeit +/// trägt, lag nicht mehr da. Das ist billiger, als sich die gesehenen Pfade zu +/// merken — jene Liste wüchse mit dem Bestand. +/// +/// +public sealed class LibraryScanner(HaloContext db, TimeProvider clock, ILogger log) +{ + public async Task ScanAsync(Guid libraryId, CancellationToken ct = default) + { + var bibliothek = await db.Libraries.FindAsync([libraryId], ct) + ?? throw new InvalidOperationException($"Keine Bibliothek mit der Kennung {libraryId}."); + + var laufZeit = clock.GetUtcNow(); + var ergebnis = LibraryWalker.Walk(bibliothek.Path); + + foreach (var problem in ergebnis.Problems) + log.LogWarning("Scan: {Pfad} übersprungen — {Grund}", problem.Path, problem.Reason); + + // In den Speicher geholt, weil beim Abgleich jede Datei dagegen + // nachschlägt. Einzeln zu fragen hiesse: eine Abfrage je Datei, und + // SQLite führt die nacheinander aus. + var bekannteDateien = await db.MediaFiles + .Where(f => f.LibraryId == libraryId) + .ToDictionaryAsync(f => f.Path, ct); + + var filme = bibliothek.Kind == LibraryKind.Movies + ? await db.Movies.Where(m => m.LibraryId == libraryId).ToListAsync(ct) + : []; + var serien = bibliothek.Kind == LibraryKind.Shows + ? await db.Series.Where(s => s.LibraryId == libraryId).ToListAsync(ct) + : []; + var folgen = bibliothek.Kind == LibraryKind.Shows + ? await db.Episodes.Where(f => serien.Select(s => s.Id).Contains(f.SeriesId)).ToListAsync(ct) + : []; + + int neu = 0, geändert = 0, unerkannt = 0; + + foreach (var datei in ergebnis.Files) + { + ct.ThrowIfCancellationRequested(); + + Guid? filmId = null, folgeId = null; + + if (bibliothek.Kind == LibraryKind.Movies) + { + var name = FileNames.ParseMovie(datei.Path); + filmId = FilmFinden(filme, libraryId, name).Id; + } + else + { + var name = FileNames.ParseEpisode(datei.Path); + if (name is null) + { + // **Nicht raten.** Eine erfundene Staffel 1 landete unter + // einer Serie, zu der die Datei nicht gehört, und dort + // fände sie niemand je wieder. + unerkannt++; + log.LogInformation("Scan: {Pfad} trägt keine Folgennummer und bleibt liegen.", datei.Path); + continue; + } + var serie = SerieFinden(serien, libraryId, name.SeriesTitle); + folgeId = FolgeFinden(folgen, serie.Id, name).Id; + } + + if (bekannteDateien.TryGetValue(datei.Path, out var bekannt)) + { + var vorher = (bekannt.SizeBytes, bekannt.ModifiedAt, bekannt.MovieId, bekannt.EpisodeId); + bekannt.SizeBytes = datei.SizeBytes; + bekannt.ModifiedAt = datei.ModifiedAt; + bekannt.MovieId = filmId; + bekannt.EpisodeId = folgeId; + bekannt.SeenAt = laufZeit; + if (vorher != (datei.SizeBytes, datei.ModifiedAt, filmId, folgeId)) geändert++; + } + else + { + db.MediaFiles.Add(new MediaFile + { + LibraryId = libraryId, + Path = datei.Path, + SizeBytes = datei.SizeBytes, + ModifiedAt = datei.ModifiedAt, + SeenAt = laufZeit, + MovieId = filmId, + EpisodeId = folgeId, + }); + neu++; + } + } + + 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); + + await VerwaisteAufraeumen(libraryId, ct); + + log.LogInformation( + "Scan von „{Name}“ fertig: {Gefunden} Dateien, {Neu} neu, {Geändert} geändert, {Entfernt} entfernt, {Unerkannt} unerkannt.", + bibliothek.Name, ergebnis.Files.Count, neu, geändert, entfernt, unerkannt); + + return new ScanReport(ergebnis.Files.Count, neu, geändert, entfernt, unerkannt, ergebnis.Problems); + } + + /// Löscht Titel, zu denen keine Datei mehr gehört. + /// + /// + /// In dieser Reihenfolge, und das ist keine Geschmacksfrage: erst Folgen + /// ohne Datei, dann Serien ohne Folgen. Andersherum bliebe eine Serie + /// stehen, deren letzte Folge im selben Lauf verschwindet. + /// + private async Task VerwaisteAufraeumen(Guid libraryId, CancellationToken ct) + { + await db.Movies + .Where(m => m.LibraryId == libraryId && !db.MediaFiles.Any(f => f.MovieId == m.Id)) + .ExecuteDeleteAsync(ct); + + await db.Episodes + .Where(f => db.Series.Any(s => s.Id == f.SeriesId && s.LibraryId == libraryId) + && !db.MediaFiles.Any(d => d.EpisodeId == f.Id)) + .ExecuteDeleteAsync(ct); + + await db.Series + .Where(s => s.LibraryId == libraryId && !db.Episodes.Any(f => f.SeriesId == s.Id)) + .ExecuteDeleteAsync(ct); + } + + /// Sucht den Film — oder legt ihn an. + /// + /// + /// Titel **und** Jahr entscheiden. Nur über den Titel zu gehen würfe die + /// beiden „Dune" von 1984 und 2021 zusammen; nur über die Datei zu gehen + /// machte aus 4K- und 1080p-Fassung zwei Filme. + /// + private Movie FilmFinden(List bekannt, Guid libraryId, MovieName name) + { + var treffer = bekannt.FirstOrDefault(m => + string.Equals(m.Title, name.Title, StringComparison.OrdinalIgnoreCase) && m.Year == name.Year); + if (treffer is not null) return treffer; + + var neu = new Movie + { + LibraryId = libraryId, + Title = name.Title, + SortTitleValue = SortTitle.For(name.Title), + Year = name.Year, + }; + db.Movies.Add(neu); + bekannt.Add(neu); + return neu; + } + + private Series SerieFinden(List bekannt, Guid libraryId, string titel) + { + var treffer = bekannt.FirstOrDefault(s => string.Equals(s.Title, titel, StringComparison.OrdinalIgnoreCase)); + if (treffer is not null) return treffer; + + var neu = new Series + { + LibraryId = libraryId, + Title = titel, + SortTitleValue = SortTitle.For(titel), + }; + db.Series.Add(neu); + bekannt.Add(neu); + return neu; + } + + private Episode FolgeFinden(List bekannt, Guid serieId, EpisodeName name) + { + var treffer = bekannt.FirstOrDefault(f => + f.SeriesId == serieId && f.SeasonNumber == name.Season && f.EpisodeNumber == name.Episode); + if (treffer is not null) + { + // Ein Folgentitel, der erst später auftaucht, wird nachgetragen — + // aber ein vorhandener nie überschrieben: er kann von TMDB kommen + // und ist dann besser als alles im Dateinamen. + treffer.Title ??= name.EpisodeTitle; + return treffer; + } + + var neu = new Episode + { + SeriesId = serieId, + SeasonNumber = name.Season, + EpisodeNumber = name.Episode, + Title = name.EpisodeTitle, + }; + db.Episodes.Add(neu); + bekannt.Add(neu); + return neu; + } +} diff --git a/server/src/Halo.Data/Migrations/20261003115256_Mediathek.Designer.cs b/server/src/Halo.Data/Migrations/20261003115256_Mediathek.Designer.cs new file mode 100644 index 0000000..6f8a33b --- /dev/null +++ b/server/src/Halo.Data/Migrations/20261003115256_Mediathek.Designer.cs @@ -0,0 +1,387 @@ +// +using System; +using Halo.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Halo.Data.Migrations +{ + [DbContext(typeof(HaloContext))] + [Migration("20261003115256_Mediathek")] + partial class Mediathek + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.12"); + + modelBuilder.Entity("Halo.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("Halo.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("Halo.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("Halo.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("Title") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId", "SeasonNumber", "EpisodeNumber") + .IsUnique(); + + b.ToTable("Episodes"); + }); + + modelBuilder.Entity("Halo.Core.Library.MediaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("EpisodeId") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("TEXT"); + + b.Property("ModifiedAt") + .HasColumnType("INTEGER"); + + b.Property("MovieId") + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("SeenAt") + .HasColumnType("INTEGER"); + + b.Property("SizeBytes") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("EpisodeId"); + + b.HasIndex("MovieId"); + + b.HasIndex("LibraryId", "Path") + .IsUnique(); + + b.ToTable("MediaFiles"); + }); + + modelBuilder.Entity("Halo.Core.Library.Movie", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AddedAt") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("TEXT"); + + b.Property("Overview") + .HasColumnType("TEXT"); + + b.Property("SortTitleValue") + .IsRequired() + .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.ToTable("Movies"); + }); + + modelBuilder.Entity("Halo.Core.Library.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AddedAt") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("TEXT"); + + b.Property("Overview") + .HasColumnType("TEXT"); + + b.Property("SortTitleValue") + .IsRequired() + .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.ToTable("Series"); + }); + + modelBuilder.Entity("Halo.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("Halo.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("Halo.Core.Auth.DeviceToken", b => + { + b.HasOne("Halo.Core.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Halo.Core.Library.Episode", b => + { + b.HasOne("Halo.Core.Library.Series", null) + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Halo.Core.Library.MediaFile", b => + { + b.HasOne("Halo.Core.Libraries.Library", null) + .WithMany() + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Halo.Core.Library.Movie", b => + { + b.HasOne("Halo.Core.Libraries.Library", null) + .WithMany() + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Halo.Core.Library.Series", b => + { + b.HasOne("Halo.Core.Libraries.Library", null) + .WithMany() + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/server/src/Halo.Data/Migrations/20261003115256_Mediathek.cs b/server/src/Halo.Data/Migrations/20261003115256_Mediathek.cs new file mode 100644 index 0000000..39470c2 --- /dev/null +++ b/server/src/Halo.Data/Migrations/20261003115256_Mediathek.cs @@ -0,0 +1,158 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Halo.Data.Migrations +{ + /// + public partial class Mediathek : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "MediaFiles", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + LibraryId = table.Column(type: "TEXT", nullable: false), + Path = table.Column(type: "TEXT", maxLength: 1024, nullable: false), + SizeBytes = table.Column(type: "INTEGER", nullable: false), + ModifiedAt = table.Column(type: "INTEGER", nullable: false), + SeenAt = table.Column(type: "INTEGER", nullable: false), + MovieId = table.Column(type: "TEXT", nullable: true), + EpisodeId = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_MediaFiles", x => x.Id); + table.ForeignKey( + name: "FK_MediaFiles_Libraries_LibraryId", + column: x => x.LibraryId, + principalTable: "Libraries", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "Movies", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + LibraryId = table.Column(type: "TEXT", nullable: false), + Title = table.Column(type: "TEXT", maxLength: 512, nullable: false), + SortTitleValue = table.Column(type: "TEXT", maxLength: 512, nullable: false), + Year = table.Column(type: "INTEGER", nullable: true), + Overview = table.Column(type: "TEXT", nullable: true), + TmdbId = table.Column(type: "INTEGER", nullable: true), + AddedAt = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Movies", x => x.Id); + table.ForeignKey( + name: "FK_Movies_Libraries_LibraryId", + column: x => x.LibraryId, + principalTable: "Libraries", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "Series", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + LibraryId = table.Column(type: "TEXT", nullable: false), + Title = table.Column(type: "TEXT", maxLength: 512, nullable: false), + SortTitleValue = table.Column(type: "TEXT", maxLength: 512, nullable: false), + Year = table.Column(type: "INTEGER", nullable: true), + Overview = table.Column(type: "TEXT", nullable: true), + TmdbId = table.Column(type: "INTEGER", nullable: true), + AddedAt = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Series", x => x.Id); + table.ForeignKey( + name: "FK_Series_Libraries_LibraryId", + column: x => x.LibraryId, + principalTable: "Libraries", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "Episodes", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + SeriesId = table.Column(type: "TEXT", nullable: false), + SeasonNumber = table.Column(type: "INTEGER", nullable: false), + EpisodeNumber = table.Column(type: "INTEGER", nullable: false), + Title = table.Column(type: "TEXT", maxLength: 512, nullable: true), + Overview = table.Column(type: "TEXT", nullable: true), + AddedAt = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Episodes", x => x.Id); + table.ForeignKey( + name: "FK_Episodes_Series_SeriesId", + column: x => x.SeriesId, + principalTable: "Series", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_Episodes_SeriesId_SeasonNumber_EpisodeNumber", + table: "Episodes", + columns: new[] { "SeriesId", "SeasonNumber", "EpisodeNumber" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_MediaFiles_EpisodeId", + table: "MediaFiles", + column: "EpisodeId"); + + migrationBuilder.CreateIndex( + name: "IX_MediaFiles_LibraryId_Path", + table: "MediaFiles", + columns: new[] { "LibraryId", "Path" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_MediaFiles_MovieId", + table: "MediaFiles", + column: "MovieId"); + + migrationBuilder.CreateIndex( + name: "IX_Movies_LibraryId_SortTitleValue", + table: "Movies", + columns: new[] { "LibraryId", "SortTitleValue" }); + + migrationBuilder.CreateIndex( + name: "IX_Series_LibraryId_SortTitleValue", + table: "Series", + columns: new[] { "LibraryId", "SortTitleValue" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Episodes"); + + migrationBuilder.DropTable( + name: "MediaFiles"); + + migrationBuilder.DropTable( + name: "Movies"); + + migrationBuilder.DropTable( + name: "Series"); + } + } +} diff --git a/server/src/Halo.Data/Migrations/HaloContextModelSnapshot.cs b/server/src/Halo.Data/Migrations/HaloContextModelSnapshot.cs index 9ac5ddc..071a28e 100644 --- a/server/src/Halo.Data/Migrations/HaloContextModelSnapshot.cs +++ b/server/src/Halo.Data/Migrations/HaloContextModelSnapshot.cs @@ -137,6 +137,156 @@ namespace Halo.Data.Migrations b.ToTable("Libraries"); }); + modelBuilder.Entity("Halo.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("Title") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId", "SeasonNumber", "EpisodeNumber") + .IsUnique(); + + b.ToTable("Episodes"); + }); + + modelBuilder.Entity("Halo.Core.Library.MediaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("EpisodeId") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("TEXT"); + + b.Property("ModifiedAt") + .HasColumnType("INTEGER"); + + b.Property("MovieId") + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("SeenAt") + .HasColumnType("INTEGER"); + + b.Property("SizeBytes") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("EpisodeId"); + + b.HasIndex("MovieId"); + + b.HasIndex("LibraryId", "Path") + .IsUnique(); + + b.ToTable("MediaFiles"); + }); + + modelBuilder.Entity("Halo.Core.Library.Movie", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AddedAt") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("TEXT"); + + b.Property("Overview") + .HasColumnType("TEXT"); + + b.Property("SortTitleValue") + .IsRequired() + .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.ToTable("Movies"); + }); + + modelBuilder.Entity("Halo.Core.Library.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AddedAt") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("TEXT"); + + b.Property("Overview") + .HasColumnType("TEXT"); + + b.Property("SortTitleValue") + .IsRequired() + .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.ToTable("Series"); + }); + modelBuilder.Entity("Halo.Core.ServerSetting", b => { b.Property("Key") @@ -192,6 +342,42 @@ namespace Halo.Data.Migrations .OnDelete(DeleteBehavior.Cascade) .IsRequired(); }); + + modelBuilder.Entity("Halo.Core.Library.Episode", b => + { + b.HasOne("Halo.Core.Library.Series", null) + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Halo.Core.Library.MediaFile", b => + { + b.HasOne("Halo.Core.Libraries.Library", null) + .WithMany() + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Halo.Core.Library.Movie", b => + { + b.HasOne("Halo.Core.Libraries.Library", null) + .WithMany() + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Halo.Core.Library.Series", b => + { + b.HasOne("Halo.Core.Libraries.Library", null) + .WithMany() + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); #pragma warning restore 612, 618 } } diff --git a/server/tests/Halo.Api.Tests/ScanTests.cs b/server/tests/Halo.Api.Tests/ScanTests.cs new file mode 100644 index 0000000..5f8cb7d --- /dev/null +++ b/server/tests/Halo.Api.Tests/ScanTests.cs @@ -0,0 +1,249 @@ +using System.Net; +using System.Net.Http.Json; +using Halo.Api.Endpoints; + +namespace Halo.Api.Tests; + +/// Scan gegen einen echten Ordner mit echten Dateien. +public class ScanTests +{ + private string _bestand = ""; + + [SetUp] + public void Anlegen() => _bestand = Directory.CreateDirectory( + Path.Combine(Path.GetTempPath(), "halo-medien-" + Guid.CreateVersion7().ToString("N"))).FullName; + + [TearDown] + public void Aufraeumen() + { + try { Directory.Delete(_bestand, recursive: true); } catch (IOException) { } + } + + private void Datei(string relativ, int bytes = 2048) + { + var voll = Path.Combine(_bestand, relativ); + Directory.CreateDirectory(Path.GetDirectoryName(voll)!); + File.WriteAllBytes(voll, new byte[bytes]); + } + + private async Task<(HttpClient Admin, Guid LibraryId)> MitBibliothek(HaloServer server, string art) + { + var admin = await server.SetUpAndSignIn(); + var client = server.AlsAngemeldeter(admin.Token); + var angelegt = await client.PostAsJsonAsync("/api/v1/libraries", + new CreateLibraryRequest("Prüfbestand", art, _bestand)); + angelegt.EnsureSuccessStatusCode(); + var bibliothek = (await angelegt.Content.ReadFromJsonAsync())!; + return (client, bibliothek.Id); + } + + /// Stösst an und wartet, bis der Hintergrundlauf fertig ist. + private static async Task Scannen(HttpClient client, Guid bibliothek) + { + var angestossen = await client.PostAsync($"/api/v1/libraries/{bibliothek}/scan", null); + Assert.That(angestossen.StatusCode, Is.EqualTo(HttpStatusCode.Accepted)); + + for (var versuch = 0; versuch < 100; versuch++) + { + var stand = await client.GetFromJsonAsync($"/api/v1/libraries/{bibliothek}/scan"); + if (stand!.State is "finished") return stand; + if (stand.State is "failed") Assert.Fail($"Der Scan ist gescheitert: {stand.Error}"); + await Task.Delay(50); + } + Assert.Fail("Der Scan wurde in fünf Sekunden nicht fertig."); + return null!; + } + + [Test] + public async Task Filme_werden_gefunden_und_benannt() + { + Datei("The.Matrix.1999.1080p.BluRay.x264-GRP.mkv"); + Datei("Der Untergang (2004).mkv"); + Datei("The Matrix (1999).srt"); // zählt nicht + Datei("@eaDir/The.Matrix.1999.mkv"); // zählt auch nicht + + using var server = new HaloServer(); + var (client, bibliothek) = await MitBibliothek(server, "Movies"); + + var stand = await Scannen(client, bibliothek); + Assert.That(stand.FilesFound, Is.EqualTo(2)); + Assert.That(stand.Added, Is.EqualTo(2)); + + var filme = await client.GetFromJsonAsync>("/api/v1/movies"); + Assert.That(filme!.Total, Is.EqualTo(2)); + // Sortiert ohne Artikel: „Matrix" vor „Untergang". + Assert.That(filme.Items.Select(m => m.Title), + Is.EqualTo(new[] { "The Matrix", "Der Untergang" })); + Assert.That(filme.Items[0].Year, Is.EqualTo(1999)); + } + + /// Zweimal scannen muss dasselbe ergeben wie einmal. + /// + /// + /// Sonst wächst die Mediathek bei jedem Lauf. Das ist der Fehler, + /// den man erst nach Wochen bemerkt — wenn jeder Film fünfmal dasteht. + /// + [Test] + public async Task Ein_zweiter_Lauf_aendert_nichts() + { + Datei("The Matrix (1999).mkv"); + Datei("Inception (2010).mkv"); + + using var server = new HaloServer(); + var (client, bibliothek) = await MitBibliothek(server, "Movies"); + + await Scannen(client, bibliothek); + var zweiter = await Scannen(client, bibliothek); + + Assert.That(zweiter.Added, Is.EqualTo(0)); + Assert.That(zweiter.Removed, Is.EqualTo(0)); + Assert.That((await client.GetFromJsonAsync>("/api/v1/movies"))!.Total, Is.EqualTo(2)); + } + + [Test] + public async Task Eine_geloeschte_Datei_verschwindet_aus_der_Mediathek() + { + Datei("The Matrix (1999).mkv"); + Datei("Inception (2010).mkv"); + + using var server = new HaloServer(); + var (client, bibliothek) = await MitBibliothek(server, "Movies"); + await Scannen(client, bibliothek); + + File.Delete(Path.Combine(_bestand, "Inception (2010).mkv")); + var danach = await Scannen(client, bibliothek); + + Assert.That(danach.Removed, Is.EqualTo(1)); + var filme = await client.GetFromJsonAsync>("/api/v1/movies"); + Assert.That(filme!.Items.Select(m => m.Title), Is.EqualTo(new[] { "The Matrix" })); + } + + /// 4K und 1080p sind ein Film mit zwei Dateien, nicht zwei Filme. + [Test] + public async Task Zwei_Fassungen_desselben_Films_bleiben_ein_Eintrag() + { + Datei("The Matrix (1999) - 1080p.mkv"); + Datei("The Matrix (1999) - 2160p.mkv"); + + using var server = new HaloServer(); + var (client, bibliothek) = await MitBibliothek(server, "Movies"); + await Scannen(client, bibliothek); + + var filme = await client.GetFromJsonAsync>("/api/v1/movies"); + Assert.That(filme!.Total, Is.EqualTo(1)); + Assert.That(filme.Items[0].Files, Has.Count.EqualTo(2)); + } + + [Test] + public async Task Serien_werden_in_Staffeln_und_Folgen_zerlegt() + { + Datei("Dark/Staffel 1/Dark - S01E01 - Geheimnisse.mkv"); + Datei("Dark/Staffel 1/Dark - S01E02 - Lügen.mkv"); + Datei("Dark/Staffel 2/Dark - S02E01 - Anfänge.mkv"); + Datei("Notruf.Hafenkante.S01E01.German.1080p.mkv"); + + using var server = new HaloServer(); + var (client, bibliothek) = await MitBibliothek(server, "Shows"); + await Scannen(client, bibliothek); + + var serien = await client.GetFromJsonAsync>("/api/v1/series"); + Assert.That(serien!.Total, Is.EqualTo(2)); + + var dark = serien.Items.Single(s => s.Title == "Dark"); + Assert.That(dark.EpisodeCount, Is.EqualTo(3)); + Assert.That(dark.Seasons, Is.EqualTo(new[] { 1, 2 })); + + var folgen = await client.GetFromJsonAsync>($"/api/v1/series/{dark.Id}/episodes"); + Assert.That(folgen!.Items.Select(f => f.Title), + Is.EqualTo(new[] { "Geheimnisse", "Lügen", "Anfänge" })); + + var nurStaffel2 = await client.GetFromJsonAsync>( + $"/api/v1/series/{dark.Id}/episodes?season=2"); + Assert.That(nurStaffel2!.Items, Has.Count.EqualTo(1)); + } + + /// Was keine Folgennummer trägt, wird nicht geraten. + [Test] + public async Task Dateien_ohne_Folgennummer_werden_gezaehlt_aber_nicht_zugeordnet() + { + Datei("Dark/Dark - S01E01.mkv"); + Datei("Dark/Irgendein Mitschnitt.mkv"); + + using var server = new HaloServer(); + var (client, bibliothek) = await MitBibliothek(server, "Shows"); + var stand = await Scannen(client, bibliothek); + + Assert.That(stand.FilesFound, Is.EqualTo(2)); + Assert.That(stand.Unrecognised, Is.EqualTo(1)); + Assert.That(stand.Added, Is.EqualTo(1)); + } + + [Test] + public async Task Die_Suche_grenzt_ein() + { + Datei("The Matrix (1999).mkv"); + Datei("The Matrix Reloaded (2003).mkv"); + Datei("Inception (2010).mkv"); + + using var server = new HaloServer(); + var (client, bibliothek) = await MitBibliothek(server, "Movies"); + await Scannen(client, bibliothek); + + var treffer = await client.GetFromJsonAsync>("/api/v1/movies?search=matrix"); + Assert.That(treffer!.Total, Is.EqualTo(2)); + } + + /// Eine Anfrage darf sich die Seitengrösse nicht selbst aussuchen. + [Test] + public async Task Die_Seitengroesse_ist_gedeckelt() + { + for (var i = 1; i <= 5; i++) Datei($"Film {i} (200{i}).mkv"); + + using var server = new HaloServer(); + var (client, bibliothek) = await MitBibliothek(server, "Movies"); + await Scannen(client, bibliothek); + + var seite = await client.GetFromJsonAsync>("/api/v1/movies?take=100000"); + Assert.That(seite!.Total, Is.EqualTo(5), "Die Gesamtzahl bleibt richtig"); + Assert.That(seite.Items, Has.Count.LessThanOrEqualTo(200)); + + var zweiteSeite = await client.GetFromJsonAsync>("/api/v1/movies?skip=2&take=2"); + Assert.That(zweiteSeite!.Items, Has.Count.EqualTo(2)); + Assert.That(zweiteSeite.Total, Is.EqualTo(5)); + } + + [Test] + public async Task Vor_dem_ersten_Lauf_steht_der_Scan_auf_untaetig() + { + using var server = new HaloServer(); + var (client, bibliothek) = await MitBibliothek(server, "Movies"); + + var stand = await client.GetFromJsonAsync($"/api/v1/libraries/{bibliothek}/scan"); + Assert.That(stand!.State, Is.EqualTo("idle")); + } + + [Test] + public async Task Ein_gewoehnlicher_Benutzer_stoesst_keinen_Scan_an() + { + using var server = new HaloServer(); + var (admin, bibliothek) = await MitBibliothek(server, "Movies"); + await admin.PostAsJsonAsync("/api/v1/users", + new CreateUserRequest("gast", "auch-ein-langes-passwort", false)); + var gast = await server.SignIn("gast", "auch-ein-langes-passwort", "iPad", "g-9"); + + var versuch = await server.AlsAngemeldeter(gast.Token) + .PostAsync($"/api/v1/libraries/{bibliothek}/scan", null); + + Assert.That(versuch.StatusCode, Is.EqualTo(HttpStatusCode.Forbidden)); + } + + [Test] + public async Task Eine_Bibliothek_die_es_nicht_gibt_laesst_sich_nicht_scannen() + { + using var server = new HaloServer(); + var (client, _) = await MitBibliothek(server, "Movies"); + + var versuch = await client.PostAsync($"/api/v1/libraries/{Guid.CreateVersion7()}/scan", null); + Assert.That(versuch.StatusCode, Is.EqualTo(HttpStatusCode.NotFound)); + } +} diff --git a/server/tests/Halo.Core.Tests/FileNameTests.cs b/server/tests/Halo.Core.Tests/FileNameTests.cs new file mode 100644 index 0000000..af6a470 --- /dev/null +++ b/server/tests/Halo.Core.Tests/FileNameTests.cs @@ -0,0 +1,167 @@ +using Halo.Core.Library; + +namespace Halo.Core.Tests; + +public class FilmNamenTests +{ + [TestCase("The Matrix (1999).mkv", "The Matrix", 1999)] + [TestCase("The.Matrix.1999.1080p.BluRay.x264-GROUP.mkv", "The Matrix", 1999)] + [TestCase("The Matrix 1999 2160p UHD BluRay REMUX HDR.mkv", "The Matrix", 1999)] + [TestCase("Der.Untergang.2004.German.1080p.BluRay.x264-ENCOUNTERS.mkv", "Der Untergang", 2004)] + [TestCase("Das Boot (1981) [1080p].mp4", "Das Boot", 1981)] + [TestCase("Inception.2010.EXTENDED.1080p.BluRay.DTS-HD.MA.5.1.x264.mkv", "Inception", 2010)] + [TestCase("Spirited Away.m4v", "Spirited Away", null)] + [TestCase("Männer.die.auf.Ziegen.starren.2009.German.DL.1080p.mkv", "Männer die auf Ziegen starren", 2009)] + public void Titel_und_Jahr_werden_erkannt(string datei, string titel, int? jahr) + { + var name = FileNames.ParseMovie(datei); + Assert.That(name.Title, Is.EqualTo(titel)); + Assert.That(name.Year, Is.EqualTo(jahr)); + } + + /// Eine Zahl im Titel ist kein Jahr. + /// + /// + /// Der Fall, an dem sich die Regel entscheidet. „2049" sähe wie ein + /// Jahr aus; es liegt nur weit in der Zukunft. Ohne diese Grenze hiesse der + /// Film „Blade Runner", erschienen 2049 — und keine Metadatenquelle der + /// Welt findet ihn so. + /// + [TestCase("Blade Runner 2049 (2017).mkv", "Blade Runner 2049", 2017)] + [TestCase("Blade.Runner.2049.2017.1080p.mkv", "Blade Runner 2049", 2017)] + [TestCase("2012 (2009).mkv", "2012", 2009)] + [TestCase("1917 (2019).mkv", "1917", 2019)] + [TestCase("300 (2006).mkv", "300", 2006)] + public void Zahlen_im_Titel_werden_nicht_fuer_Jahre_gehalten(string datei, string titel, int jahr) + { + var name = FileNames.ParseMovie(datei); + Assert.That(name.Title, Is.EqualTo(titel)); + Assert.That(name.Year, Is.EqualTo(jahr)); + } + + /// Wer von Hand benennt, meint seine Punkte so. + [Test] + public void Punkte_in_einem_von_Hand_benannten_Titel_bleiben_stehen() + { + Assert.That(FileNames.ParseMovie("S.W.A.T. (2003).mkv").Title, Is.EqualTo("S.W.A.T.")); + Assert.That(FileNames.ParseMovie("Wall-E (2008).mkv").Title, Is.EqualTo("Wall-E")); + } + + [Test] + public void Ohne_erkennbares_Muster_bleibt_der_Dateiname_stehen() + { + // Lieber ein unschöner Titel als ein leerer: so ist der Film wenigstens + // zu finden und von Hand zu berichtigen. + Assert.That(FileNames.ParseMovie("irgendwas.mkv").Title, Is.EqualTo("irgendwas")); + Assert.That(FileNames.ParseMovie("1080p.mkv").Title, Is.EqualTo("1080p")); + } +} + +public class FolgenNamenTests +{ + [TestCase("Notruf.Hafenkante.S01E01.German.1080p.WEB-DL.x264-GRP.mkv", "Notruf Hafenkante", 1, 1)] + [TestCase("Notruf Hafenkante - S01E01 - Der Fall.mkv", "Notruf Hafenkante", 1, 1)] + [TestCase("Breaking Bad S05E14 Ozymandias.mkv", "Breaking Bad", 5, 14)] + [TestCase("The.Wire.s02e05.1080p.mkv", "The Wire", 2, 5)] + [TestCase("Dark - 1x03 - Gestern und heute.mkv", "Dark", 1, 3)] + [TestCase("Tatort.01x09.mkv", "Tatort", 1, 9)] + [TestCase("Show S1E2.mkv", "Show", 1, 2)] + [TestCase("Serie.S01.E07.1080p.mkv", "Serie", 1, 7)] + public void Serie_Staffel_und_Folge_werden_erkannt(string datei, string serie, int staffel, int folge) + { + var name = FileNames.ParseEpisode(datei); + Assert.That(name, Is.Not.Null, $"„{datei}“ wurde gar nicht als Folge erkannt"); + Assert.That(name!.SeriesTitle, Is.EqualTo(serie)); + Assert.That(name.Season, Is.EqualTo(staffel)); + Assert.That(name.Episode, Is.EqualTo(folge)); + } + + [Test] + public void Der_Folgentitel_wird_mitgenommen_wenn_einer_dasteht() + { + var name = FileNames.ParseEpisode("Notruf Hafenkante - S01E01 - Der Fall.mkv"); + Assert.That(name!.EpisodeTitle, Is.EqualTo("Der Fall")); + } + + [Test] + public void Technik_hinter_der_Nummer_ist_kein_Folgentitel() + { + var name = FileNames.ParseEpisode("Breaking.Bad.S05E14.1080p.BluRay.x264-GRP.mkv"); + Assert.That(name!.EpisodeTitle, Is.Null); + } + + /// Doppelfolgen zählen ab der ersten. + [Test] + public void Eine_Doppelfolge_wird_der_ersten_Nummer_zugeordnet() + { + var name = FileNames.ParseEpisode("Serie.S02E05E06.mkv"); + Assert.That(name!.Season, Is.EqualTo(2)); + Assert.That(name.Episode, Is.EqualTo(5)); + } + + /// Ohne Nummer wird nichts geraten. + /// + /// + /// Eine erfundene Staffel 1 wäre schlimmer als gar keine Angabe: der Titel + /// landete unter einer Serie, zu der er nicht gehört, und niemand fände ihn + /// dort je wieder. + /// + [TestCase("The Matrix (1999).mkv")] + [TestCase("Irgendein Film.mkv")] + [TestCase("Dokumentation ueber 1999.mkv")] + public void Ohne_Folgennummer_gibt_es_keine_Folge(string datei) + { + Assert.That(FileNames.ParseEpisode(datei), Is.Null); + } + + /// Eine Auflösung ist keine Folgennummer. + [Test] + public void Die_Aufloesung_wird_nicht_fuer_eine_Folge_gehalten() + { + // „1920x1080" sähe in der Kreuzschreibweise wie Staffel 1920 aus. + Assert.That(FileNames.ParseEpisode("Film.1920x1080.mkv"), Is.Null); + } +} + +public class MedienDateienTests +{ + [TestCase("film.mkv", true)] + [TestCase("film.MP4", true)] + [TestCase("film.m2ts", true)] + [TestCase("film.nfo", false)] + [TestCase("film.srt", false)] + [TestCase("poster.jpg", false)] + [TestCase("Thumbs.db", false)] + [TestCase(".DS_Store", false)] + [TestCase(".versteckt.mkv", false)] + public void Nur_Videodateien_zaehlen(string name, bool erwartet) + { + Assert.That(MediaExtensions.IsVideo(name), Is.EqualTo(erwartet)); + } + + /// Ein laufender Download ist noch kein Film. + /// + /// + /// Ihn aufzunehmen hiesse: ein Titel erscheint, verschwindet wieder, und + /// wer ihn dazwischen antippt, sieht einen Film, der mittendrin abbricht. + /// + [TestCase("film.mkv.part")] + [TestCase("film.mkv.!qb")] + [TestCase("film.mkv.crdownload")] + public void Unfertige_Dateien_zaehlen_nicht(string name) + { + Assert.That(MediaExtensions.IsVideo(name), Is.False); + } + + /// @eaDir enthält Kopien jeder Datei — mitscannen verdoppelt die Mediathek. + [TestCase("@eaDir", true)] + [TestCase("#recycle", true)] + [TestCase(".git", true)] + [TestCase("Extras", true)] + [TestCase("Season 01", false)] + [TestCase("Filme", false)] + public void Bestimmte_Ordner_werden_uebersprungen(string name, bool erwartet) + { + Assert.That(MediaExtensions.IsSkippedDirectory(name), Is.EqualTo(erwartet)); + } +} diff --git a/server/tests/Halo.Core.Tests/LibraryWalkerTests.cs b/server/tests/Halo.Core.Tests/LibraryWalkerTests.cs new file mode 100644 index 0000000..47c3698 --- /dev/null +++ b/server/tests/Halo.Core.Tests/LibraryWalkerTests.cs @@ -0,0 +1,146 @@ +using Halo.Core.Library; + +namespace Halo.Core.Tests; + +public class LibraryWalkerTests +{ + private string _wurzel = ""; + + [SetUp] + public void Anlegen() => _wurzel = Directory.CreateDirectory( + Path.Combine(Path.GetTempPath(), "halo-bestand-" + Guid.CreateVersion7().ToString("N"))).FullName; + + [TearDown] + public void Aufraeumen() + { + foreach (var d in Directory.EnumerateDirectories(_wurzel, "*", SearchOption.AllDirectories)) + File.SetUnixFileMode(d, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + try { Directory.Delete(_wurzel, recursive: true); } catch (IOException) { } + } + + private void Datei(string relativ, int bytes = 1024) + { + var voll = Path.Combine(_wurzel, relativ); + Directory.CreateDirectory(Path.GetDirectoryName(voll)!); + File.WriteAllBytes(voll, new byte[bytes]); + } + + private string[] GefundeneNamen(WalkResult ergebnis) => + ergebnis.Files.Select(f => Path.GetFileName(f.Path)).Order().ToArray(); + + [Test] + public void Videodateien_werden_gefunden_Beiwerk_nicht() + { + Datei("Filme/The Matrix (1999).mkv"); + Datei("Filme/The Matrix (1999).srt"); + Datei("Filme/poster.jpg"); + Datei("Filme/The Matrix (1999).nfo"); + + Assert.That(GefundeneNamen(LibraryWalker.Walk(_wurzel)), Is.EqualTo(new[] { "The Matrix (1999).mkv" })); + } + + [Test] + public void Auch_tief_verschachtelt_wird_gefunden() + { + Datei("Serien/Dark/Staffel 1/Dark - S01E01.mkv"); + Datei("Serien/Dark/Staffel 2/Dark - S02E01.mkv"); + + Assert.That(LibraryWalker.Walk(_wurzel).Files, Has.Count.EqualTo(2)); + } + + /// Der Fund, der eine Mediathek sonst verdoppelt. + /// + /// + /// @eaDir ist Synologys Vorschauablage und enthält eine Kopie jeder + /// Datei. Mit SearchOption.AllDirectories — der bequemen + /// Fassung — liefe der Scan hinein und fände jeden Film zweimal, einmal + /// davon als unbrauchbare Kopie. Genau deshalb steigt der Walker von Hand + /// ab. + /// + [Test] + public void Vorschauablagen_und_Papierkoerbe_werden_uebersprungen() + { + Datei("Filme/The Matrix (1999).mkv"); + Datei("Filme/@eaDir/The Matrix (1999).mkv/SYNOPHOTO_THUMB.mkv"); + Datei("Filme/#recycle/Alter Film (2001).mkv"); + Datei("Filme/.versteckt/Noch einer (2002).mkv"); + Datei("Filme/Extras/Making Of.mkv"); + + Assert.That(GefundeneNamen(LibraryWalker.Walk(_wurzel)), Is.EqualTo(new[] { "The Matrix (1999).mkv" })); + } + + [Test] + public void Groesse_und_Aenderungszeit_kommen_mit() + { + Datei("Filme/Film (2000).mkv", bytes: 4096); + var gefunden = LibraryWalker.Walk(_wurzel).Files.Single(); + + Assert.That(gefunden.SizeBytes, Is.EqualTo(4096)); + Assert.That(gefunden.ModifiedAt, Is.GreaterThan(DateTimeOffset.UtcNow.AddMinutes(-5))); + } + + /// Ein gesperrter Ordner darf nicht den ganzen Lauf verderben. + /// + /// + /// Sonst entschiede ein einzelner falsch gesetzter Unterordner darüber, ob + /// überhaupt etwas gefunden wird — und die Mediathek bliebe leer, ohne dass + /// jemand sagen könnte, warum. + /// + [Test] + public void Ein_gesperrter_Ordner_wird_vermerkt_und_uebersprungen() + { + if (OperatingSystem.IsWindows()) Assert.Ignore("Dateirechte verhalten sich dort anders."); + + Datei("Filme/Erreichbar (2000).mkv"); + var gesperrt = Directory.CreateDirectory(Path.Combine(_wurzel, "Filme", "gesperrt")).FullName; + File.WriteAllBytes(Path.Combine(gesperrt, "Versteckt (2001).mkv"), new byte[10]); + File.SetUnixFileMode(gesperrt, UnixFileMode.None); + + var ergebnis = LibraryWalker.Walk(_wurzel); + + Assert.That(GefundeneNamen(ergebnis), Is.EqualTo(new[] { "Erreichbar (2000).mkv" })); + Assert.That(ergebnis.Problems, Has.Count.EqualTo(1)); + Assert.That(ergebnis.Problems[0].Path, Is.EqualTo(gesperrt)); + } + + [Test] + public void Ein_Verzeichnis_das_es_nicht_gibt_wird_gemeldet_statt_zu_werfen() + { + var ergebnis = LibraryWalker.Walk(Path.Combine(_wurzel, "gibtesnicht")); + + Assert.That(ergebnis.Files, Is.Empty); + Assert.That(ergebnis.Problems, Has.Count.EqualTo(1)); + } + + [Test] + public void Ein_leerer_Bestand_ist_kein_Fehler() + { + var ergebnis = LibraryWalker.Walk(_wurzel); + Assert.That(ergebnis.Files, Is.Empty); + Assert.That(ergebnis.Problems, Is.Empty); + } +} + +public class SortTitleTests +{ + [TestCase("Der Untergang", "Untergang")] + [TestCase("Die Welle", "Welle")] + [TestCase("Das Boot", "Boot")] + [TestCase("The Matrix", "Matrix")] + [TestCase("A Quiet Place", "Quiet Place")] + [TestCase("An American Werewolf", "American Werewolf")] + [TestCase("Blade Runner", "Blade Runner")] + [TestCase("Theater", "Theater")] + public void Der_Artikel_faellt_fuers_Sortieren_weg(string titel, string erwartet) + { + Assert.That(SortTitle.For(titel), Is.EqualTo(erwartet)); + } + + /// Ein Titel, der nur aus einem Artikel besteht, bleibt stehen. + [TestCase("Die")] + [TestCase("The")] + public void Ein_blosser_Artikel_bleibt_als_Titel_erhalten(string titel) + { + Assert.That(SortTitle.For(titel), Is.EqualTo(titel)); + } +}