Build ErsatzTV Image / decisions.md append-only (pull_request) Successful in 10s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 10s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m12s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 3m4s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m17s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 10m36s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Version every /api route to /api/v1 (251 controller routes + ~24 Location
headers + the scanner callback URL + the Startup request-log literal),
uniform across the machine API, auth, scanner and scripted-build surfaces.
Add ApiVersionRewriteMiddleware: a legacy unversioned /api/* request is
rewritten (NOT redirected) to /api/v1/* in-pipeline — method, body, auth
headers and query survive — carrying RFC 8594 Deprecation/Sunset headers,
so curl / the future MCP server / bookmarks keep working. An already-
versioned path passes through; a future /api/v2 is never forced to v1.
Standardize the route convention (leading-slash absolute route per method,
no class-[Route] — except the two Scanner/Scripted controllers whose ~all
actions share a parametrized {id} prefix), enforced by ApiRouteVersioningTests
(^/api/v\d+/ over the whole Controllers.Api surface; browser-nav
/auth/oidc/login is out of scope).
Regenerate v1.json (160 paths, all /api/v1)/endpoint-index/v1.d.ts; sweep 945
SPA request literals + the test mocks (regex + positional URL parsers). /api/v1
is additive-only after freeze; the legacy-rewrite shim sunsets in ~2 releases
(owner decision) with removal tracked as a Phase-3 follow-up.
Docs: decisions.md 2026-07-13, api-conventions §1/§9, rest-api/spa-conventions/
blazor-route-parity/e2e-local/domain-model.
fixes #286
refs #197
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
150 lines
5.5 KiB
C#
150 lines
5.5 KiB
C#
using System.Runtime.InteropServices;
|
|
using CliWrap;
|
|
using ErsatzTV.Core;
|
|
using ErsatzTV.Core.Domain;
|
|
using ErsatzTV.Core.Errors;
|
|
using ErsatzTV.Core.Interfaces.Repositories;
|
|
using ErsatzTV.FFmpeg.Runtime;
|
|
using ErsatzTV.Infrastructure.Data;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Serilog;
|
|
using Serilog.Core;
|
|
using Serilog.Events;
|
|
using Serilog.Formatting.Compact.Reader;
|
|
using Microsoft.Extensions.Logging;
|
|
using ILogger = Microsoft.Extensions.Logging.ILogger;
|
|
|
|
namespace ErsatzTV.Application.Libraries;
|
|
|
|
public abstract class CallLibraryScannerHandler<TRequest>(
|
|
IDbContextFactory<TvContext> dbContextFactory,
|
|
IConfigElementRepository configElementRepository,
|
|
IRuntimeInfo runtimeInfo,
|
|
ILogger logger)
|
|
{
|
|
protected static string GetBaseUrl(Guid scanId) => $"http://localhost:{Settings.UiPort}/api/v1/scan/{scanId}";
|
|
|
|
protected async Task<Either<BaseError, string>> PerformScan(
|
|
ScanParameters parameters,
|
|
List<string> arguments,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
try
|
|
{
|
|
using var forcefulCts = new CancellationTokenSource();
|
|
|
|
await using CancellationTokenRegistration link =
|
|
cancellationToken.Register(() => forcefulCts.CancelAfter(TimeSpan.FromSeconds(10)));
|
|
|
|
CommandResult process = await Cli.Wrap(parameters.Scanner)
|
|
.WithArguments(arguments)
|
|
.WithValidation(CommandResultValidation.None)
|
|
.WithStandardErrorPipe(PipeTarget.ToDelegate(ProcessLogOutput))
|
|
.WithStandardOutputPipe(PipeTarget.Null)
|
|
.ExecuteAsync(forcefulCts.Token, cancellationToken);
|
|
|
|
if (process.ExitCode != 0)
|
|
{
|
|
logger.LogWarning("ErsatzTV.Scanner exited with code {ExitCode}", process.ExitCode);
|
|
return BaseError.New($"ErsatzTV.Scanner exited with code {process.ExitCode}");
|
|
}
|
|
}
|
|
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
|
|
{
|
|
// do nothing
|
|
}
|
|
|
|
return parameters.LibraryName;
|
|
}
|
|
|
|
private static void ProcessLogOutput(string s)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(s))
|
|
{
|
|
try
|
|
{
|
|
// make a new log event to force using local time
|
|
// because the compact json writer used by the scanner
|
|
// writes in UTC
|
|
LogEvent logEvent = LogEventReader.ReadFromString(s);
|
|
|
|
Serilog.ILogger log = Log.Logger;
|
|
if (logEvent.Properties.TryGetValue("SourceContext", out LogEventPropertyValue property))
|
|
{
|
|
log = log.ForContext(
|
|
Constants.SourceContextPropertyName,
|
|
property.ToString().Trim('"'));
|
|
}
|
|
|
|
log.Write(
|
|
new LogEvent(
|
|
logEvent.Timestamp.ToLocalTime(),
|
|
logEvent.Level,
|
|
logEvent.Exception,
|
|
logEvent.MessageTemplate,
|
|
logEvent.Properties.Map(pair => new LogEventProperty(pair.Key, pair.Value))));
|
|
}
|
|
catch
|
|
{
|
|
Console.WriteLine(s);
|
|
}
|
|
}
|
|
}
|
|
|
|
protected abstract Task<Tuple<string, DateTimeOffset>> GetLastScan(
|
|
TvContext dbContext,
|
|
TRequest request,
|
|
CancellationToken cancellationToken);
|
|
|
|
protected abstract bool ScanIsRequired(DateTimeOffset lastScan, int libraryRefreshInterval, TRequest request);
|
|
|
|
protected async Task<Validation<BaseError, ScanParameters>> Validate(TRequest request, CancellationToken cancellationToken)
|
|
{
|
|
try
|
|
{
|
|
int libraryRefreshInterval = await configElementRepository
|
|
.GetValue<int>(ConfigElementKey.LibraryRefreshInterval, cancellationToken)
|
|
.IfNoneAsync(0);
|
|
|
|
libraryRefreshInterval = Math.Clamp(libraryRefreshInterval, 0, 999_999);
|
|
|
|
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
|
|
|
(string libraryName, DateTimeOffset lastScan) = await GetLastScan(dbContext, request, cancellationToken);
|
|
if (!ScanIsRequired(lastScan, libraryRefreshInterval, request))
|
|
{
|
|
return new ScanIsNotRequired();
|
|
}
|
|
|
|
string executable = runtimeInfo.IsOSPlatform(OSPlatform.Windows)
|
|
? "ErsatzTV.Scanner.exe"
|
|
: "ErsatzTV.Scanner";
|
|
|
|
string processFileName = Environment.ProcessPath ?? string.Empty;
|
|
string processExecutable = Path.GetFileNameWithoutExtension(processFileName);
|
|
string folderName = Path.GetDirectoryName(processFileName);
|
|
if ("dotnet".Equals(processExecutable, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
folderName = AppContext.BaseDirectory;
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(folderName))
|
|
{
|
|
string localFileName = Path.Combine(folderName, executable);
|
|
if (File.Exists(localFileName))
|
|
{
|
|
return new ScanParameters(libraryName, localFileName);
|
|
}
|
|
}
|
|
|
|
return BaseError.New("Unable to locate ErsatzTV.Scanner executable");
|
|
}
|
|
catch (Exception ex) when (ex is TaskCanceledException or OperationCanceledException)
|
|
{
|
|
return BaseError.New("Scan was canceled");
|
|
}
|
|
}
|
|
|
|
protected sealed record ScanParameters(string LibraryName, string Scanner);
|
|
}
|