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>
250 lines
11 KiB
C#
250 lines
11 KiB
C#
using System.Diagnostics;
|
|
using System.Net;
|
|
using System.Net.Http.Json;
|
|
using Flaro.Api.Endpoints;
|
|
using Flaro.Core.Library;
|
|
using Flaro.Core.Metadata;
|
|
using Flaro.Media;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
|
|
namespace Flaro.Api.Tests;
|
|
|
|
/// <summary>Eine falsche Zuordnung richtigstellen — und sie festschreiben.</summary>
|
|
public class MatchCorrectionTests
|
|
{
|
|
private string _bestand = "";
|
|
private string _filmOrdner = "";
|
|
private FlaroServer? _server;
|
|
|
|
private static string? Werkzeug(string name) =>
|
|
(Environment.GetEnvironmentVariable("PATH") ?? "").Split(Path.PathSeparator)
|
|
.Select(o => o.Length == 0 ? null : Path.Combine(o, name))
|
|
.FirstOrDefault(p => p is not null && File.Exists(p));
|
|
|
|
[SetUp]
|
|
public void Anlegen()
|
|
{
|
|
if (Werkzeug("ffmpeg") is null) Assert.Ignore("ffmpeg ist auf diesem Rechner nicht installiert.");
|
|
_bestand = Directory.CreateDirectory(
|
|
Path.Combine(Path.GetTempPath(), "flaro-korrektur-" + Guid.CreateVersion7().ToString("N"))).FullName;
|
|
_filmOrdner = Directory.CreateDirectory(Path.Combine(_bestand, "Dark (2017)")).FullName;
|
|
|
|
var start = new ProcessStartInfo(Werkzeug("ffmpeg")!) { RedirectStandardError = true, UseShellExecute = false };
|
|
foreach (var a in new[]
|
|
{
|
|
"-y", "-loglevel", "error", "-f", "lavfi", "-i", "testsrc=duration=1:size=160x120:rate=10",
|
|
"-c:v", "libx264", "-preset", "ultrafast", Path.Combine(_filmOrdner, "Dark (2017).mkv"),
|
|
}) start.ArgumentList.Add(a);
|
|
using var p = Process.Start(start)!;
|
|
p.StandardError.ReadToEnd();
|
|
p.WaitForExit();
|
|
}
|
|
|
|
[TearDown]
|
|
public void Aufraeumen()
|
|
{
|
|
_server?.Dispose(); _server = null;
|
|
try { if (_bestand.Length > 0) Directory.Delete(_bestand, recursive: true); } catch (IOException) { }
|
|
}
|
|
|
|
/// <summary>Ein Anbieter, der zwei verschiedene Filme kennt.</summary>
|
|
private sealed class ZweiTreffer : IMetadataProvider
|
|
{
|
|
public static readonly MovieMetadata Falsch =
|
|
new(1, "Dark Shadows", 1966, "Eine Fernsehserie von 1966.", null, null, "tt0059978");
|
|
public static readonly MovieMetadata Richtig =
|
|
new(70523, "Dark", 2017, "Nach dem Verschwinden eines Kindes.", null, null, "tt5753856");
|
|
|
|
/// <summary>Ein Treffer mit <b>anderem Jahr</b> als im Dateinamen.</summary>
|
|
public static readonly MovieMetadata AnderesJahr =
|
|
new(613, "Der Untergang", 2004, "Berlin, April 1945.", null, null, "tt0363163");
|
|
|
|
public Task<bool> IsConfiguredAsync(CancellationToken ct = default) => Task.FromResult(true);
|
|
|
|
// Die Suche liefert den beliebteren zuerst — genau wie TMDB.
|
|
public Task<MovieMetadata?> FindMovieAsync(string t, int? j, CancellationToken ct = default) =>
|
|
Task.FromResult<MovieMetadata?>(Falsch);
|
|
|
|
public Task<IReadOnlyList<MovieMetadata>> SearchMoviesAsync(string t, int? j, CancellationToken ct = default) =>
|
|
Task.FromResult<IReadOnlyList<MovieMetadata>>([Falsch, Richtig]);
|
|
|
|
public Task<MovieMetadata?> GetMovieAsync(int id, CancellationToken ct = default) =>
|
|
Task.FromResult<MovieMetadata?>(
|
|
id == Richtig.ProviderId ? Richtig
|
|
: id == Falsch.ProviderId ? Falsch
|
|
: id == AnderesJahr.ProviderId ? AnderesJahr
|
|
: null);
|
|
|
|
public Task<SeriesMetadata?> FindSeriesAsync(string t, int? j, CancellationToken ct = default) =>
|
|
Task.FromResult<SeriesMetadata?>(null);
|
|
public Task<IReadOnlyList<SeriesMetadata>> SearchSeriesAsync(string t, int? j, CancellationToken ct = default) =>
|
|
Task.FromResult<IReadOnlyList<SeriesMetadata>>([]);
|
|
public Task<SeriesMetadata?> GetSeriesAsync(int id, CancellationToken ct = default) =>
|
|
Task.FromResult<SeriesMetadata?>(null);
|
|
public Task<EpisodeMetadata?> FindEpisodeAsync(int s, int st, int f, CancellationToken ct = default) =>
|
|
Task.FromResult<EpisodeMetadata?>(null);
|
|
}
|
|
|
|
private async Task<(HttpClient Client, MovieResponse Film)> Eingelesen(bool beiwerkSchreiben)
|
|
{
|
|
_server = new FlaroServer(dienste =>
|
|
{
|
|
dienste.AddScoped<IMetadataProvider, ZweiTreffer>();
|
|
dienste.AddSingleton<ISidecarStore>(
|
|
new SidecarWriter(new SidecarOptions(beiwerkSchreiben),
|
|
Microsoft.Extensions.Logging.Abstractions.NullLogger<SidecarWriter>.Instance));
|
|
});
|
|
|
|
var admin = await _server.SetUpAndSignIn();
|
|
var client = _server.AlsAngemeldeter(admin.Token);
|
|
|
|
var angelegt = await client.PostAsJsonAsync("/api/v1/libraries",
|
|
new CreateLibraryRequest("Filme", "Movies", _bestand));
|
|
angelegt.EnsureSuccessStatusCode();
|
|
var bibliothek = (await angelegt.Content.ReadFromJsonAsync<LibraryResponse>())!;
|
|
|
|
(await client.PostAsync($"/api/v1/libraries/{bibliothek.Id}/scan", null)).EnsureSuccessStatusCode();
|
|
for (var i = 0; i < 150; i++)
|
|
{
|
|
var stand = await client.GetFromJsonAsync<ScanStatus>($"/api/v1/libraries/{bibliothek.Id}/scan");
|
|
if (stand!.State == "finished") break;
|
|
await Task.Delay(50);
|
|
}
|
|
|
|
var filme = await client.GetFromJsonAsync<Page<MovieResponse>>("/api/v1/movies");
|
|
return (client, filme!.Items.Single());
|
|
}
|
|
|
|
[Test]
|
|
public async Task Der_Scan_ordnet_zuerst_falsch_zu()
|
|
{
|
|
var (_, film) = await Eingelesen(beiwerkSchreiben: false);
|
|
Assert.That(film.Title, Is.EqualTo("Dark Shadows"), "So sieht der Fehler aus, den es zu beheben gilt");
|
|
}
|
|
|
|
[Test]
|
|
public async Task Die_Vorschlagsliste_enthaelt_den_richtigen()
|
|
{
|
|
var (client, film) = await Eingelesen(beiwerkSchreiben: false);
|
|
|
|
var vorschläge = await client.GetFromJsonAsync<List<MatchCandidate>>($"/api/v1/movies/{film.Id}/matches");
|
|
|
|
Assert.That(vorschläge, Has.Count.EqualTo(2));
|
|
Assert.That(vorschläge!.Select(v => v.Title), Does.Contain("Dark"));
|
|
}
|
|
|
|
[Test]
|
|
public async Task Eine_Korrektur_greift_sofort()
|
|
{
|
|
var (client, film) = await Eingelesen(beiwerkSchreiben: false);
|
|
|
|
var antwort = await client.PutAsJsonAsync($"/api/v1/movies/{film.Id}/match", new ApplyMatchRequest(70523));
|
|
antwort.EnsureSuccessStatusCode();
|
|
|
|
var stand = await antwort.Content.ReadFromJsonAsync<MatchState>();
|
|
Assert.That(stand!.Title, Is.EqualTo("Dark"));
|
|
Assert.That(stand.TmdbId, Is.EqualTo(70523));
|
|
Assert.That(stand.ImdbId, Is.EqualTo("tt5753856"));
|
|
Assert.That(stand.Locked, Is.True);
|
|
|
|
var danach = await client.GetFromJsonAsync<Page<MovieResponse>>("/api/v1/movies");
|
|
Assert.That(danach!.Items.Single().Title, Is.EqualTo("Dark"));
|
|
}
|
|
|
|
/// <summary>Die Korrektur überlebt einen Neuaufbau der Datenbank.</summary>
|
|
///
|
|
/// <remarks>
|
|
/// <b>Darum geht es bei der NFO-Datei.</b> In der Datenbank allein wäre
|
|
/// die Entscheidung mit dem nächsten Umzug oder Neuaufbau verloren.
|
|
/// </remarks>
|
|
[Test]
|
|
public async Task Eine_Korrektur_landet_in_der_NFO_Datei()
|
|
{
|
|
var (client, film) = await Eingelesen(beiwerkSchreiben: true);
|
|
(await client.PutAsJsonAsync($"/api/v1/movies/{film.Id}/match", new ApplyMatchRequest(70523)))
|
|
.EnsureSuccessStatusCode();
|
|
|
|
var nfo = Path.Combine(_filmOrdner, "movie.nfo");
|
|
Assert.That(File.Exists(nfo), Is.True, "Die NFO muss neben dem Film liegen");
|
|
|
|
var inhalt = await File.ReadAllTextAsync(nfo);
|
|
var kennungen = Nfo.ReadIds(inhalt);
|
|
Assert.That(kennungen.Tmdb, Is.EqualTo(70523));
|
|
Assert.That(kennungen.Imdb, Is.EqualTo("tt5753856"));
|
|
Assert.That(Nfo.ReadLocked(inhalt), Is.True, "Damit kein Lauf die Entscheidung überstimmt");
|
|
Assert.That(inhalt, Does.Contain("<title>Dark</title>"));
|
|
}
|
|
|
|
[Test]
|
|
public async Task Ohne_Schreiberlaubnis_bleibt_der_Ordner_unberuehrt()
|
|
{
|
|
var (client, film) = await Eingelesen(beiwerkSchreiben: false);
|
|
(await client.PutAsJsonAsync($"/api/v1/movies/{film.Id}/match", new ApplyMatchRequest(70523)))
|
|
.EnsureSuccessStatusCode();
|
|
|
|
Assert.That(Directory.GetFiles(_filmOrdner).Select(Path.GetFileName),
|
|
Is.EqualTo(new[] { "Dark (2017).mkv" }));
|
|
}
|
|
|
|
[Test]
|
|
public async Task Eine_Kennung_die_es_nicht_gibt_wird_abgelehnt()
|
|
{
|
|
var (client, film) = await Eingelesen(beiwerkSchreiben: false);
|
|
|
|
var antwort = await client.PutAsJsonAsync($"/api/v1/movies/{film.Id}/match", new ApplyMatchRequest(999999));
|
|
Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.NotFound));
|
|
}
|
|
|
|
/// <summary>Eine Korrektur muss den nächsten Scan überstehen.</summary>
|
|
///
|
|
/// <remarks>
|
|
/// <b>Live gefunden, und der Fehler war unangenehm.</b> 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 — die Zuordnung überlebte nur,
|
|
/// weil sie in der NFO stand. Zugeordnet wird deshalb über
|
|
/// <c>ParsedYear</c>, das sich nie ändert.
|
|
/// </remarks>
|
|
[Test]
|
|
public async Task Eine_Korrektur_ueberlebt_den_naechsten_Scan()
|
|
{
|
|
var (client, film) = await Eingelesen(beiwerkSchreiben: false);
|
|
|
|
(await client.PutAsJsonAsync($"/api/v1/movies/{film.Id}/match", new ApplyMatchRequest(613)))
|
|
.EnsureSuccessStatusCode();
|
|
|
|
var bibliothek = (await client.GetFromJsonAsync<List<LibraryResponse>>("/api/v1/libraries"))!.Single();
|
|
(await client.PostAsync($"/api/v1/libraries/{bibliothek.Id}/scan", null))
|
|
.EnsureSuccessStatusCode();
|
|
for (var i = 0; i < 150; i++)
|
|
{
|
|
var stand = await client.GetFromJsonAsync<ScanStatus>(
|
|
$"/api/v1/libraries/{bibliothek.Id}/scan");
|
|
if (stand!.State == "finished") break;
|
|
await Task.Delay(50);
|
|
}
|
|
|
|
var danach = await client.GetFromJsonAsync<Page<MovieResponse>>("/api/v1/movies");
|
|
Assert.That(danach!.Total, Is.EqualTo(1), "Kein zweiter Eintrag für denselben Film");
|
|
|
|
var geblieben = danach.Items.Single();
|
|
Assert.That(geblieben.Id, Is.EqualTo(film.Id), "Derselbe Eintrag, nicht ein neuer");
|
|
Assert.That(geblieben.Title, Is.EqualTo("Der Untergang"));
|
|
Assert.That(geblieben.Year, Is.EqualTo(2004), "Und nicht wieder 2017 aus dem Dateinamen");
|
|
}
|
|
|
|
[Test]
|
|
public async Task Nur_Administratoren_duerfen_korrigieren()
|
|
{
|
|
var (client, film) = await Eingelesen(beiwerkSchreiben: false);
|
|
await client.PostAsJsonAsync("/api/v1/users",
|
|
new CreateUserRequest("gast", "auch-ein-langes-passwort", false));
|
|
var gast = await _server!.SignIn("gast", "auch-ein-langes-passwort", "iPad", "g-1");
|
|
|
|
var antwort = await _server.AlsAngemeldeter(gast.Token)
|
|
.PutAsJsonAsync($"/api/v1/movies/{film.Id}/match", new ApplyMatchRequest(70523));
|
|
|
|
Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.Forbidden));
|
|
}
|
|
}
|