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>
319 lines
14 KiB
C#
319 lines
14 KiB
C#
using System.Net;
|
|
using System.Text.Json;
|
|
using System.Text.Json.Serialization;
|
|
using Halo.Core.Metadata;
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
namespace Halo.Metadata;
|
|
|
|
/// <summary>Fragt TMDB nach Titeln, Beschreibungen und Bildern.</summary>
|
|
///
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>Ein Fehlschlag ist kein Abbruch.</b> Antwortet TMDB nicht — kein Netz,
|
|
/// Kontingent erschöpft, Wartung —, kommt <c>null</c> zurück und der Titel
|
|
/// bleibt beim Dateinamen. Ein Scan, der an einem fremden Dienst scheitert,
|
|
/// wäre der falsche Entwurf: die Mediathek steht dann leer da, obwohl die
|
|
/// Dateien tadellos vorhanden sind.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Ein falscher Schlüssel ist dagegen sehr wohl etwas, das jemand erfahren
|
|
/// muss</b> — sonst sucht man stundenlang, warum keine Bilder erscheinen.
|
|
/// Deshalb nur dafür eine Ausnahme.
|
|
/// </para>
|
|
/// </remarks>
|
|
public sealed class TmdbClient(
|
|
HttpClient http,
|
|
ITmdbSettingsSource einstellungen,
|
|
ILogger<TmdbClient> log) : IMetadataProvider
|
|
{
|
|
public const string BaseAddress = "https://api.themoviedb.org/3/";
|
|
|
|
/// <summary>w500 für Plakate, w1280 für Hintergründe — genug für jeden Bildschirm.</summary>
|
|
///
|
|
/// <remarks>
|
|
/// Die Originalgrösse wäre bei einem Bestand mit tausend Titeln mehrere
|
|
/// Gigabyte Cache, ohne dass es auf einem Telefon je sichtbar würde.
|
|
/// </remarks>
|
|
private const string PosterSize = "w500";
|
|
private const string BackdropSize = "w1280";
|
|
private const string StillSize = "w300";
|
|
|
|
private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);
|
|
|
|
/// <summary>Ein Lese-Token ist ein JWT und fängt immer so an.</summary>
|
|
///
|
|
/// <remarks>
|
|
/// <b>Damit beides funktioniert, was auf TMDBs Kontoseite steht.</b> Dort
|
|
/// liegen zwei Dinge nebeneinander — „API Key" und „API Read Access
|
|
/// Token" —, und wer eines davon kopiert, soll nicht erst herausfinden
|
|
/// müssen, welches gemeint war.
|
|
/// </remarks>
|
|
private const string JwtAnfang = "eyJ";
|
|
|
|
public async Task<bool> IsConfiguredAsync(CancellationToken ct = default) =>
|
|
!string.IsNullOrWhiteSpace((await einstellungen.GetAsync(ct)).ApiKey);
|
|
|
|
public async Task<MovieMetadata?> FindMovieAsync(string title, int? year, CancellationToken ct = default)
|
|
{
|
|
var treffer = await SuchenAsync<TmdbMovie>("search/movie", title,
|
|
year is { } j ? ("year", j.ToString()) : null, m => m.Title, ct);
|
|
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);
|
|
return treffer is null ? null : await MitImdb(Als(treffer, title), "tv", ct);
|
|
}
|
|
|
|
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(
|
|
int seriesProviderId, int season, int episode, CancellationToken ct = default)
|
|
{
|
|
var einstellung = await einstellungen.GetAsync(ct);
|
|
if (string.IsNullOrWhiteSpace(einstellung.ApiKey)) return null;
|
|
|
|
var pfad = $"tv/{seriesProviderId}/season/{season}/episode/{episode}" +
|
|
$"?language={Uri.EscapeDataString(einstellung.Language)}";
|
|
|
|
var folge = await HolenAsync<TmdbEpisode>(pfad, einstellung, ct);
|
|
if (folge is null) return null;
|
|
|
|
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
|
|
{
|
|
var einstellung = await einstellungen.GetAsync(ct);
|
|
if (string.IsNullOrWhiteSpace(einstellung.ApiKey) || string.IsNullOrWhiteSpace(titel)) return null;
|
|
|
|
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);
|
|
var treffer = antwort?.Results;
|
|
if (treffer is null || treffer.Count == 0) return null;
|
|
|
|
// **Ein genau passender Name sticht den beliebtesten.**
|
|
//
|
|
// TMDB sortiert nach Beliebtheit. Ohne Jahresangabe führt das in die
|
|
// Irre: die Suche nach „Dark" lieferte „Dark Shadows" von 1966 statt
|
|
// der Serie von 2017 — am echten Dienst gemessen, nicht vermutet.
|
|
// Steht ein Treffer mit genau diesem Namen in der Liste, ist er
|
|
// gemeint.
|
|
//
|
|
// Nur bei Gleichheit, nicht bei Ähnlichkeit: eine eigene Bewertung zu
|
|
// bauen hiesse, TMDBs Rangfolge nachzubauen — mit weniger Daten.
|
|
var genau = treffer.FirstOrDefault(e =>
|
|
string.Equals(titelVon(e)?.Trim(), titel.Trim(), StringComparison.OrdinalIgnoreCase));
|
|
|
|
return genau ?? treffer[0];
|
|
}
|
|
|
|
/// <summary>Holt eine Antwort — mit Bearer, wo es geht.</summary>
|
|
///
|
|
/// <remarks>
|
|
/// <b>Der Lese-Token wandert in die Kopfzeile, nicht in die Adresse.</b>
|
|
/// Ein <c>?api_key=…</c> steht in jedem Zugriffsprotokoll, in jedem
|
|
/// Reverse Proxy und in jeder Fehlermeldung, die eine Adresse zitiert.
|
|
/// Eine Kopfzeile tut das nicht. Dazu kann ein Lese-Token nichts ändern,
|
|
/// nur lesen — bei einem Schlüssel, der in einem offenen Projekt
|
|
/// mitgeliefert wird, ist das der Unterschied, auf den es ankommt.
|
|
///
|
|
/// Der ältere Schlüssel geht trotzdem: er kann nur in die Adresse.
|
|
/// </remarks>
|
|
private async Task<T?> HolenAsync<T>(
|
|
string pfadMitAbfrage, TmdbSettings einstellung, CancellationToken ct) where T : class
|
|
{
|
|
var zugang = einstellung.ApiKey!;
|
|
var alsBearer = zugang.StartsWith(JwtAnfang, StringComparison.Ordinal);
|
|
var adresse = alsBearer
|
|
? pfadMitAbfrage
|
|
: pfadMitAbfrage + (pfadMitAbfrage.Contains('?') ? "&" : "?")
|
|
+ $"api_key={Uri.EscapeDataString(zugang)}";
|
|
|
|
try
|
|
{
|
|
using var anfrage = new HttpRequestMessage(HttpMethod.Get, adresse);
|
|
if (alsBearer) anfrage.Headers.Authorization = new("Bearer", zugang);
|
|
|
|
using var antwort = await http.SendAsync(anfrage, ct);
|
|
|
|
if (antwort.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
|
|
throw new MetadataCredentialsException(
|
|
"TMDB weist den hinterlegten Schlüssel zurück. Prüfe ihn in den Servereinstellungen.");
|
|
|
|
// 404 heisst bei TMDB schlicht „gibt es nicht" — etwa eine Folge,
|
|
// die dort noch nicht eingetragen ist.
|
|
if (antwort.StatusCode == HttpStatusCode.NotFound) return null;
|
|
|
|
if (!antwort.IsSuccessStatusCode)
|
|
{
|
|
log.LogWarning("TMDB antwortete mit {Code} auf {Pfad}.", (int)antwort.StatusCode, Gekürzt(adresse));
|
|
return null;
|
|
}
|
|
|
|
return await antwort.Content.ReadFromJsonSafeAsync<T>(Json, ct);
|
|
}
|
|
catch (MetadataCredentialsException) { throw; }
|
|
catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
|
|
catch (Exception fehler)
|
|
{
|
|
log.LogWarning(fehler, "TMDB war für {Pfad} nicht erreichbar.", Gekürzt(adresse));
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// <summary>Der Pfad ohne den Schlüssel — der gehört in kein Protokoll.</summary>
|
|
private static string Gekürzt(string pfadMitAbfrage)
|
|
{
|
|
var index = pfadMitAbfrage.IndexOf("api_key=", StringComparison.Ordinal);
|
|
return index < 0 ? pfadMitAbfrage : pfadMitAbfrage[..index] + "api_key=…";
|
|
}
|
|
|
|
private static RemoteImage? Bild(string? pfad, string grösse) =>
|
|
string.IsNullOrWhiteSpace(pfad) ? null : new RemoteImage($"https://image.tmdb.org/t/p/{grösse}{pfad}");
|
|
|
|
private static string? Leer(string? wert) => string.IsNullOrWhiteSpace(wert) ? null : wert;
|
|
|
|
private static int? JahrAus(string? datum) =>
|
|
datum is { Length: >= 4 } && int.TryParse(datum[..4], out var jahr) ? jahr : null;
|
|
|
|
// 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,
|
|
[property: JsonPropertyName("release_date")] string? ReleaseDate,
|
|
[property: JsonPropertyName("overview")] string? Overview,
|
|
[property: JsonPropertyName("poster_path")] string? PosterPath,
|
|
[property: JsonPropertyName("backdrop_path")] string? BackdropPath);
|
|
|
|
private sealed record TmdbSeries(
|
|
[property: JsonPropertyName("id")] int Id,
|
|
[property: JsonPropertyName("name")] string? Name,
|
|
[property: JsonPropertyName("first_air_date")] string? FirstAirDate,
|
|
[property: JsonPropertyName("overview")] string? Overview,
|
|
[property: JsonPropertyName("poster_path")] string? PosterPath,
|
|
[property: JsonPropertyName("backdrop_path")] string? BackdropPath);
|
|
|
|
private sealed record TmdbEpisode(
|
|
[property: JsonPropertyName("name")] string? Name,
|
|
[property: JsonPropertyName("overview")] string? Overview,
|
|
[property: JsonPropertyName("still_path")] string? StillPath);
|
|
}
|
|
|
|
internal static class HttpContentExtensions
|
|
{
|
|
/// <summary>Liest JSON und wirft nicht, wenn Unsinn zurückkommt.</summary>
|
|
///
|
|
/// <remarks>
|
|
/// Ein Reverse Proxy oder ein Anmeldeportal im Hotel-WLAN antwortet mit
|
|
/// HTML und dem Status 200. Darauf mit einer Ausnahme zu reagieren hiesse,
|
|
/// den ganzen Scan an einem fremden Netz scheitern zu lassen.
|
|
/// </remarks>
|
|
public static async Task<T?> ReadFromJsonSafeAsync<T>(
|
|
this HttpContent inhalt, JsonSerializerOptions optionen, CancellationToken ct) where T : class
|
|
{
|
|
try
|
|
{
|
|
await using var strom = await inhalt.ReadAsStreamAsync(ct);
|
|
return await JsonSerializer.DeserializeAsync<T>(strom, optionen, ct);
|
|
}
|
|
catch (JsonException) { return null; }
|
|
}
|
|
}
|