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
+25
View File
@@ -0,0 +1,25 @@
namespace Flaro.Media;
/// <summary>Wo ffmpeg liegt und wie lange es brauchen darf.</summary>
///
/// <remarks>
/// <b>Die Pfade sind einstellbar</b>, weil sie nicht überall gleich sind: im
/// Container liegt ffmpeg unter <c>/usr/bin</c>, auf einem Mac mit Homebrew
/// unter <c>/opt/homebrew/bin</c>, und manche bringen ihren eigenen Bau mit.
/// Ein fester Pfad hier wäre eine Annahme über ein fremdes System.
/// </remarks>
public sealed record FfmpegOptions(string Ffmpeg = "ffmpeg", string Ffprobe = "ffprobe")
{
/// <summary>Nach dieser Zeit gilt eine Datei als nicht lesbar.</summary>
///
/// <remarks>
/// Eine beschädigte oder halb geschriebene Datei bringt ffprobe dazu,
/// endlos zu suchen. Ohne Grenze bliebe der Scan daran hängen — und zwar
/// an *einer* Datei, während zehntausend andere warten.
/// </remarks>
public TimeSpan ProbeTimeout { get; init; } = TimeSpan.FromSeconds(30);
public static FfmpegOptions FromEnvironment() => new(
Environment.GetEnvironmentVariable("FLARO_FFMPEG") is { Length: > 0 } f ? f : "ffmpeg",
Environment.GetEnvironmentVariable("FLARO_FFPROBE") is { Length: > 0 } p ? p : "ffprobe");
}
+133
View File
@@ -0,0 +1,133 @@
using System.Diagnostics;
using System.Globalization;
using Microsoft.Extensions.Logging;
namespace Flaro.Media;
public sealed record RemuxOutcome(bool Success, string? Error);
/// <summary>Packt eine Datei in eine MP4 um — mit allen Spuren.</summary>
///
/// <remarks>
/// <para>
/// <b>Das ist der Grund, warum es diesen Server gibt.</b> Jellyfins
/// Transcode-Endpunkt bildet je Sitzung eine Tonspur ab; was offline landet,
/// ist einspurig. Hier wandern alle mit, weil jede einzeln benannt wird und
/// nichts die Auswahl auf eine beschränkt.
/// </para>
/// <para>
/// <b>Keine Zeitgrenze.</b> Anders als beim Untersuchen: ein Film mit vierzig
/// Gigabyte braucht Minuten, und ihn nach dreissig Sekunden abzubrechen wäre
/// kein Schutz, sondern ein Fehler. Abgebrochen wird über das
/// Abbruchzeichen — also wenn jemand es will oder der Server herunterfährt.
/// </para>
/// </remarks>
public sealed class FfmpegRemuxer(FfmpegOptions optionen, ILogger<FfmpegRemuxer> log)
{
public async Task<RemuxOutcome> RunAsync(
RemuxRequest anfrage,
TimeSpan? gesamtdauer = null,
IProgress<double>? fortschritt = null,
CancellationToken ct = default)
{
var argumente = RemuxArguments.For(anfrage);
var start = new ProcessStartInfo(optionen.Ffmpeg)
{
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
};
// Fortschritt auf die Standardausgabe, damit Fehlermeldungen für sich
// bleiben. Vermischt wären beide schwer auseinanderzuhalten.
if (fortschritt is not null && gesamtdauer is { TotalSeconds: > 0 })
{
start.ArgumentList.Add("-progress");
start.ArgumentList.Add("pipe:1");
start.ArgumentList.Add("-nostats");
}
foreach (var a in argumente) start.ArgumentList.Add(a);
log.LogInformation("Packe um: {Quelle} → {Ziel}", anfrage.SourcePath, anfrage.TargetPath);
try
{
using var prozess = Process.Start(start)
?? throw new InvalidOperationException($"{optionen.Ffmpeg} liess sich nicht starten.");
var lesenFortschritt = FortschrittLesen(prozess, gesamtdauer, fortschritt, ct);
var lesenFehler = prozess.StandardError.ReadToEndAsync(ct);
try
{
await prozess.WaitForExitAsync(ct);
}
catch (OperationCanceledException)
{
// **Sonst läuft ffmpeg weiter.** Das Abbruchzeichen beendet
// nur das Warten, nicht den Prozess — und der schriebe dann
// unbeaufsichtigt weiter auf die Platte.
try { prozess.Kill(entireProcessTree: true); } catch (InvalidOperationException) { }
Aufräumen(anfrage.TargetPath);
throw;
}
await lesenFortschritt;
var fehlertext = (await lesenFehler).Trim();
if (prozess.ExitCode != 0)
{
Aufräumen(anfrage.TargetPath);
log.LogWarning("ffmpeg gab {Code} zurück: {Fehler}", prozess.ExitCode, fehlertext);
return new RemuxOutcome(false, fehlertext.Length > 0 ? fehlertext : $"ffmpeg endete mit {prozess.ExitCode}.");
}
fortschritt?.Report(1.0);
return new RemuxOutcome(true, null);
}
catch (OperationCanceledException) { throw; }
catch (Exception fehler)
{
Aufräumen(anfrage.TargetPath);
log.LogError(fehler, "Das Umpacken von {Quelle} ist gescheitert.", anfrage.SourcePath);
return new RemuxOutcome(false, fehler.Message);
}
}
/// <summary>Liest die Fortschrittszeilen von ffmpeg mit.</summary>
///
/// <remarks>
/// ffmpeg schreibt dort Zeilen der Form <c>out_time_us=12345678</c>.
/// Gelesen wird laufend — nicht am Ende: ein Fortschritt, der erst
/// vorliegt, wenn alles fertig ist, hilft niemandem.
/// </remarks>
private static async Task FortschrittLesen(
Process prozess, TimeSpan? gesamt, IProgress<double>? fortschritt, CancellationToken ct)
{
while (await prozess.StandardOutput.ReadLineAsync(ct) is { } zeile)
{
if (fortschritt is null || gesamt is not { TotalMicroseconds: > 0 }) continue;
if (!zeile.StartsWith("out_time_us=", StringComparison.Ordinal)) continue;
if (double.TryParse(zeile.AsSpan("out_time_us=".Length), NumberStyles.Float,
CultureInfo.InvariantCulture, out var mikrosekunden) && mikrosekunden >= 0)
{
fortschritt.Report(Math.Clamp(mikrosekunden / gesamt.Value.TotalMicroseconds, 0, 0.999));
}
}
}
/// <summary>Eine halbe Datei ist schlimmer als keine.</summary>
///
/// <remarks>
/// Bliebe sie liegen, hielte der nächste Aufruf sie für fertig und gäbe
/// einen abgeschnittenen Film heraus — der beim Abspielen mittendrin
/// abbricht. Genau dieser Fehler hat beim Herunterladen schon einmal
/// Stunden gekostet.
/// </remarks>
private void Aufräumen(string ziel)
{
try { if (File.Exists(ziel)) File.Delete(ziel); }
catch (IOException fehler) { log.LogWarning(fehler, "Reste von {Ziel} liessen sich nicht wegräumen.", ziel); }
}
}
+201
View File
@@ -0,0 +1,201 @@
using System.Diagnostics;
using System.Globalization;
using System.Text.Json;
using Flaro.Core.Library;
using Microsoft.Extensions.Logging;
namespace Flaro.Media;
/// <summary>Liest die technischen Angaben einer Datei mit ffprobe.</summary>
///
/// <remarks>
/// <para>
/// <b>Als eigener Prozess, nicht über Bindings.</b> Das hält die Lizenzen
/// getrennt — ffmpeg steht unter GPL, und ihn einzubinden hiesse, diese Frage
/// an jeder Stelle mitzuführen. Nebenbei kann ein abgestürztes ffprobe so den
/// Server nicht mitnehmen.
/// </para>
/// <para>
/// <b>Die Argumente gehen als Liste, nicht als Zeile.</b> Ein Dateiname wie
/// <c>Der "echte" Film.mkv</c> oder einer mit einem Apostroph zerlegte eine
/// zusammengesetzte Kommandozeile — und was dann ausgeführt würde, bestimmte
/// der Dateiname. Mit <see cref="ProcessStartInfo.ArgumentList"/> gibt es
/// diese Frage gar nicht erst.
/// </para>
/// </remarks>
public sealed class FfprobeRunner(FfmpegOptions optionen, ILogger<FfprobeRunner> log) : IMediaProbe
{
public async Task<ProbeResult?> ProbeAsync(string pfad, CancellationToken ct = default)
{
var start = new ProcessStartInfo(optionen.Ffprobe)
{
ArgumentList = { "-v", "quiet", "-print_format", "json", "-show_streams", "-show_format", "-i", pfad },
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
};
using var frist = CancellationTokenSource.CreateLinkedTokenSource(ct);
frist.CancelAfter(optionen.ProbeTimeout);
string ausgabe;
try
{
using var prozess = Process.Start(start)
?? throw new InvalidOperationException($"{optionen.Ffprobe} liess sich nicht starten.");
// **Beides gleichzeitig lesen.** Wer erst wartet und dann liest,
// bleibt stehen, sobald ffprobe genug schreibt, um den Puffer der
// Weiterleitung zu füllen — und das tut es bei einer Datei mit
// vielen Spuren.
var lesenAus = prozess.StandardOutput.ReadToEndAsync(frist.Token);
var lesenFehler = prozess.StandardError.ReadToEndAsync(frist.Token);
await prozess.WaitForExitAsync(frist.Token);
ausgabe = await lesenAus;
var fehlertext = await lesenFehler;
if (prozess.ExitCode != 0)
{
log.LogWarning("ffprobe gab {Code} für {Pfad} zurück: {Fehler}",
prozess.ExitCode, pfad, fehlertext.Trim());
return null;
}
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
throw;
}
catch (OperationCanceledException)
{
log.LogWarning("ffprobe brauchte für {Pfad} länger als {Frist} und wurde abgebrochen.",
pfad, optionen.ProbeTimeout);
return null;
}
catch (Exception fehler)
{
log.LogWarning(fehler, "ffprobe liess sich für {Pfad} nicht ausführen.", pfad);
return null;
}
return Lesen(ausgabe, pfad);
}
private ProbeResult? Lesen(string json, string pfad)
{
try
{
using var urkunde = JsonDocument.Parse(json);
var wurzel = urkunde.RootElement;
string? schachtel = null;
TimeSpan? dauer = null;
long? rate = null;
if (wurzel.TryGetProperty("format", out var format))
{
schachtel = Schachtel(Text(format, "format_name"));
if (double.TryParse(Text(format, "duration"), NumberStyles.Float, CultureInfo.InvariantCulture, out var s)
&& s > 0) dauer = TimeSpan.FromSeconds(s);
if (long.TryParse(Text(format, "bit_rate"), out var b) && b > 0) rate = b;
}
var spuren = new List<ProbedStream>();
if (wurzel.TryGetProperty("streams", out var ströme) && ströme.ValueKind == JsonValueKind.Array)
{
foreach (var strom in ströme.EnumerateArray())
{
var art = Art(Text(strom, "codec_type"));
if (art is null) continue; // Anhänge, Daten, Bilder im Dateikopf
strom.TryGetProperty("disposition", out var lage);
strom.TryGetProperty("tags", out var kennzeichen);
spuren.Add(new ProbedStream(
Index: strom.TryGetProperty("index", out var i) && i.TryGetInt32(out var n) ? n : spuren.Count,
Kind: art.Value,
Codec: Text(strom, "codec_name"),
Language: Sprache(Text(kennzeichen, "language")),
Title: Spurname(kennzeichen),
IsDefault: Zahl(lage, "default") == 1,
IsForced: Zahl(lage, "forced") == 1,
Channels: strom.TryGetProperty("channels", out var k) && k.TryGetInt32(out var kz) ? kz : null,
Width: strom.TryGetProperty("width", out var w) && w.TryGetInt32(out var wz) ? wz : null,
Height: strom.TryGetProperty("height", out var h) && h.TryGetInt32(out var hz) ? hz : null));
}
}
return new ProbeResult(schachtel, dauer, rate, spuren);
}
catch (JsonException fehler)
{
log.LogWarning(fehler, "Die Antwort von ffprobe für {Pfad} war nicht lesbar.", pfad);
return null;
}
}
/// <summary>ffprobe nennt mehrere Namen auf einmal — hier zählt der erste.</summary>
///
/// <remarks>
/// Eine MP4 meldet sich als <c>mov,mp4,m4a,3gp,3g2,mj2</c>, weil ffmpeg
/// diese Familie mit einem Leser bedient. Für die Frage, ob AVPlayer die
/// Datei öffnet, ist das die MP4-Familie — also wird sie dazu
/// vereinheitlicht, statt sechs Namen weiterzureichen.
/// </remarks>
private static string? Schachtel(string? gemeldet)
{
if (string.IsNullOrWhiteSpace(gemeldet)) return null;
var erster = gemeldet.Split(',')[0].Trim().ToLowerInvariant();
return erster switch
{
"mov" => "mp4",
"matroska" => "mkv",
"mpegts" => "ts",
_ => erster,
};
}
/// <summary>Der Name der Spur — das Feld heisst je nach Schachtel anders.</summary>
///
/// <remarks>
/// <b>Matroska nennt es <c>title</c>, MP4 nennt es <c>name</c>.</b> Beim
/// Umpacken schreibt ffmpeg das eine ins andere um, und wer nur
/// <c>title</c> liest, hält jede umgepackte Datei für namenlos. Genau das
/// ist beim ersten Versuch passiert: nach dem Umpacken waren „Deutsch"
/// und „Audiodeskription" beide <c>null</c> — und damit zwei deutsche
/// Tonspuren nicht mehr auseinanderzuhalten.
///
/// <c>handler_name</c> steht ausdrücklich nicht dabei: dort schreibt
/// ffmpeg „SoundHandler" hinein, und das ist kein Name, sondern eine
/// Formalie.
/// </remarks>
private static string? Spurname(JsonElement kennzeichen) =>
Text(kennzeichen, "title") ?? Text(kennzeichen, "name");
/// <summary>„und" heisst unbekannt und ist dasselbe wie keine Angabe.</summary>
private static string? Sprache(string? roh) =>
string.IsNullOrWhiteSpace(roh) || roh.Equals("und", StringComparison.OrdinalIgnoreCase)
? null
: roh.Trim().ToLowerInvariant();
private static MediaStreamKind? Art(string? typ) => typ switch
{
"video" => MediaStreamKind.Video,
"audio" => MediaStreamKind.Audio,
"subtitle" => MediaStreamKind.Subtitle,
_ => null,
};
private static string? Text(JsonElement eltern, string name) =>
eltern.ValueKind == JsonValueKind.Object
&& eltern.TryGetProperty(name, out var wert)
&& wert.ValueKind == JsonValueKind.String
? wert.GetString()
: null;
private static int? Zahl(JsonElement eltern, string name) =>
eltern.ValueKind == JsonValueKind.Object
&& eltern.TryGetProperty(name, out var wert)
&& wert.TryGetInt32(out var zahl)
? zahl
: null;
}
+17
View File
@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\Flaro.Core\Flaro.Core.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.12" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
+103
View File
@@ -0,0 +1,103 @@
using Flaro.Core.Delivery;
using Flaro.Core.Library;
namespace Flaro.Media;
public sealed record RemuxRequest(
string SourcePath,
string TargetPath,
IReadOnlyList<ProbedStream> Streams,
DeliveryPlan Plan);
/// <summary>Baut die ffmpeg-Zeile fürs Umpacken.</summary>
///
/// <remarks>
/// <para>
/// <b>Eine reine Funktion, damit sie geprüft werden kann, ohne etwas
/// auszuführen.</b> Hier liegt die Erfahrung dieser Woche: eine falsche
/// Kombination von Parametern kostete uns Tage, weil sie nur gegen einen
/// echten Server zu entdecken war. Wo sich die Argumente allein betrachten
/// lassen, geht das in Millisekunden.
/// </para>
/// <para>
/// <b>Jede Spur wird einzeln benannt</b>, statt mit <c>-map 0</c> alles zu
/// nehmen und Unerwünschtes wieder abzuziehen. Beides ginge; die ausdrückliche
/// Liste sagt aber hinterher, was tatsächlich gemeint war — und eine
/// Prüfung kann sie lesen.
/// </para>
/// </remarks>
public static class RemuxArguments
{
/// <summary>Was eine Tonspur je Kanal bekommt, wenn sie neu gerechnet wird.</summary>
private const int BitrateJeKanal = 96_000;
private const int HöchsteBitrate = 640_000;
public static List<string> For(RemuxRequest anfrage)
{
var weggelassen = anfrage.Plan.SubtitlesDropped.ToHashSet();
var neuZuRechnen = anfrage.Plan.AudioToEncode.ToHashSet();
// Anhänge und Datenströme fliegen schon hier heraus: MP4 hat für
// Schriftarten und Kapitelbilder keinen Platz, und ffmpeg bräche mit
// einer Fehlermeldung ab, die nach etwas ganz anderem aussieht.
var genommen = anfrage.Streams
.Where(s => s.Kind is MediaStreamKind.Video or MediaStreamKind.Audio or MediaStreamKind.Subtitle)
.Where(s => !weggelassen.Contains(s.Index))
.OrderBy(s => s.Index)
.ToList();
var argumente = new List<string>
{
"-y",
"-loglevel", "error",
"-i", anfrage.SourcePath,
};
foreach (var spur in genommen)
{
argumente.Add("-map");
argumente.Add($"0:{spur.Index}");
}
// Alles kopieren, und nur dort abweichen, wo es nicht anders geht.
argumente.Add("-c");
argumente.Add("copy");
// MP4 kennt nur tx3g. Textuntertitel werden umgeschrieben — das ist
// keine Neukodierung des Inhalts, nur eine andere Ablageform.
if (genommen.Any(s => s.Kind == MediaStreamKind.Subtitle))
{
argumente.Add("-c:s");
argumente.Add("mov_text");
}
// **Die Nummer je Art, nicht die der Datei.** `-c:a:1` meint die
// zweite Tonspur der Ausgabe; wer hier die Dateinummer einsetzt,
// trifft eine andere Spur oder gar keine. Genau diese Verwechslung
// steht als Warnung an `MediaStream.Index`.
var tonspuren = genommen.Where(s => s.Kind == MediaStreamKind.Audio).ToList();
for (var stelle = 0; stelle < tonspuren.Count; stelle++)
{
var spur = tonspuren[stelle];
if (!neuZuRechnen.Contains(spur.Index)) continue;
argumente.Add($"-c:a:{stelle}");
argumente.Add("aac");
argumente.Add($"-b:a:{stelle}");
argumente.Add(Bitrate(spur.Channels).ToString());
}
// Das Inhaltsverzeichnis nach vorn. Ohne das muss ein Abspieler erst
// ans Dateiende springen, bevor er irgendetwas zeigen kann — bei einer
// heruntergeladenen Datei ist das der Unterschied zwischen „läuft
// sofort" und „denkt zehn Sekunden nach".
argumente.Add("-movflags");
argumente.Add("+faststart");
argumente.Add(anfrage.TargetPath);
return argumente;
}
private static int Bitrate(int? kanäle) =>
Math.Min(HöchsteBitrate, Math.Max(1, kanäle ?? 2) * BitrateJeKanal);
}
+123
View File
@@ -0,0 +1,123 @@
using Flaro.Core.Library;
using Microsoft.Extensions.Logging;
namespace Flaro.Media;
/// <summary>Ob Flaro ü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>FLARO_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("FLARO_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 Flaro 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 + ".flaro-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 Flaro anlegen darf. " +
"Siehe SidecarNames — das ist der Schutz des Bestands, und er wird nicht umgangen.");
}
}