using System.Collections.Concurrent; using Halo.Data; namespace Halo.Api; public sealed record ScanState( string Status, DateTimeOffset StartedAt, DateTimeOffset? FinishedAt, ScanReport? Report, EnrichReport? Enrichment, string? Error); /// Lässt Scans im Hintergrund laufen und weiss, wie weit sie sind. /// /// /// /// Nicht in der Anfrage. 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. /// /// /// Einer je Bibliothek, nicht mehr. 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. /// /// /// 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. /// /// public sealed class ScanCoordinator( IServiceScopeFactory scopes, TimeProvider clock, ILogger log) { private readonly ConcurrentDictionary _zustand = new(); public ScanState? State(Guid libraryId) => _zustand.GetValueOrDefault(libraryId); public bool IsRunning(Guid libraryId) => State(libraryId)?.Status == "running"; /// Startet einen Lauf — oder sagt, dass schon einer läuft. 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(); 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().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, }; } } }