Eine Mediathek für alles, was in keinem Katalog steht

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 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-08 21:36:06 +02:00
co-authored by Claude Opus 5
parent af2b844d65
commit c13d1e78d2
7 changed files with 240 additions and 5 deletions
@@ -470,7 +470,7 @@ public static class ManagementEndpoints
bibliotheken.MapPost("/", async (CreateLibraryRequest anfrage, FlaroContext db) =>
{
if (!Enum.TryParse<LibraryKind>(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
+29 -1
View File
@@ -1,6 +1,34 @@
namespace Flaro.Core.Libraries;
public enum LibraryKind { Movies, Shows }
public enum LibraryKind
{
/// <summary>Filme, mit Abgleich bei der Metadatenquelle.</summary>
Movies,
/// <summary>Serien mit Staffeln und Folgen.</summary>
Shows,
/// <summary>Einzelne Videos, ohne Abgleich.</summary>
///
/// <remarks>
/// <para>
/// <b>Für alles, was in keinem Katalog steht.</b> 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.
/// </para>
/// <para>
/// <b>Der Unterordner wird zur Kategorie.</b> 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.
/// </para>
/// </remarks>
Videos,
}
/// <summary>Ein Verzeichnis mit Medien, wie der Betreiber es eingerichtet hat.</summary>
///
+114 -1
View File
@@ -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;
/// <summary>Ein Video: ein Eintrag je Datei, benannt wie die Datei.</summary>
///
/// <remarks>
/// <b>Je Datei einer, auch bei gleichem Namen.</b> 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.
///
/// <b>`MatchLocked`, damit die Anreicherung die Finger davon lässt.</b>
/// Der Merker heisst „von Hand festgelegt" und bedeutet hier dasselbe:
/// dieser Titel wird nicht bei einer Quelle nachgeschlagen.
/// </remarks>
/// <summary>Macht aus den Ordnernamen Kategorien.</summary>
///
/// <remarks>
/// <b>Statt einer eigenen Gruppierung.</b> 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.
/// </remarks>
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<Movie> 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;
}
/// <summary>Der erste Ordner unter der Wurzel — oder <c>null</c>, wenn die Datei direkt dort liegt.</summary>
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;
}
/// <summary>Welches Video in welchem Ordner liegt — wird nach dem Lauf zu Genres.</summary>
private readonly Dictionary<Guid, string> _videoordner = [];
private Movie FilmFinden(List<Movie> bekannt, Guid libraryId, MovieName name)
{
var treffer = bekannt.FirstOrDefault(m =>
@@ -101,6 +101,12 @@ public sealed class MetadataEnricher(
string? nurSprache = null, IReadOnlySet<Guid>? 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 —
@@ -561,6 +561,84 @@ public class MetadataTests
}
/// <summary>Eine Videomediathek nimmt den Dateinamen und fragt niemanden.</summary>
///
/// <remarks>
/// <para>
/// <b>Für alles, was in keinem Katalog steht.</b> 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.
/// </para>
/// <para>
/// <b>Der Unterordner wird zur Kategorie</b> — damit greifen Filter und
/// Kategorienansicht, ohne dass es dort etwas Neues gibt.
/// </para>
/// </remarks>
[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<Page<MovieResponse>>("/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<List<string>>(
$"/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");
}
/// <summary>Zwei gleichnamige Dateien in zwei Ordnern sind zwei Einträge.</summary>
///
/// <remarks>
/// 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.
/// </remarks>
[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<Page<MovieResponse>>("/api/v1/movies");
Assert.That(filme!.Items, Has.Count.EqualTo(2),
"zwei Dateien gleichen Namens in zwei Ordnern sind zwei Aufnahmen");
}
/// <summary>Eine abgehakte Gruppe verlässt die Liste — und kommt zurück, wenn eine Datei dazukommt.</summary>
///
/// <remarks>