Compare commits
43
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dbd4bb43f7 | ||
|
|
a2c056dd7a | ||
|
|
b17a70be1b | ||
|
|
1575b9b537 | ||
|
|
10ae885b47 | ||
|
|
8f90cea5cc | ||
|
|
9938d9a6cd | ||
|
|
dd273fbbb2 | ||
|
|
27d01db265 | ||
|
|
3bc8192d3b | ||
|
|
de16a7c066 | ||
|
|
be85a462a4 | ||
|
|
95d58f3b69 | ||
|
|
3654fad163 | ||
|
|
9f0bb65fb4 | ||
|
|
72d47ef052 | ||
|
|
e78aa01c05 | ||
|
|
0c6e8fab01 | ||
|
|
fe4706474e | ||
|
|
a886fd2824 | ||
|
|
489956b167 | ||
|
|
2090f7865c | ||
|
|
5c1d2f63fa | ||
|
|
e8580f4a84 | ||
|
|
ca5ac94466 | ||
|
|
d1a52eaa8b | ||
|
|
7dee5194f4 | ||
|
|
ee044ab271 | ||
|
|
6532929cf2 | ||
|
|
0f0a0d7fe5 | ||
|
|
572066157e | ||
|
|
b39c3b7bb0 | ||
|
|
0d7803079c | ||
|
|
82467f188b | ||
|
|
947c8aacd8 | ||
|
|
bd84593cd1 | ||
|
|
805ca026c4 | ||
|
|
44f4db5a6d | ||
|
|
30565145cd | ||
|
|
ce5aaa8706 | ||
|
|
0416c00f96 | ||
|
|
0f76860519 | ||
|
|
8e5075e419 |
@@ -3,7 +3,7 @@
|
||||
"isRoot": true,
|
||||
"tools": {
|
||||
"jetbrains.resharper.globaltools": {
|
||||
"version": "2025.3.0.2",
|
||||
"version": "2025.3.4.1",
|
||||
"commands": [
|
||||
"jb"
|
||||
],
|
||||
|
||||
+9
-5
@@ -106,13 +106,17 @@ ij_json_wrap_long_lines = false
|
||||
dotnet_diagnostic.ca1848.severity = none
|
||||
|
||||
# --- Static-analysis pack adoption (ersatztv#15) ---
|
||||
# Roslynator / SonarAnalyzer / Meziantou / AsyncFixer are referenced centrally
|
||||
# (Directory.Build.targets). Default every analyzer diagnostic to `suggestion` so the new
|
||||
# packs don't fail the TreatWarningsAsErrors build; high-value rules get promoted to
|
||||
# warning/error one at a time (see ersatztv#15 / docs/contributing.md). Explicit per-rule
|
||||
# severities (e.g. ca1848 above) still take precedence over this bulk default.
|
||||
# Threading analyzers and Roslynator / SonarAnalyzer / Meziantou / AsyncFixer are enabled centrally.
|
||||
# Default their diagnostics to `suggestion`; the SDK's exact per-rule suggestion baseline lives in
|
||||
# eng/analyzers/sdk-all-suggestion.globalconfig because AnalysisLevel=latest-All otherwise injects
|
||||
# exact warning severities that outrank this bulk setting. High-value rules are promoted one at a
|
||||
# time. Explicit per-rule severities (e.g. ca1848 above) take precedence over both baselines.
|
||||
dotnet_analyzer_diagnostic.severity = suggestion
|
||||
|
||||
# A collection count can never be negative. Treat comparisons that therefore collapse to a
|
||||
# constant as errors; the first promotion caught a busy/idle branch that was permanently busy.
|
||||
dotnet_diagnostic.S3981.severity = warning
|
||||
|
||||
# Blazor components: analyzers run on .razor/.cshtml @code too, and TWAE would otherwise
|
||||
# turn their default-severity findings into build errors — keep them at suggestion as well.
|
||||
[*.razor]
|
||||
|
||||
@@ -101,7 +101,40 @@ jobs:
|
||||
run: dotnet build --configuration Release --no-restore
|
||||
|
||||
- name: Test
|
||||
run: dotnet test --configuration Release --no-build --blame-hang-timeout "2m" --verbosity normal
|
||||
run: >-
|
||||
dotnet test --configuration Release --no-build --blame-hang-timeout "2m" --verbosity normal
|
||||
--collect:"XPlat Code Coverage" --settings coverlet.runsettings --results-directory ./coverage
|
||||
|
||||
# Coverage reporting (ersatztv#15 scope item 4): coverlet.collector emits a Cobertura report
|
||||
# per test project (via --collect above); ReportGenerator merges them into a human-readable
|
||||
# summary printed to the log and the job step summary. No floor is enforced yet ("decide on a
|
||||
# floor later"), so this step is purely informational — continue-on-error keeps a missing
|
||||
# report or a transient tool-install failure from ever blocking a build.
|
||||
- name: Coverage summary
|
||||
continue-on-error: true
|
||||
run: |
|
||||
set -euo pipefail
|
||||
shopt -s globstar nullglob
|
||||
reports=(coverage/**/coverage.cobertura.xml)
|
||||
if [ ${#reports[@]} -eq 0 ]; then
|
||||
echo "No coverage reports found under ./coverage -- skipping summary."
|
||||
exit 0
|
||||
fi
|
||||
echo "Found ${#reports[@]} coverage report(s)."
|
||||
# `update` is install-or-update (idempotent, unlike `install` which errors if the tool
|
||||
# is already present under set -e); pinned for reproducible summary output.
|
||||
dotnet tool update --global dotnet-reportgenerator-globaltool --version 5.5.10 >/dev/null
|
||||
export PATH="$PATH:$HOME/.dotnet/tools"
|
||||
reportgenerator \
|
||||
"-reports:coverage/**/coverage.cobertura.xml" \
|
||||
"-targetdir:coverage/report" \
|
||||
"-reporttypes:TextSummary;MarkdownSummaryGithub"
|
||||
echo "::group::Coverage summary"
|
||||
cat coverage/report/Summary.txt
|
||||
echo "::endgroup::"
|
||||
if [ -n "${GITHUB_STEP_SUMMARY:-}" ] && [ -f coverage/report/SummaryGithub.md ]; then
|
||||
cat coverage/report/SummaryGithub.md >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
migrations:
|
||||
name: EF migration integrity (SQLite + MySql)
|
||||
@@ -202,6 +235,72 @@ jobs:
|
||||
done
|
||||
echo "::endgroup::"
|
||||
|
||||
functional-e2e:
|
||||
name: Functional E2E (curl contracts)
|
||||
runs-on: ubuntu-latest
|
||||
# Advisory gate (ersatztv#299): boots the app from source and drives the manual live-E2E
|
||||
# curl flows (legacy->SPA redirects, auth/CSRF/security-stamp, library-scan status contract,
|
||||
# If-Match/412) that sessions have been re-running by hand. Deliberately NOT a `needs:` of
|
||||
# `build` and not (yet) a required check, so a functional-E2E flake can't block image builds or
|
||||
# the unit-test gate — promote it to a required check / build dependency once it's proven
|
||||
# reliable (same rollout the `migrations` job used). SQLite default provider -> no DB service.
|
||||
# Runs on PRs and on main (regression net); skipped for v* tag builds.
|
||||
if: github.event_name == 'pull_request' || github.ref == 'refs/heads/main'
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- name: Cache NuGet packages
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.nuget/packages
|
||||
key: nuget-${{ runner.os }}-${{ hashFiles('Directory.Packages.props', 'global.json') }}
|
||||
restore-keys: nuget-${{ runner.os }}-
|
||||
|
||||
- name: Restore
|
||||
run: dotnet restore
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22.x'
|
||||
cache: npm
|
||||
cache-dependency-path: web/package-lock.json
|
||||
|
||||
- name: Install SPA dependencies
|
||||
working-directory: web
|
||||
run: npm ci
|
||||
|
||||
- name: Build SPA
|
||||
working-directory: web
|
||||
run: npm run build
|
||||
|
||||
- name: Build (Release)
|
||||
run: dotnet build ErsatzTV.sln --configuration Release --no-restore
|
||||
|
||||
- name: Ensure ffmpeg is available
|
||||
run: command -v ffmpeg >/dev/null 2>&1 || (sudo apt-get update && sudo apt-get install -y ffmpeg)
|
||||
|
||||
- name: Boot instance and run functional-E2E harness
|
||||
run: |
|
||||
set -euo pipefail
|
||||
export ETV_BUILD_CONFIG=Release ETV_UI_PORT=8409
|
||||
CFG="$(mktemp -d)"
|
||||
# e2e-local.sh copies wwwroot, launches the DLL in the background (logging to a file, so
|
||||
# this command substitution returns as soon as the app is ready), and prints PID/CONFIG_DIR.
|
||||
OUT="$(scripts/e2e-local.sh "$CFG")"
|
||||
printf '%s\n' "$OUT"
|
||||
PID="$(printf '%s\n' "$OUT" | awk -F= '/^PID=/{print $2}')"
|
||||
trap 'kill "$PID" 2>/dev/null || true' EXIT
|
||||
scripts/e2e-functional.sh "http://localhost:${ETV_UI_PORT}" "$CFG"
|
||||
|
||||
build:
|
||||
name: Build & push image (amd64)
|
||||
# `small` = the dedicated small-jobs runner lane (server-management#574).
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
*.*~
|
||||
project.lock.json
|
||||
.DS_Store
|
||||
# Code-coverage output (dotnet test --results-directory ./coverage, ersatztv#15)
|
||||
/coverage/
|
||||
*.pyc
|
||||
.worktrees/
|
||||
|
||||
|
||||
+13
-2
@@ -3,6 +3,12 @@
|
||||
<InformationalVersion>develop</InformationalVersion>
|
||||
<IncludeSourceRevisionInInformationalVersion>false</IncludeSourceRevisionInInformationalVersion>
|
||||
<AllowMissingPrunePackageData>true</AllowMissingPrunePackageData>
|
||||
<!-- Analyzer posture (ersatztv#15): enable the complete SDK rule set and the
|
||||
threading analyzer in every centrally managed project. The checked-in globalconfig
|
||||
keeps the SDK baseline at suggestion; individually promoted rules become CI-blocking. -->
|
||||
<EnableNETAnalyzers>true</EnableNETAnalyzers>
|
||||
<AnalysisLevel>latest-All</AnalysisLevel>
|
||||
<EnableThreadingAnalyzers>true</EnableThreadingAnalyzers>
|
||||
<!-- NuGet audit (on by default in .NET 10) reports vulnerable transitive
|
||||
packages as NU1901-1904 warnings. Several projects set
|
||||
TreatWarningsAsErrors=true, which would otherwise fail `dotnet restore`
|
||||
@@ -10,8 +16,13 @@
|
||||
advisories to warnings (still printed in build logs); NU1904 (critical)
|
||||
stays an error so criticals still block. Track fixes separately.
|
||||
WarningsAsErrors promotes NU1904 in EVERY project (even those without
|
||||
TreatWarningsAsErrors), so "criticals block" actually holds repo-wide. -->
|
||||
TreatWarningsAsErrors), so "criticals block" actually holds repo-wide.
|
||||
S3981 is the first explicitly promoted analyzer rule (ersatztv#15). -->
|
||||
<WarningsNotAsErrors>$(WarningsNotAsErrors);NU1901;NU1902;NU1903</WarningsNotAsErrors>
|
||||
<WarningsAsErrors>$(WarningsAsErrors);NU1904</WarningsAsErrors>
|
||||
<WarningsAsErrors>$(WarningsAsErrors);NU1904;S3981</WarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EditorConfigFiles Include="$(MSBuildThisFileDirectory)eng/analyzers/sdk-all-suggestion.globalconfig" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
+8
-10
@@ -1,9 +1,7 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<EnableThreadingAnalyzers Condition="'$(EnableThreadingAnalyzers)' == ''">false</EnableThreadingAnalyzers>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Guard on CPM so the gitignored .mcp tool, which deliberately uses inline package
|
||||
versions, does not inherit a versionless analyzer PackageReference. -->
|
||||
<ItemGroup Condition="'$(ManagePackageVersionsCentrally)' == 'true'">
|
||||
<PackageReference
|
||||
Include="Microsoft.VisualStudio.Threading.Analyzers"
|
||||
Condition="'$(EnableThreadingAnalyzers)' == 'true'">
|
||||
@@ -12,11 +10,11 @@
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Curated static-analysis packs (ersatztv#15), applied to every project. Versions are
|
||||
central (Directory.Packages.props / CPM). Guarded on CPM so the gitignored .mcp tool
|
||||
(which opts out of CPM) doesn't pull versionless references. They start at `suggestion`
|
||||
severity in .editorconfig so they don't fail the TreatWarningsAsErrors build; high-value
|
||||
rules are promoted to warning/error incrementally. -->
|
||||
<!-- Curated static-analysis packs (ersatztv#15), applied to every centrally managed project.
|
||||
Versions are central (Directory.Packages.props / CPM). Guarded on CPM so the gitignored
|
||||
.mcp tool (which opts out of CPM) doesn't pull versionless references. They start at
|
||||
`suggestion` severity in .editorconfig so they don't fail the TreatWarningsAsErrors build;
|
||||
high-value rules are promoted to warning/error incrementally. -->
|
||||
<ItemGroup Condition="'$(ManagePackageVersionsCentrally)' == 'true'">
|
||||
<PackageReference Include="Roslynator.Analyzers">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
<PackageVersion Include="FluentValidation.AspNetCore" Version="11.3.1" />
|
||||
<PackageVersion Include="Flurl" Version="4.0.0" />
|
||||
<PackageVersion Include="Hardware.Info" Version="101.1.1.1" />
|
||||
<PackageVersion Include="Humanizer.Core" Version="3.0.1" />
|
||||
<PackageVersion Include="Humanizer.Core" Version="3.0.10" />
|
||||
<PackageVersion Include="Jint" Version="4.5.0" />
|
||||
<PackageVersion Include="JsonSchema.Net" Version="9.0.0" />
|
||||
<PackageVersion Include="LanguageExt.Core" Version="4.4.9" />
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
using System.Collections.Immutable;
|
||||
using System.IO.Abstractions;
|
||||
using System.Security;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Iptv;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -15,7 +18,8 @@ public partial class GetChannelGuideHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
RecyclableMemoryStreamManager recyclableMemoryStreamManager,
|
||||
IFileSystem fileSystem,
|
||||
ILocalFileSystem localFileSystem)
|
||||
ILocalFileSystem localFileSystem,
|
||||
IConfigElementRepository configElementRepository)
|
||||
: IRequestHandler<GetChannelGuide, Either<BaseError, ChannelGuide>>
|
||||
{
|
||||
public async Task<Either<BaseError, ChannelGuide>> Handle(
|
||||
@@ -23,6 +27,21 @@ public partial class GetChannelGuideHandler(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
Option<string> maybeBaseUrl =
|
||||
await configElementRepository.GetValue<string>(ConfigElementKey.IptvBaseUrl, cancellationToken);
|
||||
|
||||
(string scheme, string host, string baseUrl) = AdvertisedBaseUrl.Resolve(
|
||||
maybeBaseUrl.IfNone(string.Empty),
|
||||
request.Scheme,
|
||||
request.Host,
|
||||
request.BaseUrl);
|
||||
|
||||
// The cache fragments are pre-built XML written raw (like {AccessTokenUri}, which is already
|
||||
// emitted as &), so the substituted base must be XML-escaped. A path prefix can legally
|
||||
// contain '&' (Uri keeps it out of the query), which would otherwise emit a bare '&' and
|
||||
// malform the whole guide. Normal URLs have no special chars, so this is a no-op for them.
|
||||
string requestBase = SecurityElement.Escape($"{scheme}://{host}{baseUrl}");
|
||||
var hiddenChannelNumbers = dbContext.Channels
|
||||
.Where(c => c.ShowInEpg == false)
|
||||
.Select(c => c.Number)
|
||||
@@ -48,7 +67,7 @@ public partial class GetChannelGuideHandler(
|
||||
|
||||
// TODO: is regex faster?
|
||||
channelsFragment = channelsFragment
|
||||
.Replace("{RequestBase}", $"{request.Scheme}://{request.Host}{request.BaseUrl}")
|
||||
.Replace("{RequestBase}", requestBase)
|
||||
.Replace("{AccessTokenUri}", accessTokenUri);
|
||||
|
||||
var channelDataFragments = new Dictionary<string, string>();
|
||||
@@ -70,7 +89,7 @@ public partial class GetChannelGuideHandler(
|
||||
string channelDataFragment = await ReadAllTextShared(fileName, cancellationToken);
|
||||
|
||||
channelDataFragment = channelDataFragment
|
||||
.Replace("{RequestBase}", $"{request.Scheme}://{request.Host}{request.BaseUrl}")
|
||||
.Replace("{RequestBase}", requestBase)
|
||||
.Replace("{AccessTokenUri}", accessTokenUri);
|
||||
|
||||
channelDataFragment = EtvTagRegex().Replace(channelDataFragment, string.Empty);
|
||||
|
||||
@@ -4,19 +4,31 @@ using ErsatzTV.Core.Iptv;
|
||||
|
||||
namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public class GetChannelPlaylistHandler(IChannelRepository channelRepository)
|
||||
public class GetChannelPlaylistHandler(
|
||||
IChannelRepository channelRepository,
|
||||
IConfigElementRepository configElementRepository)
|
||||
: IRequestHandler<GetChannelPlaylist, ChannelPlaylist>
|
||||
{
|
||||
public Task<ChannelPlaylist> Handle(GetChannelPlaylist request, CancellationToken cancellationToken) =>
|
||||
channelRepository.GetAll(cancellationToken)
|
||||
.Map(channels => EnsureMode(channels, request.Mode))
|
||||
.Map(channels => new ChannelPlaylist(
|
||||
request.Scheme,
|
||||
request.Host,
|
||||
request.BaseUrl,
|
||||
channels,
|
||||
request.UserAgent,
|
||||
request.AccessToken));
|
||||
public async Task<ChannelPlaylist> Handle(GetChannelPlaylist request, CancellationToken cancellationToken)
|
||||
{
|
||||
Option<string> maybeBaseUrl =
|
||||
await configElementRepository.GetValue<string>(ConfigElementKey.IptvBaseUrl, cancellationToken);
|
||||
|
||||
(string scheme, string host, string baseUrl) = AdvertisedBaseUrl.Resolve(
|
||||
maybeBaseUrl.IfNone(string.Empty),
|
||||
request.Scheme,
|
||||
request.Host,
|
||||
request.BaseUrl);
|
||||
|
||||
List<Channel> channels = EnsureMode(await channelRepository.GetAll(cancellationToken), request.Mode);
|
||||
return new ChannelPlaylist(
|
||||
scheme,
|
||||
host,
|
||||
baseUrl,
|
||||
channels,
|
||||
request.UserAgent,
|
||||
request.AccessToken);
|
||||
}
|
||||
|
||||
private static List<Channel> EnsureMode(IEnumerable<Channel> channels, string mode)
|
||||
{
|
||||
|
||||
@@ -2,6 +2,7 @@ using ErsatzTV.Core.Api.Channels;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.Streaming;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PlayoutMapper = ErsatzTV.Application.Playouts.Mapper;
|
||||
@@ -10,7 +11,8 @@ namespace ErsatzTV.Application.Channels;
|
||||
|
||||
public class GetChannelStatesForApiHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory,
|
||||
IFFmpegSegmenterService ffmpegSegmenterService)
|
||||
IFFmpegSegmenterService ffmpegSegmenterService,
|
||||
IDirectStreamSessionTracker directStreamSessionTracker)
|
||||
: IRequestHandler<GetChannelStatesForApi, List<ChannelStateResponseModel>>
|
||||
{
|
||||
// a guide entry (program + surrounding filler) never spans anywhere near a day; the time
|
||||
@@ -141,7 +143,8 @@ public class GetChannelStatesForApiHandler(
|
||||
return new ChannelStateResponseModel(
|
||||
channel.Id,
|
||||
channel.Number,
|
||||
ffmpegSegmenterService.IsActive(channel.Number),
|
||||
ffmpegSegmenterService.IsActive(channel.Number) ||
|
||||
directStreamSessionTracker.IsActive(channel.Number),
|
||||
nowPlaying);
|
||||
})
|
||||
.ToList();
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public record UpdateIptvSettings(IptvSettingsViewModel IptvSettings) : IRequest<Either<BaseError, Unit>>;
|
||||
@@ -0,0 +1,51 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Iptv;
|
||||
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public class UpdateIptvSettingsHandler(IConfigElementRepository configElementRepository)
|
||||
: IRequestHandler<UpdateIptvSettings, Either<BaseError, Unit>>
|
||||
{
|
||||
public async Task<Either<BaseError, Unit>> Handle(
|
||||
UpdateIptvSettings request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Validation<BaseError, Unit> validation = Validate(request);
|
||||
return await validation.Apply<Unit, Unit>(_ => ApplyUpdate(request.IptvSettings, cancellationToken));
|
||||
}
|
||||
|
||||
private async Task<Unit> ApplyUpdate(IptvSettingsViewModel iptvSettings, CancellationToken cancellationToken)
|
||||
{
|
||||
string baseUrl = (iptvSettings.BaseUrl ?? string.Empty).Trim();
|
||||
|
||||
// A blank value clears the setting so the request-derived behavior is restored.
|
||||
if (string.IsNullOrWhiteSpace(baseUrl))
|
||||
{
|
||||
await configElementRepository.Delete(ConfigElementKey.IptvBaseUrl, cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
await configElementRepository.Upsert(ConfigElementKey.IptvBaseUrl, baseUrl, cancellationToken);
|
||||
}
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private static Validation<BaseError, Unit> Validate(UpdateIptvSettings request)
|
||||
{
|
||||
string baseUrl = request.IptvSettings.BaseUrl;
|
||||
|
||||
// Blank is valid (clears the override); a non-blank value must be a well-formed advertised base URL.
|
||||
if (string.IsNullOrWhiteSpace(baseUrl))
|
||||
{
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
return AdvertisedBaseUrl.TryParse(baseUrl)
|
||||
.Map(_ => Unit.Default)
|
||||
.ToValidation<BaseError>(
|
||||
"Advertised base URL must be an absolute http(s) URL with no credentials, query, or fragment");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public class IptvSettingsViewModel
|
||||
{
|
||||
public string BaseUrl { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public record GetIptvSettings : IRequest<IptvSettingsViewModel>;
|
||||
@@ -0,0 +1,19 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
|
||||
namespace ErsatzTV.Application.Configuration;
|
||||
|
||||
public class GetIptvSettingsHandler(IConfigElementRepository configElementRepository)
|
||||
: IRequestHandler<GetIptvSettings, IptvSettingsViewModel>
|
||||
{
|
||||
public async Task<IptvSettingsViewModel> Handle(GetIptvSettings request, CancellationToken cancellationToken)
|
||||
{
|
||||
Option<string> maybeBaseUrl =
|
||||
await configElementRepository.GetValue<string>(ConfigElementKey.IptvBaseUrl, cancellationToken);
|
||||
|
||||
return new IptvSettingsViewModel
|
||||
{
|
||||
BaseUrl = await maybeBaseUrl.IfNoneAsync(string.Empty)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<NoWarn>VSTHRD200,CA1873</NoWarn>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<AnalysisLevel>latest-Recommended</AnalysisLevel>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<Configurations>Debug;Release;Debug No Sync</Configurations>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -27,7 +27,7 @@ public class ReleaseMemoryHandler : IRequestHandler<ReleaseMemory>
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
bool hasActiveWorkers = _ffmpegSegmenterService.Workers.Count >= 0 || FFmpegProcess.ProcessCount > 0;
|
||||
bool hasActiveWorkers = _ffmpegSegmenterService.Workers.Count > 0 || FFmpegProcess.ProcessCount > 0;
|
||||
if (request.ForceAggressive || !hasActiveWorkers)
|
||||
{
|
||||
_logger.LogDebug("Starting aggressive garbage collection");
|
||||
|
||||
@@ -8,6 +8,10 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CliWrap" />
|
||||
<PackageReference Include="coverlet.collector">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="LanguageExt.Core" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Memory" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
using ErsatzTV.Core.Iptv;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Core.Tests.Iptv;
|
||||
|
||||
[TestFixture]
|
||||
public class AdvertisedBaseUrlTests
|
||||
{
|
||||
private const string RequestScheme = "http";
|
||||
private const string RequestHost = "ersatztv:8409";
|
||||
private const string RequestBaseUrl = "";
|
||||
|
||||
[Test]
|
||||
public void Resolve_Uses_Configured_Value_When_Set()
|
||||
{
|
||||
(string scheme, string host, string baseUrl) = AdvertisedBaseUrl.Resolve(
|
||||
"https://tv.example.com",
|
||||
RequestScheme,
|
||||
RequestHost,
|
||||
RequestBaseUrl);
|
||||
|
||||
scheme.ShouldBe("https");
|
||||
host.ShouldBe("tv.example.com");
|
||||
baseUrl.ShouldBe("");
|
||||
}
|
||||
|
||||
[TestCase(null)]
|
||||
[TestCase("")]
|
||||
[TestCase(" ")]
|
||||
public void Resolve_Falls_Back_To_Request_When_Blank(string configured)
|
||||
{
|
||||
(string scheme, string host, string baseUrl) = AdvertisedBaseUrl.Resolve(
|
||||
configured,
|
||||
RequestScheme,
|
||||
RequestHost,
|
||||
RequestBaseUrl);
|
||||
|
||||
scheme.ShouldBe(RequestScheme);
|
||||
host.ShouldBe(RequestHost);
|
||||
baseUrl.ShouldBe(RequestBaseUrl);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Resolve_Falls_Back_To_Request_When_Invalid()
|
||||
{
|
||||
(string scheme, string host, string baseUrl) = AdvertisedBaseUrl.Resolve(
|
||||
"not a url",
|
||||
RequestScheme,
|
||||
RequestHost,
|
||||
RequestBaseUrl);
|
||||
|
||||
scheme.ShouldBe(RequestScheme);
|
||||
host.ShouldBe(RequestHost);
|
||||
baseUrl.ShouldBe(RequestBaseUrl);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TryParse_Preserves_Non_Default_Port()
|
||||
{
|
||||
(string scheme, string host, string baseUrl) = AdvertisedBaseUrl.TryParse("http://192.168.1.99:8409").IfNone(("", "", ""));
|
||||
|
||||
scheme.ShouldBe("http");
|
||||
host.ShouldBe("192.168.1.99:8409");
|
||||
baseUrl.ShouldBe("");
|
||||
}
|
||||
|
||||
[TestCase("http://tv.example.com:80", "http", "tv.example.com")]
|
||||
[TestCase("https://tv.example.com:443", "https", "tv.example.com")]
|
||||
public void TryParse_Drops_Redundant_Default_Port(string configured, string expectedScheme, string expectedHost)
|
||||
{
|
||||
(string scheme, string host, string baseUrl) = AdvertisedBaseUrl.TryParse(configured).IfNone(("", "", ""));
|
||||
|
||||
scheme.ShouldBe(expectedScheme);
|
||||
host.ShouldBe(expectedHost);
|
||||
baseUrl.ShouldBe("");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TryParse_Preserves_Path_Prefix()
|
||||
{
|
||||
(string _, string _, string baseUrl) = AdvertisedBaseUrl.TryParse("https://tv.example.com/etv").IfNone(("", "", ""));
|
||||
baseUrl.ShouldBe("/etv");
|
||||
}
|
||||
|
||||
[TestCase("https://tv.example.com/", "")]
|
||||
[TestCase("https://tv.example.com/etv/", "/etv")]
|
||||
[TestCase("https://tv.example.com/etv//", "/etv")]
|
||||
public void TryParse_Normalizes_Trailing_Slash(string configured, string expectedBaseUrl)
|
||||
{
|
||||
(string _, string _, string baseUrl) = AdvertisedBaseUrl.TryParse(configured).IfNone(("", "", ""));
|
||||
baseUrl.ShouldBe(expectedBaseUrl);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TryParse_Trims_Whitespace()
|
||||
{
|
||||
Option<(string Scheme, string Host, string BaseUrl)> result =
|
||||
AdvertisedBaseUrl.TryParse(" https://tv.example.com/etv ");
|
||||
|
||||
result.IsSome.ShouldBeTrue();
|
||||
result.IfNone(("", "", "")).BaseUrl.ShouldBe("/etv");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TryParse_Handles_IPv6_Host()
|
||||
{
|
||||
(string _, string host, string _) = AdvertisedBaseUrl.TryParse("http://[2001:db8::1]:8409").IfNone(("", "", ""));
|
||||
host.ShouldBe("[2001:db8::1]:8409");
|
||||
}
|
||||
|
||||
[TestCase(null)]
|
||||
[TestCase("")]
|
||||
[TestCase(" ")]
|
||||
[TestCase("not a url")]
|
||||
[TestCase("tv.example.com")] // no scheme (relative)
|
||||
[TestCase("//tv.example.com")] // protocol-relative (not absolute)
|
||||
[TestCase("ftp://tv.example.com")] // non-http(s) scheme
|
||||
[TestCase("ws://tv.example.com")] // non-http(s) scheme
|
||||
[TestCase("http://user:pass@tv.example.com")] // credentials
|
||||
[TestCase("http://tv.example.com?foo=bar")] // query
|
||||
[TestCase("http://tv.example.com/etv?foo=bar")] // query on a path
|
||||
[TestCase("http://tv.example.com#frag")] // fragment
|
||||
[TestCase("http://")] // no host
|
||||
public void TryParse_Rejects_Invalid_Input(string configured) =>
|
||||
AdvertisedBaseUrl.TryParse(configured).IsNone.ShouldBeTrue();
|
||||
}
|
||||
@@ -4,6 +4,7 @@ using ErsatzTV.Application.Channels;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Iptv;
|
||||
using ErsatzTV.Infrastructure;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
@@ -104,6 +105,88 @@ public class ChannelGuideGoldenTests
|
||||
public Task Guide_with_base_url() =>
|
||||
Verify("guide-base-url.xml", new GetChannelGuide(Scheme, Host, BaseUrl: "/etv", AccessToken: null));
|
||||
|
||||
// When an advertised base URL is configured (issue #340), {RequestBase} must use it instead of the
|
||||
// request-derived scheme/host — proving the override reaches both fragment substitution sites.
|
||||
[Test]
|
||||
public async Task Guide_uses_advertised_base_url_when_configured()
|
||||
{
|
||||
MockFileSystem fileSystem = BuildCacheFileSystem();
|
||||
var localFileSystem = Substitute.For<ILocalFileSystem>();
|
||||
localFileSystem
|
||||
.ListFiles(FileSystemLayout.ChannelGuideCacheFolder)
|
||||
.Returns(new[]
|
||||
{
|
||||
FragmentPath(fileSystem, "channels.xml"),
|
||||
FragmentPath(fileSystem, "2.xml")
|
||||
});
|
||||
|
||||
var configElementRepository = Substitute.For<IConfigElementRepository>();
|
||||
configElementRepository
|
||||
.GetValue<string>(Arg.Any<ConfigElementKey>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<string>.Some("https://public.example.com/etv"));
|
||||
|
||||
var handler = new GetChannelGuideHandler(
|
||||
_dbContextFactory,
|
||||
new RecyclableMemoryStreamManager(),
|
||||
fileSystem,
|
||||
localFileSystem,
|
||||
configElementRepository);
|
||||
|
||||
Either<BaseError, ChannelGuide> result = await handler.Handle(
|
||||
new GetChannelGuide(Scheme, Host, BaseUrl: "", AccessToken: null),
|
||||
CancellationToken.None);
|
||||
|
||||
string xml = result.Match(
|
||||
Right: guide => guide.ToXml(),
|
||||
Left: error => throw new AssertionException($"Handler returned error: {error.Value}"));
|
||||
|
||||
// The advertised origin replaces {RequestBase} on both the channel <icon> and the programme <icon>.
|
||||
xml.ShouldContain("https://public.example.com/etv/iptv/logos/news.jpg");
|
||||
xml.ShouldContain("https://public.example.com/etv/iptv/artwork/posters/abc.jpg");
|
||||
xml.ShouldNotContain(Host);
|
||||
}
|
||||
|
||||
// A configured base URL whose path prefix contains an XML-special character ('&' is a legal URL
|
||||
// path char, so it passes AdvertisedBaseUrl validation) must be XML-escaped when substituted into
|
||||
// the guide fragments — otherwise a bare '&' malforms the whole document. (Reviewer finding, #340.)
|
||||
[Test]
|
||||
public async Task Guide_xml_escapes_advertised_base_url()
|
||||
{
|
||||
MockFileSystem fileSystem = BuildCacheFileSystem();
|
||||
var localFileSystem = Substitute.For<ILocalFileSystem>();
|
||||
localFileSystem
|
||||
.ListFiles(FileSystemLayout.ChannelGuideCacheFolder)
|
||||
.Returns(new[]
|
||||
{
|
||||
FragmentPath(fileSystem, "channels.xml"),
|
||||
FragmentPath(fileSystem, "2.xml")
|
||||
});
|
||||
|
||||
var configElementRepository = Substitute.For<IConfigElementRepository>();
|
||||
configElementRepository
|
||||
.GetValue<string>(Arg.Any<ConfigElementKey>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<string>.Some("https://tv.example.com/a&b"));
|
||||
|
||||
var handler = new GetChannelGuideHandler(
|
||||
_dbContextFactory,
|
||||
new RecyclableMemoryStreamManager(),
|
||||
fileSystem,
|
||||
localFileSystem,
|
||||
configElementRepository);
|
||||
|
||||
Either<BaseError, ChannelGuide> result = await handler.Handle(
|
||||
new GetChannelGuide(Scheme, Host, BaseUrl: "", AccessToken: null),
|
||||
CancellationToken.None);
|
||||
|
||||
string xml = result.Match(
|
||||
Right: guide => guide.ToXml(),
|
||||
Left: error => throw new AssertionException($"Handler returned error: {error.Value}"));
|
||||
|
||||
// The '&' from the base URL must be emitted as '&', never a bare '&'.
|
||||
xml.ShouldContain("https://tv.example.com/a&b/iptv/logos/news.jpg");
|
||||
xml.ShouldNotContain("a&b");
|
||||
}
|
||||
|
||||
// --- harness ---
|
||||
|
||||
private async Task Verify(string goldenName, GetChannelGuide request)
|
||||
@@ -123,11 +206,19 @@ public class ChannelGuideGoldenTests
|
||||
FragmentPath(fileSystem, "2.xml")
|
||||
});
|
||||
|
||||
// No advertised base URL configured — the {RequestBase} substitution must use the request-derived
|
||||
// scheme/host/base, keeping today's output byte-for-byte identical (issue #340).
|
||||
var configElementRepository = Substitute.For<IConfigElementRepository>();
|
||||
configElementRepository
|
||||
.GetValue<string>(Arg.Any<ConfigElementKey>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<string>.None);
|
||||
|
||||
var handler = new GetChannelGuideHandler(
|
||||
_dbContextFactory,
|
||||
new RecyclableMemoryStreamManager(),
|
||||
fileSystem,
|
||||
localFileSystem);
|
||||
localFileSystem,
|
||||
configElementRepository);
|
||||
|
||||
Either<BaseError, ChannelGuide> result = await handler.Handle(request, CancellationToken.None);
|
||||
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
using ErsatzTV.Application.Channels;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Iptv;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Core.Tests.Iptv;
|
||||
|
||||
[TestFixture]
|
||||
public class GetChannelPlaylistHandlerTests
|
||||
{
|
||||
private const string Scheme = "https";
|
||||
private const string Host = "tv.example.com";
|
||||
|
||||
[Test]
|
||||
public async Task Uses_Advertised_Base_Url_When_Configured()
|
||||
{
|
||||
string m3u = await BuildM3U(configuredBaseUrl: "https://public.example.com/etv");
|
||||
|
||||
m3u.ShouldContain("https://public.example.com/etv/iptv/channel/1.");
|
||||
m3u.ShouldContain("https://public.example.com/etv/iptv/xmltv.xml");
|
||||
m3u.ShouldNotContain(Host);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Falls_Back_To_Request_When_Unset()
|
||||
{
|
||||
string m3u = await BuildM3U(configuredBaseUrl: null);
|
||||
|
||||
m3u.ShouldContain("https://tv.example.com/iptv/channel/1.");
|
||||
m3u.ShouldContain("https://tv.example.com/iptv/xmltv.xml");
|
||||
m3u.ShouldNotContain("public.example.com");
|
||||
}
|
||||
|
||||
private static async Task<string> BuildM3U(string configuredBaseUrl)
|
||||
{
|
||||
var channelRepository = Substitute.For<IChannelRepository>();
|
||||
channelRepository.GetAll(Arg.Any<CancellationToken>()).Returns([BuildChannel()]);
|
||||
|
||||
var configElementRepository = Substitute.For<IConfigElementRepository>();
|
||||
configElementRepository
|
||||
.GetValue<string>(Arg.Any<ConfigElementKey>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Optional(configuredBaseUrl));
|
||||
|
||||
var handler = new GetChannelPlaylistHandler(channelRepository, configElementRepository);
|
||||
|
||||
ChannelPlaylist playlist = await handler.Handle(
|
||||
new GetChannelPlaylist(Scheme, Host, BaseUrl: "", Mode: "mixed", UserAgent: "VLC/3.0", AccessToken: null),
|
||||
CancellationToken.None);
|
||||
|
||||
return playlist.ToM3U();
|
||||
}
|
||||
|
||||
private static Channel BuildChannel() =>
|
||||
new(new Guid("00000000-0000-0000-0000-000000000001"))
|
||||
{
|
||||
Number = "1",
|
||||
Name = "News",
|
||||
Group = "ErsatzTV",
|
||||
IsEnabled = true,
|
||||
StreamingMode = StreamingMode.HttpLiveStreamingDirect,
|
||||
Artwork = [],
|
||||
FFmpegProfile = new FFmpegProfile
|
||||
{
|
||||
VideoFormat = FFmpegProfileVideoFormat.H264,
|
||||
AudioFormat = FFmpegProfileAudioFormat.Aac
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace ErsatzTV.Core.Api.Settings;
|
||||
|
||||
#nullable enable
|
||||
|
||||
/// <summary>
|
||||
/// IPTV output settings. <see cref="BaseUrl" /> is the optional advertised base URL applied to
|
||||
/// absolute M3U/XMLTV URLs; an empty string means "use the incoming request's scheme/host/path".
|
||||
/// </summary>
|
||||
public record IptvSettingsResponseModel(string BaseUrl);
|
||||
@@ -62,6 +62,11 @@ public class ConfigElementKey
|
||||
public static ConfigElementKey XmltvDaysToBuild => new("xmltv.days_to_build");
|
||||
public static ConfigElementKey XmltvBlockBehavior => new("xmltv.block_behavior");
|
||||
|
||||
// Optional advertised IPTV base URL (issue #340). When set, overrides the request-derived
|
||||
// scheme/host/PathBase used to build absolute M3U + XMLTV URLs; when blank/unset, request-derived
|
||||
// values are used (today's behavior). Distinct from ETV_BASE_URL, which only sets ASP.NET PathBase.
|
||||
public static ConfigElementKey IptvBaseUrl => new("iptv.base_url");
|
||||
|
||||
// Browser SPA authentication (issue #295). The single local-admin credential lives in ConfigElement
|
||||
// rows (no DB migration): a username, a PBKDF2 password hash, and a security stamp that is rotated on
|
||||
// every password change so a stamp mismatch in OnValidatePrincipal revokes all outstanding sessions.
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<NoWarn>VSTHRD200,CA1873</NoWarn>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<AnalysisLevel>latest-Recommended</AnalysisLevel>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<Nullable>disable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
namespace ErsatzTV.Core.Iptv;
|
||||
|
||||
// Central resolution/validation for the optional advertised IPTV base URL (issue #340).
|
||||
//
|
||||
// ErsatzTV builds every absolute M3U/XMLTV URL from the incoming request's scheme + host + PathBase.
|
||||
// When a downstream consumer fetches ErsatzTV via a host that other consumers can't resolve (e.g.
|
||||
// Dispatcharr fetching over Docker DNS, then Kodi receiving those internal hostnames), the emitted
|
||||
// URLs break. An operator can configure an advertised base URL to override those request-derived
|
||||
// values consistently across the M3U (guide/logo/stream) and XMLTV ({RequestBase}) surfaces.
|
||||
//
|
||||
// When the configured value is blank or invalid, resolution falls back to the request-derived values
|
||||
// so today's behavior is preserved byte-for-byte.
|
||||
public static class AdvertisedBaseUrl
|
||||
{
|
||||
// Returns the effective (scheme, host, baseUrl) to use for absolute IPTV URLs. When the configured
|
||||
// value is blank or invalid, returns the request-derived values unchanged.
|
||||
public static (string Scheme, string Host, string BaseUrl) Resolve(
|
||||
string configured,
|
||||
string requestScheme,
|
||||
string requestHost,
|
||||
string requestBaseUrl) =>
|
||||
TryParse(configured).Match(
|
||||
Some: parsed => parsed,
|
||||
None: () => (requestScheme, requestHost, requestBaseUrl));
|
||||
|
||||
// Validates + normalizes an advertised base URL. None => blank or invalid. A valid value is an
|
||||
// absolute http(s) URL with no credentials, query, or fragment; an optional port and path prefix
|
||||
// are preserved, and a trailing slash is normalized away (so a root "/" yields an empty base, and
|
||||
// "/etv/" yields "/etv" — matching the PathBase convention the URL builders concatenate).
|
||||
public static Option<(string Scheme, string Host, string BaseUrl)> TryParse(string configured)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(configured))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
if (!Uri.TryCreate(configured.Trim(), UriKind.Absolute, out Uri uri))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
if (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(uri.UserInfo))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(uri.Query) || !string.IsNullOrEmpty(uri.Fragment))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(uri.Host))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
// Uri.Authority is host[:port] (omitting a redundant default port, wrapping IPv6 in brackets)
|
||||
// and excludes any userinfo — exactly the "{host}" the URL builders expect.
|
||||
string baseUrl = uri.AbsolutePath.TrimEnd('/');
|
||||
return (uri.Scheme, uri.Authority, baseUrl);
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<AnalysisLevel>latest-Recommended</AnalysisLevel>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||
<NoWarn>VSTHRD200,CA1873</NoWarn>
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
<GenerateRuntimeConfigurationFiles>true</GenerateRuntimeConfigurationFiles>
|
||||
<NoWarn>VSTHRD200,CA1873</NoWarn>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<AnalysisLevel>latest-Recommended</AnalysisLevel>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
|
||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
<Configurations>Debug;Release;Debug No Sync</Configurations>
|
||||
<Platforms>AnyCPU</Platforms>
|
||||
<UserSecretsId>729e6271-c307-43c8-8e36-1b36c39f6de2</UserSecretsId>
|
||||
<AnalysisLevel>latest-Recommended</AnalysisLevel>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<NoWarn>VSTHRD200,CA1873</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -3,6 +3,7 @@ using ErsatzTV.Core.Api.Channels;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Filler;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.Streaming;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Tests.Support;
|
||||
using NSubstitute;
|
||||
@@ -18,12 +19,14 @@ public class GetChannelStatesForApiHandlerTests
|
||||
|
||||
private InMemoryTvContext _db = null!;
|
||||
private IFFmpegSegmenterService _segmenter = null!;
|
||||
private IDirectStreamSessionTracker _directStreams = null!;
|
||||
|
||||
[SetUp]
|
||||
public async Task SetUp()
|
||||
{
|
||||
_db = await InMemoryTvContext.CreateAsync();
|
||||
_segmenter = Substitute.For<IFFmpegSegmenterService>();
|
||||
_directStreams = Substitute.For<IDirectStreamSessionTracker>();
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
@@ -36,7 +39,7 @@ public class GetChannelStatesForApiHandlerTests
|
||||
DateTime finish = Now.AddMinutes(20);
|
||||
await SeedChannelWithMovie(start, finish);
|
||||
_segmenter.IsActive("7.1").Returns(true);
|
||||
var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter);
|
||||
var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter, _directStreams);
|
||||
|
||||
List<ChannelStateResponseModel> result =
|
||||
await handler.Handle(new GetChannelStatesForApi(Now), CancellationToken.None);
|
||||
@@ -51,11 +54,26 @@ public class GetChannelStatesForApiHandlerTests
|
||||
state.NowPlaying.FinishUtc.ShouldBe(new DateTimeOffset(finish, TimeSpan.Zero));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Return_OnAir_For_Direct_Stream_Session()
|
||||
{
|
||||
await using TvContext context = _db.CreateContext();
|
||||
context.Channels.Add(MakeChannel(8, "8"));
|
||||
await context.SaveChangesAsync();
|
||||
_directStreams.IsActive("8").Returns(true);
|
||||
var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter, _directStreams);
|
||||
|
||||
List<ChannelStateResponseModel> result =
|
||||
await handler.Handle(new GetChannelStatesForApi(Now), CancellationToken.None);
|
||||
|
||||
result.ShouldHaveSingleItem().OnAir.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Match_Item_When_Now_Equals_Start()
|
||||
{
|
||||
await SeedChannelWithMovie(Now, Now.AddMinutes(30));
|
||||
var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter);
|
||||
var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter, _directStreams);
|
||||
|
||||
List<ChannelStateResponseModel> result =
|
||||
await handler.Handle(new GetChannelStatesForApi(Now), CancellationToken.None);
|
||||
@@ -67,7 +85,7 @@ public class GetChannelStatesForApiHandlerTests
|
||||
public async Task Handle_Should_Not_Match_Item_When_Now_Equals_Finish()
|
||||
{
|
||||
await SeedChannelWithMovie(Now.AddMinutes(-30), Now);
|
||||
var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter);
|
||||
var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter, _directStreams);
|
||||
|
||||
List<ChannelStateResponseModel> result =
|
||||
await handler.Handle(new GetChannelStatesForApi(Now), CancellationToken.None);
|
||||
@@ -81,7 +99,7 @@ public class GetChannelStatesForApiHandlerTests
|
||||
await using TvContext context = _db.CreateContext();
|
||||
context.Channels.Add(MakeChannel(8, "8"));
|
||||
await context.SaveChangesAsync();
|
||||
var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter);
|
||||
var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter, _directStreams);
|
||||
|
||||
List<ChannelStateResponseModel> result =
|
||||
await handler.Handle(new GetChannelStatesForApi(Now), CancellationToken.None);
|
||||
@@ -133,7 +151,7 @@ public class GetChannelStatesForApiHandlerTests
|
||||
MakeItem(125, movie, Now.AddMinutes(2), Now.AddMinutes(40), FillerKind.None));
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter);
|
||||
var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter, _directStreams);
|
||||
|
||||
List<ChannelStateResponseModel> result =
|
||||
await handler.Handle(new GetChannelStatesForApi(Now), CancellationToken.None);
|
||||
@@ -176,7 +194,7 @@ public class GetChannelStatesForApiHandlerTests
|
||||
context.PlayoutItems.Add(item);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter);
|
||||
var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter, _directStreams);
|
||||
|
||||
List<ChannelStateResponseModel> result =
|
||||
await handler.Handle(new GetChannelStatesForApi(Now), CancellationToken.None);
|
||||
@@ -216,7 +234,7 @@ public class GetChannelStatesForApiHandlerTests
|
||||
context.PlayoutItems.Add(item);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter);
|
||||
var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter, _directStreams);
|
||||
|
||||
ChannelStateResponseModel state =
|
||||
(await handler.Handle(new GetChannelStatesForApi(Now), CancellationToken.None))
|
||||
@@ -264,7 +282,7 @@ public class GetChannelStatesForApiHandlerTests
|
||||
context.PlayoutItems.Add(item);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter);
|
||||
var handler = new GetChannelStatesForApiHandler(_db.Factory, _segmenter, _directStreams);
|
||||
|
||||
List<ChannelStateResponseModel> result =
|
||||
await handler.Handle(new GetChannelStatesForApi(Now), CancellationToken.None);
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
using ErsatzTV.Application.Configuration;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using LanguageExt;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
using Unit = LanguageExt.Unit;
|
||||
|
||||
namespace ErsatzTV.Tests.Application.Configuration;
|
||||
|
||||
[TestFixture]
|
||||
public class IptvSettingsHandlerTests
|
||||
{
|
||||
private const string Key = "iptv.base_url";
|
||||
|
||||
private IConfigElementRepository _configElementRepository = null!;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp() => _configElementRepository = Substitute.For<IConfigElementRepository>();
|
||||
|
||||
[Test]
|
||||
public async Task Get_Returns_Empty_When_Unset()
|
||||
{
|
||||
_configElementRepository
|
||||
.GetValue<string>(Arg.Any<ConfigElementKey>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<string>.None);
|
||||
|
||||
var handler = new GetIptvSettingsHandler(_configElementRepository);
|
||||
|
||||
IptvSettingsViewModel result = await handler.Handle(new GetIptvSettings(), CancellationToken.None);
|
||||
|
||||
result.BaseUrl.ShouldBe(string.Empty);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Get_Returns_Stored_Value()
|
||||
{
|
||||
_configElementRepository
|
||||
.GetValue<string>(Arg.Any<ConfigElementKey>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<string>.Some("https://tv.example.com/etv"));
|
||||
|
||||
var handler = new GetIptvSettingsHandler(_configElementRepository);
|
||||
|
||||
IptvSettingsViewModel result = await handler.Handle(new GetIptvSettings(), CancellationToken.None);
|
||||
|
||||
result.BaseUrl.ShouldBe("https://tv.example.com/etv");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Update_Upserts_Trimmed_Value_When_Valid()
|
||||
{
|
||||
var handler = new UpdateIptvSettingsHandler(_configElementRepository);
|
||||
|
||||
Either<BaseError, Unit> result = await handler.Handle(
|
||||
new UpdateIptvSettings(new IptvSettingsViewModel { BaseUrl = " https://public.example.com/etv " }),
|
||||
CancellationToken.None);
|
||||
|
||||
result.IsRight.ShouldBeTrue();
|
||||
await _configElementRepository.Received(1).Upsert(
|
||||
Arg.Is<ConfigElementKey>(k => k.Key == Key),
|
||||
"https://public.example.com/etv",
|
||||
Arg.Any<CancellationToken>());
|
||||
await _configElementRepository.DidNotReceive().Delete(Arg.Any<ConfigElementKey>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[TestCase(null)]
|
||||
[TestCase("")]
|
||||
[TestCase(" ")]
|
||||
public async Task Update_Clears_Setting_When_Blank(string blank)
|
||||
{
|
||||
var handler = new UpdateIptvSettingsHandler(_configElementRepository);
|
||||
|
||||
Either<BaseError, Unit> result = await handler.Handle(
|
||||
new UpdateIptvSettings(new IptvSettingsViewModel { BaseUrl = blank }),
|
||||
CancellationToken.None);
|
||||
|
||||
result.IsRight.ShouldBeTrue();
|
||||
await _configElementRepository.Received(1).Delete(
|
||||
Arg.Is<ConfigElementKey>(k => k.Key == Key),
|
||||
Arg.Any<CancellationToken>());
|
||||
await _configElementRepository.DidNotReceive().Upsert(
|
||||
Arg.Any<ConfigElementKey>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[TestCase("not a url")]
|
||||
[TestCase("ftp://tv.example.com")]
|
||||
[TestCase("http://user:pass@tv.example.com")]
|
||||
[TestCase("http://tv.example.com?foo=bar")]
|
||||
public async Task Update_Returns_Error_And_Persists_Nothing_When_Invalid(string invalid)
|
||||
{
|
||||
var handler = new UpdateIptvSettingsHandler(_configElementRepository);
|
||||
|
||||
Either<BaseError, Unit> result = await handler.Handle(
|
||||
new UpdateIptvSettings(new IptvSettingsViewModel { BaseUrl = invalid }),
|
||||
CancellationToken.None);
|
||||
|
||||
result.IsLeft.ShouldBeTrue();
|
||||
await _configElementRepository.DidNotReceive().Upsert(
|
||||
Arg.Any<ConfigElementKey>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<CancellationToken>());
|
||||
await _configElementRepository.DidNotReceive().Delete(Arg.Any<ConfigElementKey>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using ErsatzTV.Application.Maintenance;
|
||||
using ErsatzTV.Core.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.FFmpeg;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Tests.Application.Maintenance;
|
||||
|
||||
[TestFixture]
|
||||
[NonParallelizable]
|
||||
public class ReleaseMemoryHandlerTests
|
||||
{
|
||||
[Test]
|
||||
public async Task Should_Use_Aggressive_Collection_When_No_Workers_Are_Active()
|
||||
{
|
||||
FFmpegProcess.ProcessCount.ShouldBe(0);
|
||||
|
||||
IFFmpegSegmenterService segmenterService = Substitute.For<IFFmpegSegmenterService>();
|
||||
segmenterService.Workers.Returns([]);
|
||||
ILogger<ReleaseMemoryHandler> logger = Substitute.For<ILogger<ReleaseMemoryHandler>>();
|
||||
var handler = new ReleaseMemoryHandler(segmenterService, logger);
|
||||
|
||||
await handler.Handle(new ReleaseMemory(false), CancellationToken.None);
|
||||
|
||||
ShouldHaveLogged(logger, "Starting aggressive garbage collection");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Use_Regular_Collection_When_A_Worker_Is_Active()
|
||||
{
|
||||
FFmpegProcess.ProcessCount.ShouldBe(0);
|
||||
|
||||
IFFmpegSegmenterService segmenterService = Substitute.For<IFFmpegSegmenterService>();
|
||||
segmenterService.Workers.Returns([Substitute.For<IHlsSessionWorker>()]);
|
||||
ILogger<ReleaseMemoryHandler> logger = Substitute.For<ILogger<ReleaseMemoryHandler>>();
|
||||
var handler = new ReleaseMemoryHandler(segmenterService, logger);
|
||||
|
||||
await handler.Handle(new ReleaseMemory(false), CancellationToken.None);
|
||||
|
||||
ShouldHaveLogged(logger, "Starting garbage collection");
|
||||
}
|
||||
|
||||
private static void ShouldHaveLogged(ILogger<ReleaseMemoryHandler> logger, string expectedMessage) =>
|
||||
logger.ReceivedCalls()
|
||||
.Any(call => call.GetArguments().ElementAtOrDefault(2)?.ToString() == expectedMessage)
|
||||
.ShouldBeTrue();
|
||||
}
|
||||
@@ -118,6 +118,20 @@ public class ApiControllerSecurityTests
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Local_Library_Detail_Should_Require_Authentication_While_Catalog_List_Remains_Opt_Out()
|
||||
{
|
||||
MethodInfo detailAction = typeof(LocalLibrariesController).GetMethod(nameof(LocalLibrariesController.GetById))
|
||||
?? throw new AssertionException($"Missing action {nameof(LocalLibrariesController.GetById)}");
|
||||
MethodInfo listAction = typeof(LocalLibrariesController).GetMethod(nameof(LocalLibrariesController.GetAll))
|
||||
?? throw new AssertionException($"Missing action {nameof(LocalLibrariesController.GetAll)}");
|
||||
|
||||
EffectiveRequiresAuthentication(typeof(LocalLibrariesController), detailAction)
|
||||
.ShouldBeTrue("local-library detail exposes server filesystem paths and must stay authenticated");
|
||||
EffectiveRequiresAuthentication(typeof(LocalLibrariesController), listAction)
|
||||
.ShouldBeFalse("the ordinary local-library catalog should retain the read-auth opt-out");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ScannerController_Should_Be_Localhost_Only()
|
||||
{
|
||||
@@ -131,6 +145,10 @@ public class ApiControllerSecurityTests
|
||||
|
||||
private static bool IsGloballyProtected() => ApiAuthorizationFilterIsGlobal;
|
||||
|
||||
private static bool EffectiveRequiresAuthentication(Type controllerType, MethodInfo action) =>
|
||||
controllerType.GetCustomAttributes<RequiresAuthenticationAttribute>(inherit: true).Any() ||
|
||||
action.GetCustomAttributes<RequiresAuthenticationAttribute>(inherit: true).Any();
|
||||
|
||||
private static bool IsApiAuthorizationFilterRegisteredGlobally()
|
||||
{
|
||||
var settings = new Dictionary<string, string?>
|
||||
|
||||
@@ -242,6 +242,48 @@ public class SettingsControllerTests
|
||||
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetIptv_Should_Map_Vm_To_Response_Model()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetIptvSettings>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new IptvSettingsViewModel { BaseUrl = "https://tv.example.com/etv" });
|
||||
|
||||
IptvSettingsResponseModel result = await _controller.GetIptv(CancellationToken.None);
|
||||
|
||||
result.ShouldBe(new IptvSettingsResponseModel("https://tv.example.com/etv"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task UpdateIptv_Should_Map_Request_To_Command_And_Return_Refreshed_Settings()
|
||||
{
|
||||
_mediator.Send(Arg.Any<UpdateIptvSettings>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, Unit>(Unit.Default));
|
||||
_mediator.Send(Arg.Any<GetIptvSettings>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new IptvSettingsViewModel { BaseUrl = "https://public.example.com" });
|
||||
|
||||
IActionResult result = await _controller.UpdateIptv(
|
||||
new UpdateIptvSettingsRequest("https://public.example.com"),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBe(new IptvSettingsResponseModel("https://public.example.com"));
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<UpdateIptvSettings>(c => c.IptvSettings.BaseUrl == "https://public.example.com"),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task UpdateIptv_Should_Return_422_On_Validation_Error()
|
||||
{
|
||||
_mediator.Send(Arg.Any<UpdateIptvSettings>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Left<BaseError, Unit>(BaseError.New("bad")));
|
||||
|
||||
IActionResult result = await _controller.UpdateIptv(
|
||||
new UpdateIptvSettingsRequest("not a url"),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetScanner_Should_Return_Library_Refresh_Interval()
|
||||
{
|
||||
|
||||
@@ -11,6 +11,10 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="NUnit" />
|
||||
<PackageReference Include="NUnit.Analyzers">
|
||||
|
||||
@@ -7,6 +7,7 @@ using ErsatzTV.Core.Api.MediaSources;
|
||||
using ErsatzTV.Core.Interfaces.Locking;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Extensions;
|
||||
using ErsatzTV.Filters;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -40,6 +41,7 @@ public class LocalLibrariesController(
|
||||
[HttpGet("/api/v1/libraries/local/{id:int}", Name = "GetLocalLibrary")]
|
||||
[Tags("Libraries")]
|
||||
[EndpointSummary("Get a local library by id")]
|
||||
[RequiresAuthentication]
|
||||
[ProducesResponseType(typeof(LocalLibraryDetailResponseModel), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetById(int id, CancellationToken cancellationToken)
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
using ErsatzTV.Application.Configuration;
|
||||
|
||||
namespace ErsatzTV.Controllers.Api.Requests;
|
||||
|
||||
/// <summary>
|
||||
/// IPTV output settings. <see cref="BaseUrl" /> is the optional advertised base URL applied to
|
||||
/// absolute M3U/XMLTV URLs; send an empty string to clear it and use the incoming request's origin.
|
||||
/// </summary>
|
||||
public record UpdateIptvSettingsRequest(string BaseUrl)
|
||||
{
|
||||
public UpdateIptvSettings ToCommand() =>
|
||||
new(new IptvSettingsViewModel { BaseUrl = BaseUrl });
|
||||
}
|
||||
@@ -121,6 +121,39 @@ public class SettingsController(IMediator mediator) : ControllerBase
|
||||
});
|
||||
}
|
||||
|
||||
// IPTV settings
|
||||
|
||||
[HttpGet("/api/v1/settings/iptv", Name = "GetIptvSettings")]
|
||||
[Tags("Settings")]
|
||||
[EndpointSummary("Get IPTV output settings")]
|
||||
[ProducesResponseType(typeof(IptvSettingsResponseModel), StatusCodes.Status200OK)]
|
||||
public async Task<IptvSettingsResponseModel> GetIptv(CancellationToken cancellationToken)
|
||||
{
|
||||
IptvSettingsViewModel settings = await mediator.Send(new GetIptvSettings(), cancellationToken);
|
||||
return ProjectToResponseModel(settings);
|
||||
}
|
||||
|
||||
[HttpPut("/api/v1/settings/iptv", Name = "UpdateIptvSettings")]
|
||||
[Tags("Settings")]
|
||||
[EndpointSummary("Update IPTV output settings")]
|
||||
[ProducesResponseType(typeof(IptvSettingsResponseModel), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status401Unauthorized)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> UpdateIptv(
|
||||
[Required] [FromBody]
|
||||
UpdateIptvSettingsRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Either<BaseError, Unit> result = await mediator.Send(request.ToCommand(), cancellationToken);
|
||||
return await result.Match(
|
||||
Left: error => Task.FromResult(error.ToErrorResult()),
|
||||
Right: async _ =>
|
||||
{
|
||||
IptvSettingsViewModel settings = await mediator.Send(new GetIptvSettings(), cancellationToken);
|
||||
return (IActionResult)new OkObjectResult(ProjectToResponseModel(settings));
|
||||
});
|
||||
}
|
||||
|
||||
// Scanner settings
|
||||
|
||||
[HttpGet("/api/v1/settings/scanner", Name = "GetScannerSettings")]
|
||||
@@ -274,6 +307,9 @@ public class SettingsController(IMediator mediator) : ControllerBase
|
||||
private static PlayoutSettingsResponseModel ProjectToResponseModel(PlayoutSettingsViewModel vm) =>
|
||||
new(vm.DaysToBuild, vm.SkipMissingItems, vm.ScriptedScheduleTimeoutSeconds);
|
||||
|
||||
private static IptvSettingsResponseModel ProjectToResponseModel(IptvSettingsViewModel vm) =>
|
||||
new(vm.BaseUrl);
|
||||
|
||||
private static XmltvSettingsResponseModel ProjectToResponseModel(XmltvSettingsViewModel vm) =>
|
||||
new(vm.DaysToBuild, ToApiTimeZone(vm.TimeZone), ToApiBlockBehavior(vm.BlockBehavior));
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
<Configurations>Debug;Release;Debug No Sync</Configurations>
|
||||
<Platforms>AnyCPU</Platforms>
|
||||
<UserSecretsId>bf31217d-f4ec-4520-8cc3-138059044ede</UserSecretsId>
|
||||
<AnalysisLevel>latest-Recommended</AnalysisLevel>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<RequiresAspNetWebAssets>true</RequiresAspNetWebAssets>
|
||||
<NeutralLanguage>en-US</NeutralLanguage>
|
||||
|
||||
@@ -18196,6 +18196,157 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/settings/iptv": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Settings"
|
||||
],
|
||||
"summary": "Get IPTV output settings",
|
||||
"operationId": "GetIptvSettings",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/IptvSettingsResponseModel"
|
||||
}
|
||||
},
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/IptvSettingsResponseModel"
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/IptvSettingsResponseModel"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "API key missing or invalid.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{ }
|
||||
]
|
||||
},
|
||||
"put": {
|
||||
"tags": [
|
||||
"Settings"
|
||||
],
|
||||
"summary": "Update IPTV output settings",
|
||||
"operationId": "UpdateIptvSettings",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json-patch+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UpdateIptvSettingsRequest"
|
||||
}
|
||||
},
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UpdateIptvSettingsRequest"
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UpdateIptvSettingsRequest"
|
||||
}
|
||||
},
|
||||
"application/*+json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UpdateIptvSettingsRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/IptvSettingsResponseModel"
|
||||
}
|
||||
},
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/IptvSettingsResponseModel"
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/IptvSettingsResponseModel"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Unprocessable Entity",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Request validation failed (model binding or FluentValidation).",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ValidationProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{ }
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/settings/scanner": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -25948,6 +26099,17 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"IptvSettingsResponseModel": {
|
||||
"required": [
|
||||
"baseUrl"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"baseUrl": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"LanguageCodeResponseModel": {
|
||||
"required": [
|
||||
"code",
|
||||
@@ -30988,6 +31150,20 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"UpdateIptvSettingsRequest": {
|
||||
"required": [
|
||||
"baseUrl"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"baseUrl": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"UpdateLocalLibraryPathRequest": {
|
||||
"required": [
|
||||
"id",
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Coverage collector config (ersatztv#15). Passed to dotnet test via
|
||||
"settings coverlet.runsettings" alongside collect:"XPlat Code Coverage".
|
||||
The two EF Core migration folders are ~2.59M lines of generated snapshot code
|
||||
(vs ~200k lines of authored code); instrumenting them balloons coverlet memory
|
||||
and OOM killed the shared Build and test job (exit 137). Excluding generated
|
||||
migration code bounds memory and makes the reported percentage reflect authored
|
||||
code. Keep this comment free of double hyphens (invalid in XML comments).
|
||||
-->
|
||||
<RunSettings>
|
||||
<DataCollectionRunSettings>
|
||||
<DataCollectors>
|
||||
<DataCollector friendlyName="XPlat Code Coverage">
|
||||
<Configuration>
|
||||
<ExcludeByFile>**/Migrations/*.cs</ExcludeByFile>
|
||||
<ExcludeByAttribute>GeneratedCodeAttribute,CompilerGeneratedAttribute,ExcludeFromCodeCoverageAttribute</ExcludeByAttribute>
|
||||
<Exclude>[*]*.Migrations.*</Exclude>
|
||||
<SkipAutoProps>true</SkipAutoProps>
|
||||
</Configuration>
|
||||
</DataCollector>
|
||||
</DataCollectors>
|
||||
</DataCollectionRunSettings>
|
||||
</RunSettings>
|
||||
@@ -45,6 +45,7 @@ COPY *.sln .
|
||||
# local/CI builds. Directory.Packages.props is REQUIRED here: with CPM the csproj
|
||||
# carry no versions, so restore fails without the central manifest.
|
||||
COPY Directory.Build.props Directory.Build.targets Directory.Packages.props global.json .editorconfig ./
|
||||
COPY eng/analyzers/sdk-all-suggestion.globalconfig ./eng/analyzers/
|
||||
COPY artwork/* ./artwork/
|
||||
COPY ErsatzTV/*.csproj ./ErsatzTV/
|
||||
COPY ErsatzTV.Application/*.csproj ./ErsatzTV.Application/
|
||||
|
||||
+2
-1
@@ -23,7 +23,8 @@ Read in this order at session start:
|
||||
the Blazor Server UI is removed and every legacy route now 302-redirects to its SPA equivalent
|
||||
(or falls through to the catch-all → `/app`). Read it for the full legacy→SPA route inventory.
|
||||
9. **`docs/decisions.md`** — append-only "why" log. Check here before challenging an existing
|
||||
convention.
|
||||
convention. Start from its **Index**, which links the four topic files under `docs/decisions/`
|
||||
(large same-topic clusters) and lists the remaining in-file entries.
|
||||
10. **`docs/ci-cd.md`** — build/test/release pipeline, versioning, dependency management.
|
||||
|
||||
Also present in `docs/`:
|
||||
|
||||
@@ -625,7 +625,9 @@ OpenAPI generation, so the spec can't drift). When you add an endpoint:
|
||||
- **Sensitive-read GETs** that disclose secrets/paths or trigger work must carry `[RequiresAuthentication]`
|
||||
(renamed from `[RequiresApiKey]`) so they stay gated even if an operator sets `Api:RequireKeyForReads=false`.
|
||||
A valid session satisfies this tier just as the key does. Current tier: `Troubleshoot`/`Logs`/`Settings`/
|
||||
`Maintenance`. `ApiControllerSecurityTests` asserts this reflectively.
|
||||
`Maintenance`, plus local-library detail (`GET /api/v1/libraries/local/{id}`), whose response contains
|
||||
server filesystem paths. The ordinary local-library list remains in the catalog-read opt-out tier.
|
||||
`ApiControllerSecurityTests` asserts these boundaries reflectively.
|
||||
- **Internal loopback callbacks** (the scanner's `/api/v1/scan/*`) and the **`/api/v1/auth/*` surface itself** use
|
||||
`[SkipApiAuthorization]` (renamed from `[SkipApiKeyAuthorization]`). The scanner adds `[LocalhostOnly]`; the
|
||||
auth surface must be reachable before a caller is authenticated, and its one sensitive action
|
||||
|
||||
+72
-16
@@ -21,6 +21,7 @@ Upstream's final release was **`v26.3.0`** (archived). Our line continues from t
|
||||
| `v26.4.0` | First fork release carrying application changes. Later 2026 releases continue `26.5.0`, `26.6.0`, …; a new year resets to `27.1.0`. |
|
||||
| `v26.7.0` | Blazor-removal release: ChicoryTV became the only UI. |
|
||||
| `v26.8.0` | Secured/versioned ChicoryTV SPA + REST API go-live release (#335). |
|
||||
| `v26.9.0` | Configurable advertised IPTV base URL for M3U/XMLTV (#340) + SPA shell/routing + playouts modularization (#247/#245); on-air/Plex/library-path fixes (#99/#345/#371); coverage + functional-E2E CI (#15/#299). |
|
||||
|
||||
**Before cutting a release — consolidate `docs/decisions.md`.** The log is append-only between releases
|
||||
(ersatztv#303 H9), so a release boundary is where superseded entries get pruned/merged and the Index
|
||||
@@ -95,6 +96,18 @@ long builds (observed 31 min) stalling every PR run.
|
||||
Docker build) → `dotnet build -c Release` → `dotnet test -c Release --no-build`. Gates
|
||||
the image build.
|
||||
|
||||
- **Code coverage** (ersatztv#15): `dotnet test` runs with `--collect:"XPlat Code Coverage"
|
||||
--settings coverlet.runsettings --results-directory ./coverage`, so `coverlet.collector`
|
||||
(referenced by every `*.Tests` project) emits a Cobertura report per project. A follow-up
|
||||
**Coverage summary** step merges them with ReportGenerator (`TextSummary` to the log,
|
||||
`MarkdownSummaryGithub` to the job step summary). No floor is enforced yet ("decide on a
|
||||
floor later" — #15); the step is `continue-on-error: true`, so a missing report or a
|
||||
transient tool install never blocks a build.
|
||||
- **`coverlet.runsettings` excludes generated EF migration code** (`**/Migrations/*.cs`,
|
||||
~2.59M generated lines vs ~200k authored). Instrumenting it OOM-killed the shared `test`
|
||||
job (exit 137); excluding it cuts the instrumented surface ~126× (2.5M→20k coverable
|
||||
lines in the whole-solution `Architecture.Tests` process) and makes the percentage reflect
|
||||
authored code.
|
||||
- **Shallow checkout**: `fetch-depth: 1` (ersatztv#190) — this job never runs `git
|
||||
describe`/`git log`, only `build` needs full history/tags for version computation, so
|
||||
`test` and `migrations` both check out shallow. `build`'s checkout stays `fetch-depth: 0`.
|
||||
@@ -123,6 +136,33 @@ the image build.
|
||||
cleanup; dumps container logs on failure. Catches routing / base-URL (#1) / migration
|
||||
regressions that leave the app "up" but serving broken output.
|
||||
|
||||
### `functional-e2e` job (advisory; PR + main)
|
||||
|
||||
Boots the app **from source** and drives the manual live-E2E curl flows sessions have historically
|
||||
re-run by hand, turning them into a CI regression net (ersatztv#299). It is the automatable half of
|
||||
`docs/e2e-local.md`; the two scripts it chains run identically locally and in CI:
|
||||
|
||||
1. `npm ci` + `npm run build` (SPA), `dotnet build ErsatzTV.sln -c Release`, ensure `ffmpeg` is on PATH.
|
||||
2. `ETV_BUILD_CONFIG=Release scripts/e2e-local.sh <fresh-config>` — copies `wwwroot`, launches
|
||||
`dotnet ErsatzTV.dll` in the background (logging to a file so the launch step returns once the app
|
||||
is ready), prints `PID`/`CONFIG_DIR`.
|
||||
3. `scripts/e2e-functional.sh http://localhost:8409 <config>` — asserts, all curl-only and
|
||||
deterministic (no seeded media, no browser): the **legacy→SPA redirect sweep** (+ the `/api`,
|
||||
`/artwork` never-redirect exemption), the **auth/CSRF/security-stamp** flow (setup-claim →
|
||||
read-gate 401/200 → re-claim 409 → CSRF 403 → login 401/200 → logout 403/204 → post-logout
|
||||
stamp-revocation 401), the **library-scan status contract** (404 unknown / 202 queued /
|
||||
`scan-status` 200), and the **If-Match/412** round-trip on `rerun-collections`. A `trap` kills the
|
||||
instance on step exit.
|
||||
|
||||
**Advisory, by design** (the issue's "keep it a separate job so a functional-E2E flake can't block the
|
||||
unit-test gate"): it is **not** a `needs:` of `build` and **not (yet) a required check**, so a flake
|
||||
blocks nothing. Promote it to a required check / `build` dependency once it's proven reliable — the
|
||||
same staged rollout the `migrations` job used. SQLite is the default provider, so unlike `migrations`
|
||||
it needs **no** DB service container. Runs on PRs and on `main` (regression net); skipped for `v*` tag
|
||||
builds. Out of scope for this first cut (need the scanner subprocess + seeded media, or a browser, to
|
||||
be deterministic — tracked as ersatztv#299 follow-ups): the racy 409 "already-scanning" re-trigger,
|
||||
the playout-build lock 409, and the genuinely UI-interactive Playwright flows.
|
||||
|
||||
### `docs-reminder` job (non-blocking, PR-only)
|
||||
|
||||
A lightweight nudge that enforces the CLAUDE.md "docs-update is part of done" rule for the
|
||||
@@ -260,27 +300,43 @@ running product from outside our C#/review stack.
|
||||
|
||||
## Static analysis & formatting
|
||||
|
||||
**Analyzer packs** — `Directory.Build.targets` references **Roslynator**, **SonarAnalyzer.CSharp**,
|
||||
**Meziantou.Analyzer**, and **AsyncFixer** for every project (versions central via CPM; guarded on
|
||||
`ManagePackageVersionsCentrally` so the gitignored `.mcp` tool isn't pulled in). They are introduced
|
||||
**incrementally** (ersatztv#15): `.editorconfig` sets `dotnet_analyzer_diagnostic.severity = suggestion`
|
||||
so the packs surface findings without failing the `TreatWarningsAsErrors` (TWAE) build. **Promotion is
|
||||
the enforcement** — raising a rule to `warning` makes it a CI-blocking error via the existing TWAE
|
||||
build, so no separate lint step is needed.
|
||||
**Analyzers** — `Directory.Build.props` enables the SDK analyzers at `latest-All` and turns on
|
||||
`Microsoft.VisualStudio.Threading.Analyzers` for every centrally managed project.
|
||||
`Directory.Build.targets` also references
|
||||
**Roslynator**, **SonarAnalyzer.CSharp**, **Meziantou.Analyzer**, and **AsyncFixer** repo-wide (versions
|
||||
central via CPM). All analyzer package references are guarded on `ManagePackageVersionsCentrally`, so the
|
||||
gitignored `.mcp` tool—which deliberately uses inline package versions—does not inherit versionless
|
||||
references.
|
||||
They are introduced **incrementally** (ersatztv#15). `eng/analyzers/sdk-all-suggestion.globalconfig`
|
||||
enumerates the .NET 10 SDK `All` inventory at `suggestion`; this exact-ID baseline is necessary because
|
||||
the SDK's generated `latest-All` severities outrank `.editorconfig` bulk settings. `.editorconfig` keeps
|
||||
the threading and curated-pack baselines at `suggestion`. Diagnostics remain visible to IDEs and
|
||||
`dotnet format analyzers`, but do not create a wall of failures (a direct `latest-All` trial activated
|
||||
455 existing errors in the TWAE projects).
|
||||
|
||||
**Promotion is the enforcement** — set a reviewed rule to `warning` in `.editorconfig` and append its ID
|
||||
to the central `WarningsAsErrors` list in `Directory.Build.props`. The explicit list makes the rule block
|
||||
in every project, including test projects that do not otherwise use TWAE. On a major SDK upgrade,
|
||||
regenerate the checked-in SDK baseline from `analysislevel_<major>_all.globalconfig`, preserve SDK `none`
|
||||
entries, and review newly introduced rules before accepting the snapshot.
|
||||
|
||||
Promoted rules are recorded here so the blocking subset stays intentional and reviewable:
|
||||
- **Sonar `S3981` — `warning` + `WarningsAsErrors`** (ersatztv#15): rejects collection-count comparisons that are constant
|
||||
regardless of collection size. Its first finding exposed `Workers.Count >= 0`, which permanently
|
||||
classified scheduled memory releases as busy and skipped the intended aggressive idle collection.
|
||||
|
||||
- **StyleCop.Analyzers is intentionally excluded**: its latest stable (1.1.118) crashes (`AD0001`) on
|
||||
C# `record` declarations, and its rules overlap the existing `.editorconfig`/Roslynator. Revisit via
|
||||
the record-compatible `1.2.0-beta` only if specifically wanted.
|
||||
- **Blazor `.razor` caveat**: editorconfig severity overrides don't reach analyzer diagnostics in Razor
|
||||
`@code` (source-generator limitation — `dotnet format` can't fix them either), so the currently-firing
|
||||
SonarAnalyzer rules are temporarily `NoWarn`-ed in `ErsatzTV.csproj` and burned down rule-by-rule in
|
||||
**ersatztv#25**. The same rules run at `suggestion` on `.cs`.
|
||||
- **The former Blazor `.razor` caveat is retired**: Blazor removal deleted the Razor sources and their
|
||||
temporary Sonar `NoWarn` list. The `.razor`/`.cshtml` suggestion scopes remain in `.editorconfig` only
|
||||
as a defensive default if server-rendered view code is ever reintroduced.
|
||||
|
||||
**Formatting** — the tree isn't yet `dotnet format`-clean (mixed UTF-8 BOM + whitespace inherited from
|
||||
upstream: ~1,500 BOM files + ~480 whitespace). A one-time normalization lands as its **own dedicated
|
||||
PR** (kept out of the analyzer work to stay reviewable); afterwards `dotnet format whitespace
|
||||
--verify-no-changes` (+ `style`) joins the `test` job so drift can't return. `.gitattributes` already
|
||||
pins line endings.
|
||||
**Formatting** — the inherited tree still contains legacy UTF-8 BOM/whitespace debt, so the standing
|
||||
policy is **format as you touch**, not a mass rewrite (ersatztv#311). The Husky pre-commit hook runs
|
||||
`dotnet format --verify-no-changes` for staged C# files, and the blocking `format` CI job repeats that
|
||||
check for C# files changed by the PR. Untouched legacy files remain outside the gate; `.gitattributes`
|
||||
pins line endings. A one-time full-tree normalization remains a separate, unmade decision.
|
||||
|
||||
## Migration integrity (EF Core, both providers)
|
||||
|
||||
|
||||
@@ -111,9 +111,13 @@ talks exclusively to the REST API. Every former Blazor route now 302-redirects t
|
||||
- **`TreatWarningsAsErrors=true`** in the app projects — a warning fails the build. `NoWarn` carries a
|
||||
small, documented exemption list (e.g. `VSTHRD200`, `CA1873`); NuGet-audit `NU1901-1903` are demoted
|
||||
to warnings in `Directory.Build.props` while `NU1904` (critical) blocks.
|
||||
- **Static-analysis packs** (Roslynator, SonarAnalyzer, Meziantou, AsyncFixer) run at `suggestion` and
|
||||
are promoted to `warning`/`error` rule-by-rule; promotion is the enforcement (the TWAE build). New
|
||||
rules start at suggestion — never flip a wall of rules to error at once. (ersatztv#15)
|
||||
- **Static analysis is centralized**: `Directory.Build.props` enables the SDK analyzers at
|
||||
`latest-All` plus the threading analyzer for every centrally managed project; `Directory.Build.targets`
|
||||
adds Roslynator, SonarAnalyzer, Meziantou, and AsyncFixer. Analyzer package references are CPM-guarded
|
||||
so the gitignored `.mcp` tool retains its inline-version dependency model. The SDK globalconfig and
|
||||
`.editorconfig` keep the broad baseline at `suggestion`. Promote one reviewed rule at a time by setting
|
||||
it to `warning` and adding its ID to the central `WarningsAsErrors` list; never flip a wall of rules to error at once.
|
||||
Refresh the SDK globalconfig deliberately when moving to a new .NET SDK major. (ersatztv#15)
|
||||
|
||||
## 8. Testing
|
||||
|
||||
|
||||
+86
-869
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,392 @@
|
||||
# API auth & security posture (#197, #206, #279, #283, #292, #295, #301, #319, #330)
|
||||
|
||||
Why ErsatzTV's REST/SPA surface is gated the way it is: the #197 cold-review remediation and
|
||||
its bundles, the Blazor-removal auth sign-off, the artwork stored-XSS fix, fail-closed API
|
||||
auth, browser-session auth + CSRF, the side-effecting-GET POST-ification, and the response
|
||||
security headers (CSP/Permissions-Policy/CORP). Rationale relocated from the append-only
|
||||
`docs/decisions.md` at the v26.9.0 consolidation; enforcement/mechanics cross-link to
|
||||
`docs/api-conventions.md` §5/§9 and `docs/spa-conventions.md` §5e.
|
||||
|
||||
Issue trail: #197 (Phase-0 headers/PR #279; Bundle A fail-closed/PR #292; Bundle C
|
||||
contract-freeze), #206 (Blazor-removal auth posture), #283 (artwork content-type), #295
|
||||
(PR1 server session auth + PR2 SPA cutover), #301 (side-effecting GETs), #319 (CSP), #330 (CORP).
|
||||
|
||||
## Contents
|
||||
|
||||
- [2026-07-11 — Blazor removal auth posture: no new exposure beyond phase (a); real auth deferred to #197 (#206)](#2026-07-11--blazor-removal-auth-posture-no-new-exposure-beyond-phase-a-real-auth-deferred-to-197-206)
|
||||
- [2026-07-11 — Baseline security response headers + Phase-0 API hardening (#197, PR #279)](#2026-07-11--baseline-security-response-headers--phase-0-api-hardening-197-pr-279)
|
||||
- [2026-07-12 — Artwork content-type is sniffed, never reflected (#283, S4/S9 stored XSS)](#2026-07-12--artwork-content-type-is-sniffed-never-reflected-283-s4s9-stored-xss)
|
||||
- [2026-07-12 — Fail-closed API auth + sensitive-read tier + CORS/ForwardedHeaders lockdown (#197 Bundle A, PR #292)](#2026-07-12--fail-closed-api-auth--sensitive-read-tier--corsforwardedheaders-lockdown-197-bundle-a-pr-292)
|
||||
- [2026-07-12 (#197 Bundle C — contract-freeze honesty)](#2026-07-12-197-bundle-c--contract-freeze-honesty)
|
||||
- [2026-07-12 — Browser SPA session auth: `/api` accepts session OR machine key (#295 PR1, server-only)](#2026-07-12--browser-spa-session-auth-api-accepts-session-or-machine-key-295-pr1-server-only)
|
||||
- [2026-07-12 — #295 PR2: SPA session cutover + #301 side-effecting-GET POST-ification](#2026-07-12--295-pr2-spa-session-cutover--301-side-effecting-get-post-ification)
|
||||
- [2026-07-12 — Enforcing CSP + Permissions-Policy on the host (#319, ZAP baseline)](#2026-07-12--enforcing-csp--permissions-policy-on-the-host-319-zap-baseline)
|
||||
- [2026-07-13 — Cross-origin resource policy: `same-origin` on every response (#330)](#2026-07-13--cross-origin-resource-policy-same-origin-on-every-response-330)
|
||||
|
||||
---
|
||||
|
||||
## 2026-07-11 — Blazor removal auth posture: no new exposure beyond phase (a); real auth deferred to #197 (#206)
|
||||
|
||||
Sign-off for the #91 phase (b) removal-gate item #206 ("deleting the last challenged Blazor page leaves
|
||||
only the open SPA"). The actual authorization wiring in `ErsatzTV/Startup.cs` + `ErsatzTV/Pages` was
|
||||
enumerated in code (not assumed) before clearing the gate.
|
||||
|
||||
**What is gated today**
|
||||
- **OIDC** (`OidcHelper.IsEnabled` — active only when `Authority`/`ClientId`/`ClientSecret` are configured):
|
||||
`AddAuthentication` (cookie default, `oidc` challenge) + `AddAuthorization` `DefaultPolicy =
|
||||
RequireAuthenticatedUser` + **`AddRazorPages(… AuthorizeFolder("/"))`** (Startup.cs:379-385) +
|
||||
`blazor.UseAuthentication()/UseAuthorization()` inside the Blazor `MapWhen` branch (Startup.cs:764-770).
|
||||
`AuthorizeFolder("/")` gates **Razor Pages only**, and the sole user-facing Razor Page is
|
||||
`Pages/_Host.cshtml` — the Blazor Server host (the other `.cshtml`, `Shared/_Favicons.cshtml`, is a
|
||||
cosmetic partial). **So the OIDC challenge protects exactly the Blazor UI and nothing else.**
|
||||
- **`/app` (SPA)** is served by its own `MapWhen(path=/app)` static-file branch (Startup.cs:701-714) with
|
||||
**no authentication/authorization middleware** — open since phase (a) (`/`→`/app`, PR #148).
|
||||
- **`/api/*` controllers** carry no `[Authorize]` (verified: zero attributes in `Controllers/`); the
|
||||
Razor-Pages `AuthorizeFolder`/`DefaultPolicy` never reach them. Their only optional gate is the
|
||||
per-endpoint `ApiKeyAuthorizationFilter` (API-key on mutating JSON endpoints), independent of OIDC/Blazor.
|
||||
- **`/iptv/*`** is gated by `ConditionalIptvAuthorizeFilter` (JWT `JwtOnlyScheme`, active only when
|
||||
`JwtHelper.IsEnabled`) in its own `MapWhen` branch (Startup.cs:797-803) — independent of Blazor.
|
||||
|
||||
**Posture after Blazor removal.** Removing `Pages/_Host.cshtml`, `AddRazorPages`/`AuthorizeFolder("/")`,
|
||||
`blazor.UseAuthentication/UseAuthorization`, `MapBlazorHub`, and `MapFallbackToPage("/_Host")` deletes the
|
||||
OIDC challenge's **only attachment point** — no user-facing surface remains challenged. **No capability is
|
||||
lost:** every Blazor-served capability already has an open SPA equivalent (the #91 parity effort), and the
|
||||
SPA was already the unauthenticated path since phase (a), so removal exposes nothing a user could not already
|
||||
reach via `/app`.
|
||||
|
||||
**The one honest caveat (not a regression introduced by removal):** an OIDC-configured operator's *Blazor*
|
||||
admin UI sits behind a login today; after removal there is no login-gated admin UI at all (the SPA admin UI
|
||||
is open). That exposure delta already happened at **phase (a)** (the open SPA became the default admin
|
||||
surface); removal only deletes the now-redundant challenged duplicate. Designing real SPA/API authentication
|
||||
is deliberately deferred to **#197** (cold API security review — a HARD GATE before any remote exposure).
|
||||
|
||||
**Removal-PR must-not-break (independent gates that survive):** `ConditionalIptvAuthorizeFilter` (`/iptv/*`
|
||||
JWT), `ApiKeyAuthorizationFilter` (mutating `/api/*`), and `JwtHelper` access_token query support. **Leave
|
||||
the OIDC service registrations in place** (conditional on config, inert once no Razor Page consumes them) —
|
||||
ripping OIDC out is a #197 decision, not a removal-PR one. The removal PR removes only the Blazor-attached
|
||||
pieces above; `MapControllers()` + `/docs` (Scalar), currently co-hosted in the Blazor `MapWhen` branch, must
|
||||
survive the surgical reduction.
|
||||
|
||||
## 2026-07-11 — Baseline security response headers + Phase-0 API hardening (#197, PR #279)
|
||||
|
||||
Phase-0 of the #197 remediation — the posture-**independent** safe subset, shipped ahead of the
|
||||
fail-closed/CORS/versioning posture work tracked in #280–#289.
|
||||
|
||||
- **Baseline security headers on every response.** New `ErsatzTV/Middleware/SecurityHeadersMiddleware`,
|
||||
registered **first** in the pipeline (before the `/iptv` `MapWhen` branch and `UseCors`), so it covers
|
||||
`/api`, `/iptv`, `/artwork`, static, the SPA fallback, and filter-produced 4xx alike — which is why it's
|
||||
middleware, not an MVC filter. It sets `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, and
|
||||
`Referrer-Policy: strict-origin-when-cross-origin`. `nosniff` is the standing backstop for the artwork
|
||||
content-type MIME-sniffing risk (#283). **CSP and HSTS are deliberately NOT included here**: CSP must be
|
||||
validated against the ChicoryTV SPA's inline assets, and HSTS is a proxy/TLS-termination decision — both
|
||||
belong to the #197 posture design (#284/roadmap), not this baseline. Headers are set eagerly (not via
|
||||
`Response.OnStarting`); safe today because the pipeline has no `UseExceptionHandler`/`UseStatusCodePages`
|
||||
that would `Response.Clear()` — switch to `OnStarting` if one is ever added.
|
||||
- **Constant-time API-key comparison.** `ApiKeyAuthorizationFilter` compares `X-Api-Key` with
|
||||
`CryptographicOperations.FixedTimeEquals` (over UTF-8 bytes) instead of ordinal `string.Equals`, removing
|
||||
the response-timing oracle on the write key. Accept/reject behavior is otherwise identical.
|
||||
- **Playout pagination clamped.** `GET /api/playouts` and `GET /api/playouts/{id}/items` now clamp
|
||||
`Math.Clamp(pageSize, 1, 100)` + `Math.Max(0, pageNum)` before the query — applying the api-conventions §1
|
||||
clamp convention the other paged endpoints already follow (these two were passing the raw client value
|
||||
straight to EF `Take()`).
|
||||
|
||||
The larger #197 posture (fail-closed writes, sensitive-read auth tier, CORS lockdown, `/api/v1` versioning,
|
||||
the OpenAPI security scheme) is decomposed into #280–#289 with the phased roadmap on #197; those PRs will
|
||||
append their own decisions here as they land.
|
||||
|
||||
## 2026-07-12 — Artwork content-type is sniffed, never reflected (#283, S4/S9 stored XSS)
|
||||
|
||||
The artwork upload/serve path trusted client-supplied content types at both ends, giving a stored-XSS
|
||||
chain on **unauthenticated** GET sinks: upload `<script>` bytes declared `image/png` →
|
||||
`GET /iptv/logos/{hash}?contentType=text/html` served them as HTML in the ErsatzTV origin. The #279
|
||||
`nosniff` header is not a fix here — the server was *explicitly declaring* `text/html`, which the browser
|
||||
honors regardless of `nosniff`. The trust was the bug; the fix removes it at both ends.
|
||||
|
||||
- **Upload derives the content type from the bytes, never the declared value.** `UploadArtworkHandler`
|
||||
buffers the (size-bounded) upload and calls `ErsatzTV.Core/Images/ImageContentTypes.DetectContentType`,
|
||||
which uses SkiaSharp's `SKCodec` to identify the format from the image header only — pixels are **not**
|
||||
decoded, so this can't be turned into a decompression-bomb vector. A payload that isn't one of the
|
||||
accepted raster formats (png/jpeg/gif/webp) is rejected 422; the declared `Content-Type` is no longer
|
||||
read at all (the field was dropped from the `UploadArtwork` command).
|
||||
- **Serve sniffs the stored file; the `?contentType=` reflection is gone.** `GetCachedImagePath` no longer
|
||||
carries a `ContentType`, and `GetImage` (`/iptv/logos`) / `GetWatermark` (`/artwork/watermarks`) dropped
|
||||
their `[FromQuery] contentType` binding. `GetCachedImagePathHandler` always derives the MIME type from the
|
||||
file (`MimeTypes.GetMimeTypeFromFile`) and **clamps it to the image allow-list** (`ImageContentTypes.IsAccepted`),
|
||||
serving `application/octet-stream` for anything else — so a file whose bytes are not an accepted image (a
|
||||
legacy cache entry poisoned before the upload sniff landed, or a hypothetical polyglot) is a non-renderable
|
||||
download, never HTML/script. The removal is **structural** — there is no longer any request path that lets a
|
||||
client choose the served `Content-Type`. `ArtworkContentTypeModel.UrlWithContentType` now returns the bare path, and the SPA
|
||||
watermark/logo previews no longer append the query.
|
||||
- **Defense-in-depth on the persisted JSON DTOs.** The `{path, contentType}` bodies (channel logo, watermark)
|
||||
run their content type through `ArtworkContentTypeModel.Sanitized()`, which blanks anything outside the
|
||||
image allow-list before it is stored — so a stale/hostile value can't be reflected by any future code path
|
||||
even though the serve route already ignores it.
|
||||
- **S9 upload-size DoS.** Kestrel `Limits.MaxRequestBodySize` is now set from `ETV_MAXIMUM_UPLOAD_MB`, so an
|
||||
oversized body is rejected as it is read rather than only after the controller's post-binding `file.Length`
|
||||
check (kept as the friendly-error backstop). This is a global bound; the app has no other large inbound
|
||||
body (streaming is outbound GET).
|
||||
|
||||
`ImageContentTypes` is the single source of truth for the accepted image types (the allow-list previously
|
||||
duplicated in `UploadArtworkHandler`). Both serve sinks are `[ApiExplorerSettings(IgnoreApi = true)]`, so
|
||||
none of this changes the OpenAPI document.
|
||||
|
||||
## 2026-07-12 — Fail-closed API auth + sensitive-read tier + CORS/ForwardedHeaders lockdown (#197 Bundle A, PR #292)
|
||||
|
||||
Phase-1 of the #197 remediation — the auth posture that must land before any remote exposure.
|
||||
Owner decisions (confirmed this session): **single API key** (no read/write split), and
|
||||
**`Api:RequireKeyForReads` defaults `true`** (the whole `/api` surface requires the key). This does
|
||||
**not** affect Jellyfin/streaming: `/iptv/*` (playlist/guide/streams/logos) and `/artwork/*` are outside
|
||||
the filter's `/api` scope and keep their own optional access-token; only the management API the SPA talks
|
||||
to is gated.
|
||||
|
||||
- **Fail-closed writes (#280, S1).** The empty-key "open" branch is deleted; there is no open mode. New
|
||||
`IApiKeyProvider` (`ErsatzTV/Services/ApiKeyProvider.cs`, singleton, resolved once at startup) yields a
|
||||
never-empty key: `Api:WriteKey` if set, else a key persisted at `FileSystemLayout.ApiKeyPath`
|
||||
(`/config/api.key`, `0600`, path logged not value), else a generated 256-bit hex key. Every mutating
|
||||
`/api` request now requires `X-Api-Key`.
|
||||
- **Sensitive-read tier (#282, S3/S5).** Reads are gated by `Api:RequireKeyForReads` (default true) OR a
|
||||
new `[RequiresApiKey]` marker (mirror of `[SkipApiKeyAuthorization]`) applied to
|
||||
`Troubleshoot`/`Logs`/`Settings`/`Maintenance`, so that tier stays gated even if an operator opts reads
|
||||
open. `OPTIONS` preflight is exempt (CORS middleware owns it). `ApiControllerSecurityTests` asserts the
|
||||
tier reflectively.
|
||||
- **Delete dead non-`/api` mutation surfaces (#281, S2).** `SortController`
|
||||
(`POST media/collections/{id}/items`, dead Blazor SortableJS residue — the SPA uses
|
||||
`PUT /api/collections/{id}/custom-order`) and `AccountController` (`POST account/logout`, dead OIDC)
|
||||
bypassed the key because they sat outside `/api`. Removed rather than guarded.
|
||||
- **CORS opt-in (#284, S6).** `AllowAnyOrigin/Method/Header` is replaced by the `ApiCors` policy: an
|
||||
exact-origin allowlist from `Api:CorsAllowedOrigins` (semicolon list) that permits `X-Api-Key`/`If-Match`
|
||||
and exposes `ETag`; with no origins configured there is no cross-origin access (the SPA is same-origin).
|
||||
- **ForwardedHeaders trust + scanner loopback (#285, S7/S10).** `GET /api/maintenance/gc` → `POST`
|
||||
(crawler-triggerable GC; spec regenerated). `ForwardedHeaders` trust is configurable via
|
||||
`ForwardedHeaders:KnownProxies`/`KnownNetworks` — **unconfigured preserves the current trust-all
|
||||
behavior but logs a warning** (flipping the default to loopback-only would break reverse-proxy scheme/host
|
||||
detection and thus M3U/XMLTV absolute URLs — the operator must name their proxy network). `ScannerController`
|
||||
gains `[LocalhostOnly]` (the scanner always calls back over `http://localhost:{UiPort}/api/scan/...`), which
|
||||
is only spoof-resistant once ForwardedHeaders trust is restricted — the two interlock. `search/all-items`
|
||||
DoS-paging is **deferred** (it feeds the SPA "add all" flow and needs coordinated pagination; the unauth
|
||||
exposure is already closed by read-gating).
|
||||
- **SPA (`web/`).** The client sends the stored key (`ctv-api-key`) on **every** method (not just
|
||||
mutations); a new keyless **API Key** screen (`/app/api-key`) lets the user paste the generated key, and a
|
||||
shell-level banner points there on any 401. See spa-conventions §5e. **First-run/upgrade UX:** with reads
|
||||
gated by default, the SPA shows no data until the key (from `/config/api.key`) is entered — an intended
|
||||
consequence of the strict default.
|
||||
|
||||
Phase-2 (contract freeze) — the declarative OpenAPI security scheme, global 401 docs, and `/api/v1`
|
||||
versioning — remains #286/#287/#288. Phase-3 follow-ups: #265, #269, #172 remainder, `search/all-items`
|
||||
paging, per-key rate limiting.
|
||||
|
||||
## 2026-07-12 (#197 Bundle C — contract-freeze honesty)
|
||||
|
||||
**#287 — OpenAPI contract honesty by construction.** The "v1" document now emits the `ApiKey` security
|
||||
scheme plus per-operation `security`/`401` derived from the *same*
|
||||
`ApiKeyAuthorizationFilter.EndpointRequiresKey` predicate the runtime filter enforces, so declared auth
|
||||
can never drift from enforced auth. Every operation also gets a synthesized stable `operationId` (the
|
||||
framework only assigned one when `Name=` was set — ~90 were missing), and body/param-binding operations
|
||||
get the documented `400 ValidationProblemDetails` they actually return. `DayOfWeek` is now a string enum
|
||||
in the schema (added to `Startup.UseStringEnumSchemas`), removing the SPA's `WithDayNames` wart. Pinned by
|
||||
in-process document generation in tests (`OpenApiContractHonestyTests`) rather than the committed `v1.json`.
|
||||
|
||||
**#288 — Wrap the last raw ViewModels; reverse the §7a "intentional `version` leak."** Minted
|
||||
`MediaCollectionResponseModel`, `ProgramScheduleResponseModel`, and `ChannelDetailResponseModel` (all
|
||||
`#nullable enable`) and routed `CollectionController` / `ScheduleController` / `SmartCollectionController` /
|
||||
`ResolutionController.GetResolutionByName` / the channel detail GET+writes through ResponseModels, so no
|
||||
`/api/*` action returns an Application VM. This reverses the earlier §7a judgment that a ResponseModel
|
||||
"purely to hide one field was disproportionate": `Version` is now header-only (ETag) on every aggregate
|
||||
body — confirmed safe by grepping `web/src` (the SPA reads `version` from the ETag header, never the
|
||||
response body). `ChannelDetailResponseModel` is the *full editable* field set the channel editor needs
|
||||
(distinct from the lean list `ChannelResponseModel`; drops only the derived `webEncodedName`). Also flipped
|
||||
`#nullable enable` onto the remaining 24 lagging `ErsatzTV.Core/Api/` files for schema honesty, and added
|
||||
`pageNum` paging to `GET /api/search`.
|
||||
|
||||
**Channel REST resources are keyed by database `Id`, never by `Number`.** `Channel.Number` is user-mutable
|
||||
(editable on update, bulk-renumbered via `/api/channels/bulk/renumber`, transiently invalid mid-renumber),
|
||||
so the immutable int PK is the canonical key for all `/api/channels/*` single-item routes, sub-resources
|
||||
(including `playout/reset`, re-keyed from `{channelNumber}` to `{id:int}` in Bundle C), and `Location`
|
||||
headers. `Number` remains the identity on broadcast surfaces only (IPTV/M3U/XMLTV), a separate contract. A
|
||||
number-based lookup endpoint may be added additively later; `UniqueId` (Guid) stays out of the REST
|
||||
contract absent a federation requirement.
|
||||
|
||||
## 2026-07-12 — Browser SPA session auth: `/api` accepts session OR machine key (#295 PR1, server-only)
|
||||
|
||||
Implements the ratified #295 design (Fable [PLAN-MODE] pass, issue comment 9548). Supersedes the #206
|
||||
"OIDC wiring stays inert until #197" note: the retained OIDC service registration is now **revived**, and a
|
||||
cookie session becomes a first-class `/api` credential alongside the machine `X-Api-Key`. **PR1 is
|
||||
server-only and backward compatible** — the SPA keeps sending its stored key; the SPA login flow, the
|
||||
`ApiKeyScreen`→machine-key repurpose, and `spa-conventions §5e` land in **PR2**.
|
||||
|
||||
**One gate, evolved (not `[Authorize]`-per-controller).** `ApiKeyAuthorizationFilter` → `ApiAuthorizationFilter`,
|
||||
same fail-closed-by-omission logic (a forgotten `[Authorize]` fails *open* — the #280 failure mode — so the
|
||||
global filter stays the gate). It now accepts a request when a valid `X-Api-Key` matches **OR** the principal
|
||||
is an authenticated session; the "does this endpoint need auth?" decision is still the single shared
|
||||
`EndpointRequiresKey(...)` predicate (also drives OpenAPI, so the spec can't drift). Attributes renamed to
|
||||
match the widened meaning: `[RequiresApiKey]`→`[RequiresAuthentication]`, `[SkipApiKeyAuthorization]`→
|
||||
`[SkipApiAuthorization]`. `IApiKeyProvider`, the `X-Api-Key` header, and `Api:WriteKey`/`Api:RequireKeyForReads`
|
||||
are unchanged — **machine/key behavior is byte-identical** (verified: no OpenAPI drift, existing filter tests
|
||||
still green).
|
||||
|
||||
**CSRF (session only).** The machine key is CSRF-immune (a browser can't set a custom header cross-origin
|
||||
without a credentialed CORS grant we never issue). A cookie session is not: a session-authenticated **mutation**
|
||||
must carry the `X-CSRF` header (presence-only — a custom header forces a CORS preflight a cross-site page can't
|
||||
satisfy) or is rejected **403**. Reinforced by `SameSite=Lax` + CORS without `AllowCredentials` (cross-origin
|
||||
cookie auth is impossible by design). No antiforgery-token machinery.
|
||||
|
||||
**Cookie `ctv-session`.** Always registered (local login works with no IdP); OIDC handler added only when
|
||||
`OIDC:*` is configured. `HttpOnly`, `SameSite=Lax`, `SecurePolicy=SameAsRequest` (so a plain-HTTP LAN isn't
|
||||
bricked), 14-day sliding. `/api` XHR gets **401/403, not a redirect** (`OnRedirectToLogin`/`AccessDenied`).
|
||||
The `UseAuthentication`/`UseAuthorization` middleware — deleted with Blazor in #91b — is **revived in the
|
||||
`legacy` `MapWhen` branch only** (hosts `/api` + OIDC `/callback` + `/docs`; `/iptv` and `/app` untouched).
|
||||
|
||||
**Local store = `ConfigElement` rows, single admin, NO migration** (owner ruling F2):
|
||||
`AuthLocalAdminUsername`, `AuthLocalAdminPasswordHash` (ASP.NET `PasswordHasher`, PBKDF2, via
|
||||
`Microsoft.Extensions.Identity.Core`), `AuthSecurityStamp`. A password change rotates the stamp; the cookie
|
||||
`OnValidatePrincipal` (`CookieSecurityStampValidator`) compares the claim to the stored stamp and rejects a
|
||||
stale session (revocation). OIDC sessions carry an `etv:auth_method=oidc` claim and skip the stamp check
|
||||
(governed by the IdP).
|
||||
|
||||
**Fail-closed out of the box + recovery.** An unconfigured instance keeps `/api` gated (the key still works);
|
||||
first-run is a **setup-claim** (`POST /api/auth/setup`, first-claim-wins, only valid while unconfigured —
|
||||
owner ruling F1). Recovery without the browser: `Auth:LocalAdmin:Password` env seed (`LocalAdminSeedService`,
|
||||
overwrites + rotates the stamp on startup) or the machine key. Login hardening: per-IP rate limit
|
||||
(`[EnableRateLimiting("auth")]`, 10 / 5 min) on login/setup/password, dummy-hash verify on unknown/unconfigured
|
||||
user (no enumeration).
|
||||
|
||||
**Authelia = app-owned OIDC session; never trust proxy identity headers** (owner ruling F3): the container is
|
||||
LAN-reachable bypassing the proxy, so `Remote-User`/`Remote-Email` header trust is spoofable. OIDC→Authelia
|
||||
gives SSO without a double login. **`ForwardedHeaders` behaviour is kept unchanged from #285** (trust any peer
|
||||
with a warning; restrict via `KnownProxies`/`:KnownNetworks`). A stricter "ignore `X-Forwarded-*` unless a proxy
|
||||
is configured" default was implemented and then **reverted** after review (cold fork M1): the forwarded
|
||||
scheme/host feed `/iptv` M3U/XMLTV/HLS absolute-URL generation (`Request.Scheme` in `GetChannelGuideHandler`/
|
||||
`IptvController`), so ignoring them would regress stream URLs to `http`/internal-host for a proxied deployment
|
||||
that hasn't set `KnownProxies`. **Deployment coordination:** operators behind a proxy should set
|
||||
`ForwardedHeaders:KnownProxies`/`:KnownNetworks` — it gives the login rate limiter an unspoofable client IP and
|
||||
marks the session cookie `Secure` behind TLS. The residual (a direct LAN peer can spoof `X-Forwarded-For` to
|
||||
evade the per-IP login limit when unrestricted) is accepted defense-in-depth loss, mitigated by PBKDF2 +
|
||||
no-enumeration.
|
||||
|
||||
**Review hardening (fork + independent Codex pass, folded into PR1).** Codex caught concurrency defects the
|
||||
fork missed — folded in: (a) **atomic first-claim-wins** — setup writes the three credential rows in one
|
||||
transaction guarded by the unique `ConfigElement.Key` index (a lost race → `DbUpdateException` → 409), so a
|
||||
concurrent claim can't produce a mixed-state credential; (b) **consistent login snapshot** — login reads the
|
||||
hash + stamp in one query and no longer rehashes-on-verify, so a login racing a password change can't capture a
|
||||
newer stamp than the hash it verified (a concurrent change either fails the old password or leaves the issued
|
||||
cookie carrying the pre-change stamp → revoked next request); (c) **env-seed waits on
|
||||
`SystemStartup.WaitForDatabase`** (the migrator is a `BackgroundService`, so registration order alone didn't
|
||||
guarantee the schema existed) — moved to `Services/RunOnce/`. Also: **logout + password require `X-CSRF`**
|
||||
(the `[SkipApiAuthorization]` auth surface isn't covered by the filter's CSRF check → forced-logout CSRF), and
|
||||
input length caps on username/password. **Logout rotates the security stamp** when called from a local session
|
||||
(E2E-caught: `SignOutAsync` alone only clears the *client* cookie, leaving the stateless encrypted ticket
|
||||
replayable server-side) — so signing out actually ends the session server-side; for the single admin this
|
||||
revokes all local sessions ("log out everywhere"). Gated on an authenticated session so an unauthenticated
|
||||
caller can't force-revoke the admin. **Deferred with a tracked gate:** side-effecting `[RequiresAuthentication]`
|
||||
GETs (troubleshoot playback/archive) aren't CSRF-covered — **#301**, gating PR2 (latent in PR1: the SPA still
|
||||
uses the machine key). OIDC-session revocation lever (no local stamp) noted for PR3 operator docs.
|
||||
|
||||
A **fix-commit re-review** (Codex, #242 discipline) then confirmed the above resolved and caught a second round:
|
||||
(a) **HIGH — env-seed vs. setup race**: an attacker could claim admin in the startup window before
|
||||
`LocalAdminSeedService` runs, and the seed's insert would then be swallowed (attacker's credential persists,
|
||||
defeating the env recovery path). Fixed structurally: **the setup-claim endpoint is closed whenever
|
||||
`Auth:LocalAdmin:Password` is configured** — the env seed owns the credential, so there is no claim to race
|
||||
(this also strengthens the setup-claim TOFU posture: an operator on an untrusted network sets the env password
|
||||
and browser setup is disabled). (b) **LOW**: a concurrent setup race-loser now returns **409** (not 422), and
|
||||
`ClaimLocalAdmin`'s `DbUpdateException` catch re-checks existence and **rethrows genuine/transient DB errors**
|
||||
rather than masking them as "already configured". (c) **MEDIUM — accepted**: two *simultaneous* authenticated
|
||||
password changes are a non-serializable lost-update (last-write-wins; the loser's cookie may be immediately
|
||||
revoked). Accepted for a **single-admin** system: it needs two concurrent authenticated sessions both submitting
|
||||
the correct current password at the same instant, and the outcome is self-healing (re-login). Adding EF
|
||||
optimistic concurrency to the credential rows is disproportionate here.
|
||||
|
||||
**OpenAPI = `ApiKey`-only; `/api/auth/*` excluded** (owner ruling F4): the spec's audience is machine/MCP
|
||||
clients, and a browser-interactive cookie login isn't something a generated client drives, so the cookie path
|
||||
is an additional accepted credential the doc needn't express. `AuthController` is `[ApiExplorerSettings(IgnoreApi
|
||||
= true)]`. Verified: no `v1.json`/`v1.d.ts`/`endpoint-index` drift from this PR.
|
||||
|
||||
**Phasing.** PR1 = this (server only, no migration). PR2 = SPA (drop the key header for browser calls + add
|
||||
`X-CSRF`, `AuthContext` + boot gate, login/setup screens, `ApiKeyScreen`→machine-key management, E2E,
|
||||
`spa-conventions §5e`). PR3 = key rotation + operator docs (Authelia client + env reference). Rollout: PR1→PR2
|
||||
same release, then a manual Authelia round-trip checklist before the prod pin bump.
|
||||
|
||||
## 2026-07-12 — #295 PR2: SPA session cutover + #301 side-effecting-GET POST-ification
|
||||
|
||||
PR1 shipped the server side (previous entry): `/api` accepts a session cookie OR the machine `X-Api-Key`, with
|
||||
`X-CSRF` required on session-authenticated mutations. **PR2 is the SPA cutover** — the browser now authenticates
|
||||
with the session only — plus **#301** (a session-cookie CSRF hole in side-effecting GETs).
|
||||
|
||||
**Browser is cookie-only; the machine key is external/MCP-only.** `web/src/api/client.ts` no longer attaches
|
||||
`X-Api-Key`; it relies on the same-origin session cookie and sets `X-Csrf: '1'` on every mutating verb centrally.
|
||||
The former "paste your key" `ApiKeyScreen` is repurposed to **machine-key management**: it reads the server key
|
||||
from the new `GET /api/auth/machine-key` (session-gated; masked with Reveal + Copy) so an operator can hand it to
|
||||
MCP / external REST clients — the browser itself never sends it again. *Why:* one credential per audience (the
|
||||
ratified #295 model); leaving a browser key path alive would keep a CSRF-immune bypass around and defeat the
|
||||
point.
|
||||
|
||||
**Boot gate, not a route** (`web/src/AuthGate.tsx`, wrapping `<App/>` in `main.tsx`): on load it calls the public
|
||||
`GET /api/auth/config` then `GET /api/auth/session` and renders Setup (first-run local-admin claim) / Login
|
||||
(local form + an OIDC "Sign in with SSO" button when `oidcEnabled`) / the app. Login and Setup mint **no URL** —
|
||||
the gate renders them at whatever `/app/*` path was requested, so a deep link survives login for free and **no
|
||||
`blazor-route-parity.md`/`domain-model.md` route rows are added**. It publishes `AuthContext`
|
||||
(`{ username, method, signOut, requireLogin }`); the 401 signal (`notifyUnauthorized`) now drives re-login via a
|
||||
passive shell banner (never yanks a dirty draft — it consults the navigation guard first). Auth flows that expect
|
||||
a 401 inline (login, change-password) pass `suppressUnauthorizedSignal`.
|
||||
|
||||
**#301 — POST-ify, don't gate-the-GET.** A side-effecting GET is a CSRF vector once a `SameSite=Lax` cookie is a
|
||||
normal credential (it rides a cross-site top-level navigation). The three offenders became mutating verbs so the
|
||||
existing filter CSRF gate covers them with zero new machinery: `GET /api/troubleshoot/playback.m3u8` →
|
||||
**`POST /api/troubleshoot/playback/start`** returning `200 { url }` (the open `/iptv` manifest the player then
|
||||
loads — so hls.js/native-HLS needs no header injection, strictly better than X-CSRF-on-GET); the archive and
|
||||
sample GETs → **POST** (SPA downloads them via a fetch-blob helper, never `window.open`). Removing the HEAD
|
||||
variants also fixed a latent bug: a HEAD opened the `DeleteOnClose` stream and destroyed the artifact. Standing
|
||||
rule added to `api-conventions.md §9`: **never add a side-effecting GET/HEAD under `/api`.**
|
||||
|
||||
**Machine-key GET discloses the key to any authenticated session** — deliberate: the session principal is the
|
||||
single admin (local or OIDC), same-origin policy blocks a cross-site page from reading the response body, and it
|
||||
is how the "copy the key for MCP" UX works without a rotation endpoint (rotation is a later PR). **Accepted
|
||||
residual (OIDC logout):** `POST /api/auth/logout` ends the *app* cookie but not the IdP session, so an OIDC user
|
||||
who clicks "Sign out" then "Sign in with SSO" returns without re-entering credentials — a `returnUrl`/RP-initiated
|
||||
logout is a future nicety. Docs: `spa-conventions.md §5e` (SPA seams), `api-conventions.md §9`, `e2e-local.md`
|
||||
(browser setup/login flow). Refs #295 #301 #197.
|
||||
|
||||
## 2026-07-12 — Enforcing CSP + Permissions-Policy on the host (#319, ZAP baseline)
|
||||
|
||||
Completes the CSP that the #279 baseline-headers entry deferred ("CSP must be validated against the ChicoryTV
|
||||
SPA's inline assets"). Surfaced by the #314 out-of-ecosystem ZAP baseline (missing CSP/Permissions-Policy WARNs);
|
||||
a **#197 exit item**. `SecurityHeadersMiddleware` now also sets `Permissions-Policy` (deny-all for
|
||||
camera/microphone/geolocation/payment/usb) and an **enforcing** `Content-Security-Policy`.
|
||||
|
||||
- **Enforce, not report-only.** Report-only was the issue's acceptable fallback, but the SPA's asset graph is
|
||||
small and fully knowable, so we ship an enforcing policy (report-only leaves the ZAP WARN and provides no real
|
||||
protection). The policy: `default-src 'self'`; `script-src 'self' '<sha256 of the inline theme-bootstrap
|
||||
script>'` (**no** `'unsafe-inline'`/`'unsafe-eval'` — the real XSS win); `style-src 'self' 'unsafe-inline'
|
||||
https://fonts.googleapis.com`; `img-src 'self' data: blob:`; `font-src 'self' data: https://fonts.gstatic.com`;
|
||||
`connect-src 'self'`; `object-src 'none'`; `base-uri 'self'`; `frame-ancestors 'none'`; `form-action 'self'`.
|
||||
- **Why each relaxation.** The SPA is a static file, so a per-response nonce is impossible → the one inline
|
||||
theme-bootstrap `<script>` is allow-listed **by hash**; `SecurityHeadersMiddlewareTests.Csp_Script_Hash_Should_
|
||||
Match_The_Spa_Index` hashes the built `wwwroot/app/index.html` when present (else the committed `web/index.html`
|
||||
source, since the built artifact is gitignored/absent in CI — Vite copies the inline script verbatim) and fails
|
||||
if it drifts from the middleware constant. `style-src 'unsafe-inline'` covers
|
||||
React's inline `style=""` attributes (no CSS-in-JS lib to hash). The **Google Fonts** hosts are required — the
|
||||
SPA CSS `@import`s the Geist web font (caught by **live-E2E**, which the static grep missed); self-hosting the
|
||||
font to drop the Google dependency is a follow-on hardening, not this issue. `img-src data: blob:` covers
|
||||
favicon/generated-image data URIs and object-URL upload previews.
|
||||
- **Scoped: `/docs` (Scalar) and `/openapi` are excluded.** The Scalar API-reference UI relies on inline bootstrap
|
||||
scripts/styles a strict CSP would break; it keeps the baseline headers (nosniff/frame/referrer) but no CSP.
|
||||
Hardening that admin surface (self-hosted Scalar or a Scalar-tuned CSP) is a #197 follow-up. Everything else —
|
||||
SPA, `/api`, `/artwork`, `/iptv` — gets the CSP (non-HTML responses simply never exercise the script/style
|
||||
directives). Verified by live-E2E (SPA renders clean, zero CSP violations) + curl (CSP present on `/app`/`/api`,
|
||||
absent on `/docs`/`/openapi`). HSTS remains out (proxy/TLS decision). Refs #319 #314 #197.
|
||||
|
||||
## 2026-07-13 — Cross-origin resource policy: `same-origin` on every response (#330)
|
||||
|
||||
The authenticated #314 ZAP scan found that ErsatzTV's baseline response posture omitted
|
||||
`Cross-Origin-Resource-Policy`. `SecurityHeadersMiddleware` now sends
|
||||
`Cross-Origin-Resource-Policy: same-origin` on every response, including `/docs` and `/openapi`.
|
||||
Those two paths remain exempt only from the strict CSP that would break Scalar's inline bootstrap;
|
||||
CORP has no equivalent rendering conflict and belongs with the middleware's path-independent baseline
|
||||
headers.
|
||||
|
||||
`same-origin` requires the browser request and response to share the exact scheme, host, and port. It
|
||||
blocks cross-origin `no-cors` loads, so direct browser embedding of ErsatzTV artwork or media from an
|
||||
alternate origin is deliberately unsupported. It does not reject an allowed CORS-mode API fetch, so the
|
||||
explicit `Api:CorsAllowedOrigins` machine-client path continues to work. It is also not enforced by
|
||||
server-side HTTP clients, so Jellyfin's `/iptv/*` requests are unaffected; same-origin SPA artwork and
|
||||
IPTV requests remain allowed. This is defense in depth for browser embedding and does not replace CORS
|
||||
or authentication. Refs #330 #319 #314.
|
||||
@@ -0,0 +1,256 @@
|
||||
# Optimistic concurrency — ETag / If-Match / Version (#253, #259, #265, #269)
|
||||
|
||||
Why the replace-all and config-bearing aggregates carry optimistic concurrency, and how the
|
||||
contract evolved (including the reversals). The **mechanics** — headers, status codes, the
|
||||
recipe — live in `docs/api-conventions.md` §7a–§7c; this file preserves the *decision
|
||||
rationale*, relocated from the append-only `docs/decisions.md` at the v26.9.0 consolidation.
|
||||
|
||||
Issue trail: #253 (PR1 infra + PR3 Diff/Scalar fan-out), #259 (stable child identity for
|
||||
schedule-item replace), #265 (RFC 7232 If-Match semantics), #269 (non-If-Match force-write +
|
||||
cross-editor ETag rotation). Refs #197.
|
||||
|
||||
## Contents
|
||||
|
||||
- [2026-07-11 — Optimistic-concurrency contract for replace-all PUTs (#253 PR1: infra + Block reference)](#2026-07-11--optimistic-concurrency-contract-for-replace-all-puts-253-pr1-infra--block-reference)
|
||||
- [2026-07-11 — #253 PR3: Diff + Scalar concurrency fan-out (Collection / Playout×2 / MultiCollection / RerunCollection)](#2026-07-11--253-pr3-diff--scalar-concurrency-fan-out-collection--playout2--multicollection--reruncollection)
|
||||
- [2026-07-11 — Stable child identity for schedule-item replace (#259, split from #252/#253)](#2026-07-11--stable-child-identity-for-schedule-item-replace-259-split-from-252253)
|
||||
- [2026-07-12 (#269 — non-If-Match root writers force-write past a concurrent Version bump)](#2026-07-12-269--non-if-match-root-writers-force-write-past-a-concurrent-version-bump)
|
||||
- [2026-07-12 — Cross-editor ETag rotation completed for Collection/Playout config siblings (#269)](#2026-07-12--cross-editor-etag-rotation-completed-for-collectionplayout-config-siblings-269)
|
||||
- [2026-07-12 — If-Match evaluates per RFC 7232: valid-but-non-matching → 412, only grammar violations → 400 (#265)](#2026-07-12--if-match-evaluates-per-rfc-7232-valid-but-non-matching--412-only-grammar-violations--400-265)
|
||||
|
||||
---
|
||||
|
||||
## 2026-07-11 — Optimistic-concurrency contract for replace-all PUTs (#253 PR1: infra + Block reference)
|
||||
|
||||
Replace-all aggregate PUTs had **no** optimistic concurrency — a stale second tab silently overwrote a
|
||||
fresher edit (200, no signal) across ~10 aggregate surfaces. PR1 lands the shared contract on the Block
|
||||
reference aggregate; PRs 2–4 fan it out. The full ratified design + independent-review hardening is
|
||||
[#253#issuecomment-8472](http://192.168.1.95:3000/timothy/ersatztv/issues/253#issuecomment-8472);
|
||||
the mechanics live in `api-conventions.md` §7a. Decisions frozen here:
|
||||
|
||||
- **Token = uniform plain `int Version`** on each root implementing `IVersionedAggregate`, EF-mapped
|
||||
`.IsConcurrencyToken()`, one dual-provider migration (`AddAggregateVersions`, `defaultValue: 0`). **Not**
|
||||
a reused `DateUpdated` (tick-collision, SQLite TEXT precision, couples UI cosmetics to correctness) and
|
||||
**not** a MySQL-native rowversion (portability over provider-native).
|
||||
- **412 Precondition Failed**, not 409 — 409 stays the §3a EntityLocker "build in progress" guard;
|
||||
distinct codes → distinct SPA UX. New `PreconditionFailedError : BaseError` → 412 in `ApiResults.ToErrorResult`.
|
||||
- **Pre-check AND EF token both required.** The handler pre-check (a standalone `Either` introduced AFTER
|
||||
the validation pipeline — never via `Apply`, which `Join()`-flattens the subtype to 422) gives a clean
|
||||
412; the unconditional `root.Version++` + `IsConcurrencyToken` UPDATE-guard + a `SaveChangesWithConcurrencyGuard`
|
||||
backstop closes the residual load→save TOCTOU (`DbUpdateConcurrencyException` → 412).
|
||||
- **Unconditional bump** (not "only when a child changed"): EF writes the root row only when a scalar
|
||||
differs, so a no-op PUT-back must still bump to fire the token and rotate every other client's ETag.
|
||||
- **Config-only aggregate boundary**: every mutating handler of a root's *editor-visible config state*
|
||||
bumps `Version` (incl. bulk `ExecuteUpdate/Delete` writers via `.SetProperty`); regenerated build output
|
||||
(playout items/history) is outside the token — neither bumped nor guarded.
|
||||
- **Header-only ETag**, strong tag of the decimal `Version`; parsed/emitted by `ConcurrencyHeaders`. The
|
||||
successful PUT returns the new ETag (else a same-tab second save 412s against its own write).
|
||||
- **Phasing**: Phase 1 (this arc) = a missing `If-Match` force-writes (zero breakage) while the SPA starts
|
||||
echoing; Phase 2 (a later PR) flips missing → **428** after every editor echoes and one release soaks.
|
||||
`If-Match: *` stays the scripted force-write escape hatch.
|
||||
- **Child stable-identity is OUT of #253** (the "moved fill-group item inherits the wrong slot's state"
|
||||
concern on the positional reconcile) — root-anchored versioning is orthogonal to it; split to **#259**.
|
||||
- **If-Match status semantics** (non-canonical/weak/list → 400) are fail-safe; the stricter RFC 7232
|
||||
"valid-but-non-matching → 412" refinement is deferred to #197 (**#265**).
|
||||
|
||||
## 2026-07-11 — #253 PR3: Diff + Scalar concurrency fan-out (Collection / Playout×2 / MultiCollection / RerunCollection)
|
||||
|
||||
**Context.** PR3 of the #253 optimistic-concurrency arc fans the frozen Block recipe (api-conventions §7a)
|
||||
across the five Diff/Scalar aggregates. Three judgment calls beyond the mechanical copy:
|
||||
|
||||
**H1 — Playout `catch(Exception)`→422.** The two Playout replace handlers wrap `SaveChangesAsync` in a
|
||||
`catch(Exception)` that maps any exception to a bare `BaseError` (→ 422). Rather than let the guard's
|
||||
concurrency failure be reshaped into a 422, the guarded save (`SaveChangesWithConcurrencyGuard`) returns a
|
||||
`PreconditionFailedError` **Left as a value** and the handler returns it before the post-commit block —
|
||||
so it never reaches the catch. Proven by the pre-check-subtype tests (a `.Apply` flatten would fail
|
||||
`ShouldBeOfType<PreconditionFailedError>`) plus a non-vacuous Playout racing-save test.
|
||||
|
||||
**M2 — the `SaveChangesAsync() > 0` gates.** RerunCollection and Collection-custom-order run their
|
||||
playout-refresh **unconditionally** on a successful save (the unconditional `Version++` makes the old gate
|
||||
always-true; the "nothing changed" branch is dead). MultiCollection is the exception: it saved the name
|
||||
first specifically so a name-only change wouldn't rebuild playouts, so we bump `Version` on that **first**
|
||||
save and leave the **second** (items) save's `> 0` gate intact — a name-only edit still bumps + rotates the
|
||||
ETag but does not rebuild. Enumerating every behavior the gate provided before reworking it (the #232 lesson).
|
||||
|
||||
**Sibling-writer scope (deferred).** §7a's config-only boundary says every writer of an aggregate's
|
||||
editor-visible config bumps `Version`. PR3 ships the five primary endpoints' full contract + the one
|
||||
design-named bulk writer (`UpdateDefaultDecoHandler`, safe via `.SetProperty`). It **defers** the other
|
||||
same-root non-bulk config writers (`UpdateCollectionHandler`, `RemoveItemsFromCollectionHandler`,
|
||||
`UpdatePlayoutHandler`, the `ScheduleFile` handlers) and the repository-mediated `Add*ToCollection` family.
|
||||
Rationale: the primary endpoints' own bump+guard fully cover the two-tab lost-update the issue targets;
|
||||
the deferred writers only affect cross-editor ETag *rotation*, and adding an unconditional bump to a handler
|
||||
that uses plain `SaveChangesAsync` (not the guard) converts a latent lost-update into a **new 500**
|
||||
(`DbUpdateConcurrencyException`) — doing it safely needs a uniform guard+bump+412 pass of its own, better
|
||||
done with the #197 contract work. Tracked as a follow-up issue.
|
||||
|
||||
**VMs.** `Playout.Version` surfaces via `PlayoutNameViewModel` (required arg); the three collection VMs
|
||||
(`MediaCollectionViewModel`, `MultiCollectionViewModel`, `RerunCollectionViewModel`) carry `int Version = 0`
|
||||
(defaulted — 0 for the selection-placeholder constructions, real value from the Mapper projection).
|
||||
Header-only via ETag, never echoed in a response body (the Block precedent).
|
||||
|
||||
**Post-merge addendum (PR3 review, #269).** Activating the `Version` token means EF guards *every* root
|
||||
UPDATE, so non-participating root-scalar writers that use plain `SaveChangesAsync` (playout settings /
|
||||
schedule-file / on-demand-checkpoint, collection name) would 500 on a concurrent bump. The realistic
|
||||
UPDATE writers were fixed in-PR with `ConcurrencyExtensions.SaveChangesForcingVersion` (Phase-1
|
||||
force-write on conflict: adopt the stored token, retry, never revert the concurrent bump). The deferral
|
||||
above is re-scoped to the DELETE handlers + repository `Add*` writers only (→ #269).
|
||||
|
||||
## 2026-07-11 — Stable child identity for schedule-item replace (#259, split from #252/#253)
|
||||
|
||||
`PUT /api/schedules/{id}/items` now reconciles by an optional round-tripped child id, not by array
|
||||
position, so an item's persisted fill-group/shuffle state (`PlayoutScheduleItemFillGroupIndex`, FK
|
||||
`OnDelete(Cascade)`) follows the logical item across reorders/inserts instead of being inherited by
|
||||
whatever previously held its new slot. Contract + rules in **api-conventions §7c**. Key decisions:
|
||||
|
||||
- **`ScheduleItemRequest.Id` (`int?`)**: null/absent/`0` ⇒ new item (controller normalizes `0`→null so the
|
||||
handler is two-state). Any id present ⇒ id-based reconcile; a fully id-less payload keeps the verbatim
|
||||
positional fallback (legacy; retires with the §7a Phase-2 `If-Match`→428 flip).
|
||||
- **Unknown or duplicate id ⇒ 422, nothing persisted**; the guards live in the handler **after** §7a
|
||||
`CheckVersion`, so **412 precedes 422** — a client that is both version-stale and id-stale gets the reload
|
||||
signal, not a payload-bug signal. Rationale for reject-not-insert on an unknown id: under Phase-1
|
||||
force-write a stale id is a live lost-update signal, so silently inserting-as-new would duplicate the item
|
||||
and return a different id than the client sent (the exact class §7a exists to surface). This is also the
|
||||
correct #197 posture — never honor an unrecognized identifier.
|
||||
- **Scope = schedule items only.** Blocks/templates/deco-templates/playlists stay positional: their children
|
||||
are stateless config rows (no FK'd state to misattribute; #3/#4 have no GET child id). Child ids are added
|
||||
only where a child row anchors server-side state; the contract can be retrofitted per-endpoint later
|
||||
(field stays optional) — so this is not #197 ossification pressure.
|
||||
- **TPT subtype change at a matched id** stays delete+insert (EF can't retype in place); state resets and a
|
||||
new id is returned, so the SPA must re-seed item state from the PUT response (a stale id on a second save
|
||||
now 422s).
|
||||
|
||||
## 2026-07-12 (#269 — non-If-Match root writers force-write past a concurrent Version bump)
|
||||
|
||||
**Routing the aggregate delete handlers + `UpdateProgramScheduleHandler` through `SaveChangesForcingVersion`.**
|
||||
Once #253 made each replace-all root's `Version` an `IsConcurrencyToken`, EF started guarding *every*
|
||||
UPDATE **and DELETE** of that row with `WHERE Version=@orig` — so any writer that is not part of the
|
||||
If-Match contract but still saves via plain `SaveChangesAsync` throws an unhandled
|
||||
`DbUpdateConcurrencyException`→**500** if a replace-all editor bumps the row in its narrow load→save window.
|
||||
PR3 already force-wrote the exposed *UPDATE* siblings (Playout settings/`ScheduleFile`/checkpoint,
|
||||
`UpdateCollectionHandler`); a completeness sweep for #269 found the gap was wider than reported —
|
||||
**18 writers** in total, all on plain `SaveChangesAsync`. **The correct exposure filter is "any handler
|
||||
that leaves a versioned root `Modified` or `Deleted`", NOT just `Version`-bumpers + deletes** — an early
|
||||
sweep used the narrower filter and a review of PR #302 caught what it missed (`ErasePlayoutHistory` below):
|
||||
- the **nine versioned-root delete handlers** (`DeletePlayout`/`DeleteCollection`/`DeleteMultiCollection`/
|
||||
`DeleteRerunCollection`/`DeletePlaylist`/`DeleteBlock`/`DeleteTemplate`/`DeleteDecoTemplate`/
|
||||
`DeleteProgramSchedule`) — a DELETE is now token-guarded too;
|
||||
- `UpdateProgramScheduleHandler` (bumps `Version` then saved plainly — the ProgramSchedule case PR3 only
|
||||
*suspected*);
|
||||
- the **seven item add/remove bumpers** that PR2 wired to bump their root but left on plain save —
|
||||
`AddProgramScheduleItem`/`DeleteProgramScheduleItem` and the five
|
||||
`Add{Items,Movie,Show,Season,Episode}ToPlaylist` handlers;
|
||||
- **`ErasePlayoutHistoryHandler`** — modifies Playout root **scalars** (`Seed`/`Anchor`/`OnDemandCheckpoint`)
|
||||
**without** bumping `Version`, inside an explicit transaction with no try/catch → the one the bumper-only
|
||||
filter missed; reachable via `POST /api/playouts/{id}/erase-items-and-history`.
|
||||
|
||||
All now save through `ConcurrencyExtensions.SaveChangesForcingVersion`.
|
||||
|
||||
**Two deliberate boundaries (documented, not gaps):** (1) the background build/time-shift Playout-scalar
|
||||
writers (`BuildPlayoutHandler` via `PlayoutBuilder`'s `Anchor`/`Seed`; `PlayoutTimeShifter`'s
|
||||
`OnDemandCheckpoint`) are token-guarded too but **intentionally left on plain save** — they never surface a
|
||||
request-path 500 (`BuildPlayoutHandler` catches → a build-failure `BaseError`; `PlayoutTimeShifter` runs only
|
||||
via the background worker), and force-writing would be *wrong*: a concurrent config edit that bumped
|
||||
`Version` also enqueues a rebuild, so failing the in-flight build and letting the rebuild redo it with fresh
|
||||
config is correct (force-writing would persist output built from stale config). (2)
|
||||
Item-add force-write can leave a duplicate/gap `Index` (accepted Phase-1 effect): the handler computes the
|
||||
new index from its stale child list, so if a concurrent replace-all grew the list the item lands at a
|
||||
now-colliding index (no unique constraint on `PlaylistItem.Index`/`ProgramScheduleItem.Index`) — non-
|
||||
corrupting, self-correcting on the next edit, still strictly better than the pre-#269 500; a
|
||||
reload-and-recompute-on-conflict refinement is a candidate for #197. Decision:
|
||||
**force-write, not 412** — these endpoints take no `If-Match` (an unconditional DELETE/settings-edit should
|
||||
win over a concurrent editor), matching the Phase-1 force-write posture. A delete has no ETag to rotate, so
|
||||
it needs only the force-write, not a `Version` bump. A genuine row-deletion race (two concurrent deletes)
|
||||
still surfaces as a `DbUpdateConcurrencyException` — accepted (rare, non-corrupting, the resource is already
|
||||
gone). **Still deferred to #197:** *cross-editor ETag rotation* for the non-bumping config siblings and the
|
||||
scanner-shared `Add*ToCollection` family (they don't 500 — they insert children / `ExecuteDelete`, neither
|
||||
of which is token-guarded — they just don't rotate an open editor's ETag). Non-vacuously tested by racing a
|
||||
bump *through the handler* via a pre-tracked context (`RootWriterForceVersionTests`), plus an explicit
|
||||
negative control proving the plain-save path throws.
|
||||
|
||||
## 2026-07-12 — Cross-editor ETag rotation completed for Collection/Playout config siblings (#269)
|
||||
|
||||
The #253 optimistic-concurrency contract (§7a) had a documented tail: the non-If-Match config-sibling
|
||||
writers of a versioned root mutated editor-visible state **without** bumping `Version`, so editing through
|
||||
them did not rotate an open editor's ETag (a cross-editor invalidation gap — never a lost-update or a 500,
|
||||
which the primary endpoints' bump+guard already cover). #269's first slice (PR #302) removed the 500 exposure
|
||||
by routing those writers through `SaveChangesForcingVersion`; this slice completes the **rotation**.
|
||||
|
||||
Handlers now bumping `Version` (all via `SaveChangesForcingVersion`, since they take no `If-Match` → a
|
||||
concurrent replace-all bump force-writes, never 412/500): the Collection `Add*ToCollection` family (11
|
||||
handlers) and `RemoveItemsFromCollectionHandler` bump `Collection.Version`; `UpdateCollectionHandler`
|
||||
(name/flag), `UpdatePlayoutHandler` (`DailyRebuildTime`), and the three `ScheduleFile` writers
|
||||
(`UpdateSequential`/`UpdateScripted`/`UpdateExternalJsonPlayout`) — which already force-wrote — now also bump.
|
||||
|
||||
Decisions frozen (ratified with Fable before implementation, feeding the #197 contract freeze):
|
||||
|
||||
- **Rotate on every editor-visible config change, no per-aggregate carve-outs.** §7a's config-only boundary
|
||||
("every mutating handler of a root's editor-visible config bumps `Version`") already held for Playlist
|
||||
`Add*`/schedule item writers; the Collection/Playout siblings were an inconsistency, not a judgment call. A
|
||||
membership add rotating an open custom-order editor's ETag (→ 412 → reload) is correct: its list is genuinely
|
||||
stale. Blast radius of the aggressive-but-safe rotation is a reload, never data loss.
|
||||
- **No-op idempotence — the trap Fable caught.** These handlers gate their reindex/`BuildPlayout` fan-out on
|
||||
`SaveChanges() > 0`. An *unconditional* bump makes that gate always-true, so an idempotent re-add / same-value
|
||||
re-submit would fire spurious rebuilds across every playout using the aggregate. Fix: short-circuit a genuine
|
||||
no-op **before** the bump — the Add handlers by an explicit membership check (which also fixes the latent
|
||||
duplicate-`CollectionItem` insert on a *sequential* re-add; two *concurrent* same-item adds can still both
|
||||
pass the check and the loser 500s on the composite-PK unique violation — `SaveChangesForcingVersion` catches
|
||||
only `DbUpdateConcurrencyException`, not `DbUpdateException`. That race is narrow and pre-existing, deferred
|
||||
to #308), the scalar writers (`UpdateCollection`, `UpdatePlayout`, the
|
||||
three `ScheduleFile` writers) by `ChangeTracker.HasChanges()`. A no-op neither bumps nor rebuilds nor rotates
|
||||
the ETag — which is itself correct (nothing changed).
|
||||
- **The `Add*ToCollection` family is not repository-mediated.** #269's original framing ("repository-mediated,
|
||||
shared with the scanner hot path") was wrong: `IMediaCollectionRepository` is read-only; each handler loads
|
||||
the `Collection` into its own `dbContext` and writes directly. So the rotation bump is a pure API-layer
|
||||
concern and the scanner's separate membership-write path is untouched — a background scan does **not** rotate
|
||||
the editor ETag (correct: background indexing is not an editor action).
|
||||
- **Force-write rebases the bump, never adopts the stored token verbatim (Codex review of this PR).**
|
||||
`SaveChangesForcingVersion` originally resolved a conflict by setting current=original=stored — which
|
||||
silently *discarded* a sibling's pending `Version++` when a versioned writer committed in its load→save
|
||||
window (sibling loads 1, bumps to pending 2, concurrent PUT commits 2 → retry wrote 2, so the concurrent
|
||||
writer's ETag "2" stayed valid and the rotation was lost under exactly the race it exists for). Fixed in
|
||||
this PR (it affects all 25 bumpers routed through the helper, including the pre-existing playlist/schedule
|
||||
ones): the retry now rebases — original = stored, current = stored + (pending current − pending original) —
|
||||
so a bumper lands at stored+1 and a non-bumper (delta 0, e.g. `ErasePlayoutHistory`) adopts stored unchanged.
|
||||
The race tests assert the post-race Version (3, not 2) and fail against the verbatim-adopt implementation.
|
||||
- **No new status codes.** These endpoints take no `If-Match` and force-write, so they never 412; no
|
||||
`[ProducesResponseType(...412...)]` and no OpenAPI regen (response types unchanged). Only §7a prose changes.
|
||||
|
||||
Tests: `CollectionEtagRotationTests` (rotation + no-op-without-bump-or-rebuild + force-write-past-concurrent-bump
|
||||
for Add/Remove/Update) and `PlayoutScheduleFileEtagRotationTests` (ScheduleFile rotation + no-op-without-refresh),
|
||||
the no-op guard proven non-vacuous by inverting the membership check. The `#265` RFC-7232 If-Match parser
|
||||
refinement (valid-but-non-matching/weak/list → 412 not 400) is a **separate** PR (disjoint surface: the shared
|
||||
parser + `CheckVersion`, not the handler saves). Refs #253 #269 #197 · `api-conventions.md` §7a.
|
||||
|
||||
## 2026-07-12 — If-Match evaluates per RFC 7232: valid-but-non-matching → 412, only grammar violations → 400 (#265)
|
||||
|
||||
Closing the last #253 concurrency-contract piece. `ConcurrencyHeaders.ParseIfMatch` previously classified
|
||||
**any** non-canonical/weak/list `If-Match` value as `Malformed → 400` (a deliberate fail-safe: reject rather
|
||||
than risk a stale write, deferred from the reference-aggregate PR). That was RFC-incorrect. Per **RFC 7232
|
||||
§3.1**, a syntactically-valid entity-tag that simply doesn't strong-match must return **412 Precondition
|
||||
Failed**, and **400** is reserved for a genuine grammar violation.
|
||||
|
||||
**What changed.** The parser is now a real RFC 7232 entity-tag/list parser (`If-Match = "*" / 1#entity-tag`).
|
||||
It **scans** the list (it does *not* `Split(',')` — a comma is a valid `etagc`, so it can appear inside a quoted
|
||||
opaque-tag: `"3,5"` is ONE tag, and a comma separates members only outside the quotes), trims only RFC OWS
|
||||
(SP/HTAB — not `string.Trim()`, which would strip NBSP and let `" * "` masquerade as the `*` force-write),
|
||||
validates each member as `[ "W/" ] DQUOTE *etagc DQUOTE`, and collects the versions of the **strong** members
|
||||
whose opaque text is the exact canonical decimal we emit. Outcomes:
|
||||
- **weak** (`W/"3"`), **empty** (`""`), **non-canonical** (`"03"`, `"3.0"`, `"+3"`), **out-of-range**
|
||||
(`"99999999999999999999"`) → valid tags that contribute no version → **412** (a `Version`-kind with an
|
||||
*empty* candidate set is a guaranteed no-match).
|
||||
- **list** (`"3", "5"`) → any strong member that matches proceeds; weak/non-canonical members drop out.
|
||||
- genuine grammar violations (unquoted `3`, SP inside the tag `" 3 "`, unterminated `"3`, `garbage`, a
|
||||
separator-only header) → **400**.
|
||||
|
||||
**Type reshape.** `IfMatchCondition.ExpectedVersion : Option<int>` → `ExpectedVersions : Option<Seq<int>>`
|
||||
(`None` = force-write; `Some(set)` = strong-match against the set, empty ⇒ always 412), and
|
||||
`VersionedAggregateExtensions.CheckVersion(Option<int>)` → `CheckVersion(Option<Seq<int>>)` = set membership.
|
||||
This threads through all 10 replace/update commands + handlers + request mappers + 9 controllers uniformly; no
|
||||
wire-contract change (400 and 412 were already declared on every PUT; the field is header-derived and internal,
|
||||
so no OpenAPI/DTO change).
|
||||
|
||||
*Why now, not #197:* it is the shared parser all replace-all PUTs copy, and the 412-vs-404 ordering the issue
|
||||
worried about was already correct (each handler loads/validates → 404 before `CheckVersion`). *Why safe:* the
|
||||
first-party SPA only ever echoes the single canonical strong tag we emit, so no shipped client changes behavior;
|
||||
the change only makes a hand-written/tooling `If-Match` get the RFC-correct status. Docs: `api-conventions.md`
|
||||
§7a. Refs #265 #253 #197.
|
||||
@@ -0,0 +1,221 @@
|
||||
# Release, CI & merge-governance decisions (#303, #311, #314, #315, #335)
|
||||
|
||||
Why the merge/release process is enforced by hooks and CI gates rather than prose: the #303
|
||||
methodology-hardening waves (state-derived merge-consent, API-contract CI gate, append-only
|
||||
decisions log, review-verdict gate), formatting-as-you-touch + rebase discipline, the
|
||||
auto-grant fix, migration rehearsal on a prod-DB copy, and release promotion. Rationale
|
||||
relocated from the append-only `docs/decisions.md` at the v26.9.0 consolidation; operational
|
||||
detail cross-links to `docs/ci-cd.md` and CLAUDE.md → Task Completion Protocol.
|
||||
|
||||
Issue trail: #303 (H4/H5 api-docs gate, H6 Done-when, H9/H3 append-only + root-png, H10
|
||||
review-verdict), #311 (H11 formatting/rebase), #314 (merge-gate auto-grant), #315 (migration
|
||||
rehearsal), #335 (release promotion). The whole hook program's throughline: make each process
|
||||
rule a derivation/hook, not prose to remember (#303 methodology review).
|
||||
|
||||
## Contents
|
||||
|
||||
- [2026-07-12 — Blocking CI gate for API-contract artifacts (#303 H4/H5)](#2026-07-12--blocking-ci-gate-for-api-contract-artifacts-303-h4h5)
|
||||
- [2026-07-12 — Merge-consent derived from state via a `## Done-when` issue checklist (#303 H6)](#2026-07-12--merge-consent-derived-from-state-via-a--done-when-issue-checklist-303-h6)
|
||||
- [2026-07-12 — decisions.md is append-only, enforced; root-screenshot guard (#303 H9/H3)](#2026-07-12--decisionsmd-is-append-only-enforced-root-screenshot-guard-303-h9h3)
|
||||
- [2026-07-12 — Review-verdict merge-gate: latest commit must be reviewed (#303 H10)](#2026-07-12--review-verdict-merge-gate-latest-commit-must-be-reviewed-303-h10)
|
||||
- [2026-07-12 — Formatting-as-you-touch, enforced; rebase-not-merge for PR branches (#311 H11 + format CI)](#2026-07-12--formatting-as-you-touch-enforced-rebase-not-merge-for-pr-branches-311-h11--format-ci)
|
||||
- [2026-07-12 — Merge-consent gate auto-grants when satisfied (no redundant prompt); state IS the consent (#314)](#2026-07-12--merge-consent-gate-auto-grants-when-satisfied-no-redundant-prompt-state-is-the-consent-314)
|
||||
- [2026-07-12 — Release path rehearses migrations on a prod-DB copy before promoting (#315)](#2026-07-12--release-path-rehearses-migrations-on-a-prod-db-copy-before-promoting-315)
|
||||
- [2026-07-13 — Release promotion: floating `:prod`, exact-image scan before manual deploy (#335)](#2026-07-13--release-promotion-floating-prod-exact-image-scan-before-manual-deploy-335)
|
||||
|
||||
---
|
||||
|
||||
## 2026-07-12 — Blocking CI gate for API-contract artifacts (#303 H4/H5)
|
||||
|
||||
**A PR whose diff touches `ErsatzTV/Controllers/Api/**` or `ErsatzTV.Core/Api/**` must ship the
|
||||
regenerated OpenAPI artifacts in that same diff, enforced by a blocking `api-docs` CI job.** It rebuilds
|
||||
`ErsatzTV/wwwroot/openapi/v1.json`, `web/src/api/generated/v1.d.ts`, and `docs/endpoint-index.md` from
|
||||
source and fails on any drift. This mechanizes the previously prose-only "docs-update in the same PR"
|
||||
rule for the API contract (the `docs-reminder` job stays a non-blocking nudge for the route-parity doc).
|
||||
Path-gated *inside* the job (not via a top-level `if:`) so it always reports a status on every PR — API-free
|
||||
PRs skip the expensive regen and pass trivially, so it is safe as a required check. Rationale: generation
|
||||
is deterministic from a **fresh** build (verified — a clean checkout reproduces the committed spec exactly,
|
||||
including the 244 auth `security`/`401` blocks), so CI can trust regenerate-and-diff. The one caveat is
|
||||
local-only: `update-openapi.sh` runs `dotnet-getdocument` against the already-built assembly, so a stale
|
||||
`bin/` silently yields a stale spec — always `dotnet build` first (see `api-conventions.md` §5). CI is
|
||||
immune (no `bin/` on a fresh checkout).
|
||||
|
||||
## 2026-07-12 — Merge-consent derived from state via a `## Done-when` issue checklist (#303 H6)
|
||||
|
||||
**An issue's `## Done-when` checklist (in the issue body) is the machine-readable source of truth for whether
|
||||
its PR may merge; consent is *derived*, not asserted.** Rationale: DONE/OPEN status used to live in
|
||||
append-only prose that lags live Gitea state (the queue-drift #303 fixes) — so the completion gate moves out
|
||||
of memory and into a checklist two hooks read. Convention: the issue body carries a `## Done-when` section
|
||||
(always an "adversarial review passed" box, plus per-issue criteria); a merge is allowed only when the PR's CI
|
||||
is green **and** every box on the linked issue (`fixes #N`) is ticked.
|
||||
|
||||
Enforcement (both fail *safe*, never a silent pass):
|
||||
- `pretooluse-merge-consent.sh` — Claude PreToolUse on `mcp__gitea__pull_request_write` merge: **deny** on an
|
||||
unticked box or non-green CI; **allow** when both satisfied; **ask** (human prompt) when state isn't
|
||||
derivable (no linked issue, no `## Done-when`, no creds, Gitea unreachable). Docs-only PRs exempt.
|
||||
- `.husky/pre-push` → `prepush-donewhen.sh` — backstop for a direct `git push origin main`; fail-*open* (a
|
||||
git hook has no "ask"), blocks only on a positively-proven unticked box.
|
||||
|
||||
Both authenticate to Gitea from env only (`ETV_GITEA_BASICAUTH` / `ETV_GITEA_TOKEN`, `ETV_GITEA_URL`) — no
|
||||
creds committed; without them the gate degrades to today's manual confirmation. Rollout is non-breaking: until
|
||||
issues adopt `## Done-when`, the merge hook simply *asks* rather than auto-allowing. See CLAUDE.md → Task
|
||||
Completion Protocol. (H6 lives with H1/H2/H8 in `.claude/settings.json`; H7 worktree-owner guard is its
|
||||
sibling Wave-2 hook.)
|
||||
|
||||
## 2026-07-12 — decisions.md is append-only, enforced; root-screenshot guard (#303 H9/H3)
|
||||
|
||||
**This log is append-only by construction, not just by convention.** A commit or PR that deletes or
|
||||
modifies an existing line of `docs/decisions.md` is blocked — by the Husky `commit-msg` hook
|
||||
(`.claude/hooks/decisions-guard.sh staged`) locally and the blocking `decisions-guard` CI job (same
|
||||
script, `range` mode) on PRs. Shared detection, deliberately different granularity: the Husky hook
|
||||
gates **each commit** (its own message must carry the token); CI gates the **PR-wide** net diff
|
||||
(token in any commit of the range suffices), so the local hook is the stricter primary gate and CI the
|
||||
push/bypass backstop. Insertions anywhere are always allowed, so a normal new entry (TOC line
|
||||
near the top + a block appended at the bottom, both pure insertions) passes untouched. Detection is
|
||||
`git diff --numstat` deleted-count > 0, which is robust to markdown `-` list markers (a byte-level `-`
|
||||
prefix would false-match). The block is lifted only by the literal **`[decisions-edit]`** token in the
|
||||
commit message, reserved for two cases: fixing a factual error, and superseding a reversed decision
|
||||
(add the new entry, prepend a `> **Superseded …**` banner to the old one, tag its Index line
|
||||
`(superseded)` — keep the old rationale, never silently rewrite). **Consolidation** of superseded
|
||||
entries is a release-checklist step (`docs/ci-cd.md` → Versioning & releases), backstopped by a
|
||||
non-blocking 1800-line **size floor** in the `decisions-guard` job (the read-cost point past which the
|
||||
log no longer fits one default agent Read), so append-only doesn't accrete contradictory *or
|
||||
unreadably-large* history between releases (Timothy's call, 2026-07-12: mark-and-keep on reversal,
|
||||
consolidate at each milestone, size-floor backstop).
|
||||
|
||||
Companion guard **H3**: the Husky `pre-commit` hook refuses a staged **root-level `*.png`** (a
|
||||
review/debug screenshot dropped at the repo root) — belt-and-suspenders with the `.gitignore` rule, so
|
||||
a forced `git add -f` still can't land one. Nested `*.png` (real assets) are unaffected. Rationale for
|
||||
both: the methodology review (#303) — make the process rules derivations/hooks, not prose to remember.
|
||||
|
||||
## 2026-07-12 — Review-verdict merge-gate: latest commit must be reviewed (#303 H10)
|
||||
|
||||
**A PR may not merge until a `Review-verdict:` comment on it references the PR's CURRENT head sha** —
|
||||
so the *latest* commit is proven-reviewed, not a stale earlier diff. This mechanizes the ersatztv#242
|
||||
lesson ("re-review the fix commit, not just the initial PR diff": a review of an earlier revision does
|
||||
not license merging a head that carries un-reviewed follow-up commits). It folds into the existing H6
|
||||
`pretooluse-merge-consent.sh` as condition (c), reusing its PR fetch, docs-only exemption, and
|
||||
Gitea-auth-from-env (no second hook → no detection drift, per the #303 methodology review).
|
||||
|
||||
Convention: after reviewing a PR (or its latest fix commit), post a PR **comment** (issue-style, not a
|
||||
Gitea formal-review body — the gate reads `issues/{pr}/comments`) whose line **starts with** the marker:
|
||||
`Review-verdict: <MERGEABLE|APPROVED|BLOCKED|NOT-MERGEABLE> @ <head-sha>` (short ≥7-char or full sha).
|
||||
The gate counts a line as a verdict only when the marker is at line-start (after optional indent) —
|
||||
a comment that merely *quotes* the template mid-sentence (an instruction "please post: Review-verdict:
|
||||
MERGEABLE @ …", or the gate's own suggestion text echoed back) does **not** self-approve the merge
|
||||
(adversarial re-review false-open, folded pre-merge). It then classifies each verdict line by the sha
|
||||
in its `@ <sha>` field, matched to the head by **git short-sha prefix semantics** (head *begins with*
|
||||
the token, token ≥7 chars) — NOT a loose substring test, so an older sha that merely contains the head
|
||||
prefix, or the head prefix appearing in an unrelated URL on the line, does not count:
|
||||
- a MERGEABLE/APPROVED/LGTM verdict whose `@ <sha>` is the current head → **allow**;
|
||||
- a **negative** verdict (BLOCKED/NOT-MERGEABLE) *on the head* → **deny**, and it *wins over* a positive
|
||||
one on the same head (a later BLOCKED retracts an earlier MERGEABLE; to retract, re-review head and
|
||||
post BLOCKED @ head). Staleness is **symmetric on purpose**: a negative for an *older* commit is stale
|
||||
exactly like a positive for an older commit, and does NOT override a fresh head-positive — otherwise a
|
||||
pre-fix `BLOCKED @ oldsha` would block forever even after the fix changes the sha and earns a fresh
|
||||
`MERGEABLE @ head` (the normal flow). So a genuine block must reference head, per the convention;
|
||||
- verdict comment(s) exist but reference only *older* commits → **deny** — the stale-review case #242
|
||||
targets;
|
||||
- a `Review-verdict:` marker with **no `@ <sha>`** at all → **ask** (a lazy/quoted marker; not
|
||||
mislabelled as stale);
|
||||
- no `Review-verdict:` comment at all → **ask** (graceful adoption, mirrors H6's "no Done-when →
|
||||
ask": surface, don't hard-block a PR that hasn't adopted the convention yet);
|
||||
- comments unfetchable / head sha unresolvable → **ask**.
|
||||
|
||||
Scope: the Claude PreToolUse gate on the Gitea merge tool only. A direct `git push origin main` has no
|
||||
PR comments to check, so the `.husky/pre-push` backstop is not extended for H10 (the merge tool is the
|
||||
real merge path; docs-only PRs remain exempt via H6's file-set exemption). Rationale, as with the whole
|
||||
Wave-1/2/3 hook set: make the process rule a derivation/hook, not prose to remember (#303).
|
||||
|
||||
## 2026-07-12 — Formatting-as-you-touch, enforced; rebase-not-merge for PR branches (#311 H11 + format CI)
|
||||
|
||||
Two coupled process decisions, prompted when a stale docs branch *merged main in*, dragged ~17
|
||||
legacy-BOM `.cs` files it never touched into the merge commit, and the pre-commit `dotnet format`
|
||||
hook then blocked on code that wasn't the author's (#309 session; the BOM backlog is #310).
|
||||
|
||||
**1. Formatting-as-you-touch is the standing rule, and it is now *enforced* (not just prose).** ~2500
|
||||
of ~3900 `.cs` files carry a legacy UTF-8 BOM that violates `.editorconfig`'s `charset=utf-8`. We do
|
||||
**not** mass-reformat (a repo-wide normalization stays an unmade, separate decision). Instead, a file
|
||||
you modify for other work must be normalized (`dotnet format`, incl. BOM strip) in that same PR.
|
||||
Enforcement — previously only the `--no-verify`-bypassable pre-commit hook, which is how #269 landed 17
|
||||
BOM files (CI never checked charset):
|
||||
- a **blocking `format` CI job** runs `dotnet format --verify-no-changes` **scoped to the PR's changed
|
||||
`.cs`** (vs the merge-base) — so it demands conformance only of files the PR touched, never the
|
||||
untouched legacy 2500; a `.cs`-free PR skips the expensive steps and passes (always reports a status,
|
||||
safe as a required check). This closes the "CI never verifies charset/format" gap.
|
||||
- `docs/contributing.md` §7 documents the rule.
|
||||
|
||||
**2. Keep a PR branch current by REBASING on `origin/main`, never merging main in (H11).** A merge
|
||||
commit pulls in *every* file main changed — including files the author never touched — which then trip
|
||||
the format hook/CI on code that isn't theirs; rebasing keeps the diff to exactly what changed.
|
||||
Enforced by `.claude/hooks/prepush-rebase-check.sh` wired into `.husky/pre-push`: a push from a branch
|
||||
that is behind `origin/main` (origin/main not an ancestor of HEAD) is **blocked** with
|
||||
`git rebase origin/main` guidance. Fail-open (offline / no origin/main / not a repo → allow, since a
|
||||
git hook has no "ask"); deliberate escape `ETV_SKIP_REBASE_CHECK=1`. This supersedes the old lore
|
||||
guidance to "merge main into your PR branch." (After a rebase that conflicts in *generated* artifacts —
|
||||
v1.json/v1.d.ts/endpoint-index — regenerate, don't hand-resolve; `npm run check:api` guards.)
|
||||
|
||||
Rationale, as with the whole hook program: make the process rule a derivation/hook, not prose to
|
||||
remember (#303 methodology review). Tracked: #311; sibling #312 (H12 issue-qualification audit).
|
||||
|
||||
## 2026-07-12 — Merge-consent gate auto-grants when satisfied (no redundant prompt); state IS the consent (#314)
|
||||
|
||||
Completes the #303 H6/H10 intent — *derive merge-consent from state* — which the original hook only
|
||||
half-delivered. The rule the user set: **merge permission is auto-granted for the session when the
|
||||
linked issue's `## Done-when` boxes are all ticked, a fresh positive `Review-verdict` references the
|
||||
current head, and CI is green** — no separate confirmation, conversational or mechanical.
|
||||
|
||||
**Root cause of the bug this fixes:** `pretooluse-merge-consent.sh`'s satisfied path did a bare
|
||||
`exit 0`. A PreToolUse hook that exits 0 with no JSON does **not** auto-approve — it only declines to
|
||||
block, so control falls through to the normal permission system and the raw MCP permission prompt
|
||||
still fires (the merge tool isn't allow-listed). So the gate only ever *added* a deny/ask net; it never
|
||||
*removed* the baseline prompt on the happy path. Net effect for the operator: a ready-to-merge PR was
|
||||
confirmed twice — once conversationally (the per-session merge-consent norm) and again by a redundant
|
||||
mechanical prompt the gate was supposed to have subsumed.
|
||||
|
||||
**Fix:** ONLY the genuinely-satisfied merge path (a+b+c all true) now emits
|
||||
`{"hookSpecificOutput":{"permissionDecision":"allow", ...}}` (a new `grant` decision), which actually
|
||||
suppresses the prompt. Deny (unticked/red/negative/stale) and ask (non-derivable: no creds, Gitea
|
||||
down, no linked issue, no `## Done-when`, no verdict) are unchanged — the gate still fails closed, not
|
||||
open. Two paths deliberately do **not** auto-grant and keep the bare `exit 0` **passthrough** (normal
|
||||
permissioning → one prompt): non-merge `pull_request_write` methods (auto-grant is scoped to
|
||||
method=merge only), and the **docs/process-only exemption**. The exemption is a file-TYPE bypass, not
|
||||
the a+b+c "provably reviewed & ready" proof, so it must not *silently* self-merge — critically, its set
|
||||
includes `.claude/`/`.gitea/`/`.husky/` (the gate, CI workflows, and git hooks themselves), so a PR
|
||||
that weakens the gate still gets a human prompt (ersatztv#317 review nit). Verified by 8 pipe tests
|
||||
(satisfied→allow, docs-only→passthrough, unticked→deny, stale→deny, red-CI→deny, no-verdict→ask,
|
||||
no-creds→ask, non-merge→passthrough).
|
||||
|
||||
**Process consequence:** the state-derived gate *is* the consent on the satisfied path — do **not**
|
||||
also ask conversationally to merge a PR whose gate auto-grants. A separate human confirmation is still
|
||||
warranted only when the gate **asks** (state not derivable). This supersedes the "always confirm merge
|
||||
consent in-conversation per session" phrasing in the kickoff HARD CONSTRAINTS (updated in the same PR).
|
||||
|
||||
## 2026-07-12 — Release path rehearses migrations on a prod-DB copy before promoting (#315)
|
||||
|
||||
The CI `migrations` job proves a migration is well-formed against a **fresh, empty** DB (model-drift +
|
||||
apply-to-fresh, per provider). That is necessary but not sufficient: it never exercises the migration —
|
||||
or ErsatzTV's startup data steps (`DatabaseMigratorService` → `DbInitializer` + `PopulatePathHashes`
|
||||
over the real `MediaFile` table) — against the **accumulated prod SQLite**, where row volume and
|
||||
historical values differ. A migration green on a fresh DB can still fail or corrupt on prod, discovered
|
||||
only mid-deploy after the container recreates.
|
||||
|
||||
Decision: before promoting a migration-bearing release, **rehearse** the new image's migrations against
|
||||
a **throwaway copy of the latest prod backup** via `scripts/migration-smoke.sh` — boot the new image
|
||||
against the copy, gate PASS on the `Done applying database migrations` log line (the migrator is a
|
||||
`BackgroundService` running concurrently with Kestrel, so HTTP-readiness alone does *not* prove
|
||||
migrations finished), FAIL on early container exit / a migration exception / timeout / not serving
|
||||
afterwards. Always operates on a copy, never the live DB. Home: the script + docs are ours; wiring it
|
||||
into the Komodo **pre-deploy** step (which already produces the backup) is a server-management concern.
|
||||
Rationale: data-plane rigor — catch a bad migration on a disposable copy, not on live prod data.
|
||||
See `docs/ci-cd.md` → Migration-on-prod-copy smoke. Cross-repo wiring tracked in server-management.
|
||||
|
||||
## 2026-07-13 — Release promotion: floating `:prod`, exact-image scan before manual deploy (#335)
|
||||
|
||||
Prod keeps the floating `:prod` image reference; PR #191's workflow-driven immutable pin bump is
|
||||
closed as superseded. server-management#585 proved that automatic and manual promotions share
|
||||
`DeployStack` and made a changed `:prod` digest trigger the fail-closed backup; #589 added the
|
||||
prod-copy migration smoke before live recreation. Tagging and promotion remain separate: scan the
|
||||
tag build's immutable `:<version>` image, then deploy manually. Daily auto-update is only a fallback,
|
||||
so cut tags with enough runway before 03:00 to prevent an unscanned promotion. Refs #335 and
|
||||
server-management#585/#589.
|
||||
@@ -0,0 +1,98 @@
|
||||
# SPA modularization — App.tsx screen/shell extraction epic (#243)
|
||||
|
||||
Why the ChicoryTV SPA was decomposed out of a monolithic `App.tsx`, and the conventions the
|
||||
extraction froze (single-file screens, screen-owned route wrappers, explicit primary-action
|
||||
ownership). Pure structural moves — no API/route/CSS/behavior change — so the rationale is
|
||||
about *boundaries*, relocated from the append-only `docs/decisions.md` at the v26.9.0
|
||||
consolidation. Cross-links: `docs/spa-conventions.md` §2/§6/§10.
|
||||
|
||||
Issue trail: epic #243 — phase 1 #244 (Channels), phase 2 #245 (Playouts), phase 4 #247
|
||||
(shell/routing + primary actions). Refs #238 #230.
|
||||
|
||||
## Contents
|
||||
|
||||
- [2026-07-11 — Channels screen extraction (#244): single-file screen, no sibling helper dir (epic #243 phase 1)](#2026-07-11--channels-screen-extraction-244-single-file-screen-no-sibling-helper-dir-epic-243-phase-1)
|
||||
- [2026-07-14 — Playouts screen extraction (#245): screen-owned route wrapper (epic #243 phase 2)](#2026-07-14--playouts-screen-extraction-245-screen-owned-route-wrapper-epic-243-phase-2)
|
||||
- [2026-07-15 — App shell/routing extraction + explicit primary-action ownership (#247)](#2026-07-15--app-shellrouting-extraction--explicit-primary-action-ownership-247)
|
||||
|
||||
---
|
||||
|
||||
## 2026-07-11 — Channels screen extraction (#244): single-file screen, no sibling helper dir (epic #243 phase 1)
|
||||
|
||||
First bounded extraction under the App.tsx modularization epic (#243): the Channels domain moved
|
||||
verbatim out of `web/src/App.tsx` into `web/src/screens/ChannelsScreen.tsx` (zero-prop, self-sufficient,
|
||||
mirroring the SchedulesScreen extraction), with its behavior tests moved to a colocated
|
||||
`ChannelsScreen.test.tsx` that owns its own scoped fetch mock (spa-conventions §6). Pure structural
|
||||
move: no API/route/CSS/visual change; `App.tsx` retains only the import + the `<ChannelsScreen />`
|
||||
dispatch clause.
|
||||
|
||||
**Decision — no `web/src/channels/` sibling helper directory** (unlike Schedules' `web/src/schedules/`).
|
||||
Channels' pure logic (`stateByChannelId`, `sortedChannels`, `groupedChannels`, `progressFromChannelState`,
|
||||
`formatChannelNumber`) totals ~30 lines with no independent business-rule layer comparable to Schedules'
|
||||
`itemRules.ts` (~470 lines, separately unit-tested). Keeping it inside the single screen file matches the
|
||||
Blocks/Decos/Templates precedent. Revisit only if a later #243 phase adds substantial pure Channels logic
|
||||
worth isolating.
|
||||
|
||||
**One cross-domain helper inlined, not shared:** the Dashboard-owned `progressFromNowPlaying` (still in
|
||||
`App.tsx`, used by `OnAirCard`) was structurally reused by the Channels `progressFromChannelState`. Rather
|
||||
than export it from App or create a shared module, its ~5-line start/finish/now percentage math was inlined
|
||||
into the moved `progressFromChannelState` (behavior-identical — `ChannelState.nowPlaying` carries the same
|
||||
`startUtc`/`finishUtc` shape), so `ChannelsScreen.tsx` has no import back into `App.tsx`.
|
||||
|
||||
**#238 (TopBar `primaryAction` dead button) left untouched** — the `channels` route's inert `ctv:primary-action`
|
||||
dispatch is #238's owned bug and out of scope for a behavior-preserving extraction; the shell/action redesign
|
||||
is deferred to #247 (epic phase 4).
|
||||
|
||||
## 2026-07-14 — Playouts screen extraction (#245): screen-owned route wrapper (epic #243 phase 2)
|
||||
|
||||
Second bounded extraction under the App.tsx modularization epic (#243): the Playouts domain moved from
|
||||
`web/src/App.tsx` into `web/src/screens/PlayoutsScreen.tsx`, including its loading/error/empty states,
|
||||
dialogs, mutations, timeline/filter helpers, and the existing `PlayoutsRouteScreen`. The dedicated
|
||||
`PlayoutScheduleEditors.tsx` modules remain separate. This is a pure structural move: no API, route, CSS,
|
||||
or runtime behavior changed; `App.tsx` retains only the import and `<PlayoutsRouteScreen />` dispatch.
|
||||
|
||||
**The unguarded route wrapper moves with the screen.** Playouts owns two sibling sub-path editors and,
|
||||
per `spa-conventions.md` §2, must keep its local pathname state plus `popstate` listener because App's
|
||||
allow-sub-path route object is reference-stable. Colocating the wrapper keeps that screen-specific route
|
||||
ownership beside the base screen while App-level tests retain the cross-route navigation assertions.
|
||||
|
||||
**Temporal mutation behavior stays verbatim.** `mutatingRef`, `runMutation`, and the explicit
|
||||
`query.refresh()` after a 409 moved as one unit. The extraction deliberately does not add mount/current
|
||||
guards to these pre-existing promise completions; changing those semantics belongs to a separate issue.
|
||||
Detailed behavior tests now render `PlayoutsScreen` directly with a scoped fetch mock, including the
|
||||
zero-playout Add Playout affordance, lock/409 handling, refresh/poll ownership, action and kind gates, and
|
||||
dialog flows. Refs #245 #243.
|
||||
|
||||
## 2026-07-15 — App shell/routing extraction + explicit primary-action ownership (#247)
|
||||
|
||||
Final phase of the App.tsx modularization epic (#243). `web/src/App.tsx` is now only the composition
|
||||
root: it owns `activeRoute`, `currentPathRef`, the `navigate`/`popstate` pair that consults the dirty
|
||||
guard, the approved `librariesSubPath`, and the global theme/health inputs, then composes the shell with
|
||||
the matched screen. Route metadata/matching/sidebar groups moved to `web/src/app/routes.tsx`; shell chrome
|
||||
(Sidebar, TopBar, Connect menu, version and theme controls) moved to `app/AppShell.tsx`; exhaustive screen
|
||||
dispatch plus the Media/Libraries wrappers moved to `app/ScreenContent.tsx`. No route, styling, API, or
|
||||
dependency changed.
|
||||
|
||||
**Route identity is deliberate infrastructure.** `routes.tsx` contains the ONE stable module-level array
|
||||
of shared `ScreenRoute` objects. `routeFromLocation()` returns those same references for an
|
||||
`allowSubPaths` base path and every owned sub-path, so React's `Object.is` state bailout remains part of
|
||||
the contract rather than an accidental implementation detail. The three existing sub-path mechanisms stay
|
||||
distinct: Playouts and Media self-own `pathname`/`popstate`; guarded Libraries receives only the path App
|
||||
approved after its dirty guard; the remaining route screens retain their established self-owned or keyed
|
||||
remount behavior. The extraction does not unify them into a speculative router/framework. `ScreenContent`
|
||||
uses an exhaustive `ScreenId` switch, so a future route added without a dispatch branch fails typecheck
|
||||
instead of silently falling through to a placeholder.
|
||||
|
||||
**Primary actions are one explicit screen-owned registration, not a global event.** The old
|
||||
`ctv:primary-action` window `CustomEvent` and string-keyed dispatcher are removed. `PrimaryActionProvider`
|
||||
holds exactly one `{ routeId, handler, owner }` registration shared by the active screen and TopBar;
|
||||
`usePrimaryAction` registers it, keeps the latest handler behind a ref without ownership churn, and clears
|
||||
it only when the same opaque owner unmounts. A stale cleanup therefore cannot erase a newer screen's
|
||||
action. TopBar renders the Plus action only when the route has a non-empty label AND that active route owns
|
||||
a matching registration, so metadata alone can no longer create a dead button. This is intentionally not a
|
||||
generic action bus: one declared action per screen is the whole contract.
|
||||
|
||||
Tests pin stable route-object identity (including sibling sub-paths and query-only URLs), composition-level
|
||||
sub-path rendering and dirty-popstate restoration, and primary-action matching/latest-handler/route-change/
|
||||
cleanup/stale-owner behavior. Detailed Builder and Settings behavior is colocated with those screens while
|
||||
`App.test.tsx` remains shell/routing/composition coverage. Refs #247 #243 #238 #230.
|
||||
@@ -71,6 +71,7 @@ Channel (1) ──< Playout (0..N per channel; ChannelPlayoutSource distinguishe
|
||||
| **Channel concepts** | `Number` (validated by `Channel.NumberValidator` regex), `Group`, `PlayoutSource` (Generated/Mirror; Mirror channels relay another channel via `MirrorSourceChannelId`+`PlayoutOffset`), `PlayoutMode` (Continuous/OnDemand), `TranscodeMode` (OnDemand only, today), `IdleBehavior` (StopOnDisconnect/KeepRunning), `StreamingMode` (TransportStream/HttpLiveStreamingDirect/HttpLiveStreamingSegmenter/TransportStreamHybrid). | `Channel` | `/app/channels`, `/app/edit-channel/{id}`, `/app/new-channel` |
|
||||
| **Guide / EPG (XMLTV)** | Per-channel programme guide generated from playout items; channels with `ShowInEpg=false` are excluded. | `GetChannelGuideHandler` | `/app/guide` (viewer); settings at `/app/settings/xmltv` |
|
||||
| **M3U** | The channel lineup playlist Jellyfin/Dispatcharr consume. | `ChannelPlaylist.ToM3U()` | — |
|
||||
| **IPTV base URL** | Optional advertised base URL for the IPTV surface (#340). Stored as a single `ConfigElement` (`ConfigElementKey.IptvBaseUrl`, key `iptv.base_url`, no EF migration); when set, `GetChannelPlaylistHandler` (M3U) and `GetChannelGuideHandler` (XMLTV) pin their absolute URLs to its scheme/host/base instead of the request `Host` (blank/invalid → request-derived). Not applied to HDHomeRun; distinct from `ETV_BASE_URL`. Parsed by `ErsatzTV.Core/Iptv/AdvertisedBaseUrl.cs`. | `ConfigElementKey.IptvBaseUrl` | `/app/settings` → IPTV (`GET`/`PUT /api/v1/settings/iptv`) |
|
||||
| **Aggregate `Version`** | Optimistic-concurrency token (issue #253): a plain `int Version` on the 9 replace-all roots — `ProgramSchedule`, `Block`, `Template`, `DecoTemplate`, `Playlist`, `Collection`, `Playout`, `MultiCollection`, `RerunCollection` — implementing `IVersionedAggregate`, EF-mapped `.IsConcurrencyToken()`. Surfaced as a strong ETag on the aggregate's GET and checked against `If-Match` on the PUT (mismatch → 412). See `api-conventions.md` §7a. | `IVersionedAggregate` | (not user-edited) |
|
||||
|
||||
## Where things are edited (SPA routes)
|
||||
@@ -102,7 +103,8 @@ These replace the Blazor `/media/sources/{local,plex,jellyfin,emby}/...` pages o
|
||||
`docs/blazor-route-parity.md` Section 2 for the full per-route mapping.
|
||||
|
||||
System nav: `/app/settings` (sub-tabs: streaming/system/logging/playout/scanner/general/xmltv —
|
||||
all mapped 1:1 from legacy `/settings/*` Blazor routes), `/app/logs`, `/app/troubleshooting` (+
|
||||
all mapped 1:1 from legacy `/settings/*` Blazor routes — plus a new **IPTV** section, #340, that
|
||||
edits the advertised IPTV base URL via `GET`/`PUT /api/v1/settings/iptv`), `/app/logs`, `/app/troubleshooting` (+
|
||||
`/app/troubleshooting/blocks` block-playout history, `/app/troubleshooting/yaml` sequential-schedule
|
||||
validator; playback troubleshooting still gated on API #145), `/app/ffmpeg-profiles`,
|
||||
`/app/watermarks`.
|
||||
|
||||
+41
-1
@@ -120,11 +120,51 @@ Usage:
|
||||
scripts/e2e-local.sh [CONFIG_DIR]
|
||||
```
|
||||
- `CONFIG_DIR` defaults to a fresh `mktemp -d` if omitted.
|
||||
- Copies `ErsatzTV/wwwroot` → `ErsatzTV/bin/Debug/net10.0/wwwroot`.
|
||||
- Copies `ErsatzTV/wwwroot` → `ErsatzTV/bin/<config>/net10.0/wwwroot`.
|
||||
- Launches `dotnet ErsatzTV.dll` in the background with `ETV_CONFIG_FOLDER` set.
|
||||
- Waits (up to 120s) for the `Done migrating search index` log line.
|
||||
- Prints the PID and port, then **exits leaving the server running** — the caller is responsible
|
||||
for killing the PID when done (`kill <PID>`).
|
||||
- **`ETV_BUILD_CONFIG`** selects which build output to launch (`Debug` default for local dev; the
|
||||
CI `functional-e2e` job sets `Release`). It must match the `dotnet build --configuration` you ran
|
||||
first — the script only copies `wwwroot` + launches; it does not build.
|
||||
|
||||
## Functional-E2E harness: `scripts/e2e-functional.sh`
|
||||
|
||||
The ad-hoc curl scenarios sessions run against a live instance (redirect sweeps, auth flows, the scan
|
||||
status contract, `If-Match`/412) are codified into a single harness so they run identically by hand
|
||||
and in CI (the advisory `functional-e2e` job — `docs/ci-cd.md`). It does **not** boot the app; pair it
|
||||
with `scripts/e2e-local.sh` above (or point it at any running instance):
|
||||
|
||||
```bash
|
||||
CFG=$(mktemp -d)
|
||||
eval "$(scripts/e2e-local.sh "$CFG" | sed -n 's/^\(PID\|PORT\)=/\1=/p')" # launch, capture PID/PORT
|
||||
scripts/e2e-functional.sh "http://localhost:${PORT}" "$CFG" # assert; exit 1 on any failure
|
||||
kill "$PID"
|
||||
```
|
||||
|
||||
`CONFIG_DIR` (arg 2) is **required** — the harness reads the instance's machine key from
|
||||
`$CONFIG_DIR/api.key` and sends `X-Api-Key` on every `/api` call (the surface is fail-closed). It runs
|
||||
each assertion even after a failure and prints a pass/fail summary, exiting non-zero if any failed.
|
||||
|
||||
What it covers (all curl-only, deterministic, no seeded media / ffmpeg-transcode / browser needed):
|
||||
- **Legacy→SPA redirects**: a sweep of representative `LegacyUiRedirects` routes 302→`/app/*`, plus
|
||||
the `/api` + `/artwork` never-redirect exemption (asserted as "did not 302 to `/app`", since those
|
||||
4xx from their own handlers — `/api/*` 404s, `/artwork/*` 400s).
|
||||
- **Auth/CSRF/security-stamp** (mirrors the #295 manual set): fresh `auth/config` `setupRequired:true`
|
||||
→ read-gate 401-without-key / 200-with-key → setup-claim 200 → re-claim 409 → session mutation
|
||||
403-without-`X-CSRF` → login 401-then-200 → logout 403-without-CSRF / 204-with → post-logout the
|
||||
same cookie is 401 (rotated security stamp).
|
||||
- **Library-scan status contract**: create an empty local library → scan `202`, unknown-library scan
|
||||
`404`, `scan-status` `200`.
|
||||
- **Optimistic concurrency**: create a collection + a `rerun-collections` targeting it → `GET` emits
|
||||
an `ETag` → `PUT` with a stale `If-Match` `412`, current `200`, malformed `400`.
|
||||
|
||||
**Deliberately deferred** (need the scanner subprocess + seeded media, or a browser, to be
|
||||
deterministic — ersatztv#299 follow-ups): the 409 "already-scanning" re-trigger, the playout-build
|
||||
lock 409, and the genuinely UI-interactive Playwright flows. When you extend the harness, add the
|
||||
observed contract here and keep the assertions deterministic (probe the real instance first — the
|
||||
initial cut caught that `/artwork/*` 400s where a guess said 404).
|
||||
|
||||
## Seeding a local TV library for E2E
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
*Generated by `scripts/generate-endpoint-index.py` from `ErsatzTV/wwwroot/openapi/v1.json`. Do not edit by hand -- regenerated by `scripts/update-openapi.sh`.*
|
||||
|
||||
160 endpoints, 242 operations.
|
||||
161 endpoints, 244 operations.
|
||||
|
||||
## Artists
|
||||
|
||||
@@ -354,6 +354,8 @@
|
||||
| PUT | `/api/v1/settings/ffmpeg` | UpdateFfmpegSettings | Update global FFmpeg settings |
|
||||
| GET | `/api/v1/settings/hdhr` | GetHdhrSettings | Get HDHomeRun emulation settings |
|
||||
| PUT | `/api/v1/settings/hdhr` | UpdateHdhrSettings | Update HDHomeRun emulation settings |
|
||||
| GET | `/api/v1/settings/iptv` | GetIptvSettings | Get IPTV output settings |
|
||||
| PUT | `/api/v1/settings/iptv` | UpdateIptvSettings | Update IPTV output settings |
|
||||
| GET | `/api/v1/settings/logging` | GetLoggingSettings | Get per-area minimum log levels |
|
||||
| PUT | `/api/v1/settings/logging` | UpdateLoggingSettings | Update per-area minimum log levels |
|
||||
| GET | `/api/v1/settings/playout` | GetPlayoutSettings | Get global playout settings |
|
||||
|
||||
@@ -8,10 +8,10 @@ The original live local E2E run (branch `feat/202-media-sources`, worktree
|
||||
`/Users/timothy/etv-worktrees/202-int`) automated everything that a disposable local instance could
|
||||
exercise (local library CRUD, scan, move-path, delete, dirty-guard, apiKey contract, remote-screen
|
||||
render). The two flows below genuinely need a live Jellyfin/Emby server and a live Plex account.
|
||||
Results from the first homelab run on 2026-07-13 are recorded under each flow. Jellyfin validation is
|
||||
complete. Plex account authorization and cleanup were exercised, but the test account returned no
|
||||
eligible Plex servers, so server/library discovery and sync-preference persistence remain outstanding.
|
||||
Do not mark #333 done until those remaining Plex steps are exercised against an account with a server.
|
||||
Results from the homelab runs are recorded under each flow. **Both flows are now complete.** Jellyfin
|
||||
validation finished on 2026-07-13. The Plex flow finished on 2026-07-16 against a disposable test Plex
|
||||
Media Server: authorization, server/library discovery, sync-preference persistence, and sign-out
|
||||
cleanup all passed. #333 is satisfied.
|
||||
|
||||
## 1. Jellyfin/Emby real-server connect
|
||||
|
||||
@@ -88,6 +88,41 @@ but no sign-out action. Cleanup therefore required the API. The exact request/st
|
||||
root-cause analysis are captured in
|
||||
[#345](https://gitea.tblindustries.be/timothy/ersatztv/issues/345).
|
||||
|
||||
### Result — 2026-07-16: completed against a live Plex server
|
||||
|
||||
The Plex-server prerequisite from the 2026-07-13 run was resolved: a disposable test Plex Media
|
||||
Server (`plex` container in the `media-servers` stack, reachable from ErsatzTV at `http://plex:32400`
|
||||
and on the LAN at `http://192.168.1.99:32400`, claimed to the account and seeded with a **Movies**
|
||||
library) was stood up specifically for this validation (#333 comment). Run against `ersatztv-test`
|
||||
(`:latest`, host port 8410); API state verified out-of-band with the machine key against the
|
||||
in-container port 8409.
|
||||
|
||||
- [x] Real plex.tv pin flow completed. Server log recorded `Successfully authenticated with plex`
|
||||
(21:33:27) with no discovery exception, and the SPA transitioned to the signed-in state without a
|
||||
manual refresh.
|
||||
- [x] **Server discovery succeeded** (the 2026-07-13 gap): `GET /api/v1/media-sources/plex` reported
|
||||
`isAuthorized: true`, `isLocked: false`, and `servers: [{ id: 3, name: "8d720a58d5d6",
|
||||
address: "http://192.168.1.99:32400" }]`.
|
||||
- [x] Libraries populated for the discovered server:
|
||||
`GET /api/v1/media-sources/plex/3/libraries` returned the **Movies** library
|
||||
(`id: 16`, `mediaKind: Movies`, initial `shouldSyncItems: false`).
|
||||
- [x] Sync-preference persistence: toggled **Sync** on for Movies and saved in the SPA; the API then
|
||||
reported `shouldSyncItems: true`, and the value survived a full SPA reload.
|
||||
- [x] Sign-out cleanup: signed out from the SPA (the **Sign out** affordance was present in the
|
||||
with-server state, unlike the zero-server case in #345). The API returned to `isAuthorized: false`,
|
||||
`isLocked: false`, `servers: []`; `GET /api/v1/media-sources/plex/3/libraries` then returned **404**
|
||||
(no stale server/library data); the SPA showed **Not signed in** with no stale rows.
|
||||
|
||||
No unexpected page errors. This completes the outstanding Plex steps and the #202 real-server
|
||||
integration validation. Note: #345 (authorized/zero-server SPA state) was not re-exercised here — this
|
||||
run always had a discovered server — so that specific edge case remains as filed.
|
||||
|
||||
**Test-environment note:** the `ersatztv-test` local-admin credential (set by an earlier session and
|
||||
unrecorded) was reset to `admin` / the homelab default for this run by clearing the three
|
||||
`auth.local_admin.*` / `auth.security_stamp` `ConfigElement` rows and re-running first-run setup. The
|
||||
disposable `plex` container should be removed from the `media-servers` stack now that this flow is
|
||||
validated (server-management-owned compose change).
|
||||
|
||||
## Notes for whoever runs this
|
||||
|
||||
- Use a throwaway/test Jellyfin or Emby API key if possible — the SPA's `hasApiKey` contract means the
|
||||
|
||||
@@ -65,16 +65,29 @@ silently expanding orchestrator reconnaissance. Global Codex hook enforcement is
|
||||
`timothy/server-management#592`; the wider Claude-hook port is tracked in `timothy/server-management#593`.
|
||||
|
||||
**When the user has not named an issue, queue selection is mechanical fast/small work, never orchestrator
|
||||
work.** Dispatch exactly one selector on the cheapest suitable model at `low` effort. Give it only
|
||||
#237's body, last ~6 comments, every OPEN milestone and its OPEN issues, OPEN `review`-labeled
|
||||
issues, and the reviewer-repo candidate list plus each candidate's claim/deliverable comments; it
|
||||
returns a ranked shortlist of at most three issue IDs with one-line rationales and live-state evidence. The
|
||||
orchestrator receives only that compact packet, then performs a focused live recheck of the winner before
|
||||
claiming. Do not load implementation docs or issue bodies into the selector. If the client cannot route a
|
||||
work.** Dispatch exactly one selector on the cheapest suitable model at `low` effort. Give it #237's
|
||||
body and last ~6 comments, then have it query live candidates as a cascade in this exact tier order:
|
||||
**arc → OPEN issues assigned to OPEN milestones → open `review` → unmilestoned/unreviewed
|
||||
`priority: high` → `priority: medium` → `priority: low`**. Every candidate row represents an issue
|
||||
(or eligible reviewer audit): milestone records provide tier metadata and are NEVER pickup candidates.
|
||||
An OPEN milestone with zero eligible OPEN issues contributes zero candidates. Deduplicate candidates,
|
||||
apply the live eligibility exclusions at each tier (including automatic exclusion of `parked` issues),
|
||||
and stop as soon as five eligible issues have been accumulated (or all tiers are exhausted). It returns
|
||||
only that ranked top-five shortlist, with one-line rationales and live-state evidence; a small result
|
||||
packet is the desired behavior, but an empty or undersized higher tier must fall through to the next tier.
|
||||
The orchestrator receives only that compact packet, then performs a focused live recheck of the winner
|
||||
before claiming. If the cheap worker lacks a repository-scoped issue-list tool, it must immediately return
|
||||
the literal result `TOOL_LIMITATION`; it must not rank issue IDs mentioned in tracker prose, turn milestone
|
||||
records into candidates, or infer that any tier is empty. The orchestrator then performs only the mechanical
|
||||
tier queries (repository + state + milestone/label), passes at most the bounded raw ISSUE rows back to the
|
||||
cheap worker, and leaves all filtering/ranking to that worker. Never substitute an owner-wide/global search
|
||||
or treat that tool limitation as an empty tier.
|
||||
Do not load implementation docs or issue bodies into the selector. If the client cannot route a
|
||||
cheaper subagent, run the selector in a separate low-cost session before starting or resuming the orchestrator
|
||||
and pass in its packet. **Do not fall back to inline sorting or an equally expensive selector.** If no cheaper
|
||||
route or session is available, pause and request the selector packet rather than consuming orchestrator tokens
|
||||
on queue ranking. If the user names an issue, skip selection and only verify that issue's live claimability.
|
||||
on queue ranking. If the user names an issue, skip selection and only verify that issue's live claimability;
|
||||
this explicit user choice is the sole path by which a `parked` issue may be worked.
|
||||
|
||||
FIRST read `AGENTS.md` and `CLAUDE.md` when present, then `docs/README.md`, the convention docs it
|
||||
indexes, and the Lessons below. Apply both client instruction files; where they differ, follow the
|
||||
@@ -108,9 +121,16 @@ Then work the queue:
|
||||
item in #237's arc list**; its open children are the gate cluster (query them by the `review`
|
||||
label). Cross-check every arc / "recommended" item's real open/closed state (issue **and**
|
||||
milestone) before trusting it — do NOT hardcode which issue is the frontier; read it. Candidate
|
||||
discovery MUST enumerate every OPEN issue in every OPEN milestone plus OPEN `review`-labeled
|
||||
issues; do not limit the pool to tracker prose or recent comments. Treat an umbrella/epic as a
|
||||
container rather than a pickup when #237 names its eligible children. ALSO list
|
||||
discovery is a bounded cascade, not a full inventory: query **arc → OPEN issues assigned to OPEN
|
||||
milestones → open `review` → unmilestoned/unreviewed `priority: high` → `priority: medium` →
|
||||
`priority: low`**, carrying eligible unique results forward until the shortlist contains five issues
|
||||
or all tiers are exhausted. Milestone records are tier metadata, NEVER pickup candidates; an OPEN
|
||||
milestone with zero eligible OPEN issues contributes zero candidates.
|
||||
This priority-label fall-through is mandatory: an empty arc/milestone/review pool is never evidence
|
||||
that the queue is empty. Each priority tier queries all OPEN `timothy/ersatztv` issues carrying that
|
||||
label, not only IDs mentioned in #237 or recent comments, and excludes pull requests. Treat an
|
||||
umbrella/epic as a container rather than a pickup when #237 names its eligible children. In the
|
||||
review tier, ALSO list
|
||||
open `ersatztv`-labeled issues in **timothy/adversarial-reviewer** — unclaimed audits there are
|
||||
pickup candidates too (read-only, parallel-safe; see the tracker's "Pending adversarial reviews"
|
||||
section). Reviewer audits are claimed by comment: a claim remains active until a later comment
|
||||
@@ -120,23 +140,29 @@ Then work the queue:
|
||||
print them):
|
||||
`curl -u <user>:<pass> http://192.168.1.95:3000/api/v1/repos/timothy/ersatztv/issues/237`.
|
||||
**The authoritative pickup + ranking protocol lives in #237's "Session protocol" section — this is a summary; if the two ever disagree, #237 wins.**
|
||||
2. The selector picks the highest-ranked OPEN, un-`in-progress` candidate and returns at most two
|
||||
fallbacks. **Rank by labels, not just arc position** (labels are live state; prose is not):
|
||||
(1) arc order in #237, then
|
||||
(2) **milestone/review tier vs backlog** — an item with the `review` label OR membership in an OPEN
|
||||
milestone outranks anything unmilestoned; then (3) **`priority:`** label — `high` > `medium` >
|
||||
`low` within a tier. Pick order across the three pools: the **arc frontier** (lowest-numbered open
|
||||
arc item) first; an unclaimed Blocker/High **priority-pickup** (`review` + `priority: high`, e.g.
|
||||
#253) beats a *non-frontier* arc item; reviewer-repo **audits** are read-only and run in parallel.
|
||||
2. The selector returns the highest-ranked eligible candidate plus up to four fallbacks. Apply this
|
||||
strict tier order: **(1)** open arc items in #237 arc order; **(2)** OPEN issues assigned to an OPEN
|
||||
milestone;
|
||||
**(3)** OPEN `review`-labeled issues and eligible reviewer-repo audits; **(4)** remaining
|
||||
unmilestoned/unreviewed `priority: high` issues; **(5)** `priority: medium`; **(6)** `priority: low`.
|
||||
Within milestone and review tiers, order by `priority: high` > `medium` > `low`; for otherwise equal
|
||||
candidates preserve #237's explicit eligible order, then use lowest issue number. At every tier exclude
|
||||
pull requests, CLOSED issues, `in-progress` claims, `parked` issues, issues blocked by a CLOSED milestone,
|
||||
containers whose children are the pickups, and reviewer audits that are already claimed or have a posted
|
||||
deliverable. `parked` means excluded from every automatic tier; work it only when the user explicitly
|
||||
names it.
|
||||
Query the next tier only while fewer than five eligible unique candidates have been collected; do not
|
||||
enumerate the remainder after the shortlist is full.
|
||||
The orchestrator then makes one focused Gitea read to confirm the proposed winner is still OPEN,
|
||||
unclaimed, and not blocked by a closed milestone; if it changed, check the next supplied fallback.
|
||||
**An empty arc frontier is not a stopping condition.** If the selector returns any eligible
|
||||
candidate, claim its top-ranked winner; do not ask the user to choose merely because candidates
|
||||
belong to different workstreams.
|
||||
Do not reread the full tracker or comments for selection. If the prose says "recommended next / now
|
||||
unblocked" but the issue (or its milestone) is already CLOSED, it is done — skip it and fix the
|
||||
stale line in your session comment. Prose lags live state; live state wins; milestone + `priority:`
|
||||
labels decide gate-vs-backlog, not the prose. For an equal milestone/review tier and equal priority,
|
||||
preserve #237's explicit eligible order, then use lowest issue number as the deterministic fallback;
|
||||
never invent a fix-size, recency, or perceived-relevance tiebreaker. (This mirrors #237's
|
||||
Session-protocol ranking — #237 is canonical.)
|
||||
stale line in your session comment. Prose lags live state; live state wins; tier + `priority:` labels
|
||||
decide order, not the prose. Never invent a fix-size, recency, or perceived-relevance tiebreaker.
|
||||
(This mirrors #237's Session-protocol ranking — #237 is canonical.)
|
||||
3. **Claim it**: add the `in-progress` label + a "claiming" comment on the issue(s);
|
||||
reviewer-repo audits are claimed by comment only. Treat that claim as live until a later comment
|
||||
explicitly releases or abandons it, and exclude audits with a posted deliverable even while the
|
||||
|
||||
+19
-7
@@ -104,7 +104,7 @@ This is therefore **not a code bug in the M3U/XMLTV generators**. The fix is ope
|
||||
|
||||
### Architecture: absolute-URL generation (relevant for future work)
|
||||
|
||||
**ErsatzTV has no "advertised/public/base URL" setting.** Every absolute URL it emits is derived from the *incoming request's* `Scheme` + `Host` + `PathBase`:
|
||||
**ErsatzTV now has an optional "advertised base URL" setting for IPTV (`iptv.base_url`, #340 — see "IPTV base URL" below).** When it is **unset** (the default), every absolute URL ErsatzTV emits is derived from the *incoming request's* `Scheme` + `Host` + `PathBase`, exactly as described below; when it is **set**, the two IPTV generators (M3U + XMLTV) use the configured scheme/host/base instead:
|
||||
|
||||
| Output | Source of host |
|
||||
|--------|----------------|
|
||||
@@ -113,8 +113,8 @@ This is therefore **not a code bug in the M3U/XMLTV generators**. The fix is ope
|
||||
| FFmpeg watermark overlay logo | `ChannelLogoGenerator.GenerateChannelLogoUrl()` — hardcoded `http://localhost:{StreamingPort}` (correct: FFmpeg runs in-container). **Not** part of M3U/XMLTV. |
|
||||
|
||||
Implications for future work:
|
||||
- Any client that connects via a different host (localhost, a reverse-proxy alias, split-DNS name) gets that host echoed back into stream + logo + artwork URLs. This affects the planned REST API (#2) and any reverse-proxy fronting (server-management) too.
|
||||
- **Settings are stored in the `ConfigElement` table as simple key/value** (`ConfigElementKey` in `ErsatzTV.Core/Domain/ConfigElementKey.cs`; read/write via `IConfigElementRepository.GetValue<T>` / upsert). Adding a new setting needs **no EF migration**.
|
||||
- With `iptv.base_url` **unset**, any client that connects via a different host (localhost, a reverse-proxy alias, split-DNS name) still gets that host echoed back into stream + logo + artwork URLs. Setting `iptv.base_url` pins the M3U + XMLTV URLs to a fixed public origin regardless of the request `Host`, which is the mitigation for a reverse-proxy / split-DNS deployment (server-management). The HDHomeRun lineup URLs are **not** covered by `iptv.base_url` (deliberately out of scope for #340) and still echo the request host.
|
||||
- **Settings are stored in the `ConfigElement` table as simple key/value** (`ConfigElementKey` in `ErsatzTV.Core/Domain/ConfigElementKey.cs`; read/write via `IConfigElementRepository.GetValue<T>` / upsert). Adding a new setting needs **no EF migration** — this is exactly how `iptv.base_url` (`ConfigElementKey.IptvBaseUrl`) was added.
|
||||
|
||||
### Resolution (2026-06-27) — Gitea #1 closed as config/topology, no code change
|
||||
|
||||
@@ -138,13 +138,25 @@ Evidence gathered (all read-only):
|
||||
|
||||
> Note — the forwarded-headers theory (`KnownProxies.Clear()` in `Startup.cs` making `X-Forwarded-*` be *ignored*) is **incorrect**: clearing *both* `KnownProxies` and `KnownNetworks` sets `checkKnownIps = false`, which *trusts all* proxies, not none. And there is no proxy between Jellyfin and ErsatzTV. Red herring.
|
||||
|
||||
### Latent fragility (not currently broken)
|
||||
### Latent fragility (not currently broken) — now configurable via `iptv.base_url` (#340)
|
||||
|
||||
The EPG Jellyfin consumes still contains 3095 absolute `http://ersatztv:8409/...` programme-icon URLs. They work **only** because Dispatcharr fetches ErsatzTV with a Jellyfin-resolvable host, and Jellyfin shares the Docker network. ErsatzTV still has no advertised/base-URL setting — every absolute URL is hostage to the request `Host`. If a client ever fetches ErsatzTV with a host that downstream consumers can't resolve, the same class of breakage returns.
|
||||
The EPG Jellyfin consumes still contains 3095 absolute `http://ersatztv:8409/...` programme-icon URLs. They work **only** because Dispatcharr fetches ErsatzTV with a Jellyfin-resolvable host, and Jellyfin shares the Docker network. By default every absolute URL is still hostage to the request `Host`, so if a client ever fetches ErsatzTV with a host that downstream consumers can't resolve, the same class of breakage returns.
|
||||
|
||||
### Optional future hardening (NOT committed)
|
||||
**The mitigation is now built-in:** set the IPTV base URL (`iptv.base_url`, see below) to a fixed public origin and the M3U + XMLTV URLs stop depending on the request `Host` entirely. It stays **opt-in** — unset behaviour is byte-for-byte the request-derived behaviour described above, so nothing changes for a deployment that doesn't need it.
|
||||
|
||||
If that fragility is ever worth removing: add an optional `iptv.base_url` ConfigElement (key/value, **no EF migration**) + a "Base URL" settings field; when set, `GetChannelPlaylistHandler` / `GetChannelGuideHandler` parse it into scheme/host/base instead of the request values; when blank, behaviour is unchanged. Cover with unit tests on `ChannelPlaylist.ToM3U()`. Deliberately **not** built for #1 — the problem no longer manifests, so this stays a documented option rather than committed work.
|
||||
### IPTV base URL (#340)
|
||||
|
||||
Originally logged here as "optional future hardening (NOT committed)"; **implemented in #340**. An optional advertised base URL for the IPTV surface:
|
||||
|
||||
- **Config**: stored as a single `ConfigElement` under `ConfigElementKey.IptvBaseUrl` (key `iptv.base_url`, key/value, **no EF migration**). Distinct from the `ETV_BASE_URL` environment variable, which only sets the ASP.NET Core `PathBase` (request routing) and does not advertise a host.
|
||||
- **Helper**: a pure Core helper, `ErsatzTV.Core/Iptv/AdvertisedBaseUrl.cs`.
|
||||
- `TryParse(string)` validates the configured value and returns `Option<(scheme, host, baseUrl)>` — it must be an **absolute http(s)** URL with **no credentials, query, or fragment**; the **port** and any **path prefix** are preserved, and a trailing slash is normalized off. Anything that fails these rules parses to `None`.
|
||||
- `Resolve(configured, requestScheme, requestHost, requestBaseUrl)` returns the parsed override when the configured value is present and valid, and otherwise **falls back to the request-derived** scheme/host/base. So a blank or malformed value is transparently ignored.
|
||||
- **Where it's applied**: resolution happens **inside the two generation handlers**, not in the controller — `GetChannelPlaylistHandler` (M3U guide/logo/stream URLs, via `ChannelPlaylist`) and `GetChannelGuideHandler` (both XMLTV `{RequestBase}` substitution sites). Because unset/invalid falls back to the request values, the golden tests (`ChannelPlaylistGoldenTests`, `ChannelGuideGoldenTests`) are unchanged.
|
||||
- **Scope**: M3U + XMLTV only. Deliberately **NOT** applied to the HDHomeRun lineup URLs (out of scope for #340).
|
||||
- **API + UI**: managed through a new "IPTV" settings group — `GET`/`PUT /api/v1/settings/iptv` on `SettingsController` (tier `[RequiresAuthentication]`), body `{ baseUrl }`. A **blank** value clears the key; a **non-blank malformed** value → **422**. A new "IPTV" section on the SPA Settings screen edits it.
|
||||
|
||||
Covered by unit tests on `AdvertisedBaseUrl` (parse/validate/resolve) plus the unchanged M3U/XMLTV goldens proving unset = today's output.
|
||||
|
||||
### Historical Workaround (pre-Dispatcharr)
|
||||
|
||||
|
||||
+41
-19
@@ -12,22 +12,34 @@ Companion to `api-conventions.md` (the API surface the SPA talks to) and `docs/c
|
||||
Vite + React + TypeScript, builds to `ErsatzTV/wwwroot/app` (see `web/vite.config.ts`:
|
||||
`base: '/app/'`, `build.outDir: '../ErsatzTV/wwwroot/app'`), served by the ASP.NET host at `/app`.
|
||||
|
||||
- **Routes + nav**: `web/src/App.tsx` — one big route table of `ScreenRoute` objects (`path`,
|
||||
`label`, `title`, `kicker`, `icon`, etc.) plus an `allowSubPaths?: boolean` flag.
|
||||
- **Composition root**: `web/src/App.tsx` — owns the active route, approved Libraries sub-path,
|
||||
navigation guard integration, and theme; it composes the shell with the matched screen.
|
||||
- **Routes + nav**: `web/src/app/routes.tsx` — the ONE stable module-level array of `ScreenRoute`
|
||||
objects (`path`, `label`, `title`, `kicker`, `icon`, etc.) plus `allowSubPaths?: boolean`, the
|
||||
matcher, and sidebar group definitions. Route objects must never be rebuilt per render.
|
||||
- **Shell + screen dispatch**: `web/src/app/AppShell.tsx` owns Sidebar/TopBar/Connect/version/theme
|
||||
chrome; `web/src/app/ScreenContent.tsx` exhaustively maps a matched route to its screen and owns
|
||||
the Media/Libraries route wrappers.
|
||||
- **Screens**: `web/src/screens/*.tsx`, one file per top-level screen, generally with a colocated
|
||||
`*.test.tsx`.
|
||||
- **API clients**: `web/src/api/<domain>.ts` (see §4).
|
||||
- **Styling**: `web/src/shell.css` (+ `web/src/components/components.css`) — utility classes with a
|
||||
`ctv-` prefix (~690 occurrences across those two files). Reuse an existing `ctv-*` class before
|
||||
inventing a new one.
|
||||
inventing a new one. `shell.css` carries the only base reset — `html, body { margin: 0 }` plus
|
||||
`body { background: var(--surface-app) }` (the 8px default body margin otherwise frames every
|
||||
full-viewport layout with a light border, #373). **There is no global `box-sizing` reset**
|
||||
(the SPA is authored under the default `content-box`), so any element that combines `width: 100%`
|
||||
with padding/border must set `box-sizing: border-box` locally or it overflows its container — e.g.
|
||||
`.ctv-nav-item` (#377). Prefer `width: auto` (shrink-to-fit) over `width: 100%` + padding where you can.
|
||||
|
||||
## 2. CRITICAL: sub-path screens must own their own pathname state
|
||||
|
||||
If a route sets `allowSubPaths: true` (e.g. so `/app/blocks/{id}` works under the `/app/blocks` nav
|
||||
entry), **the screen component itself must track `window.location.pathname` and listen for
|
||||
`popstate`** — do not rely on `App.tsx` re-rendering `ScreenContent` when the sub-path changes.
|
||||
`popstate`** — do not rely on the composition root re-rendering `ScreenContent` when the sub-path
|
||||
changes.
|
||||
|
||||
**Why**: `App.tsx`'s `routeFromLocation()` matches an `allowSubPaths` route by prefix
|
||||
**Why**: `app/routes.tsx`'s `routeFromLocation()` matches an `allowSubPaths` route by prefix
|
||||
(`pathname.startsWith(\`${route.path}/\`)`) and returns the **same `ScreenRoute` object reference**
|
||||
for the base path and every sub-path under it. `App`'s state update is
|
||||
`setActiveRoute(routeFromLocation())`; React's `useState` setter bails via `Object.is` when the new
|
||||
@@ -46,8 +58,8 @@ Exemplars of screens that already do this correctly: `BlocksScreen.tsx`, `Templa
|
||||
**Exception — a guarded sub-path route defers pathname ownership to App.** When a sub-path route's
|
||||
screens ALSO register a dirty guard (§8), the wrapper must **not** self-listen for `popstate`; App
|
||||
owns the pathname and passes the approved sub-path down as a prop. The `LibrariesRouteScreen` wrapper
|
||||
in `App.tsx` is the exemplar (sub-path parsed by `parseLibrariesSubRoute`, dispatched via a flat
|
||||
`switch`, sub-path supplied by App's `librariesSubPath` state). See §8 for why child-before-parent
|
||||
in `app/ScreenContent.tsx` is the exemplar (sub-path parsed by `parseLibrariesSubRoute`, dispatched
|
||||
via a flat `switch`, sub-path supplied by App's `librariesSubPath` state). See §8 for why child-before-parent
|
||||
effect order makes the self-listening pattern unsafe here.
|
||||
|
||||
## 3. Data loading pattern
|
||||
@@ -251,16 +263,17 @@ is gone. The SPA seams now are:
|
||||
- **vitest**, colocated `*.test.ts` / `*.test.tsx` next to the source file.
|
||||
- Every screen with meaningful logic gets a screen test; every API client module gets a
|
||||
param-mapping / URL-building test (e.g. `logs.test.ts` next to `logs.ts`).
|
||||
- `web/src/App.test.tsx` covers navigation + the route table, including regressions like the
|
||||
sub-path bug in §2 (see the tests around `PlayoutsRouteScreen` that click into
|
||||
`/app/playouts/{id}/...` sub-paths and assert the correct sub-screen rendered).
|
||||
- `web/src/App.test.tsx` covers composition, navigation, shell behavior, and integration regressions
|
||||
such as sub-path-to-sub-path rendering and guarded popstate restoration. Detailed screen behavior
|
||||
belongs in the screen's colocated test. `web/src/app/routes.test.tsx` independently pins stable
|
||||
route-object identity and query/path matching.
|
||||
- **Nav-label test-selector care**: `getByRole('link'/'button', { name: /Regex/ })` matches by
|
||||
substring by default — a loose regex can match more than one nav item. Verified example: the
|
||||
System nav button is matched with an **anchored** regex (`name: /^System/`) rather than a bare
|
||||
`/System/`, specifically to avoid ambiguous matches against other labels that start with or
|
||||
contain "System". Anchor (`^`/`$`) or use exact strings in `getByRole` name matchers whenever a
|
||||
new label could be a substring of (or share a substring with) an existing one — check
|
||||
`App.tsx`'s nav `label:` list for collisions before picking a new label.
|
||||
`app/routes.tsx`'s nav `label:` list for collisions before picking a new label.
|
||||
- **Extracted-screen tests own their own fetch mock** (Schedules #207, Channels #244, Playouts
|
||||
#245): when a
|
||||
screen is pulled out of `App.tsx` into `web/src/screens/<Name>Screen.tsx`, its colocated
|
||||
@@ -356,21 +369,30 @@ Keep the guard predicate reading a **ref** (`dirtyRef`), not the `dirty` state v
|
||||
|
||||
## 10. TopBar primary-action button (`usePrimaryAction`)
|
||||
|
||||
The shell TopBar (`App.tsx`) renders at most **one** primary-action button (top-right, Plus icon) for the
|
||||
active screen. The TopBar has no reference to the screen component, so the click is delivered as a window
|
||||
`CustomEvent` keyed on the active route id; the seam is `web/src/primaryAction.ts`:
|
||||
The shell TopBar (`app/AppShell.tsx`) renders at most **one** primary-action button (top-right, Plus
|
||||
icon) for the active screen. `App.tsx` wraps the shell and matched screen in the minimal
|
||||
`PrimaryActionProvider` from `web/src/primaryAction.ts`: the active screen explicitly registers one
|
||||
handler, and the TopBar reads that matching registration directly from React context. No window event,
|
||||
string-keyed dispatcher, or generic screen-action framework participates in normal screen actions.
|
||||
|
||||
- **A screen opts in** by calling `usePrimaryAction(routeId, handler)` at the **top of the component, before
|
||||
any early return** (it's a hook). The handler must be reachable there — a create/navigate handler or a
|
||||
`useState` setter, not something defined below a loading/error `return`. Reference: `SchedulesScreen`
|
||||
(`usePrimaryAction('schedules', () => setForm('create'))`).
|
||||
- **The route must ALSO declare a matching non-empty `primaryAction` label** in the `routes` table. The
|
||||
TopBar renders the button **only when `route.primaryAction` is non-empty** — so declaring a label without
|
||||
subscribing renders a dead button, and subscribing without a label renders nothing. Keep the two in lockstep.
|
||||
- **The provider owns exactly one registration.** Each mounted hook gets an opaque owner token; registering a
|
||||
newer screen replaces the previous registration, and cleanup clears only the registration owned by that
|
||||
hook. A stale unmount therefore cannot erase the newer screen's action. The hook keeps the latest handler in
|
||||
a ref, so ordinary screen renders update behavior without reclaiming/churning ownership. Directly-rendered
|
||||
screen tests outside the provider remain harmless; there is simply no shell action consumer.
|
||||
- **The route must ALSO declare a matching non-empty `primaryAction` label** in the stable
|
||||
`app/routes.tsx` table. The TopBar renders the button only when that label is non-empty **and** the active
|
||||
route owns a matching registration — metadata alone can no longer produce a dead button, while a
|
||||
registration without a label remains intentionally invisible. Keep the two in lockstep.
|
||||
The `#238` tests in `App.test.tsx` guard this: a data-driven `it.each` asserts each URL-navigating create
|
||||
screen's banner actually navigates (so a typo'd route id → a button that navigates nowhere → red), plus a
|
||||
drop test that an action screen shows no banner button. The dialog/editor create screens (schedules, multi/
|
||||
rerun collections, trakt) are exercised by their own create-flow tests.
|
||||
drop test that an action screen shows no banner button. `primaryAction.test.tsx` separately pins matching,
|
||||
latest-handler, route-change, cleanup, and stale-owner semantics. The dialog/editor create screens
|
||||
(schedules, multi/rerun collections, trakt) are exercised by their own create-flow tests.
|
||||
- **When to WIRE vs DROP (issue #238).** The Plus icon makes the button semantically a *"create new item"*
|
||||
affordance. **Keep + wire it only on list screens with a single, unambiguous create flow** ("Add Channel",
|
||||
"Add Schedule", "Add Multi-Collection", "Add Rerun Collection", "Add Trakt List", "Add Filler Preset", "Add
|
||||
|
||||
@@ -2,16 +2,16 @@
|
||||
|
||||
**Date:** 2026-07-02
|
||||
**Issue:** [ersatztv#97 - Backend: channel on-air / now-playing state API](http://192.168.1.95:3000/timothy/ersatztv/issues/97)
|
||||
**Status:** Approved for implementation planning.
|
||||
**Status:** Implemented by #97; universal streaming-mode coverage completed by #99.
|
||||
|
||||
## Context
|
||||
|
||||
The ChicoryTV Channels prototype needs runtime channel state for the **On-air** filter, live row treatment, and **Now playing** lane. The existing `GET /api/channels` endpoint returns static channel configuration and should stay cheap. Runtime state changes with wall-clock time and active streaming sessions, so it belongs in a separate pollable endpoint.
|
||||
The ChicoryTV Channels prototype needs runtime channel state for the **On-air** filter, live row treatment, and **Now playing** lane. The existing `GET /api/v1/channels` endpoint returns static channel configuration and should stay cheap. Runtime state changes with wall-clock time and active streaming sessions, so it belongs in a separate pollable endpoint.
|
||||
|
||||
ErsatzTV has two distinct concepts:
|
||||
|
||||
- The playout schedule is clock-based and exists independent of viewers.
|
||||
- A stream session exists only while a client is actively pulling a segmenter-backed stream.
|
||||
- A stream session exists only while a client is actively pulling a stream.
|
||||
|
||||
The API should expose both concepts separately rather than blending them into one ambiguous status.
|
||||
|
||||
@@ -20,7 +20,7 @@ The API should expose both concepts separately rather than blending them into on
|
||||
Add a batched endpoint:
|
||||
|
||||
```http
|
||||
GET /api/channels/state
|
||||
GET /api/v1/channels/state
|
||||
```
|
||||
|
||||
Response:
|
||||
@@ -48,9 +48,9 @@ Response:
|
||||
|
||||
Fields:
|
||||
|
||||
- `channelId`: channel database id for joining with `GET /api/channels` rows.
|
||||
- `channelId`: channel database id for joining with `GET /api/v1/channels` rows.
|
||||
- `channelNumber`: stable display/routing number, also useful for active session checks.
|
||||
- `onAir`: `true` only when the channel has an active segmenter session.
|
||||
- `onAir`: `true` when the channel has an active segmenter-backed or direct stream session.
|
||||
- `nowPlaying`: current scheduled `PlayoutItem` by wall-clock, or `null`.
|
||||
- `nowPlaying.title`: display title from the existing playout title mapper.
|
||||
- `nowPlaying.startUtc` / `finishUtc`: UTC item bounds. The SPA computes progress percentage client-side from these values.
|
||||
@@ -61,18 +61,22 @@ The endpoint returns one row per channel so the SPA can poll the whole grid with
|
||||
|
||||
`nowPlaying` means "what is scheduled right now." It is populated for any channel with a built playout item covering the current wall-clock time, even when `onAir` is false.
|
||||
|
||||
`onAir` initially means "has an active HLS segmenter session" using `IFFmpegSegmenterService.IsActive(channelNumber)`. This matches the current `/api/sessions` semantics because the only session registry in the codebase is `IFFmpegSegmenterService.Workers`.
|
||||
`onAir` means "has an active stream session." It combines
|
||||
`IFFmpegSegmenterService.IsActive(channelNumber)` for segmenter-backed modes with
|
||||
`IDirectStreamSessionTracker.IsActive(channelNumber)` for direct modes.
|
||||
|
||||
Known `onAir` coverage:
|
||||
|
||||
| Streaming mode | Covered by `IsActive` |
|
||||
| Streaming mode | Activity source |
|
||||
| --- | --- |
|
||||
| `HttpLiveStreamingSegmenter` | Yes |
|
||||
| `TransportStreamHybrid` | Yes |
|
||||
| `TransportStream` / MPEG-TS direct | No |
|
||||
| `HttpLiveStreamingDirect` | No |
|
||||
| `HttpLiveStreamingSegmenter` | `IFFmpegSegmenterService` |
|
||||
| `TransportStreamHybrid` | `IFFmpegSegmenterService` |
|
||||
| `TransportStream` / MPEG-TS direct | `IDirectStreamSessionTracker` |
|
||||
| `HttpLiveStreamingDirect` | `IDirectStreamSessionTracker` |
|
||||
|
||||
The direct streaming modes can be actively streaming while `onAir` reports false because they do not register a segmenter worker. That limitation is documented here and tracked as follow-up [#99](http://192.168.1.95:3000/timothy/ersatztv/issues/99). Issue #97 should not instrument the streaming hot path.
|
||||
The direct-stream tracking seam landed in #99 without changing this endpoint's response contract. Direct
|
||||
registrations are created only while the response body is executing and are removed on completion,
|
||||
disconnect, or error; HEAD probes do not count as viewers.
|
||||
|
||||
## Null And Degraded States
|
||||
|
||||
@@ -86,7 +90,7 @@ When no runtime data is available, the response should degrade to `onAir: false`
|
||||
|
||||
`nowPlaying` is also `null` when the current playout item is filler with no program item in its guide group (for example a channel looping fallback filler). When the current item is filler *inside* a program's guide group (pre/mid-roll), the endpoint surfaces the program — matching the XMLTV guide — not the filler.
|
||||
|
||||
Known imprecision: OnDemand channels report `nowPlaying` from stored playout items, which are only re-anchored when a viewer tunes in. While such a channel sits idle, the reported item and progress drift from what a new viewer will actually see. This is inherent to the stored data and accepted, like the #99 `onAir` caveat.
|
||||
Known imprecision: OnDemand channels report `nowPlaying` from stored playout items, which are only re-anchored when a viewer tunes in. While such a channel sits idle, the reported item and progress drift from what a new viewer will actually see. This is inherent to the stored data and accepted.
|
||||
|
||||
## Implementation Shape
|
||||
|
||||
@@ -98,7 +102,8 @@ The handler should:
|
||||
2. Query current playout items in one batched database query using the current UTC time.
|
||||
3. Include enough media metadata to map the item title with the existing `ErsatzTV.Application.Playouts.Mapper.GetDisplayTitle` behavior.
|
||||
4. Join the current items back to channels in memory.
|
||||
5. Ask `IFFmpegSegmenterService.IsActive(channel.Number)` for each channel's `onAir` value.
|
||||
5. Ask both `IFFmpegSegmenterService.IsActive(channel.Number)` and
|
||||
`IDirectStreamSessionTracker.IsActive(channel.Number)` for each channel's `onAir` value.
|
||||
|
||||
Avoid an N+1 database query. The endpoint is intended for frequent table polling.
|
||||
|
||||
@@ -108,19 +113,21 @@ No database migration is required because the endpoint reads existing channel, p
|
||||
|
||||
If #68 introduces "always playing" channels by holding a segmenter-backed session, `onAir` will continue to work without an API contract change. If that feature uses a different runtime mechanism, revisit the `onAir` implementation behind the same response field.
|
||||
|
||||
Once #99 adds active-session tracking for MPEG-TS and HLS-Direct paths, `onAir` can become universal without changing the endpoint contract.
|
||||
#99 added active-session tracking for MPEG-TS and HLS-Direct paths, making `onAir` universal without changing
|
||||
the endpoint contract.
|
||||
|
||||
## Testing
|
||||
|
||||
Controller tests should verify:
|
||||
|
||||
- `GET /api/channels/state` routes through MediatR and returns `200 OK`.
|
||||
- `GET /api/v1/channels/state` routes through MediatR and returns `200 OK`.
|
||||
- The endpoint advertises a `List<ChannelStateResponseModel>` response.
|
||||
|
||||
Application handler tests should verify:
|
||||
|
||||
- A channel with an active segmenter session returns `onAir: true`.
|
||||
- A channel with no active segmenter session returns `onAir: false`.
|
||||
- A channel with an active direct stream session returns `onAir: true`.
|
||||
- A channel with neither kind of active session returns `onAir: false`.
|
||||
- A current playout item returns `nowPlaying` with title, `startUtc`, and `finishUtc`.
|
||||
- A channel without a current item returns `nowPlaying: null`.
|
||||
- Multiple channels are resolved through one handler call without per-channel playout lookups.
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
# .NET 10 SDK analyzer baseline for ersatztv#15.
|
||||
#
|
||||
# AnalysisLevel=latest-All enables the complete SDK rule inventory, whose generated
|
||||
# per-rule severities otherwise override .editorconfig's bulk suggestion setting.
|
||||
# This higher-priority snapshot keeps every current .NET 10 rule visible but non-blocking.
|
||||
# Preserve SDK 'none' entries for rules introduced after .NET 10. On an SDK major upgrade,
|
||||
# regenerate this list from analysislevel_<major>_all.globalconfig and review new rules.
|
||||
# A per-rule severity in the repository .editorconfig overrides this baseline.
|
||||
is_global = true
|
||||
global_level = -50
|
||||
|
||||
dotnet_diagnostic.CA1000.severity = suggestion
|
||||
dotnet_diagnostic.CA1001.severity = suggestion
|
||||
dotnet_diagnostic.CA1002.severity = suggestion
|
||||
dotnet_diagnostic.CA1003.severity = suggestion
|
||||
dotnet_diagnostic.CA1008.severity = suggestion
|
||||
dotnet_diagnostic.CA1010.severity = suggestion
|
||||
dotnet_diagnostic.CA1012.severity = suggestion
|
||||
dotnet_diagnostic.CA1016.severity = suggestion
|
||||
dotnet_diagnostic.CA1018.severity = suggestion
|
||||
dotnet_diagnostic.CA1019.severity = suggestion
|
||||
dotnet_diagnostic.CA1024.severity = suggestion
|
||||
dotnet_diagnostic.CA1027.severity = suggestion
|
||||
dotnet_diagnostic.CA1028.severity = suggestion
|
||||
dotnet_diagnostic.CA1030.severity = suggestion
|
||||
dotnet_diagnostic.CA1031.severity = suggestion
|
||||
dotnet_diagnostic.CA1032.severity = suggestion
|
||||
dotnet_diagnostic.CA1033.severity = suggestion
|
||||
dotnet_diagnostic.CA1034.severity = suggestion
|
||||
dotnet_diagnostic.CA1036.severity = suggestion
|
||||
dotnet_diagnostic.CA1040.severity = suggestion
|
||||
dotnet_diagnostic.CA1041.severity = suggestion
|
||||
dotnet_diagnostic.CA1043.severity = suggestion
|
||||
dotnet_diagnostic.CA1044.severity = suggestion
|
||||
dotnet_diagnostic.CA1046.severity = suggestion
|
||||
dotnet_diagnostic.CA1047.severity = suggestion
|
||||
dotnet_diagnostic.CA1050.severity = suggestion
|
||||
dotnet_diagnostic.CA1051.severity = suggestion
|
||||
dotnet_diagnostic.CA1052.severity = suggestion
|
||||
dotnet_diagnostic.CA1054.severity = suggestion
|
||||
dotnet_diagnostic.CA1055.severity = suggestion
|
||||
dotnet_diagnostic.CA1056.severity = suggestion
|
||||
dotnet_diagnostic.CA1058.severity = suggestion
|
||||
dotnet_diagnostic.CA1061.severity = suggestion
|
||||
dotnet_diagnostic.CA1062.severity = suggestion
|
||||
dotnet_diagnostic.CA1063.severity = suggestion
|
||||
dotnet_diagnostic.CA1064.severity = suggestion
|
||||
dotnet_diagnostic.CA1065.severity = suggestion
|
||||
dotnet_diagnostic.CA1066.severity = suggestion
|
||||
dotnet_diagnostic.CA1067.severity = suggestion
|
||||
dotnet_diagnostic.CA1068.severity = suggestion
|
||||
dotnet_diagnostic.CA1069.severity = suggestion
|
||||
dotnet_diagnostic.CA1070.severity = suggestion
|
||||
dotnet_diagnostic.CA1200.severity = suggestion
|
||||
dotnet_diagnostic.CA1303.severity = suggestion
|
||||
dotnet_diagnostic.CA1304.severity = suggestion
|
||||
dotnet_diagnostic.CA1305.severity = suggestion
|
||||
dotnet_diagnostic.CA1307.severity = suggestion
|
||||
dotnet_diagnostic.CA1308.severity = suggestion
|
||||
dotnet_diagnostic.CA1309.severity = suggestion
|
||||
dotnet_diagnostic.CA1310.severity = suggestion
|
||||
dotnet_diagnostic.CA1311.severity = suggestion
|
||||
dotnet_diagnostic.CA1401.severity = suggestion
|
||||
dotnet_diagnostic.CA1419.severity = suggestion
|
||||
dotnet_diagnostic.CA1421.severity = suggestion
|
||||
dotnet_diagnostic.CA1507.severity = suggestion
|
||||
dotnet_diagnostic.CA1508.severity = suggestion
|
||||
dotnet_diagnostic.CA1510.severity = suggestion
|
||||
dotnet_diagnostic.CA1511.severity = suggestion
|
||||
dotnet_diagnostic.CA1512.severity = suggestion
|
||||
dotnet_diagnostic.CA1513.severity = suggestion
|
||||
dotnet_diagnostic.CA1514.severity = suggestion
|
||||
dotnet_diagnostic.CA1515.severity = suggestion
|
||||
dotnet_diagnostic.CA1516.severity = none
|
||||
dotnet_diagnostic.CA1700.severity = suggestion
|
||||
dotnet_diagnostic.CA1707.severity = suggestion
|
||||
dotnet_diagnostic.CA1708.severity = suggestion
|
||||
dotnet_diagnostic.CA1710.severity = suggestion
|
||||
dotnet_diagnostic.CA1711.severity = suggestion
|
||||
dotnet_diagnostic.CA1712.severity = suggestion
|
||||
dotnet_diagnostic.CA1713.severity = suggestion
|
||||
dotnet_diagnostic.CA1715.severity = suggestion
|
||||
dotnet_diagnostic.CA1716.severity = suggestion
|
||||
dotnet_diagnostic.CA1720.severity = suggestion
|
||||
dotnet_diagnostic.CA1721.severity = suggestion
|
||||
dotnet_diagnostic.CA1724.severity = suggestion
|
||||
dotnet_diagnostic.CA1725.severity = suggestion
|
||||
dotnet_diagnostic.CA1727.severity = suggestion
|
||||
dotnet_diagnostic.CA1802.severity = suggestion
|
||||
dotnet_diagnostic.CA1805.severity = suggestion
|
||||
dotnet_diagnostic.CA1806.severity = suggestion
|
||||
dotnet_diagnostic.CA1810.severity = suggestion
|
||||
dotnet_diagnostic.CA1812.severity = suggestion
|
||||
dotnet_diagnostic.CA1813.severity = suggestion
|
||||
dotnet_diagnostic.CA1814.severity = suggestion
|
||||
dotnet_diagnostic.CA1815.severity = suggestion
|
||||
dotnet_diagnostic.CA1816.severity = suggestion
|
||||
dotnet_diagnostic.CA1819.severity = suggestion
|
||||
dotnet_diagnostic.CA1820.severity = suggestion
|
||||
dotnet_diagnostic.CA1821.severity = suggestion
|
||||
dotnet_diagnostic.CA1822.severity = suggestion
|
||||
dotnet_diagnostic.CA1823.severity = suggestion
|
||||
dotnet_diagnostic.CA1824.severity = suggestion
|
||||
dotnet_diagnostic.CA1825.severity = suggestion
|
||||
dotnet_diagnostic.CA1826.severity = suggestion
|
||||
dotnet_diagnostic.CA1827.severity = suggestion
|
||||
dotnet_diagnostic.CA1828.severity = suggestion
|
||||
dotnet_diagnostic.CA1829.severity = suggestion
|
||||
dotnet_diagnostic.CA1830.severity = suggestion
|
||||
dotnet_diagnostic.CA1832.severity = suggestion
|
||||
dotnet_diagnostic.CA1833.severity = suggestion
|
||||
dotnet_diagnostic.CA1834.severity = suggestion
|
||||
dotnet_diagnostic.CA1835.severity = suggestion
|
||||
dotnet_diagnostic.CA1836.severity = suggestion
|
||||
dotnet_diagnostic.CA1837.severity = suggestion
|
||||
dotnet_diagnostic.CA1838.severity = suggestion
|
||||
dotnet_diagnostic.CA1839.severity = suggestion
|
||||
dotnet_diagnostic.CA1840.severity = suggestion
|
||||
dotnet_diagnostic.CA1841.severity = suggestion
|
||||
dotnet_diagnostic.CA1842.severity = suggestion
|
||||
dotnet_diagnostic.CA1843.severity = suggestion
|
||||
dotnet_diagnostic.CA1844.severity = suggestion
|
||||
dotnet_diagnostic.CA1845.severity = suggestion
|
||||
dotnet_diagnostic.CA1846.severity = suggestion
|
||||
dotnet_diagnostic.CA1847.severity = suggestion
|
||||
dotnet_diagnostic.CA1848.severity = suggestion
|
||||
dotnet_diagnostic.CA1849.severity = suggestion
|
||||
dotnet_diagnostic.CA1850.severity = suggestion
|
||||
dotnet_diagnostic.CA1851.severity = suggestion
|
||||
dotnet_diagnostic.CA1852.severity = suggestion
|
||||
dotnet_diagnostic.CA1853.severity = suggestion
|
||||
dotnet_diagnostic.CA1854.severity = suggestion
|
||||
dotnet_diagnostic.CA1855.severity = suggestion
|
||||
dotnet_diagnostic.CA1856.severity = suggestion
|
||||
dotnet_diagnostic.CA1858.severity = suggestion
|
||||
dotnet_diagnostic.CA1859.severity = suggestion
|
||||
dotnet_diagnostic.CA1860.severity = suggestion
|
||||
dotnet_diagnostic.CA1861.severity = suggestion
|
||||
dotnet_diagnostic.CA1862.severity = suggestion
|
||||
dotnet_diagnostic.CA1863.severity = suggestion
|
||||
dotnet_diagnostic.CA1864.severity = suggestion
|
||||
dotnet_diagnostic.CA1865.severity = suggestion
|
||||
dotnet_diagnostic.CA1866.severity = suggestion
|
||||
dotnet_diagnostic.CA1867.severity = suggestion
|
||||
dotnet_diagnostic.CA1868.severity = suggestion
|
||||
dotnet_diagnostic.CA1869.severity = suggestion
|
||||
dotnet_diagnostic.CA1870.severity = suggestion
|
||||
dotnet_diagnostic.CA1871.severity = suggestion
|
||||
dotnet_diagnostic.CA1872.severity = suggestion
|
||||
dotnet_diagnostic.CA1873.severity = suggestion
|
||||
dotnet_diagnostic.CA1874.severity = suggestion
|
||||
dotnet_diagnostic.CA1875.severity = suggestion
|
||||
dotnet_diagnostic.CA2000.severity = suggestion
|
||||
dotnet_diagnostic.CA2002.severity = suggestion
|
||||
dotnet_diagnostic.CA2007.severity = suggestion
|
||||
dotnet_diagnostic.CA2008.severity = suggestion
|
||||
dotnet_diagnostic.CA2009.severity = suggestion
|
||||
dotnet_diagnostic.CA2011.severity = suggestion
|
||||
dotnet_diagnostic.CA2012.severity = suggestion
|
||||
dotnet_diagnostic.CA2016.severity = suggestion
|
||||
dotnet_diagnostic.CA2019.severity = suggestion
|
||||
dotnet_diagnostic.CA2020.severity = suggestion
|
||||
dotnet_diagnostic.CA2025.severity = suggestion
|
||||
dotnet_diagnostic.CA2100.severity = suggestion
|
||||
dotnet_diagnostic.CA2101.severity = suggestion
|
||||
dotnet_diagnostic.CA2119.severity = suggestion
|
||||
dotnet_diagnostic.CA2153.severity = suggestion
|
||||
dotnet_diagnostic.CA2201.severity = suggestion
|
||||
dotnet_diagnostic.CA2207.severity = suggestion
|
||||
dotnet_diagnostic.CA2208.severity = suggestion
|
||||
dotnet_diagnostic.CA2211.severity = suggestion
|
||||
dotnet_diagnostic.CA2213.severity = suggestion
|
||||
dotnet_diagnostic.CA2214.severity = suggestion
|
||||
dotnet_diagnostic.CA2215.severity = suggestion
|
||||
dotnet_diagnostic.CA2216.severity = suggestion
|
||||
dotnet_diagnostic.CA2217.severity = suggestion
|
||||
dotnet_diagnostic.CA2218.severity = suggestion
|
||||
dotnet_diagnostic.CA2219.severity = suggestion
|
||||
dotnet_diagnostic.CA2224.severity = suggestion
|
||||
dotnet_diagnostic.CA2225.severity = suggestion
|
||||
dotnet_diagnostic.CA2226.severity = suggestion
|
||||
dotnet_diagnostic.CA2227.severity = suggestion
|
||||
dotnet_diagnostic.CA2231.severity = suggestion
|
||||
dotnet_diagnostic.CA2234.severity = suggestion
|
||||
dotnet_diagnostic.CA2235.severity = suggestion
|
||||
dotnet_diagnostic.CA2237.severity = suggestion
|
||||
dotnet_diagnostic.CA2241.severity = suggestion
|
||||
dotnet_diagnostic.CA2242.severity = suggestion
|
||||
dotnet_diagnostic.CA2243.severity = suggestion
|
||||
dotnet_diagnostic.CA2244.severity = suggestion
|
||||
dotnet_diagnostic.CA2245.severity = suggestion
|
||||
dotnet_diagnostic.CA2246.severity = suggestion
|
||||
dotnet_diagnostic.CA2248.severity = suggestion
|
||||
dotnet_diagnostic.CA2249.severity = suggestion
|
||||
dotnet_diagnostic.CA2250.severity = suggestion
|
||||
dotnet_diagnostic.CA2251.severity = suggestion
|
||||
dotnet_diagnostic.CA2252.severity = suggestion
|
||||
dotnet_diagnostic.CA2253.severity = suggestion
|
||||
dotnet_diagnostic.CA2254.severity = suggestion
|
||||
dotnet_diagnostic.CA2262.severity = suggestion
|
||||
dotnet_diagnostic.CA2263.severity = suggestion
|
||||
dotnet_diagnostic.CA2300.severity = suggestion
|
||||
dotnet_diagnostic.CA2301.severity = suggestion
|
||||
dotnet_diagnostic.CA2302.severity = suggestion
|
||||
dotnet_diagnostic.CA2305.severity = suggestion
|
||||
dotnet_diagnostic.CA2310.severity = suggestion
|
||||
dotnet_diagnostic.CA2311.severity = suggestion
|
||||
dotnet_diagnostic.CA2312.severity = suggestion
|
||||
dotnet_diagnostic.CA2315.severity = suggestion
|
||||
dotnet_diagnostic.CA2321.severity = suggestion
|
||||
dotnet_diagnostic.CA2322.severity = suggestion
|
||||
dotnet_diagnostic.CA2326.severity = suggestion
|
||||
dotnet_diagnostic.CA2327.severity = suggestion
|
||||
dotnet_diagnostic.CA2328.severity = suggestion
|
||||
dotnet_diagnostic.CA2329.severity = suggestion
|
||||
dotnet_diagnostic.CA2330.severity = suggestion
|
||||
dotnet_diagnostic.CA2350.severity = suggestion
|
||||
dotnet_diagnostic.CA2351.severity = suggestion
|
||||
dotnet_diagnostic.CA2352.severity = suggestion
|
||||
dotnet_diagnostic.CA2353.severity = suggestion
|
||||
dotnet_diagnostic.CA2354.severity = suggestion
|
||||
dotnet_diagnostic.CA2355.severity = suggestion
|
||||
dotnet_diagnostic.CA2356.severity = suggestion
|
||||
dotnet_diagnostic.CA2361.severity = suggestion
|
||||
dotnet_diagnostic.CA2362.severity = suggestion
|
||||
dotnet_diagnostic.CA3001.severity = suggestion
|
||||
dotnet_diagnostic.CA3002.severity = suggestion
|
||||
dotnet_diagnostic.CA3003.severity = suggestion
|
||||
dotnet_diagnostic.CA3004.severity = suggestion
|
||||
dotnet_diagnostic.CA3005.severity = suggestion
|
||||
dotnet_diagnostic.CA3006.severity = suggestion
|
||||
dotnet_diagnostic.CA3007.severity = suggestion
|
||||
dotnet_diagnostic.CA3008.severity = suggestion
|
||||
dotnet_diagnostic.CA3009.severity = suggestion
|
||||
dotnet_diagnostic.CA3010.severity = suggestion
|
||||
dotnet_diagnostic.CA3011.severity = suggestion
|
||||
dotnet_diagnostic.CA3012.severity = suggestion
|
||||
dotnet_diagnostic.CA3061.severity = suggestion
|
||||
dotnet_diagnostic.CA3075.severity = suggestion
|
||||
dotnet_diagnostic.CA3076.severity = suggestion
|
||||
dotnet_diagnostic.CA3077.severity = suggestion
|
||||
dotnet_diagnostic.CA3147.severity = suggestion
|
||||
dotnet_diagnostic.CA5350.severity = suggestion
|
||||
dotnet_diagnostic.CA5351.severity = suggestion
|
||||
dotnet_diagnostic.CA5358.severity = suggestion
|
||||
dotnet_diagnostic.CA5359.severity = suggestion
|
||||
dotnet_diagnostic.CA5360.severity = suggestion
|
||||
dotnet_diagnostic.CA5361.severity = suggestion
|
||||
dotnet_diagnostic.CA5362.severity = suggestion
|
||||
dotnet_diagnostic.CA5363.severity = suggestion
|
||||
dotnet_diagnostic.CA5364.severity = suggestion
|
||||
dotnet_diagnostic.CA5365.severity = suggestion
|
||||
dotnet_diagnostic.CA5366.severity = suggestion
|
||||
dotnet_diagnostic.CA5367.severity = suggestion
|
||||
dotnet_diagnostic.CA5368.severity = suggestion
|
||||
dotnet_diagnostic.CA5369.severity = suggestion
|
||||
dotnet_diagnostic.CA5370.severity = suggestion
|
||||
dotnet_diagnostic.CA5371.severity = suggestion
|
||||
dotnet_diagnostic.CA5372.severity = suggestion
|
||||
dotnet_diagnostic.CA5373.severity = suggestion
|
||||
dotnet_diagnostic.CA5374.severity = suggestion
|
||||
dotnet_diagnostic.CA5375.severity = suggestion
|
||||
dotnet_diagnostic.CA5376.severity = suggestion
|
||||
dotnet_diagnostic.CA5377.severity = suggestion
|
||||
dotnet_diagnostic.CA5378.severity = suggestion
|
||||
dotnet_diagnostic.CA5379.severity = suggestion
|
||||
dotnet_diagnostic.CA5380.severity = suggestion
|
||||
dotnet_diagnostic.CA5381.severity = suggestion
|
||||
dotnet_diagnostic.CA5382.severity = suggestion
|
||||
dotnet_diagnostic.CA5383.severity = suggestion
|
||||
dotnet_diagnostic.CA5384.severity = suggestion
|
||||
dotnet_diagnostic.CA5385.severity = suggestion
|
||||
dotnet_diagnostic.CA5386.severity = suggestion
|
||||
dotnet_diagnostic.CA5387.severity = suggestion
|
||||
dotnet_diagnostic.CA5388.severity = suggestion
|
||||
dotnet_diagnostic.CA5389.severity = suggestion
|
||||
dotnet_diagnostic.CA5390.severity = suggestion
|
||||
dotnet_diagnostic.CA5391.severity = suggestion
|
||||
dotnet_diagnostic.CA5392.severity = suggestion
|
||||
dotnet_diagnostic.CA5393.severity = suggestion
|
||||
dotnet_diagnostic.CA5394.severity = suggestion
|
||||
dotnet_diagnostic.CA5395.severity = suggestion
|
||||
dotnet_diagnostic.CA5396.severity = suggestion
|
||||
dotnet_diagnostic.CA5397.severity = suggestion
|
||||
dotnet_diagnostic.CA5398.severity = suggestion
|
||||
dotnet_diagnostic.CA5399.severity = suggestion
|
||||
dotnet_diagnostic.CA5400.severity = suggestion
|
||||
dotnet_diagnostic.CA5401.severity = suggestion
|
||||
dotnet_diagnostic.CA5402.severity = suggestion
|
||||
dotnet_diagnostic.CA5403.severity = suggestion
|
||||
dotnet_diagnostic.CA5404.severity = suggestion
|
||||
dotnet_diagnostic.CA5405.severity = suggestion
|
||||
Executable
+217
@@ -0,0 +1,217 @@
|
||||
#!/usr/bin/env bash
|
||||
# scripts/e2e-functional.sh — drive the manual live-E2E functional flows against a RUNNING
|
||||
# ErsatzTV instance and assert their HTTP contracts. This is the codified, automatable half of the
|
||||
# ad-hoc curl scenarios sessions have been running by hand (ersatztv#299); it runs both locally and
|
||||
# in CI (the `functional-e2e` job in .gitea/workflows/docker-build.yml).
|
||||
#
|
||||
# It does NOT boot the app — pair it with scripts/e2e-local.sh (which builds/launches and prints the
|
||||
# CONFIG_DIR), or point it at any already-running instance:
|
||||
#
|
||||
# scripts/e2e-functional.sh [BASE_URL] [CONFIG_DIR]
|
||||
#
|
||||
# BASE_URL default http://localhost:8409 (the app's UI+API port; ETV_UI_PORT)
|
||||
# CONFIG_DIR the instance's config folder — REQUIRED, so we can read its machine api key
|
||||
# ($CONFIG_DIR/api.key). The /api surface is fail-closed (Api:RequireKeyForReads
|
||||
# defaults true), so every /api call sends X-Api-Key.
|
||||
#
|
||||
# Scope (all curl-only, deterministic, no seeded media / ffmpeg / browser needed):
|
||||
# 1. Legacy -> SPA redirect sweep (+ the /api,/artwork never-redirect exemption).
|
||||
# 2. Auth / CSRF / security-stamp flow (setup-claim, read-gate, CSRF gate, login, logout+revoke).
|
||||
# 3. Library scan lifecycle status-code contract (404 / 202 / scan-status).
|
||||
# 4. Optimistic-concurrency If-Match / 412 round-trip.
|
||||
#
|
||||
# Deliberately OUT of scope for this first cut (need seeded media + the scanner subprocess, or a
|
||||
# browser, to be deterministic — tracked as ersatztv#299 follow-ups):
|
||||
# - the 409 "already scanning" re-trigger (racy without a long-running scan),
|
||||
# - the playout-build lock 409,
|
||||
# - the genuinely UI-interactive Playwright flows.
|
||||
#
|
||||
# Exit status: 0 if every assertion passed, 1 if any failed. Assertions keep running after a
|
||||
# failure so one run reports the full picture.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
BASE_URL="${1:-http://localhost:8409}"
|
||||
CONFIG_DIR="${2:-}"
|
||||
|
||||
if [ -z "$CONFIG_DIR" ]; then
|
||||
echo "error: CONFIG_DIR (arg 2) is required — needed to read the machine api key ($CONFIG_DIR/api.key)." >&2
|
||||
echo "usage: scripts/e2e-functional.sh [BASE_URL] CONFIG_DIR" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
KEY_FILE="$CONFIG_DIR/api.key"
|
||||
if [ ! -f "$KEY_FILE" ]; then
|
||||
echo "error: $KEY_FILE not found. Is the instance running with ETV_CONFIG_FOLDER=$CONFIG_DIR?" >&2
|
||||
exit 2
|
||||
fi
|
||||
API_KEY="$(cat "$KEY_FILE")"
|
||||
|
||||
PASS=0
|
||||
FAIL=0
|
||||
FAILURES=()
|
||||
|
||||
# ---- assertion helpers -------------------------------------------------------------------------
|
||||
|
||||
# ok/bad: record a single assertion result.
|
||||
ok() { PASS=$((PASS + 1)); printf ' \033[32mPASS\033[0m %s\n' "$1"; }
|
||||
bad() { FAIL=$((FAIL + 1)); FAILURES+=("$1"); printf ' \033[31mFAIL\033[0m %s\n' "$1"; }
|
||||
|
||||
# status_of METHOD URL [extra curl args...] -> prints the HTTP status code.
|
||||
status_of() {
|
||||
local method="$1" url="$2"; shift 2
|
||||
curl -s -o /dev/null -w '%{http_code}' -X "$method" "$@" "$url"
|
||||
}
|
||||
|
||||
# expect_status "desc" EXPECTED METHOD URL [extra curl args...]
|
||||
expect_status() {
|
||||
local desc="$1" expected="$2" method="$3" url="$4"; shift 4
|
||||
local got; got="$(status_of "$method" "$url" "$@")"
|
||||
if [ "$got" = "$expected" ]; then ok "$desc (=$expected)"; else bad "$desc — expected $expected, got $got [$method $url]"; fi
|
||||
}
|
||||
|
||||
# api(): curl against the /api surface with the machine key + JSON content type.
|
||||
api() { curl -s -H "X-Api-Key: $API_KEY" -H 'Content-Type: application/json' "$@"; }
|
||||
|
||||
# json_field FILE FIELD -> value of a top-level JSON field (via python3, always present here).
|
||||
json_field() { python3 -c "import json,sys; print(json.load(open('$1')).get('$2',''))"; }
|
||||
|
||||
section() { printf '\n\033[1m== %s ==\033[0m\n' "$1"; }
|
||||
|
||||
echo "Functional E2E against $BASE_URL (config: $CONFIG_DIR)"
|
||||
|
||||
# ---- 1. Legacy -> SPA redirect sweep ------------------------------------------------------------
|
||||
# The legacy branch 302s GET requests to their /app equivalent; /api, /artwork, /docs, /openapi are
|
||||
# NEVER redirected (they 4xx from their own handlers/fallback). See ErsatzTV/LegacyUiRedirects.cs +
|
||||
# Startup.cs MapFallback, and docs/blazor-route-parity.md.
|
||||
section "Legacy -> SPA redirects"
|
||||
|
||||
# redirects_to "src" "expected /app location suffix"
|
||||
redirects_to() {
|
||||
local src="$1" want="$2"
|
||||
local code loc
|
||||
code="$(curl -s -o /dev/null -w '%{http_code}' "$BASE_URL$src")"
|
||||
loc="$(curl -s -o /dev/null -w '%{redirect_url}' "$BASE_URL$src")"
|
||||
if [ "$code" = "302" ] && [ "$loc" = "$BASE_URL$want" ]; then
|
||||
ok "$src -> 302 $want"
|
||||
else
|
||||
bad "$src — expected 302 -> $BASE_URL$want, got $code -> ${loc:-<none>}"
|
||||
fi
|
||||
}
|
||||
redirects_to "/" "/app"
|
||||
redirects_to "/channels" "/app/channels"
|
||||
redirects_to "/channels/5" "/app/edit-channel/5"
|
||||
redirects_to "/settings/ffmpeg" "/app/settings/streaming"
|
||||
redirects_to "/media/movies" "/app/media?kind=movies"
|
||||
redirects_to "/media/sources/plex/3/libraries" "/app/libraries/plex/3/sync"
|
||||
|
||||
# The exempt prefixes must NOT redirect to /app — they 4xx from their own handlers (an unknown
|
||||
# /api path 404s; /artwork rejects a non-artwork path 400). Assert only "did not 302 to /app".
|
||||
never_redirects() {
|
||||
local src="$1"
|
||||
local code loc
|
||||
code="$(curl -s -o /dev/null -w '%{http_code}' "$BASE_URL$src")"
|
||||
loc="$(curl -s -o /dev/null -w '%{redirect_url}' "$BASE_URL$src")"
|
||||
if [ "$code" != "302" ] && [ -z "$loc" ] && [ "${code:0:1}" = "4" ]; then
|
||||
ok "$src -> not redirected (${code})"
|
||||
else
|
||||
bad "$src — expected a non-redirect 4xx, got $code -> ${loc:-<none>}"
|
||||
fi
|
||||
}
|
||||
never_redirects "/api/does-not-exist"
|
||||
never_redirects "/artwork/does-not-exist"
|
||||
|
||||
# ---- 2. Machine-key data-plane contracts (scan lifecycle + If-Match/412) ------------------------
|
||||
# These use the machine key (X-Api-Key), which is independent of the session and does NOT claim the
|
||||
# local admin — so the fresh-config setup-claim flow in section 3 still sees setupRequired:true.
|
||||
section "Read gate (Api:RequireKeyForReads = true)"
|
||||
expect_status "GET /api/v1/channels without a key -> 401" 401 GET "$BASE_URL/api/v1/channels"
|
||||
expect_status "GET /api/v1/channels with the machine key -> 200" 200 GET "$BASE_URL/api/v1/channels" \
|
||||
-H "X-Api-Key: $API_KEY"
|
||||
|
||||
section "Library scan lifecycle contract"
|
||||
# Create an empty local library (no LibraryPath) — enough to exercise the enqueue/status contract
|
||||
# without any media on disk (QueueLibraryScanByLibraryIdHandler never inspects paths).
|
||||
LIB_JSON="$(mktemp)"
|
||||
api -X POST "$BASE_URL/api/v1/libraries/local" -d '{"name":"E2E Functional Library"}' -o "$LIB_JSON" -w ''
|
||||
LIB_ID="$(json_field "$LIB_JSON" id)"
|
||||
if [ -n "$LIB_ID" ]; then ok "created local library id=$LIB_ID"; else bad "could not create local library (body: $(cat "$LIB_JSON"))"; fi
|
||||
expect_status "POST /api/v1/libraries/$LIB_ID/scan -> 202 (queued)" 202 POST "$BASE_URL/api/v1/libraries/$LIB_ID/scan" \
|
||||
-H "X-Api-Key: $API_KEY" -H 'Content-Type: application/json' -d '{}'
|
||||
expect_status "POST /api/v1/libraries/99999/scan -> 404 (unknown library)" 404 POST "$BASE_URL/api/v1/libraries/99999/scan" \
|
||||
-H "X-Api-Key: $API_KEY" -H 'Content-Type: application/json' -d '{}'
|
||||
expect_status "GET /api/v1/libraries/scan-status -> 200" 200 GET "$BASE_URL/api/v1/libraries/scan-status" \
|
||||
-H "X-Api-Key: $API_KEY"
|
||||
|
||||
section "Optimistic concurrency (If-Match / 412)"
|
||||
# rerun-collections is the reference concurrency-aware editor: GET emits an ETag, PUT enforces
|
||||
# If-Match. It needs an existing collection to target, so create one first.
|
||||
COLL_JSON="$(mktemp)"
|
||||
api -X POST "$BASE_URL/api/v1/collections" -d '{"name":"E2E Functional Collection"}' -o "$COLL_JSON" -w ''
|
||||
COLL_ID="$(json_field "$COLL_JSON" id)"
|
||||
RC_JSON="$(mktemp)"
|
||||
api -X POST "$BASE_URL/api/v1/rerun-collections" -o "$RC_JSON" -w '' -d "{
|
||||
\"name\":\"E2E Functional Rerun\",\"collectionType\":\"Collection\",\"selectedId\":$COLL_ID,
|
||||
\"firstRunPlaybackOrder\":\"Chronological\",\"rerunPlaybackOrder\":\"Chronological\"}"
|
||||
RC_ID="$(json_field "$RC_JSON" id)"
|
||||
if [ -n "$RC_ID" ]; then ok "created rerun-collection id=$RC_ID (targets collection $COLL_ID)"; else bad "could not create rerun-collection (body: $(cat "$RC_JSON"))"; fi
|
||||
|
||||
# GET must surface the current version as a quoted-integer ETag.
|
||||
ETAG="$(api -D - -o /dev/null "$BASE_URL/api/v1/rerun-collections/$RC_ID" | awk 'tolower($1)=="etag:"{print $2}' | tr -d '\r')"
|
||||
if [ -n "$ETAG" ]; then ok "GET rerun-collection $RC_ID surfaces ETag $ETAG"; else bad "GET rerun-collection $RC_ID has no ETag header"; fi
|
||||
|
||||
RC_BODY="{\"name\":\"E2E Functional Rerun v2\",\"selectedId\":$COLL_ID,\"firstRunPlaybackOrder\":\"Chronological\",\"rerunPlaybackOrder\":\"Chronological\"}"
|
||||
# A well-formed but non-current version must be rejected 412 (999999 can never be the live version here).
|
||||
expect_status "PUT with a stale If-Match -> 412" 412 PUT "$BASE_URL/api/v1/rerun-collections/$RC_ID" \
|
||||
-H "X-Api-Key: $API_KEY" -H 'Content-Type: application/json' -H 'If-Match: "999999"' -d "$RC_BODY"
|
||||
# The current ETag must be accepted.
|
||||
expect_status "PUT with the current If-Match -> 200" 200 PUT "$BASE_URL/api/v1/rerun-collections/$RC_ID" \
|
||||
-H "X-Api-Key: $API_KEY" -H 'Content-Type: application/json' -H "If-Match: $ETAG" -d "$RC_BODY"
|
||||
# A malformed (non-integer) If-Match is a client error.
|
||||
expect_status "PUT with a malformed If-Match -> 400" 400 PUT "$BASE_URL/api/v1/rerun-collections/$RC_ID" \
|
||||
-H "X-Api-Key: $API_KEY" -H 'Content-Type: application/json' -H 'If-Match: not-a-version' -d "$RC_BODY"
|
||||
|
||||
# ---- 3. Auth / CSRF / security-stamp flow -------------------------------------------------------
|
||||
# Runs last: setup-claim is a one-shot on a fresh config and logout revokes the session. All prior
|
||||
# sections used the machine key, which does not claim the admin, so setupRequired is still true here.
|
||||
section "Auth: setup-claim, CSRF gate, login, logout + stamp revocation"
|
||||
|
||||
CFG_JSON="$(mktemp)"
|
||||
curl -s "$BASE_URL/api/v1/auth/config" -o "$CFG_JSON"
|
||||
if [ "$(json_field "$CFG_JSON" setupRequired)" = "True" ]; then ok "fresh config: setupRequired=true"; else bad "expected setupRequired=true on a fresh config (body: $(cat "$CFG_JSON"))"; fi
|
||||
|
||||
JAR="$(mktemp)"
|
||||
# Claim the local admin (setup is CSRF-gated like every mutation).
|
||||
expect_status "POST /api/v1/auth/setup -> 200 (claims admin, issues session)" 200 POST "$BASE_URL/api/v1/auth/setup" \
|
||||
-c "$JAR" -H 'Content-Type: application/json' -H 'X-CSRF: 1' -d '{"username":"admin","password":"e2e-pass-1234"}'
|
||||
curl -s "$BASE_URL/api/v1/auth/config" -o "$CFG_JSON"
|
||||
if [ "$(json_field "$CFG_JSON" setupRequired)" = "False" ]; then ok "after claim: setupRequired=false"; else bad "expected setupRequired=false after claim (body: $(cat "$CFG_JSON"))"; fi
|
||||
expect_status "POST /api/v1/auth/setup again -> 409 (already configured)" 409 POST "$BASE_URL/api/v1/auth/setup" \
|
||||
-H 'Content-Type: application/json' -H 'X-CSRF: 1' -d '{"username":"admin2","password":"e2e-pass-1234"}'
|
||||
|
||||
# CSRF gate on a general /api session mutation: a POST with the session cookie but no X-CSRF is 403.
|
||||
expect_status "session mutation without X-CSRF -> 403" 403 POST "$BASE_URL/api/v1/collections" \
|
||||
-b "$JAR" -H 'Content-Type: application/json' -d '{"name":"should-be-csrf-blocked"}'
|
||||
|
||||
# Login: wrong password 401, correct 200 (fresh cookie jar).
|
||||
expect_status "login with wrong password -> 401" 401 POST "$BASE_URL/api/v1/auth/login" \
|
||||
-H 'Content-Type: application/json' -H 'X-CSRF: 1' -d '{"username":"admin","password":"nope"}'
|
||||
JAR2="$(mktemp)"
|
||||
expect_status "login with correct password -> 200" 200 POST "$BASE_URL/api/v1/auth/login" \
|
||||
-c "$JAR2" -H 'Content-Type: application/json' -H 'X-CSRF: 1' -d '{"username":"admin","password":"e2e-pass-1234"}'
|
||||
|
||||
# Logout is CSRF-gated; and after it, the same cookie is invalidated by the rotated security stamp.
|
||||
expect_status "authed GET before logout -> 200" 200 GET "$BASE_URL/api/v1/auth/machine-key" -b "$JAR2"
|
||||
expect_status "logout without X-CSRF -> 403" 403 POST "$BASE_URL/api/v1/auth/logout" -b "$JAR2"
|
||||
expect_status "logout with X-CSRF -> 204" 204 POST "$BASE_URL/api/v1/auth/logout" -b "$JAR2" -H 'X-CSRF: 1'
|
||||
expect_status "authed GET after logout (stamp revoked) -> 401" 401 GET "$BASE_URL/api/v1/auth/machine-key" -b "$JAR2"
|
||||
|
||||
# ---- summary ------------------------------------------------------------------------------------
|
||||
printf '\n\033[1m== Summary ==\033[0m\n'
|
||||
printf 'passed: %d failed: %d\n' "$PASS" "$FAIL"
|
||||
if [ "$FAIL" -ne 0 ]; then
|
||||
printf '\033[31mfailed assertions:\033[0m\n'
|
||||
for f in "${FAILURES[@]}"; do printf ' - %s\n' "$f"; done
|
||||
exit 1
|
||||
fi
|
||||
echo "all functional-E2E assertions passed."
|
||||
@@ -22,7 +22,10 @@
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(git rev-parse --show-toplevel)"
|
||||
BUILD_DIR="$REPO_ROOT/ErsatzTV/bin/Debug/net10.0"
|
||||
# ETV_BUILD_CONFIG selects which build output to launch (Debug for local dev; the CI functional-E2E
|
||||
# job builds Release). Must match the `dotnet build --configuration` you ran beforehand.
|
||||
BUILD_CONFIG="${ETV_BUILD_CONFIG:-Debug}"
|
||||
BUILD_DIR="$REPO_ROOT/ErsatzTV/bin/$BUILD_CONFIG/net10.0"
|
||||
PORT="${ETV_UI_PORT:-8409}"
|
||||
READY_LINE="Done migrating search index"
|
||||
TIMEOUT_SECS=120
|
||||
@@ -31,7 +34,7 @@ CONFIG_DIR="${1:-$(mktemp -d)}"
|
||||
mkdir -p "$CONFIG_DIR"
|
||||
|
||||
if [ ! -d "$BUILD_DIR" ]; then
|
||||
echo "error: $BUILD_DIR does not exist — run 'dotnet build ErsatzTV.sln' first" >&2
|
||||
echo "error: $BUILD_DIR does not exist — run 'dotnet build ErsatzTV.sln${BUILD_CONFIG:+ --configuration $BUILD_CONFIG}' first" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
+82
-1311
File diff suppressed because it is too large
Load Diff
+35
-1150
File diff suppressed because it is too large
Load Diff
Vendored
+6
@@ -734,6 +734,9 @@ export interface components {
|
||||
"subfolderCount": number;
|
||||
"imageCount": number;
|
||||
"durationSeconds": null | number;
|
||||
};
|
||||
"IptvSettingsResponseModel": {
|
||||
"baseUrl": string;
|
||||
};
|
||||
"LanguageCodeResponseModel": {
|
||||
"code": string;
|
||||
@@ -1657,6 +1660,9 @@ export interface components {
|
||||
};
|
||||
"UpdateImageFolderDurationResponseModel": {
|
||||
"durationSeconds": null | number;
|
||||
};
|
||||
"UpdateIptvSettingsRequest": {
|
||||
"baseUrl": null | string;
|
||||
};
|
||||
"UpdateLocalLibraryPathRequest": {
|
||||
"id": number;
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
deleteResolution,
|
||||
getFfmpegSettings,
|
||||
getHdhrSettings,
|
||||
getIptvSettings,
|
||||
getLoggingSettings,
|
||||
getPlayoutSettings,
|
||||
getResolutions,
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
messageFromSettingsError,
|
||||
updateFfmpegSettings,
|
||||
updateHdhrSettings,
|
||||
updateIptvSettings,
|
||||
updateLoggingSettings,
|
||||
updatePlayoutSettings,
|
||||
updateScannerSettings,
|
||||
@@ -145,6 +147,20 @@ describe('settings API module', () => {
|
||||
expect(JSON.parse(init?.body as string)).toMatchObject({ tunerCount: 2 });
|
||||
});
|
||||
|
||||
it('fetches and updates IPTV settings', async () => {
|
||||
const settings = { baseUrl: 'http://192.168.1.99:8409' };
|
||||
vi.spyOn(window, 'fetch').mockImplementation(() => Promise.resolve(jsonResponse(settings)));
|
||||
|
||||
await expect(getIptvSettings()).resolves.toMatchObject(settings);
|
||||
expect(lastFetchCall()[0].toString()).toBe('/api/v1/settings/iptv');
|
||||
|
||||
await updateIptvSettings(settings);
|
||||
const [path, init] = lastFetchCall();
|
||||
expect(path.toString()).toBe('/api/v1/settings/iptv');
|
||||
expect(init?.method).toBe('PUT');
|
||||
expect(JSON.parse(init?.body as string)).toMatchObject({ baseUrl: 'http://192.168.1.99:8409' });
|
||||
});
|
||||
|
||||
it('fetches resolutions from GET /api/v1/settings/resolutions', async () => {
|
||||
const resolutions = [{ height: 1080, id: 1, isCustom: false, name: '1920x1080', width: 1920 }];
|
||||
vi.spyOn(window, 'fetch').mockResolvedValue(jsonResponse(resolutions));
|
||||
|
||||
+14
-1
@@ -19,6 +19,8 @@ export type UiSettings = components['schemas']['UiSettingsResponseModel'];
|
||||
export type UpdateUiSettingsRequest = components['schemas']['UpdateUiSettingsRequest'];
|
||||
export type HdhrSettings = components['schemas']['HdhrSettingsResponseModel'];
|
||||
export type UpdateHdhrSettingsRequest = components['schemas']['UpdateHdhrSettingsRequest'];
|
||||
export type IptvSettings = components['schemas']['IptvSettingsResponseModel'];
|
||||
export type UpdateIptvSettingsRequest = components['schemas']['UpdateIptvSettingsRequest'];
|
||||
export type Resolution = components['schemas']['ResolutionResponseModel'];
|
||||
export type CreateResolutionRequest = components['schemas']['CreateResolutionRequest'];
|
||||
export type LogEventLevel = components['schemas']['LogEventLevel'];
|
||||
@@ -83,6 +85,14 @@ export function updateHdhrSettings(body: UpdateHdhrSettingsRequest): Promise<Hdh
|
||||
return request<HdhrSettings>('/api/v1/settings/hdhr', { body, method: 'PUT' });
|
||||
}
|
||||
|
||||
export function getIptvSettings(): Promise<IptvSettings> {
|
||||
return request<IptvSettings>('/api/v1/settings/iptv');
|
||||
}
|
||||
|
||||
export function updateIptvSettings(body: UpdateIptvSettingsRequest): Promise<IptvSettings> {
|
||||
return request<IptvSettings>('/api/v1/settings/iptv', { body, method: 'PUT' });
|
||||
}
|
||||
|
||||
export function getResolutions(): Promise<Resolution[]> {
|
||||
return request<Resolution[]>('/api/v1/settings/resolutions');
|
||||
}
|
||||
@@ -103,6 +113,7 @@ export interface SettingsScreenData {
|
||||
logging: LoggingSettings;
|
||||
ui: UiSettings;
|
||||
hdhr: HdhrSettings;
|
||||
iptv: IptvSettings;
|
||||
resolutions: Resolution[];
|
||||
ffmpegProfiles: FFmpegProfile[];
|
||||
watermarks: Watermark[];
|
||||
@@ -158,6 +169,7 @@ async function loadSettingsScreenData(): Promise<SettingsScreenData> {
|
||||
getLoggingSettings(),
|
||||
getUiSettings(),
|
||||
getHdhrSettings(),
|
||||
getIptvSettings(),
|
||||
getResolutions()
|
||||
]);
|
||||
|
||||
@@ -172,7 +184,7 @@ async function loadSettingsScreenData(): Promise<SettingsScreenData> {
|
||||
getDashboardHealth()
|
||||
] as const);
|
||||
|
||||
const [ffmpeg, playout, xmltv, scanner, logging, ui, hdhr, resolutions] = await tier1;
|
||||
const [ffmpeg, playout, xmltv, scanner, logging, ui, hdhr, iptv, resolutions] = await tier1;
|
||||
const [profilesResult, watermarksResult, fillerPresetsResult, mediaSourcesResult, versionResult, healthResult] =
|
||||
await tier2;
|
||||
|
||||
@@ -189,6 +201,7 @@ async function loadSettingsScreenData(): Promise<SettingsScreenData> {
|
||||
fillerPresets: fillerPresets.value,
|
||||
health: health.value,
|
||||
hdhr,
|
||||
iptv,
|
||||
logging,
|
||||
mediaSources: mediaSources.value,
|
||||
playout,
|
||||
|
||||
@@ -0,0 +1,364 @@
|
||||
import {
|
||||
Fragment,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type KeyboardEvent,
|
||||
type MouseEvent,
|
||||
type ReactNode
|
||||
} from 'react';
|
||||
import {
|
||||
Bell,
|
||||
CalendarClock,
|
||||
Cast,
|
||||
Check,
|
||||
ChevronDown,
|
||||
CircleHelp,
|
||||
ClipboardCopy,
|
||||
Info,
|
||||
ListVideo,
|
||||
Plus,
|
||||
Search
|
||||
} from 'lucide-react';
|
||||
import chicoryMarkUrl from '../../../design-system/assets/chicory-mark.svg';
|
||||
import {
|
||||
useChannelsQuery,
|
||||
useDashboardVersionQuery,
|
||||
type DashboardHealthQueryState
|
||||
} from '../api';
|
||||
import {
|
||||
Button,
|
||||
IconButton,
|
||||
NavItem,
|
||||
NavSection
|
||||
} from '../components';
|
||||
import {
|
||||
designSystemThemes,
|
||||
type DesignSystemThemeId
|
||||
} from '../designSystem';
|
||||
import { usePrimaryActionHandler } from '../primaryAction';
|
||||
import { navigateToPath } from '../routing';
|
||||
import { DashboardHealthSummary } from '../screens/DashboardScreen';
|
||||
import { UnauthorizedBanner } from '../UnauthorizedBanner';
|
||||
import { UserMenu } from '../UserMenu';
|
||||
import {
|
||||
routeById,
|
||||
routeHref,
|
||||
sidebarNavGroups,
|
||||
type ScreenId,
|
||||
type ScreenRoute
|
||||
} from './routes';
|
||||
|
||||
type NavigateHandler = (route: ScreenRoute, event: MouseEvent) => void;
|
||||
|
||||
function SidebarNavGroup({
|
||||
activeRoute,
|
||||
ids,
|
||||
onNavigate
|
||||
}: {
|
||||
activeRoute: ScreenRoute | null;
|
||||
ids: readonly ScreenId[];
|
||||
onNavigate: NavigateHandler;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{ids.map((id) => {
|
||||
const route = routeById.get(id)!;
|
||||
|
||||
return (
|
||||
<NavItem
|
||||
key={route.id}
|
||||
icon={route.icon}
|
||||
label={route.label}
|
||||
active={route.id === activeRoute?.id}
|
||||
badge={route.badge}
|
||||
badgeTone="warn"
|
||||
href={routeHref(route)}
|
||||
onClick={(event) => onNavigate(route, event)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ThemeSwitcher({
|
||||
theme,
|
||||
onThemeChange
|
||||
}: {
|
||||
theme: DesignSystemThemeId;
|
||||
onThemeChange: (theme: DesignSystemThemeId) => void;
|
||||
}) {
|
||||
return (
|
||||
<section className="ctv-theme-switcher" aria-label="Theme">
|
||||
<span>Theme</span>
|
||||
<div>
|
||||
{designSystemThemes.map(({ id, description, label }) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
className={`ctv-theme-swatch ctv-theme-swatch-${id}`}
|
||||
aria-label={description}
|
||||
aria-pressed={theme === id}
|
||||
title={label}
|
||||
onClick={() => onThemeChange(id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Sidebar({
|
||||
activeRoute,
|
||||
healthState,
|
||||
onNavigate
|
||||
}: {
|
||||
activeRoute: ScreenRoute | null;
|
||||
healthState: DashboardHealthQueryState;
|
||||
onNavigate: NavigateHandler;
|
||||
}) {
|
||||
return (
|
||||
<aside className="ctv-sidebar">
|
||||
<div className="ctv-brand">
|
||||
<img src={chicoryMarkUrl} alt="ChicoryTV" />
|
||||
<span className="ctv-brand-wordmark">
|
||||
Chicory<span>TV</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<nav aria-label="Primary" className="ctv-nav">
|
||||
{sidebarNavGroups.map(({ ids, label }) => (
|
||||
<Fragment key={label ?? 'Primary'}>
|
||||
{label ? <NavSection>{label}</NavSection> : null}
|
||||
<SidebarNavGroup activeRoute={activeRoute} ids={ids} onNavigate={onNavigate} />
|
||||
</Fragment>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="ctv-sidebar-health">
|
||||
<div>
|
||||
<span>ChicoryTV</span>
|
||||
<SidebarVersion />
|
||||
</div>
|
||||
<DashboardHealthSummary healthState={healthState} />
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarVersion() {
|
||||
const versionQuery = useDashboardVersionQuery();
|
||||
|
||||
if (versionQuery.status === 'success') {
|
||||
return <code>{versionQuery.version.appVersion ?? 'version unknown'}</code>;
|
||||
}
|
||||
|
||||
if (versionQuery.status === 'error') {
|
||||
return <code>version unavailable</code>;
|
||||
}
|
||||
|
||||
return <code>loading version</code>;
|
||||
}
|
||||
|
||||
function EndpointRow({ icon, label, url }: { icon: ReactNode; label: string; url: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const copyUrl = () => {
|
||||
void navigator.clipboard?.writeText(url);
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 1200);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ctv-connect-endpoint">
|
||||
<span className="ctv-connect-endpoint-icon">{icon}</span>
|
||||
<div>
|
||||
<strong>{label}</strong>
|
||||
<code>{url}</code>
|
||||
</div>
|
||||
<button type="button" onClick={copyUrl}>
|
||||
{copied ? <Check aria-hidden="true" size={13} /> : <ClipboardCopy aria-hidden="true" size={13} />}
|
||||
{copied ? 'Copied' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConnectMenu() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const channelsQuery = useChannelsQuery();
|
||||
const playlistUrl = `${window.location.origin}/iptv/channels.m3u`;
|
||||
const guideUrl = `${window.location.origin}/iptv/xmltv.xml`;
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
menuRef.current?.focus();
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const onMenuKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key === 'Escape') {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const channelCountLabel = (() => {
|
||||
if (channelsQuery.status === 'loading') {
|
||||
return 'Loading channels';
|
||||
}
|
||||
|
||||
if (channelsQuery.status === 'error') {
|
||||
return 'Channels unavailable';
|
||||
}
|
||||
|
||||
const count = channelsQuery.channels.length;
|
||||
return `${count} ${count === 1 ? 'channel' : 'channels'}`;
|
||||
})();
|
||||
|
||||
return (
|
||||
<div className="ctv-connect">
|
||||
<button
|
||||
type="button"
|
||||
className="ctv-connect-button"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((current) => !current)}
|
||||
>
|
||||
<Cast aria-hidden="true" size={15} />
|
||||
Connect
|
||||
<ChevronDown aria-hidden="true" size={13} />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="ctv-connect-dismiss"
|
||||
aria-label="Close Connect menu"
|
||||
onClick={() => setOpen(false)}
|
||||
/>
|
||||
<div
|
||||
className="ctv-connect-menu"
|
||||
role="dialog"
|
||||
aria-label="Connect a player"
|
||||
tabIndex={-1}
|
||||
ref={menuRef}
|
||||
onKeyDown={onMenuKeyDown}
|
||||
>
|
||||
<div className="ctv-connect-header">
|
||||
<strong>Connect a player</strong>
|
||||
<span>{channelCountLabel}</span>
|
||||
</div>
|
||||
<EndpointRow
|
||||
icon={<ListVideo aria-hidden="true" size={15} />}
|
||||
label="M3U playlist"
|
||||
url={playlistUrl}
|
||||
/>
|
||||
<EndpointRow
|
||||
icon={<CalendarClock aria-hidden="true" size={15} />}
|
||||
label="XMLTV guide"
|
||||
url={guideUrl}
|
||||
/>
|
||||
<p>
|
||||
<Info aria-hidden="true" size={13} />
|
||||
Works with any IPTV player.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TopBar({ route }: { route: ScreenRoute | null }) {
|
||||
const title = route?.title ?? 'Page not found';
|
||||
const kicker = route?.kicker ?? 'Unknown route';
|
||||
const primaryAction = route?.primaryAction ?? '';
|
||||
const primaryActionHandler = usePrimaryActionHandler(route?.id ?? '');
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
|
||||
return (
|
||||
<header className="ctv-topbar">
|
||||
<div className="ctv-topbar-title">
|
||||
<p>{kicker}</p>
|
||||
<h1>{title}</h1>
|
||||
</div>
|
||||
|
||||
<form
|
||||
className="ctv-topbar-search"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
const trimmed = searchValue.trim();
|
||||
if (trimmed) {
|
||||
navigateToPath(`/app/search?query=${encodeURIComponent(trimmed)}`);
|
||||
}
|
||||
}}
|
||||
role="search"
|
||||
>
|
||||
<Search aria-hidden="true" size={15} />
|
||||
<input
|
||||
aria-label="Search"
|
||||
onChange={(event) => setSearchValue(event.target.value)}
|
||||
placeholder="Search movies, shows, music..."
|
||||
type="search"
|
||||
value={searchValue}
|
||||
/>
|
||||
</form>
|
||||
|
||||
<div className="ctv-topbar-spacer" />
|
||||
|
||||
<div className="ctv-topbar-tools">
|
||||
<UserMenu />
|
||||
<ConnectMenu />
|
||||
<span className="ctv-topbar-divider" />
|
||||
<IconButton title="Documentation" size="sm">
|
||||
<CircleHelp aria-hidden="true" size={17} />
|
||||
</IconButton>
|
||||
<IconButton title="Notifications" size="sm">
|
||||
<Bell aria-hidden="true" size={17} />
|
||||
</IconButton>
|
||||
<span className="ctv-avatar">TB</span>
|
||||
</div>
|
||||
|
||||
{route && primaryAction && primaryActionHandler ? (
|
||||
<Button
|
||||
startIcon={<Plus aria-hidden="true" size={15} />}
|
||||
onClick={primaryActionHandler}
|
||||
>
|
||||
{primaryAction}
|
||||
</Button>
|
||||
) : null}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export function AppShell({
|
||||
children,
|
||||
healthState,
|
||||
onNavigate,
|
||||
onThemeChange,
|
||||
route,
|
||||
theme
|
||||
}: {
|
||||
children: ReactNode;
|
||||
healthState: DashboardHealthQueryState;
|
||||
onNavigate: NavigateHandler;
|
||||
onThemeChange: (theme: DesignSystemThemeId) => void;
|
||||
route: ScreenRoute | null;
|
||||
theme: DesignSystemThemeId;
|
||||
}) {
|
||||
return (
|
||||
<div className="ctv-app-shell">
|
||||
<Sidebar activeRoute={route} healthState={healthState} onNavigate={onNavigate} />
|
||||
<div className="ctv-shell-body">
|
||||
<TopBar route={route} />
|
||||
<UnauthorizedBanner />
|
||||
<main className="ctv-main">
|
||||
<ThemeSwitcher theme={theme} onThemeChange={onThemeChange} />
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Info } from 'lucide-react';
|
||||
import type { DashboardHealthQueryState } from '../api';
|
||||
import { ChannelBuilderScreen } from '../builder/ChannelBuilder';
|
||||
import { Card } from '../components';
|
||||
import {
|
||||
parseLibrariesSubRoute,
|
||||
parseMediaSubRoute
|
||||
} from '../routing';
|
||||
import { ApiKeyScreen } from '../screens/ApiKeyScreen';
|
||||
import { BlockPlayoutTroubleshootingScreen } from '../screens/BlockPlayoutTroubleshootingScreen';
|
||||
import { BlocksScreen } from '../screens/BlocksScreen';
|
||||
import { ChannelEditScreen } from '../screens/ChannelEditScreen';
|
||||
import { ChannelsScreen } from '../screens/ChannelsScreen';
|
||||
import { CollectionsScreen } from '../screens/CollectionsScreen';
|
||||
import { DashboardScreen } from '../screens/DashboardScreen';
|
||||
import { DecoTemplatesScreen } from '../screens/DecoTemplatesScreen';
|
||||
import { DecosScreen } from '../screens/DecosScreen';
|
||||
import { FFmpegProfilesScreen } from '../screens/FFmpegProfilesScreen';
|
||||
import { FillerPresetsScreen } from '../screens/FillerPresetsScreen';
|
||||
import { GuideScreen } from '../screens/GuideScreen';
|
||||
import { ImageBrowserScreen } from '../screens/ImageBrowserScreen';
|
||||
import { LibrariesScreen } from '../screens/LibrariesScreen';
|
||||
import { LocalLibraryEditScreen } from '../screens/LocalLibraryEditScreen';
|
||||
import { LogsScreen } from '../screens/LogsScreen';
|
||||
import { MediaBrowseScreen } from '../screens/MediaBrowseScreen';
|
||||
import {
|
||||
ArtistDetailScreen,
|
||||
MovieDetailScreen,
|
||||
SeasonDetailScreen,
|
||||
ShowDetailScreen
|
||||
} from '../screens/MediaDetailScreen';
|
||||
import { MultiCollectionsScreen } from '../screens/MultiCollectionsScreen';
|
||||
import { PathReplacementsEditScreen } from '../screens/PathReplacementsEditScreen';
|
||||
import { PlaybackTroubleshootingScreen } from '../screens/PlaybackTroubleshootingScreen';
|
||||
import { PlexSourceScreen } from '../screens/PlexSourceScreen';
|
||||
import { PlaylistsScreen } from '../screens/PlaylistsScreen';
|
||||
import { PlayoutsRouteScreen } from '../screens/PlayoutsScreen';
|
||||
import { RemoteConnectionEditScreen } from '../screens/RemoteConnectionEditScreen';
|
||||
import { RemoteLibrariesEditScreen } from '../screens/RemoteLibrariesEditScreen';
|
||||
import { RemoteSourceScreen } from '../screens/RemoteSourceScreen';
|
||||
import { RerunCollectionsScreen } from '../screens/RerunCollectionsScreen';
|
||||
import { SchedulesScreen } from '../screens/SchedulesScreen';
|
||||
import { SearchScreen } from '../screens/SearchScreen';
|
||||
import { SettingsScreen } from '../screens/SettingsScreen';
|
||||
import { TemplatesScreen } from '../screens/TemplatesScreen';
|
||||
import { TraktListsScreen } from '../screens/TraktListsScreen';
|
||||
import { TrashScreen } from '../screens/TrashScreen';
|
||||
import { TroubleshootingScreen } from '../screens/TroubleshootingScreen';
|
||||
import { WatermarksScreen } from '../screens/WatermarksScreen';
|
||||
import { YamlValidatorScreen } from '../screens/YamlValidatorScreen';
|
||||
import {
|
||||
normalizeAppPath,
|
||||
type ScreenId,
|
||||
type ScreenRoute
|
||||
} from './routes';
|
||||
|
||||
function NotFoundScreen() {
|
||||
const pathname = normalizeAppPath(window.location.pathname);
|
||||
|
||||
return (
|
||||
<div className="ctv-screen-stack">
|
||||
<Card title={<h2>Unknown app route</h2>} subtitle="This URL does not match a ChicoryTV screen.">
|
||||
<div className="ctv-placeholder-layout">
|
||||
<div className="ctv-placeholder-icon"><Info aria-hidden="true" size={20} /></div>
|
||||
<div>
|
||||
<h3>Requested path</h3>
|
||||
<p>{pathname}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// The Media nav entry owns detail sub-pages (/app/media/{movies|shows|seasons|artists}/{id}) and the
|
||||
// image folder browser (/app/media/images/browser). Like PlayoutsRouteScreen, this wrapper tracks
|
||||
// pathname locally + listens for popstate, because routeFromLocation() returns the SAME 'media'
|
||||
// ScreenRoute object for the base grid and every sub-path (Object.is bails App's setActiveRoute).
|
||||
export function MediaRouteScreen() {
|
||||
const [pathname, setPathname] = useState(() => window.location.pathname);
|
||||
const [search, setSearch] = useState(() => window.location.search);
|
||||
|
||||
useEffect(() => {
|
||||
const onPopState = () => {
|
||||
setPathname(window.location.pathname);
|
||||
setSearch(window.location.search);
|
||||
};
|
||||
window.addEventListener('popstate', onPopState);
|
||||
return () => window.removeEventListener('popstate', onPopState);
|
||||
}, []);
|
||||
|
||||
const sub = parseMediaSubRoute(pathname);
|
||||
|
||||
if (sub?.kind === 'movie') {
|
||||
return <MovieDetailScreen key={pathname} id={sub.id} />;
|
||||
}
|
||||
if (sub?.kind === 'show') {
|
||||
return <ShowDetailScreen key={pathname} id={sub.id} />;
|
||||
}
|
||||
if (sub?.kind === 'season') {
|
||||
return <SeasonDetailScreen key={pathname} id={sub.id} />;
|
||||
}
|
||||
if (sub?.kind === 'artist') {
|
||||
return <ArtistDetailScreen key={pathname} id={sub.id} />;
|
||||
}
|
||||
if (sub?.kind === 'images') {
|
||||
return <ImageBrowserScreen key={pathname} />;
|
||||
}
|
||||
|
||||
return <MediaBrowseScreen key={search} />;
|
||||
}
|
||||
|
||||
// The Libraries nav entry owns the media-source editor sub-paths. CRITICAL (design §D.2, finding 4):
|
||||
// unlike PlayoutsRouteScreen/MediaRouteScreen this wrapper does NOT register its own `popstate`
|
||||
// listener. Its editor sub-screens register a dirty guard, so App must approve the path first and
|
||||
// pass it down. This wrapper never reads a raw navigation event.
|
||||
export function LibrariesRouteScreen({ subPath }: { subPath: string }) {
|
||||
const sub = parseLibrariesSubRoute(subPath);
|
||||
|
||||
if (sub === null) {
|
||||
return <LibrariesScreen />;
|
||||
}
|
||||
|
||||
switch (sub.kind) {
|
||||
case 'local-new':
|
||||
case 'local-edit':
|
||||
return <LocalLibraryEditScreen key={subPath} />;
|
||||
case 'remote-source':
|
||||
return sub.family === 'plex'
|
||||
? <PlexSourceScreen key={subPath} />
|
||||
: <RemoteSourceScreen key={subPath} family={sub.family} />;
|
||||
case 'remote-connection':
|
||||
return <RemoteConnectionEditScreen key={subPath} family={sub.family} />;
|
||||
case 'remote-libraries':
|
||||
return <RemoteLibrariesEditScreen key={subPath} family={sub.family} sourceId={sub.id} />;
|
||||
case 'remote-path-replacements':
|
||||
return <PathReplacementsEditScreen key={subPath} family={sub.family} sourceId={sub.id} />;
|
||||
}
|
||||
}
|
||||
|
||||
function assertNeverScreen(screenId: never): never {
|
||||
throw new Error(`Unhandled screen route: ${String(screenId)}`);
|
||||
}
|
||||
|
||||
export function ScreenContent({
|
||||
healthState,
|
||||
librariesSubPath,
|
||||
route
|
||||
}: {
|
||||
healthState: DashboardHealthQueryState;
|
||||
librariesSubPath: string;
|
||||
route: ScreenRoute | null;
|
||||
}) {
|
||||
if (route === null) {
|
||||
return <NotFoundScreen />;
|
||||
}
|
||||
|
||||
const routeId: ScreenId = route.id;
|
||||
|
||||
switch (routeId) {
|
||||
case 'dashboard':
|
||||
return <DashboardScreen healthState={healthState} />;
|
||||
case 'channels':
|
||||
return <ChannelsScreen />;
|
||||
case 'builder':
|
||||
return <ChannelBuilderScreen />;
|
||||
case 'editChannel':
|
||||
return <ChannelEditScreen key={window.location.pathname} />;
|
||||
case 'guide':
|
||||
return <GuideScreen />;
|
||||
case 'schedules':
|
||||
return <SchedulesScreen />;
|
||||
case 'blocks':
|
||||
return <BlocksScreen key={window.location.pathname} />;
|
||||
case 'templates':
|
||||
return <TemplatesScreen key={window.location.pathname} />;
|
||||
case 'decos':
|
||||
return <DecosScreen key={window.location.pathname} />;
|
||||
case 'decoTemplates':
|
||||
return <DecoTemplatesScreen key={window.location.pathname} />;
|
||||
case 'playouts':
|
||||
return <PlayoutsRouteScreen />;
|
||||
case 'libraries':
|
||||
return <LibrariesRouteScreen subPath={librariesSubPath} />;
|
||||
case 'settings':
|
||||
return <SettingsScreen />;
|
||||
case 'apiKey':
|
||||
return <ApiKeyScreen />;
|
||||
case 'media':
|
||||
return <MediaRouteScreen />;
|
||||
case 'search':
|
||||
return <SearchScreen key={window.location.search} />;
|
||||
case 'trash':
|
||||
return <TrashScreen />;
|
||||
case 'collections':
|
||||
return <CollectionsScreen />;
|
||||
case 'multiCollections':
|
||||
return <MultiCollectionsScreen />;
|
||||
case 'rerunCollections':
|
||||
return <RerunCollectionsScreen />;
|
||||
case 'playlists':
|
||||
return <PlaylistsScreen />;
|
||||
case 'traktLists':
|
||||
return <TraktListsScreen key={window.location.pathname} />;
|
||||
case 'fillerPresets':
|
||||
return <FillerPresetsScreen />;
|
||||
case 'ffmpegProfiles':
|
||||
return <FFmpegProfilesScreen />;
|
||||
case 'watermarks':
|
||||
return <WatermarksScreen />;
|
||||
case 'logs':
|
||||
return <LogsScreen />;
|
||||
case 'troubleshooting':
|
||||
return <TroubleshootingScreen />;
|
||||
case 'blockPlayoutTroubleshooting':
|
||||
return <BlockPlayoutTroubleshootingScreen />;
|
||||
case 'playbackTroubleshooting':
|
||||
return <PlaybackTroubleshootingScreen key={window.location.search} />;
|
||||
case 'yamlValidator':
|
||||
return <YamlValidatorScreen />;
|
||||
default:
|
||||
return assertNeverScreen(routeId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
routeById,
|
||||
routeFromLocation
|
||||
} from './routes';
|
||||
|
||||
describe('app route matching', () => {
|
||||
afterEach(() => {
|
||||
window.history.replaceState(null, '', '/app');
|
||||
});
|
||||
|
||||
it('returns one stable route object across sibling allowSubPaths locations', () => {
|
||||
const playoutsRoute = routeById.get('playouts');
|
||||
|
||||
window.history.replaceState(null, '', '/app/playouts/20/alternate-schedules');
|
||||
const alternateSchedules = routeFromLocation();
|
||||
|
||||
window.history.replaceState(null, '', '/app/playouts/20/templates');
|
||||
const templates = routeFromLocation();
|
||||
|
||||
expect(alternateSchedules).toBe(playoutsRoute);
|
||||
expect(templates).toBe(playoutsRoute);
|
||||
expect(templates).toBe(alternateSchedules);
|
||||
});
|
||||
|
||||
it('ignores the query string while preserving the route object', () => {
|
||||
const searchRoute = routeById.get('search');
|
||||
|
||||
window.history.replaceState(null, '', '/app/search?query=alpha');
|
||||
const firstQuery = routeFromLocation();
|
||||
|
||||
window.history.replaceState(null, '', '/app/search?query=beta');
|
||||
const secondQuery = routeFromLocation();
|
||||
|
||||
expect(firstQuery).toBe(searchRoute);
|
||||
expect(secondQuery).toBe(searchRoute);
|
||||
});
|
||||
|
||||
it('normalizes a trailing slash and rejects an unowned path', () => {
|
||||
window.history.replaceState(null, '', '/app/channels/');
|
||||
expect(routeFromLocation()).toBe(routeById.get('channels'));
|
||||
|
||||
window.history.replaceState(null, '', '/app/channels/42');
|
||||
expect(routeFromLocation()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,524 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import {
|
||||
Boxes,
|
||||
Palette,
|
||||
CalendarClock,
|
||||
CalendarDays,
|
||||
CalendarRange,
|
||||
Clapperboard,
|
||||
Film,
|
||||
FolderTree,
|
||||
KeyRound,
|
||||
Layers,
|
||||
LayoutDashboard,
|
||||
LayoutGrid,
|
||||
Library,
|
||||
Link2,
|
||||
ListChecks,
|
||||
ListMusic,
|
||||
ListVideo,
|
||||
Plus,
|
||||
Repeat,
|
||||
ScrollText,
|
||||
Search,
|
||||
Settings,
|
||||
SlidersHorizontal,
|
||||
Stamp,
|
||||
Stethoscope,
|
||||
Trash2,
|
||||
Tv
|
||||
} from 'lucide-react';
|
||||
|
||||
export type ScreenId =
|
||||
| 'dashboard'
|
||||
| 'channels'
|
||||
| 'builder'
|
||||
| 'editChannel'
|
||||
| 'guide'
|
||||
| 'schedules'
|
||||
| 'blocks'
|
||||
| 'templates'
|
||||
| 'decos'
|
||||
| 'decoTemplates'
|
||||
| 'playouts'
|
||||
| 'media'
|
||||
| 'search'
|
||||
| 'trash'
|
||||
| 'collections'
|
||||
| 'multiCollections'
|
||||
| 'rerunCollections'
|
||||
| 'playlists'
|
||||
| 'fillerPresets'
|
||||
| 'libraries'
|
||||
| 'traktLists'
|
||||
| 'ffmpegProfiles'
|
||||
| 'watermarks'
|
||||
| 'settings'
|
||||
| 'apiKey'
|
||||
| 'logs'
|
||||
| 'troubleshooting'
|
||||
| 'blockPlayoutTroubleshooting'
|
||||
| 'playbackTroubleshooting'
|
||||
| 'yamlValidator';
|
||||
|
||||
export interface ScreenRoute {
|
||||
id: ScreenId;
|
||||
path: string;
|
||||
label: string;
|
||||
title: string;
|
||||
kicker: string;
|
||||
description: string;
|
||||
icon: ReactNode;
|
||||
primaryAction: string;
|
||||
placeholder: string;
|
||||
badge?: number;
|
||||
// Screens that own linkable sub-sections (e.g. /app/settings/streaming) opt in here;
|
||||
// the sub-path is otherwise unowned and 404s (see routeFromLocation).
|
||||
allowSubPaths?: boolean;
|
||||
}
|
||||
|
||||
// This is the one stable route-object table for the application. routeFromLocation returns
|
||||
// references from this array so allowSubPaths navigations intentionally preserve object identity.
|
||||
export const routes: ScreenRoute[] = [
|
||||
{
|
||||
id: 'dashboard',
|
||||
path: '/app',
|
||||
label: 'Dashboard',
|
||||
title: 'Dashboard',
|
||||
kicker: 'Overview',
|
||||
description: 'On-air status, health checks, and recent server activity.',
|
||||
icon: <LayoutDashboard aria-hidden="true" size={16} />,
|
||||
primaryAction: '',
|
||||
placeholder: 'Dashboard workspace'
|
||||
},
|
||||
{
|
||||
id: 'channels',
|
||||
path: '/app/channels',
|
||||
label: 'Channels',
|
||||
title: 'Channels',
|
||||
kicker: 'Lineup',
|
||||
description: 'Dense channel table, ordering tools, bulk actions, and playback status.',
|
||||
icon: <Tv aria-hidden="true" size={16} />,
|
||||
primaryAction: 'Add Channel',
|
||||
placeholder: 'Channel list workspace'
|
||||
},
|
||||
{
|
||||
id: 'builder',
|
||||
path: '/app/new-channel',
|
||||
label: 'New Channel',
|
||||
title: 'New Channel',
|
||||
kicker: 'Builder',
|
||||
description: 'Library-to-lineup channel creation flow with template-backed defaults.',
|
||||
icon: <Plus aria-hidden="true" size={16} />,
|
||||
primaryAction: '',
|
||||
placeholder: 'Channel builder workspace'
|
||||
},
|
||||
{
|
||||
// Not in the sidebar nav; reached via the edit pencil on the channel table. The
|
||||
// screen owns parsing the {id} suffix (see ChannelEditScreen), so it opts into
|
||||
// sub-paths like /app/edit-channel/5.
|
||||
id: 'editChannel',
|
||||
path: '/app/edit-channel',
|
||||
label: 'Edit Channel',
|
||||
title: 'Edit Channel',
|
||||
kicker: 'Channel',
|
||||
description: 'Full channel editor: identity, playout, streaming, selection and branding.',
|
||||
icon: <Tv aria-hidden="true" size={16} />,
|
||||
primaryAction: '',
|
||||
placeholder: 'Channel editor workspace',
|
||||
allowSubPaths: true
|
||||
},
|
||||
{
|
||||
id: 'guide',
|
||||
path: '/app/guide',
|
||||
label: 'Guide',
|
||||
title: 'Guide',
|
||||
kicker: 'EPG',
|
||||
description: 'Timeline grid with channel rows, programme blocks, and a live now marker.',
|
||||
icon: <LayoutGrid aria-hidden="true" size={16} />,
|
||||
primaryAction: '',
|
||||
placeholder: 'EPG grid workspace'
|
||||
},
|
||||
{
|
||||
id: 'schedules',
|
||||
path: '/app/schedules',
|
||||
label: 'Schedules',
|
||||
title: 'Schedules',
|
||||
kicker: 'Programming',
|
||||
description: 'Two-pane schedule editor with drag ordering and progressive details.',
|
||||
icon: <CalendarClock aria-hidden="true" size={16} />,
|
||||
primaryAction: 'Add Schedule',
|
||||
placeholder: 'Schedule editor workspace'
|
||||
},
|
||||
{
|
||||
// The editor lives at a sub-path (/app/blocks/{id}); BlocksScreen owns parsing the id suffix.
|
||||
id: 'blocks',
|
||||
path: '/app/blocks',
|
||||
label: 'Blocks',
|
||||
title: 'Blocks',
|
||||
kicker: 'Programming',
|
||||
description: 'Group blocks and edit their items, durations, and playout preview.',
|
||||
icon: <Boxes aria-hidden="true" size={16} />,
|
||||
primaryAction: '',
|
||||
placeholder: 'Blocks workspace',
|
||||
allowSubPaths: true
|
||||
},
|
||||
{
|
||||
// The editor lives at a sub-path (/app/templates/{id}); TemplatesScreen owns parsing the id suffix.
|
||||
id: 'templates',
|
||||
path: '/app/templates',
|
||||
label: 'Templates',
|
||||
title: 'Templates',
|
||||
kicker: 'Programming',
|
||||
description: 'Group templates and assign blocks to times of day.',
|
||||
icon: <CalendarDays aria-hidden="true" size={16} />,
|
||||
primaryAction: '',
|
||||
placeholder: 'Templates workspace',
|
||||
allowSubPaths: true
|
||||
},
|
||||
{
|
||||
// The editor lives at a sub-path (/app/decos/{id}); DecosScreen owns parsing the id suffix.
|
||||
id: 'decos',
|
||||
path: '/app/decos',
|
||||
label: 'Decos',
|
||||
title: 'Decos',
|
||||
kicker: 'Programming',
|
||||
description: 'Group decos and configure watermark, graphics, break content, and filler overrides.',
|
||||
icon: <Palette aria-hidden="true" size={16} />,
|
||||
primaryAction: '',
|
||||
placeholder: 'Decos workspace',
|
||||
allowSubPaths: true
|
||||
},
|
||||
{
|
||||
// The editor lives at a sub-path (/app/deco-templates/{id}); DecoTemplatesScreen owns parsing the id suffix.
|
||||
id: 'decoTemplates',
|
||||
path: '/app/deco-templates',
|
||||
label: 'Deco Templates',
|
||||
title: 'Deco Templates',
|
||||
kicker: 'Programming',
|
||||
description: 'Group deco templates and assign decos to times of day.',
|
||||
icon: <CalendarRange aria-hidden="true" size={16} />,
|
||||
primaryAction: '',
|
||||
placeholder: 'Deco templates workspace',
|
||||
allowSubPaths: true
|
||||
},
|
||||
{
|
||||
// The alternate-schedules / templates editors live at sub-paths
|
||||
// (/app/playouts/{id}/alternate-schedules, /app/playouts/{id}/templates).
|
||||
id: 'playouts',
|
||||
path: '/app/playouts',
|
||||
label: 'Playouts',
|
||||
title: 'Playouts',
|
||||
kicker: 'Runtime',
|
||||
description: 'Playout state, reset controls, timeline diagnostics, and build warnings.',
|
||||
icon: <ListVideo aria-hidden="true" size={16} />,
|
||||
primaryAction: '',
|
||||
placeholder: 'Playouts workspace',
|
||||
allowSubPaths: true
|
||||
},
|
||||
{
|
||||
id: 'media',
|
||||
path: '/app/media',
|
||||
label: 'Browse',
|
||||
title: 'Browse Media',
|
||||
kicker: 'Media',
|
||||
description: 'Browse indexed movies, shows, episodes, music and more by kind.',
|
||||
icon: <Clapperboard aria-hidden="true" size={16} />,
|
||||
primaryAction: '',
|
||||
placeholder: 'Media browse workspace',
|
||||
allowSubPaths: true
|
||||
},
|
||||
{
|
||||
id: 'search',
|
||||
path: '/app/search',
|
||||
label: 'Search',
|
||||
title: 'Search',
|
||||
kicker: 'Media',
|
||||
description: 'Search across every media kind at once.',
|
||||
icon: <Search aria-hidden="true" size={16} />,
|
||||
primaryAction: '',
|
||||
placeholder: 'Search workspace',
|
||||
allowSubPaths: true
|
||||
},
|
||||
{
|
||||
id: 'trash',
|
||||
path: '/app/trash',
|
||||
label: 'Trash',
|
||||
title: 'Trash',
|
||||
kicker: 'Media',
|
||||
description: 'Library items whose files have gone missing.',
|
||||
icon: <Trash2 aria-hidden="true" size={16} />,
|
||||
primaryAction: '',
|
||||
placeholder: 'Trash workspace'
|
||||
},
|
||||
{
|
||||
id: 'collections',
|
||||
path: '/app/collections',
|
||||
label: 'Collections',
|
||||
title: 'Collections',
|
||||
kicker: 'Media',
|
||||
description: 'Manual, smart, multi, playlist, search, and rerun collection management.',
|
||||
icon: <FolderTree aria-hidden="true" size={16} />,
|
||||
primaryAction: '',
|
||||
placeholder: 'Collections workspace'
|
||||
},
|
||||
{
|
||||
id: 'multiCollections',
|
||||
path: '/app/multi-collections',
|
||||
label: 'Multi-Collections',
|
||||
title: 'Multi-Collections',
|
||||
kicker: 'Media',
|
||||
description: 'Group manual and smart collections into a single schedulable collection.',
|
||||
icon: <Layers aria-hidden="true" size={16} />,
|
||||
primaryAction: 'Add Multi-Collection',
|
||||
placeholder: 'Multi-collections workspace'
|
||||
},
|
||||
{
|
||||
id: 'rerunCollections',
|
||||
path: '/app/rerun-collections',
|
||||
label: 'Rerun Collections',
|
||||
title: 'Rerun Collections',
|
||||
kicker: 'Media',
|
||||
description: 'A collection with distinct first-run and rerun playback orders.',
|
||||
icon: <Repeat aria-hidden="true" size={16} />,
|
||||
primaryAction: 'Add Rerun Collection',
|
||||
placeholder: 'Rerun collections workspace'
|
||||
},
|
||||
{
|
||||
id: 'playlists',
|
||||
path: '/app/playlists',
|
||||
label: 'Playlists',
|
||||
title: 'Playlists',
|
||||
kicker: 'Media',
|
||||
description: 'Group, order, and preview playlist items scheduled into channels.',
|
||||
icon: <ListMusic aria-hidden="true" size={16} />,
|
||||
primaryAction: '',
|
||||
placeholder: 'Playlists workspace'
|
||||
},
|
||||
{
|
||||
id: 'fillerPresets',
|
||||
path: '/app/filler-presets',
|
||||
label: 'Filler Presets',
|
||||
title: 'Filler Presets',
|
||||
kicker: 'Media',
|
||||
description: 'Pre/mid/post-roll, tail and fallback filler presets used by schedules.',
|
||||
icon: <Film aria-hidden="true" size={16} />,
|
||||
primaryAction: 'Add Filler Preset',
|
||||
placeholder: 'Filler presets workspace',
|
||||
allowSubPaths: true
|
||||
},
|
||||
{
|
||||
// The media-source editors live at sub-paths (/app/libraries/local/{id}, /app/libraries/plex,
|
||||
// /app/libraries/{jellyfin|emby}/connection, .../{id}/sync, .../{id}/path-replacements).
|
||||
// Unlike other allowSubPaths screens, the libraries wrapper does NOT self-listen for popstate:
|
||||
// its editors register a dirty guard, so App owns pathname/popstate and passes the approved
|
||||
// sub-path down (see LibrariesRouteScreen + spa-conventions §8, design §D.2).
|
||||
id: 'libraries',
|
||||
path: '/app/libraries',
|
||||
label: 'Libraries',
|
||||
title: 'Libraries',
|
||||
kicker: 'Sources',
|
||||
description: 'Local, Plex, Jellyfin, and Emby media source monitoring.',
|
||||
icon: <Library aria-hidden="true" size={16} />,
|
||||
primaryAction: '',
|
||||
placeholder: 'Libraries workspace',
|
||||
allowSubPaths: true
|
||||
},
|
||||
{
|
||||
// The editor lives at a sub-path (/app/trakt-lists/{id}); the screen owns parsing the
|
||||
// {id} suffix itself (see TraktListsScreen), same pattern as editChannel/settings.
|
||||
id: 'traktLists',
|
||||
path: '/app/trakt-lists',
|
||||
label: 'Trakt Lists',
|
||||
title: 'Trakt Lists',
|
||||
kicker: 'Media',
|
||||
description: 'Add, match, and manage Trakt list imports.',
|
||||
icon: <Link2 aria-hidden="true" size={16} />,
|
||||
primaryAction: 'Add Trakt List',
|
||||
placeholder: 'Trakt lists workspace',
|
||||
allowSubPaths: true
|
||||
},
|
||||
{
|
||||
id: 'ffmpegProfiles',
|
||||
path: '/app/ffmpeg-profiles',
|
||||
label: 'FFmpeg Profiles',
|
||||
title: 'FFmpeg Profiles',
|
||||
kicker: 'System',
|
||||
description: 'Transcoding profiles: resolution, video/audio formats, hardware acceleration.',
|
||||
icon: <SlidersHorizontal aria-hidden="true" size={16} />,
|
||||
primaryAction: 'Add Profile',
|
||||
placeholder: 'FFmpeg profiles workspace',
|
||||
allowSubPaths: true
|
||||
},
|
||||
{
|
||||
id: 'watermarks',
|
||||
path: '/app/watermarks',
|
||||
label: 'Watermarks',
|
||||
title: 'Watermarks',
|
||||
kicker: 'System',
|
||||
description: 'Channel watermark overlays: image, position, size and opacity.',
|
||||
icon: <Stamp aria-hidden="true" size={16} />,
|
||||
primaryAction: 'Add Watermark',
|
||||
placeholder: 'Watermarks workspace',
|
||||
allowSubPaths: true
|
||||
},
|
||||
{
|
||||
id: 'settings',
|
||||
path: '/app/settings',
|
||||
label: 'Settings',
|
||||
title: 'Settings',
|
||||
kicker: 'System',
|
||||
description: 'Server configuration, access keys, FFmpeg profiles, and UI preferences.',
|
||||
icon: <Settings aria-hidden="true" size={16} />,
|
||||
primaryAction: '',
|
||||
placeholder: 'Settings workspace',
|
||||
allowSubPaths: true
|
||||
},
|
||||
{
|
||||
// Since #295 the browser authenticates by session cookie; this screen only *displays* the
|
||||
// server machine key (for MCP / external REST clients) and, for local accounts, changes the
|
||||
// admin password — there is no key to "save" here (see spa-conventions §5e).
|
||||
id: 'apiKey',
|
||||
path: '/app/api-key',
|
||||
label: 'API Key',
|
||||
title: 'API Key',
|
||||
kicker: 'System',
|
||||
description: 'View the machine key for MCP and external REST clients, and change the local admin password.',
|
||||
icon: <KeyRound aria-hidden="true" size={16} />,
|
||||
primaryAction: '',
|
||||
placeholder: 'API key workspace'
|
||||
},
|
||||
{
|
||||
id: 'logs',
|
||||
path: '/app/logs',
|
||||
label: 'Logs',
|
||||
title: 'Logs',
|
||||
kicker: 'System',
|
||||
description: 'Recent server log entries with level and free-text filtering.',
|
||||
icon: <ScrollText aria-hidden="true" size={16} />,
|
||||
primaryAction: '',
|
||||
placeholder: 'Logs workspace'
|
||||
},
|
||||
{
|
||||
id: 'troubleshooting',
|
||||
path: '/app/troubleshooting',
|
||||
label: 'Troubleshooting',
|
||||
title: 'Troubleshooting',
|
||||
kicker: 'System',
|
||||
description: 'Version, environment, hardware and FFmpeg capability diagnostics.',
|
||||
icon: <Stethoscope aria-hidden="true" size={16} />,
|
||||
primaryAction: '',
|
||||
placeholder: 'Troubleshooting workspace'
|
||||
},
|
||||
{
|
||||
id: 'blockPlayoutTroubleshooting',
|
||||
path: '/app/troubleshooting/blocks',
|
||||
label: 'Block History',
|
||||
title: 'Block Playout Troubleshooting',
|
||||
kicker: 'System',
|
||||
description: 'Inspect a block playout, its blocks, and each block’s scheduling history.',
|
||||
icon: <Boxes aria-hidden="true" size={16} />,
|
||||
primaryAction: '',
|
||||
placeholder: 'Block playout troubleshooting workspace'
|
||||
},
|
||||
{
|
||||
id: 'playbackTroubleshooting',
|
||||
path: '/app/troubleshooting/playback',
|
||||
label: 'Playback Troubleshooting',
|
||||
title: 'Playback Troubleshooting',
|
||||
kicker: 'System',
|
||||
description: 'Preview channel or media-item playback with a chosen FFmpeg profile and inspect the transcode logs.',
|
||||
icon: <Stethoscope aria-hidden="true" size={16} />,
|
||||
primaryAction: '',
|
||||
placeholder: 'Playback troubleshooting workspace'
|
||||
},
|
||||
{
|
||||
id: 'yamlValidator',
|
||||
path: '/app/troubleshooting/yaml',
|
||||
label: 'Schedule Validator',
|
||||
title: 'Sequential Schedule Validator',
|
||||
kicker: 'System',
|
||||
description: 'Validate a sequential-schedule YAML document against the schema.',
|
||||
icon: <ListChecks aria-hidden="true" size={16} />,
|
||||
primaryAction: '',
|
||||
placeholder: 'Sequential schedule validator workspace'
|
||||
}
|
||||
];
|
||||
|
||||
export const routeById = new Map(routes.map((route) => [route.id, route]));
|
||||
|
||||
export interface SidebarNavGroupDefinition {
|
||||
label?: string;
|
||||
ids: readonly ScreenId[];
|
||||
}
|
||||
|
||||
export const sidebarNavGroups: SidebarNavGroupDefinition[] = [
|
||||
{
|
||||
ids: [
|
||||
'dashboard',
|
||||
'channels',
|
||||
'builder',
|
||||
'guide',
|
||||
'schedules',
|
||||
'blocks',
|
||||
'templates',
|
||||
'decos',
|
||||
'decoTemplates',
|
||||
'playouts'
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Media',
|
||||
ids: [
|
||||
'media',
|
||||
'search',
|
||||
'trash',
|
||||
'collections',
|
||||
'multiCollections',
|
||||
'rerunCollections',
|
||||
'playlists',
|
||||
'fillerPresets',
|
||||
'libraries',
|
||||
'traktLists'
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'System',
|
||||
ids: [
|
||||
'settings',
|
||||
'apiKey',
|
||||
'logs',
|
||||
'troubleshooting',
|
||||
'blockPlayoutTroubleshooting',
|
||||
'yamlValidator',
|
||||
'ffmpegProfiles',
|
||||
'watermarks'
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
export function normalizeAppPath(pathname: string): string {
|
||||
return pathname.replace(/\/+$/, '') || '/app';
|
||||
}
|
||||
|
||||
export function routeFromLocation(): ScreenRoute | null {
|
||||
const pathname = normalizeAppPath(window.location.pathname);
|
||||
const exactMatch = routes.find((route) => route.path === pathname);
|
||||
|
||||
if (exactMatch) {
|
||||
return exactMatch;
|
||||
}
|
||||
|
||||
// Fall back to a sub-path of a route that has explicitly opted into linkable
|
||||
// sub-sections (e.g. /app/settings/streaming) - the screen owns parsing the
|
||||
// suffix itself (see SettingsScreen).
|
||||
const prefixMatches = routes
|
||||
.filter((route) => route.allowSubPaths && pathname.startsWith(`${route.path}/`))
|
||||
.sort((a, b) => b.path.length - a.path.length);
|
||||
|
||||
return prefixMatches[0] ?? null;
|
||||
}
|
||||
|
||||
export function routeHref(route: ScreenRoute): string {
|
||||
return route.id === 'dashboard' ? '/app' : route.path;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { cleanup, render, waitFor } from '@testing-library/react';
|
||||
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { applyDesignSystemTheme } from '../designSystem';
|
||||
import { ChannelBuilderScreen } from './ChannelBuilder';
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
@@ -9,42 +10,208 @@ function jsonResponse(body: unknown, status = 200): Response {
|
||||
});
|
||||
}
|
||||
|
||||
// Mocks every endpoint the builder loads on mount so it can render its library
|
||||
// browser, plus /api/v1/library/browse which the browse hook fans out across.
|
||||
function mockBuilderApi() {
|
||||
return vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL) => {
|
||||
const url = input.toString();
|
||||
// ---- Channel Builder (#89) fixtures ----------------------------------------
|
||||
// The API serializes with Newtonsoft NullValueHandling.Ignore: null-valued
|
||||
// members are omitted from the wire entirely. Fixtures mirror that by
|
||||
// stripping null/undefined keys after overrides are applied.
|
||||
function omitNullKeys(fixture: Record<string, unknown>): Record<string, unknown> {
|
||||
return Object.fromEntries(Object.entries(fixture).filter(([, value]) => value != null));
|
||||
}
|
||||
|
||||
if (url.startsWith('/api/v1/library/browse')) {
|
||||
return Promise.resolve(jsonResponse({ page: [], totalCount: 0 }));
|
||||
// ChannelResponseModel
|
||||
function channelSummary(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
categories: '',
|
||||
ffmpegProfile: '1080p H.264',
|
||||
group: 'ChicoryTV',
|
||||
id: 1,
|
||||
isEnabled: true,
|
||||
language: 'English',
|
||||
name: 'Movies',
|
||||
number: '5',
|
||||
showInEpg: true,
|
||||
sortNumber: 5,
|
||||
streamingMode: 'HttpLiveStreamingSegmenter',
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
// ChannelTemplateResponseModel — nullable members (filler/watermark ids,
|
||||
// preferred languages, streamSelector, musicVideoCreditsTemplate) are null on
|
||||
// the Standard template and therefore absent from the serialized payload.
|
||||
function channelTemplate(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return omitNullKeys({
|
||||
description: 'General-purpose 1080p H.264.',
|
||||
ffmpegProfileId: 100,
|
||||
fixedStartTimeBehavior: 'Strict',
|
||||
id: 10,
|
||||
idleBehavior: 'StopOnDisconnect',
|
||||
isDefault: true,
|
||||
isSystem: true,
|
||||
musicVideoCreditsMode: 'None',
|
||||
name: 'Standard',
|
||||
playoutMode: 'Continuous',
|
||||
playoutSource: 'Generated',
|
||||
randomStartPoint: false,
|
||||
shuffleScheduleItems: false,
|
||||
songVideoMode: 'Default',
|
||||
streamSelectorMode: 'Default',
|
||||
streamingMode: 'HttpLiveStreamingSegmenter',
|
||||
subtitleMode: 'None',
|
||||
transcodeMode: 'OnDemand',
|
||||
...overrides
|
||||
});
|
||||
}
|
||||
|
||||
// LibraryBrowseItemResponseModel - Newtonsoft omits null fields, so exactly one
|
||||
// typed id is populated and nullable metadata (duration/itemCount) is absent
|
||||
// unless the caller supplies it.
|
||||
function browseItem(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return omitNullKeys({
|
||||
artwork: '',
|
||||
collectionType: 'TelevisionShow',
|
||||
id: 1,
|
||||
libraryId: 31,
|
||||
libraryName: 'Cartoons',
|
||||
mediaItemId: 1,
|
||||
mediaType: 'TelevisionShow',
|
||||
title: 'Looney Tunes',
|
||||
...overrides
|
||||
});
|
||||
}
|
||||
|
||||
// FFmpegFullProfileResponseModel (only id/name are read by the builder)
|
||||
function ffmpegProfile(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return { id: 100, name: '1080p H.264', ...overrides };
|
||||
}
|
||||
|
||||
// Read the JSON body captured for a mutating request to `path`.
|
||||
function requestBodyFor(path: string): Record<string, unknown> {
|
||||
const call = vi
|
||||
.mocked(window.fetch)
|
||||
.mock.calls.find(([input, init]) => input.toString() === path && init?.body != null);
|
||||
if (!call) {
|
||||
throw new Error(`no request captured for ${path}`);
|
||||
}
|
||||
return JSON.parse(call[1]?.body as string) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function mockBuilderApi({
|
||||
artworkUploadFailure = null,
|
||||
browseHandler = null,
|
||||
browseItems = [],
|
||||
channels = [],
|
||||
channelTemplates = [],
|
||||
createTemplateResponse = null,
|
||||
defaultChannelTemplate = null,
|
||||
ffmpegProfiles = [],
|
||||
fromLineupFailure = null,
|
||||
fromLineupResponse = { channelId: 1, playlistId: null, playoutId: 3, programScheduleId: 2 }
|
||||
}: {
|
||||
artworkUploadFailure?: { status: number } | null;
|
||||
browseHandler?: ((search: URLSearchParams) => { page: unknown[]; totalCount: number }) | null;
|
||||
browseItems?: unknown[];
|
||||
channels?: unknown[];
|
||||
channelTemplates?: unknown[];
|
||||
createTemplateResponse?: unknown;
|
||||
defaultChannelTemplate?: unknown;
|
||||
ffmpegProfiles?: unknown[];
|
||||
fromLineupFailure?: { detail?: string; status?: number; title?: string } | null;
|
||||
fromLineupResponse?: unknown;
|
||||
} = {}) {
|
||||
let currentChannelTemplates = channelTemplates;
|
||||
|
||||
return vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const path = input.toString();
|
||||
|
||||
if (path === '/api/v1/channels') {
|
||||
return Promise.resolve(jsonResponse(channels));
|
||||
}
|
||||
|
||||
if (url === '/api/v1/channel-templates/default') {
|
||||
// Builder treats a 404 here as "no default template".
|
||||
return Promise.resolve(jsonResponse({ status: 404, title: 'Not Found' }, 404));
|
||||
if (path === '/api/v1/channels/from-lineup') {
|
||||
if (fromLineupFailure) {
|
||||
return Promise.resolve(jsonResponse(fromLineupFailure, fromLineupFailure.status ?? 422));
|
||||
}
|
||||
|
||||
return Promise.resolve(jsonResponse(fromLineupResponse, 201));
|
||||
}
|
||||
|
||||
// channels, channel-templates, ffmpeg/profiles, filler-presets, watermarks, media-sources
|
||||
return Promise.resolve(jsonResponse([]));
|
||||
if (path.startsWith('/api/v1/library/browse')) {
|
||||
const search = new URL(path, window.location.origin).searchParams;
|
||||
if (browseHandler) {
|
||||
return Promise.resolve(jsonResponse(browseHandler(search)));
|
||||
}
|
||||
|
||||
const mediaType = search.get('mediaType');
|
||||
const page = mediaType
|
||||
? browseItems.filter((item) => (item as { mediaType?: string }).mediaType === mediaType)
|
||||
: browseItems;
|
||||
return Promise.resolve(jsonResponse({ page, totalCount: page.length }));
|
||||
}
|
||||
|
||||
if (path === '/api/v1/channel-templates') {
|
||||
if ((init?.method ?? 'GET') === 'POST') {
|
||||
const created = createTemplateResponse ?? channelTemplate();
|
||||
currentChannelTemplates = [...currentChannelTemplates, created];
|
||||
return Promise.resolve(jsonResponse(created, 201));
|
||||
}
|
||||
|
||||
return Promise.resolve(jsonResponse(currentChannelTemplates));
|
||||
}
|
||||
|
||||
if (path === '/api/v1/channel-templates/default') {
|
||||
return Promise.resolve(
|
||||
defaultChannelTemplate ? jsonResponse(defaultChannelTemplate) : jsonResponse(null, 404)
|
||||
);
|
||||
}
|
||||
|
||||
if (path === '/api/v1/ffmpeg/profiles') {
|
||||
return Promise.resolve(jsonResponse(ffmpegProfiles));
|
||||
}
|
||||
|
||||
if (path.startsWith('/api/v1/filler-presets')) {
|
||||
return Promise.resolve(jsonResponse([]));
|
||||
}
|
||||
|
||||
if (path === '/api/v1/watermarks' || path === '/api/v1/media-sources') {
|
||||
return Promise.resolve(jsonResponse([]));
|
||||
}
|
||||
|
||||
if (path === '/api/v1/artwork/uploads') {
|
||||
if (artworkUploadFailure) {
|
||||
return Promise.resolve(new Response(null, { status: artworkUploadFailure.status }));
|
||||
}
|
||||
|
||||
return Promise.resolve(
|
||||
jsonResponse({ contentType: 'image/png', path: '/artwork/logo/uploaded.png' }, 201)
|
||||
);
|
||||
}
|
||||
|
||||
return Promise.resolve(jsonResponse(null, 404));
|
||||
});
|
||||
}
|
||||
|
||||
function browseMediaTypes(fetchMock: ReturnType<typeof mockBuilderApi>): string[] {
|
||||
return fetchMock.mock.calls
|
||||
.map(([u]) => u.toString())
|
||||
.filter((u) => u.startsWith('/api/v1/library/browse'))
|
||||
.map((u) => new URL(u, 'http://localhost').searchParams.get('mediaType'))
|
||||
.map(([url]) => url.toString())
|
||||
.filter((url) => url.startsWith('/api/v1/library/browse'))
|
||||
.map((url) => new URL(url, 'http://localhost').searchParams.get('mediaType'))
|
||||
.filter((mediaType): mediaType is string => mediaType != null && mediaType !== '');
|
||||
}
|
||||
|
||||
describe('ChannelBuilder library browse', () => {
|
||||
describe('Channel Builder (#89)', () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
window.history.replaceState(null, '', '/');
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
document.documentElement.removeAttribute('data-theme');
|
||||
window.history.replaceState(null, '', '/app/new-channel');
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('fans out over movies/shows/artists and never requests TelevisionSeason', async () => {
|
||||
@@ -52,7 +219,6 @@ describe('ChannelBuilder library browse', () => {
|
||||
|
||||
render(<ChannelBuilderScreen />);
|
||||
|
||||
// Wait until the initial library browse fan-out has fired.
|
||||
await waitFor(() => {
|
||||
expect(browseMediaTypes(fetchMock).length).toBeGreaterThan(0);
|
||||
});
|
||||
@@ -61,4 +227,616 @@ describe('ChannelBuilder library browse', () => {
|
||||
expect(types).toEqual(new Set(['Movie', 'TelevisionShow', 'Artist']));
|
||||
expect(types.has('TelevisionSeason')).toBe(false);
|
||||
});
|
||||
|
||||
// ---- Channel Builder (#89) -----------------------------------------------
|
||||
const renderBuilder = async () => {
|
||||
window.history.replaceState(null, '', '/app/new-channel');
|
||||
const utils = render(<ChannelBuilderScreen />);
|
||||
await screen.findByPlaceholderText('Search shows & movies…');
|
||||
return utils;
|
||||
};
|
||||
|
||||
// The shell's nav link and top-bar primary action also read "New Channel" /
|
||||
// "Create Channel"; scope these to the builder toolbar.
|
||||
const createBtn = () =>
|
||||
within(document.querySelector('.ctv-builder-toolbar') as HTMLElement).getByRole('button', {
|
||||
name: 'Create Channel'
|
||||
});
|
||||
const builderTitle = () => screen.getByText('New Channel', { selector: '.ctv-builder-title' });
|
||||
const numberField = () => screen.getByLabelText('Number', { exact: false }) as HTMLInputElement;
|
||||
|
||||
const builderDefaults = () => ({
|
||||
channels: [channelSummary()],
|
||||
channelTemplates: [channelTemplate()],
|
||||
defaultChannelTemplate: channelTemplate(),
|
||||
ffmpegProfiles: [ffmpegProfile()]
|
||||
});
|
||||
|
||||
it('renders the builder three-column layout with library items and the default template', async () => {
|
||||
mockBuilderApi({
|
||||
...builderDefaults(),
|
||||
browseItems: [browseItem(), browseItem({ id: 2, mediaItemId: 2, title: 'Tom & Jerry' })]
|
||||
});
|
||||
|
||||
const { container } = await renderBuilder();
|
||||
|
||||
expect(builderTitle()).toBeInTheDocument();
|
||||
expect(screen.getByText('Lineup')).toBeInTheDocument();
|
||||
expect(screen.getByText('Channel Template')).toBeInTheDocument();
|
||||
expect(screen.getByText('Standard')).toBeInTheDocument();
|
||||
expect(await screen.findByText('Looney Tunes')).toBeInTheDocument();
|
||||
expect(screen.getByText('Tom & Jerry')).toBeInTheDocument();
|
||||
expect(container.querySelectorAll('.ctv-builder-col')).toHaveLength(3);
|
||||
expect(screen.getByText('This channel has no content yet. Double-click or drag titles from the library to build the lineup.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('computes the AUTO channel number from the max integer part of existing channels', async () => {
|
||||
mockBuilderApi({
|
||||
...builderDefaults(),
|
||||
channels: [channelSummary({ id: 1, number: '5' }), channelSummary({ id: 2, number: '13.1' })],
|
||||
browseItems: [browseItem()]
|
||||
});
|
||||
|
||||
await renderBuilder();
|
||||
|
||||
const numberInput = numberField();
|
||||
expect(numberInput.value).toBe('14');
|
||||
expect(screen.getByText('AUTO')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('adds a library item to the lineup on double-click and dedupes repeats', async () => {
|
||||
mockBuilderApi({ ...builderDefaults(), browseItems: [browseItem()] });
|
||||
|
||||
const { container } = await renderBuilder();
|
||||
|
||||
fireEvent.doubleClick(await screen.findByText('Looney Tunes'));
|
||||
expect(container.querySelector('.ctv-builder-summary')?.textContent).toContain('1 in lineup');
|
||||
expect(container.querySelectorAll('.ctv-builder-lineup-row')).toHaveLength(1);
|
||||
|
||||
fireEvent.doubleClick(screen.getAllByText('Looney Tunes')[0]);
|
||||
expect(container.querySelectorAll('.ctv-builder-lineup-row')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('removes a lineup item and clears the lineup through the confirm dialog', async () => {
|
||||
mockBuilderApi({
|
||||
...builderDefaults(),
|
||||
browseItems: [browseItem(), browseItem({ id: 2, mediaItemId: 2, title: 'Tom & Jerry' })]
|
||||
});
|
||||
|
||||
const { container } = await renderBuilder();
|
||||
|
||||
fireEvent.doubleClick(await screen.findByText('Looney Tunes'));
|
||||
fireEvent.doubleClick(screen.getByText('Tom & Jerry'));
|
||||
expect(container.querySelectorAll('.ctv-builder-lineup-row')).toHaveLength(2);
|
||||
|
||||
fireEvent.click(within(container.querySelectorAll('.ctv-builder-lineup-row')[0] as HTMLElement).getByRole('button', { name: 'Remove' }));
|
||||
expect(container.querySelectorAll('.ctv-builder-lineup-row')).toHaveLength(1);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Clear' }));
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Clear lineup' }));
|
||||
await waitFor(() => expect(container.querySelectorAll('.ctv-builder-lineup-row')).toHaveLength(0));
|
||||
});
|
||||
|
||||
it('filters library results through the debounced search query', async () => {
|
||||
mockBuilderApi({
|
||||
...builderDefaults(),
|
||||
browseHandler: (search) => {
|
||||
const query = (search.get('query') ?? '').toLowerCase();
|
||||
const mediaType = search.get('mediaType');
|
||||
const all = [
|
||||
browseItem(),
|
||||
browseItem({ id: 2, mediaItemId: 2, mediaType: 'Movie', collectionType: 'Movie', title: 'Blade Runner', libraryName: 'Movies' })
|
||||
];
|
||||
const page = all
|
||||
.filter((item) => !mediaType || item.mediaType === mediaType)
|
||||
.filter((item) => !query || String(item.title).toLowerCase().includes(query));
|
||||
return { page, totalCount: page.length };
|
||||
}
|
||||
});
|
||||
|
||||
await renderBuilder();
|
||||
|
||||
expect(await screen.findByText('Blade Runner')).toBeInTheDocument();
|
||||
fireEvent.change(screen.getByPlaceholderText('Search shows & movies…'), { target: { value: 'blade' } });
|
||||
|
||||
await waitFor(() => expect(screen.queryByText('Looney Tunes')).not.toBeInTheDocument());
|
||||
expect(screen.getByText('Blade Runner')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('reorders lineup items with native drag and drop', async () => {
|
||||
mockBuilderApi({
|
||||
...builderDefaults(),
|
||||
browseItems: [browseItem(), browseItem({ id: 2, mediaItemId: 2, title: 'Tom & Jerry' })]
|
||||
});
|
||||
|
||||
const { container } = await renderBuilder();
|
||||
|
||||
fireEvent.doubleClick(await screen.findByText('Looney Tunes'));
|
||||
fireEvent.doubleClick(screen.getByText('Tom & Jerry'));
|
||||
|
||||
let rows = container.querySelectorAll('.ctv-builder-lineup-row');
|
||||
expect(rows[0].querySelector('.ctv-builder-row-title')?.textContent).toBe('Looney Tunes');
|
||||
|
||||
fireEvent.dragStart(rows[0]);
|
||||
fireEvent.dragOver(rows[1]);
|
||||
fireEvent.drop(rows[1]);
|
||||
|
||||
rows = container.querySelectorAll('.ctv-builder-lineup-row');
|
||||
expect(rows[0].querySelector('.ctv-builder-row-title')?.textContent).toBe('Tom & Jerry');
|
||||
expect(rows[1].querySelector('.ctv-builder-row-title')?.textContent).toBe('Looney Tunes');
|
||||
});
|
||||
|
||||
it('validates the channel number for format and uniqueness', async () => {
|
||||
mockBuilderApi({
|
||||
...builderDefaults(),
|
||||
channels: [channelSummary({ number: '5' })],
|
||||
browseItems: [browseItem()]
|
||||
});
|
||||
|
||||
await renderBuilder();
|
||||
|
||||
const numberInput = numberField();
|
||||
|
||||
fireEvent.change(numberInput, { target: { value: 'abc' } });
|
||||
expect(await screen.findByText(/Use digits/)).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(numberInput, { target: { value: '5' } });
|
||||
expect(await screen.findByText(/already in use/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps Create disabled until a name and at least one lineup item exist', async () => {
|
||||
mockBuilderApi({ ...builderDefaults(), browseItems: [browseItem()] });
|
||||
|
||||
await renderBuilder();
|
||||
|
||||
const createButton = createBtn();
|
||||
expect(createButton).toBeDisabled();
|
||||
|
||||
fireEvent.doubleClick(await screen.findByText('Looney Tunes'));
|
||||
fireEvent.change(screen.getByLabelText('Channel name'), { target: { value: 'Retro Cartoons' } });
|
||||
|
||||
expect(createButton).toBeEnabled();
|
||||
});
|
||||
|
||||
it('preselects the default template and flags an override when the Shuffle toggle diverges', async () => {
|
||||
mockBuilderApi({ ...builderDefaults(), browseItems: [browseItem()] });
|
||||
|
||||
const { container } = await renderBuilder();
|
||||
|
||||
// default template has shuffleScheduleItems=false -> no override tag yet
|
||||
expect(screen.queryByText('overrides template')).not.toBeInTheDocument();
|
||||
|
||||
const shuffleRow = container.querySelector('.ctv-builder-toggle-row:not(.ctv-builder-toggle-live)') as HTMLElement;
|
||||
fireEvent.click(shuffleRow);
|
||||
|
||||
expect(await screen.findByText('overrides template')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies a selected template shuffle default and clears the override', async () => {
|
||||
mockBuilderApi({
|
||||
...builderDefaults(),
|
||||
channelTemplates: [
|
||||
channelTemplate(),
|
||||
channelTemplate({ id: 11, isDefault: false, name: 'Music videos', shuffleScheduleItems: true })
|
||||
],
|
||||
browseItems: [browseItem()]
|
||||
});
|
||||
|
||||
const { container } = await renderBuilder();
|
||||
|
||||
const shuffleRow = container.querySelector('.ctv-builder-toggle-row:not(.ctv-builder-toggle-live)') as HTMLElement;
|
||||
fireEvent.click(shuffleRow);
|
||||
expect(await screen.findByText('overrides template')).toBeInTheDocument();
|
||||
|
||||
// open the template picker and select the shuffle-by-default template
|
||||
fireEvent.click(screen.getByText('Standard'));
|
||||
fireEvent.click(await screen.findByText('Music videos'));
|
||||
|
||||
await waitFor(() => expect(screen.queryByText('overrides template')).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('marks a rerun-collection row invalid in a multi-item lineup and blocks Create', async () => {
|
||||
mockBuilderApi({
|
||||
...builderDefaults(),
|
||||
browseItems: [
|
||||
browseItem(),
|
||||
browseItem({ id: 9, title: 'Saturday Block', mediaType: 'RerunCollection', collectionType: 'RerunFirstRun', collectionKind: 'Rerun', rerunCollectionId: 9, mediaItemId: undefined })
|
||||
]
|
||||
});
|
||||
|
||||
await renderBuilder();
|
||||
|
||||
fireEvent.doubleClick(await screen.findByText('Looney Tunes'));
|
||||
|
||||
// RerunCollection is a collections kind, not one of the 4 library kinds —
|
||||
// find it under the Collections tab.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Collections' }));
|
||||
fireEvent.doubleClick(await screen.findByText('Saturday Block'));
|
||||
fireEvent.change(screen.getByLabelText('Channel name'), { target: { value: 'Mixed' } });
|
||||
|
||||
expect(await screen.findByText('Only valid alone')).toBeInTheDocument();
|
||||
expect(createBtn()).toBeDisabled();
|
||||
});
|
||||
|
||||
it('clears a MultiCollection shuffle requirement when the Shuffle toggle is turned on', async () => {
|
||||
mockBuilderApi({
|
||||
...builderDefaults(),
|
||||
browseItems: [
|
||||
browseItem({ id: 7, title: 'Prime Time', mediaType: 'MultiCollection', collectionType: 'MultiCollection', multiCollectionId: 7, mediaItemId: undefined })
|
||||
]
|
||||
});
|
||||
|
||||
const { container } = await renderBuilder();
|
||||
|
||||
// MultiCollection is a collections kind, not one of the 4 library kinds —
|
||||
// find it under the Collections tab.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Collections' }));
|
||||
fireEvent.doubleClick(await screen.findByText('Prime Time'));
|
||||
fireEvent.change(screen.getByLabelText('Channel name'), { target: { value: 'Multi' } });
|
||||
|
||||
expect(await screen.findByText('Requires Shuffle')).toBeInTheDocument();
|
||||
expect(createBtn()).toBeDisabled();
|
||||
|
||||
const shuffleRow = container.querySelector('.ctv-builder-toggle-row:not(.ctv-builder-toggle-live)') as HTMLElement;
|
||||
fireEvent.click(shuffleRow);
|
||||
|
||||
await waitFor(() => expect(screen.queryByText('Requires Shuffle')).not.toBeInTheDocument());
|
||||
expect(createBtn()).toBeEnabled();
|
||||
});
|
||||
|
||||
it('posts a well-formed create request and navigates to channels on success', async () => {
|
||||
mockBuilderApi({
|
||||
...builderDefaults(),
|
||||
channels: [channelSummary({ number: '5' })],
|
||||
browseItems: [browseItem(), browseItem({ id: 2, mediaItemId: 2, title: 'Tom & Jerry' })]
|
||||
});
|
||||
|
||||
await renderBuilder();
|
||||
|
||||
fireEvent.doubleClick(await screen.findByText('Looney Tunes'));
|
||||
fireEvent.doubleClick(screen.getByText('Tom & Jerry'));
|
||||
fireEvent.change(screen.getByLabelText('Channel name'), { target: { value: ' Retro Cartoons ' } });
|
||||
|
||||
fireEvent.click(createBtn());
|
||||
|
||||
await waitFor(() => expect(window.location.pathname).toBe('/app/channels'));
|
||||
|
||||
const body = requestBodyFor('/api/v1/channels/from-lineup');
|
||||
expect(body.name).toBe('Retro Cartoons');
|
||||
expect(body.number).toBe('6');
|
||||
expect(body.group).toBe('ChicoryTV');
|
||||
expect(body.templateId).toBe(10);
|
||||
expect(body.logo).toEqual({ contentType: '', path: '' });
|
||||
expect((body.advanced as Record<string, unknown>).playbackOrder).toBe('Chronological');
|
||||
expect((body.advanced as Record<string, unknown>).playoutMode).toBe('Continuous');
|
||||
const lineup = body.lineup as Array<Record<string, unknown>>;
|
||||
expect(lineup).toHaveLength(2);
|
||||
expect(lineup[0].mediaItemId).toBe(1);
|
||||
expect(lineup[1].mediaItemId).toBe(2);
|
||||
expect(lineup[0].mediaType).toBe('TelevisionShow');
|
||||
});
|
||||
|
||||
it('highlights the offending lineup row and shows the detail on a 422 ProblemDetails', async () => {
|
||||
mockBuilderApi({
|
||||
...builderDefaults(),
|
||||
browseItems: [browseItem(), browseItem({ id: 2, mediaItemId: 2, title: 'Tom & Jerry' })],
|
||||
// Verbatim wire shape: ApiResults hard-codes the 422 title "Validation failed",
|
||||
// and the handler's not-found detail is "lineup[i] <Label> <id> does not exist."
|
||||
fromLineupFailure: { detail: 'lineup[1] TelevisionShow 999 does not exist.', status: 422, title: 'Validation failed' }
|
||||
});
|
||||
|
||||
const { container } = await renderBuilder();
|
||||
|
||||
fireEvent.doubleClick(await screen.findByText('Looney Tunes'));
|
||||
fireEvent.doubleClick(screen.getByText('Tom & Jerry'));
|
||||
fireEvent.change(screen.getByLabelText('Channel name'), { target: { value: 'Retro' } });
|
||||
|
||||
fireEvent.click(createBtn());
|
||||
|
||||
expect(await screen.findByText('lineup[1] TelevisionShow 999 does not exist.')).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
const rows = container.querySelectorAll('.ctv-builder-lineup-row');
|
||||
expect(rows[1].classList.contains('ctv-builder-lineup-row-error')).toBe(true);
|
||||
});
|
||||
expect(window.location.pathname).toBe('/app/new-channel');
|
||||
});
|
||||
|
||||
it('uploads the channel image then references the returned path in the create body', async () => {
|
||||
mockBuilderApi({ ...builderDefaults(), browseItems: [browseItem()] });
|
||||
|
||||
const { container } = await renderBuilder();
|
||||
|
||||
fireEvent.doubleClick(await screen.findByText('Looney Tunes'));
|
||||
fireEvent.change(screen.getByLabelText('Channel name'), { target: { value: 'Retro' } });
|
||||
|
||||
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
const file = new File(['logo-bytes'], 'logo.png', { type: 'image/png' });
|
||||
fireEvent.change(fileInput, { target: { files: [file] } });
|
||||
|
||||
fireEvent.click(createBtn());
|
||||
|
||||
await waitFor(() => expect(window.location.pathname).toBe('/app/channels'));
|
||||
|
||||
const uploadCall = vi
|
||||
.mocked(window.fetch)
|
||||
.mock.calls.find(([input]) => input.toString() === '/api/v1/artwork/uploads');
|
||||
expect(uploadCall).toBeDefined();
|
||||
const formData = uploadCall?.[1]?.body as FormData;
|
||||
expect(formData).toBeInstanceOf(FormData);
|
||||
expect(formData.get('target')).toBe('logo');
|
||||
expect((formData.get('file') as File).name).toBe('logo.png');
|
||||
|
||||
const body = requestBodyFor('/api/v1/channels/from-lineup');
|
||||
expect(body.logo).toEqual({ contentType: 'image/png', path: '/artwork/logo/uploaded.png' });
|
||||
});
|
||||
|
||||
it('fans out 5 typed browse requests in Collections mode and renders the merged, title-sorted items', async () => {
|
||||
const byType: Record<string, Record<string, unknown>> = {
|
||||
Collection: browseItem({
|
||||
id: 2,
|
||||
title: 'Saturday Cartoons',
|
||||
mediaType: 'Collection',
|
||||
collectionType: 'Collection',
|
||||
collectionKind: 'Manual',
|
||||
collectionId: 2,
|
||||
mediaItemId: undefined
|
||||
}),
|
||||
SmartCollection: browseItem({
|
||||
id: 3,
|
||||
title: 'Action Movies',
|
||||
mediaType: 'SmartCollection',
|
||||
collectionType: 'SmartCollection',
|
||||
collectionKind: 'Smart',
|
||||
smartCollectionId: 3,
|
||||
mediaItemId: undefined
|
||||
}),
|
||||
MultiCollection: browseItem({
|
||||
id: 7,
|
||||
title: 'Prime Time',
|
||||
mediaType: 'MultiCollection',
|
||||
collectionType: 'MultiCollection',
|
||||
collectionKind: 'Multi',
|
||||
multiCollectionId: 7,
|
||||
mediaItemId: undefined
|
||||
}),
|
||||
RerunCollection: browseItem({
|
||||
id: 9,
|
||||
title: 'Saturday Block',
|
||||
mediaType: 'RerunCollection',
|
||||
collectionType: 'RerunFirstRun',
|
||||
collectionKind: 'Rerun',
|
||||
rerunCollectionId: 9,
|
||||
mediaItemId: undefined
|
||||
}),
|
||||
Playlist: browseItem({
|
||||
id: 12,
|
||||
title: 'Zzz Late Night',
|
||||
mediaType: 'Playlist',
|
||||
collectionType: 'Playlist',
|
||||
playlistId: 12,
|
||||
mediaItemId: undefined
|
||||
})
|
||||
};
|
||||
|
||||
mockBuilderApi({
|
||||
...builderDefaults(),
|
||||
browseItems: [browseItem()],
|
||||
browseHandler: (search) => {
|
||||
const mediaType = search.get('mediaType');
|
||||
const item = mediaType ? byType[mediaType] : undefined;
|
||||
return item ? { page: [item], totalCount: 1 } : { page: [], totalCount: 0 };
|
||||
}
|
||||
});
|
||||
|
||||
const { container } = await renderBuilder();
|
||||
|
||||
// Wait for the initial library fan-out (Movie/TelevisionShow/Artist) to be
|
||||
// ISSUED before clearing the mock — renderBuilder only awaits the search
|
||||
// box, and React flushes passive effects asynchronously, so on a slow
|
||||
// machine the mount fan-out can otherwise leak past mockClear and pollute
|
||||
// the post-click assertion below.
|
||||
await waitFor(() => {
|
||||
const browseCalls = vi
|
||||
.mocked(window.fetch)
|
||||
.mock.calls.filter(([input]) => input.toString().startsWith('/api/v1/library/browse'));
|
||||
expect(browseCalls.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
// Only count requests made after switching to the Collections tab.
|
||||
vi.mocked(window.fetch).mockClear();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Collections' }));
|
||||
|
||||
expect(await screen.findByText('Action Movies')).toBeInTheDocument();
|
||||
|
||||
const browseTypes = vi
|
||||
.mocked(window.fetch)
|
||||
.mock.calls.map(([input]) => input.toString())
|
||||
.filter((url) => url.startsWith('/api/v1/library/browse'))
|
||||
.map((url) => new URL(url, window.location.origin).searchParams.get('mediaType'))
|
||||
.filter((mediaType): mediaType is string => mediaType != null);
|
||||
|
||||
expect(browseTypes).toEqual(['Collection', 'SmartCollection', 'MultiCollection', 'RerunCollection', 'Playlist']);
|
||||
|
||||
const titles = Array.from(container.querySelectorAll('.ctv-builder-poster-title')).map((el) => el.textContent);
|
||||
expect(titles).toEqual(['Action Movies', 'Prime Time', 'Saturday Block', 'Saturday Cartoons', 'Zzz Late Night']);
|
||||
});
|
||||
|
||||
it('loads a second page of library results and preserves the first page', async () => {
|
||||
const pages = [
|
||||
[browseItem({ id: 1, mediaItemId: 1, title: 'Item A' }), browseItem({ id: 2, mediaItemId: 2, title: 'Item B' })],
|
||||
[browseItem({ id: 3, mediaItemId: 3, title: 'Item C' })]
|
||||
];
|
||||
|
||||
mockBuilderApi({
|
||||
...builderDefaults(),
|
||||
browseHandler: (search) => {
|
||||
// The builder fans out per-kind (Movie/TelevisionShow/TelevisionSeason/Artist);
|
||||
// only return real pages for the kind these fixtures use (TelevisionShow) so
|
||||
// the other 3 kinds don't duplicate the same items.
|
||||
if (search.get('mediaType') !== 'TelevisionShow') {
|
||||
return { page: [], totalCount: 0 };
|
||||
}
|
||||
const pageNum = Number(search.get('pageNum') ?? '0');
|
||||
return { page: pages[pageNum] ?? [], totalCount: 3 };
|
||||
}
|
||||
});
|
||||
|
||||
await renderBuilder();
|
||||
|
||||
expect(await screen.findByText('Item A')).toBeInTheDocument();
|
||||
expect(screen.getByText('Item B')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Item C')).not.toBeInTheDocument();
|
||||
|
||||
const loadMoreButton = screen.getByRole('button', { name: 'Load more' });
|
||||
fireEvent.click(loadMoreButton);
|
||||
|
||||
expect(await screen.findByText('Item C')).toBeInTheDocument();
|
||||
expect(screen.getByText('Item A')).toBeInTheDocument();
|
||||
expect(screen.getByText('Item B')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Load more' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('saves the current settings as a new template and selects it', async () => {
|
||||
const created = channelTemplate({
|
||||
id: 55,
|
||||
name: 'Saturday Special',
|
||||
description: 'Custom mix.',
|
||||
isDefault: false,
|
||||
isSystem: false,
|
||||
shuffleScheduleItems: true
|
||||
});
|
||||
|
||||
mockBuilderApi({
|
||||
...builderDefaults(),
|
||||
browseItems: [browseItem()],
|
||||
createTemplateResponse: created
|
||||
});
|
||||
|
||||
const { container } = await renderBuilder();
|
||||
|
||||
const shuffleRow = container.querySelector('.ctv-builder-toggle-row:not(.ctv-builder-toggle-live)') as HTMLElement;
|
||||
fireEvent.click(shuffleRow);
|
||||
|
||||
fireEvent.click(screen.getByText('Standard'));
|
||||
fireEvent.click(await screen.findByText('Save current settings as template…'));
|
||||
|
||||
expect(await screen.findByText('Save as channel template')).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Template name'), { target: { value: 'Saturday Special' } });
|
||||
fireEvent.change(screen.getByLabelText('Description'), { target: { value: 'Custom mix.' } });
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save template' }));
|
||||
|
||||
await waitFor(() => expect(screen.queryByText('Save as channel template')).not.toBeInTheDocument());
|
||||
|
||||
const body = requestBodyFor('/api/v1/channel-templates');
|
||||
expect(body.name).toBe('Saturday Special');
|
||||
expect(body.description).toBe('Custom mix.');
|
||||
expect(body.shuffleScheduleItems).toBe(true);
|
||||
expect(body.playoutMode).toBe('Continuous');
|
||||
|
||||
expect(screen.getByText('Saturday Special')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows a friendly error and skips channel creation when the artwork upload returns a bare 413', async () => {
|
||||
mockBuilderApi({
|
||||
...builderDefaults(),
|
||||
browseItems: [browseItem()],
|
||||
artworkUploadFailure: { status: 413 }
|
||||
});
|
||||
|
||||
const { container } = await renderBuilder();
|
||||
|
||||
fireEvent.doubleClick(await screen.findByText('Looney Tunes'));
|
||||
fireEvent.change(screen.getByLabelText('Channel name'), { target: { value: 'Retro' } });
|
||||
|
||||
const fileInput = container.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
const file = new File(['logo-bytes'], 'logo.png', { type: 'image/png' });
|
||||
fireEvent.change(fileInput, { target: { files: [file] } });
|
||||
|
||||
fireEvent.click(createBtn());
|
||||
|
||||
expect(await screen.findByText('Image is too large (max 30 MB).')).toBeInTheDocument();
|
||||
expect(window.location.pathname).toBe('/app/new-channel');
|
||||
expect(
|
||||
vi.mocked(window.fetch).mock.calls.some(([input]) => input.toString() === '/api/v1/channels/from-lineup')
|
||||
).toBe(false);
|
||||
expect(createBtn()).toBeEnabled();
|
||||
});
|
||||
|
||||
it('renders the empty-templates state and keeps Create disabled with no templates', async () => {
|
||||
mockBuilderApi({
|
||||
...builderDefaults(),
|
||||
channelTemplates: [],
|
||||
defaultChannelTemplate: null,
|
||||
browseItems: [browseItem()]
|
||||
});
|
||||
|
||||
await renderBuilder();
|
||||
|
||||
expect(
|
||||
screen.getByText(/No channel templates exist yet\. Create one in Settings before building a channel/)
|
||||
).toBeInTheDocument();
|
||||
|
||||
fireEvent.doubleClick(await screen.findByText('Looney Tunes'));
|
||||
fireEvent.change(screen.getByLabelText('Channel name'), { target: { value: 'Retro Cartoons' } });
|
||||
|
||||
expect(createBtn()).toBeDisabled();
|
||||
});
|
||||
|
||||
it('sends a Collection lineup item with only its typed id populated', async () => {
|
||||
mockBuilderApi({
|
||||
...builderDefaults(),
|
||||
browseItems: [
|
||||
browseItem({
|
||||
id: 2,
|
||||
title: 'Saturday Cartoons',
|
||||
mediaType: 'Collection',
|
||||
collectionType: 'Collection',
|
||||
collectionKind: 'Manual',
|
||||
collectionId: 2,
|
||||
mediaItemId: undefined
|
||||
})
|
||||
]
|
||||
});
|
||||
|
||||
await renderBuilder();
|
||||
|
||||
// Collection is a collections kind, not one of the 4 library kinds —
|
||||
// find it under the Collections tab.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Collections' }));
|
||||
fireEvent.doubleClick(await screen.findByText('Saturday Cartoons'));
|
||||
fireEvent.change(screen.getByLabelText('Channel name'), { target: { value: 'Marathon' } });
|
||||
|
||||
fireEvent.click(createBtn());
|
||||
|
||||
await waitFor(() => expect(window.location.pathname).toBe('/app/channels'));
|
||||
|
||||
const body = requestBodyFor('/api/v1/channels/from-lineup');
|
||||
const lineup = body.lineup as Array<Record<string, unknown>>;
|
||||
expect(lineup).toHaveLength(1);
|
||||
expect(lineup[0].mediaType).toBe('Collection');
|
||||
expect(lineup[0].collectionType).toBe('Collection');
|
||||
expect(lineup[0].collectionId).toBe(2);
|
||||
expect(lineup[0].mediaItemId).toBeUndefined();
|
||||
expect(lineup[0].multiCollectionId).toBeUndefined();
|
||||
expect(lineup[0].smartCollectionId).toBeUndefined();
|
||||
expect(lineup[0].rerunCollectionId).toBeUndefined();
|
||||
expect(lineup[0].playlistId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('renders the builder under the cool and dual design themes', async () => {
|
||||
mockBuilderApi({ ...builderDefaults(), browseItems: [browseItem()] });
|
||||
|
||||
await renderBuilder();
|
||||
|
||||
applyDesignSystemTheme('cool');
|
||||
expect(document.documentElement).toHaveAttribute('data-theme', 'cool');
|
||||
expect(builderTitle()).toBeInTheDocument();
|
||||
|
||||
applyDesignSystemTheme('dual');
|
||||
expect(document.documentElement).toHaveAttribute('data-theme', 'dual');
|
||||
expect(screen.getByPlaceholderText('Search shows & movies…')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -764,6 +764,11 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
/* The SPA ships no global box-sizing reset (default content-box), so width:100% + the
|
||||
horizontal padding below would otherwise overflow .ctv-nav (overflow:auto) by 20px and
|
||||
produce a horizontal scrollbar in the sidebar (#377). Border-box keeps the item exactly
|
||||
the nav content width, padding included. */
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
height: 34px;
|
||||
border: 0;
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
Input,
|
||||
ProgressBar,
|
||||
Switch,
|
||||
Tabs,
|
||||
Toast,
|
||||
Tooltip
|
||||
} from '.';
|
||||
|
||||
describe('component primitives', () => {
|
||||
afterEach(cleanup);
|
||||
|
||||
it('exports typed primitives with expected interactions', () => {
|
||||
const onSwitch = vi.fn();
|
||||
const onCheckbox = vi.fn();
|
||||
const onTab = vi.fn();
|
||||
|
||||
render(
|
||||
<>
|
||||
<Button variant="primary" loading>
|
||||
Saving
|
||||
</Button>
|
||||
<Switch checked={false} onChange={onSwitch} label="Show disabled" />
|
||||
<Checkbox indeterminate onChange={onCheckbox} label="Select all" />
|
||||
<Input label="Channel number" value="5.1" error="Already in use" onChange={() => {}} />
|
||||
<ProgressBar value={62} showLabel />
|
||||
<Tabs
|
||||
value="streaming"
|
||||
onChange={onTab}
|
||||
tabs={[{ value: 'streaming', label: 'Streaming' }]}
|
||||
/>
|
||||
<Tooltip label="Reset playout">
|
||||
<button type="button">Reset</button>
|
||||
</Tooltip>
|
||||
</>
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Saving' })).toBeDisabled();
|
||||
fireEvent.click(screen.getByRole('switch', { name: 'Show disabled' }));
|
||||
expect(onSwitch).toHaveBeenCalledWith(true);
|
||||
expect(screen.getByRole('checkbox', { name: 'Select all' })).toHaveAttribute(
|
||||
'aria-checked',
|
||||
'mixed'
|
||||
);
|
||||
expect(screen.getByText('Already in use')).toBeInTheDocument();
|
||||
expect(screen.getByText('62%')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Streaming' }));
|
||||
expect(onTab).toHaveBeenCalledWith('streaming');
|
||||
fireEvent.mouseEnter(screen.getByText('Reset'));
|
||||
expect(screen.getByRole('tooltip')).toHaveTextContent('Reset playout');
|
||||
});
|
||||
|
||||
it('uses distinct semantic icons for warning and error toasts', () => {
|
||||
const { container } = render(
|
||||
<>
|
||||
<Toast tone="warn" title="Check settings" />
|
||||
<Toast tone="error" title="Save failed" />
|
||||
</>
|
||||
);
|
||||
|
||||
expect(container.querySelector('.lucide-triangle-alert')).toBeInTheDocument();
|
||||
expect(container.querySelector('.lucide-circle-x')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -25,48 +25,63 @@ describe('initialPinFlowState', () => {
|
||||
|
||||
describe('evaluatePinFlow — all transitions with injected state', () => {
|
||||
it('locked & !authorized => waiting (keep polling)', () => {
|
||||
const state = evaluatePinFlow({ isLocked: true, isAuthorized: false });
|
||||
const state = evaluatePinFlow({ isLocked: true, isAuthorized: false, hasServers: false });
|
||||
expect(state.status).toBe('waiting');
|
||||
expect(state.done).toBe(false);
|
||||
expect(state.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('locked & authorized => finalizing (keep polling, discovering servers)', () => {
|
||||
const state = evaluatePinFlow({ isLocked: true, isAuthorized: true });
|
||||
const state = evaluatePinFlow({ isLocked: true, isAuthorized: true, hasServers: false });
|
||||
expect(state.status).toBe('finalizing');
|
||||
expect(state.done).toBe(false);
|
||||
expect(state.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('!locked & authorized => success (terminal)', () => {
|
||||
const state = evaluatePinFlow({ isLocked: false, isAuthorized: true });
|
||||
it('!locked & authorized & servers => success (terminal)', () => {
|
||||
const state = evaluatePinFlow({ isLocked: false, isAuthorized: true, hasServers: true });
|
||||
expect(state.status).toBe('success');
|
||||
expect(state.done).toBe(true);
|
||||
expect(state.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('!locked & authorized & NO servers => authorized-no-servers (terminal, recoverable, not ok) — #345', () => {
|
||||
const state = evaluatePinFlow({ isLocked: false, isAuthorized: true, hasServers: false });
|
||||
expect(state.status).toBe('authorized-no-servers');
|
||||
expect(state.done).toBe(true); // terminal: the lock has released, polling stops
|
||||
expect(state.ok).toBe(false); // authorized, but NOT "Connected to Plex."
|
||||
expect(state.message).not.toBe('Connected to Plex.');
|
||||
expect(state.message).toMatch(/no eligible servers/i);
|
||||
});
|
||||
|
||||
it('!locked & !authorized => timeout (terminal, ended without auth)', () => {
|
||||
const state = evaluatePinFlow({ isLocked: false, isAuthorized: false });
|
||||
const state = evaluatePinFlow({ isLocked: false, isAuthorized: false, hasServers: false });
|
||||
expect(state.status).toBe('timeout');
|
||||
expect(state.done).toBe(true);
|
||||
expect(state.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('locked & budget exhausted => budget-exhausted (terminal, not a failure)', () => {
|
||||
const state = evaluatePinFlow({ isLocked: true, isAuthorized: true }, { budgetExhausted: true });
|
||||
const state = evaluatePinFlow({ isLocked: true, isAuthorized: true, hasServers: false }, { budgetExhausted: true });
|
||||
expect(state.status).toBe('budget-exhausted');
|
||||
expect(state.done).toBe(true);
|
||||
expect(state.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('budget exhaustion is ignored once the lock has released (success still wins)', () => {
|
||||
const state = evaluatePinFlow({ isLocked: false, isAuthorized: true }, { budgetExhausted: true });
|
||||
const state = evaluatePinFlow({ isLocked: false, isAuthorized: true, hasServers: true }, { budgetExhausted: true });
|
||||
expect(state.status).toBe('success');
|
||||
expect(state.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('budget exhaustion is ignored once the lock has released (authorized-no-servers still wins)', () => {
|
||||
const state = evaluatePinFlow({ isLocked: false, isAuthorized: true, hasServers: false }, { budgetExhausted: true });
|
||||
expect(state.status).toBe('authorized-no-servers');
|
||||
expect(state.ok).toBe(false);
|
||||
});
|
||||
|
||||
it('budget exhaustion is ignored once the lock has released (timeout still wins)', () => {
|
||||
const state = evaluatePinFlow({ isLocked: false, isAuthorized: false }, { budgetExhausted: true });
|
||||
const state = evaluatePinFlow({ isLocked: false, isAuthorized: false, hasServers: false }, { budgetExhausted: true });
|
||||
expect(state.status).toBe('timeout');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,26 +1,40 @@
|
||||
// The Plex OAuth pin-flow poll STATE MACHINE (design §C1 step 5), as a pure, timer-free module so
|
||||
// the transitions can be unit-tested with injected state. The Plex screen (slice S6b) drives it:
|
||||
// after `POST /pin-flow` it opens the auth URL and polls `GET /api/v1/media-sources/plex` every 2s for
|
||||
// up to 150s, feeding each observation ({ isLocked, isAuthorized }) here to decide what to show and
|
||||
// whether to keep polling.
|
||||
// up to 150s, feeding each observation ({ isLocked, isAuthorized, hasServers }) here to decide what to
|
||||
// show and whether to keep polling.
|
||||
//
|
||||
// KEY INSIGHT (finding 5): `isAuthorized` flips true the moment the token is saved, which is BEFORE
|
||||
// SynchronizePlexMediaSources discovers the servers and releases the Plex lock. So authorization
|
||||
// alone is NOT success — the terminal success signal is the lock RELEASING while authorized. Poll
|
||||
// until the lock releases, not until authorized.
|
||||
//
|
||||
// isLocked & !isAuthorized -> waiting (keep polling; user hasn't authorized yet)
|
||||
// isLocked & isAuthorized -> finalizing (keep polling; discovering servers)
|
||||
// !isLocked & isAuthorized -> success (terminal)
|
||||
// !isLocked & !isAuthorized -> timeout (terminal; flow ended without auth)
|
||||
// isLocked (budget spent) -> budget-exhausted (terminal; a large first sync legitimately
|
||||
// still holds the lock — not a failure)
|
||||
// AUTHORIZED-BUT-EMPTY (#345): authorization and server discovery are distinct outcomes. A real flow
|
||||
// reached `isAuthorized: true`, `isLocked: false`, `servers: []` (a Plex account that owns/has been
|
||||
// granted no eligible servers). That is a coherent, recoverable terminal — NOT "Connected to Plex." —
|
||||
// so the released-lock success branch further splits on whether any server was actually discovered.
|
||||
//
|
||||
// isLocked & !isAuthorized -> waiting (keep polling; user hasn't authorized yet)
|
||||
// isLocked & isAuthorized -> finalizing (keep polling; discovering servers)
|
||||
// !isLocked & isAuthorized & servers -> success (terminal; connected to a server)
|
||||
// !isLocked & isAuthorized & !servers -> authorized-no-servers (terminal; authorized, none discovered)
|
||||
// !isLocked & !isAuthorized -> timeout (terminal; flow ended without auth)
|
||||
// isLocked (budget spent) -> budget-exhausted (terminal; a large first sync legitimately
|
||||
// still holds the lock — not a failure)
|
||||
|
||||
export type PinFlowStatus = 'waiting' | 'finalizing' | 'success' | 'timeout' | 'budget-exhausted';
|
||||
export type PinFlowStatus =
|
||||
| 'waiting'
|
||||
| 'finalizing'
|
||||
| 'success'
|
||||
| 'authorized-no-servers'
|
||||
| 'timeout'
|
||||
| 'budget-exhausted';
|
||||
|
||||
export interface PinFlowObservation {
|
||||
isLocked: boolean;
|
||||
isAuthorized: boolean;
|
||||
/** Whether server discovery has surfaced at least one server for this authorization. */
|
||||
hasServers: boolean;
|
||||
}
|
||||
|
||||
export interface PinFlowState {
|
||||
@@ -41,14 +55,22 @@ const MESSAGES: Record<PinFlowStatus, string> = {
|
||||
waiting: 'Waiting for you to authorize in the Plex tab…',
|
||||
finalizing: 'Authorized — discovering your Plex servers…',
|
||||
success: 'Connected to Plex.',
|
||||
'authorized-no-servers': 'Signed in to Plex, but no eligible servers were discovered.',
|
||||
timeout: 'Plex sign-in timed out — try again.',
|
||||
'budget-exhausted': 'Still working — this can take a while on a large first sync. Use Refresh to check again.'
|
||||
};
|
||||
|
||||
const TERMINAL: ReadonlySet<PinFlowStatus> = new Set<PinFlowStatus>([
|
||||
'success',
|
||||
'authorized-no-servers',
|
||||
'timeout',
|
||||
'budget-exhausted'
|
||||
]);
|
||||
|
||||
function stateFor(status: PinFlowStatus): PinFlowState {
|
||||
return {
|
||||
status,
|
||||
done: status === 'success' || status === 'timeout' || status === 'budget-exhausted',
|
||||
done: TERMINAL.has(status),
|
||||
ok: status === 'success',
|
||||
message: MESSAGES[status]
|
||||
};
|
||||
@@ -73,11 +95,15 @@ export function evaluatePinFlow(
|
||||
observation: PinFlowObservation,
|
||||
options: { budgetExhausted?: boolean } = {}
|
||||
): PinFlowState {
|
||||
const { isLocked, isAuthorized } = observation;
|
||||
const { isLocked, isAuthorized, hasServers } = observation;
|
||||
|
||||
// Lock released: terminal either way. Success iff authorized.
|
||||
// Lock released: terminal. Not authorized at all -> timeout. Authorized -> success only when a
|
||||
// server was actually discovered; authorized-but-empty is its own recoverable terminal (#345).
|
||||
if (!isLocked) {
|
||||
return stateFor(isAuthorized ? 'success' : 'timeout');
|
||||
if (!isAuthorized) {
|
||||
return stateFor('timeout');
|
||||
}
|
||||
return stateFor(hasServers ? 'success' : 'authorized-no-servers');
|
||||
}
|
||||
|
||||
// Still locked but out of budget: not a failure, just "still working".
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { PrimaryActionProvider, usePrimaryAction, usePrimaryActionHandler } from './primaryAction';
|
||||
|
||||
function Registration({ routeId, handler }: { routeId: string; handler: () => void }) {
|
||||
usePrimaryAction(routeId, handler);
|
||||
return null;
|
||||
}
|
||||
|
||||
function Action({ routeId }: { routeId: string }) {
|
||||
const handler = usePrimaryActionHandler(routeId);
|
||||
return (
|
||||
<button type="button" disabled={!handler} onClick={handler}>
|
||||
Run {routeId}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
describe('primary actions', () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('invokes the active handler for a matching route', () => {
|
||||
const handler = vi.fn();
|
||||
render(
|
||||
<PrimaryActionProvider>
|
||||
<Registration routeId="schedules" handler={handler} />
|
||||
<Action routeId="schedules" />
|
||||
</PrimaryActionProvider>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Run schedules' }));
|
||||
|
||||
expect(handler).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('returns no handler for a nonmatching route', () => {
|
||||
const handler = vi.fn();
|
||||
render(
|
||||
<PrimaryActionProvider>
|
||||
<Registration routeId="schedules" handler={handler} />
|
||||
<Action routeId="channels" />
|
||||
</PrimaryActionProvider>
|
||||
);
|
||||
|
||||
const action = screen.getByRole('button', { name: 'Run channels' });
|
||||
expect(action).toBeDisabled();
|
||||
fireEvent.click(action);
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('invokes the latest handler without replacing the registration', () => {
|
||||
const firstHandler = vi.fn();
|
||||
const latestHandler = vi.fn();
|
||||
const view = render(
|
||||
<PrimaryActionProvider>
|
||||
<Registration routeId="schedules" handler={firstHandler} />
|
||||
<Action routeId="schedules" />
|
||||
</PrimaryActionProvider>
|
||||
);
|
||||
|
||||
view.rerender(
|
||||
<PrimaryActionProvider>
|
||||
<Registration routeId="schedules" handler={latestHandler} />
|
||||
<Action routeId="schedules" />
|
||||
</PrimaryActionProvider>
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Run schedules' }));
|
||||
|
||||
expect(firstHandler).not.toHaveBeenCalled();
|
||||
expect(latestHandler).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("moves the same owner's registration to a new route and clears it on unmount", () => {
|
||||
const handler = vi.fn();
|
||||
const view = render(
|
||||
<PrimaryActionProvider>
|
||||
<Registration routeId="schedules" handler={handler} />
|
||||
<Action routeId="schedules" />
|
||||
<Action routeId="channels" />
|
||||
</PrimaryActionProvider>
|
||||
);
|
||||
|
||||
view.rerender(
|
||||
<PrimaryActionProvider>
|
||||
<Registration routeId="channels" handler={handler} />
|
||||
<Action routeId="schedules" />
|
||||
<Action routeId="channels" />
|
||||
</PrimaryActionProvider>
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Run schedules' })).toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: 'Run channels' })).toBeEnabled();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Run channels' }));
|
||||
expect(handler).toHaveBeenCalledOnce();
|
||||
|
||||
view.rerender(
|
||||
<PrimaryActionProvider>
|
||||
<Action routeId="schedules" />
|
||||
<Action routeId="channels" />
|
||||
</PrimaryActionProvider>
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Run channels' })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('does not let an older owner rerender or cleanup reclaim a newer registration', () => {
|
||||
const olderHandler = vi.fn();
|
||||
const updatedOlderHandler = vi.fn();
|
||||
const newerHandler = vi.fn();
|
||||
const view = render(
|
||||
<PrimaryActionProvider>
|
||||
<Registration key="older" routeId="schedules" handler={olderHandler} />
|
||||
<Action routeId="schedules" />
|
||||
</PrimaryActionProvider>
|
||||
);
|
||||
|
||||
view.rerender(
|
||||
<PrimaryActionProvider>
|
||||
<Registration key="older" routeId="schedules" handler={olderHandler} />
|
||||
<Registration key="newer" routeId="schedules" handler={newerHandler} />
|
||||
<Action routeId="schedules" />
|
||||
</PrimaryActionProvider>
|
||||
);
|
||||
view.rerender(
|
||||
<PrimaryActionProvider>
|
||||
<Registration key="older" routeId="schedules" handler={updatedOlderHandler} />
|
||||
<Registration key="newer" routeId="schedules" handler={newerHandler} />
|
||||
<Action routeId="schedules" />
|
||||
</PrimaryActionProvider>
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Run schedules' }));
|
||||
expect(newerHandler).toHaveBeenCalledOnce();
|
||||
|
||||
view.rerender(
|
||||
<PrimaryActionProvider>
|
||||
<Registration key="newer" routeId="schedules" handler={newerHandler} />
|
||||
<Action routeId="schedules" />
|
||||
</PrimaryActionProvider>
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Run schedules' }));
|
||||
|
||||
expect(olderHandler).not.toHaveBeenCalled();
|
||||
expect(updatedOlderHandler).not.toHaveBeenCalled();
|
||||
expect(newerHandler).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('lets screens render harmlessly without a provider', () => {
|
||||
expect(() => render(<Registration routeId="schedules" handler={vi.fn()} />)).not.toThrow();
|
||||
});
|
||||
});
|
||||
+57
-28
@@ -1,39 +1,68 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import {
|
||||
createContext,
|
||||
createElement,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState
|
||||
} from 'react';
|
||||
|
||||
// The TopBar renders one "primary action" button per screen (top-right). Because the
|
||||
// TopBar and the screens are decoupled (the TopBar has no reference to the active
|
||||
// screen component), the click is delivered as a window CustomEvent keyed on the
|
||||
// active route id; a screen opts in with `usePrimaryAction(routeId, handler)`.
|
||||
//
|
||||
// A screen that does NOT call usePrimaryAction gets NO button — App.tsx's route table
|
||||
// declares an empty `primaryAction` for such screens and the TopBar suppresses the
|
||||
// button (see issue #238: the old code rendered a dead button for every unwired route).
|
||||
export const PRIMARY_ACTION_EVENT = 'ctv:primary-action';
|
||||
type PrimaryActionHandler = () => void;
|
||||
|
||||
export function dispatchPrimaryAction(routeId: string): void {
|
||||
window.dispatchEvent(new CustomEvent(PRIMARY_ACTION_EVENT, { detail: routeId }));
|
||||
interface PrimaryActionRegistration {
|
||||
owner: symbol;
|
||||
routeId: string;
|
||||
handler: PrimaryActionHandler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe a screen to its TopBar primary-action button. The handler runs whenever the
|
||||
* TopBar dispatches `ctv:primary-action` with a detail matching `routeId`. The latest
|
||||
* handler is held in a ref so re-renders don't churn the window listener.
|
||||
*/
|
||||
export function usePrimaryAction(routeId: string, handler: () => void): void {
|
||||
interface PrimaryActionContextValue {
|
||||
registration: PrimaryActionRegistration | undefined;
|
||||
register: (owner: symbol, routeId: string, handler: PrimaryActionHandler) => () => void;
|
||||
}
|
||||
|
||||
const PrimaryActionContext = createContext<PrimaryActionContextValue | undefined>(undefined);
|
||||
|
||||
/** Owns the single primary-action registration shared by the shell and active screen. */
|
||||
export function PrimaryActionProvider({ children }: { children: ReactNode }) {
|
||||
const [registration, setRegistration] = useState<PrimaryActionRegistration>();
|
||||
|
||||
const register = useCallback((owner: symbol, routeId: string, handler: PrimaryActionHandler) => {
|
||||
setRegistration({ owner, routeId, handler });
|
||||
|
||||
return () => {
|
||||
setRegistration((current) => (current?.owner === owner ? undefined : current));
|
||||
};
|
||||
}, []);
|
||||
|
||||
const value = useMemo(() => ({ registration, register }), [register, registration]);
|
||||
|
||||
return createElement(PrimaryActionContext.Provider, { value }, children);
|
||||
}
|
||||
|
||||
/** Register a screen's primary action. Rendering outside the provider is intentionally harmless. */
|
||||
export function usePrimaryAction(routeId: string, handler: PrimaryActionHandler): void {
|
||||
const handlerRef = useRef(handler);
|
||||
// Keep the ref pointing at the latest handler without re-subscribing the window listener
|
||||
// on every render. Updating a ref during render trips react-hooks/refs, so do it in an effect.
|
||||
const [owner] = useState(() => Symbol('primary-action-owner'));
|
||||
const register = useContext(PrimaryActionContext)?.register;
|
||||
|
||||
useEffect(() => {
|
||||
handlerRef.current = handler;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const listener = (event: Event) => {
|
||||
if ((event as CustomEvent<string>).detail === routeId) {
|
||||
handlerRef.current();
|
||||
}
|
||||
};
|
||||
window.addEventListener(PRIMARY_ACTION_EVENT, listener);
|
||||
return () => window.removeEventListener(PRIMARY_ACTION_EVENT, listener);
|
||||
}, [routeId]);
|
||||
if (!register) {
|
||||
return;
|
||||
}
|
||||
|
||||
return register(owner, routeId, () => handlerRef.current());
|
||||
}, [owner, register, routeId]);
|
||||
}
|
||||
|
||||
/** Return the active handler only when its registration belongs to the requested route. */
|
||||
export function usePrimaryActionHandler(routeId: string): PrimaryActionHandler | undefined {
|
||||
const registration = useContext(PrimaryActionContext)?.registration;
|
||||
return registration?.routeId === routeId ? registration.handler : undefined;
|
||||
}
|
||||
|
||||
@@ -170,4 +170,100 @@ describe('PlexSourceScreen', () => {
|
||||
expect(fetchSpy.mock.calls.some(([input, init]) => String(input).endsWith('/sign-out') && (init?.method ?? '').toUpperCase() === 'POST')).toBe(true)
|
||||
);
|
||||
});
|
||||
|
||||
// #345: a real pin flow reached isAuthorized:true, isLocked:false, servers:[] (a Plex account that
|
||||
// owns no eligible servers). The UI used to claim "Connected to Plex." while the Connection card said
|
||||
// "Not signed in.", offered Sign in, and hid Sign out — so the stored authorization was unremovable.
|
||||
it('pin flow ending authorized/unlocked with NO servers is coherent and recoverable', async () => {
|
||||
const stateRef = { current: { isAuthorized: false, isLocked: false, servers: [] } as PlexState };
|
||||
const counter = { state: 0 };
|
||||
installFetch(stateRef, counter);
|
||||
vi.spyOn(window, 'open').mockReturnValue({} as Window);
|
||||
vi.useFakeTimers();
|
||||
|
||||
render(<PlexSourceScreen />);
|
||||
await vi.waitFor(() => expect(screen.getByRole('button', { name: 'Sign in to Plex' })).toBeTruthy());
|
||||
|
||||
stateRef.current = { isAuthorized: false, isLocked: true, servers: [] };
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Sign in to Plex' }));
|
||||
await vi.waitFor(() => expect(screen.getByText(/Waiting for you to authorize/i)).toBeTruthy());
|
||||
|
||||
// Authorized but STILL LOCKED = finalizing (discovery in progress). The empty-servers terminal
|
||||
// messaging must NOT appear here — while locked this is the normal "discovering" window, not
|
||||
// "no servers were found" (regression guard for the isLocked gate).
|
||||
stateRef.current = { isAuthorized: true, isLocked: true, servers: [] };
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
await vi.waitFor(() => expect(screen.getByText(/discovering your Plex servers/i)).toBeTruthy());
|
||||
expect(screen.queryByText(/Your Plex account is authorized/i)).toBeNull(); // no "sign out and retry" summary
|
||||
expect(screen.queryByText(/no eligible servers were discovered/i)).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: 'Sign in to Plex' })).toBeNull();
|
||||
|
||||
// Lock releases while authorized but discovery found nothing: the distinct terminal state.
|
||||
stateRef.current = { isAuthorized: true, isLocked: false, servers: [] };
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
|
||||
// Pin-flow message distinguishes authorization from server discovery (does not claim connection).
|
||||
await vi.waitFor(() =>
|
||||
expect(screen.getByText('Signed in to Plex, but no eligible servers were discovered.')).toBeTruthy()
|
||||
);
|
||||
expect(screen.queryByText('Connected to Plex.')).toBeNull();
|
||||
// The explanatory summary is shown (distinct wording anchors it).
|
||||
expect(screen.getByText(/Your Plex account is authorized/i)).toBeTruthy();
|
||||
// No Servers card, and the Connection card + actions agree: Sign out offered, Sign in withheld.
|
||||
expect(screen.queryByRole('heading', { name: 'Servers' })).toBeNull();
|
||||
expect(screen.getByRole('button', { name: 'Sign out of Plex' })).toBeTruthy();
|
||||
expect(screen.queryByRole('button', { name: 'Sign in to Plex' })).toBeNull();
|
||||
|
||||
// Polling stopped on the terminal state.
|
||||
const callsAfterTerminal = counter.state;
|
||||
await vi.advanceTimersByTimeAsync(6000);
|
||||
expect(counter.state).toBe(callsAfterTerminal);
|
||||
});
|
||||
|
||||
it('authorized/unlocked/empty state loaded fresh (no pin flow) can sign out', async () => {
|
||||
// The reload path: no pinState, not polling — driven purely by the loaded RemoteMediaSourceState.
|
||||
const stateRef = { current: { isAuthorized: true, isLocked: false, servers: [] } as PlexState };
|
||||
const counter = { state: 0 };
|
||||
const fetchSpy = installFetch(stateRef, counter);
|
||||
|
||||
render(<PlexSourceScreen />);
|
||||
|
||||
// The explanatory summary and Sign-out action reflect "authorized, no servers" (not "Not signed in.").
|
||||
await screen.findByText(/Your Plex account is authorized/i);
|
||||
expect(screen.queryByRole('button', { name: 'Sign in to Plex' })).toBeNull();
|
||||
expect(screen.queryByText('Not signed in.')).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Sign out of Plex' }));
|
||||
expect(await screen.findByText(/removes all synced Plex servers/i)).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Sign out' }));
|
||||
await vi.waitFor(() =>
|
||||
expect(fetchSpy.mock.calls.some(([input, init]) => String(input).endsWith('/sign-out') && (init?.method ?? '').toUpperCase() === 'POST')).toBe(true)
|
||||
);
|
||||
});
|
||||
|
||||
// #345 (review follow-up): a large first sync legitimately still holds the lock past the poll
|
||||
// budget (budget-exhausted). The empty-servers "sign out and retry" messaging must NOT show — it
|
||||
// would contradict the "Still working…" status and urge aborting an in-progress sync.
|
||||
it('budget-exhausted while locked with no servers does not show the "no servers, sign out" messaging', async () => {
|
||||
const stateRef = { current: { isAuthorized: false, isLocked: false, servers: [] } as PlexState };
|
||||
const counter = { state: 0 };
|
||||
installFetch(stateRef, counter);
|
||||
vi.spyOn(window, 'open').mockReturnValue({} as Window);
|
||||
vi.useFakeTimers();
|
||||
|
||||
render(<PlexSourceScreen />);
|
||||
await vi.waitFor(() => expect(screen.getByRole('button', { name: 'Sign in to Plex' })).toBeTruthy());
|
||||
|
||||
// Authorized but locked forever (large first sync holds the lock).
|
||||
stateRef.current = { isAuthorized: true, isLocked: true, servers: [] };
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Sign in to Plex' }));
|
||||
await vi.waitFor(() => expect(screen.getByText(/discovering your Plex servers|Waiting/i)).toBeTruthy());
|
||||
|
||||
await vi.advanceTimersByTimeAsync(152_000); // consume the whole 150s budget
|
||||
await vi.waitFor(() => expect(screen.getByText(/Still working/i)).toBeTruthy());
|
||||
|
||||
// The lock is still held, so no terminal "no servers were found" messaging.
|
||||
expect(screen.queryByText(/Your Plex account is authorized/i)).toBeNull();
|
||||
expect(screen.queryByText(/no eligible servers were discovered/i)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -84,7 +84,10 @@ export function PlexSourceScreen() {
|
||||
}
|
||||
setBoot({ status: 'ready', state });
|
||||
const elapsed = Date.now() - startedAtRef.current;
|
||||
const next = evaluatePinFlow(state, { budgetExhausted: isPinFlowBudgetExhausted(elapsed) });
|
||||
const next = evaluatePinFlow(
|
||||
{ isLocked: state.isLocked, isAuthorized: state.isAuthorized, hasServers: (state.servers ?? []).length > 0 },
|
||||
{ budgetExhausted: isPinFlowBudgetExhausted(elapsed) }
|
||||
);
|
||||
setPinState(next);
|
||||
if (next.done) {
|
||||
setPolling(false);
|
||||
@@ -210,8 +213,26 @@ export function PlexSourceScreen() {
|
||||
|
||||
const { isAuthorized, isLocked, servers } = boot.state;
|
||||
const serverList = servers ?? [];
|
||||
const connected = serverList.length > 0;
|
||||
const needsFix = connected && !isAuthorized;
|
||||
const hasServers = serverList.length > 0;
|
||||
// Authorization (a stored Plex token) and connection (a discovered server) are DISTINCT (#345).
|
||||
// `hasPlexAccount` is "there is stored Plex state to sign out of" — an authorized token OR
|
||||
// discovered servers — and it, not the server count, gates Sign in vs Sign out. This makes the
|
||||
// authorized/unlocked/empty-servers state recoverable: Sign out is offered, Sign in is not.
|
||||
const hasPlexAccount = isAuthorized || hasServers;
|
||||
const needsFix = hasServers && !isAuthorized;
|
||||
// Only a RELEASED lock is terminal: while `isLocked`, an authorized+empty observation is the
|
||||
// normal `finalizing` (discovery in progress) or `budget-exhausted` (large first sync still
|
||||
// running) window — NOT "no servers were found". Gating on `!isLocked` keeps the "no eligible
|
||||
// servers, sign out and retry" messaging from contradicting the in-flight discovery status and
|
||||
// from urging a sign-out that would abort a legitimately-running sync.
|
||||
const authorizedNoServers = isAuthorized && !hasServers && !isLocked;
|
||||
const connectionSubtitle = !hasPlexAccount
|
||||
? 'Not signed in.'
|
||||
: needsFix
|
||||
? 'Signed in, but credentials need attention.'
|
||||
: authorizedNoServers
|
||||
? 'Signed in to Plex — no eligible servers were discovered.'
|
||||
: 'Signed in to Plex.';
|
||||
|
||||
return (
|
||||
<div className="ctv-libraries-screen">
|
||||
@@ -235,10 +256,10 @@ export function PlexSourceScreen() {
|
||||
|
||||
<Card
|
||||
title={<h3>Connection</h3>}
|
||||
subtitle={connected ? (isAuthorized ? 'Signed in to Plex.' : 'Signed in, but credentials need attention.') : 'Not signed in.'}
|
||||
subtitle={connectionSubtitle}
|
||||
actions={
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
{!connected && (
|
||||
{!hasPlexAccount && (
|
||||
<Button disabled={starting || polling} loading={starting} onClick={startPinFlow} startIcon={<LogIn aria-hidden="true" size={14} />}>
|
||||
Sign in to Plex
|
||||
</Button>
|
||||
@@ -248,7 +269,7 @@ export function PlexSourceScreen() {
|
||||
Fix Plex Credentials
|
||||
</Button>
|
||||
)}
|
||||
{connected && (
|
||||
{hasPlexAccount && (
|
||||
<Button disabled={signingOut || polling} onClick={() => setConfirmSignOut(true)} startIcon={<LogOut aria-hidden="true" size={14} />} variant="secondary">
|
||||
Sign out of Plex
|
||||
</Button>
|
||||
@@ -265,12 +286,12 @@ export function PlexSourceScreen() {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!polling && !pinState && !connected && (
|
||||
{!polling && !pinState && !hasPlexAccount && (
|
||||
<div className="ctv-schedule-empty">Sign in to Plex to connect a server.</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{connected && (
|
||||
{hasServers && (
|
||||
<Card title={<h3>Servers</h3>} subtitle={`${serverList.length} server${serverList.length === 1 ? '' : 's'} discovered.`} padded={false}>
|
||||
<div className="ctv-library-list" role="list" aria-label="Plex servers">
|
||||
{serverList.map((server) => (
|
||||
@@ -300,6 +321,12 @@ export function PlexSourceScreen() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{authorizedNoServers && (
|
||||
<div className="ctv-libraries-summary">
|
||||
<span><Badge tone="neutral">No servers</Badge> Your Plex account is authorized, but no eligible servers were discovered. Confirm your Plex account owns or has been granted access to a server, then Sign out of Plex and sign in again to retry.</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirmSignOut}
|
||||
tone="danger"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { PrimaryActionProvider, usePrimaryActionHandler } from '../primaryAction';
|
||||
import { SchedulesScreen } from './SchedulesScreen';
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
@@ -157,6 +158,11 @@ async function renderReady(options: MockOptions = {}) {
|
||||
return handle;
|
||||
}
|
||||
|
||||
function SchedulesPrimaryActionButton() {
|
||||
const handler = usePrimaryActionHandler('schedules');
|
||||
return <button type="button" disabled={!handler} onClick={handler}>TopBar action</button>;
|
||||
}
|
||||
|
||||
describe('SchedulesScreen — load', () => {
|
||||
it('renders the lineup for the active schedule', async () => {
|
||||
await renderReady();
|
||||
@@ -438,10 +444,19 @@ describe('SchedulesScreen — schedule CRUD', () => {
|
||||
await waitFor(() => expect(handle.requests.some((r) => r.url === '/api/v1/schedules/99/items')).toBe(true));
|
||||
});
|
||||
|
||||
it('responds to the TopBar primary-action event by opening create', async () => {
|
||||
await renderReady();
|
||||
window.dispatchEvent(new CustomEvent('ctv:primary-action', { detail: 'schedules' }));
|
||||
await waitFor(() => expect(screen.getByRole('dialog')).toBeTruthy());
|
||||
it('registers the TopBar primary action to open create', async () => {
|
||||
mockApi();
|
||||
render(
|
||||
<PrimaryActionProvider>
|
||||
<SchedulesPrimaryActionButton />
|
||||
<SchedulesScreen />
|
||||
</PrimaryActionProvider>
|
||||
);
|
||||
await waitFor(() => expect(screen.getByLabelText('Schedule lineup')).toBeTruthy());
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'TopBar action' }));
|
||||
|
||||
expect(screen.getByRole('dialog')).toBeTruthy();
|
||||
expect(within(screen.getByRole('dialog')).getByLabelText('Name')).toBeTruthy();
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,660 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { SettingsScreen } from './SettingsScreen';
|
||||
|
||||
describe('Settings screen (#93)', () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
window.history.replaceState(null, '', '/');
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
document.documentElement.removeAttribute('data-theme');
|
||||
window.history.replaceState(null, '', '/app/settings/general');
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const openSettings = async () => {
|
||||
render(<SettingsScreen />);
|
||||
expect(
|
||||
await screen.findByText('Interface preferences. These apply to the legacy web UI; ChicoryTV theming lives in the theme switcher.')
|
||||
).toBeInTheDocument();
|
||||
};
|
||||
|
||||
it('renders each section on nav click', async () => {
|
||||
mockSettingsApi();
|
||||
render(<SettingsScreen />);
|
||||
|
||||
expect(await screen.findByText('Interface preferences. These apply to the legacy web UI; ChicoryTV theming lives in the theme switcher.')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Streaming/ }));
|
||||
expect(await screen.findByText('FFmpeg engine, transcoding defaults and HLS session tuning.')).toBeInTheDocument();
|
||||
expect(window.location.pathname).toBe('/app/settings/streaming');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Playout/ }));
|
||||
expect(await screen.findByText('Defaults for how far ahead playouts are built and how gaps are handled.')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Guide \(XMLTV\)/ }));
|
||||
expect(await screen.findByText('Shape of the XMLTV guide data served to Jellyfin and other clients.')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Scanner/ }));
|
||||
expect(await screen.findByText('Background scanning of local libraries.')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Logging/ }));
|
||||
expect(await screen.findByText('Minimum level written per category. Verbose and Debug are noisy — use for troubleshooting only.')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /^System/ }));
|
||||
expect(await screen.findByText('HDHomeRun emulation, connected media sources and server info.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('supports deep-linking directly to a section path', async () => {
|
||||
mockSettingsApi();
|
||||
window.history.replaceState(null, '', '/app/settings/scanner');
|
||||
render(<SettingsScreen />);
|
||||
|
||||
expect(await screen.findByText('Background scanning of local libraries.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('loads values from the API into the General pane', async () => {
|
||||
mockSettingsApi({ uiSettings: defaultUiSettings({ isDarkMode: false, language: 'fr' }) });
|
||||
await openSettings();
|
||||
|
||||
expect(await screen.findByDisplayValue('Light')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('fr')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the save bar with the correct dirty count when editing a field, and Discard restores', async () => {
|
||||
mockSettingsApi();
|
||||
await openSettings();
|
||||
|
||||
const languageInput = screen.getByPlaceholderText('en-US');
|
||||
fireEvent.change(languageInput, { target: { value: 'de-DE' } });
|
||||
|
||||
expect(await screen.findByText('1 unsaved change')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Discard' }));
|
||||
|
||||
expect(screen.queryByText('1 unsaved change')).not.toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('en-US')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('saves only the changed group(s) and shows a confirmation', async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
mockSettingsApi();
|
||||
await openSettings();
|
||||
|
||||
const languageInput = screen.getByPlaceholderText('en-US');
|
||||
fireEvent.change(languageInput, { target: { value: 'de-DE' } });
|
||||
|
||||
await screen.findByText('1 unsaved change');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Save changes/ }));
|
||||
|
||||
expect(await screen.findByText('Settings saved')).toBeInTheDocument();
|
||||
expect(requestBodyFor('/api/v1/settings/ui')).toMatchObject({ language: 'de-DE' });
|
||||
expect(window.fetch).not.toHaveBeenCalledWith('/api/v1/settings/ffmpeg', expect.objectContaining({ method: 'PUT' }));
|
||||
|
||||
vi.advanceTimersByTime(2000);
|
||||
await waitFor(() => expect(screen.queryByText('Settings saved')).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('renders the IPTV pane and saves an edited advertised base URL', async () => {
|
||||
mockSettingsApi();
|
||||
await openSettings();
|
||||
fireEvent.click(await screen.findByRole('button', { name: /^IPTV/ }));
|
||||
|
||||
expect(
|
||||
await screen.findByText('How ErsatzTV advertises itself to IPTV clients in M3U playlists and stream URLs.')
|
||||
).toBeInTheDocument();
|
||||
|
||||
const baseUrlInput = screen.getByPlaceholderText('(use request origin)');
|
||||
fireEvent.change(baseUrlInput, { target: { value: 'https://tv.example.com/etv' } });
|
||||
|
||||
await screen.findByText('1 unsaved change');
|
||||
fireEvent.click(screen.getByRole('button', { name: /Save changes/ }));
|
||||
|
||||
expect(await screen.findByText('Settings saved')).toBeInTheDocument();
|
||||
expect(requestBodyFor('/api/v1/settings/iptv')).toMatchObject({ baseUrl: 'https://tv.example.com/etv' });
|
||||
});
|
||||
|
||||
it('shows a warning callout when scanner refresh interval is 0', async () => {
|
||||
mockSettingsApi({ scannerSettings: defaultScannerSettings({ libraryRefreshInterval: 0 }) });
|
||||
await openSettings();
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Scanner/ }));
|
||||
|
||||
expect(await screen.findByText('Automatic scanning is disabled — libraries only update when scanned manually.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('adds and deletes a custom resolution (delete goes through a confirm dialog)', async () => {
|
||||
mockSettingsApi();
|
||||
await openSettings();
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Streaming/ }));
|
||||
await screen.findByText('Custom resolutions');
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('width'), { target: { value: '2560' } });
|
||||
fireEvent.change(screen.getByPlaceholderText('height'), { target: { value: '1080' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add resolution' }));
|
||||
|
||||
expect(await screen.findByText('2560 × 1080')).toBeInTheDocument();
|
||||
expect(requestBodyFor('/api/v1/settings/resolutions')).toMatchObject({ height: 1080, width: 2560 });
|
||||
|
||||
const deleteButtons = screen.getAllByRole('button', { name: /Delete \d+×\d+/ });
|
||||
fireEvent.click(deleteButtons[deleteButtons.length - 1]);
|
||||
|
||||
expect(await screen.findByRole('dialog')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Delete' }));
|
||||
|
||||
await waitFor(() => expect(screen.queryByText('2560 × 1080')).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('does not show a delete button on non-custom resolutions', async () => {
|
||||
mockSettingsApi();
|
||||
await openSettings();
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Streaming/ }));
|
||||
|
||||
await screen.findByText('1920 × 1080');
|
||||
expect(screen.queryByRole('button', { name: 'Delete 1920×1080' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders FFmpeg profiles read-only with a link to the FFmpeg Profiles editor, and media sources', async () => {
|
||||
mockSettingsApi({
|
||||
ffmpegProfiles: [ffmpegProfile({ id: 1, name: '1080p H.264' }), ffmpegProfile({ id: 2, name: '720p H.264' })],
|
||||
mediaSources: [mediaSource({ id: 30, kind: 'Local', name: 'Local' })]
|
||||
});
|
||||
await openSettings();
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Streaming/ }));
|
||||
|
||||
expect((await screen.findAllByText('1080p H.264')).length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('720p H.264').length).toBeGreaterThan(0);
|
||||
expect(screen.getByRole('button', { name: /Manage FFmpeg profiles/ })).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /^System/ }));
|
||||
expect(await screen.findByText('Local')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows a Classic UI link to the legacy Blazor app in the System pane (#147)', async () => {
|
||||
mockSettingsApi();
|
||||
await openSettings();
|
||||
fireEvent.click(await screen.findByRole('button', { name: /^System/ }));
|
||||
|
||||
const link = await screen.findByRole('link', { name: /Open Classic UI/ });
|
||||
expect(link).toHaveAttribute('href', '/system/health');
|
||||
});
|
||||
|
||||
it('renders and stays editable when media sources (tier-2 reference data) fail to load', async () => {
|
||||
mockSettingsApi({ mediaSourcesFailuresBeforeSuccess: 99 });
|
||||
await openSettings();
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: /^System/ }));
|
||||
expect(await screen.findByText("Couldn't load media sources")).toBeInTheDocument();
|
||||
|
||||
// The rest of the screen is fully functional: edit + save still work.
|
||||
fireEvent.click(screen.getByRole('button', { name: /General/ }));
|
||||
fireEvent.change(await screen.findByPlaceholderText('en-US'), { target: { value: 'nl-BE' } });
|
||||
await screen.findByText('1 unsaved change');
|
||||
fireEvent.click(screen.getByRole('button', { name: /Save changes/ }));
|
||||
|
||||
expect(await screen.findByText('Settings saved')).toBeInTheDocument();
|
||||
expect(requestBodyFor('/api/v1/settings/ui')).toMatchObject({ language: 'nl-BE' });
|
||||
});
|
||||
|
||||
it('shows the error state with a working retry when a settings group (tier-1) fails', async () => {
|
||||
mockSettingsApi({ settingsGetFailuresBeforeSuccess: { '/api/v1/settings/scanner': 1 } });
|
||||
render(<SettingsScreen />);
|
||||
|
||||
const alert = await screen.findByRole('alert');
|
||||
expect(alert).toBeInTheDocument();
|
||||
expect(screen.queryByText('Loading settings…')).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Retry' }));
|
||||
|
||||
expect(
|
||||
await screen.findByText('Interface preferences. These apply to the legacy web UI; ChicoryTV theming lives in the theme switcher.')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('saves exactly the dirty groups when multiple groups are edited', async () => {
|
||||
mockSettingsApi();
|
||||
await openSettings();
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('en-US'), { target: { value: 'de-DE' } });
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Playout/ }));
|
||||
await screen.findByText('Defaults for how far ahead playouts are built and how gaps are handled.');
|
||||
fireEvent.change(screen.getByDisplayValue('2'), { target: { value: '5' } });
|
||||
|
||||
await screen.findByText('2 unsaved changes');
|
||||
fireEvent.click(screen.getByRole('button', { name: /Save changes/ }));
|
||||
|
||||
expect(await screen.findByText('Settings saved')).toBeInTheDocument();
|
||||
expect(requestBodyFor('/api/v1/settings/ui')).toMatchObject({ language: 'de-DE' });
|
||||
expect(requestBodyFor('/api/v1/settings/playout')).toMatchObject({ daysToBuild: 5 });
|
||||
|
||||
const putCallCount = (path: string) =>
|
||||
vi.mocked(window.fetch).mock.calls.filter(([input, init]) => input.toString() === path && init?.method === 'PUT')
|
||||
.length;
|
||||
|
||||
expect(putCallCount('/api/v1/settings/ui')).toBe(1);
|
||||
expect(putCallCount('/api/v1/settings/playout')).toBe(1);
|
||||
expect(putCallCount('/api/v1/settings/ffmpeg')).toBe(0);
|
||||
expect(putCallCount('/api/v1/settings/xmltv')).toBe(0);
|
||||
expect(putCallCount('/api/v1/settings/scanner')).toBe(0);
|
||||
expect(putCallCount('/api/v1/settings/logging')).toBe(0);
|
||||
expect(putCallCount('/api/v1/settings/hdhr')).toBe(0);
|
||||
});
|
||||
|
||||
it('on partial save failure, keeps only the failed group dirty and surfaces its error', async () => {
|
||||
mockSettingsApi({
|
||||
settingsMutationFailures: { '/api/v1/settings/playout': { detail: 'Playout save failed', status: 500 } }
|
||||
});
|
||||
await openSettings();
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('en-US'), { target: { value: 'de-DE' } });
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Playout/ }));
|
||||
await screen.findByText('Defaults for how far ahead playouts are built and how gaps are handled.');
|
||||
fireEvent.change(screen.getByDisplayValue('2'), { target: { value: '5' } });
|
||||
|
||||
await screen.findByText('2 unsaved changes');
|
||||
fireEvent.click(screen.getByRole('button', { name: /Save changes/ }));
|
||||
|
||||
expect(await screen.findByText('1 unsaved change')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Playout save failed/)).toBeInTheDocument();
|
||||
expect(screen.queryByText('Settings saved')).not.toBeInTheDocument();
|
||||
|
||||
// The succeeded (ui) group's edit stuck even though the draft stayed on the Playout pane.
|
||||
fireEvent.click(screen.getByRole('button', { name: /General/ }));
|
||||
expect(await screen.findByDisplayValue('de-DE')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('surfaces an inline error (no unhandled rejection) when adding a duplicate resolution fails', async () => {
|
||||
mockSettingsApi({ resolutionCreateFailure: { detail: 'Resolution already exists', status: 422 } });
|
||||
await openSettings();
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Streaming/ }));
|
||||
await screen.findByText('Custom resolutions');
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('width'), { target: { value: '2560' } });
|
||||
fireEvent.change(screen.getByPlaceholderText('height'), { target: { value: '1080' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add resolution' }));
|
||||
|
||||
expect(await screen.findByText('Resolution already exists')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('surfaces a resolution-delete failure inside the still-open confirm dialog', async () => {
|
||||
mockSettingsApi({ resolutionDeleteFailure: { detail: 'Resolution is in use', status: 409 } });
|
||||
await openSettings();
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Streaming/ }));
|
||||
await screen.findByText('1920 × 1080');
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('width'), { target: { value: '2560' } });
|
||||
fireEvent.change(screen.getByPlaceholderText('height'), { target: { value: '1080' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add resolution' }));
|
||||
await screen.findByText('2560 × 1080');
|
||||
|
||||
const deleteButtons = screen.getAllByRole('button', { name: /Delete \d+×\d+/ });
|
||||
fireEvent.click(deleteButtons[deleteButtons.length - 1]);
|
||||
|
||||
const dialog = await screen.findByRole('dialog');
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Delete' }));
|
||||
|
||||
expect(await within(dialog).findByText('Resolution is in use')).toBeInTheDocument();
|
||||
// The dialog stays open with the resolution still present - a clean draft would
|
||||
// otherwise show nothing at all, since saveError only renders inside the save bar.
|
||||
expect(screen.getByText('2560 × 1080')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables Save and shows an inline error when a numeric field is cleared', async () => {
|
||||
mockSettingsApi();
|
||||
await openSettings();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Playout/ }));
|
||||
await screen.findByText('Defaults for how far ahead playouts are built and how gaps are handled.');
|
||||
fireEvent.change(screen.getByDisplayValue('2'), { target: { value: '' } });
|
||||
|
||||
await screen.findByText('1 unsaved change');
|
||||
expect(screen.getByText('Must be a whole number ≥ 0')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Save changes/ })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('treats a tunerCount of 0 as invalid (backend rejects 0)', async () => {
|
||||
mockSettingsApi();
|
||||
await openSettings();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /^System/ }));
|
||||
await screen.findByText('HDHomeRun emulation, connected media sources and server info.');
|
||||
fireEvent.change(screen.getByDisplayValue('2'), { target: { value: '0' } });
|
||||
|
||||
await screen.findByText('1 unsaved change');
|
||||
expect(screen.getByText('Must be a whole number ≥ 1')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Save changes/ })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('shows the raw wire value as an extra HLS Direct option when it is outside the known set', async () => {
|
||||
mockSettingsApi({ ffmpegSettings: defaultFfmpegSettings({ hlsDirectOutputFormat: 'Hls' }) });
|
||||
await openSettings();
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Streaming/ }));
|
||||
|
||||
expect(await screen.findByDisplayValue('Hls')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('sends a non-null globalWatermarkId through in the ffmpeg save PUT body', async () => {
|
||||
mockSettingsApi({ watermarks: [{ id: 5, name: 'Bug' }] });
|
||||
await openSettings();
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Streaming/ }));
|
||||
await screen.findByText('Global defaults');
|
||||
|
||||
const watermarkSelect = screen
|
||||
.getAllByRole('combobox')
|
||||
.find((select) => within(select).queryByText('Bug')) as HTMLSelectElement;
|
||||
|
||||
fireEvent.change(watermarkSelect, { target: { value: '5' } });
|
||||
|
||||
await screen.findByText('1 unsaved change');
|
||||
fireEvent.click(screen.getByRole('button', { name: /Save changes/ }));
|
||||
|
||||
expect(await screen.findByText('Settings saved')).toBeInTheDocument();
|
||||
expect(requestBodyFor('/api/v1/settings/ffmpeg')).toMatchObject({ globalWatermarkId: 5 });
|
||||
});
|
||||
|
||||
it('renders the most recent last-scan time per media source', async () => {
|
||||
mockSettingsApi({
|
||||
mediaSources: [
|
||||
mediaSource({
|
||||
id: 30,
|
||||
kind: 'Local',
|
||||
libraries: [
|
||||
library({ id: 31, lastScan: '2026-07-01T00:00:00Z' }),
|
||||
library({ id: 32, lastScan: '2026-07-05T14:30:00Z' })
|
||||
],
|
||||
name: 'Local'
|
||||
}),
|
||||
mediaSource({ id: 40, kind: 'Jellyfin', libraries: [], name: 'Jellyfin Server' })
|
||||
]
|
||||
});
|
||||
await openSettings();
|
||||
fireEvent.click(await screen.findByRole('button', { name: /^System/ }));
|
||||
|
||||
expect(await screen.findByText('Local')).toBeInTheDocument();
|
||||
expect(await screen.findByText('Jellyfin Server')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Last scan/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
status
|
||||
});
|
||||
}
|
||||
|
||||
function library(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
id: 31,
|
||||
itemCount: 0,
|
||||
lastScan: null,
|
||||
mediaKind: 'Movies',
|
||||
name: 'Movies',
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
function mediaSource(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
connectionAddress: null,
|
||||
id: 30,
|
||||
kind: 'Local',
|
||||
libraries: [],
|
||||
name: 'Local',
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
function ffmpegProfile(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return { id: 100, name: '1080p H.264', ...overrides };
|
||||
}
|
||||
|
||||
// Read the JSON body captured for a mutating request to `path`.
|
||||
function requestBodyFor(path: string): Record<string, unknown> {
|
||||
const call = vi
|
||||
.mocked(window.fetch)
|
||||
.mock.calls.find(([input, init]) => input.toString() === path && init?.body != null);
|
||||
if (!call) {
|
||||
throw new Error(`no request captured for ${path}`);
|
||||
}
|
||||
return JSON.parse(call[1]?.body as string) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function defaultFfmpegSettings(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
defaultFFmpegProfileId: 1,
|
||||
defaultMpegTsScript: '',
|
||||
extractEmbeddedSubtitles: false,
|
||||
fFmpegPath: '/usr/bin/ffmpeg',
|
||||
fFprobePath: '/usr/bin/ffprobe',
|
||||
globalFallbackFillerId: null,
|
||||
globalWatermarkId: null,
|
||||
hlsDirectOutputFormat: 'MpegTs',
|
||||
hlsSegmenterIdleTimeout: 60,
|
||||
initialSegmentCount: 1,
|
||||
preferredAudioLanguageCode: 'eng',
|
||||
probeForInterlacedFrames: true,
|
||||
saveReports: false,
|
||||
useEmbeddedSubtitles: true,
|
||||
workAheadSegmenterLimit: 1,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
function defaultPlayoutSettings(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
daysToBuild: 2,
|
||||
scriptedScheduleTimeoutSeconds: 30,
|
||||
skipMissingItems: true,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
function defaultXmltvSettings(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
blockBehavior: 'SplitTimeEvenly',
|
||||
daysToBuild: 2,
|
||||
timeZone: 'Local',
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
function defaultScannerSettings(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
libraryRefreshInterval: 6,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
function defaultLoggingSettings(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
defaultMinimumLogLevel: 'Information',
|
||||
httpMinimumLogLevel: 'Warning',
|
||||
scanningMinimumLogLevel: 'Information',
|
||||
schedulingMinimumLogLevel: 'Information',
|
||||
searchingMinimumLogLevel: 'Information',
|
||||
streamingMinimumLogLevel: 'Information',
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
function defaultUiSettings(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
isDarkMode: true,
|
||||
language: 'en-US',
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
function defaultHdhrSettings(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
tunerCount: 2,
|
||||
uuid: '6f1b0a2e-93c4-4d1e-b7aa-0e5f2c9d8a41',
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
function defaultIptvSettings(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
baseUrl: '',
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
function defaultResolutions(): Array<Record<string, unknown>> {
|
||||
return [
|
||||
{ height: 1080, id: 1, isCustom: false, name: '1920x1080', width: 1920 },
|
||||
{ height: 720, id: 2, isCustom: false, name: '1280x720', width: 1280 }
|
||||
];
|
||||
}
|
||||
|
||||
function mockSettingsApi({
|
||||
ffmpegProfiles = [],
|
||||
ffmpegSettings = defaultFfmpegSettings(),
|
||||
hdhrSettings = defaultHdhrSettings(),
|
||||
iptvSettings = defaultIptvSettings(),
|
||||
loggingSettings = defaultLoggingSettings(),
|
||||
mediaSources = [],
|
||||
mediaSourcesFailuresBeforeSuccess = 0,
|
||||
playoutSettings = defaultPlayoutSettings(),
|
||||
resolutionCreateFailure = null,
|
||||
resolutionDeleteFailure = null,
|
||||
resolutions = defaultResolutions(),
|
||||
scannerSettings = defaultScannerSettings(),
|
||||
settingsGetFailuresBeforeSuccess = {},
|
||||
settingsMutationFailures = {},
|
||||
uiSettings = defaultUiSettings(),
|
||||
watermarks = [],
|
||||
xmltvSettings = defaultXmltvSettings()
|
||||
}: {
|
||||
ffmpegProfiles?: unknown[];
|
||||
ffmpegSettings?: Record<string, unknown>;
|
||||
hdhrSettings?: Record<string, unknown>;
|
||||
iptvSettings?: Record<string, unknown>;
|
||||
loggingSettings?: Record<string, unknown>;
|
||||
mediaSources?: unknown[];
|
||||
mediaSourcesFailuresBeforeSuccess?: number;
|
||||
playoutSettings?: Record<string, unknown>;
|
||||
resolutionCreateFailure?: { detail?: string; status?: number; title?: string } | null;
|
||||
resolutionDeleteFailure?: { detail?: string; status?: number; title?: string } | null;
|
||||
resolutions?: Array<Record<string, unknown>>;
|
||||
scannerSettings?: Record<string, unknown>;
|
||||
settingsGetFailuresBeforeSuccess?: Record<string, number>;
|
||||
settingsMutationFailures?: Record<string, { detail?: string; status?: number; title?: string }>;
|
||||
uiSettings?: Record<string, unknown>;
|
||||
watermarks?: unknown[];
|
||||
xmltvSettings?: Record<string, unknown>;
|
||||
} = {}) {
|
||||
let currentResolutions = resolutions;
|
||||
let remainingMediaSourcesFailures = mediaSourcesFailuresBeforeSuccess;
|
||||
const remainingSettingsGetFailures = { ...settingsGetFailuresBeforeSuccess };
|
||||
const currentSettings: Record<string, Record<string, unknown>> = {
|
||||
'/api/v1/settings/ffmpeg': ffmpegSettings,
|
||||
'/api/v1/settings/hdhr': hdhrSettings,
|
||||
'/api/v1/settings/iptv': iptvSettings,
|
||||
'/api/v1/settings/logging': loggingSettings,
|
||||
'/api/v1/settings/playout': playoutSettings,
|
||||
'/api/v1/settings/scanner': scannerSettings,
|
||||
'/api/v1/settings/ui': uiSettings,
|
||||
'/api/v1/settings/xmltv': xmltvSettings
|
||||
};
|
||||
|
||||
vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const path = input.toString();
|
||||
const method = init?.method ?? 'GET';
|
||||
|
||||
if (path in currentSettings) {
|
||||
if (method === 'GET' && (remainingSettingsGetFailures[path] ?? 0) > 0) {
|
||||
remainingSettingsGetFailures[path] -= 1;
|
||||
return Promise.resolve(new Response(null, { status: 500 }));
|
||||
}
|
||||
|
||||
if (method === 'PUT') {
|
||||
const failure = settingsMutationFailures[path];
|
||||
if (failure) {
|
||||
return Promise.resolve(jsonResponse(failure, failure.status ?? 422));
|
||||
}
|
||||
|
||||
currentSettings[path] = {
|
||||
...currentSettings[path],
|
||||
...JSON.parse(String(init?.body ?? '{}')) as Record<string, unknown>
|
||||
};
|
||||
}
|
||||
|
||||
return Promise.resolve(jsonResponse(currentSettings[path]));
|
||||
}
|
||||
|
||||
if (path === '/api/v1/settings/resolutions') {
|
||||
if (method === 'POST') {
|
||||
if (resolutionCreateFailure) {
|
||||
return Promise.resolve(
|
||||
jsonResponse(resolutionCreateFailure, resolutionCreateFailure.status ?? 422)
|
||||
);
|
||||
}
|
||||
|
||||
const body = JSON.parse(String(init?.body ?? '{}')) as { height: number; width: number };
|
||||
const created = {
|
||||
height: body.height,
|
||||
id: Math.max(0, ...currentResolutions.map((resolution) => Number(resolution.id))) + 1,
|
||||
isCustom: true,
|
||||
name: `${body.width}x${body.height}`,
|
||||
width: body.width
|
||||
};
|
||||
currentResolutions = [...currentResolutions, created];
|
||||
return Promise.resolve(jsonResponse(created, 201));
|
||||
}
|
||||
|
||||
return Promise.resolve(jsonResponse(currentResolutions));
|
||||
}
|
||||
|
||||
if (path.match(/^\/api\/v1\/settings\/resolutions\/\d+$/)) {
|
||||
if (resolutionDeleteFailure) {
|
||||
return Promise.resolve(
|
||||
jsonResponse(resolutionDeleteFailure, resolutionDeleteFailure.status ?? 422)
|
||||
);
|
||||
}
|
||||
|
||||
const id = Number(path.split('/').at(-1));
|
||||
currentResolutions = currentResolutions.filter((resolution) => Number(resolution.id) !== id);
|
||||
return Promise.resolve(new Response(null, { status: 204 }));
|
||||
}
|
||||
|
||||
if (path === '/api/v1/media-sources') {
|
||||
if (remainingMediaSourcesFailures > 0) {
|
||||
remainingMediaSourcesFailures -= 1;
|
||||
return Promise.resolve(new Response(null, { status: 500 }));
|
||||
}
|
||||
|
||||
return Promise.resolve(jsonResponse(mediaSources));
|
||||
}
|
||||
|
||||
if (path === '/api/v1/ffmpeg/profiles') {
|
||||
return Promise.resolve(jsonResponse(ffmpegProfiles));
|
||||
}
|
||||
|
||||
if (path === '/api/v1/watermarks') {
|
||||
return Promise.resolve(jsonResponse(watermarks));
|
||||
}
|
||||
|
||||
if (path.startsWith('/api/v1/filler-presets')) {
|
||||
return Promise.resolve(jsonResponse([]));
|
||||
}
|
||||
|
||||
if (path === '/api/v1/version') {
|
||||
return Promise.resolve(jsonResponse({ apiVersion: 3, appVersion: '26.4.0' }));
|
||||
}
|
||||
|
||||
if (path === '/api/v1/health') {
|
||||
return Promise.resolve(jsonResponse([]));
|
||||
}
|
||||
|
||||
return Promise.resolve(jsonResponse(null, 404));
|
||||
});
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
ListVideo,
|
||||
Proportions,
|
||||
Radar,
|
||||
Radio,
|
||||
ScrollText,
|
||||
Server,
|
||||
SlidersHorizontal,
|
||||
@@ -28,6 +29,7 @@ import {
|
||||
messageFromSettingsError,
|
||||
updateFfmpegSettings,
|
||||
updateHdhrSettings,
|
||||
updateIptvSettings,
|
||||
updateLoggingSettings,
|
||||
updatePlayoutSettings,
|
||||
updateScannerSettings,
|
||||
@@ -36,6 +38,7 @@ import {
|
||||
useSettingsScreenQuery,
|
||||
type FfmpegSettings,
|
||||
type HdhrSettings,
|
||||
type IptvSettings,
|
||||
type LoggingSettings,
|
||||
type LogEventLevel,
|
||||
type MediaSource,
|
||||
@@ -52,7 +55,7 @@ import {
|
||||
|
||||
const SETTINGS_BASE_PATH = '/app/settings';
|
||||
|
||||
type SectionId = 'general' | 'streaming' | 'playout' | 'xmltv' | 'scanner' | 'logging' | 'system';
|
||||
type SectionId = 'general' | 'streaming' | 'playout' | 'xmltv' | 'iptv' | 'scanner' | 'logging' | 'system';
|
||||
|
||||
interface SectionDef {
|
||||
hint: string;
|
||||
@@ -66,6 +69,7 @@ const SECTIONS: SectionDef[] = [
|
||||
{ hint: 'FFmpeg & transcoding', icon: <Clapperboard aria-hidden="true" size={16} />, id: 'streaming', label: 'Streaming' },
|
||||
{ hint: 'Build defaults', icon: <ListVideo aria-hidden="true" size={16} />, id: 'playout', label: 'Playout' },
|
||||
{ hint: 'EPG output', icon: <CalendarDays aria-hidden="true" size={16} />, id: 'xmltv', label: 'Guide (XMLTV)' },
|
||||
{ hint: 'M3U & stream URLs', icon: <Radio aria-hidden="true" size={16} />, id: 'iptv', label: 'IPTV' },
|
||||
{ hint: 'Library refresh', icon: <Radar aria-hidden="true" size={16} />, id: 'scanner', label: 'Scanner' },
|
||||
{ hint: 'Log levels', icon: <ScrollText aria-hidden="true" size={16} />, id: 'logging', label: 'Logging' },
|
||||
{ hint: 'HDHR, sources, about', icon: <Server aria-hidden="true" size={16} />, id: 'system', label: 'System' }
|
||||
@@ -115,6 +119,7 @@ function hlsDirectFormatOptions(current: OutputFormatKind): Array<{ label: strin
|
||||
interface Draft {
|
||||
ffmpeg: FfmpegSettings;
|
||||
hdhr: HdhrSettings;
|
||||
iptv: IptvSettings;
|
||||
logging: LoggingSettings;
|
||||
playout: PlayoutSettings;
|
||||
scanner: ScannerSettings;
|
||||
@@ -124,12 +129,13 @@ interface Draft {
|
||||
|
||||
type GroupKey = keyof Draft;
|
||||
|
||||
const GROUP_KEYS: GroupKey[] = ['ui', 'ffmpeg', 'playout', 'xmltv', 'scanner', 'logging', 'hdhr'];
|
||||
const GROUP_KEYS: GroupKey[] = ['ui', 'ffmpeg', 'playout', 'xmltv', 'iptv', 'scanner', 'logging', 'hdhr'];
|
||||
|
||||
function draftFromData(data: SettingsScreenData): Draft {
|
||||
return {
|
||||
ffmpeg: data.ffmpeg,
|
||||
hdhr: data.hdhr,
|
||||
iptv: data.iptv,
|
||||
logging: data.logging,
|
||||
playout: data.playout,
|
||||
scanner: data.scanner,
|
||||
@@ -172,6 +178,7 @@ function formatLastScan(value: string): string {
|
||||
const GROUP_LABELS: Record<GroupKey, string> = {
|
||||
ffmpeg: 'Streaming',
|
||||
hdhr: 'System',
|
||||
iptv: 'IPTV',
|
||||
logging: 'Logging',
|
||||
playout: 'Playout',
|
||||
scanner: 'Scanner',
|
||||
@@ -189,6 +196,8 @@ async function saveGroup(key: GroupKey, draft: Draft): Promise<Partial<Draft>> {
|
||||
return { playout: await updatePlayoutSettings(draft.playout) };
|
||||
case 'xmltv':
|
||||
return { xmltv: await updateXmltvSettings(draft.xmltv) };
|
||||
case 'iptv':
|
||||
return { iptv: await updateIptvSettings(draft.iptv) };
|
||||
case 'scanner':
|
||||
return { scanner: await updateScannerSettings(draft.scanner) };
|
||||
case 'logging':
|
||||
@@ -703,6 +712,36 @@ function XmltvPane({
|
||||
);
|
||||
}
|
||||
|
||||
function IptvPane({
|
||||
iptv,
|
||||
set
|
||||
}: {
|
||||
iptv: IptvSettings;
|
||||
set: (patch: Partial<IptvSettings>) => void;
|
||||
}) {
|
||||
return (
|
||||
<Pane subtitle="How ErsatzTV advertises itself to IPTV clients in M3U playlists and stream URLs." title="IPTV">
|
||||
<Card padded={false}>
|
||||
<Row
|
||||
control={340}
|
||||
first
|
||||
help="Absolute base URL clients should use to reach this server (e.g. http://192.168.1.99:8409 or https://tv.example.com/etv). Leave empty to use the address of each incoming request."
|
||||
label="Advertised base URL"
|
||||
>
|
||||
<Input
|
||||
fullWidth
|
||||
onChange={(event) => set({ baseUrl: event.target.value })}
|
||||
placeholder="(use request origin)"
|
||||
size="sm"
|
||||
style={{ fontFamily: 'var(--font-mono)' }}
|
||||
value={iptv.baseUrl}
|
||||
/>
|
||||
</Row>
|
||||
</Card>
|
||||
</Pane>
|
||||
);
|
||||
}
|
||||
|
||||
function ScannerPane({
|
||||
invalidFields,
|
||||
scanner,
|
||||
@@ -1104,6 +1143,7 @@ export function SettingsScreen() {
|
||||
{section === 'xmltv' && (
|
||||
<XmltvPane invalidFields={invalidFields} set={(patch) => set('xmltv', patch)} xmltv={draft.xmltv} />
|
||||
)}
|
||||
{section === 'iptv' && <IptvPane iptv={draft.iptv} set={(patch) => set('iptv', patch)} />}
|
||||
{section === 'scanner' && (
|
||||
<ScannerPane invalidFields={invalidFields} scanner={draft.scanner} set={(patch) => set('scanner', patch)} />
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
/* Base reset on <html>/<body>: the browser-default body margin (8px) frames every
|
||||
full-viewport layout — the app shell AND the shell-less .ctv-auth-page boot pages, both
|
||||
min-height:100vh — exposing a light border on all sides (#373). Strip it and paint the app
|
||||
surface so any residual gap/overscroll stays dark. Matches the design-system convention
|
||||
(design-system/components/forms/forms.card.html, .../chicorytv-admin/*.html). */
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Base typography on <body>: Dialog (and any overlay) portals into document.body, outside
|
||||
.ctv-app-shell — without this, portaled UI falls back to the browser default font. */
|
||||
body {
|
||||
background: var(--surface-app);
|
||||
font-family: var(--font-sans, system-ui, sans-serif);
|
||||
font-size: var(--text-sm, 13px);
|
||||
color: var(--text-primary, #f0ebe4);
|
||||
|
||||
Reference in New Issue
Block a user