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',