From c13d1e78d2f8e935379a00dd2786b2b174016369 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Thu, 8 Oct 2026 21:36:06 +0200 Subject: [PATCH] =?UTF-8?q?Eine=20Mediathek=20f=C3=BCr=20alles,=20was=20in?= =?UTF-8?q?=20keinem=20Katalog=20steht?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Urlaubsaufnahmen, Mitschnitte, Vorträge. Jedes Verfahren, das bei Filmen richtig ist, wäre hier falsch: „Sommer 2019 Teil 2" ist kein Film von 2019, zwei Aufnahmen desselben Tages sind nicht zwei Fassungen derselben Sache, und in keinem Katalog steht, was da liegt. Die neue Art `Videos` nimmt den Dateinamen als Titel, zerlegt nichts, legt nichts zusammen und fragt niemanden. Der Pfad ist dabei der Vergleichsschlüssel, nicht der Name — zwei Dateien „Ausflug.mp4" in zwei Ordnern sind zwei Ausflüge. **Der Unterordner wird zur Kategorie.** Statt eine eigene Gruppierung zu bauen, bekommt jedes Video den Namen seines Ordners als Genre. Damit greifen Filter, Kategorienansicht und Suche, ohne dass es in der Oberfläche etwas Neues gibt. Eine zweite Gruppierung daneben hiesse: zwei Wege für dasselbe, und einer davon wäre überall sonst ungepflegt. Die Anreicherung übergeht jetzt alles, was `MatchLocked` trägt. Das galt bisher nur für von Hand korrigierte Zuordnungen und gilt damit auch hier — eine Suche nach „Abend am Feuer" fände irgendeinen fremden Film, und der stünde danach mit Plakat und Beschreibung in der Urlaubsmediathek. Geprüft: vier Dateien in zwei Ordnern ergeben vier Titel mit ihren Dateinamen, keine Jahre, keinen fremden Treffer — und genau zwei Kategorien, denn was direkt in der Wurzel liegt, bekommt keine. 742 Tests grün, davon zwei neue. Co-Authored-By: Claude Opus 5 --- .../Endpoints/ManagementEndpoints.cs | 2 +- server/src/Flaro.Core/Libraries/Library.cs | 30 ++++- server/src/Flaro.Data/LibraryScanner.cs | 115 +++++++++++++++++- server/src/Flaro.Data/MetadataEnricher.cs | 6 + server/tests/Flaro.Api.Tests/MetadataTests.cs | 78 ++++++++++++ web/app/src/api.ts | 2 +- .../src/views/einstellungen/mediatheken.ts | 12 +- 7 files changed, 240 insertions(+), 5 deletions(-) diff --git a/server/src/Flaro.Api/Endpoints/ManagementEndpoints.cs b/server/src/Flaro.Api/Endpoints/ManagementEndpoints.cs index a16ff14..04db682 100644 --- a/server/src/Flaro.Api/Endpoints/ManagementEndpoints.cs +++ b/server/src/Flaro.Api/Endpoints/ManagementEndpoints.cs @@ -470,7 +470,7 @@ public static class ManagementEndpoints bibliotheken.MapPost("/", async (CreateLibraryRequest anfrage, FlaroContext db) => { if (!Enum.TryParse(anfrage.Kind, ignoreCase: true, out var art)) - return Einwand.Bitte($"Unbekannte Art „{anfrage.Kind}“. Möglich: Movies, Shows."); + return Einwand.Bitte($"Unbekannte Art „{anfrage.Kind}“. Möglich: Movies, Shows, Videos."); // **Jetzt prüfen, nicht beim ersten Scan.** Ein Tippfehler im Pfad // führte sonst zu einer Bibliothek, die dauerhaft leer bleibt, und diff --git a/server/src/Flaro.Core/Libraries/Library.cs b/server/src/Flaro.Core/Libraries/Library.cs index 3b2b52f..c93a2e4 100644 --- a/server/src/Flaro.Core/Libraries/Library.cs +++ b/server/src/Flaro.Core/Libraries/Library.cs @@ -1,6 +1,34 @@ namespace Flaro.Core.Libraries; -public enum LibraryKind { Movies, Shows } +public enum LibraryKind +{ + /// Filme, mit Abgleich bei der Metadatenquelle. + Movies, + + /// Serien mit Staffeln und Folgen. + Shows, + + /// Einzelne Videos, ohne Abgleich. + /// + /// + /// + /// Für alles, was in keinem Katalog steht. Urlaubsaufnahmen, + /// Mitschnitte, Vorträge. Der Dateiname ist der Titel — kein Zerlegen + /// nach „Titel (Jahr)", kein Zusammenlegen zweier Dateien zu einer + /// Fassung, keine Anfrage bei TMDB. Jedes dieser Verfahren ist bei + /// Filmen richtig und hier falsch: „Sommer 2019 Teil 2" ist kein Film + /// von 2019, und zwei Aufnahmen desselben Tages sind nicht zwei + /// Fassungen derselben Sache. + /// + /// + /// Der Unterordner wird zur Kategorie. Statt eine eigene + /// Gruppierung zu bauen, bekommt jedes Video den Namen seines Ordners + /// als Genre. Damit greifen Filter, Kategorienansicht und Suche ohne + /// eine einzige neue Zeile in der Oberfläche. + /// + /// + Videos, +} /// Ein Verzeichnis mit Medien, wie der Betreiber es eingerichtet hat. /// diff --git a/server/src/Flaro.Data/LibraryScanner.cs b/server/src/Flaro.Data/LibraryScanner.cs index a3380d8..5aca053 100644 --- a/server/src/Flaro.Data/LibraryScanner.cs +++ b/server/src/Flaro.Data/LibraryScanner.cs @@ -149,7 +149,17 @@ public sealed class LibraryScanner( Guid? filmId = null, folgeId = null; - if (bibliothek.Kind == LibraryKind.Movies) + if (bibliothek.Kind == LibraryKind.Videos) + { + // **Der Dateiname ist der Titel, und sonst nichts.** + // + // Kein Zerlegen nach „Titel (Jahr)": „Sommer 2019 Teil 2" ist + // kein Film von 2019. Kein Zusammenlegen: zwei Aufnahmen + // desselben Tages sind nicht zwei Fassungen derselben Sache. + // Und kein Abgleich — in keinem Katalog steht, was hier liegt. + filmId = VideoAnlegen(filme, libraryId, bibliothek.Path, datei.Path).Id; + } + else if (bibliothek.Kind == LibraryKind.Movies) { var name = FileNames.ParseMovie(datei.Path); filmId = FilmFinden(filme, libraryId, name).Id; @@ -305,6 +315,7 @@ public sealed class LibraryScanner( } await db.SaveChangesAsync(ct); + await VideoordnerAlsKategorienAsync(ct); // **Was dieser Lauf nicht gesehen hat, wird markiert — nicht gelöscht.** // Eine umbenannte Festplatte, ein Tippfehler im Pfad, ein Laufwerk im @@ -433,6 +444,108 @@ public sealed class LibraryScanner( private DateTimeOffset _laufZeit; + /// Ein Video: ein Eintrag je Datei, benannt wie die Datei. + /// + /// + /// Je Datei einer, auch bei gleichem Namen. Bei Filmen sucht der + /// Scanner einen vorhandenen Eintrag und hängt die Datei dort an — das + /// ist der Weg, auf dem zwei Fassungen zusammenfinden. Hier wäre das + /// falsch: zwei Dateien „Ausflug.mp4" in zwei Ordnern sind zwei + /// Ausflüge. Der Pfad entscheidet, nicht der Name. + /// + /// `MatchLocked`, damit die Anreicherung die Finger davon lässt. + /// Der Merker heisst „von Hand festgelegt" und bedeutet hier dasselbe: + /// dieser Titel wird nicht bei einer Quelle nachgeschlagen. + /// + /// Macht aus den Ordnernamen Kategorien. + /// + /// + /// Statt einer eigenen Gruppierung. Die Mediathek kann schon nach + /// Genre filtern und hat einen Reiter „Kategorien" — den Ordner als + /// Genre zu führen, bringt beides geschenkt. Eine zweite Gruppierung + /// daneben hiesse: zwei Wege, dasselbe zu tun, und einer davon wäre + /// überall sonst ungepflegt. + /// + private async Task VideoordnerAlsKategorienAsync(CancellationToken ct) + { + if (_videoordner.Count == 0) return; + + var namen = _videoordner.Values.Distinct().ToList(); + var vorhanden = await db.Genres.Where(g => namen.Contains(g.Name)).ToListAsync(ct); + + foreach (var name in namen.Where(n => vorhanden.All(g => g.Name != n))) + { + var neu = new Genre { Name = name }; + db.Genres.Add(neu); + vorhanden.Add(neu); + } + + await db.SaveChangesAsync(ct); + + var filme = _videoordner.Keys.ToList(); + var schonDa = await db.GenreLinks + .Where(l => l.MovieId != null && filme.Contains(l.MovieId.Value)) + .ToListAsync(ct); + + foreach (var (filmId, ordner) in _videoordner) + { + var genre = vorhanden.First(g => g.Name == ordner); + if (schonDa.Any(l => l.MovieId == filmId && l.GenreId == genre.Id)) continue; + + db.GenreLinks.Add(new GenreLink { GenreId = genre.Id, MovieId = filmId }); + } + + await db.SaveChangesAsync(ct); + _videoordner.Clear(); + } + + private Movie VideoAnlegen(List bekannt, Guid libraryId, string wurzel, string pfad) + { + var treffer = bekannt.FirstOrDefault(m => m.ParsedTitle == pfad); + if (treffer is not null) return treffer; + + var titel = Path.GetFileNameWithoutExtension(pfad); + + var neu = new Movie + { + LibraryId = libraryId, + // **Der Pfad als Vergleichsschlüssel.** Er ist eindeutig; der + // Name ist es nicht. Angezeigt wird er nie — dafür ist `Title` da. + ParsedTitle = pfad, + ParsedYear = null, + Title = titel, + SortTitleValue = SortTitle.For(titel), + Year = null, + MatchLocked = true, + TitlePinned = true, + PublishAt = _pruefungNoetig ? null : _laufZeit, + }; + + db.Movies.Add(neu); + bekannt.Add(neu); + + // Der Ordner unter der Wurzel wird zur Kategorie — damit greifen + // Filter und Kategorienansicht, ohne dass es dort etwas Neues gibt. + var ordner = Ordnername(wurzel, pfad); + if (ordner is not null) _videoordner[neu.Id] = ordner; + + return neu; + } + + /// Der erste Ordner unter der Wurzel — oder null, wenn die Datei direkt dort liegt. + private static string? Ordnername(string wurzel, string pfad) + { + var relativ = Path.GetRelativePath(wurzel, Path.GetDirectoryName(pfad) ?? wurzel); + if (relativ is "." or "") return null; + + var erster = relativ.Split(Path.DirectorySeparatorChar, StringSplitOptions.RemoveEmptyEntries) + .FirstOrDefault(); + return string.IsNullOrWhiteSpace(erster) ? null : erster; + } + + /// Welches Video in welchem Ordner liegt — wird nach dem Lauf zu Genres. + private readonly Dictionary _videoordner = []; + private Movie FilmFinden(List bekannt, Guid libraryId, MovieName name) { var treffer = bekannt.FirstOrDefault(m => diff --git a/server/src/Flaro.Data/MetadataEnricher.cs b/server/src/Flaro.Data/MetadataEnricher.cs index e7a66fc..6df0a4e 100644 --- a/server/src/Flaro.Data/MetadataEnricher.cs +++ b/server/src/Flaro.Data/MetadataEnricher.cs @@ -101,6 +101,12 @@ public sealed class MetadataEnricher( string? nurSprache = null, IReadOnlySet? nurDiese = null, bool pruefungNoetig = false) { var offen = await db.Movies + // **Was von Hand festgelegt ist, wird nicht nachgeschlagen.** + // Das gilt für korrigierte Zuordnungen — und für eine + // Videomediathek, in der nichts nachzuschlagen ist: dort steht + // der Dateiname als Titel, und jede Suche danach fände + // irgendeinen fremden Film. + .Where(m => !m.MatchLocked) .Where(m => m.LibraryId == libraryId && (erneut || m.TmdbId == null) // **Beim Sprachlauf nur Zugeordnetes.** Ein Titel ohne // Kennung hat keine Sprache, weil er keine Metadaten hat — diff --git a/server/tests/Flaro.Api.Tests/MetadataTests.cs b/server/tests/Flaro.Api.Tests/MetadataTests.cs index b3eba45..915c36f 100644 --- a/server/tests/Flaro.Api.Tests/MetadataTests.cs +++ b/server/tests/Flaro.Api.Tests/MetadataTests.cs @@ -561,6 +561,84 @@ public class MetadataTests } + /// Eine Videomediathek nimmt den Dateinamen und fragt niemanden. + /// + /// + /// + /// Für alles, was in keinem Katalog steht. Urlaubsaufnahmen, + /// Mitschnitte, Vorträge. Jedes der Verfahren, die bei Filmen richtig + /// sind, wäre hier falsch: „Sommer 2019 Teil 2" ist kein Film von 2019, + /// und zwei Aufnahmen desselben Tages sind nicht zwei Fassungen + /// derselben Sache. + /// + /// + /// Der Unterordner wird zur Kategorie — damit greifen Filter und + /// Kategorienansicht, ohne dass es dort etwas Neues gibt. + /// + /// + [Test] + public async Task Eine_Videomediathek_nimmt_den_Dateinamen_als_Titel() + { + Datei("Ferien 2019/Ausflug zum See.mp4"); + Datei("Ferien 2019/Abend am Feuer.mp4"); + Datei("Vortraege/Wie Dateisysteme altern.mp4"); + Datei("Direkt daneben.mp4"); + + // Die Quelle würde antworten — gefragt werden darf sie trotzdem nicht. + var anbieter = new VorgegebenerAnbieter + { + Film = new MovieMetadata(999, "Irgendein fremder Film", 1999, null, null, null), + }; + + using var server = MitAnbieter(anbieter); + var (client, bibliothek) = await Einrichten(server, "Videos"); + await Scannen(client, bibliothek); + + var filme = await client.GetFromJsonAsync>("/api/v1/movies"); + var titel = filme!.Items.Select(f => f.Title).ToList(); + + Assert.Multiple(() => + { + Assert.That(titel, Is.EquivalentTo(new[] + { + "Ausflug zum See", "Abend am Feuer", "Wie Dateisysteme altern", "Direkt daneben", + })); + Assert.That(filme.Items.All(f => f.Year is null), Is.True, + "der Ordnername Ferien 2019 ist kein Erscheinungsjahr"); + Assert.That(titel, Has.No.Member("Irgendein fremder Film"), + "die Metadatenquelle wird hier nicht gefragt"); + }); + + var kategorien = await client.GetFromJsonAsync>( + $"/api/v1/libraries/{bibliothek}/genres"); + + Assert.That(kategorien, Is.EquivalentTo(new[] { "Ferien 2019", "Vortraege" }), + "die Unterordner werden zu Kategorien; was direkt in der Wurzel liegt, bekommt keine"); + } + + /// Zwei gleichnamige Dateien in zwei Ordnern sind zwei Einträge. + /// + /// + /// Bei Filmen sucht der Scanner einen vorhandenen Eintrag und hängt die + /// Datei dort an — so finden zwei Fassungen zusammen. Hier wäre das + /// falsch: zwei „Ausflug.mp4" in zwei Ordnern sind zwei Ausflüge. + /// + [Test] + public async Task Gleichnamige_Videos_werden_nicht_zusammengelegt() + { + Datei("Mai/Ausflug.mp4"); + Datei("Juni/Ausflug.mp4"); + + using var server = MitAnbieter(new VorgegebenerAnbieter()); + var (client, bibliothek) = await Einrichten(server, "Videos"); + await Scannen(client, bibliothek); + + var filme = await client.GetFromJsonAsync>("/api/v1/movies"); + + Assert.That(filme!.Items, Has.Count.EqualTo(2), + "zwei Dateien gleichen Namens in zwei Ordnern sind zwei Aufnahmen"); + } + /// Eine abgehakte Gruppe verlässt die Liste — und kommt zurück, wenn eine Datei dazukommt. /// /// diff --git a/web/app/src/api.ts b/web/app/src/api.ts index a365f41..eab208f 100644 --- a/web/app/src/api.ts +++ b/web/app/src/api.ts @@ -135,7 +135,7 @@ export type Angefangen = { export type Bibliothek = { id: string; name: string; /** „Movies" oder „Shows" — so nennt der Server es. */ - kind: 'Movies' | 'Shows'; + kind: 'Movies' | 'Shows' | 'Videos'; path: string; /** `null` heisst „nicht gezählt", nicht „leer". */ itemCount: number | null; diff --git a/web/app/src/views/einstellungen/mediatheken.ts b/web/app/src/views/einstellungen/mediatheken.ts index b882f75..2ca899f 100644 --- a/web/app/src/views/einstellungen/mediatheken.ts +++ b/web/app/src/views/einstellungen/mediatheken.ts @@ -23,7 +23,17 @@ export function bibliotheksbereich( { name: 'name', beschriftung: 'Name', platzhalter: 'etwa Filme' }, { name: 'art', beschriftung: 'Art', - auswahl: [{ wert: 'Movies', text: 'Filme' }, { wert: 'Shows', text: 'Serien' }], + auswahl: [ + { wert: 'Movies', text: 'Filme' }, + { wert: 'Shows', text: 'Serien' }, + { wert: 'Videos', text: 'Videos (ohne Abgleich)' }, + ], + // **Was „ohne Abgleich" heisst, bevor man es auswählt.** Hinterher + // wäre es eine Überraschung: keine Plakate, keine Beschreibungen, + // und der Dateiname steht als Titel da. + hinweis: 'Videos: der Dateiname ist der Titel, es wird nichts bei ' + + 'TMDB nachgeschlagen und nichts zusammengelegt. Der Unterordner ' + + 'wird zur Kategorie. Für Urlaubsaufnahmen, Mitschnitte, Vorträge.', }, { name: 'pfad', beschriftung: 'Ordner', art: 'ordner',