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
@@ -0,0 +1,79 @@
using System.Security.Claims;
using System.Text.Encodings.Web;
using Flaro.Core.Auth;
using Flaro.Data;
using Microsoft.AspNetCore.Authentication;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
namespace Flaro.Api.Auth;
/// <summary>Weist Anfragen über ein Geräte-Token aus.</summary>
///
/// <remarks>
/// <para>
/// <c>Authorization: Bearer &lt;token&gt;</c>. Der Server hasht das Vorgezeigte
/// und sucht den Fingerabdruck — das Token selbst steht nirgends in der
/// Datenbank und ist aus ihr auch nicht zu gewinnen.
/// </para>
/// <para>
/// Keine JWT. Ein Token, das der Server nachschlägt, lässt sich <b>sofort</b>
/// widerrufen; ein signiertes gilt bis zum Ablauf weiter, und für ein
/// verlorenes Telefon ist das der falsche Satz.
/// </para>
/// </remarks>
public sealed class TokenAuthenticationHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
FlaroContext db,
TimeProvider clock) : AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
{
public const string SchemeName = "FlaroToken";
public const string DeviceTokenIdClaim = "flaro:device";
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{
if (!Request.Headers.TryGetValue("Authorization", out var kopfzeile)) return AuthenticateResult.NoResult();
var roh = kopfzeile.ToString();
const string vorsatz = "Bearer ";
if (!roh.StartsWith(vorsatz, StringComparison.OrdinalIgnoreCase)) return AuthenticateResult.NoResult();
var token = roh[vorsatz.Length..].Trim();
if (token.Length == 0) return AuthenticateResult.NoResult();
var fingerabdruck = Secrets.Fingerprint(token);
var eintrag = await db.DeviceTokens.FirstOrDefaultAsync(t => t.TokenFingerprint == fingerabdruck);
var jetzt = clock.GetUtcNow();
if (eintrag is null || !eintrag.IsValid(jetzt)) return AuthenticateResult.Fail("Token gilt nicht.");
var benutzer = await db.Users.FindAsync(eintrag.UserId);
if (benutzer is null) return AuthenticateResult.Fail("Token gilt nicht.");
// Für die Geräteliste: „zuletzt gesehen". Höchstens einmal je Stunde
// geschrieben — bei jeder Anfrage zu schreiben hiesse, jede Anfrage in
// einen Schreibzugriff zu verwandeln, und SQLite serialisiert die.
if (jetzt - eintrag.LastSeenAt > TimeSpan.FromHours(1))
{
eintrag.LastSeenAt = jetzt;
await db.SaveChangesAsync();
}
var ausweis = new ClaimsIdentity(
[
new Claim(ClaimTypes.NameIdentifier, benutzer.Id.ToString()),
new Claim(ClaimTypes.Name, benutzer.UserName),
new Claim(DeviceTokenIdClaim, eintrag.Id.ToString()),
.. benutzer.IsAdministrator ? new[] { new Claim(ClaimTypes.Role, FlaroRoles.Administrator) } : [],
], SchemeName);
return AuthenticateResult.Success(new AuthenticationTicket(new ClaimsPrincipal(ausweis), SchemeName));
}
}
public static class FlaroRoles
{
public const string Administrator = "administrator";
}
@@ -0,0 +1,190 @@
using System.Security.Claims;
using Flaro.Api.Auth;
using Flaro.Core.Auth;
using Flaro.Core.Users;
using Flaro.Data;
using Microsoft.EntityFrameworkCore;
namespace Flaro.Api.Endpoints;
public static class AuthEndpoints
{
/// <summary>Ein Hash, gegen den geprüft wird, wenn es den Benutzer gar nicht gibt.</summary>
///
/// <remarks>
/// <b>Gegen das Abfragen von Benutzernamen über die Uhr.</b> Ohne ihn
/// antwortet der Server bei einem unbekannten Namen sofort und bei einem
/// bekannten erst nach dem Argon2-Lauf — vierzig Millisekunden Unterschied,
/// und damit lässt sich eine Liste gültiger Konten zusammentragen.
/// </remarks>
private static readonly Lazy<string> Strohmann =
new(() => PasswordHasher.Hash(Secrets.Create()), LazyThreadSafetyMode.ExecutionAndPublication);
public static void MapAuth(this IEndpointRouteBuilder app)
{
app.MapPost("/api/v1/auth/login", async (
LoginRequest anfrage, FlaroContext db, TimeProvider clock) =>
{
var name = anfrage.UserName.Trim();
var benutzer = await db.Users.FirstOrDefaultAsync(u => u.UserName == name);
if (benutzer is null)
{
// Dieselbe Arbeit wie bei einem echten Konto, dann ablehnen.
PasswordHasher.Verify(anfrage.Password, Strohmann.Value);
return Abgelehnt();
}
if (!PasswordHasher.Verify(anfrage.Password, benutzer.PasswordHash)) return Abgelehnt();
// Gelegenheit genutzt: hier liegt das Passwort im Klartext vor, und
// nur hier lässt sich ein nach alten Kennwerten gebildeter Hash
// überhaupt erneuern.
if (PasswordHasher.NeedsRehash(benutzer.PasswordHash))
benutzer.PasswordHash = PasswordHasher.Hash(anfrage.Password);
var antwort = await TokenAusstellen(db, clock, benutzer, anfrage.DeviceName, anfrage.DeviceId);
await db.SaveChangesAsync();
return Results.Ok(antwort);
}).AllowAnonymous().RequireRateLimiting(FlaroRateLimits.SignIn);
app.MapPost("/api/v1/auth/logout", async (ClaimsPrincipal wer, FlaroContext db, TimeProvider clock) =>
{
if (!Guid.TryParse(wer.FindFirstValue(TokenAuthenticationHandler.DeviceTokenIdClaim), out var id))
return Results.Unauthorized();
var eintrag = await db.DeviceTokens.FindAsync(id);
if (eintrag is not null)
{
eintrag.RevokedAt = clock.GetUtcNow();
await db.SaveChangesAsync();
}
return Results.NoContent();
}).RequireAuthorization();
app.MapGet("/api/v1/auth/me", (ClaimsPrincipal wer) =>
Guid.TryParse(wer.FindFirstValue(ClaimTypes.NameIdentifier), out var id)
? Results.Ok(new AuthenticatedUser(
id,
wer.FindFirstValue(ClaimTypes.Name) ?? "",
wer.IsInRole(FlaroRoles.Administrator)))
: Results.Unauthorized())
.RequireAuthorization();
MapCodeFlow(app);
}
/// <summary>Anmelden mit einem Code auf dem Bildschirm.</summary>
private static void MapCodeFlow(IEndpointRouteBuilder app)
{
app.MapPost("/api/v1/auth/code", async (CodeRequest anfrage, FlaroContext db, TimeProvider clock) =>
{
var jetzt = clock.GetUtcNow();
// Beiläufig aufräumen. Ein eigener Hintergrunddienst für ein paar
// Zeilen je Tag wäre Aufwand ohne Gegenwert.
await db.AuthCodes.Where(c => c.ExpiresAt < jetzt).ExecuteDeleteAsync();
var abholschein = Secrets.Create();
var code = new AuthCode
{
Code = AuthCode.NewCode(),
HandleFingerprint = Secrets.Fingerprint(abholschein),
DeviceName = Kürzen(anfrage.DeviceName, 128),
DeviceId = Kürzen(anfrage.DeviceId, 128),
CreatedAt = jetzt,
ExpiresAt = jetzt + AuthCode.Lifetime,
};
db.AuthCodes.Add(code);
await db.SaveChangesAsync();
return Results.Ok(new CodeResponse(code.Code, abholschein, code.ExpiresAt));
}).AllowAnonymous().RequireRateLimiting(FlaroRateLimits.SignIn);
app.MapGet("/api/v1/auth/code/{handle}", async (
string handle, FlaroContext db, TimeProvider clock) =>
{
var jetzt = clock.GetUtcNow();
var fingerabdruck = Secrets.Fingerprint(handle);
var code = await db.AuthCodes.FirstOrDefaultAsync(c => c.HandleFingerprint == fingerabdruck);
if (code is null || code.IsExpired(jetzt) || code.ConsumedAt is not null)
return Results.Ok(new CodeState("expired", null));
if (code.ApprovedByUserId is not { } benutzerId)
return Results.Ok(new CodeState("pending", null));
var benutzer = await db.Users.FindAsync(benutzerId);
if (benutzer is null) return Results.Ok(new CodeState("expired", null));
// Einmal abgeholt ist abgeholt — vor dem Ausstellen gesetzt, damit
// zwei gleichzeitige Abholungen nicht zwei Token ergeben.
code.ConsumedAt = jetzt;
var antwort = await TokenAusstellen(db, clock, benutzer, code.DeviceName, code.DeviceId);
await db.SaveChangesAsync();
return Results.Ok(new CodeState("approved", antwort));
}).AllowAnonymous().RequireRateLimiting(FlaroRateLimits.Polling);
app.MapPost("/api/v1/auth/code/{code}", async (
string code, ClaimsPrincipal wer, FlaroContext db, TimeProvider clock) =>
{
if (!Guid.TryParse(wer.FindFirstValue(ClaimTypes.NameIdentifier), out var benutzerId))
return Results.Unauthorized();
var jetzt = clock.GetUtcNow();
var offen = await db.AuthCodes.FirstOrDefaultAsync(c =>
c.Code == code && c.ApprovedByUserId == null && c.ExpiresAt > jetzt);
if (offen is null)
return Results.Problem("Dieser Code gilt nicht mehr.", statusCode: StatusCodes.Status404NotFound);
offen.ApprovedByUserId = benutzerId;
await db.SaveChangesAsync();
// Damit am Telefon steht, was man da gerade hereinlässt.
return Results.Ok(new { device = offen.DeviceName });
}).RequireAuthorization().RequireRateLimiting(FlaroRateLimits.SignIn);
}
/// <summary>Stellt ein Geräte-Token aus und löst ein älteres desselben Geräts ab.</summary>
///
/// <remarks>
/// Ohne das Ablösen füllt sich die Geräteliste mit Karteileichen: jede
/// erneute Anmeldung derselben App hinterliesse einen weiteren Eintrag,
/// und nach einem Jahr weiss niemand mehr, welches Telefon welches ist.
/// </remarks>
private static async Task<LoginResponse> TokenAusstellen(
FlaroContext db, TimeProvider clock, User benutzer, string geräteName, string geräteKennung)
{
var jetzt = clock.GetUtcNow();
var kennung = Kürzen(geräteKennung, 128);
await db.DeviceTokens
.Where(t => t.UserId == benutzer.Id && t.DeviceId == kennung && t.RevokedAt == null)
.ExecuteUpdateAsync(s => s.SetProperty(t => t.RevokedAt, jetzt));
var token = Secrets.Create();
db.DeviceTokens.Add(new DeviceToken
{
UserId = benutzer.Id,
TokenFingerprint = Secrets.Fingerprint(token),
DeviceName = Kürzen(geräteName, 128),
DeviceId = kennung,
CreatedAt = jetzt,
LastSeenAt = jetzt,
});
return new LoginResponse(token, new AuthenticatedUser(benutzer.Id, benutzer.UserName, benutzer.IsAdministrator));
}
/// <summary>Eine Antwort, die nicht verrät, welche Hälfte falsch war.</summary>
private static IResult Abgelehnt() =>
Results.Problem("Benutzername oder Passwort stimmt nicht.", statusCode: StatusCodes.Status401Unauthorized);
private static string Kürzen(string? wert, int höchstens)
{
var sauber = (wert ?? "").Trim();
return sauber.Length <= höchstens ? sauber : sauber[..höchstens];
}
}
+134
View File
@@ -0,0 +1,134 @@
namespace Flaro.Api.Endpoints;
// Was über die Leitung geht. Bewusst eigene Typen und nicht die der Domäne:
// sonst wandert jede Umbenennung im Datenmodell ungefragt in die Schnittstelle
// und bricht Clients, die niemand mehr aktualisiert.
public sealed record SystemInfo(
string Version, bool NeedsSetup, IReadOnlyList<AttributionResponse> Attributions);
public sealed record SetupRequest(string UserName, string Password);
public sealed record LoginRequest(string UserName, string Password, string DeviceName, string DeviceId);
public sealed record AuthenticatedUser(Guid Id, string UserName, bool IsAdministrator);
public sealed record LoginResponse(string Token, AuthenticatedUser User);
public sealed record CodeRequest(string DeviceName, string DeviceId);
/// <summary>Was der Fernseher nach dem Anfordern bekommt.</summary>
///
/// <remarks>
/// Der <c>Handle</c> ist das Geheimnis und wird <b>nicht</b> angezeigt — der
/// <c>Code</c> ist das, was auf dem Bildschirm steht.
/// </remarks>
public sealed record CodeResponse(string Code, string Handle, DateTimeOffset ExpiresAt);
public sealed record CodeState(string State, LoginResponse? Login);
public sealed record CreateUserRequest(string UserName, string Password, bool IsAdministrator);
public sealed record LibraryResponse(Guid Id, string Name, string Kind, string Path);
public sealed record CreateLibraryRequest(string Name, string Kind, string Path);
// --- Stufe 2: Bibliothek -------------------------------------------------
public sealed record ScanProblem(string Path, string Reason);
public sealed record ScanStatus(
string State,
DateTimeOffset? StartedAt,
DateTimeOffset? FinishedAt,
int? FilesFound,
int? Added,
int? Updated,
int? Removed,
int? Unrecognised,
int? Probed,
IReadOnlyList<ScanProblem>? Problems,
string? Error,
MetadataProgress? Metadata);
/// <summary>Was das Nachschlagen bei der Metadatenquelle ergeben hat.</summary>
public sealed record MetadataProgress(
int MoviesMatched, int SeriesMatched, int EpisodesMatched, bool Skipped, string? Error);
/// <summary>Eine Spur in einer Datei.</summary>
public sealed record MediaStreamResponse(
int Index, string Kind, string? Codec, string? Language, string? Title,
bool IsDefault, bool IsForced, int? Channels, int? Width, int? Height);
/// <summary>Eine Datei hinter einem Titel — eine Fassung also.</summary>
///
/// <remarks>
/// <b>Nur der Dateiname, nicht der Pfad.</b> Der Pfad verriete jedem
/// Angemeldeten den Aufbau des Servers, und gebraucht wird er nirgends: die
/// App spricht Dateien über ihre Kennung an. Zum Unterscheiden zweier
/// Fassungen genügt der Name.
/// </remarks>
public sealed record MediaFileResponse(
Guid Id, string FileName, long SizeBytes, string? Container, double? DurationSeconds,
IReadOnlyList<MediaStreamResponse> Streams);
public sealed record MovieResponse(
Guid Id, string Title, int? Year, string? Overview,
string? PosterImage, string? BackdropImage,
IReadOnlyList<MediaFileResponse> Files);
public sealed record SeriesResponse(
Guid Id, string Title, int? Year, string? Overview,
string? PosterImage, string? BackdropImage,
int EpisodeCount, IReadOnlyList<int> Seasons);
public sealed record EpisodeResponse(
Guid Id, int Season, int Episode, string? Title, string? Overview, string? StillImage,
IReadOnlyList<MediaFileResponse> Files);
public sealed record Page<T>(IReadOnlyList<T> Items, int Total);
public sealed record MetadataSettingsResponse(
bool HasApiKey, string Language, bool UsingProjectKey);
/// <summary>Was ein Client über der Mediathek anzeigen muss.</summary>
///
/// <remarks>
/// <b>Bedingung, nicht Höflichkeit.</b> TMDB verlangt in seinen
/// Nutzungsbedingungen, dass das Logo gezeigt wird und dieser Satz dasteht.
/// Der Server liefert beides, damit kein Client es abtippen muss — und keiner
/// es vergisst.
/// </remarks>
public sealed record AttributionResponse(string Text, string LogoUrl, string Url);
public sealed record MetadataSettingsRequest(string? ApiKey, string? Language);
// --- Stufe 3: Ausliefern -------------------------------------------------
/// <summary>Was mit einer Datei geschehen wird, bevor sie hinausgeht.</summary>
///
/// <remarks>
/// <b>Damit vorher feststeht, was man bekommt.</b> Drei Gigabyte
/// herunterzuladen und erst danach zu merken, dass die Untertitel fehlen oder
/// die falsche Tonspur drin ist, war der Anlass für dieses ganze Projekt.
/// </remarks>
public sealed record DeliveryInfo(
string Method,
IReadOnlyList<string> Reasons,
IReadOnlyList<int> AudioToEncode,
IReadOnlyList<int> SubtitlesDropped,
string PackageState,
double Progress,
string? Error);
// --- Zuordnung korrigieren -----------------------------------------------
/// <summary>Ein Vorschlag, aus dem gewählt werden kann.</summary>
public sealed record MatchCandidate(
int TmdbId, string Title, int? Year, string? Overview, string? PosterUrl);
public sealed record ApplyMatchRequest(int TmdbId);
/// <summary>Wie die Zuordnung jetzt aussieht.</summary>
public sealed record MatchState(
int? TmdbId, string? ImdbId, bool Locked, string Title, int? Year);
@@ -0,0 +1,105 @@
using Flaro.Core.Delivery;
using Flaro.Core.Library;
using Flaro.Data;
using Microsoft.EntityFrameworkCore;
namespace Flaro.Api.Endpoints;
public static class DeliveryEndpoints
{
public static void MapDelivery(this IEndpointRouteBuilder app)
{
app.MapGet("/api/v1/files/{id:guid}/delivery", async (
Guid id, FlaroContext db, PackageCoordinator packer) =>
{
var gefunden = await Laden(db, id);
if (gefunden is not var (datei, plan)) return Results.NotFound();
return Results.Ok(Auskunft(plan, packer, datei.Id, datei.ModifiedAt));
}).RequireAuthorization();
app.MapGet("/api/v1/files/{id:guid}/download", async (
Guid id, FlaroContext db, PackageCoordinator packer) =>
{
var gefunden = await Laden(db, id);
if (gefunden is not var (datei, plan)) return Results.NotFound();
// Passt schon alles, geht die Datei unverändert hinaus — mit
// Längenangabe und Bereichsanfragen, also mit Fortschritt und
// Fortsetzen.
if (!plan.NeedsWork)
{
return File.Exists(datei.Path)
? Results.File(datei.Path, "video/mp4", Path.GetFileName(datei.Path),
enableRangeProcessing: true)
: Results.Problem($"„{Path.GetFileName(datei.Path)}“ liegt nicht mehr da.",
statusCode: StatusCodes.Status410Gone);
}
var fertig = packer.PathFor(datei.Id, datei.ModifiedAt);
if (File.Exists(fertig))
{
return Results.File(fertig, "video/mp4",
Path.ChangeExtension(Path.GetFileName(datei.Path), ".mp4"),
enableRangeProcessing: true);
}
var stand = packer.State(datei.Id);
if (stand?.Status == "failed")
{
return Results.Problem(
stand.Error ?? "Das Umpacken ist gescheitert.",
statusCode: StatusCodes.Status500InternalServerError);
}
packer.TryStart(datei.Id);
// **202 und kein Warten.** Das Umpacken eines grossen Films dauert
// Minuten; eine so lange offene Anfrage läuft hinter jedem Reverse
// Proxy ins Zeitlimit. Der Client fragt stattdessen nach und holt
// die Datei, wenn sie fertig ist.
return Results.Json(
Auskunft(plan, packer, datei.Id, datei.ModifiedAt),
statusCode: StatusCodes.Status202Accepted);
}).RequireAuthorization();
}
private static async Task<(MediaFile Datei, DeliveryPlan Plan)?> Laden(FlaroContext db, Guid id)
{
var datei = await db.MediaFiles.AsNoTracking().FirstOrDefaultAsync(f => f.Id == id);
if (datei is null) return null;
var spuren = await db.MediaStreams.AsNoTracking()
.Where(s => s.MediaFileId == id).OrderBy(s => s.Index).ToListAsync();
var gelesen = spuren.Select(s => new ProbedStream(
s.Index, s.Kind, s.Codec, s.Language, s.Title,
s.IsDefault, s.IsForced, s.Channels, s.Width, s.Height)).ToList();
return (datei, DeliveryDecider.ForDownload(datei.Container, gelesen));
}
private static DeliveryInfo Auskunft(
DeliveryPlan plan, PackageCoordinator packer, Guid dateiId, DateTimeOffset geändert)
{
var stand = !plan.NeedsWork ? "notNeeded"
: packer.IsReady(dateiId, geändert) ? "ready"
: packer.State(dateiId)?.Status ?? "idle";
return new DeliveryInfo(
Method: plan.Method switch
{
DeliveryMethod.DirectFile => "directFile",
DeliveryMethod.Remux => "remux",
_ => "unknown",
},
Reasons: plan.Reasons.Select(g => Kleinbuchstabe(g.ToString())).ToList(),
AudioToEncode: plan.AudioToEncode,
SubtitlesDropped: plan.SubtitlesDropped,
PackageState: stand,
Progress: packer.State(dateiId)?.Progress ?? (stand == "ready" ? 1 : 0),
Error: packer.State(dateiId)?.Error);
}
private static string Kleinbuchstabe(string name) => char.ToLowerInvariant(name[0]) + name[1..];
}
@@ -0,0 +1,31 @@
using Flaro.Metadata;
using Microsoft.AspNetCore.Mvc;
namespace Flaro.Api.Endpoints;
public static class ImageEndpoints
{
public static void MapImages(this IEndpointRouteBuilder app)
{
app.MapGet("/api/v1/images/{key}", (string key, ImageCache ablage) =>
{
// Gibt `null` zurück, sobald der Schlüssel kein reiner Hash ist —
// ohne diese Prüfung wäre die Adresszeile ein Weg in jedes
// Verzeichnis des Containers.
var pfad = ablage.PfadFür(key);
if (pfad is null || !File.Exists(pfad)) return Results.NotFound();
// Der Schlüssel ist der Hash des Inhalts, also ändert sich das Bild
// unter dieser Adresse nie. Ein Jahr zwischenspeichern spart bei
// einer Mediathek mit tausend Plakaten das meiste an Verkehr.
return Results.File(pfad, "image/jpeg", enableRangeProcessing: true);
})
.RequireAuthorization()
.AddEndpointFilter(async (zusammenhang, weiter) =>
{
var ergebnis = await weiter(zusammenhang);
zusammenhang.HttpContext.Response.Headers.CacheControl = "private, max-age=31536000, immutable";
return ergebnis;
});
}
}
@@ -0,0 +1,176 @@
using Flaro.Data;
using Microsoft.EntityFrameworkCore;
namespace Flaro.Api.Endpoints;
public static class LibraryContentEndpoints
{
/// <summary>Mehr als das liefert keine Anfrage — auch wenn sie danach fragt.</summary>
///
/// <remarks>
/// Ohne Obergrenze holt ein <c>?take=100000</c> die ganze Mediathek in
/// einem Zug, und zwar sowohl in den Arbeitsspeicher des Servers als auch
/// über die Leitung. Eine Grenze, die der Aufrufer setzt, ist keine.
/// </remarks>
private const int HöchstensJeSeite = 200;
public static void MapLibraryContent(this IEndpointRouteBuilder app)
{
MapScan(app);
MapBrowse(app);
}
/// <summary>Holt die Spuren aller genannten Dateien in einer Abfrage.</summary>
///
/// <remarks>
/// Je Datei einzeln zu fragen hiesse: fünfzig Titel, fünfzig Abfragen —
/// und SQLite führt die nacheinander aus.
/// </remarks>
private static async Task<ILookup<Guid, MediaStreamResponse>> SpurenZu(FlaroContext db, List<Guid> dateien)
{
if (dateien.Count == 0) return Array.Empty<(Guid, MediaStreamResponse)>().ToLookup(x => x.Item1, x => x.Item2);
var roh = await db.MediaStreams.AsNoTracking()
.Where(s => dateien.Contains(s.MediaFileId))
.OrderBy(s => s.Index)
.ToListAsync();
return roh.ToLookup(s => s.MediaFileId, s => new MediaStreamResponse(
s.Index, s.Kind.ToString(), s.Codec, s.Language, s.Title,
s.IsDefault, s.IsForced, s.Channels, s.Width, s.Height));
}
private static MediaFileResponse Datei(
Guid id, string pfad, long bytes, string? schachtel, TimeSpan? dauer,
ILookup<Guid, MediaStreamResponse> spuren) =>
new(id, System.IO.Path.GetFileName(pfad), bytes, schachtel, dauer?.TotalSeconds, spuren[id].ToList());
private static void MapScan(IEndpointRouteBuilder app)
{
app.MapPost("/api/v1/libraries/{id:guid}/scan", async (
Guid id, FlaroContext db, ScanCoordinator koordinator) =>
{
if (!await db.Libraries.AnyAsync(l => l.Id == id)) return Results.NotFound();
return koordinator.TryStart(id)
? Results.AcceptedAtRoute("Scanstand", new { id })
: Results.Problem(
"Für diese Bibliothek läuft bereits ein Scan.",
statusCode: StatusCodes.Status409Conflict);
}).RequireAuthorization(FlaroPolicies.Administrator);
app.MapGet("/api/v1/libraries/{id:guid}/scan", (Guid id, ScanCoordinator koordinator) =>
{
var zustand = koordinator.State(id);
if (zustand is null)
return Results.Ok(new ScanStatus("idle", null, null, null, null, null, null, null, null, null, null, null));
var bericht = zustand.Report;
var metadaten = zustand.Enrichment;
return Results.Ok(new ScanStatus(
zustand.Status, zustand.StartedAt, zustand.FinishedAt,
bericht?.FilesFound, bericht?.Added, bericht?.Updated, bericht?.Removed, bericht?.Unrecognised,
bericht?.Probed,
bericht?.Problems.Select(p => new ScanProblem(p.Path, p.Reason)).ToList(),
zustand.Error,
metadaten is null ? null : new MetadataProgress(
metadaten.MoviesMatched, metadaten.SeriesMatched, metadaten.EpisodesMatched,
metadaten.Skipped, metadaten.Error)));
}).WithName("Scanstand").RequireAuthorization();
}
private static void MapBrowse(IEndpointRouteBuilder app)
{
app.MapGet("/api/v1/movies", async (
FlaroContext db, Guid? library, string? search, int skip = 0, int take = 50) =>
{
var abfrage = db.Movies.AsNoTracking();
if (library is { } kennung) abfrage = abfrage.Where(m => m.LibraryId == kennung);
if (!string.IsNullOrWhiteSpace(search))
abfrage = abfrage.Where(m => EF.Functions.Like(m.Title, $"%{search.Trim()}%"));
var gesamt = await abfrage.CountAsync();
var kennungen = await abfrage
.OrderBy(m => m.SortTitleValue).ThenBy(m => m.Year)
.Skip(Math.Max(0, skip)).Take(Math.Clamp(take, 1, HöchstensJeSeite))
.Select(m => new { m.Id, m.Title, m.Year, m.Overview, m.PosterImage, m.BackdropImage })
.ToListAsync();
// Dateien in **einer** Abfrage nachholen statt je Film einer.
// Andernfalls kostet eine Seite mit fünfzig Titeln einundfünfzig
// Abfragen, und SQLite führt die nacheinander aus.
var ids = kennungen.Select(m => m.Id).ToList();
var dateien = await db.MediaFiles.AsNoTracking()
.Where(f => f.MovieId != null && ids.Contains(f.MovieId!.Value))
.Select(f => new { f.Id, f.Path, f.SizeBytes, f.Container, f.Duration, f.MovieId })
.ToListAsync();
var spuren = await SpurenZu(db, dateien.Select(f => f.Id).ToList());
var items = kennungen.Select(m => new MovieResponse(
m.Id, m.Title, m.Year, m.Overview, m.PosterImage, m.BackdropImage,
dateien.Where(f => f.MovieId == m.Id)
.Select(f => Datei(f.Id, f.Path, f.SizeBytes, f.Container, f.Duration, spuren)).ToList())).ToList();
return Results.Ok(new Page<MovieResponse>(items, gesamt));
}).RequireAuthorization();
app.MapGet("/api/v1/series", async (
FlaroContext db, Guid? library, string? search, int skip = 0, int take = 50) =>
{
var abfrage = db.Series.AsNoTracking();
if (library is { } kennung) abfrage = abfrage.Where(s => s.LibraryId == kennung);
if (!string.IsNullOrWhiteSpace(search))
abfrage = abfrage.Where(s => EF.Functions.Like(s.Title, $"%{search.Trim()}%"));
var gesamt = await abfrage.CountAsync();
var serien = await abfrage
.OrderBy(s => s.SortTitleValue)
.Skip(Math.Max(0, skip)).Take(Math.Clamp(take, 1, HöchstensJeSeite))
.Select(s => new { s.Id, s.Title, s.Year, s.Overview, s.PosterImage, s.BackdropImage })
.ToListAsync();
var ids = serien.Select(s => s.Id).ToList();
var folgen = await db.Episodes.AsNoTracking()
.Where(f => ids.Contains(f.SeriesId))
.Select(f => new { f.SeriesId, f.SeasonNumber })
.ToListAsync();
var items = serien.Select(s =>
{
var meine = folgen.Where(f => f.SeriesId == s.Id).ToList();
return new SeriesResponse(
s.Id, s.Title, s.Year, s.Overview, s.PosterImage, s.BackdropImage, meine.Count,
meine.Select(f => f.SeasonNumber).Distinct().Order().ToList());
}).ToList();
return Results.Ok(new Page<SeriesResponse>(items, gesamt));
}).RequireAuthorization();
app.MapGet("/api/v1/series/{id:guid}/episodes", async (Guid id, FlaroContext db, int? season) =>
{
if (!await db.Series.AnyAsync(s => s.Id == id)) return Results.NotFound();
var abfrage = db.Episodes.AsNoTracking().Where(f => f.SeriesId == id);
if (season is { } staffel) abfrage = abfrage.Where(f => f.SeasonNumber == staffel);
var folgen = await abfrage
.OrderBy(f => f.SeasonNumber).ThenBy(f => f.EpisodeNumber)
.Select(f => new { f.Id, f.SeasonNumber, f.EpisodeNumber, f.Title, f.Overview, f.StillImage })
.ToListAsync();
var ids = folgen.Select(f => f.Id).ToList();
var dateien = await db.MediaFiles.AsNoTracking()
.Where(d => d.EpisodeId != null && ids.Contains(d.EpisodeId!.Value))
.Select(d => new { d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, d.EpisodeId })
.ToListAsync();
var spuren = await SpurenZu(db, dateien.Select(d => d.Id).ToList());
var items = folgen.Select(f => new EpisodeResponse(
f.Id, f.SeasonNumber, f.EpisodeNumber, f.Title, f.Overview, f.StillImage,
dateien.Where(d => d.EpisodeId == f.Id)
.Select(d => Datei(d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, spuren)).ToList())).ToList();
return Results.Ok(new Page<EpisodeResponse>(items, items.Count));
}).RequireAuthorization();
}
}
@@ -0,0 +1,94 @@
using Flaro.Api.Auth;
using Flaro.Core.Libraries;
using Flaro.Core.Users;
using Flaro.Data;
using Microsoft.EntityFrameworkCore;
namespace Flaro.Api.Endpoints;
public static class ManagementEndpoints
{
public static void MapManagement(this IEndpointRouteBuilder app)
{
var benutzer = app.MapGroup("/api/v1/users").RequireAuthorization(FlaroPolicies.Administrator);
benutzer.MapGet("/", async (FlaroContext db) =>
Results.Ok(await db.Users
.OrderBy(u => u.UserName)
.Select(u => new AuthenticatedUser(u.Id, u.UserName, u.IsAdministrator))
.ToListAsync()));
benutzer.MapPost("/", async (CreateUserRequest anfrage, FlaroContext db) =>
{
var name = anfrage.UserName.Trim();
if (name.Length == 0 || anfrage.Password.Length < 8)
return Results.ValidationProblem(new Dictionary<string, string[]>
{
["password"] = ["Benutzername darf nicht leer sein, das Passwort braucht mindestens 8 Zeichen."],
});
if (await db.Users.AnyAsync(u => u.UserName == name))
return Results.Problem($"„{name}“ gibt es schon.", statusCode: StatusCodes.Status409Conflict);
var neu = new User
{
UserName = name,
PasswordHash = PasswordHasher.Hash(anfrage.Password),
IsAdministrator = anfrage.IsAdministrator,
};
db.Users.Add(neu);
await db.SaveChangesAsync();
return Results.Created($"/api/v1/users/{neu.Id}",
new AuthenticatedUser(neu.Id, neu.UserName, neu.IsAdministrator));
});
var bibliotheken = app.MapGroup("/api/v1/libraries");
// Lesen darf jeder Angemeldete — ohne die Liste zeigt die App nichts an.
bibliotheken.MapGet("/", async (FlaroContext db) =>
Results.Ok(await db.Libraries
.OrderBy(l => l.Name)
.Select(l => new LibraryResponse(l.Id, l.Name, l.Kind.ToString(), l.Path))
.ToListAsync()))
.RequireAuthorization();
bibliotheken.MapPost("/", async (CreateLibraryRequest anfrage, FlaroContext db) =>
{
if (!Enum.TryParse<LibraryKind>(anfrage.Kind, ignoreCase: true, out var art))
return Results.ValidationProblem(new Dictionary<string, string[]>
{
["kind"] = [$"Unbekannte Art „{anfrage.Kind}“. Möglich: Movies, Shows."],
});
// **Jetzt prüfen, nicht beim ersten Scan.** Ein Tippfehler im Pfad
// führte sonst zu einer Bibliothek, die dauerhaft leer bleibt, und
// die Ursache stünde allenfalls im Protokoll.
if (!Directory.Exists(anfrage.Path))
return Results.ValidationProblem(new Dictionary<string, string[]>
{
["path"] = [$"„{anfrage.Path}“ gibt es im Container nicht. " +
"Ist das Verzeichnis eingehängt? Gemeint ist der Pfad im Container, nicht der auf dem Wirt."],
});
var neu = new Library { Name = anfrage.Name.Trim(), Kind = art, Path = anfrage.Path };
db.Libraries.Add(neu);
await db.SaveChangesAsync();
return Results.Created($"/api/v1/libraries/{neu.Id}",
new LibraryResponse(neu.Id, neu.Name, neu.Kind.ToString(), neu.Path));
}).RequireAuthorization(FlaroPolicies.Administrator);
}
}
public static class FlaroPolicies
{
public const string Administrator = "administrator-only";
}
public static class FlaroRateLimits
{
/// <summary>Für alles, was ein Geheimnis prüft.</summary>
public const string SignIn = "sign-in";
/// <summary>Für das Nachfragen des Fernsehers — darf häufig sein.</summary>
public const string Polling = "polling";
}
@@ -0,0 +1,88 @@
using Flaro.Core.Library;
using Flaro.Core.Metadata;
using Flaro.Data;
using Microsoft.EntityFrameworkCore;
namespace Flaro.Api.Endpoints;
/// <summary>Eine falsche Zuordnung von Hand richtigstellen.</summary>
///
/// <remarks>
/// <para>
/// <b>Weil Raten manchmal danebengeht.</b> Die Suche nach „Dark" lieferte
/// „Dark Shadows" von 1966 — ohne Jahresangabe gewinnt bei TMDB der
/// beliebteste Treffer. Dagegen hilft keine bessere Heuristik, sondern nur
/// die Möglichkeit, es richtigzustellen.
/// </para>
/// <para>
/// <b>Eine Korrektur wird festgeschrieben.</b> In der Datenbank <i>und</i> in
/// der NFO-Datei neben dem Film, mit <c>lockdata</c>. Danach sucht kein Lauf
/// mehr danach — und ein Neuaufbau der Datenbank oder ein Umzug auf einen
/// anderen Server findet die Entscheidung wieder.
/// </para>
/// </remarks>
public static class MatchEndpoints
{
public static void MapMatching(this IEndpointRouteBuilder app)
{
app.MapGet("/api/v1/movies/{id:guid}/matches", async (
Guid id, FlaroContext db, IMetadataProvider anbieter, string? query, int? year) =>
{
var film = await db.Movies.AsNoTracking().FirstOrDefaultAsync(m => m.Id == id);
if (film is null) return Results.NotFound();
// Ohne eigene Suchworte das, was aus dem Dateinamen gelesen wurde
// — nicht der angezeigte Titel, der ja gerade der falsche ist.
var vorschläge = await anbieter.SearchMoviesAsync(
string.IsNullOrWhiteSpace(query) ? film.ParsedTitle : query.Trim(), year ?? film.Year);
return Results.Ok(vorschläge.Select(v =>
new MatchCandidate(v.ProviderId, v.Title, v.Year, v.Overview, v.Poster?.Url)).ToList());
}).RequireAuthorization(FlaroPolicies.Administrator);
app.MapPut("/api/v1/movies/{id:guid}/match", async (
Guid id, ApplyMatchRequest anfrage, FlaroContext db,
IMetadataProvider anbieter, MatchApplier anwender) =>
{
var film = await db.Movies.FirstOrDefaultAsync(m => m.Id == id);
if (film is null) return Results.NotFound();
var gefunden = await anbieter.GetMovieAsync(anfrage.TmdbId);
if (gefunden is null)
return Results.Problem($"TMDB kennt den Film {anfrage.TmdbId} nicht.",
statusCode: StatusCodes.Status404NotFound);
await anwender.ApplyAsync(film, gefunden);
return Results.Ok(new MatchState(film.TmdbId, film.ImdbId, film.MatchLocked, film.Title, film.Year));
}).RequireAuthorization(FlaroPolicies.Administrator);
app.MapGet("/api/v1/series/{id:guid}/matches", async (
Guid id, FlaroContext db, IMetadataProvider anbieter, string? query, int? year) =>
{
var serie = await db.Series.AsNoTracking().FirstOrDefaultAsync(s => s.Id == id);
if (serie is null) return Results.NotFound();
var vorschläge = await anbieter.SearchSeriesAsync(
string.IsNullOrWhiteSpace(query) ? serie.ParsedTitle : query.Trim(), year ?? serie.Year);
return Results.Ok(vorschläge.Select(v =>
new MatchCandidate(v.ProviderId, v.Title, v.Year, v.Overview, v.Poster?.Url)).ToList());
}).RequireAuthorization(FlaroPolicies.Administrator);
app.MapPut("/api/v1/series/{id:guid}/match", async (
Guid id, ApplyMatchRequest anfrage, FlaroContext db,
IMetadataProvider anbieter, MatchApplier anwender) =>
{
var serie = await db.Series.FirstOrDefaultAsync(s => s.Id == id);
if (serie is null) return Results.NotFound();
var gefunden = await anbieter.GetSeriesAsync(anfrage.TmdbId);
if (gefunden is null)
return Results.Problem($"TMDB kennt die Serie {anfrage.TmdbId} nicht.",
statusCode: StatusCodes.Status404NotFound);
await anwender.ApplyAsync(serie, gefunden);
return Results.Ok(new MatchState(serie.TmdbId, serie.ImdbId, serie.MatchLocked, serie.Title, serie.Year));
}).RequireAuthorization(FlaroPolicies.Administrator);
}
}
@@ -0,0 +1,61 @@
using Flaro.Core;
using Flaro.Data;
using Flaro.Metadata;
using Microsoft.EntityFrameworkCore;
namespace Flaro.Api.Endpoints;
public static class SettingsEndpoints
{
public static void MapSettings(this IEndpointRouteBuilder app)
{
app.MapGet("/api/v1/settings/metadata", async (FlaroContext db) =>
{
var zeilen = await db.Settings.AsNoTracking()
.Where(s => s.Key == DatabaseTmdbSettings.ApiKeyName || s.Key == DatabaseTmdbSettings.LanguageName)
.ToDictionaryAsync(s => s.Key, s => s.Value);
// **Der Schlüssel selbst kommt nicht zurück.** Er gäbe jedem
// Angemeldeten das TMDB-Kontingent des Betreibers in die Hand, und
// zum Anzeigen genügt die Auskunft, dass einer da ist.
var aufgelöst = TmdbSettings.Resolve(
zeilen.GetValueOrDefault(DatabaseTmdbSettings.ApiKeyName),
zeilen.GetValueOrDefault(DatabaseTmdbSettings.LanguageName));
return Results.Ok(new MetadataSettingsResponse(
HasApiKey: !string.IsNullOrEmpty(aufgelöst.ApiKey),
Language: aufgelöst.Language,
// Damit in der Oberfläche steht, ob der mitgelieferte Schlüssel
// benutzt wird — und dass ein eigener ihn ablösen würde.
UsingProjectKey: aufgelöst.UsingProjectKey));
}).RequireAuthorization(FlaroPolicies.Administrator);
app.MapPut("/api/v1/settings/metadata", async (
MetadataSettingsRequest anfrage, FlaroContext db) =>
{
if (anfrage.ApiKey is not null) await Setzen(db, DatabaseTmdbSettings.ApiKeyName, anfrage.ApiKey.Trim());
if (anfrage.Language is not null)
{
var sprache = anfrage.Language.Trim();
// Form wie „de-DE". Alles andere nähme TMDB stillschweigend als
// Englisch an, und niemand wüsste, warum.
if (!System.Text.RegularExpressions.Regex.IsMatch(sprache, "^[a-z]{2}(-[A-Z]{2})?$"))
return Results.ValidationProblem(new Dictionary<string, string[]>
{
["language"] = [$"„{sprache}“ ist keine Sprachkennung. Erwartet wird etwa „de-DE“ oder „en“."],
});
await Setzen(db, DatabaseTmdbSettings.LanguageName, sprache);
}
await db.SaveChangesAsync();
return Results.NoContent();
}).RequireAuthorization(FlaroPolicies.Administrator);
}
private static async Task Setzen(FlaroContext db, string schlüssel, string wert)
{
var vorhanden = await db.Settings.FindAsync(schlüssel);
if (vorhanden is null) db.Settings.Add(new ServerSetting { Key = schlüssel, Value = wert });
else vorhanden.Value = wert;
}
}
@@ -0,0 +1,68 @@
using Flaro.Core.Users;
using Flaro.Metadata;
using Flaro.Data;
using Microsoft.EntityFrameworkCore;
namespace Flaro.Api.Endpoints;
public static class SystemEndpoints
{
/// <summary>Verhindert, dass zwei gleichzeitige Anfragen zwei Administratoren anlegen.</summary>
///
/// <remarks>
/// Ein Prozess, ein Container — ein Semaphor reicht. Ohne ihn liegt
/// zwischen „kein Benutzer vorhanden" und dem Anlegen ein Fenster, und
/// genau in dieses Fenster passt eine zweite Anfrage.
/// </remarks>
private static readonly SemaphoreSlim EinrichtungsSchloss = new(1, 1);
public static void MapSystem(this IEndpointRouteBuilder app)
{
app.MapGet("/api/v1/system/info", async (FlaroContext db) =>
Results.Ok(new SystemInfo(
Version: typeof(SystemEndpoints).Assembly.GetName().Version?.ToString(3) ?? "0.0.0",
NeedsSetup: !await db.Users.AnyAsync(),
// Ohne Anmeldung abrufbar: ein Client muss den Hinweis auch
// auf dem Anmeldebildschirm zeigen können.
Attributions:
[
new AttributionResponse(Attribution.Tmdb, Attribution.TmdbLogoUrl, Attribution.TmdbUrl),
])))
.AllowAnonymous();
app.MapPost("/api/v1/system/setup", async (SetupRequest anfrage, FlaroContext db) =>
{
if (string.IsNullOrWhiteSpace(anfrage.UserName) || anfrage.Password.Length < 8)
return Results.ValidationProblem(new Dictionary<string, string[]>
{
["password"] = ["Benutzername darf nicht leer sein, das Passwort braucht mindestens 8 Zeichen."],
});
await EinrichtungsSchloss.WaitAsync();
try
{
// Noch einmal prüfen, innerhalb des Schlosses. Draussen wäre die
// Antwort schon veraltet, wenn sie ankommt.
if (await db.Users.AnyAsync())
return Results.Problem(
"Dieser Server ist bereits eingerichtet.", statusCode: StatusCodes.Status409Conflict);
var administrator = new User
{
UserName = anfrage.UserName.Trim(),
PasswordHash = PasswordHasher.Hash(anfrage.Password),
IsAdministrator = true,
};
db.Users.Add(administrator);
await db.SaveChangesAsync();
return Results.Ok(new AuthenticatedUser(administrator.Id, administrator.UserName, true));
}
finally { EinrichtungsSchloss.Release(); }
}).AllowAnonymous();
// Ohne Anmeldung, damit Docker und Reverse Proxies danach fragen können.
// Verrät nichts ausser der Tatsache, dass etwas antwortet.
app.MapGet("/health", () => Results.Ok(new { status = "ok" })).AllowAnonymous();
}
}
+23
View File
@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<ItemGroup>
<ProjectReference Include="..\Flaro.Core\Flaro.Core.csproj" />
<ProjectReference Include="..\Flaro.Data\Flaro.Data.csproj" />
<ProjectReference Include="..\Flaro.Metadata\Flaro.Metadata.csproj" />
<ProjectReference Include="..\Flaro.Media\Flaro.Media.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.12">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>
+110
View File
@@ -0,0 +1,110 @@
namespace Flaro.Api;
/// <summary>Wo der Server seine Sachen ablegt.</summary>
///
/// <remarks>
/// <para>
/// <b>Alles aus der Umgebung, nichts im Code.</b> Dies ist kein Server für
/// einen Haushalt: wer ihn startet, hat ein anderes NAS, andere Platten und
/// andere Gewohnheiten. Ein fester Pfad hier wäre eine Annahme über ein
/// fremdes System.
/// </para>
/// <para>
/// Die Voreinstellungen sind die für Entwicklung auf einem Arbeitsrechner —
/// ein Unterverzeichnis neben dem Programm. Im Container setzt das Dockerfile
/// <c>/config</c>, <c>/cache</c> und <c>/transcode</c>; das sind die Pfade,
/// die in der Compose-Datei eingehängt werden.
/// </para>
/// <para>
/// <b>Medienpfade stehen hier nicht.</b> Sie werden in der Oberfläche als
/// Bibliotheken angelegt und liegen in der Datenbank — sonst müsste der
/// Container neu starten, um eine Bibliothek hinzuzufügen.
/// </para>
/// </remarks>
public sealed class FlaroPaths
{
public required string Data { get; init; }
public required string Cache { get; init; }
public required string Transcode { get; init; }
public string DatabaseFile => Path.Combine(Data, "flaro.db");
public static FlaroPaths FromEnvironment(string contentRoot)
{
string lies(string name, string voreinstellung) =>
Environment.GetEnvironmentVariable(name) is { Length: > 0 } wert
? wert
: Path.Combine(contentRoot, voreinstellung);
return new FlaroPaths
{
Data = lies("FLARO_DATA", "data"),
Cache = lies("FLARO_CACHE", "cache"),
Transcode = lies("FLARO_TRANSCODE", "transcode"),
};
}
/// <summary>Legt an, was fehlt — und prüft, ob sich dort wirklich schreiben lässt.</summary>
///
/// <remarks>
/// <para>
/// <b>Anlegen allein genügt nicht.</b> Ein vorhandenes Verzeichnis, in das
/// der Benutzer nicht schreiben darf, lässt <c>CreateDirectory</c> ohne
/// Murren durchgehen — und der Server stirbt zehn Zeilen später an
/// „unable to open database file". Diese Meldung sagt niemandem, dass es
/// um Dateirechte geht, geschweige denn um welches Verzeichnis.
/// </para>
/// <para>
/// Es ist der häufigste Stolperstein beim ersten Start eines Containers,
/// und deshalb wird hier tatsächlich eine Datei angelegt und wieder
/// gelöscht, statt es anzunehmen.
/// </para>
/// </remarks>
public void EnsureExists()
{
foreach (var (name, pfad) in new[] { ("FLARO_DATA", Data), ("FLARO_CACHE", Cache), ("FLARO_TRANSCODE", Transcode) })
{
try
{
Directory.CreateDirectory(pfad);
var probe = Path.Combine(pfad, $".flaro-schreibprobe-{Environment.ProcessId}");
File.WriteAllBytes(probe, []);
File.Delete(probe);
}
catch (Exception fehler)
{
throw new InvalidOperationException(
$"""
{name} zeigt auf „{pfad}“, und dort lässt sich nicht schreiben.
Flaro läuft als Benutzer {Kennung()}. Dieses Verzeichnis muss
ihm gehören oder für ihn beschreibbar sein.
Im Container ist das fast immer die Einhängung: entweder gehört
das Verzeichnis auf dem Wirt jemand anderem — dann hilft
`chown -R <uid>:<gid> <verzeichnis>` —, oder in der
Compose-Datei steht ein `user:`, der nicht dazu passt.
Ursprüngliche Meldung: {fehler.Message}
""", fehler);
}
}
}
/// <summary>Unter welcher Kennung der Prozess läuft — die eine Angabe, die beim Suchen hilft.</summary>
private static string Kennung()
{
if (OperatingSystem.IsWindows()) return Environment.UserName;
return $"uid={LibC.getuid()}, gid={LibC.getgid()}";
}
private static partial class LibC
{
[System.Runtime.InteropServices.DllImport("libc", EntryPoint = "getuid")]
public static extern uint getuid();
[System.Runtime.InteropServices.DllImport("libc", EntryPoint = "getgid")]
public static extern uint getgid();
}
}
+127
View File
@@ -0,0 +1,127 @@
using Flaro.Core.Library;
using Flaro.Core.Metadata;
using Flaro.Data;
using Microsoft.EntityFrameworkCore;
namespace Flaro.Api;
/// <summary>Schreibt eine korrigierte Zuordnung überallhin, wo sie hingehört.</summary>
///
/// <remarks>
/// <b>An drei Stellen, und alle drei sind nötig.</b> In die Datenbank, damit
/// es sofort stimmt. In die NFO-Datei, damit es einen Neuaufbau und einen
/// Umzug übersteht. Und als Sperre, damit kein automatischer Lauf die
/// Entscheidung wieder überstimmt.
/// </remarks>
public sealed class MatchApplier(
FlaroContext db,
IMetadataProvider anbieter,
IImageStore bilder,
IImageReader bildLeser,
ISidecarStore beiwerk,
ILogger<MatchApplier> log)
{
public async Task ApplyAsync(Movie film, MovieMetadata gefunden, CancellationToken ct = default)
{
film.TmdbId = gefunden.ProviderId;
film.ImdbId = gefunden.ImdbId;
film.Title = gefunden.Title;
film.SortTitleValue = SortTitle.For(gefunden.Title);
film.Year = gefunden.Year ?? film.Year;
film.Overview = gefunden.Overview;
film.PosterImage = await Ablegen(gefunden.Poster, ct);
film.BackdropImage = await Ablegen(gefunden.Backdrop, ct);
film.MatchLocked = true;
await db.SaveChangesAsync(ct);
var datei = await db.MediaFiles.AsNoTracking().FirstOrDefaultAsync(f => f.MovieId == film.Id, ct);
if (datei is null || !beiwerk.Enabled) return;
var allein = beiwerk.IsAloneInFolder(datei.Path);
await BildNeben(datei.Path, Name(allein, datei.Path, SidecarNames.MoviePoster), film.PosterImage, ct);
await BildNeben(datei.Path, Name(allein, datei.Path, SidecarNames.MovieBackdrop), film.BackdropImage, ct);
await beiwerk.WriteTextAsync(datei.Path, SidecarNames.MovieNfo, Nfo.Write(new MovieNfo(
film.Title, film.Year, film.Overview, new ProviderIds(film.TmdbId, film.ImdbId), true)), ct);
log.LogInformation("Zuordnung von Hand gesetzt: „{Titel}“ → TMDB {Kennung}", film.Title, film.TmdbId);
}
public async Task ApplyAsync(Series serie, SeriesMetadata gefunden, CancellationToken ct = default)
{
serie.TmdbId = gefunden.ProviderId;
serie.ImdbId = gefunden.ImdbId;
serie.Title = gefunden.Title;
serie.SortTitleValue = SortTitle.For(gefunden.Title);
serie.Year = gefunden.Year ?? serie.Year;
serie.Overview = gefunden.Overview;
serie.PosterImage = await Ablegen(gefunden.Poster, ct);
serie.BackdropImage = await Ablegen(gefunden.Backdrop, ct);
serie.MatchLocked = true;
// **Die Folgen gehörten zur alten Serie.** Ihre Beschreibungen und
// Standbilder sind jetzt falsch; sie bleiben stehen, bis sie durch die
// der richtigen Serie ersetzt sind.
var folgen = await db.Episodes.Where(f => f.SeriesId == serie.Id)
.OrderBy(f => f.SeasonNumber).ThenBy(f => f.EpisodeNumber).ToListAsync(ct);
foreach (var folge in folgen)
{
ct.ThrowIfCancellationRequested();
var neu = await anbieter.FindEpisodeAsync(gefunden.ProviderId, folge.SeasonNumber, folge.EpisodeNumber, ct);
folge.Title = neu?.Title;
folge.Overview = neu?.Overview;
folge.StillImage = await Ablegen(neu?.Still, ct);
}
await db.SaveChangesAsync(ct);
if (!beiwerk.Enabled) return;
await SerienBeiwerk(serie, folgen, ct);
log.LogInformation("Zuordnung von Hand gesetzt: „{Titel}“ → TMDB {Kennung}, {Anzahl} Folgen neu geholt",
serie.Title, serie.TmdbId, folgen.Count);
}
private async Task SerienBeiwerk(Series serie, List<Episode> folgen, CancellationToken ct)
{
var folgenKennungen = folgen.Select(f => f.Id).ToList();
var dateien = await db.MediaFiles.AsNoTracking()
.Where(d => d.EpisodeId != null && folgenKennungen.Contains(d.EpisodeId!.Value))
.ToListAsync(ct);
if (dateien.Count == 0) return;
var staffel = Path.GetDirectoryName(dateien[0].Path);
var serienOrdner = staffel is null ? null : Path.GetDirectoryName(staffel);
if (serienOrdner is not null)
{
var anker = Path.Combine(serienOrdner, "egal.mkv");
await BildNeben(anker, SidecarNames.SeriesPoster, serie.PosterImage, ct);
await BildNeben(anker, SidecarNames.SeriesBackdrop, serie.BackdropImage, ct);
await beiwerk.WriteTextAsync(anker, SidecarNames.SeriesNfo, Nfo.Write(new SeriesNfo(
serie.Title, serie.Year, serie.Overview, new ProviderIds(serie.TmdbId, serie.ImdbId), true)), ct);
}
foreach (var datei in dateien)
{
var folge = folgen.First(f => f.Id == datei.EpisodeId);
await BildNeben(datei.Path, SidecarNames.EpisodeThumb(datei.Path), folge.StillImage, ct);
await beiwerk.WriteTextAsync(datei.Path, SidecarNames.EpisodeNfo(datei.Path), Nfo.Write(
new EpisodeNfo(folge.Title, folge.SeasonNumber, folge.EpisodeNumber, folge.Overview)), ct);
}
}
private async Task<string?> Ablegen(RemoteImage? bild, CancellationToken ct) =>
bild is null ? null : await bilder.StoreAsync(bild, ct);
private async Task BildNeben(string anker, string name, string? schlüssel, CancellationToken ct)
{
if (schlüssel is null) return;
await using var strom = await bildLeser.OpenAsync(schlüssel, ct);
if (strom is null) return;
await beiwerk.WriteStreamAsync(anker, name, strom, ct);
}
private static string Name(bool allein, string mediendatei, string beiwerkName) =>
allein ? beiwerkName : SidecarNames.Prefixed(mediendatei, beiwerkName);
}
+37
View File
@@ -0,0 +1,37 @@
using Flaro.Data;
using Flaro.Metadata;
using Microsoft.EntityFrameworkCore;
namespace Flaro.Api;
/// <summary>Holt die Metadaten-Einstellungen aus der Datenbank.</summary>
///
/// <remarks>
/// <b>Hier und nicht in <c>Flaro.Data</c>:</b> sonst müsste die Datenschicht
/// den Metadaten-Anbieter kennen. So zeigen beide Richtungen auf den Kern und
/// keine aufeinander.
///
/// Je Anfrage einmal gelesen und dann gemerkt. Ohne das fragte ein Scan über
/// tausend Titel tausendmal dieselbe Zeile ab.
/// </remarks>
public sealed class DatabaseTmdbSettings(FlaroContext db) : ITmdbSettingsSource
{
public const string ApiKeyName = "metadata.tmdb.apiKey";
public const string LanguageName = "metadata.language";
private TmdbSettings? _gemerkt;
public async Task<TmdbSettings> GetAsync(CancellationToken ct = default)
{
if (_gemerkt is not null) return _gemerkt;
var zeilen = await db.Settings
.AsNoTracking()
.Where(s => s.Key == ApiKeyName || s.Key == LanguageName)
.ToDictionaryAsync(s => s.Key, s => s.Value, ct);
return _gemerkt = TmdbSettings.Resolve(
zeilen.GetValueOrDefault(ApiKeyName),
zeilen.GetValueOrDefault(LanguageName));
}
}
+156
View File
@@ -0,0 +1,156 @@
using System.Collections.Concurrent;
using Flaro.Core.Delivery;
using Flaro.Core.Library;
using Flaro.Data;
using Flaro.Media;
using Microsoft.EntityFrameworkCore;
namespace Flaro.Api;
public sealed record PackageState(
string Status,
double Progress,
DateTimeOffset StartedAt,
DateTimeOffset? FinishedAt,
string? Error);
/// <summary>Packt Dateien im Hintergrund um und weiss, wie weit das ist.</summary>
///
/// <remarks>
/// <para>
/// <b>Erst auf die Platte, dann hinaus — nicht unmittelbar durchgereicht.</b>
/// Der bequeme Weg wäre, ffmpeg in die Antwort schreiben zu lassen. Dann
/// kennt aber niemand die Länge im Voraus: kein Fortschrittsbalken, kein
/// Fortsetzen nach einem Abbruch, kein Springen in der fertigen Datei. Genau
/// daran ist das Herunterladen schon einmal gescheitert — bei drei Gigabyte
/// über Mobilfunk ist „von vorn" keine Antwort.
/// </para>
/// <para>
/// Der Preis ist Plattenplatz und Wartezeit vor dem ersten Byte. Dafür gibt
/// es <c>FLARO_TRANSCODE</c>, und dafür meldet dieser Weg einen Fortschritt.
/// </para>
/// </remarks>
public sealed class PackageCoordinator(
IServiceScopeFactory scopes,
FlaroPaths pfade,
TimeProvider clock,
ILogger<PackageCoordinator> log)
{
private readonly ConcurrentDictionary<Guid, PackageState> _zustand = new();
/// <summary>Wie viel freier Platz über die Dateigrösse hinaus bleiben muss.</summary>
///
/// <remarks>
/// Eine volle Platte ist der unangenehmste Ausgang: ffmpeg bricht
/// irgendwann ab, und nebenher kann der Server seine Datenbank nicht mehr
/// schreiben. Lieber vorher ablehnen und sagen, woran es liegt.
/// </remarks>
private const long Sicherheitsabstand = 2L * 1024 * 1024 * 1024;
public PackageState? State(Guid fileId) => _zustand.GetValueOrDefault(fileId);
/// <summary>Wohin die umgepackte Datei kommt.</summary>
///
/// <remarks>
/// <b>Der Änderungszeitpunkt steckt im Namen.</b> Wird die Quelle ersetzt,
/// heisst das Ergebnis anders — und niemand bekommt die alte Fassung
/// ausgeliefert, bloss weil sie zufällig noch herumliegt.
/// </remarks>
public string PathFor(Guid fileId, DateTimeOffset geändert) =>
Path.Combine(pfade.Transcode, "packages", $"{fileId:N}-{geändert.ToUnixTimeMilliseconds()}.mp4");
public bool IsReady(Guid fileId, DateTimeOffset geändert) => File.Exists(PathFor(fileId, geändert));
/// <summary>Startet einen Lauf — oder sagt, dass schon einer läuft.</summary>
public bool TryStart(Guid fileId)
{
var start = new PackageState("running", 0, clock.GetUtcNow(), null, null);
var vorher = _zustand.AddOrUpdate(fileId, start, (_, alt) => alt.Status == "running" ? alt : start);
if (!ReferenceEquals(vorher, start)) return false;
_ = Task.Run(() => Laufen(fileId));
return true;
}
private async Task Laufen(Guid fileId)
{
try
{
using var bereich = scopes.CreateScope();
var db = bereich.ServiceProvider.GetRequiredService<FlaroContext>();
var datei = await db.MediaFiles.AsNoTracking().FirstOrDefaultAsync(f => f.Id == fileId)
?? throw new InvalidOperationException("Diese Datei steht nicht mehr in der Mediathek.");
if (!File.Exists(datei.Path))
throw new InvalidOperationException($"„{Path.GetFileName(datei.Path)}“ liegt nicht mehr da.");
PlatzPruefen(datei.SizeBytes);
var spuren = await db.MediaStreams.AsNoTracking()
.Where(s => s.MediaFileId == fileId).OrderBy(s => s.Index).ToListAsync();
var gelesen = spuren.Select(s => new ProbedStream(
s.Index, s.Kind, s.Codec, s.Language, s.Title,
s.IsDefault, s.IsForced, s.Channels, s.Width, s.Height)).ToList();
var plan = DeliveryDecider.ForDownload(datei.Container, gelesen);
var ziel = PathFor(fileId, datei.ModifiedAt);
Directory.CreateDirectory(Path.GetDirectoryName(ziel)!);
AelteresWegraeumen(fileId, ziel);
var fortschritt = new Progress<double>(wert =>
_zustand.AddOrUpdate(fileId,
_ => new PackageState("running", wert, clock.GetUtcNow(), null, null),
(_, alt) => alt with { Progress = wert }));
var umpacker = bereich.ServiceProvider.GetRequiredService<FfmpegRemuxer>();
var ergebnis = await umpacker.RunAsync(
new RemuxRequest(datei.Path, ziel, gelesen, plan), datei.Duration, fortschritt);
_zustand[fileId] = ergebnis.Success
? new PackageState("ready", 1, _zustand[fileId].StartedAt, clock.GetUtcNow(), null)
: new PackageState("failed", 0, _zustand[fileId].StartedAt, clock.GetUtcNow(), ergebnis.Error);
}
catch (Exception fehler)
{
log.LogError(fehler, "Das Umpacken von {Kennung} ist gescheitert.", fileId);
_zustand[fileId] = new PackageState(
"failed", 0, _zustand[fileId].StartedAt, clock.GetUtcNow(), fehler.Message);
}
}
/// <summary>Bricht ab, bevor die Platte volläuft.</summary>
private void PlatzPruefen(long quellgrösse)
{
try
{
var platte = new DriveInfo(Path.GetPathRoot(Path.GetFullPath(pfade.Transcode)) ?? "/");
var gebraucht = quellgrösse + Sicherheitsabstand;
if (platte.AvailableFreeSpace >= gebraucht) return;
throw new InvalidOperationException(
$"Auf dem Kratzplatz ({pfade.Transcode}) sind noch " +
$"{platte.AvailableFreeSpace / (1024 * 1024)} MB frei, gebraucht werden etwa " +
$"{gebraucht / (1024 * 1024)} MB. Räume dort auf oder hänge ein grösseres Volume ein.");
}
catch (Exception fehler) when (fehler is ArgumentException or IOException or UnauthorizedAccessException)
{
// Lässt sich der freie Platz nicht ermitteln, wird es versucht.
// Eine Vorsichtsprüfung darf nicht selbst zum Hindernis werden.
log.LogDebug(fehler, "Der freie Platz auf {Pfad} liess sich nicht ermitteln.", pfade.Transcode);
}
}
/// <summary>Löscht frühere Fassungen derselben Datei.</summary>
private void AelteresWegraeumen(Guid fileId, string behalten)
{
var ordner = Path.GetDirectoryName(behalten)!;
foreach (var alt in Directory.EnumerateFiles(ordner, $"{fileId:N}-*.mp4"))
{
if (string.Equals(alt, behalten, StringComparison.Ordinal)) continue;
try { File.Delete(alt); }
catch (IOException fehler) { log.LogDebug(fehler, "{Datei} liess sich nicht löschen.", alt); }
}
}
}
+124
View File
@@ -0,0 +1,124 @@
using System.Threading.RateLimiting;
using Flaro.Api;
using Flaro.Api.Auth;
using Flaro.Api.Endpoints;
using Flaro.Core.Metadata;
using Flaro.Data;
using Flaro.Core.Library;
using Flaro.Media;
using Flaro.Metadata;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
var pfade = FlaroPaths.FromEnvironment(builder.Environment.ContentRootPath);
pfade.EnsureExists();
builder.Services.AddSingleton(pfade);
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddDbContext<FlaroContext>(o => o.UseSqlite($"Data Source={pfade.DatabaseFile}"));
builder.Services
.AddAuthentication(TokenAuthenticationHandler.SchemeName)
.AddScheme<AuthenticationSchemeOptions, TokenAuthenticationHandler>(
TokenAuthenticationHandler.SchemeName, _ => { });
builder.Services.AddAuthorizationBuilder()
// Jeder Endpunkt verlangt Anmeldung, sofern er sie nicht ausdrücklich
// abbestellt. Andersherum — jeden einzeln absichern — ist der Entwurf, bei
// dem irgendwann einer vergessen wird.
.SetFallbackPolicy(new Microsoft.AspNetCore.Authorization.AuthorizationPolicyBuilder()
.RequireAuthenticatedUser().Build())
.AddPolicy(FlaroPolicies.Administrator, p => p.RequireRole(FlaroRoles.Administrator));
// Einstellbar, weil die richtige Zahl vom Aufbau abhängt: steht Flaro hinter
// einem Reverse Proxy, erscheinen **alle** Benutzer unter derselben Adresse
// und teilen sich einen Eimer. Wer das nicht weiss, hält die eigene App für
// kaputt, während in Wahrheit der Nachbar im selben Haus gerade angemeldet hat.
//
// NOCH OFFEN: `X-Forwarded-For` auswerten, damit die Aufteilung auch hinter
// einem Proxy je Benutzer greift. Blind zu vertrauen wäre falsch — es braucht
// eine Liste vertrauenswürdiger Gegenstellen.
int grenze(string name, int voreinstellung) =>
int.TryParse(Environment.GetEnvironmentVariable(name), out var wert) && wert > 0 ? wert : voreinstellung;
builder.Services.AddRateLimiter(o =>
{
o.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
var beimAnmelden = grenze("FLARO_RATELIMIT_SIGNIN", 10);
var beimNachfragen = grenze("FLARO_RATELIMIT_POLLING", 120);
// Je Absender, nicht global: sonst sperrt ein einzelner Angreifer den
// ganzen Haushalt aus, und die Abwehr wäre selbst der Ausfall.
o.AddPolicy(FlaroRateLimits.SignIn, anfrage => RateLimitPartition.GetFixedWindowLimiter(
anfrage.Connection.RemoteIpAddress?.ToString() ?? "unbekannt",
_ => new FixedWindowRateLimiterOptions { PermitLimit = beimAnmelden, Window = TimeSpan.FromMinutes(1) }));
o.AddPolicy(FlaroRateLimits.Polling, anfrage => RateLimitPartition.GetFixedWindowLimiter(
anfrage.Connection.RemoteIpAddress?.ToString() ?? "unbekannt",
_ => new FixedWindowRateLimiterOptions { PermitLimit = beimNachfragen, Window = TimeSpan.FromMinutes(1) }));
});
// ffmpeg als eigener Prozess, nicht eingebunden: hält die Lizenzen getrennt
// und lässt einen Absturz dort den Server nicht mitnehmen.
builder.Services.AddSingleton(FfmpegOptions.FromEnvironment());
builder.Services.AddSingleton<IMediaProbe, FfprobeRunner>();
builder.Services.AddSingleton<FfmpegRemuxer>();
// Beiwerk im Medienordner — standardmässig aus. In fremde Medienordner zu
// schreiben ist nichts, was ein Server ungefragt tun sollte.
builder.Services.AddSingleton(SidecarOptions.FromEnvironment());
builder.Services.AddSingleton<ISidecarStore, SidecarWriter>();
builder.Services.AddScoped<LibraryScanner>();
// Metadaten. Der Anbieter steckt hinter einer Schnittstelle, damit alles, was
// ihn benutzt, ohne Netz und ohne Schlüssel geprüft werden kann.
builder.Services.AddScoped<ITmdbSettingsSource, DatabaseTmdbSettings>();
builder.Services.AddHttpClient<TmdbClient>(c =>
{
c.BaseAddress = new Uri(TmdbClient.BaseAddress);
// Ein hängender fremder Dienst darf einen Scan nicht festsetzen.
c.Timeout = TimeSpan.FromSeconds(20);
});
builder.Services.AddScoped<IMetadataProvider>(sp => sp.GetRequiredService<TmdbClient>());
builder.Services.AddSingleton(new ImageCacheOptions(pfade.Cache));
builder.Services.AddHttpClient<ImageCache>(c => c.Timeout = TimeSpan.FromSeconds(30));
builder.Services.AddScoped<IImageStore>(sp => sp.GetRequiredService<ImageCache>());
builder.Services.AddScoped<IImageReader>(sp => sp.GetRequiredService<ImageCache>());
builder.Services.AddScoped<MetadataEnricher>();
builder.Services.AddScoped<MatchApplier>();
builder.Services.AddSingleton<ScanCoordinator>();
builder.Services.AddSingleton<PackageCoordinator>();
builder.Services.AddProblemDetails();
var app = builder.Build();
// Beim Start wandern, nicht über ein eigenes Kommando: wer den Container
// hochfährt, soll nicht erst eine Migration von Hand anstossen müssen.
using (var bereich = app.Services.CreateScope())
{
await bereich.ServiceProvider.GetRequiredService<FlaroContext>().Database.MigrateAsync();
}
app.UseExceptionHandler();
app.UseStatusCodePages();
app.UseRateLimiter();
app.UseAuthentication();
app.UseAuthorization();
app.MapSystem();
app.MapAuth();
app.MapManagement();
app.MapLibraryContent();
app.MapSettings();
app.MapImages();
app.MapDelivery();
app.MapMatching();
app.Run();
/// <summary>Damit die Tests den Server hochfahren können.</summary>
public partial class Program;
@@ -0,0 +1,23 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "http://localhost:5153",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "https://localhost:7190;http://localhost:5153",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
+96
View File
@@ -0,0 +1,96 @@
using System.Collections.Concurrent;
using Flaro.Data;
namespace Flaro.Api;
public sealed record ScanState(
string Status,
DateTimeOffset StartedAt,
DateTimeOffset? FinishedAt,
ScanReport? Report,
EnrichReport? Enrichment,
string? Error);
/// <summary>Lässt Scans im Hintergrund laufen und weiss, wie weit sie sind.</summary>
///
/// <remarks>
/// <para>
/// <b>Nicht in der Anfrage.</b> Ein Bestand mit 40 000 Titeln braucht Minuten;
/// eine HTTP-Anfrage, die so lange offen steht, läuft hinter jedem Reverse
/// Proxy ins Zeitlimit — und der Anwender sieht einen Fehler, während der Scan
/// in Wahrheit tadellos weiterläuft.
/// </para>
/// <para>
/// <b>Einer je Bibliothek, nicht mehr.</b> Zwei gleichzeitige Läufe über
/// dasselbe Verzeichnis würden sich beim Aufräumen gegenseitig die Einträge
/// löschen: der eine schreibt seine Startzeit, der andere hält sie für alt.
/// </para>
/// <para>
/// Der Zustand liegt im Arbeitsspeicher und nicht in der Datenbank. Ein
/// Neustart bricht den Scan ohnehin ab, und ein „läuft" aus einem früheren
/// Leben wäre dann eine Lüge, die niemand mehr aufräumt.
/// </para>
/// </remarks>
public sealed class ScanCoordinator(
IServiceScopeFactory scopes,
TimeProvider clock,
ILogger<ScanCoordinator> log)
{
private readonly ConcurrentDictionary<Guid, ScanState> _zustand = new();
public ScanState? State(Guid libraryId) => _zustand.GetValueOrDefault(libraryId);
public bool IsRunning(Guid libraryId) => State(libraryId)?.Status == "running";
/// <summary>Startet einen Lauf — oder sagt, dass schon einer läuft.</summary>
public bool TryStart(Guid libraryId)
{
var start = new ScanState("running", clock.GetUtcNow(), null, null, null, null);
var vorher = _zustand.AddOrUpdate(
libraryId,
start,
(_, alt) => alt.Status == "running" ? alt : start);
// Lief schon einer, steht hier dessen Zustand und nicht unserer.
if (!ReferenceEquals(vorher, start)) return false;
_ = Task.Run(() => Laufen(libraryId));
return true;
}
private async Task Laufen(Guid libraryId)
{
try
{
using var bereich = scopes.CreateScope();
var scanner = bereich.ServiceProvider.GetRequiredService<LibraryScanner>();
var bericht = await scanner.ScanAsync(libraryId);
// Der Scan ist durch, die Dateien stehen in der Mediathek. Was
// jetzt noch kommt, kann scheitern, ohne dass davon etwas verloren
// geht — deshalb erst hier und in eigenem Zustand.
_zustand[libraryId] = _zustand[libraryId] with { Status = "enriching", Report = bericht };
var metadaten = await bereich.ServiceProvider
.GetRequiredService<MetadataEnricher>().EnrichAsync(libraryId);
_zustand[libraryId] = _zustand[libraryId] with
{
Status = "finished",
FinishedAt = clock.GetUtcNow(),
Enrichment = metadaten,
};
}
catch (Exception fehler)
{
log.LogError(fehler, "Scan der Bibliothek {Kennung} ist gescheitert.", libraryId);
_zustand[libraryId] = _zustand[libraryId] with
{
Status = "failed",
FinishedAt = clock.GetUtcNow(),
Error = fehler.Message,
};
}
}
}
@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}