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:
co-authored by
Claude Opus 5
parent
79b6001f86
commit
f98b995050
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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><uniqueid></c> —
|
||||
/// die neuere Form — und einmal als <c><tmdbid></c> und
|
||||
/// <c><imdbid></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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user