Stufe 2a: Der Bestand wird gelesen, nicht mehr nur verwaltet
Dateinamen erkennen, Verzeichnisse durchlaufen, abgleichen, blättern. Noch ohne TMDB — die Titel kommen vorerst aus den Dateinamen, und genau daran entscheidet sich später, ob die Metadatenquelle etwas findet. **Der Dateiname ist die Stelle, an der Medienserver in der Praxis scheitern.** Was auf der Platte liegt, heisst nicht „Der Fall", sondern `Notruf.Hafenkante.S01E01.German.1080p.WEB-DL.x264-GRP.mkv`. Das Verfahren schneidet ab dem ersten Technik-Merkmal ab, statt einzelne Tags herauszulösen: Letzteres klingt gründlicher, lässt aber bei jedem unbekannten Tag Bruchstücke stehen — und unbekannte Tags gibt es immer. Drei Fälle, an denen sich die Regeln entscheiden, alle geprüft: - **„Blade Runner 2049" behält seine Zahl.** Eine Jahreszahl muss plausibel sein; was weit in der Zukunft liegt, gehört zum Titel. Ohne diese Grenze hiesse der Film „Blade Runner", erschienen 2049, und keine Metadatenquelle fände ihn so. - **„S.W.A.T." behält seine Punkte.** Trennpunkte werden nur dort zu Leerzeichen, wo sonst keine sind — stehen welche im Namen, hat jemand von Hand benannt, und seine Punkte sind gewollt. (Der Schlusspunkt fiel erst einem Test auf.) - **Ohne Folgennummer wird nichts geraten.** Eine erfundene Staffel 1 landete unter einer Serie, zu der die Datei nicht gehört. **`@eaDir` ist der Grund, warum der Walker von Hand absteigt.** Synologys Vorschauablage enthält eine Kopie *jeder* Datei; mit `SearchOption.AllDirectories` — der bequemen Fassung — stünde jeder Film zweimal in der Mediathek, zur Hälfte als unbrauchbare Kopie. Äste beschneiden kann nur, wer selbst absteigt. Ein gesperrter Unterordner bricht den Lauf nicht ab, sondern wird vermerkt. **Die Datei zeigt auf den Titel, nicht umgekehrt.** Sieht herumgedreht aus und ist Absicht: ein Film liegt oft als 4K *und* als 1080p vor. 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. Geprüft. **Der Scan läuft im Hintergrund**, nicht in der Anfrage: ein Bestand mit 40 000 Titeln braucht Minuten, und eine so lange offene Anfrage läuft hinter jedem Reverse Proxy ins Zeitlimit. Einer je Bibliothek, sonst löschen sich zwei Läufe gegenseitig die Einträge. Aufgeräumt wird über den Zeitstempel des Laufs — billiger, als sich alle gesehenen Pfade zu merken. Zweimal scannen ergibt dasselbe wie einmal. Das ist der Fehler, den man sonst erst nach Wochen bemerkt, wenn jeder Film fünfmal dasteht. 131 Tests grün (88 Kern, 43 Schnittstelle). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
156c740330
commit
78b7e3bcb8
@@ -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);
|
||||
|
||||
/// <summary>Macht aus einem Dateinamen wieder Titel, Jahr, Staffel und Folge.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Hier scheitern Medienserver in der Praxis.</b> Was auf der Platte liegt,
|
||||
/// heisst nicht „Der Fall", sondern
|
||||
/// <c>Notruf.Hafenkante.S01E01.German.1080p.WEB-DL.x264-GRP.mkv</c>, 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Das Verfahren: abschneiden, nicht herauslösen.</b> 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static partial class FileNames
|
||||
{
|
||||
/// <summary>Merkmale, ab denen nur noch Technik kommt.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// Nicht vollständig und kann es nicht sein. Es muss nur das erste treffen
|
||||
/// — alles dahinter fällt ohnehin weg.
|
||||
/// </remarks>
|
||||
private static readonly HashSet<string> 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",
|
||||
};
|
||||
|
||||
/// <summary>Auflösungen wie 1080p, 2160p, 720i — und 4K.</summary>
|
||||
[GeneratedRegex(@"^\d{3,4}[pi]$|^[48]k$", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex Auflösung();
|
||||
|
||||
/// <summary>S01E02, s1e2, S01.E02, S01E02E03 — die erste Folge zählt.</summary>
|
||||
[GeneratedRegex(@"\bS(?<staffel>\d{1,3})[\s._-]*E(?<folge>\d{1,4})", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex StaffelUndFolge();
|
||||
|
||||
/// <summary>1x02, 01x02 — die ältere Schreibweise.</summary>
|
||||
[GeneratedRegex(@"(?<![\dx])(?<staffel>\d{1,2})x(?<folge>\d{2,3})(?![\dx])", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex KreuzSchreibweise();
|
||||
|
||||
/// <summary>Ein Jahr in Klammern: (1999) oder [1999].</summary>
|
||||
[GeneratedRegex(@"[\(\[](?<jahr>(?:18|19|20)\d{2})[\)\]]")]
|
||||
private static partial Regex JahrInKlammern();
|
||||
|
||||
[GeneratedRegex(@"\s+")]
|
||||
private static partial Regex Leerraum();
|
||||
|
||||
/// <summary>Liest Titel und Jahr aus einem Filmdateinamen.</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>Liest Serie, Staffel, Folge und Folgentitel — oder <c>null</c>.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <c>null</c> 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.
|
||||
/// </remarks>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>Nimmt Worte, bis das erste Beiwerk oder eine Jahreszahl kommt.</summary>
|
||||
private static (string Titel, int? Jahr) TitelBis(string[] worte, int grenze)
|
||||
{
|
||||
var genommen = new List<string>();
|
||||
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));
|
||||
}
|
||||
|
||||
/// <summary>Vier Ziffern allein machen noch kein Jahr.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>„Blade Runner 2049" ist der Fall, an dem sich das entscheidet.</b>
|
||||
/// Eine Obergrenze knapp über heute trennt eine Jahreszahl von einer Zahl
|
||||
/// im Titel — alles, was weit in der Zukunft liegt, gehört zum Namen.
|
||||
/// </remarks>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Dateiname ohne Pfad und ohne Endung.</summary>
|
||||
private static string Rohname(string dateiname) => Path.GetFileNameWithoutExtension(dateiname);
|
||||
|
||||
/// <summary>Zerlegt in Worte und holt ein eingeklammertes Jahr heraus.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Punkte werden nur dann zu Leerzeichen, wenn sonst keine da sind.</b>
|
||||
/// „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.
|
||||
/// </remarks>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>Räumt Trennzeichen weg, die am Rand übrig bleiben.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Der Punkt steht ausdrücklich nicht dabei.</b> 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.
|
||||
/// </remarks>
|
||||
private static string Säubern(string roh) =>
|
||||
Leerraum().Replace(roh.Replace('_', ' '), " ").Trim(' ', '-', '–', ',', '[', ']', '(', ')');
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
namespace Halo.Core.Library;
|
||||
|
||||
/// <summary>Eine gefundene Datei, so wie das Dateisystem sie beschreibt.</summary>
|
||||
public sealed record ScannedFile(string Path, long SizeBytes, DateTimeOffset ModifiedAt);
|
||||
|
||||
/// <summary>Was beim Durchlaufen schiefging, ohne den Lauf abzubrechen.</summary>
|
||||
public sealed record WalkProblem(string Path, string Reason);
|
||||
|
||||
public sealed record WalkResult(IReadOnlyList<ScannedFile> Files, IReadOnlyList<WalkProblem> Problems);
|
||||
|
||||
/// <summary>Läuft ein Bibliotheksverzeichnis ab und sammelt Videodateien.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Von Hand rekursiv statt mit <c>AllDirectories</c>.</b> Die bequeme
|
||||
/// Fassung läuft auch in <c>@eaDir</c> hinein — Synologys Vorschauablage, die
|
||||
/// eine Kopie <i>jeder</i> Datei enthält. Wer sie mitnimmt, hat seine
|
||||
/// Mediathek doppelt, zur Hälfte mit unbrauchbaren Dateien. Äste beschneiden
|
||||
/// kann nur, wer selbst absteigt.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Ein gesperrtes Unterverzeichnis bricht den Lauf nicht ab</b>, 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class LibraryWalker
|
||||
{
|
||||
/// <summary>Tiefer als das ist kein Bestand, sondern eine Schleife.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// Verweise im Dateisystem können im Kreis zeigen. Ohne Grenze liefe der
|
||||
/// Scan, bis der Arbeitsspeicher voll ist.
|
||||
/// </remarks>
|
||||
private const int MaximaleTiefe = 12;
|
||||
|
||||
public static WalkResult Walk(string wurzel)
|
||||
{
|
||||
var gefunden = new List<ScannedFile>();
|
||||
var probleme = new List<WalkProblem>();
|
||||
|
||||
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<ScannedFile> gefunden, List<WalkProblem> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
namespace Halo.Core.Library;
|
||||
|
||||
/// <summary>Eine Datei auf der Platte, so wie der Scan sie vorgefunden hat.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Die Datei zeigt auf den Titel, nicht umgekehrt.</b> 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Genau dasselbe gilt für Folgen.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class MediaFile
|
||||
{
|
||||
public Guid Id { get; init; } = Guid.CreateVersion7();
|
||||
public required Guid LibraryId { get; init; }
|
||||
|
||||
/// <summary>Der Pfad, wie der Server ihn sieht — im Container, nicht auf dem Wirt.</summary>
|
||||
public required string Path { get; set; }
|
||||
|
||||
public long SizeBytes { get; set; }
|
||||
public DateTimeOffset ModifiedAt { get; set; }
|
||||
|
||||
/// <summary>Wann der letzte Scan diese Datei noch vorgefunden hat.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
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; }
|
||||
|
||||
/// <summary>Ohne führenden Artikel — siehe <see cref="SortTitle"/>.</summary>
|
||||
public required string SortTitleValue { get; set; }
|
||||
|
||||
public int? Year { get; set; }
|
||||
public string? Overview { get; set; }
|
||||
|
||||
/// <summary>Die Kennung bei TMDB, sobald zugeordnet.</summary>
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
namespace Halo.Core.Library;
|
||||
|
||||
/// <summary>Welche Dateien überhaupt als Medien gelten.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Eine Erlaubnisliste, keine Verbotsliste.</b> 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.
|
||||
/// </remarks>
|
||||
public static class MediaExtensions
|
||||
{
|
||||
private static readonly HashSet<string> Video = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
".mkv", ".mp4", ".m4v", ".avi", ".mov", ".ts", ".m2ts", ".mpg", ".mpeg",
|
||||
".wmv", ".flv", ".webm", ".vob", ".iso", ".divx", ".ogv", ".3gp",
|
||||
};
|
||||
|
||||
/// <summary>Was unterwegs oder unvollständig ist und deshalb nicht zählt.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
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));
|
||||
}
|
||||
|
||||
/// <summary>Verzeichnisse, die in einem Bestand nichts Abspielbares enthalten.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// `@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.
|
||||
/// </remarks>
|
||||
private static readonly HashSet<string> Ü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));
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
namespace Halo.Core.Library;
|
||||
|
||||
/// <summary>Der Titel, nach dem sortiert wird — ohne den Artikel davor.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
public static class SortTitle
|
||||
{
|
||||
/// <summary>Deutsch und Englisch — die beiden Sprachen, in denen Bestände hier vorliegen.</summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user