Beiwerk im Medienordner, und eine Zuordnung lässt sich korrigieren

Plakate, Hintergründe und NFO-Dateien landen jetzt neben dem Film — und eine
falsche Zuordnung lässt sich richtigstellen, ohne dass der nächste Scan sie
wieder überschreibt.

    Der Untergang (2004)/
    ├── Der Untergang (2004).mkv
    ├── poster.jpg
    ├── fanart.jpg
    └── movie.nfo

Bei Serien liegt `tvshow.nfo` eine Ebene über dem Staffelordner, je Folge
kommen NFO und Standbild daneben. Liegen mehrere Filme in einem Ordner, heisst
das Beiwerk `Titel (Jahr)-poster.jpg` — sonst überschrieben sie sich
gegenseitig.

**Die Erlaubnisliste ist der ganze Schutz.** Damit das geht, muss die
Medieneinhängung ohne `:ro` auskommen, und damit könnte der Server dort alles
anstellen. Dass er es nicht tut, hängt an `SidecarNames`: sechs Arten von
Datei, nichts sonst. Ein Name, der nicht daraufsteht, wirft — laut, nicht
still, denn das wäre ein Fehler im Programm. Mediendateien werden nie
geändert, umbenannt oder gelöscht; es gibt keine Stelle, die das könnte.
Geprüft an echten Dateien, einschliesslich der Versuche mit `../`, mit
absoluten Pfaden und mit einer Mediendatei als Ziel.

Standardmässig **aus**. In fremde Medienordner zu schreiben ist nichts, was
ein Server ungefragt tun sollte.

**Gelesen wird auch ohne Schreiberlaubnis.** Eine vorhandene NFO ist eine
Zuordnung, die jemand getroffen hat — Halo nimmt sie und sucht gar nicht
erst. Dasselbe gilt für Ordnernamen der Form `[tmdbid-613]`. Halo liest sie
und benennt **nichts** um: Umbenennen ist der einzige Eingriff, der eine
Bibliothek wirklich zerlegen kann.

Nebenbei: eine NFO aus dem Medienordner hat Halo nicht geschrieben. DTDs sind
beim Lesen abgeschaltet — eine untergeschobene Datei mit externer Entität
könnte sonst den Server dazu bringen, `/config/halo.db` einzulesen und als
Titel auszuliefern. Mit Prüfung.

---

**Der Fehler, den erst der echte Lauf zeigte** — und er ist die zweite
Auflage eines schon einmal behobenen:

Nach der Korrektur stand im Ordner „Dark (2017)", in der Datenbank aber 2004.
Der nächste Scan suchte nach *(Dark, 2017)*, fand nichts, legte den Film neu
an und löschte den korrigierten als verwaist. Überlebt hat die Zuordnung nur,
weil sie in der NFO stand — das System hat sich selbst gerettet, aber der
Eintrag war ein anderer und das Jahr wieder falsch.

Dieselbe Klasse wie bei `ParsedTitle`: zugeordnet wurde über einen Wert, den
die Metadatenquelle verändert. Jetzt gibt es `ParsedYear`, und das ändert sich
nie. Mit Prüfung, die genau diesen Ablauf festhält.

---

Dazu von TMDB: die IMDb-Kennung wird nachgeschlagen, damit sie in der NFO
steht — wer später auf ein anderes Werkzeug wechselt, findet denselben Titel
wieder, auch wenn das TMDB gar nicht kennt.

308 Tests grün. Im Container nachgewiesen: Zuordnung falsch, korrigiert,
zweiter Scan — derselbe Eintrag, dieselbe Kennung, Jahr bleibt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-03 19:01:26 +02:00
co-authored by Claude Opus 5
parent 79b6001f86
commit f98b995050
30 changed files with 2911 additions and 39 deletions
@@ -120,3 +120,15 @@ public sealed record DeliveryInfo(
string PackageState,
double Progress,
string? Error);
// --- Zuordnung korrigieren -----------------------------------------------
/// <summary>Ein Vorschlag, aus dem gewählt werden kann.</summary>
public sealed record MatchCandidate(
int TmdbId, string Title, int? Year, string? Overview, string? PosterUrl);
public sealed record ApplyMatchRequest(int TmdbId);
/// <summary>Wie die Zuordnung jetzt aussieht.</summary>
public sealed record MatchState(
int? TmdbId, string? ImdbId, bool Locked, string Title, int? Year);
@@ -0,0 +1,88 @@
using Halo.Core.Library;
using Halo.Core.Metadata;
using Halo.Data;
using Microsoft.EntityFrameworkCore;
namespace Halo.Api.Endpoints;
/// <summary>Eine falsche Zuordnung von Hand richtigstellen.</summary>
///
/// <remarks>
/// <para>
/// <b>Weil Raten manchmal danebengeht.</b> Die Suche nach „Dark" lieferte
/// „Dark Shadows" von 1966 — ohne Jahresangabe gewinnt bei TMDB der
/// beliebteste Treffer. Dagegen hilft keine bessere Heuristik, sondern nur
/// die Möglichkeit, es richtigzustellen.
/// </para>
/// <para>
/// <b>Eine Korrektur wird festgeschrieben.</b> In der Datenbank <i>und</i> in
/// der NFO-Datei neben dem Film, mit <c>lockdata</c>. Danach sucht kein Lauf
/// mehr danach — und ein Neuaufbau der Datenbank oder ein Umzug auf einen
/// anderen Server findet die Entscheidung wieder.
/// </para>
/// </remarks>
public static class MatchEndpoints
{
public static void MapMatching(this IEndpointRouteBuilder app)
{
app.MapGet("/api/v1/movies/{id:guid}/matches", async (
Guid id, HaloContext db, IMetadataProvider anbieter, string? query, int? year) =>
{
var film = await db.Movies.AsNoTracking().FirstOrDefaultAsync(m => m.Id == id);
if (film is null) return Results.NotFound();
// Ohne eigene Suchworte das, was aus dem Dateinamen gelesen wurde
// — nicht der angezeigte Titel, der ja gerade der falsche ist.
var vorschläge = await anbieter.SearchMoviesAsync(
string.IsNullOrWhiteSpace(query) ? film.ParsedTitle : query.Trim(), year ?? film.Year);
return Results.Ok(vorschläge.Select(v =>
new MatchCandidate(v.ProviderId, v.Title, v.Year, v.Overview, v.Poster?.Url)).ToList());
}).RequireAuthorization(HaloPolicies.Administrator);
app.MapPut("/api/v1/movies/{id:guid}/match", async (
Guid id, ApplyMatchRequest anfrage, HaloContext db,
IMetadataProvider anbieter, MatchApplier anwender) =>
{
var film = await db.Movies.FirstOrDefaultAsync(m => m.Id == id);
if (film is null) return Results.NotFound();
var gefunden = await anbieter.GetMovieAsync(anfrage.TmdbId);
if (gefunden is null)
return Results.Problem($"TMDB kennt den Film {anfrage.TmdbId} nicht.",
statusCode: StatusCodes.Status404NotFound);
await anwender.ApplyAsync(film, gefunden);
return Results.Ok(new MatchState(film.TmdbId, film.ImdbId, film.MatchLocked, film.Title, film.Year));
}).RequireAuthorization(HaloPolicies.Administrator);
app.MapGet("/api/v1/series/{id:guid}/matches", async (
Guid id, HaloContext db, IMetadataProvider anbieter, string? query, int? year) =>
{
var serie = await db.Series.AsNoTracking().FirstOrDefaultAsync(s => s.Id == id);
if (serie is null) return Results.NotFound();
var vorschläge = await anbieter.SearchSeriesAsync(
string.IsNullOrWhiteSpace(query) ? serie.ParsedTitle : query.Trim(), year ?? serie.Year);
return Results.Ok(vorschläge.Select(v =>
new MatchCandidate(v.ProviderId, v.Title, v.Year, v.Overview, v.Poster?.Url)).ToList());
}).RequireAuthorization(HaloPolicies.Administrator);
app.MapPut("/api/v1/series/{id:guid}/match", async (
Guid id, ApplyMatchRequest anfrage, HaloContext db,
IMetadataProvider anbieter, MatchApplier anwender) =>
{
var serie = await db.Series.FirstOrDefaultAsync(s => s.Id == id);
if (serie is null) return Results.NotFound();
var gefunden = await anbieter.GetSeriesAsync(anfrage.TmdbId);
if (gefunden is null)
return Results.Problem($"TMDB kennt die Serie {anfrage.TmdbId} nicht.",
statusCode: StatusCodes.Status404NotFound);
await anwender.ApplyAsync(serie, gefunden);
return Results.Ok(new MatchState(serie.TmdbId, serie.ImdbId, serie.MatchLocked, serie.Title, serie.Year));
}).RequireAuthorization(HaloPolicies.Administrator);
}
}
+127
View File
@@ -0,0 +1,127 @@
using Halo.Core.Library;
using Halo.Core.Metadata;
using Halo.Data;
using Microsoft.EntityFrameworkCore;
namespace Halo.Api;
/// <summary>Schreibt eine korrigierte Zuordnung überallhin, wo sie hingehört.</summary>
///
/// <remarks>
/// <b>An drei Stellen, und alle drei sind nötig.</b> In die Datenbank, damit
/// es sofort stimmt. In die NFO-Datei, damit es einen Neuaufbau und einen
/// Umzug übersteht. Und als Sperre, damit kein automatischer Lauf die
/// Entscheidung wieder überstimmt.
/// </remarks>
public sealed class MatchApplier(
HaloContext db,
IMetadataProvider anbieter,
IImageStore bilder,
IImageReader bildLeser,
ISidecarStore beiwerk,
ILogger<MatchApplier> log)
{
public async Task ApplyAsync(Movie film, MovieMetadata gefunden, CancellationToken ct = default)
{
film.TmdbId = gefunden.ProviderId;
film.ImdbId = gefunden.ImdbId;
film.Title = gefunden.Title;
film.SortTitleValue = SortTitle.For(gefunden.Title);
film.Year = gefunden.Year ?? film.Year;
film.Overview = gefunden.Overview;
film.PosterImage = await Ablegen(gefunden.Poster, ct);
film.BackdropImage = await Ablegen(gefunden.Backdrop, ct);
film.MatchLocked = true;
await db.SaveChangesAsync(ct);
var datei = await db.MediaFiles.AsNoTracking().FirstOrDefaultAsync(f => f.MovieId == film.Id, ct);
if (datei is null || !beiwerk.Enabled) return;
var allein = beiwerk.IsAloneInFolder(datei.Path);
await BildNeben(datei.Path, Name(allein, datei.Path, SidecarNames.MoviePoster), film.PosterImage, ct);
await BildNeben(datei.Path, Name(allein, datei.Path, SidecarNames.MovieBackdrop), film.BackdropImage, ct);
await beiwerk.WriteTextAsync(datei.Path, SidecarNames.MovieNfo, Nfo.Write(new MovieNfo(
film.Title, film.Year, film.Overview, new ProviderIds(film.TmdbId, film.ImdbId), true)), ct);
log.LogInformation("Zuordnung von Hand gesetzt: „{Titel}“ → TMDB {Kennung}", film.Title, film.TmdbId);
}
public async Task ApplyAsync(Series serie, SeriesMetadata gefunden, CancellationToken ct = default)
{
serie.TmdbId = gefunden.ProviderId;
serie.ImdbId = gefunden.ImdbId;
serie.Title = gefunden.Title;
serie.SortTitleValue = SortTitle.For(gefunden.Title);
serie.Year = gefunden.Year ?? serie.Year;
serie.Overview = gefunden.Overview;
serie.PosterImage = await Ablegen(gefunden.Poster, ct);
serie.BackdropImage = await Ablegen(gefunden.Backdrop, ct);
serie.MatchLocked = true;
// **Die Folgen gehörten zur alten Serie.** Ihre Beschreibungen und
// Standbilder sind jetzt falsch; sie bleiben stehen, bis sie durch die
// der richtigen Serie ersetzt sind.
var folgen = await db.Episodes.Where(f => f.SeriesId == serie.Id)
.OrderBy(f => f.SeasonNumber).ThenBy(f => f.EpisodeNumber).ToListAsync(ct);
foreach (var folge in folgen)
{
ct.ThrowIfCancellationRequested();
var neu = await anbieter.FindEpisodeAsync(gefunden.ProviderId, folge.SeasonNumber, folge.EpisodeNumber, ct);
folge.Title = neu?.Title;
folge.Overview = neu?.Overview;
folge.StillImage = await Ablegen(neu?.Still, ct);
}
await db.SaveChangesAsync(ct);
if (!beiwerk.Enabled) return;
await SerienBeiwerk(serie, folgen, ct);
log.LogInformation("Zuordnung von Hand gesetzt: „{Titel}“ → TMDB {Kennung}, {Anzahl} Folgen neu geholt",
serie.Title, serie.TmdbId, folgen.Count);
}
private async Task SerienBeiwerk(Series serie, List<Episode> folgen, CancellationToken ct)
{
var folgenKennungen = folgen.Select(f => f.Id).ToList();
var dateien = await db.MediaFiles.AsNoTracking()
.Where(d => d.EpisodeId != null && folgenKennungen.Contains(d.EpisodeId!.Value))
.ToListAsync(ct);
if (dateien.Count == 0) return;
var staffel = Path.GetDirectoryName(dateien[0].Path);
var serienOrdner = staffel is null ? null : Path.GetDirectoryName(staffel);
if (serienOrdner is not null)
{
var anker = Path.Combine(serienOrdner, "egal.mkv");
await BildNeben(anker, SidecarNames.SeriesPoster, serie.PosterImage, ct);
await BildNeben(anker, SidecarNames.SeriesBackdrop, serie.BackdropImage, ct);
await beiwerk.WriteTextAsync(anker, SidecarNames.SeriesNfo, Nfo.Write(new SeriesNfo(
serie.Title, serie.Year, serie.Overview, new ProviderIds(serie.TmdbId, serie.ImdbId), true)), ct);
}
foreach (var datei in dateien)
{
var folge = folgen.First(f => f.Id == datei.EpisodeId);
await BildNeben(datei.Path, SidecarNames.EpisodeThumb(datei.Path), folge.StillImage, ct);
await beiwerk.WriteTextAsync(datei.Path, SidecarNames.EpisodeNfo(datei.Path), Nfo.Write(
new EpisodeNfo(folge.Title, folge.SeasonNumber, folge.EpisodeNumber, folge.Overview)), ct);
}
}
private async Task<string?> Ablegen(RemoteImage? bild, CancellationToken ct) =>
bild is null ? null : await bilder.StoreAsync(bild, ct);
private async Task BildNeben(string anker, string name, string? schlüssel, CancellationToken ct)
{
if (schlüssel is null) return;
await using var strom = await bildLeser.OpenAsync(schlüssel, ct);
if (strom is null) return;
await beiwerk.WriteStreamAsync(anker, name, strom, ct);
}
private static string Name(bool allein, string mediendatei, string beiwerkName) =>
allein ? beiwerkName : SidecarNames.Prefixed(mediendatei, beiwerkName);
}
+8
View File
@@ -67,6 +67,11 @@ builder.Services.AddSingleton(FfmpegOptions.FromEnvironment());
builder.Services.AddSingleton<IMediaProbe, FfprobeRunner>();
builder.Services.AddSingleton<FfmpegRemuxer>();
// Beiwerk im Medienordner — standardmässig aus. In fremde Medienordner zu
// schreiben ist nichts, was ein Server ungefragt tun sollte.
builder.Services.AddSingleton(SidecarOptions.FromEnvironment());
builder.Services.AddSingleton<ISidecarStore, SidecarWriter>();
builder.Services.AddScoped<LibraryScanner>();
// Metadaten. Der Anbieter steckt hinter einer Schnittstelle, damit alles, was
@@ -82,7 +87,9 @@ builder.Services.AddScoped<IMetadataProvider>(sp => sp.GetRequiredService<TmdbCl
builder.Services.AddSingleton(new ImageCacheOptions(pfade.Cache));
builder.Services.AddHttpClient<ImageCache>(c => c.Timeout = TimeSpan.FromSeconds(30));
builder.Services.AddScoped<IImageStore>(sp => sp.GetRequiredService<ImageCache>());
builder.Services.AddScoped<IImageReader>(sp => sp.GetRequiredService<ImageCache>());
builder.Services.AddScoped<MetadataEnricher>();
builder.Services.AddScoped<MatchApplier>();
builder.Services.AddSingleton<ScanCoordinator>();
builder.Services.AddSingleton<PackageCoordinator>();
builder.Services.AddProblemDetails();
@@ -109,6 +116,7 @@ app.MapLibraryContent();
app.MapSettings();
app.MapImages();
app.MapDelivery();
app.MapMatching();
app.Run();
@@ -0,0 +1,29 @@
namespace Halo.Core.Library;
/// <summary>Legt Beiwerk neben eine Mediendatei.</summary>
///
/// <remarks>
/// <b>Jede Umsetzung muss <see cref="SidecarNames.MayWrite"/> befragen.</b>
/// Die Erlaubnisliste ist der einzige Schutz des Bestands, seit die
/// Einhängung schreibbar ist — eine Umsetzung, die sie übergeht, hebelt ihn
/// aus.
/// </remarks>
public interface ISidecarStore
{
/// <summary>Ob überhaupt geschrieben werden soll und darf.</summary>
bool Enabled { get; }
Task WriteTextAsync(string mediendatei, string beiwerkName, string inhalt, CancellationToken ct = default);
Task WriteStreamAsync(string mediendatei, string beiwerkName, Stream inhalt, CancellationToken ct = default);
Task<string?> ReadTextAsync(string mediendatei, string beiwerkName, CancellationToken ct = default);
/// <summary>Ob diese Datei allein in ihrem Ordner liegt.</summary>
///
/// <remarks>
/// Entscheidet, ob das Beiwerk schlicht <c>poster.jpg</c> heisst oder den
/// Dateinamen davor bekommt.
/// </remarks>
bool IsAloneInFolder(string mediendatei);
}
@@ -70,6 +70,18 @@ public sealed class Movie
/// </remarks>
public required string ParsedTitle { get; init; }
/// <summary>Das Jahr, wie es im Dateinamen stand.</summary>
///
/// <remarks>
/// <b>Aus demselben Grund wie <see cref="ParsedTitle"/>, und er wurde
/// teuer gelernt.</b> Nach einer Korrektur von Hand stand im Ordner
/// „Dark (2017)", in der Datenbank aber 2004 — der nächste Scan suchte
/// nach (Dark, 2017), fand nichts, legte den Film neu an und löschte den
/// korrigierten als verwaist. Gerettet hat es nur die NFO-Datei. Dieses
/// Feld ändert sich nie.
/// </remarks>
public int? ParsedYear { get; init; }
public required string Title { get; set; }
/// <summary>Ohne führenden Artikel — siehe <see cref="SortTitle"/>.</summary>
@@ -81,6 +93,23 @@ public sealed class Movie
/// <summary>Die Kennung bei TMDB, sobald zugeordnet.</summary>
public int? TmdbId { get; set; }
/// <summary>Die Kennung bei IMDb — für die NFO-Datei.</summary>
///
/// <remarks>
/// Damit ein anderes Werkzeug denselben Titel wiederfindet, auch wenn es
/// TMDB gar nicht kennt.
/// </remarks>
public string? ImdbId { get; set; }
/// <summary>Ob jemand diese Zuordnung von Hand festgelegt hat.</summary>
///
/// <remarks>
/// Dann sucht kein Lauf mehr danach. Steht auch als <c>lockdata</c> in der
/// NFO-Datei — so überlebt die Entscheidung einen Neuaufbau der Datenbank
/// und einen Umzug auf einen anderen Server.
/// </remarks>
public bool MatchLocked { get; set; }
/// <summary>Schlüssel des zwischengespeicherten Plakats — siehe Bildablage.</summary>
///
/// <remarks>
@@ -110,11 +139,40 @@ public sealed class Series
/// </remarks>
public required string ParsedTitle { get; init; }
/// <summary>Das Jahr, wie es im Dateinamen stand.</summary>
///
/// <remarks>
/// <b>Aus demselben Grund wie <see cref="ParsedTitle"/>, und er wurde
/// teuer gelernt.</b> Nach einer Korrektur von Hand stand im Ordner
/// „Dark (2017)", in der Datenbank aber 2004 — der nächste Scan suchte
/// nach (Dark, 2017), fand nichts, legte den Film neu an und löschte den
/// korrigierten als verwaist. Gerettet hat es nur die NFO-Datei. Dieses
/// Feld ändert sich nie.
/// </remarks>
public int? ParsedYear { 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; }
/// <summary>Die Kennung bei IMDb — für die NFO-Datei.</summary>
///
/// <remarks>
/// Damit ein anderes Werkzeug denselben Titel wiederfindet, auch wenn es
/// TMDB gar nicht kennt.
/// </remarks>
public string? ImdbId { get; set; }
/// <summary>Ob jemand diese Zuordnung von Hand festgelegt hat.</summary>
///
/// <remarks>
/// Dann sucht kein Lauf mehr danach. Steht auch als <c>lockdata</c> in der
/// NFO-Datei — so überlebt die Entscheidung einen Neuaufbau der Datenbank
/// und einen Umzug auf einen anderen Server.
/// </remarks>
public bool MatchLocked { get; set; }
/// <summary>Schlüssel des zwischengespeicherten Plakats — siehe Bildablage.</summary>
///
/// <remarks>
+154
View File
@@ -0,0 +1,154 @@
using System.Xml;
using System.Xml.Linq;
namespace Halo.Core.Library;
public sealed record MovieNfo(
string Title, int? Year, string? Plot, ProviderIds Ids, bool Locked);
public sealed record SeriesNfo(
string Title, int? Year, string? Plot, ProviderIds Ids, bool Locked);
public sealed record EpisodeNfo(
string? Title, int Season, int Episode, string? Plot);
/// <summary>Die NFO-Datei neben dem Film: schreiben und lesen.</summary>
///
/// <remarks>
/// <para>
/// <b>Die Form stammt von Kodi und wird von Jellyfin, Plex und Emby
/// gelesen.</b> Dass sie hier erzeugt wird, macht den Bestand tragbar: ein
/// Umzug auf einen anderen Server, ein Neuaufbau der Datenbank oder ein
/// zweiter Scan finden dieselben Zuordnungen wieder.
/// </para>
/// <para>
/// <b>Kennungen stehen doppelt drin.</b> Einmal als <c>&lt;uniqueid&gt;</c> —
/// die neuere Form — und einmal als <c>&lt;tmdbid&gt;</c> und
/// <c>&lt;imdbid&gt;</c>, weil ältere Werkzeuge nur die kennen. Zwei Zeilen
/// mehr gegen die Frage, warum ein anderes Programm nichts findet.
/// </para>
/// </remarks>
public static class Nfo
{
/// <summary>Verhindert, dass ein Scan eine Korrektur von Hand überschreibt.</summary>
///
/// <remarks>
/// Kodi und Jellyfin kennen dieses Feld unter demselben Namen. Steht es
/// auf <c>true</c>, hat jemand die Zuordnung ausdrücklich festgelegt —
/// und dann hat kein automatischer Lauf daran etwas zu ändern.
/// </remarks>
private const string Sperre = "lockdata";
public static string Write(MovieNfo film) => Dokument("movie", wurzel =>
{
wurzel.Add(new XElement("title", film.Title));
if (film.Year is { } jahr) wurzel.Add(new XElement("year", jahr));
if (film.Plot is { Length: > 0 }) wurzel.Add(new XElement("plot", film.Plot));
Kennungen(wurzel, film.Ids);
wurzel.Add(new XElement(Sperre, film.Locked ? "true" : "false"));
});
public static string Write(SeriesNfo serie) => Dokument("tvshow", wurzel =>
{
wurzel.Add(new XElement("title", serie.Title));
if (serie.Year is { } jahr) wurzel.Add(new XElement("year", jahr));
if (serie.Plot is { Length: > 0 }) wurzel.Add(new XElement("plot", serie.Plot));
Kennungen(wurzel, serie.Ids);
wurzel.Add(new XElement(Sperre, serie.Locked ? "true" : "false"));
});
public static string Write(EpisodeNfo folge) => Dokument("episodedetails", wurzel =>
{
if (folge.Title is { Length: > 0 }) wurzel.Add(new XElement("title", folge.Title));
wurzel.Add(new XElement("season", folge.Season));
wurzel.Add(new XElement("episode", folge.Episode));
if (folge.Plot is { Length: > 0 }) wurzel.Add(new XElement("plot", folge.Plot));
});
/// <summary>Liest Kennungen aus einer vorhandenen NFO.</summary>
///
/// <remarks>
/// Gibt bei unlesbarem Inhalt <see cref="ProviderIds.None"/> zurück statt
/// zu werfen: eine von Hand geschriebene Datei ist oft kaputt, und das
/// darf keinen Scan abbrechen.
/// </remarks>
public static ProviderIds ReadIds(string xml)
{
var wurzel = Lesen(xml);
if (wurzel is null) return ProviderIds.None;
int? tmdb = null;
string? imdb = null;
foreach (var eindeutig in wurzel.Elements("uniqueid"))
{
var art = (string?)eindeutig.Attribute("type");
var wert = eindeutig.Value.Trim();
if (art is null || wert.Length == 0) continue;
if (art.Equals("tmdb", StringComparison.OrdinalIgnoreCase) && int.TryParse(wert, out var zahl))
tmdb ??= zahl;
else if (art.Equals("imdb", StringComparison.OrdinalIgnoreCase))
imdb ??= wert.ToLowerInvariant();
}
// Die ältere Form, falls die neuere fehlt.
if (tmdb is null && int.TryParse(wurzel.Element("tmdbid")?.Value.Trim(), out var alt)) tmdb = alt;
imdb ??= wurzel.Element("imdbid")?.Value.Trim() is { Length: > 0 } a ? a.ToLowerInvariant() : null;
return new ProviderIds(tmdb, imdb);
}
/// <summary>Ob diese Zuordnung von Hand festgelegt wurde.</summary>
public static bool ReadLocked(string xml) =>
Lesen(xml)?.Element(Sperre)?.Value.Trim()
.Equals("true", StringComparison.OrdinalIgnoreCase) == true;
private static void Kennungen(XElement wurzel, ProviderIds kennungen)
{
if (kennungen.Tmdb is { } tmdb)
{
wurzel.Add(new XElement("uniqueid", new XAttribute("type", "tmdb"),
new XAttribute("default", "true"), tmdb));
wurzel.Add(new XElement("tmdbid", tmdb));
}
if (kennungen.Imdb is { Length: > 0 } imdb)
{
wurzel.Add(new XElement("uniqueid", new XAttribute("type", "imdb"), imdb));
wurzel.Add(new XElement("imdbid", imdb));
}
}
private static string Dokument(string wurzelName, Action<XElement> füllen)
{
var wurzel = new XElement(wurzelName);
füllen(wurzel);
return new XDocument(new XDeclaration("1.0", "utf-8", null), wurzel)
.ToString(SaveOptions.None)
.Insert(0, "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n");
}
/// <summary>Liest XML, ohne sich dabei etwas unterschieben zu lassen.</summary>
///
/// <remarks>
/// <b>DTDs sind abgeschaltet und es wird nichts nachgeladen.</b> Diese
/// Dateien liegen im Medienordner, und was dort liegt, hat Halo nicht
/// geschrieben — eine NFO mit einer externen Entität könnte sonst den
/// Server dazu bringen, fremde Dateien zu lesen und auszuliefern.
/// </remarks>
private static XElement? Lesen(string xml)
{
if (string.IsNullOrWhiteSpace(xml)) return null;
try
{
using var leser = XmlReader.Create(new StringReader(xml), new XmlReaderSettings
{
DtdProcessing = DtdProcessing.Prohibit,
XmlResolver = null,
IgnoreWhitespace = true,
});
return XDocument.Load(leser).Root;
}
catch (XmlException) { return null; }
}
}
@@ -0,0 +1,47 @@
using System.Text.RegularExpressions;
namespace Halo.Core.Library;
/// <summary>Kennungen bei Metadatenquellen, wie sie in Namen stehen können.</summary>
public sealed record ProviderIds(int? Tmdb, string? Imdb)
{
public static readonly ProviderIds None = new(null, null);
public bool Any => Tmdb is not null || Imdb is not null;
}
/// <summary>Liest Kennungen aus Ordner- und Dateinamen.</summary>
///
/// <remarks>
/// <para>
/// <b>Die Form stammt von Kodi und wird von Jellyfin gelesen:</b>
/// <c>Der Untergang (2004) [tmdbid-613]</c>. Wer seine Ordner so benennt —
/// von Hand oder über Radarr —, hat seine Zuordnung damit festgeschrieben,
/// und Halo soll sie nicht erneut erraten.
/// </para>
/// <para>
/// <b>Halo benennt nichts um.</b> Es liest nur. Festgeschrieben wird eine
/// Korrektur in der NFO-Datei — dieselbe Wirkung, ohne den einen Eingriff,
/// der eine Bibliothek wirklich zerlegen kann.
/// </para>
/// </remarks>
public static partial class ProviderIdParser
{
[GeneratedRegex(@"\[tmdbid-(?<id>\d{1,9})\]", RegexOptions.IgnoreCase)]
private static partial Regex TmdbKlammer();
[GeneratedRegex(@"\[imdbid-(?<id>tt\d{6,10})\]", RegexOptions.IgnoreCase)]
private static partial Regex ImdbKlammer();
/// <summary>Sucht im ganzen Pfad — der Ordner trägt sie meist, nicht die Datei.</summary>
public static ProviderIds FromPath(string pfad)
{
if (string.IsNullOrWhiteSpace(pfad)) return ProviderIds.None;
var tmdb = TmdbKlammer().Match(pfad);
var imdb = ImdbKlammer().Match(pfad);
return new ProviderIds(
tmdb.Success && int.TryParse(tmdb.Groups["id"].Value, out var zahl) ? zahl : null,
imdb.Success ? imdb.Groups["id"].Value.ToLowerInvariant() : null);
}
}
@@ -0,0 +1,89 @@
namespace Halo.Core.Library;
/// <summary>Welche Dateien Halo neben eine Mediendatei legen darf — und sonst keine.</summary>
///
/// <remarks>
/// <para>
/// <b>Eine Erlaubnisliste, und sie ist der ganze Schutz.</b> Damit Plakate und
/// Metadaten im Filmordner landen können, muss die Einhängung schreibbar
/// sein — und damit kann der Server im Grundsatz alles dort anstellen. Dass
/// er es nicht tut, hängt an dieser Liste und an der Prüfung darunter.
/// </para>
/// <para>
/// <b>Mediendateien werden nie angefasst.</b> Nicht geändert, nicht
/// umbenannt, nicht gelöscht. Es gibt im ganzen Projekt keine Stelle, die das
/// könnte, und diese Liste ist der Grund: was nicht daraufsteht, wird
/// abgelehnt — auch wenn jemand es ausdrücklich verlangt.
/// </para>
/// <para>
/// <b>Transcode-Zwischenstände gehören ausdrücklich nicht hierher.</b> Die
/// liegen unter <c>HALO_TRANSCODE</c>, auf einem eigenen Volume. Ein
/// Medienordner ist kein Kratzplatz.
/// </para>
/// </remarks>
public static class SidecarNames
{
public const string MoviePoster = "poster.jpg";
public const string MovieBackdrop = "fanart.jpg";
public const string MovieLogo = "logo.png";
public const string MovieNfo = "movie.nfo";
public const string SeriesPoster = "poster.jpg";
public const string SeriesBackdrop = "fanart.jpg";
public const string SeriesNfo = "tvshow.nfo";
/// <summary>Was zu einer Folge gehört, hängt am Namen ihrer Datei.</summary>
///
/// <remarks>
/// Aus <c>Dark - S01E01.mkv</c> werden <c>Dark - S01E01.nfo</c> und
/// <c>Dark - S01E01-thumb.jpg</c>. So erwarten es Kodi, Jellyfin und Plex
/// — und so findet auch ein Mensch sie wieder.
/// </remarks>
public static string EpisodeNfo(string mediendatei) =>
Path.GetFileNameWithoutExtension(mediendatei) + ".nfo";
public static string EpisodeThumb(string mediendatei) =>
Path.GetFileNameWithoutExtension(mediendatei) + "-thumb.jpg";
/// <summary>Beiwerk für einen Film, der sich den Ordner mit anderen teilt.</summary>
///
/// <remarks>
/// <b>Sonst überschreiben sich zwei Filme gegenseitig das Plakat.</b> Wer
/// je Film einen Ordner anlegt, bekommt die schlichten Namen; wer zehn
/// Filme nebeneinanderlegt, bekommt sie mit dem Dateinamen davor. Kodi und
/// Jellyfin lesen beide Formen.
/// </remarks>
public static string Prefixed(string mediendatei, string beiwerk) =>
Path.GetFileNameWithoutExtension(mediendatei) + "-" + beiwerk;
private static readonly HashSet<string> Feste = new(StringComparer.OrdinalIgnoreCase)
{ MoviePoster, MovieBackdrop, MovieLogo, MovieNfo, SeriesNfo };
private static readonly string[] Endungen =
[".nfo", "-thumb.jpg", "-poster.jpg", "-fanart.jpg", "-logo.png"];
/// <summary>Ob dieser Dateiname geschrieben werden darf.</summary>
///
/// <remarks>
/// <b>Geprüft wird der Name, nicht die Absicht.</b> Ein Pfadbestandteil,
/// ein Verzeichniswechsel oder ein führender Punkt kommen hier nicht
/// durch — und „film.mkv" erst recht nicht, auch wenn es auf <c>.nfo</c>
/// endete.
/// </remarks>
public static bool MayWrite(string dateiname)
{
if (string.IsNullOrWhiteSpace(dateiname)) return false;
// Kein Pfad, nur ein Name. Alles andere könnte aus dem Ordner führen.
if (dateiname.Contains('/') || dateiname.Contains('\\')) return false;
if (dateiname != Path.GetFileName(dateiname)) return false;
if (dateiname.StartsWith('.')) return false;
if (dateiname.Contains("..", StringComparison.Ordinal)) return false;
if (Feste.Contains(dateiname)) return true;
// Je Folge: beliebiger Rumpf, aber nur mit diesen Endungen.
return Endungen.Any(e => dateiname.EndsWith(e, StringComparison.OrdinalIgnoreCase)
&& dateiname.Length > e.Length);
}
}
@@ -5,11 +5,11 @@ public sealed record RemoteImage(string Url);
public sealed record MovieMetadata(
int ProviderId, string Title, int? Year, string? Overview,
RemoteImage? Poster, RemoteImage? Backdrop);
RemoteImage? Poster, RemoteImage? Backdrop, string? ImdbId = null);
public sealed record SeriesMetadata(
int ProviderId, string Title, int? Year, string? Overview,
RemoteImage? Poster, RemoteImage? Backdrop);
RemoteImage? Poster, RemoteImage? Backdrop, string? ImdbId = null);
public sealed record EpisodeMetadata(string? Title, string? Overview, RemoteImage? Still);
@@ -43,6 +43,23 @@ public interface IMetadataProvider
Task<SeriesMetadata?> FindSeriesAsync(string title, int? year, CancellationToken ct = default);
Task<EpisodeMetadata?> FindEpisodeAsync(int seriesProviderId, int season, int episode, CancellationToken ct = default);
/// <summary>Holt einen Titel über seine Kennung, ohne zu suchen.</summary>
///
/// <remarks>
/// <b>Für alles, was bereits zugeordnet ist.</b> Steht eine Kennung im
/// Ordnernamen oder in einer NFO, hat jemand die Frage schon beantwortet
/// — erneut zu suchen hiesse, seine Antwort zu überstimmen. Dasselbe gilt
/// nach einer Korrektur von Hand.
/// </remarks>
Task<MovieMetadata?> GetMovieAsync(int providerId, CancellationToken ct = default);
Task<SeriesMetadata?> GetSeriesAsync(int providerId, CancellationToken ct = default);
/// <summary>Mehrere Vorschläge zur Auswahl — für das Korrigieren von Hand.</summary>
Task<IReadOnlyList<MovieMetadata>> SearchMoviesAsync(string title, int? year, CancellationToken ct = default);
Task<IReadOnlyList<SeriesMetadata>> SearchSeriesAsync(string title, int? year, CancellationToken ct = default);
}
/// <summary>Der Schlüssel stimmt nicht — das muss der Betreiber erfahren.</summary>
@@ -67,3 +84,14 @@ public interface IImageStore
/// <summary>Holt das Bild und gibt seinen Schlüssel zurück — oder <c>null</c>.</summary>
Task<string?> StoreAsync(RemoteImage bild, CancellationToken ct = default);
}
/// <summary>Ein Bild aus der Ablage wieder herausgeben.</summary>
///
/// <remarks>
/// Gebraucht, um ein zwischengespeichertes Plakat zusätzlich in den
/// Medienordner zu legen — ohne es ein zweites Mal herunterzuladen.
/// </remarks>
public interface IImageReader
{
Task<Stream?> OpenAsync(string schlüssel, CancellationToken ct = default);
}
+2
View File
@@ -121,6 +121,7 @@ public sealed class HaloContext(DbContextOptions<HaloContext> options) : DbConte
e.HasIndex(x => new { x.LibraryId, x.SortTitleValue });
e.Property(x => x.Title).HasMaxLength(512);
e.Property(x => x.SortTitleValue).HasMaxLength(512);
e.Property(x => x.ImdbId).HasMaxLength(32);
e.HasOne<Core.Libraries.Library>().WithMany().HasForeignKey(x => x.LibraryId)
.OnDelete(DeleteBehavior.Cascade);
});
@@ -131,6 +132,7 @@ public sealed class HaloContext(DbContextOptions<HaloContext> options) : DbConte
e.HasIndex(x => new { x.LibraryId, x.SortTitleValue });
e.Property(x => x.Title).HasMaxLength(512);
e.Property(x => x.SortTitleValue).HasMaxLength(512);
e.Property(x => x.ImdbId).HasMaxLength(32);
e.HasOne<Core.Libraries.Library>().WithMany().HasForeignKey(x => x.LibraryId)
.OnDelete(DeleteBehavior.Cascade);
});
+3 -1
View File
@@ -230,13 +230,15 @@ public sealed class LibraryScanner(
private Movie FilmFinden(List<Movie> bekannt, Guid libraryId, MovieName name)
{
var treffer = bekannt.FirstOrDefault(m =>
string.Equals(m.ParsedTitle, name.Title, StringComparison.OrdinalIgnoreCase) && m.Year == name.Year);
string.Equals(m.ParsedTitle, name.Title, StringComparison.OrdinalIgnoreCase)
&& m.ParsedYear == name.Year);
if (treffer is not null) return treffer;
var neu = new Movie
{
LibraryId = libraryId,
ParsedTitle = name.Title,
ParsedYear = name.Year,
Title = name.Title,
SortTitleValue = SortTitle.For(name.Title),
Year = name.Year,
+159 -6
View File
@@ -1,3 +1,4 @@
using Halo.Core.Library;
using Halo.Core.Metadata;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
@@ -27,6 +28,8 @@ public sealed class MetadataEnricher(
HaloContext db,
IMetadataProvider anbieter,
IImageStore bilder,
IImageReader bildLeser,
ISidecarStore beiwerk,
ILogger<MetadataEnricher> log)
{
public async Task<EnrichReport> EnrichAsync(Guid libraryId, CancellationToken ct = default)
@@ -65,23 +68,59 @@ public sealed class MetadataEnricher(
foreach (var film in offen)
{
ct.ThrowIfCancellationRequested();
var gefunden = await anbieter.FindMovieAsync(film.Title, film.Year, ct);
var datei = await db.MediaFiles.FirstOrDefaultAsync(f => f.MovieId == film.Id, ct);
var (vorgegeben, festgelegt) = await VorhandeneZuordnung(datei, SidecarNames.MovieNfo, ct);
// **Eine vorhandene Kennung sticht die Suche.** Steht sie im
// Ordnernamen oder in einer NFO, hat jemand die Frage schon
// beantwortet — erneut zu suchen hiesse, ihn zu überstimmen.
var gefunden = vorgegeben.Tmdb is { } kennung
? await anbieter.GetMovieAsync(kennung, ct)
: festgelegt
? null // festgelegt, aber ohne Kennung: dann bleibt es, wie es ist
: await anbieter.FindMovieAsync(film.Title, film.Year, ct);
if (gefunden is null) continue;
film.TmdbId = gefunden.ProviderId;
film.ImdbId = gefunden.ImdbId;
film.Title = gefunden.Title;
film.SortTitleValue = Core.Library.SortTitle.For(gefunden.Title);
film.Year ??= gefunden.Year;
film.SortTitleValue = SortTitle.For(gefunden.Title);
film.Year = gefunden.Year ?? film.Year;
film.Overview = gefunden.Overview;
film.PosterImage = await BildAblegen(gefunden.Poster, ct);
film.BackdropImage = await BildAblegen(gefunden.Backdrop, ct);
film.MatchLocked = festgelegt;
zugeordnet++;
if (datei is not null) await FilmBeiwerk(film, datei, ct);
}
if (zugeordnet > 0) await db.SaveChangesAsync(ct);
return zugeordnet;
}
/// <summary>Liest eine Zuordnung, die schon dasteht.</summary>
///
/// <remarks>
/// Zwei Quellen, in dieser Reihenfolge: die NFO-Datei neben dem Film und
/// der Ordnername in der Form <c>[tmdbid-613]</c>. Die NFO gewinnt — sie
/// ist die ausdrücklichere Aussage.
/// </remarks>
private async Task<(ProviderIds Kennungen, bool Festgelegt)> VorhandeneZuordnung(
MediaFile? datei, string nfoName, CancellationToken ct)
{
if (datei is null) return (ProviderIds.None, false);
var ausPfad = ProviderIdParser.FromPath(datei.Path);
var nfo = await beiwerk.ReadTextAsync(datei.Path, nfoName, ct);
if (nfo is null) return (ausPfad, false);
var ausNfo = Nfo.ReadIds(nfo);
return (new ProviderIds(ausNfo.Tmdb ?? ausPfad.Tmdb, ausNfo.Imdb ?? ausPfad.Imdb), Nfo.ReadLocked(nfo));
}
private async Task<int> SerienZuordnen(Guid libraryId, CancellationToken ct)
{
var offen = await db.Series.Where(s => s.LibraryId == libraryId && s.TmdbId == null).ToListAsync(ct);
@@ -90,23 +129,75 @@ public sealed class MetadataEnricher(
foreach (var serie in offen)
{
ct.ThrowIfCancellationRequested();
var gefunden = await anbieter.FindSeriesAsync(serie.Title, serie.Year, ct);
// Bei einer Serie liegt die NFO neben der Folge, also im
// Staffelordner — gesucht wird deshalb ab der ersten Folgendatei.
var folgenKennungen = await db.Episodes.Where(f => f.SeriesId == serie.Id)
.Select(f => f.Id).ToListAsync(ct);
var datei = await db.MediaFiles
.FirstOrDefaultAsync(f => f.EpisodeId != null && folgenKennungen.Contains(f.EpisodeId!.Value), ct);
var (vorgegeben, festgelegt) = await SerienZuordnungLesen(datei, ct);
var gefunden = vorgegeben.Tmdb is { } kennung
? await anbieter.GetSeriesAsync(kennung, ct)
: festgelegt ? null : await anbieter.FindSeriesAsync(serie.Title, serie.Year, ct);
if (gefunden is null) continue;
serie.TmdbId = gefunden.ProviderId;
serie.ImdbId = gefunden.ImdbId;
serie.Title = gefunden.Title;
serie.SortTitleValue = Core.Library.SortTitle.For(gefunden.Title);
serie.Year ??= gefunden.Year;
serie.SortTitleValue = SortTitle.For(gefunden.Title);
serie.Year = gefunden.Year ?? serie.Year;
serie.Overview = gefunden.Overview;
serie.PosterImage = await BildAblegen(gefunden.Poster, ct);
serie.BackdropImage = await BildAblegen(gefunden.Backdrop, ct);
serie.MatchLocked = festgelegt;
zugeordnet++;
if (datei is not null) await SerienBeiwerk(serie, datei, ct);
}
if (zugeordnet > 0) await db.SaveChangesAsync(ct);
return zugeordnet;
}
/// <summary>Für Serien liegt die tvshow.nfo einen Ordner höher.</summary>
///
/// <remarks>
/// Die Folgen stecken in Staffelordnern; die Serienangaben gehören
/// daneben, nicht hinein. Deshalb wird von der Folgendatei aus eine Ebene
/// nach oben gegangen.
/// </remarks>
private async Task<(ProviderIds, bool)> SerienZuordnungLesen(MediaFile? datei, CancellationToken ct)
{
if (datei is null) return (ProviderIds.None, false);
var ausPfad = ProviderIdParser.FromPath(datei.Path);
foreach (var kandidat in SerienOrdner(datei.Path))
{
var nfo = await beiwerk.ReadTextAsync(kandidat, SidecarNames.SeriesNfo, ct);
if (nfo is null) continue;
var ausNfo = Nfo.ReadIds(nfo);
return (new ProviderIds(ausNfo.Tmdb ?? ausPfad.Tmdb, ausNfo.Imdb ?? ausPfad.Imdb), Nfo.ReadLocked(nfo));
}
return (ausPfad, false);
}
/// <summary>Der Ordner der Folge und der darüber — mehr nicht.</summary>
private static IEnumerable<string> SerienOrdner(string folgenPfad)
{
yield return folgenPfad;
var staffel = Path.GetDirectoryName(folgenPfad);
var serie = staffel is null ? null : Path.GetDirectoryName(staffel);
if (serie is not null) yield return Path.Combine(serie, "egal.mkv");
}
/// <summary>Folgen nachschlagen — aber nur bei Serien, die zugeordnet sind.</summary>
private async Task<int> FolgenZuordnen(Guid libraryId, CancellationToken ct)
{
@@ -136,6 +227,9 @@ public sealed class MetadataEnricher(
folge.Overview = gefunden.Overview;
folge.StillImage = await BildAblegen(gefunden.Still, ct);
zugeordnet++;
var datei = await db.MediaFiles.FirstOrDefaultAsync(d => d.EpisodeId == folge.Id, ct);
if (datei is not null) await FolgenBeiwerk(folge, datei, ct);
}
if (zugeordnet > 0) await db.SaveChangesAsync(ct);
@@ -144,4 +238,63 @@ public sealed class MetadataEnricher(
private async Task<string?> BildAblegen(RemoteImage? bild, CancellationToken ct) =>
bild is null ? null : await bilder.StoreAsync(bild, ct);
// --- Beiwerk im Medienordner -----------------------------------------
private async Task FilmBeiwerk(Movie film, MediaFile datei, CancellationToken ct)
{
if (!beiwerk.Enabled) return;
var allein = beiwerk.IsAloneInFolder(datei.Path);
await BildNeben(datei, Name(allein, datei.Path, SidecarNames.MoviePoster), film.PosterImage, ct);
await BildNeben(datei, Name(allein, datei.Path, SidecarNames.MovieBackdrop), film.BackdropImage, ct);
await beiwerk.WriteTextAsync(datei.Path, SidecarNames.MovieNfo, Nfo.Write(new MovieNfo(
film.Title, film.Year, film.Overview,
new ProviderIds(film.TmdbId, film.ImdbId), film.MatchLocked)), ct);
}
private async Task SerienBeiwerk(Series serie, MediaFile folgenDatei, CancellationToken ct)
{
if (!beiwerk.Enabled) return;
// Eine Ebene über dem Staffelordner — dorthin gehören Serienangaben.
var staffel = Path.GetDirectoryName(folgenDatei.Path);
var serienOrdner = staffel is null ? null : Path.GetDirectoryName(staffel);
if (serienOrdner is null) return;
var anker = Path.Combine(serienOrdner, "egal.mkv");
await BildNeben(anker, SidecarNames.SeriesPoster, serie.PosterImage, ct);
await BildNeben(anker, SidecarNames.SeriesBackdrop, serie.BackdropImage, ct);
await beiwerk.WriteTextAsync(anker, SidecarNames.SeriesNfo, Nfo.Write(new SeriesNfo(
serie.Title, serie.Year, serie.Overview,
new ProviderIds(serie.TmdbId, serie.ImdbId), serie.MatchLocked)), ct);
}
private async Task FolgenBeiwerk(Episode folge, MediaFile datei, CancellationToken ct)
{
if (!beiwerk.Enabled) return;
await BildNeben(datei, SidecarNames.EpisodeThumb(datei.Path), folge.StillImage, ct);
await beiwerk.WriteTextAsync(datei.Path, SidecarNames.EpisodeNfo(datei.Path), Nfo.Write(
new EpisodeNfo(folge.Title, folge.SeasonNumber, folge.EpisodeNumber, folge.Overview)), ct);
}
private Task BildNeben(MediaFile datei, string name, string? schlüssel, CancellationToken ct) =>
BildNeben(datei.Path, name, schlüssel, ct);
private async Task BildNeben(string ankerPfad, string name, string? schlüssel, CancellationToken ct)
{
if (schlüssel is null) return;
// Aus der Ablage kopiert, nicht erneut geholt: das Bild liegt schon da.
await using var strom = await bildLeser.OpenAsync(schlüssel, ct);
if (strom is null) return;
await beiwerk.WriteStreamAsync(ankerPfad, name, strom, ct);
}
private static string Name(bool allein, string mediendatei, string beiwerkName) =>
allein ? beiwerkName : SidecarNames.Prefixed(mediendatei, beiwerkName);
}
@@ -0,0 +1,498 @@
// <auto-generated />
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("20261003165210_Zuordnung")]
partial class Zuordnung
{
/// <inheritdoc />
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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid?>("ApprovedByUserId")
.HasColumnType("TEXT");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<long?>("ConsumedAt")
.HasColumnType("INTEGER");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("DeviceId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("DeviceName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<long>("ExpiresAt")
.HasColumnType("INTEGER");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("DeviceId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("DeviceName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<long>("LastSeenAt")
.HasColumnType("INTEGER");
b.Property<long?>("RevokedAt")
.HasColumnType("INTEGER");
b.Property<string>("TokenFingerprint")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<Guid>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("Kind")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("Path")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("Libraries");
});
modelBuilder.Entity("Halo.Core.Library.Episode", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("AddedAt")
.HasColumnType("INTEGER");
b.Property<int>("EpisodeNumber")
.HasColumnType("INTEGER");
b.Property<string>("Overview")
.HasColumnType("TEXT");
b.Property<int>("SeasonNumber")
.HasColumnType("INTEGER");
b.Property<Guid>("SeriesId")
.HasColumnType("TEXT");
b.Property<string>("StillImage")
.HasColumnType("TEXT");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long?>("BitRate")
.HasColumnType("INTEGER");
b.Property<string>("Container")
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<long?>("Duration")
.HasColumnType("INTEGER");
b.Property<Guid?>("EpisodeId")
.HasColumnType("TEXT");
b.Property<Guid>("LibraryId")
.HasColumnType("TEXT");
b.Property<long>("ModifiedAt")
.HasColumnType("INTEGER");
b.Property<Guid?>("MovieId")
.HasColumnType("TEXT");
b.Property<string>("Path")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<long?>("ProbedAt")
.HasColumnType("INTEGER");
b.Property<long>("SeenAt")
.HasColumnType("INTEGER");
b.Property<long>("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.MediaStream", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<int?>("Channels")
.HasColumnType("INTEGER");
b.Property<string>("Codec")
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int?>("Height")
.HasColumnType("INTEGER");
b.Property<int>("Index")
.HasColumnType("INTEGER");
b.Property<bool>("IsDefault")
.HasColumnType("INTEGER");
b.Property<bool>("IsForced")
.HasColumnType("INTEGER");
b.Property<string>("Kind")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<string>("Language")
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<Guid>("MediaFileId")
.HasColumnType("TEXT");
b.Property<string>("Title")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<int?>("Width")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("MediaFileId", "Index")
.IsUnique();
b.ToTable("MediaStreams");
});
modelBuilder.Entity("Halo.Core.Library.Movie", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("AddedAt")
.HasColumnType("INTEGER");
b.Property<string>("BackdropImage")
.HasColumnType("TEXT");
b.Property<string>("ImdbId")
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<Guid>("LibraryId")
.HasColumnType("TEXT");
b.Property<bool>("MatchLocked")
.HasColumnType("INTEGER");
b.Property<string>("Overview")
.HasColumnType("TEXT");
b.Property<string>("ParsedTitle")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("PosterImage")
.HasColumnType("TEXT");
b.Property<string>("SortTitleValue")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<int?>("TmdbId")
.HasColumnType("INTEGER");
b.Property<int?>("Year")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("LibraryId", "SortTitleValue");
b.ToTable("Movies");
});
modelBuilder.Entity("Halo.Core.Library.Series", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("AddedAt")
.HasColumnType("INTEGER");
b.Property<string>("BackdropImage")
.HasColumnType("TEXT");
b.Property<string>("ImdbId")
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<Guid>("LibraryId")
.HasColumnType("TEXT");
b.Property<bool>("MatchLocked")
.HasColumnType("INTEGER");
b.Property<string>("Overview")
.HasColumnType("TEXT");
b.Property<string>("ParsedTitle")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("PosterImage")
.HasColumnType("TEXT");
b.Property<string>("SortTitleValue")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<int?>("TmdbId")
.HasColumnType("INTEGER");
b.Property<int?>("Year")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("LibraryId", "SortTitleValue");
b.ToTable("Series");
});
modelBuilder.Entity("Halo.Core.ServerSetting", b =>
{
b.Property<string>("Key")
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("Value")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("TEXT");
b.HasKey("Key");
b.ToTable("Settings");
});
modelBuilder.Entity("Halo.Core.Users.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<bool>("IsAdministrator")
.HasColumnType("INTEGER");
b.Property<string>("PasswordHash")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("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.MediaStream", b =>
{
b.HasOne("Halo.Core.Library.MediaFile", null)
.WithMany()
.HasForeignKey("MediaFileId")
.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
}
}
}
@@ -0,0 +1,62 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Halo.Data.Migrations
{
/// <inheritdoc />
public partial class Zuordnung : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "ImdbId",
table: "Series",
type: "TEXT",
maxLength: 32,
nullable: true);
migrationBuilder.AddColumn<bool>(
name: "MatchLocked",
table: "Series",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<string>(
name: "ImdbId",
table: "Movies",
type: "TEXT",
maxLength: 32,
nullable: true);
migrationBuilder.AddColumn<bool>(
name: "MatchLocked",
table: "Movies",
type: "INTEGER",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ImdbId",
table: "Series");
migrationBuilder.DropColumn(
name: "MatchLocked",
table: "Series");
migrationBuilder.DropColumn(
name: "ImdbId",
table: "Movies");
migrationBuilder.DropColumn(
name: "MatchLocked",
table: "Movies");
}
}
}
@@ -0,0 +1,504 @@
// <auto-generated />
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("20261003165918_GeparstesJahr")]
partial class GeparstesJahr
{
/// <inheritdoc />
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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid?>("ApprovedByUserId")
.HasColumnType("TEXT");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<long?>("ConsumedAt")
.HasColumnType("INTEGER");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("DeviceId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("DeviceName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<long>("ExpiresAt")
.HasColumnType("INTEGER");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("DeviceId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("DeviceName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<long>("LastSeenAt")
.HasColumnType("INTEGER");
b.Property<long?>("RevokedAt")
.HasColumnType("INTEGER");
b.Property<string>("TokenFingerprint")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<Guid>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("Kind")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("Path")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("Libraries");
});
modelBuilder.Entity("Halo.Core.Library.Episode", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("AddedAt")
.HasColumnType("INTEGER");
b.Property<int>("EpisodeNumber")
.HasColumnType("INTEGER");
b.Property<string>("Overview")
.HasColumnType("TEXT");
b.Property<int>("SeasonNumber")
.HasColumnType("INTEGER");
b.Property<Guid>("SeriesId")
.HasColumnType("TEXT");
b.Property<string>("StillImage")
.HasColumnType("TEXT");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long?>("BitRate")
.HasColumnType("INTEGER");
b.Property<string>("Container")
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<long?>("Duration")
.HasColumnType("INTEGER");
b.Property<Guid?>("EpisodeId")
.HasColumnType("TEXT");
b.Property<Guid>("LibraryId")
.HasColumnType("TEXT");
b.Property<long>("ModifiedAt")
.HasColumnType("INTEGER");
b.Property<Guid?>("MovieId")
.HasColumnType("TEXT");
b.Property<string>("Path")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<long?>("ProbedAt")
.HasColumnType("INTEGER");
b.Property<long>("SeenAt")
.HasColumnType("INTEGER");
b.Property<long>("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.MediaStream", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<int?>("Channels")
.HasColumnType("INTEGER");
b.Property<string>("Codec")
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int?>("Height")
.HasColumnType("INTEGER");
b.Property<int>("Index")
.HasColumnType("INTEGER");
b.Property<bool>("IsDefault")
.HasColumnType("INTEGER");
b.Property<bool>("IsForced")
.HasColumnType("INTEGER");
b.Property<string>("Kind")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<string>("Language")
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<Guid>("MediaFileId")
.HasColumnType("TEXT");
b.Property<string>("Title")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<int?>("Width")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("MediaFileId", "Index")
.IsUnique();
b.ToTable("MediaStreams");
});
modelBuilder.Entity("Halo.Core.Library.Movie", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("AddedAt")
.HasColumnType("INTEGER");
b.Property<string>("BackdropImage")
.HasColumnType("TEXT");
b.Property<string>("ImdbId")
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<Guid>("LibraryId")
.HasColumnType("TEXT");
b.Property<bool>("MatchLocked")
.HasColumnType("INTEGER");
b.Property<string>("Overview")
.HasColumnType("TEXT");
b.Property<string>("ParsedTitle")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int?>("ParsedYear")
.HasColumnType("INTEGER");
b.Property<string>("PosterImage")
.HasColumnType("TEXT");
b.Property<string>("SortTitleValue")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<int?>("TmdbId")
.HasColumnType("INTEGER");
b.Property<int?>("Year")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("LibraryId", "SortTitleValue");
b.ToTable("Movies");
});
modelBuilder.Entity("Halo.Core.Library.Series", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("AddedAt")
.HasColumnType("INTEGER");
b.Property<string>("BackdropImage")
.HasColumnType("TEXT");
b.Property<string>("ImdbId")
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<Guid>("LibraryId")
.HasColumnType("TEXT");
b.Property<bool>("MatchLocked")
.HasColumnType("INTEGER");
b.Property<string>("Overview")
.HasColumnType("TEXT");
b.Property<string>("ParsedTitle")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int?>("ParsedYear")
.HasColumnType("INTEGER");
b.Property<string>("PosterImage")
.HasColumnType("TEXT");
b.Property<string>("SortTitleValue")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<int?>("TmdbId")
.HasColumnType("INTEGER");
b.Property<int?>("Year")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("LibraryId", "SortTitleValue");
b.ToTable("Series");
});
modelBuilder.Entity("Halo.Core.ServerSetting", b =>
{
b.Property<string>("Key")
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("Value")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("TEXT");
b.HasKey("Key");
b.ToTable("Settings");
});
modelBuilder.Entity("Halo.Core.Users.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<bool>("IsAdministrator")
.HasColumnType("INTEGER");
b.Property<string>("PasswordHash")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("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.MediaStream", b =>
{
b.HasOne("Halo.Core.Library.MediaFile", null)
.WithMany()
.HasForeignKey("MediaFileId")
.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
}
}
}
@@ -0,0 +1,38 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Halo.Data.Migrations
{
/// <inheritdoc />
public partial class GeparstesJahr : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "ParsedYear",
table: "Series",
type: "INTEGER",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "ParsedYear",
table: "Movies",
type: "INTEGER",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ParsedYear",
table: "Series");
migrationBuilder.DropColumn(
name: "ParsedYear",
table: "Movies");
}
}
}
@@ -291,9 +291,16 @@ namespace Halo.Data.Migrations
b.Property<string>("BackdropImage")
.HasColumnType("TEXT");
b.Property<string>("ImdbId")
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<Guid>("LibraryId")
.HasColumnType("TEXT");
b.Property<bool>("MatchLocked")
.HasColumnType("INTEGER");
b.Property<string>("Overview")
.HasColumnType("TEXT");
@@ -301,6 +308,9 @@ namespace Halo.Data.Migrations
.IsRequired()
.HasColumnType("TEXT");
b.Property<int?>("ParsedYear")
.HasColumnType("INTEGER");
b.Property<string>("PosterImage")
.HasColumnType("TEXT");
@@ -339,9 +349,16 @@ namespace Halo.Data.Migrations
b.Property<string>("BackdropImage")
.HasColumnType("TEXT");
b.Property<string>("ImdbId")
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<Guid>("LibraryId")
.HasColumnType("TEXT");
b.Property<bool>("MatchLocked")
.HasColumnType("INTEGER");
b.Property<string>("Overview")
.HasColumnType("TEXT");
@@ -349,6 +366,9 @@ namespace Halo.Data.Migrations
.IsRequired()
.HasColumnType("TEXT");
b.Property<int?>("ParsedYear")
.HasColumnType("INTEGER");
b.Property<string>("PosterImage")
.HasColumnType("TEXT");
+123
View File
@@ -0,0 +1,123 @@
using Halo.Core.Library;
using Microsoft.Extensions.Logging;
namespace Halo.Media;
/// <summary>Ob Halo überhaupt in Medienordner schreiben darf.</summary>
///
/// <remarks>
/// <b>Standardmässig aus.</b> In fremde Medienordner zu schreiben ist nichts,
/// was ein Server ungefragt tun sollte — und für viele Aufbauten ist die
/// Einhängung ohnehin schreibgeschützt. Wer es will, setzt
/// <c>HALO_WRITE_SIDECARS=true</c> und hängt die Medien ohne <c>:ro</c> ein.
/// </remarks>
public sealed record SidecarOptions(bool Enabled)
{
public static SidecarOptions FromEnvironment() => new(
Environment.GetEnvironmentVariable("HALO_WRITE_SIDECARS")?.Trim()
.Equals("true", StringComparison.OrdinalIgnoreCase) == true);
}
/// <summary>Legt Plakate und NFO-Dateien neben die Mediendatei.</summary>
///
/// <remarks>
/// <para>
/// <b>Jeder Schreibvorgang geht durch <see cref="SidecarNames.MayWrite"/>.</b>
/// Diese Liste ist der einzige Schutz des Bestands, seit die Einhängung
/// schreibbar ist. Ein Name, der nicht daraufsteht, wirft — und zwar laut:
/// das wäre ein Fehler im Programm, kein Zustand, den man stillschweigend
/// übergehen darf.
/// </para>
/// <para>
/// <b>Geschrieben wird daneben und dann umbenannt.</b> Bricht der Vorgang ab,
/// liegt sonst ein halbes Plakat unter dem richtigen Namen — und wird nie
/// wieder erneuert, weil die Datei ja da ist.
/// </para>
/// </remarks>
public sealed class SidecarWriter(SidecarOptions optionen, ILogger<SidecarWriter> log) : ISidecarStore
{
public bool Enabled => optionen.Enabled;
public Task WriteTextAsync(string mediendatei, string beiwerkName, string inhalt, CancellationToken ct = default) =>
SchreibenAsync(mediendatei, beiwerkName, ziel => File.WriteAllTextAsync(ziel, inhalt, ct));
public Task WriteStreamAsync(string mediendatei, string beiwerkName, Stream inhalt, CancellationToken ct = default) =>
SchreibenAsync(mediendatei, beiwerkName, async ziel =>
{
await using var datei = File.Create(ziel);
await inhalt.CopyToAsync(datei, ct);
});
public async Task<string?> ReadTextAsync(string mediendatei, string beiwerkName, CancellationToken ct = default)
{
// **Gelesen wird auch, wenn nicht geschrieben werden darf.** Eine
// vorhandene NFO ist eine Zuordnung, die jemand getroffen hat — die
// zu übergehen, bloss weil Halo selbst nicht schreiben soll, wäre
// verkehrt herum.
Pruefen(beiwerkName);
var pfad = Pfad(mediendatei, beiwerkName);
try
{
return File.Exists(pfad) ? await File.ReadAllTextAsync(pfad, ct) : null;
}
catch (Exception fehler) when (fehler is IOException or UnauthorizedAccessException)
{
log.LogDebug(fehler, "{Pfad} liess sich nicht lesen.", pfad);
return null;
}
}
public bool IsAloneInFolder(string mediendatei)
{
var ordner = Path.GetDirectoryName(mediendatei);
if (ordner is null) return false;
try
{
return Directory.EnumerateFiles(ordner).Count(MediaExtensions.IsVideo) <= 1;
}
catch (Exception fehler) when (fehler is IOException or UnauthorizedAccessException)
{
// Im Zweifel die vorsichtige Form: lieber ein Plakat mit
// Dateinamen davor als eines, das ein fremdes überschreibt.
log.LogDebug(fehler, "{Ordner} liess sich nicht durchsehen.", ordner);
return false;
}
}
private async Task SchreibenAsync(string mediendatei, string beiwerkName, Func<string, Task> schreiben)
{
Pruefen(beiwerkName);
if (!optionen.Enabled) return;
var ziel = Pfad(mediendatei, beiwerkName);
var halbfertig = ziel + ".halo-tmp";
try
{
await schreiben(halbfertig);
File.Move(halbfertig, ziel, overwrite: true);
}
catch (Exception fehler) when (fehler is IOException or UnauthorizedAccessException)
{
// **Kein Abbruch.** Eine schreibgeschützte Einhängung oder eine
// volle Platte darf keinen Scan umwerfen — die Mediathek steht
// auch ohne Beiwerk.
log.LogWarning(fehler, "{Ziel} liess sich nicht schreiben.", ziel);
try { if (File.Exists(halbfertig)) File.Delete(halbfertig); } catch (IOException) { }
}
}
private static string Pfad(string mediendatei, string beiwerkName) =>
Path.Combine(Path.GetDirectoryName(mediendatei) ?? ".", beiwerkName);
private static void Pruefen(string beiwerkName)
{
if (SidecarNames.MayWrite(beiwerkName)) return;
throw new InvalidOperationException(
$"„{beiwerkName}“ steht nicht auf der Liste der Dateien, die Halo anlegen darf. " +
"Siehe SidecarNames — das ist der Schutz des Bestands, und er wird nicht umgangen.");
}
}
+20 -1
View File
@@ -26,7 +26,7 @@ public sealed record ImageCacheOptions(string Root);
public sealed class ImageCache(
HttpClient http,
ImageCacheOptions optionen,
ILogger<ImageCache> log) : IImageStore
ILogger<ImageCache> log) : IImageStore, IImageReader
{
/// <summary>Mehr als das ist kein Plakat, sondern ein Versehen.</summary>
private const long HöchstensBytes = 16 * 1024 * 1024;
@@ -91,6 +91,25 @@ public sealed class ImageCache(
return Path.Combine(optionen.Root, "images", schlüssel[..2], schlüssel + ".img");
}
/// <summary>Gibt ein abgelegtes Bild zum Lesen heraus.</summary>
///
/// <remarks>
/// Damit dasselbe Plakat zusätzlich in den Medienordner gelegt werden
/// kann, ohne es ein zweites Mal herunterzuladen.
/// </remarks>
public Task<Stream?> OpenAsync(string schlüssel, CancellationToken ct = default)
{
var pfad = PfadFür(schlüssel);
if (pfad is null || !File.Exists(pfad)) return Task.FromResult<Stream?>(null);
try { return Task.FromResult<Stream?>(File.OpenRead(pfad)); }
catch (IOException fehler)
{
log.LogDebug(fehler, "{Pfad} liess sich nicht öffnen.", pfad);
return Task.FromResult<Stream?>(null);
}
}
private static string Schlüssel(string adresse) =>
Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(adresse)));
}
+89 -17
View File
@@ -58,30 +58,84 @@ public sealed class TmdbClient(
{
var treffer = await SuchenAsync<TmdbMovie>("search/movie", title,
year is { } j ? ("year", j.ToString()) : null, m => m.Title, ct);
if (treffer is null) return null;
return new MovieMetadata(
treffer.Id,
treffer.Title ?? title,
JahrAus(treffer.ReleaseDate),
Leer(treffer.Overview),
Bild(treffer.PosterPath, PosterSize),
Bild(treffer.BackdropPath, BackdropSize));
return treffer is null ? null : await MitImdb(Als(treffer, title), "movie", ct);
}
public async Task<IReadOnlyList<MovieMetadata>> SearchMoviesAsync(
string title, int? year, CancellationToken ct = default)
{
var alle = await AlleAsync<TmdbMovie>("search/movie", title,
year is { } j ? ("year", j.ToString()) : null, ct);
return alle.Select(m => Als(m, title)).ToList();
}
public async Task<MovieMetadata?> GetMovieAsync(int providerId, CancellationToken ct = default)
{
var einstellung = await einstellungen.GetAsync(ct);
if (string.IsNullOrWhiteSpace(einstellung.ApiKey)) return null;
var film = await HolenAsync<TmdbMovie>(
$"movie/{providerId}?language={Uri.EscapeDataString(einstellung.Language)}", einstellung, ct);
return film is null ? null : await MitImdb(Als(film, ""), "movie", ct);
}
private static MovieMetadata Als(TmdbMovie roh, string rückfall) => new(
roh.Id, roh.Title ?? rückfall, JahrAus(roh.ReleaseDate), Leer(roh.Overview),
Bild(roh.PosterPath, PosterSize), Bild(roh.BackdropPath, BackdropSize));
public async Task<SeriesMetadata?> FindSeriesAsync(string title, int? year, CancellationToken ct = default)
{
var treffer = await SuchenAsync<TmdbSeries>("search/tv", title,
year is { } j ? ("first_air_date_year", j.ToString()) : null, s => s.Name, ct);
if (treffer is null) return null;
return treffer is null ? null : await MitImdb(Als(treffer, title), "tv", ct);
}
return new SeriesMetadata(
treffer.Id,
treffer.Name ?? title,
JahrAus(treffer.FirstAirDate),
Leer(treffer.Overview),
Bild(treffer.PosterPath, PosterSize),
Bild(treffer.BackdropPath, BackdropSize));
public async Task<IReadOnlyList<SeriesMetadata>> SearchSeriesAsync(
string title, int? year, CancellationToken ct = default)
{
var alle = await AlleAsync<TmdbSeries>("search/tv", title,
year is { } j ? ("first_air_date_year", j.ToString()) : null, ct);
return alle.Select(s => Als(s, title)).ToList();
}
public async Task<SeriesMetadata?> GetSeriesAsync(int providerId, CancellationToken ct = default)
{
var einstellung = await einstellungen.GetAsync(ct);
if (string.IsNullOrWhiteSpace(einstellung.ApiKey)) return null;
var serie = await HolenAsync<TmdbSeries>(
$"tv/{providerId}?language={Uri.EscapeDataString(einstellung.Language)}", einstellung, ct);
return serie is null ? null : await MitImdb(Als(serie, ""), "tv", ct);
}
private static SeriesMetadata Als(TmdbSeries roh, string rückfall) => new(
roh.Id, roh.Name ?? rückfall, JahrAus(roh.FirstAirDate), Leer(roh.Overview),
Bild(roh.PosterPath, PosterSize), Bild(roh.BackdropPath, BackdropSize));
/// <summary>Holt die IMDb-Kennung nach — eine Anfrage je neuem Treffer.</summary>
///
/// <remarks>
/// <b>Die Suche bei TMDB liefert sie nicht mit.</b> Gebraucht wird sie für
/// die NFO-Datei: wer später auf ein anderes Werkzeug wechselt, findet
/// damit denselben Titel wieder, auch wenn das Werkzeug TMDB gar nicht
/// kennt.
///
/// Scheitert der Nachschlag, bleibt sie leer — das ist kein Grund, den
/// ganzen Treffer wegzuwerfen.
/// </remarks>
private async Task<MovieMetadata> MitImdb(MovieMetadata film, string art, CancellationToken ct) =>
film with { ImdbId = await ImdbAsync(art, film.ProviderId, ct) };
private async Task<SeriesMetadata> MitImdb(SeriesMetadata serie, string art, CancellationToken ct) =>
serie with { ImdbId = await ImdbAsync(art, serie.ProviderId, ct) };
private async Task<string?> ImdbAsync(string art, int kennung, CancellationToken ct)
{
var einstellung = await einstellungen.GetAsync(ct);
if (string.IsNullOrWhiteSpace(einstellung.ApiKey)) return null;
var fremde = await HolenAsync<TmdbExternalIds>($"{art}/{kennung}/external_ids", einstellung, ct);
return Leer(fremde?.ImdbId);
}
public async Task<EpisodeMetadata?> FindEpisodeAsync(
@@ -99,6 +153,22 @@ public sealed class TmdbClient(
return new EpisodeMetadata(Leer(folge.Name), Leer(folge.Overview), Bild(folge.StillPath, StillSize));
}
/// <summary>Die ganze Trefferliste — für die Auswahl von Hand.</summary>
private async Task<List<T>> AlleAsync<T>(
string pfad, string titel, (string Name, string Wert)? zusatz, CancellationToken ct) where T : class
{
var einstellung = await einstellungen.GetAsync(ct);
if (string.IsNullOrWhiteSpace(einstellung.ApiKey) || string.IsNullOrWhiteSpace(titel)) return [];
var abfrage = $"{pfad}?language={Uri.EscapeDataString(einstellung.Language)}" +
$"&query={Uri.EscapeDataString(titel)}";
if (zusatz is { } z) abfrage += $"&{z.Name}={Uri.EscapeDataString(z.Wert)}";
var antwort = await HolenAsync<TmdbSearch<T>>(abfrage, einstellung, ct);
// Gedeckelt: wer aus fünfzig Vorschlägen wählen soll, wählt gar nicht.
return antwort?.Results?.Take(10).ToList() ?? [];
}
private async Task<T?> SuchenAsync<T>(
string pfad, string titel, (string Name, string Wert)? zusatz,
Func<T, string?> titelVon, CancellationToken ct) where T : class
@@ -202,6 +272,8 @@ public sealed class TmdbClient(
// Nur die Felder, die gebraucht werden. TMDB liefert dreissig weitere.
private sealed record TmdbSearch<T>([property: JsonPropertyName("results")] List<T>? Results);
private sealed record TmdbExternalIds([property: JsonPropertyName("imdb_id")] string? ImdbId);
private sealed record TmdbMovie(
[property: JsonPropertyName("id")] int Id,
[property: JsonPropertyName("title")] string? Title,