Halo heisst ab hier Flaro — die Marke stand im Weg

Der Upload zu App Store Connect scheiterte am Namen. „Halo" ist eine
eingetragene Marke von Microsoft; im App Store heisst zwar keine App exakt
so, aber reservierte und markenrechtlich belegte Namen tauchen in keiner
Suche auf. Der einzige verlässliche Test war die Eingabe dort, und die ging
daneben.

**Der Name wurde diesmal vorher geprüft.** Zwanzig Kandidaten gegen den
deutschen und amerikanischen App Store, auf exakte Treffer und auf ähnliche
Namen. „Kavo" wäre dieselbe Wand gewesen — exakt belegt in beiden Stores, in
Deutschland von KaVo Dental GmbH. „Flare" ist mit 42 ähnlichen Apps je Store
der am dichtesten besetzte Name der ganzen Runde. **Flaro** ist in beiden
frei und liest sich als eigenes Wort, nicht als Flare mit Tippfehler.

Das bleibt eine Store-Suche und keine Markenrecherche — reservierte Namen
ohne veröffentlichte App sieht sie nicht. Aber es ist mehr Prüfung, als beim
letzten Mal stattfand.

---

2160 Textvorkommen in 273 Dateien, 49 Pfade. Betroffen ist diesmal auch der
Server: `Flaro.Api`, `Flaro.Core`, `Flaro.Data`, `Flaro.Media`,
`Flaro.Metadata`, `FlaroContext`, `FlaroPaths`, `flaro.db`, die Variablen
`FLARO_DATA`, `FLARO_CACHE`, `FLARO_TRANSCODE`, `FLARO_WRITE_SIDECARS`, und
das Image.

**Vor dem Ersetzen geprüft**, ob „halo" irgendwo als Teil eines anderen
Wortes steckt — und ob es im TMDB-Token vorkommt. Tat es nicht; ein blindes
Ersetzen hätte den sonst zerlegt.

`docs/superpowers/specs/` bleibt wie beim letzten Mal unangetastet: datierte
Entwurfsdokumente sind ein Protokoll getroffener Entscheidungen. Das
Server-Dokument hat stattdessen einen Nachtrag bekommen, damit es niemanden
in die Irre führt.

**Die Bundle-ID wandert mit**, zum zweiten Mal an einem Tag:
`com.scarriffleservices.flaro`. Wer die vorige Fassung installiert hat, hat
jetzt zwei Karteileichen auf dem Gerät — beide löschen, dann ist Ruhe.

308 Server-Tests grün, 368 im Swift-Paket, null Warnungen. iOS und tvOS
bauen, das Image baut, und ein frischer Container läuft durch Einrichtung
und Anmeldung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-03 19:38:27 +02:00
co-authored by Claude Opus 5
parent f98b995050
commit a7cf6c5def
390 changed files with 2078 additions and 2069 deletions
+58
View File
@@ -0,0 +1,58 @@
using System.Security.Cryptography;
namespace Flaro.Core.Auth;
/// <summary>Eine laufende Anmeldung per Code — für den Fernseher.</summary>
///
/// <remarks>
/// <para>
/// Ein Passwort auf der Apple-TV-Fernbedienung einzutippen ist eine Zumutung,
/// die man selbst jedes Mal spürt. Stattdessen: sechsstelliger Code auf dem
/// Bildschirm, Bestätigung am Telefon.
/// </para>
/// <para>
/// <b>Der Code allein holt nie ein Token ab.</b> Dafür gibt es den Abholschein,
/// den nur das anfragende Gerät kennt und der hier nur als Fingerabdruck
/// liegt. Ohne ihn könnte jemand sechsstellige Zahlen durchprobieren — eine
/// Million Versuche sind nichts, und am Ende stünde ein fremdes Gerät in der
/// Mediathek.
/// </para>
/// </remarks>
public sealed class AuthCode
{
/// <summary>Kurz genug zum Vorlesen, lang genug zum Nichterraten.</summary>
public const int CodeLength = 6;
/// <summary>So lange, wie jemand braucht, um zum Telefon zu greifen.</summary>
public static readonly TimeSpan Lifetime = TimeSpan.FromMinutes(5);
public Guid Id { get; init; } = Guid.CreateVersion7();
public required string Code { get; init; }
public required string HandleFingerprint { get; init; }
public required string DeviceName { get; init; }
public required string DeviceId { get; init; }
public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow;
public required DateTimeOffset ExpiresAt { get; init; }
/// <summary>Wer bestätigt hat — <c>null</c>, solange niemand.</summary>
public Guid? ApprovedByUserId { get; set; }
/// <summary>Wann das Token abgeholt wurde. Danach ist der Code verbraucht.</summary>
public DateTimeOffset? ConsumedAt { get; set; }
public bool IsExpired(DateTimeOffset now) => now >= ExpiresAt;
/// <summary>Ob jetzt ein Token herausgegeben werden darf.</summary>
public bool CanBeRedeemed(DateTimeOffset now) =>
ApprovedByUserId is not null && ConsumedAt is null && !IsExpired(now);
/// <summary>Sechs Ziffern, gleichverteilt gezogen.</summary>
///
/// <remarks>
/// <see cref="RandomNumberGenerator"/> und nicht <see cref="Random"/>: der
/// Code ist ein Zugang. Die obere Grenze ist ausschliesslich, führende
/// Nullen bleiben erhalten.
/// </remarks>
public static string NewCode() =>
RandomNumberGenerator.GetInt32(0, 1_000_000).ToString("D" + CodeLength);
}
+33
View File
@@ -0,0 +1,33 @@
namespace Flaro.Core.Auth;
/// <summary>Ein angemeldetes Gerät.</summary>
///
/// <remarks>
/// Ein Eintrag je Gerät, nicht je Benutzer: so lässt sich ein verlorenes
/// Telefon einzeln aussperren, ohne alle anderen abzumelden.
/// </remarks>
public sealed class DeviceToken
{
public Guid Id { get; init; } = Guid.CreateVersion7();
public required Guid UserId { get; init; }
/// <summary>Siehe <see cref="Secrets.Fingerprint"/> — nie das Token selbst.</summary>
public required string TokenFingerprint { get; init; }
/// <summary>Wie das Gerät sich nennt, für die Geräteliste.</summary>
public required string DeviceName { get; init; }
/// <summary>Die vom Client vergebene Gerätekennung.</summary>
///
/// <remarks>
/// Damit dieselbe App nach einer erneuten Anmeldung denselben Eintrag
/// ablöst, statt die Liste mit Karteileichen zu füllen.
/// </remarks>
public required string DeviceId { get; init; }
public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow;
public DateTimeOffset LastSeenAt { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset? RevokedAt { get; set; }
public bool IsValid(DateTimeOffset now) => RevokedAt is null || RevokedAt > now;
}
+32
View File
@@ -0,0 +1,32 @@
using System.Security.Cryptography;
using System.Text;
namespace Flaro.Core.Auth;
/// <summary>Erzeugt und verfingert Geheimnisse, die Clients vorzeigen.</summary>
///
/// <remarks>
/// <b>Abgelegt wird nur der Fingerabdruck, nie das Geheimnis.</b> Wer die
/// Datenbank erbeutet, hat damit Hashes und keine Zugänge. Dass das überhaupt
/// erwähnt werden muss: bei Medienservern liegen Token regelmässig im
/// Klartext, und ein einziges Datenbankleck gibt dann jedem alles.
///
/// SHA-256 ohne Salz und ohne Streckung genügt hier — anders als bei
/// Passwörtern. Diese Geheimnisse sind 256 Bit Zufall; sie zu erraten ist
/// aussichtslos, ganz gleich wie schnell jemand hashen kann.
/// </remarks>
public static class Secrets
{
private const int Bytes = 32;
/// <summary>Ein neues Geheimnis, in URL-sicherem Base64.</summary>
public static string Create() =>
Base64UrlEncode(RandomNumberGenerator.GetBytes(Bytes));
/// <summary>Der Fingerabdruck, wie er in der Datenbank steht.</summary>
public static string Fingerprint(string secret) =>
Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(secret)));
private static string Base64UrlEncode(byte[] roh) =>
Convert.ToBase64String(roh).TrimEnd('=').Replace('+', '-').Replace('/', '_');
}
@@ -0,0 +1,136 @@
using Flaro.Core.Library;
namespace Flaro.Core.Delivery;
public enum DeliveryMethod
{
/// <summary>Die Datei geht unverändert hinaus.</summary>
DirectFile,
/// <summary>Die Spuren wandern in eine MP4 — Bild und meist auch Ton unberührt.</summary>
Remux,
}
public enum DeliveryReason
{
NativelySupported,
ContainerUnsupported,
VideoCodecUnsupported,
AudioCodecUnsupported,
/// <summary>Untertitel müssen für MP4 nach tx3g umgeschrieben werden.</summary>
SubtitlesNeedConversion,
/// <summary>Bildbasierte Untertitel passen in keine MP4 und fallen weg.</summary>
ImageSubtitlesDropped,
}
/// <summary>Was mit einer Datei geschehen muss, damit das Gerät sie öffnet.</summary>
public sealed record DeliveryPlan(
DeliveryMethod Method,
IReadOnlyList<DeliveryReason> Reasons,
/// <summary>Tonspuren, die neu kodiert werden müssen — Nummern wie in der Datei.</summary>
IReadOnlyList<int> AudioToEncode,
/// <summary>Textspuren, die nicht mitkönnen.</summary>
IReadOnlyList<int> SubtitlesDropped)
{
public bool NeedsWork => Method != DeliveryMethod.DirectFile;
}
/// <summary>Entscheidet, ob eine Datei unverändert hinausgehen kann.</summary>
///
/// <remarks>
/// <para>
/// <b>Das Bild wird nie angefasst.</b> Beim Umpacken wandert es Byte für Byte
/// in die neue Schachtel; das kostet den Server fast nichts. Nur Tonspuren in
/// Codecs, die das Gerät nicht kennt, werden neu gerechnet — und auch dann
/// keine einzige Bildzeile.
/// </para>
/// <para>
/// <b>Was fehlt, zählt als passend.</b> Nennt die Datei keinen Codec, ist das
/// keine Aussage über seine Untauglichkeit. Vorsichtshalber umzupacken hiesse,
/// aus einer tadellosen MP4 eine neue Datei zu machen.
/// </para>
/// </remarks>
public static class DeliveryDecider
{
/// <summary>Untertitel, die als Bild vorliegen — MP4 hat dafür keinen Platz.</summary>
///
/// <remarks>
/// <b>Betrifft fast jeden Blu-ray-Rip.</b> PGS ist dort die Regel. Diese
/// Spuren fallen beim Umpacken weg, und das muss gesagt werden, statt
/// stillschweigend zu geschehen — sonst sucht jemand nach Untertiteln, die
/// der Server bewusst weggelassen hat.
/// </remarks>
private static readonly HashSet<string> BildUntertitel = new(StringComparer.OrdinalIgnoreCase)
{ "hdmv_pgs_subtitle", "pgssub", "dvd_subtitle", "dvdsub", "xsub" };
/// <summary>Textuntertitel, die sich nach tx3g umschreiben lassen.</summary>
private static readonly HashSet<string> TextUntertitel = new(StringComparer.OrdinalIgnoreCase)
{ "subrip", "srt", "ass", "ssa", "mov_text", "webvtt", "text" };
public static DeliveryPlan ForDownload(
string? container, IReadOnlyList<ProbedStream> spuren, DeviceProfile? profil = null)
{
profil ??= DeviceProfile.AppleDefault;
var gründe = new List<DeliveryReason>();
var neuZuRechnen = new List<int>();
var fallenWeg = new List<int>();
var schachtelPasst = Passt(container, profil.Containers);
if (!schachtelPasst) gründe.Add(DeliveryReason.ContainerUnsupported);
foreach (var spur in spuren)
{
switch (spur.Kind)
{
case MediaStreamKind.Video when !Passt(spur.Codec, profil.VideoCodecs):
gründe.Add(DeliveryReason.VideoCodecUnsupported);
break;
case MediaStreamKind.Audio when !Passt(spur.Codec, profil.AudioCodecs):
gründe.Add(DeliveryReason.AudioCodecUnsupported);
neuZuRechnen.Add(spur.Index);
break;
case MediaStreamKind.Subtitle when spur.Codec is { } codec && BildUntertitel.Contains(codec):
fallenWeg.Add(spur.Index);
break;
case MediaStreamKind.Subtitle when spur.Codec is { } codec
&& TextUntertitel.Contains(codec)
&& !codec.Equals("mov_text", StringComparison.OrdinalIgnoreCase):
gründe.Add(DeliveryReason.SubtitlesNeedConversion);
break;
}
}
if (fallenWeg.Count > 0) gründe.Add(DeliveryReason.ImageSubtitlesDropped);
// Alles passt, und nichts fällt weg: die Datei geht, wie sie ist.
var mussUmgepackt = !schachtelPasst
|| gründe.Contains(DeliveryReason.VideoCodecUnsupported)
|| neuZuRechnen.Count > 0
|| gründe.Contains(DeliveryReason.SubtitlesNeedConversion);
if (!mussUmgepackt)
{
// Bildbasierte Untertitel in einer MP4 wären schon beim Erzeugen
// der Datei weggefallen — sie können hier also gar nicht stehen.
return new DeliveryPlan(
DeliveryMethod.DirectFile, [DeliveryReason.NativelySupported], [], fallenWeg);
}
return new DeliveryPlan(
DeliveryMethod.Remux, Eindeutig(gründe), neuZuRechnen, fallenWeg);
}
private static bool Passt(string? wert, IReadOnlySet<string> erlaubt) =>
string.IsNullOrWhiteSpace(wert) || erlaubt.Contains(wert);
private static List<DeliveryReason> Eindeutig(List<DeliveryReason> gründe) =>
gründe.Distinct().ToList();
}
@@ -0,0 +1,41 @@
namespace Flaro.Core.Delivery;
/// <summary>Was ein Gerät von sich aus öffnen kann.</summary>
///
/// <remarks>
/// <para>
/// <b>Diese Liste steht hier ein zweites Mal.</b> Dieselbe Aufstellung führt
/// die App in Swift, und solange Server und Client verschiedene Sprachen
/// sprechen, lässt sie sich nicht teilen. Das ist der bewusst gezahlte Preis
/// für eine eigene Schnittstelle — und der Grund, warum beide Seiten
/// Prüfungen dazu haben: auseinanderlaufen würden sie sonst unbemerkt.
/// </para>
/// <para>
/// Der Client schickt sein Profil künftig selbst mit. Bis dahin gilt
/// <see cref="AppleDefault"/>, und das ist keine Verlegenheit: es ist genau
/// die Liste, an der sich entscheidet, ob umgepackt werden muss.
/// </para>
/// </remarks>
public sealed record DeviceProfile(
IReadOnlySet<string> Containers,
IReadOnlySet<string> VideoCodecs,
IReadOnlySet<string> AudioCodecs)
{
/// <summary>Was AVFoundation auf iOS, tvOS und macOS öffnet.</summary>
///
/// <remarks>
/// <b>Kein mkv.</b> Das ist keine Auslassung, sondern der Kern des
/// Problems: Matroska ist die übliche Schachtel für Remuxe, und
/// AVFoundation öffnet sie nicht — obwohl Bild und Ton darin oft genau
/// das sind, was sie bestens abspielt.
///
/// <b>Kein DTS, kein TrueHD, kein Vorbis.</b> Die drei Gründe, warum ein
/// sonst tadelloser Film stumm bliebe.
/// </remarks>
public static readonly DeviceProfile AppleDefault = new(
Containers: new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "mp4", "m4v", "mov" },
VideoCodecs: new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{ "h264", "avc1", "hevc", "h265", "hvc1", "dvh1", "dvhe" },
AudioCodecs: new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{ "aac", "mp3", "ac3", "eac3", "alac", "pcm", "lpcm", "flac" });
}
+13
View File
@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Konscious.Security.Cryptography.Argon2" Version="1.3.1" />
</ItemGroup>
</Project>
@@ -0,0 +1,20 @@
namespace Flaro.Core.Libraries;
public enum LibraryKind { Movies, Shows }
/// <summary>Ein Verzeichnis mit Medien, wie der Betreiber es eingerichtet hat.</summary>
///
/// <remarks>
/// <b>In der Datenbank und nicht in der Umgebung.</b> Stünden Medienpfade in
/// der Compose-Datei, müsste der Container neu starten, um eine Bibliothek
/// hinzuzufügen — und ein Fremder, der Flaro zum ersten Mal startet, hätte
/// keine Datei, in der schon das Richtige steht.
/// </remarks>
public sealed class Library
{
public Guid Id { get; init; } = Guid.CreateVersion7();
public required string Name { get; set; }
public required LibraryKind Kind { get; set; }
public required string Path { get; set; }
public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow;
}
+211
View File
@@ -0,0 +1,211 @@
using System.Globalization;
using System.Text.RegularExpressions;
namespace Flaro.Core.Library;
public sealed record MovieName(string Title, int? Year);
public sealed record EpisodeName(string SeriesTitle, int Season, int Episode, string? EpisodeTitle);
/// <summary>Macht aus einem Dateinamen wieder Titel, Jahr, Staffel und Folge.</summary>
///
/// <remarks>
/// <para>
/// <b>Hier scheitern Medienserver in der Praxis.</b> Was auf der Platte liegt,
/// heisst nicht „Der Fall", sondern
/// <c>Notruf.Hafenkante.S01E01.German.1080p.WEB-DL.x264-GRP.mkv</c>, und was
/// davon Titel ist und was Beiwerk, steht nirgends. Rät man falsch, sucht die
/// Metadatenquelle nach „Notruf Hafenkante German 1080p" und findet nichts —
/// der Titel bleibt dann für immer der Dateiname.
/// </para>
/// <para>
/// <b>Das Verfahren: abschneiden, nicht herauslösen.</b> Alles ab dem ersten
/// Beiwerk-Merkmal fliegt weg. Einzelne Tags zu entfernen und den Rest
/// zusammenzuschieben klingt gründlicher, lässt aber bei jedem unbekannten Tag
/// Bruchstücke im Titel stehen — und unbekannte Tags gibt es immer.
/// </para>
/// </remarks>
public static partial class FileNames
{
/// <summary>Merkmale, ab denen nur noch Technik kommt.</summary>
///
/// <remarks>
/// Nicht vollständig und kann es nicht sein. Es muss nur das erste treffen
/// — alles dahinter fällt ohnehin weg.
/// </remarks>
private static readonly HashSet<string> Beiwerk = new(StringComparer.OrdinalIgnoreCase)
{
// Quelle
"bluray", "blu-ray", "bdrip", "brrip", "bdremux", "remux", "dvdrip", "dvd", "dvdr",
"webrip", "web-dl", "webdl", "web", "hdtv", "pdtv", "hdrip", "cam", "ts", "tc", "r5",
"uhdbd", "uhd", "hddvd",
// Bild
"x264", "x265", "h264", "h265", "hevc", "avc", "xvid", "divx", "av1", "mpeg2", "vc1",
"hdr", "hdr10", "hdr10plus", "dolbyvision", "dovi", "sdr", "10bit", "8bit",
// Ton
"aac", "ac3", "eac3", "dts", "dtshd", "dts-hd", "truehd", "atmos", "flac", "opus",
"mp3", "dd", "ddp", "dd5", "ddp5", "lpcm", "pcm",
// Fassung
"proper", "repack", "rerip", "extended", "unrated", "uncut", "remastered", "imax",
"theatrical", "limited", "internal", "complete", "retail", "subbed", "dubbed",
// Sprache und Herkunft, wie die Szene sie schreibt
"german", "englisch", "english", "multi", "dual", "dl", "ger", "eng", "vo", "vost",
"omu", "subs", "sub",
};
/// <summary>Auflösungen wie 1080p, 2160p, 720i — und 4K.</summary>
[GeneratedRegex(@"^\d{3,4}[pi]$|^[48]k$", RegexOptions.IgnoreCase)]
private static partial Regex Auflösung();
/// <summary>S01E02, s1e2, S01.E02, S01E02E03 — die erste Folge zählt.</summary>
[GeneratedRegex(@"\bS(?<staffel>\d{1,3})[\s._-]*E(?<folge>\d{1,4})", RegexOptions.IgnoreCase)]
private static partial Regex StaffelUndFolge();
/// <summary>1x02, 01x02 — die ältere Schreibweise.</summary>
[GeneratedRegex(@"(?<![\dx])(?<staffel>\d{1,2})x(?<folge>\d{2,3})(?![\dx])", RegexOptions.IgnoreCase)]
private static partial Regex KreuzSchreibweise();
/// <summary>Ein Jahr in Klammern: (1999) oder [1999].</summary>
[GeneratedRegex(@"[\(\[](?<jahr>(?:18|19|20)\d{2})[\)\]]")]
private static partial Regex JahrInKlammern();
[GeneratedRegex(@"\s+")]
private static partial Regex Leerraum();
/// <summary>Liest Titel und Jahr aus einem Filmdateinamen.</summary>
public static MovieName ParseMovie(string dateiname)
{
var (worte, jahrAusKlammern) = Zerlegen(dateiname);
var (titel, jahr) = TitelBis(worte, grenze: worte.Length);
return new MovieName(titel.Length > 0 ? titel : Rohname(dateiname), jahrAusKlammern ?? jahr);
}
/// <summary>Liest Serie, Staffel, Folge und Folgentitel — oder <c>null</c>.</summary>
///
/// <remarks>
/// <c>null</c> heisst: hier steht keine Folgennummer. Dann ist es entweder
/// ein Film oder etwas, das niemand benennen kann — in beiden Fällen wäre
/// eine geratene Staffel 1 schlimmer als gar keine.
/// </remarks>
public static EpisodeName? ParseEpisode(string dateiname)
{
var roh = Rohname(dateiname);
var treffer = StaffelUndFolge().Match(roh);
if (!treffer.Success) treffer = KreuzSchreibweise().Match(roh);
if (!treffer.Success) return null;
if (!int.TryParse(treffer.Groups["staffel"].Value, out var staffel) ||
!int.TryParse(treffer.Groups["folge"].Value, out var folge)) return null;
var davor = roh[..treffer.Index];
var danach = roh[(treffer.Index + treffer.Length)..];
var (worteDavor, _) = Zerlegen(davor, istDateiname: false);
var (serie, _) = TitelBis(worteDavor, worteDavor.Length);
var (worteDanach, _) = Zerlegen(danach, istDateiname: false);
var (folgenTitel, _) = TitelBis(worteDanach, worteDanach.Length);
return new EpisodeName(
SeriesTitle: serie.Length > 0 ? serie : Rohname(dateiname),
Season: staffel,
Episode: folge,
EpisodeTitle: folgenTitel.Length > 0 ? folgenTitel : null);
}
/// <summary>Nimmt Worte, bis das erste Beiwerk oder eine Jahreszahl kommt.</summary>
private static (string Titel, int? Jahr) TitelBis(string[] worte, int grenze)
{
var genommen = new List<string>();
int? jahr = null;
for (var i = 0; i < grenze; i++)
{
var wort = worte[i];
if (IstBeiwerk(wort)) break;
// Eine Jahreszahl beendet den Titel — aber nur, wenn schon etwas
// davorsteht. Sonst verlöre „2012" seinen ganzen Namen.
if (genommen.Count > 0 && IstPlausiblesJahr(wort, out var gelesen))
{
jahr = gelesen;
break;
}
genommen.Add(wort);
}
return (Säubern(string.Join(' ', genommen)), jahr);
}
private static bool IstBeiwerk(string wort)
{
var blank = wort.Trim('[', ']', '(', ')', '{', '}', '-', '_');
if (blank.Length == 0) return false;
if (Beiwerk.Contains(blank) || Auflösung().IsMatch(blank)) return true;
// „x264-GRP" und „DD5.1" hängen am Beiwerk fest.
var vorStrich = blank.Split('-', '.')[0];
return vorStrich.Length > 1 && (Beiwerk.Contains(vorStrich) || Auflösung().IsMatch(vorStrich));
}
/// <summary>Vier Ziffern allein machen noch kein Jahr.</summary>
///
/// <remarks>
/// <b>„Blade Runner 2049" ist der Fall, an dem sich das entscheidet.</b>
/// Eine Obergrenze knapp über heute trennt eine Jahreszahl von einer Zahl
/// im Titel — alles, was weit in der Zukunft liegt, gehört zum Namen.
/// </remarks>
private static bool IstPlausiblesJahr(string wort, out int jahr)
{
jahr = 0;
var blank = wort.Trim('[', ']', '(', ')');
if (blank.Length != 4 || !int.TryParse(blank, NumberStyles.None, CultureInfo.InvariantCulture, out var zahl))
return false;
if (zahl < 1880 || zahl > DateTime.UtcNow.Year + 2) return false;
jahr = zahl;
return true;
}
/// <summary>Dateiname ohne Pfad und ohne Endung.</summary>
private static string Rohname(string dateiname) => Path.GetFileNameWithoutExtension(dateiname);
/// <summary>Zerlegt in Worte und holt ein eingeklammertes Jahr heraus.</summary>
///
/// <remarks>
/// <b>Punkte werden nur dann zu Leerzeichen, wenn sonst keine da sind.</b>
/// „The.Matrix.1999" braucht es; „S.W.A.T. (2003)" verlöre dadurch seinen
/// Namen. Dass im Namen überhaupt Leerzeichen stehen, heisst: hier hat
/// jemand von Hand benannt, und seine Punkte sind gewollt.
/// </remarks>
private static (string[] Worte, int? JahrInKlammern) Zerlegen(string eingabe, bool istDateiname = true)
{
var text = istDateiname ? Rohname(eingabe) : eingabe;
int? jahr = null;
var geklammert = JahrInKlammern().Match(text);
if (geklammert.Success && int.TryParse(geklammert.Groups["jahr"].Value, out var gelesen))
{
jahr = gelesen;
text = text.Remove(geklammert.Index, geklammert.Length);
}
text = text.Replace('_', ' ');
if (!text.Contains(' ')) text = text.Replace('.', ' ');
return (Leerraum().Split(text.Trim()).Where(w => w.Length > 0).ToArray(), jahr);
}
/// <summary>Räumt Trennzeichen weg, die am Rand übrig bleiben.</summary>
///
/// <remarks>
/// <b>Der Punkt steht ausdrücklich nicht dabei.</b> Aus „S.W.A.T." würde
/// sonst „S.W.A.T" — und ein Punkt am Titelende ist ohnehin selten, weil
/// Trennpunkte schon beim Zerlegen zu Leerzeichen werden.
/// </remarks>
private static string Säubern(string roh) =>
Leerraum().Replace(roh.Replace('_', ' '), " ").Trim(' ', '-', '–', ',', '[', ']', '(', ')');
}
@@ -0,0 +1,33 @@
namespace Flaro.Core.Library;
/// <summary>Was in einer Datei steckt.</summary>
public sealed record ProbeResult(
string? Container,
TimeSpan? Duration,
long? BitRate,
IReadOnlyList<ProbedStream> Streams);
public sealed record ProbedStream(
int Index,
MediaStreamKind Kind,
string? Codec,
string? Language,
string? Title,
bool IsDefault,
bool IsForced,
int? Channels,
int? Width,
int? Height);
/// <summary>Liest die technischen Angaben einer Datei.</summary>
///
/// <remarks>
/// Als Schnittstelle, damit alles darauf Aufbauende ohne ffmpeg prüfbar
/// bleibt — aus demselben Grund wie beim Metadaten-Anbieter. <c>null</c>
/// heisst: nicht lesbar. Das ist kein Abbruch, sondern eine Datei, über die
/// nichts bekannt ist.
/// </remarks>
public interface IMediaProbe
{
Task<ProbeResult?> ProbeAsync(string pfad, CancellationToken ct = default);
}
@@ -0,0 +1,29 @@
namespace Flaro.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);
}
@@ -0,0 +1,93 @@
namespace Flaro.Core.Library;
/// <summary>Eine gefundene Datei, so wie das Dateisystem sie beschreibt.</summary>
public sealed record ScannedFile(string Path, long SizeBytes, DateTimeOffset ModifiedAt);
/// <summary>Was beim Durchlaufen schiefging, ohne den Lauf abzubrechen.</summary>
public sealed record WalkProblem(string Path, string Reason);
public sealed record WalkResult(IReadOnlyList<ScannedFile> Files, IReadOnlyList<WalkProblem> Problems);
/// <summary>Läuft ein Bibliotheksverzeichnis ab und sammelt Videodateien.</summary>
///
/// <remarks>
/// <para>
/// <b>Von Hand rekursiv statt mit <c>AllDirectories</c>.</b> Die bequeme
/// Fassung läuft auch in <c>@eaDir</c> hinein — Synologys Vorschauablage, die
/// eine Kopie <i>jeder</i> Datei enthält. Wer sie mitnimmt, hat seine
/// Mediathek doppelt, zur Hälfte mit unbrauchbaren Dateien. Äste beschneiden
/// kann nur, wer selbst absteigt.
/// </para>
/// <para>
/// <b>Ein gesperrtes Unterverzeichnis bricht den Lauf nicht ab</b>, sondern
/// wird vermerkt. Sonst entschiede ein einzelner falsch gesetzter Ordner
/// darüber, ob überhaupt etwas gefunden wird — und die Mediathek bliebe leer,
/// ohne dass jemand sagen könnte, warum.
/// </para>
/// </remarks>
public static class LibraryWalker
{
/// <summary>Tiefer als das ist kein Bestand, sondern eine Schleife.</summary>
///
/// <remarks>
/// Verweise im Dateisystem können im Kreis zeigen. Ohne Grenze liefe der
/// Scan, bis der Arbeitsspeicher voll ist.
/// </remarks>
private const int MaximaleTiefe = 12;
public static WalkResult Walk(string wurzel)
{
var gefunden = new List<ScannedFile>();
var probleme = new List<WalkProblem>();
if (!Directory.Exists(wurzel))
{
probleme.Add(new WalkProblem(wurzel, "Das Verzeichnis gibt es nicht."));
return new WalkResult(gefunden, probleme);
}
Absteigen(wurzel, 0, gefunden, probleme);
return new WalkResult(gefunden, probleme);
}
private static void Absteigen(string ordner, int tiefe, List<ScannedFile> gefunden, List<WalkProblem> probleme)
{
if (tiefe > MaximaleTiefe)
{
probleme.Add(new WalkProblem(ordner, $"Tiefer als {MaximaleTiefe} Ebenen — hier wird nicht weitergesucht."));
return;
}
string[] dateien, unterordner;
try
{
dateien = Directory.GetFiles(ordner);
unterordner = Directory.GetDirectories(ordner);
}
catch (Exception fehler) when (fehler is UnauthorizedAccessException or IOException)
{
probleme.Add(new WalkProblem(ordner, fehler.Message));
return;
}
foreach (var datei in dateien)
{
if (!MediaExtensions.IsVideo(datei)) continue;
try
{
var angaben = new FileInfo(datei);
gefunden.Add(new ScannedFile(datei, angaben.Length, angaben.LastWriteTimeUtc));
}
catch (Exception fehler) when (fehler is UnauthorizedAccessException or IOException)
{
probleme.Add(new WalkProblem(datei, fehler.Message));
}
}
foreach (var unter in unterordner)
{
if (MediaExtensions.IsSkippedDirectory(Path.GetFileName(unter))) continue;
Absteigen(unter, tiefe + 1, gefunden, probleme);
}
}
}
@@ -0,0 +1,203 @@
namespace Flaro.Core.Library;
/// <summary>Eine Datei auf der Platte, so wie der Scan sie vorgefunden hat.</summary>
///
/// <remarks>
/// <para>
/// <b>Die Datei zeigt auf den Titel, nicht umgekehrt.</b> Das sieht
/// herumgedreht aus und ist Absicht: ein Film liegt oft zweimal vor, als 4K
/// und als 1080p. Trüge der Film den Verweis auf seine eine Datei, wäre die
/// zweite Fassung ein Umbau am Datenmodell — so ist sie eine Zeile mehr.
/// </para>
/// <para>
/// Genau dasselbe gilt für Folgen.
/// </para>
/// </remarks>
public sealed class MediaFile
{
public Guid Id { get; init; } = Guid.CreateVersion7();
public required Guid LibraryId { get; init; }
/// <summary>Der Pfad, wie der Server ihn sieht — im Container, nicht auf dem Wirt.</summary>
public required string Path { get; set; }
public long SizeBytes { get; set; }
public DateTimeOffset ModifiedAt { get; set; }
/// <summary>Wann der letzte Scan diese Datei noch vorgefunden hat.</summary>
///
/// <remarks>
/// Daran hängt das Aufräumen: Was ein Lauf nicht gesehen hat, liegt nicht
/// mehr da. Ohne diese Angabe müsste der Scan sich merken, was er gesehen
/// hat, und das wächst mit dem Bestand.
/// </remarks>
public DateTimeOffset SeenAt { get; set; }
public Guid? MovieId { get; set; }
public Guid? EpisodeId { get; set; }
// --- Was ffprobe gelesen hat -----------------------------------------
/// <summary>Die Schachtel: mkv, mp4, ts …</summary>
public string? Container { get; set; }
public TimeSpan? Duration { get; set; }
public long? BitRate { get; set; }
/// <summary>Wann zuletzt untersucht — <c>null</c>, solange nie.</summary>
///
/// <remarks>
/// Daran hängt, ob der nächste Scan die Datei erneut untersucht. Ein
/// Durchgang kostet rund fünfzig Millisekunden; bei vierzigtausend Dateien
/// sind das über eine halbe Stunde, und die will niemand bei jedem Lauf
/// erneut aufbringen.
/// </remarks>
public DateTimeOffset? ProbedAt { get; set; }
}
public sealed class Movie
{
public Guid Id { get; init; } = Guid.CreateVersion7();
public required Guid LibraryId { get; init; }
/// <summary>Der Titel, wie er aus dem Dateinamen gelesen wurde.</summary>
///
/// <remarks>
/// <b>Daran ordnet der Scan zu, nicht an <see cref="Title"/>.</b> Der
/// angezeigte Titel kommt nach dem ersten Lauf von der Metadatenquelle und
/// ändert sich dabei — aus „The Matrix" wird „Matrix". Würde der nächste
/// Scan darauf suchen, fände er nichts, legte den Film ein zweites Mal an,
/// und die Mediathek wüchse bei jedem Lauf. Dieses Feld ändert sich nie.
/// </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>
public required string SortTitleValue { get; set; }
public int? Year { get; set; }
public string? Overview { get; set; }
/// <summary>Die Kennung bei TMDB, sobald zugeordnet.</summary>
public int? TmdbId { get; set; }
/// <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>
/// Der Schlüssel, nicht die Adresse bei TMDB. Würde die App dort unmittelbar
/// laden, bräuchte sie Internet für ein Plakat, das zehn Zentimeter weiter
/// auf der Platte liegt — und TMDB erführe, wer wann was ansieht.
/// </remarks>
public string? PosterImage { get; set; }
public string? BackdropImage { get; set; }
public DateTimeOffset AddedAt { get; init; } = DateTimeOffset.UtcNow;
}
public sealed class Series
{
public Guid Id { get; init; } = Guid.CreateVersion7();
public required Guid LibraryId { get; init; }
/// <summary>Der Titel, wie er aus dem Dateinamen gelesen wurde.</summary>
///
/// <remarks>
/// <b>Daran ordnet der Scan zu, nicht an <see cref="Title"/>.</b> Der
/// angezeigte Titel kommt nach dem ersten Lauf von der Metadatenquelle und
/// ändert sich dabei — aus „The Matrix" wird „Matrix". Würde der nächste
/// Scan darauf suchen, fände er nichts, legte den Film ein zweites Mal an,
/// und die Mediathek wüchse bei jedem Lauf. Dieses Feld ändert sich nie.
/// </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>
/// Der Schlüssel, nicht die Adresse bei TMDB. Würde die App dort unmittelbar
/// laden, bräuchte sie Internet für ein Plakat, das zehn Zentimeter weiter
/// auf der Platte liegt — und TMDB erführe, wer wann was ansieht.
/// </remarks>
public string? PosterImage { get; set; }
public string? BackdropImage { get; set; }
public DateTimeOffset AddedAt { get; init; } = DateTimeOffset.UtcNow;
}
public sealed class Episode
{
public Guid Id { get; init; } = Guid.CreateVersion7();
public required Guid SeriesId { get; init; }
public required int SeasonNumber { get; init; }
public required int EpisodeNumber { get; init; }
public string? Title { get; set; }
public string? Overview { get; set; }
/// <summary>Das Standbild der Folge.</summary>
public string? StillImage { get; set; }
public DateTimeOffset AddedAt { get; init; } = DateTimeOffset.UtcNow;
}
@@ -0,0 +1,52 @@
namespace Flaro.Core.Library;
/// <summary>Welche Dateien überhaupt als Medien gelten.</summary>
///
/// <remarks>
/// <b>Eine Erlaubnisliste, keine Verbotsliste.</b> Ein Bestand enthält
/// Bildchen, Untertitel, `.nfo`, `.DS_Store`, Papierkörbe und angefangene
/// Downloads. Alles aufzunehmen, was nicht ausdrücklich verboten ist, hiesse,
/// in der Mediathek irgendwann eine `Thumbs.db` als Film zu finden.
/// </remarks>
public static class MediaExtensions
{
private static readonly HashSet<string> Video = new(StringComparer.OrdinalIgnoreCase)
{
".mkv", ".mp4", ".m4v", ".avi", ".mov", ".ts", ".m2ts", ".mpg", ".mpeg",
".wmv", ".flv", ".webm", ".vob", ".iso", ".divx", ".ogv", ".3gp",
};
/// <summary>Was unterwegs oder unvollständig ist und deshalb nicht zählt.</summary>
///
/// <remarks>
/// Ein laufender Download heisst oft `.mkv.part` oder liegt in einem
/// Ordner, der mit einem Punkt anfängt. Ihn aufzunehmen hiesse: ein Titel
/// erscheint, verschwindet wieder, und beim Abspielen bricht er mittendrin
/// ab.
/// </remarks>
private static readonly string[] UnfertigeEndungen = [".part", ".!qb", ".crdownload", ".tmp", ".partial"];
public static bool IsVideo(string pfad)
{
var name = Path.GetFileName(pfad);
if (name.Length == 0 || name[0] == '.') return false;
if (UnfertigeEndungen.Any(e => name.EndsWith(e, StringComparison.OrdinalIgnoreCase))) return false;
return Video.Contains(Path.GetExtension(name));
}
/// <summary>Verzeichnisse, die in einem Bestand nichts Abspielbares enthalten.</summary>
///
/// <remarks>
/// `@eaDir` ist Synologys Vorschaubild-Ablage und enthält Kopien **jeder**
/// Datei — wer sie mitscannt, verdoppelt seine Mediathek mit unbrauchbaren
/// Dateien. Die übrigen sind Papierkörbe und Wiederherstellungspunkte.
/// </remarks>
private static readonly HashSet<string> ÜbersprungeneOrdner = new(StringComparer.OrdinalIgnoreCase)
{
"@eaDir", "#recycle", "$RECYCLE.BIN", "lost+found", ".Trashes", ".AppleDouble",
"System Volume Information", "extras", "featurettes", "behind the scenes", "trailers",
};
public static bool IsSkippedDirectory(string name) =>
name.Length > 0 && (name[0] == '.' || ÜbersprungeneOrdner.Contains(name));
}
@@ -0,0 +1,59 @@
namespace Flaro.Core.Library;
public enum MediaStreamKind { Video, Audio, Subtitle }
/// <summary>Eine Spur in einer Datei, so wie ffprobe sie beschreibt.</summary>
///
/// <remarks>
/// <para>
/// <b>Hier entscheidet sich alles Weitere.</b> Ob eine Datei unverändert
/// laufen kann, ob sie nur umgepackt werden muss oder ob gerechnet werden
/// muss, hängt an Schachtel und Codecs. Und die Frage, die das ganze Projekt
/// ausgelöst hat — <i>welche Tonspuren gibt es überhaupt</i> — ist ohne diese
/// Tabelle nicht zu beantworten.
/// </para>
/// </remarks>
public sealed class MediaStream
{
public Guid Id { get; init; } = Guid.CreateVersion7();
public required Guid MediaFileId { get; init; }
/// <summary>Die Nummer, unter der ffmpeg die Spur anspricht.</summary>
///
/// <remarks>
/// Nicht fortlaufend je Art: in einer Datei mit Bild, zwei Tonspuren und
/// einem Untertitel sind das 0 bis 3. Wer hier eine eigene Zählung führt,
/// baut dem späteren <c>-map 0:a:1</c> eine Falle.
/// </remarks>
public required int Index { get; init; }
public required MediaStreamKind Kind { get; init; }
public string? Codec { get; init; }
/// <summary>Die Sprache, wie die Datei sie führt — meist dreibuchstabig.</summary>
///
/// <remarks>
/// <c>null</c> oder <c>und</c> kommt oft vor und ist der Grund, warum beim
/// Herunterladen überhaupt gefragt werden muss: ohne Sprachkennung lässt
/// sich „deutsch" nicht von „deutsch mit Audiodeskription" unterscheiden.
/// </remarks>
public string? Language { get; init; }
/// <summary>Der Name der Spur, falls die Datei einen führt.</summary>
///
/// <remarks>
/// Oft die einzige Stelle, an der „Audiodeskription" oder „Kommentar"
/// steht — die Sprachkennung sagt bei beiden nur „deu".
/// </remarks>
public string? Title { get; init; }
public bool IsDefault { get; init; }
public bool IsForced { get; init; }
/// <summary>Nur bei Ton: wie viele Kanäle.</summary>
public int? Channels { get; init; }
/// <summary>Nur bei Bild.</summary>
public int? Width { get; init; }
public int? Height { get; init; }
}
+154
View File
@@ -0,0 +1,154 @@
using System.Xml;
using System.Xml.Linq;
namespace Flaro.Core.Library;
public sealed record MovieNfo(
string Title, int? Year, string? Plot, ProviderIds Ids, bool Locked);
public sealed record SeriesNfo(
string Title, int? Year, string? Plot, ProviderIds Ids, bool Locked);
public sealed record EpisodeNfo(
string? Title, int Season, int Episode, string? Plot);
/// <summary>Die NFO-Datei neben dem Film: schreiben und lesen.</summary>
///
/// <remarks>
/// <para>
/// <b>Die Form stammt von Kodi und wird von Jellyfin, Plex und Emby
/// gelesen.</b> Dass sie hier erzeugt wird, macht den Bestand tragbar: ein
/// Umzug auf einen anderen Server, ein Neuaufbau der Datenbank oder ein
/// zweiter Scan finden dieselben Zuordnungen wieder.
/// </para>
/// <para>
/// <b>Kennungen stehen doppelt drin.</b> Einmal als <c>&lt;uniqueid&gt;</c> —
/// die neuere Form — und einmal als <c>&lt;tmdbid&gt;</c> und
/// <c>&lt;imdbid&gt;</c>, weil ältere Werkzeuge nur die kennen. Zwei Zeilen
/// mehr gegen die Frage, warum ein anderes Programm nichts findet.
/// </para>
/// </remarks>
public static class Nfo
{
/// <summary>Verhindert, dass ein Scan eine Korrektur von Hand überschreibt.</summary>
///
/// <remarks>
/// Kodi und Jellyfin kennen dieses Feld unter demselben Namen. Steht es
/// auf <c>true</c>, hat jemand die Zuordnung ausdrücklich festgelegt —
/// und dann hat kein automatischer Lauf daran etwas zu ändern.
/// </remarks>
private const string Sperre = "lockdata";
public static string Write(MovieNfo film) => Dokument("movie", wurzel =>
{
wurzel.Add(new XElement("title", film.Title));
if (film.Year is { } jahr) wurzel.Add(new XElement("year", jahr));
if (film.Plot is { Length: > 0 }) wurzel.Add(new XElement("plot", film.Plot));
Kennungen(wurzel, film.Ids);
wurzel.Add(new XElement(Sperre, film.Locked ? "true" : "false"));
});
public static string Write(SeriesNfo serie) => Dokument("tvshow", wurzel =>
{
wurzel.Add(new XElement("title", serie.Title));
if (serie.Year is { } jahr) wurzel.Add(new XElement("year", jahr));
if (serie.Plot is { Length: > 0 }) wurzel.Add(new XElement("plot", serie.Plot));
Kennungen(wurzel, serie.Ids);
wurzel.Add(new XElement(Sperre, serie.Locked ? "true" : "false"));
});
public static string Write(EpisodeNfo folge) => Dokument("episodedetails", wurzel =>
{
if (folge.Title is { Length: > 0 }) wurzel.Add(new XElement("title", folge.Title));
wurzel.Add(new XElement("season", folge.Season));
wurzel.Add(new XElement("episode", folge.Episode));
if (folge.Plot is { Length: > 0 }) wurzel.Add(new XElement("plot", folge.Plot));
});
/// <summary>Liest Kennungen aus einer vorhandenen NFO.</summary>
///
/// <remarks>
/// Gibt bei unlesbarem Inhalt <see cref="ProviderIds.None"/> zurück statt
/// zu werfen: eine von Hand geschriebene Datei ist oft kaputt, und das
/// darf keinen Scan abbrechen.
/// </remarks>
public static ProviderIds ReadIds(string xml)
{
var wurzel = Lesen(xml);
if (wurzel is null) return ProviderIds.None;
int? tmdb = null;
string? imdb = null;
foreach (var eindeutig in wurzel.Elements("uniqueid"))
{
var art = (string?)eindeutig.Attribute("type");
var wert = eindeutig.Value.Trim();
if (art is null || wert.Length == 0) continue;
if (art.Equals("tmdb", StringComparison.OrdinalIgnoreCase) && int.TryParse(wert, out var zahl))
tmdb ??= zahl;
else if (art.Equals("imdb", StringComparison.OrdinalIgnoreCase))
imdb ??= wert.ToLowerInvariant();
}
// Die ältere Form, falls die neuere fehlt.
if (tmdb is null && int.TryParse(wurzel.Element("tmdbid")?.Value.Trim(), out var alt)) tmdb = alt;
imdb ??= wurzel.Element("imdbid")?.Value.Trim() is { Length: > 0 } a ? a.ToLowerInvariant() : null;
return new ProviderIds(tmdb, imdb);
}
/// <summary>Ob diese Zuordnung von Hand festgelegt wurde.</summary>
public static bool ReadLocked(string xml) =>
Lesen(xml)?.Element(Sperre)?.Value.Trim()
.Equals("true", StringComparison.OrdinalIgnoreCase) == true;
private static void Kennungen(XElement wurzel, ProviderIds kennungen)
{
if (kennungen.Tmdb is { } tmdb)
{
wurzel.Add(new XElement("uniqueid", new XAttribute("type", "tmdb"),
new XAttribute("default", "true"), tmdb));
wurzel.Add(new XElement("tmdbid", tmdb));
}
if (kennungen.Imdb is { Length: > 0 } imdb)
{
wurzel.Add(new XElement("uniqueid", new XAttribute("type", "imdb"), imdb));
wurzel.Add(new XElement("imdbid", imdb));
}
}
private static string Dokument(string wurzelName, Action<XElement> füllen)
{
var wurzel = new XElement(wurzelName);
füllen(wurzel);
return new XDocument(new XDeclaration("1.0", "utf-8", null), wurzel)
.ToString(SaveOptions.None)
.Insert(0, "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n");
}
/// <summary>Liest XML, ohne sich dabei etwas unterschieben zu lassen.</summary>
///
/// <remarks>
/// <b>DTDs sind abgeschaltet und es wird nichts nachgeladen.</b> Diese
/// Dateien liegen im Medienordner, und was dort liegt, hat Flaro 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 Flaro.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 Flaro soll sie nicht erneut erraten.
/// </para>
/// <para>
/// <b>Flaro 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 Flaro.Core.Library;
/// <summary>Welche Dateien Flaro 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>FLARO_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);
}
}
@@ -0,0 +1,28 @@
namespace Flaro.Core.Library;
/// <summary>Der Titel, nach dem sortiert wird — ohne den Artikel davor.</summary>
///
/// <remarks>
/// Sonst stünde die halbe Mediathek unter „D": „Der Untergang", „Die Welle",
/// „Das Boot". Gezeigt wird weiterhin der richtige Titel; nur die Reihenfolge
/// richtet sich nach dem, was danach kommt.
/// </remarks>
public static class SortTitle
{
/// <summary>Deutsch und Englisch — die beiden Sprachen, in denen Bestände hier vorliegen.</summary>
private static readonly string[] Artikel = ["der ", "die ", "das ", "the ", "a ", "an ", "ein ", "eine "];
public static string For(string titel)
{
var blank = titel.Trim();
foreach (var artikel in Artikel)
{
if (!blank.StartsWith(artikel, StringComparison.OrdinalIgnoreCase)) continue;
var rest = blank[artikel.Length..].TrimStart();
// „Die" allein ist ein Titel, kein Artikel — dann bliebe nichts übrig.
if (rest.Length > 0) return rest;
}
return blank;
}
}
@@ -0,0 +1,97 @@
namespace Flaro.Core.Metadata;
/// <summary>Ein Bild bei der Quelle — noch nicht heruntergeladen.</summary>
public sealed record RemoteImage(string Url);
public sealed record MovieMetadata(
int ProviderId, string Title, int? Year, string? Overview,
RemoteImage? Poster, RemoteImage? Backdrop, string? ImdbId = null);
public sealed record SeriesMetadata(
int ProviderId, string Title, int? Year, string? Overview,
RemoteImage? Poster, RemoteImage? Backdrop, string? ImdbId = null);
public sealed record EpisodeMetadata(string? Title, string? Overview, RemoteImage? Still);
/// <summary>Woher Titel, Beschreibungen und Bilder kommen.</summary>
///
/// <remarks>
/// <para>
/// <b>Als Schnittstelle, obwohl es vorerst nur TMDB gibt.</b> Nicht aus
/// Vorsorge für einen zweiten Anbieter, sondern damit sich alles, was darauf
/// aufbaut, <i>ohne Netz und ohne Schlüssel</i> prüfen lässt. Ein Projekt,
/// dessen Tests einen fremden Dienst brauchen, hat am Ende keine Tests.
/// </para>
/// <para>
/// <c>null</c> heisst durchweg: nichts gefunden. Das ist kein Fehler — ein
/// Bestand enthält Mitschnitte und Heimvideos, die bei keiner Quelle stehen,
/// und die sollen trotzdem in der Mediathek erscheinen.
/// </para>
/// </remarks>
public interface IMetadataProvider
{
/// <summary>Ob ein Schlüssel hinterlegt ist. Ohne ihn wird nichts gefragt.</summary>
///
/// <remarks>
/// Asynchron, weil der Schlüssel in der Datenbank steht und nicht im Code
/// — der Betreiber trägt ihn in der Oberfläche ein, nicht in einer Datei.
/// </remarks>
Task<bool> IsConfiguredAsync(CancellationToken ct = default);
Task<MovieMetadata?> FindMovieAsync(string title, int? year, CancellationToken ct = default);
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>
///
/// <remarks>
/// Ausdrücklich eine eigene Ausnahme und kein stilles <c>null</c>: „nichts
/// gefunden" und „dein Schlüssel ist falsch" sehen in der Mediathek gleich
/// aus, sind aber völlig verschiedene Lagen. Ohne diese Unterscheidung sucht
/// jemand stundenlang nach dem Grund, warum keine Bilder erscheinen.
/// </remarks>
public sealed class MetadataCredentialsException(string nachricht) : Exception(nachricht);
/// <summary>Legt Bilder lokal ab, damit Clients sie nicht beim Anbieter holen.</summary>
///
/// <remarks>
/// Liegt im Kern, obwohl die Umsetzung herunterlädt: so hängt der Teil, der
/// Metadaten einträgt, an einem Vertrag und nicht an einem HTTP-Client —
/// und lässt sich ohne Netz prüfen.
/// </remarks>
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);
}
+15
View File
@@ -0,0 +1,15 @@
namespace Flaro.Core;
/// <summary>Eine Einstellung des Servers, als Zeile in der Datenbank.</summary>
///
/// <remarks>
/// Für alles, was der Betreiber in der Oberfläche setzt und was einen
/// Neustart überleben muss — den TMDB-Schlüssel zum Beispiel. Was dagegen
/// den Betrieb des Containers betrifft (Pfade, Port, Zeitzone), kommt aus der
/// Umgebung und steht nicht hier.
/// </remarks>
public sealed class ServerSetting
{
public required string Key { get; init; }
public required string Value { get; set; }
}
@@ -0,0 +1,116 @@
using System.Security.Cryptography;
using System.Text;
using Konscious.Security.Cryptography;
namespace Flaro.Core.Users;
/// <summary>Passwörter als Argon2id, im PHC-Format abgelegt.</summary>
///
/// <remarks>
/// <para>
/// Argon2id ist speicherhart: ein Angreifer mit Grafikkarten gewinnt dadurch
/// viel weniger, als er es bei PBKDF2 täte. Die Kennwerte folgen der
/// OWASP-Empfehlung — 19 MiB, zwei Durchgänge, eine Spur.
/// </para>
/// <para>
/// <b>Die Kennwerte stehen im Hash selbst</b>, nicht nur hier als Konstante.
/// Werden sie später angehoben, lassen sich alte Passwörter weiterhin prüfen,
/// statt alle Konten auf einen Schlag auszusperren. Genau daran scheitern
/// solche Umstellungen sonst.
/// </para>
/// </remarks>
public static class PasswordHasher
{
private const int MemoryKiB = 19 * 1024;
private const int Iterations = 2;
private const int Parallelism = 1;
private const int SaltBytes = 16;
private const int HashBytes = 32;
public static string Hash(string password)
{
ArgumentException.ThrowIfNullOrEmpty(password);
var salt = RandomNumberGenerator.GetBytes(SaltBytes);
var hash = Derive(password, salt, MemoryKiB, Iterations, Parallelism, HashBytes);
return $"$argon2id$v=19$m={MemoryKiB},t={Iterations},p={Parallelism}$" +
$"{Convert.ToBase64String(salt).TrimEnd('=')}$" +
$"{Convert.ToBase64String(hash).TrimEnd('=')}";
}
/// <summary>Prüft ein Passwort gegen einen abgelegten Hash.</summary>
///
/// <remarks>
/// Gibt bei unlesbarem Hash <c>false</c> zurück statt zu werfen: eine
/// beschädigte Zeile in der Datenbank soll die Anmeldung ablehnen, nicht
/// den Server umwerfen.
/// </remarks>
public static bool Verify(string password, string stored)
{
if (string.IsNullOrEmpty(password) || string.IsNullOrEmpty(stored)) return false;
var teile = stored.Split('$', StringSplitOptions.RemoveEmptyEntries);
if (teile.Length != 5 || teile[0] != "argon2id") return false;
var kennwerte = teile[2].Split(',');
if (kennwerte.Length != 3) return false;
if (!TryLesen(kennwerte[0], "m=", out var speicher) ||
!TryLesen(kennwerte[1], "t=", out var durchgänge) ||
!TryLesen(kennwerte[2], "p=", out var spuren)) return false;
byte[] salt, erwartet;
try
{
salt = Convert.FromBase64String(Auffüllen(teile[3]));
erwartet = Convert.FromBase64String(Auffüllen(teile[4]));
}
catch (FormatException) { return false; }
if (erwartet.Length == 0) return false;
var gerechnet = Derive(password, salt, speicher, durchgänge, spuren, erwartet.Length);
return CryptographicOperations.FixedTimeEquals(gerechnet, erwartet);
}
/// <summary>Ob dieser Hash mit schwächeren Kennwerten entstand.</summary>
///
/// <remarks>
/// Damit lässt sich beim nächsten erfolgreichen Anmelden still neu hashen.
/// Ohne diese Frage bliebe ein Konto von 2026 für immer bei den Kennwerten
/// von 2026, auch wenn die längst zu schwach wären.
/// </remarks>
public static bool NeedsRehash(string stored)
{
var teile = stored.Split('$', StringSplitOptions.RemoveEmptyEntries);
if (teile.Length != 5 || teile[0] != "argon2id") return true;
var kennwerte = teile[2].Split(',');
if (kennwerte.Length != 3) return true;
return !TryLesen(kennwerte[0], "m=", out var speicher) || speicher < MemoryKiB
|| !TryLesen(kennwerte[1], "t=", out var durchgänge) || durchgänge < Iterations;
}
private static byte[] Derive(string password, byte[] salt, int speicher, int durchgänge, int spuren, int länge)
{
using var argon = new Argon2id(Encoding.UTF8.GetBytes(password))
{
Salt = salt,
MemorySize = speicher,
Iterations = durchgänge,
DegreeOfParallelism = spuren,
};
return argon.GetBytes(länge);
}
private static bool TryLesen(string stück, string vorsatz, out int wert)
{
wert = 0;
return stück.StartsWith(vorsatz, StringComparison.Ordinal)
&& int.TryParse(stück.AsSpan(vorsatz.Length), out wert)
&& wert > 0;
}
/// <summary>PHC lässt die Base64-Auffüllung weg; .NET besteht darauf.</summary>
private static string Auffüllen(string roh) => roh.PadRight((roh.Length + 3) / 4 * 4, '=');
}
+23
View File
@@ -0,0 +1,23 @@
namespace Flaro.Core.Users;
/// <summary>Ein Benutzerkonto.</summary>
///
/// <remarks>
/// Mehrbenutzer von Anfang an — auch wenn der erste Server nur einen hat.
/// Fortschritt und Gesehen-Status je Benutzer nachträglich einzuziehen wäre
/// ein Umbau am Datenmodell und an jeder Abfrage darauf, kein Zusatz.
/// </remarks>
public sealed class User
{
/// <summary>Zeitlich geordnet, damit der Index nicht zerfasert.</summary>
public Guid Id { get; init; } = Guid.CreateVersion7();
public required string UserName { get; set; }
/// <summary>Im PHC-Format, siehe <see cref="PasswordHasher"/>.</summary>
public required string PasswordHash { get; set; }
public bool IsAdministrator { get; set; }
public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow;
}