Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df8c5202d6 | ||
|
|
2fff5fc05d | ||
|
|
dd55f00ed4 | ||
|
|
922b1ef53c | ||
|
|
eb12176fe1 | ||
|
|
8152afead1 | ||
|
|
1d95cbfee6 | ||
|
|
ca87336600 | ||
|
|
f62ff0eebc | ||
|
|
0f34c86afa | ||
|
|
b1521f9047 | ||
|
|
ebb9a40dc3 | ||
|
|
ab8e5d7a91 | ||
|
|
11ec07ee1b | ||
|
|
6462c36983 | ||
|
|
6e97664487 | ||
|
|
8cef07a673 | ||
|
|
eb47aed767 |
@@ -36,6 +36,14 @@ name: Build ErsatzTV Image
|
||||
# `if:`-skip a required job: on Gitea 1.25.4 a skipped job reports commit-status state `skipped`
|
||||
# (verified, throwaway PR #418) and we don't rely on how branch protection treats a skipped
|
||||
# REQUIRED context. See docs/ci-cd.md -> "Docs-only skip".
|
||||
#
|
||||
# ALREADY-VALIDATED SKIP (ersatztv#420): a second, sibling gate in `test`, `migrations` and
|
||||
# `functional-e2e` only (NOT `build`). On a push-to-main merge commit, `id: revalidate` runs
|
||||
# `scripts/ci-detect-already-validated.sh`, which emits `skip=true` only when the merged tree is
|
||||
# byte-identical to a PR head that already has a green Gitea combined status — i.e. the exact
|
||||
# source was already validated in the PR run. Every heavy step in those three jobs additionally
|
||||
# gates on `steps.revalidate.outputs.skip != 'true'`. `build` is untouched and always runs on
|
||||
# main, so the image is still built (from already-validated source) even when the skip fires.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
@@ -103,9 +111,9 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# only the test job's steps below need the working tree; git history/tags
|
||||
# are only needed by the `build` job's `git describe` (ersatztv#190)
|
||||
fetch-depth: 1
|
||||
# git history/tags are needed by the `build` job's `git describe` (ersatztv#190) and,
|
||||
# here, by the #420 revalidate step's `HEAD^2` tree comparison on a main merge commit.
|
||||
fetch-depth: 2
|
||||
|
||||
# ersatztv#416: is this a docs-only change? If so, every heavy step below is skipped and this
|
||||
# REQUIRED job reports success in seconds. It still RUNS (never `if:`-skipped) so the required
|
||||
@@ -113,9 +121,14 @@ jobs:
|
||||
- name: Detect docs-only changes
|
||||
id: detect
|
||||
run: scripts/ci-detect-docs-only.sh
|
||||
- name: Detect already-validated tree (#420)
|
||||
id: revalidate
|
||||
env:
|
||||
ETV_STATUS_AUTH: ${{ secrets.REGISTRY_USER }}:${{ secrets.REGISTRY_PASSWORD }}
|
||||
run: scripts/ci-detect-already-validated.sh
|
||||
|
||||
- name: Cache NuGet packages
|
||||
if: steps.detect.outputs.docs_only != 'true'
|
||||
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.nuget/packages
|
||||
@@ -123,13 +136,13 @@ jobs:
|
||||
restore-keys: nuget-${{ runner.os }}-
|
||||
|
||||
- name: Restore
|
||||
if: steps.detect.outputs.docs_only != 'true'
|
||||
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
|
||||
run: dotnet restore
|
||||
|
||||
# Replaces setup-node's built-in `cache: npm`. The toolchain image supplies node/npm, but
|
||||
# the SPA's package downloads are project deps, so they stay cached per lockfile.
|
||||
- name: Cache npm packages
|
||||
if: steps.detect.outputs.docs_only != 'true'
|
||||
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.npm
|
||||
@@ -137,45 +150,45 @@ jobs:
|
||||
restore-keys: npm-${{ runner.os }}-
|
||||
|
||||
- name: Install SPA dependencies
|
||||
if: steps.detect.outputs.docs_only != 'true'
|
||||
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
|
||||
working-directory: web
|
||||
run: npm ci
|
||||
|
||||
- name: Check generated SPA API client
|
||||
if: steps.detect.outputs.docs_only != 'true'
|
||||
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
|
||||
working-directory: web
|
||||
run: npm run check:api
|
||||
|
||||
- name: Lint SPA
|
||||
if: steps.detect.outputs.docs_only != 'true'
|
||||
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
|
||||
working-directory: web
|
||||
run: npm run lint
|
||||
|
||||
- name: Typecheck SPA
|
||||
if: steps.detect.outputs.docs_only != 'true'
|
||||
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
|
||||
working-directory: web
|
||||
run: npm run typecheck
|
||||
|
||||
- name: Test SPA
|
||||
if: steps.detect.outputs.docs_only != 'true'
|
||||
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
|
||||
working-directory: web
|
||||
run: npm test -- --run
|
||||
|
||||
- name: Build SPA
|
||||
if: steps.detect.outputs.docs_only != 'true'
|
||||
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
|
||||
working-directory: web
|
||||
run: npm run build
|
||||
|
||||
- name: Strip Scanner project ref (matches Docker build)
|
||||
if: steps.detect.outputs.docs_only != 'true'
|
||||
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
|
||||
run: sed -i '/Scanner/d' ErsatzTV/ErsatzTV.csproj
|
||||
|
||||
- name: Build
|
||||
if: steps.detect.outputs.docs_only != 'true'
|
||||
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
|
||||
run: dotnet build --configuration Release --no-restore
|
||||
|
||||
- name: Test
|
||||
if: steps.detect.outputs.docs_only != 'true'
|
||||
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
|
||||
run: >-
|
||||
dotnet test --configuration Release --no-build --blame-hang-timeout "2m" --verbosity normal
|
||||
--collect:"XPlat Code Coverage" --settings coverlet.runsettings --results-directory ./coverage
|
||||
@@ -186,7 +199,7 @@ jobs:
|
||||
# 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
|
||||
if: steps.detect.outputs.docs_only != 'true'
|
||||
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
|
||||
continue-on-error: true
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -239,7 +252,7 @@ jobs:
|
||||
# `defaults.run.shell: bash` means `-e -o pipefail` is on, so a failed `cat`/redirect here
|
||||
# would redden a green test job. `continue-on-error` is what actually makes it advisory,
|
||||
# the same guarantee the Coverage summary step above uses.
|
||||
if: ${{ always() && steps.detect.outputs.docs_only != 'true' }}
|
||||
if: ${{ always() && steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true' }}
|
||||
continue-on-error: true
|
||||
run: |
|
||||
mib() { echo "$(( ${1:-0} / 1048576 ))"; }
|
||||
@@ -339,17 +352,24 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
# default fetch-depth: 1 -- this job never runs git describe/log, only
|
||||
# actions/checkout@v4's default (shallow) history is needed (ersatztv#190)
|
||||
with:
|
||||
# was the default fetch-depth: 1 (ersatztv#190); bumped to 2 so the #420 revalidate
|
||||
# step's `HEAD^2` tree comparison can resolve on a main merge commit.
|
||||
fetch-depth: 2
|
||||
|
||||
# ersatztv#416: docs-only? Skip the build + migration replay; the job still reports success in
|
||||
# seconds. REQUIRED context, so it always RUNS (never `if:`-skipped). See the workflow header.
|
||||
- name: Detect docs-only changes
|
||||
id: detect
|
||||
run: scripts/ci-detect-docs-only.sh
|
||||
- name: Detect already-validated tree (#420)
|
||||
id: revalidate
|
||||
env:
|
||||
ETV_STATUS_AUTH: ${{ secrets.REGISTRY_USER }}:${{ secrets.REGISTRY_PASSWORD }}
|
||||
run: scripts/ci-detect-already-validated.sh
|
||||
|
||||
- name: Cache NuGet packages
|
||||
if: steps.detect.outputs.docs_only != 'true'
|
||||
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.nuget/packages
|
||||
@@ -357,11 +377,11 @@ jobs:
|
||||
restore-keys: nuget-${{ runner.os }}-
|
||||
|
||||
- name: Restore
|
||||
if: steps.detect.outputs.docs_only != 'true'
|
||||
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
|
||||
run: dotnet restore
|
||||
|
||||
- name: Build
|
||||
if: steps.detect.outputs.docs_only != 'true'
|
||||
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
|
||||
run: dotnet build --configuration Release --no-restore
|
||||
|
||||
# dotnet-ef is baked into the CI toolchain image (docker/ci/Dockerfile) and already on PATH
|
||||
@@ -369,7 +389,7 @@ jobs:
|
||||
|
||||
# SQLite is the prod provider; both checks validated locally.
|
||||
- name: SQLite — model drift + apply all migrations to a fresh DB
|
||||
if: steps.detect.outputs.docs_only != 'true'
|
||||
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "::group::SQLite model drift (has-pending-model-changes)"
|
||||
@@ -385,7 +405,7 @@ jobs:
|
||||
# MySql uses ServerVersion.AutoDetect (connects at config time), so it runs against the
|
||||
# service container above. MySql__ConnectionString maps to config key "MySql:ConnectionString".
|
||||
- name: MySql — model drift + apply all migrations to a fresh DB
|
||||
if: steps.detect.outputs.docs_only != 'true'
|
||||
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
|
||||
env:
|
||||
# DefaultCommandTimeout is raised from MySqlConnector's 30s default: replaying every
|
||||
# migration to a fresh DB issues DDL commands that can exceed 30s when two migration jobs
|
||||
@@ -440,15 +460,22 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
# bumped from 1 (ersatztv#190 default) so the #420 revalidate step's `HEAD^2` tree
|
||||
# comparison can resolve on a main merge commit.
|
||||
fetch-depth: 2
|
||||
|
||||
# ersatztv#416: docs-only? Skip the boot + curl harness (advisory job; safe to no-op).
|
||||
- name: Detect docs-only changes
|
||||
id: detect
|
||||
run: scripts/ci-detect-docs-only.sh
|
||||
- name: Detect already-validated tree (#420)
|
||||
id: revalidate
|
||||
env:
|
||||
ETV_STATUS_AUTH: ${{ secrets.REGISTRY_USER }}:${{ secrets.REGISTRY_PASSWORD }}
|
||||
run: scripts/ci-detect-already-validated.sh
|
||||
|
||||
- name: Cache NuGet packages
|
||||
if: steps.detect.outputs.docs_only != 'true'
|
||||
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.nuget/packages
|
||||
@@ -456,11 +483,11 @@ jobs:
|
||||
restore-keys: nuget-${{ runner.os }}-
|
||||
|
||||
- name: Restore
|
||||
if: steps.detect.outputs.docs_only != 'true'
|
||||
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
|
||||
run: dotnet restore
|
||||
|
||||
- name: Cache npm packages
|
||||
if: steps.detect.outputs.docs_only != 'true'
|
||||
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.npm
|
||||
@@ -468,17 +495,17 @@ jobs:
|
||||
restore-keys: npm-${{ runner.os }}-
|
||||
|
||||
- name: Install SPA dependencies
|
||||
if: steps.detect.outputs.docs_only != 'true'
|
||||
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
|
||||
working-directory: web
|
||||
run: npm ci
|
||||
|
||||
- name: Build SPA
|
||||
if: steps.detect.outputs.docs_only != 'true'
|
||||
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
|
||||
working-directory: web
|
||||
run: npm run build
|
||||
|
||||
- name: Build (Release)
|
||||
if: steps.detect.outputs.docs_only != 'true'
|
||||
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
|
||||
run: dotnet build ErsatzTV.sln --configuration Release --no-restore
|
||||
|
||||
# The old `command -v ffmpeg || sudo apt-get install ffmpeg` step is gone (ersatztv#390):
|
||||
@@ -489,7 +516,7 @@ jobs:
|
||||
# the image, so still no per-run install. The scan flow self-skips if ffmpeg is ever absent.
|
||||
|
||||
- name: Boot instance and run functional-E2E harness
|
||||
if: steps.detect.outputs.docs_only != 'true'
|
||||
if: steps.detect.outputs.docs_only != 'true' && steps.revalidate.outputs.skip != 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
export ETV_BUILD_CONFIG=Release ETV_UI_PORT=8409
|
||||
|
||||
@@ -52,16 +52,6 @@ public class UpdateChannelHandler(
|
||||
UpdateChannel update,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// don't save mirror when playout exists
|
||||
if (c.Playouts.Count > 0)
|
||||
{
|
||||
update = update with
|
||||
{
|
||||
PlayoutSource = ChannelPlayoutSource.Generated,
|
||||
MirrorSourceChannelId = null
|
||||
};
|
||||
}
|
||||
|
||||
bool hasEpgChange = c.PlayoutSource != update.PlayoutSource || c.ShowInEpg != update.ShowInEpg;
|
||||
|
||||
c.Name = update.Name;
|
||||
@@ -140,6 +130,8 @@ public class UpdateChannelHandler(
|
||||
c.PlayoutMode = ChannelPlayoutMode.Continuous;
|
||||
hasEpgChange |= c.MirrorSourceChannelId != update.MirrorSourceChannelId;
|
||||
hasEpgChange |= c.PlayoutOffset != update.PlayoutOffset;
|
||||
c.MirrorSourceChannelId = update.MirrorSourceChannelId;
|
||||
c.PlayoutOffset = update.PlayoutOffset;
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -147,8 +139,6 @@ public class UpdateChannelHandler(
|
||||
c.PlayoutOffset = null;
|
||||
}
|
||||
|
||||
c.MirrorSourceChannelId = update.MirrorSourceChannelId;
|
||||
c.PlayoutOffset = update.PlayoutOffset;
|
||||
c.StreamingMode = update.StreamingMode;
|
||||
c.WatermarkId = update.WatermarkId;
|
||||
c.FallbackFillerId = update.FallbackFillerId;
|
||||
@@ -194,7 +184,7 @@ public class UpdateChannelHandler(
|
||||
{
|
||||
Validation<BaseError, Channel> channelValidation = (ValidateName(request),
|
||||
await ValidateNumber(dbContext, request, cancellationToken),
|
||||
await MirrorSourceMustBeValid(dbContext, request, cancellationToken),
|
||||
await MirrorSourceMustBeValid(dbContext, request, channel, cancellationToken),
|
||||
ValidateShowInEpg(request.IsEnabled, request.ShowInEpg),
|
||||
ValidateLogo(request.Logo?.Path))
|
||||
.Apply((_, _, _, _, _) => channel);
|
||||
@@ -269,6 +259,7 @@ public class UpdateChannelHandler(
|
||||
private static async Task<Validation<BaseError, Unit>> MirrorSourceMustBeValid(
|
||||
TvContext dbContext,
|
||||
UpdateChannel request,
|
||||
Channel channel,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (request.PlayoutSource is not ChannelPlayoutSource.Mirror)
|
||||
@@ -276,6 +267,18 @@ public class UpdateChannelHandler(
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
// a channel with its own playout already built (Generated mode) cannot become a Mirror —
|
||||
// Mirror channels relay another channel's playout and never build one of their own, so
|
||||
// switching this transition on would strand the existing playout. This used to be
|
||||
// silently coerced back to Generated (issue #401); reject the transition instead so the
|
||||
// caller sees why the requested Mirror source was not applied. A round-trip that keeps
|
||||
// PlayoutSource as Generated never reaches this check.
|
||||
if (channel.Playouts.Count > 0)
|
||||
{
|
||||
return BaseError.New(
|
||||
"Channel cannot switch to Mirror playout source while it has a playout; reset or delete the existing playout first.");
|
||||
}
|
||||
|
||||
Option<Channel> maybeMirrorSource = await dbContext.Channels
|
||||
.AsNoTracking()
|
||||
.SelectOneAsync(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Threading.Channels;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Playouts;
|
||||
using ErsatzTV.Application.Search;
|
||||
using ErsatzTV.Core;
|
||||
|
||||
@@ -74,7 +74,12 @@ public class ReplacePlaylistItemsHandler(IDbContextFactory<TvContext> dbContextF
|
||||
CancellationToken cancellationToken) =>
|
||||
PlaylistMustExist(dbContext, request.PlaylistId, cancellationToken)
|
||||
.BindT(playlist => CollectionTypesMustBeValid(request, playlist))
|
||||
.BindT(playlist => PlaybackOrdersMustBeSupported(request, playlist));
|
||||
.BindT(playlist => PlaybackOrdersMustBeSupported(request, playlist))
|
||||
.BindT(playlist => ValidateName(request).Map(_ => playlist));
|
||||
|
||||
private static Validation<BaseError, string> ValidateName(ReplacePlaylistItems request) =>
|
||||
request.NotEmpty(x => x.Name)
|
||||
.Bind(_ => request.NotLongerThan(50)(x => x.Name));
|
||||
|
||||
private static Validation<BaseError, Playlist> PlaybackOrdersMustBeSupported(
|
||||
ReplacePlaylistItems request,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Threading.Channels;
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application.Playouts;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
@@ -10,6 +10,16 @@ public class GetAllMediaSourcesForApiHandler(
|
||||
IDbContextFactory<TvContext> dbContextFactory)
|
||||
: IRequestHandler<GetAllMediaSourcesForApi, List<MediaSourceResponseModel>>
|
||||
{
|
||||
// A never-scanned library has a null LastScan at runtime, but historical DB rows still carry the
|
||||
// 0001-01-01 MinValue sentinel written by the old Reset_* migrations. Coerce any such residual
|
||||
// sentinel to null so the API/MCP surface reports "never scanned" as null (parity with the UI),
|
||||
// regardless of DB history or provider. Belt-and-suspenders alongside the NullOutNeverScannedLastScan
|
||||
// data migration.
|
||||
private static readonly DateTime NeverScannedThreshold = new(2000, 1, 1);
|
||||
|
||||
private static DateTime? NormalizeLastScan(DateTime? lastScan) =>
|
||||
lastScan is { } value && value < NeverScannedThreshold ? null : lastScan;
|
||||
|
||||
public async Task<List<MediaSourceResponseModel>> Handle(
|
||||
GetAllMediaSourcesForApi request,
|
||||
CancellationToken cancellationToken)
|
||||
@@ -36,7 +46,7 @@ public class GetAllMediaSourcesForApiHandler(
|
||||
l.Id,
|
||||
l.Name,
|
||||
l.MediaKind,
|
||||
l.LastScan,
|
||||
NormalizeLastScan(l.LastScan),
|
||||
itemCountsByLibrary.TryGetValue(l.Id, out int count) ? count : 0))
|
||||
.ToList();
|
||||
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
|
||||
namespace ErsatzTV.Core.Tests.Scheduling;
|
||||
|
||||
[TestFixture]
|
||||
public class PlaybackOrderSupportTests
|
||||
{
|
||||
private static readonly PlaybackOrder[] AllOrders = Enum.GetValues<PlaybackOrder>();
|
||||
|
||||
// The tripwire (#403): every engine must classify every PlaybackOrder value as either supported or
|
||||
// explicitly unsupported. Adding a new order without classifying it here fails this test, which forces the
|
||||
// author to wire it into (or deliberately reject it from) each dispatch site instead of letting it degrade
|
||||
// silently.
|
||||
[Test]
|
||||
public void EveryOrder_IsClassified_ForEveryEngine()
|
||||
{
|
||||
foreach (SchedulingEngineKind engine in PlaybackOrderSupport.Engines)
|
||||
{
|
||||
foreach (PlaybackOrder order in AllOrders)
|
||||
{
|
||||
PlaybackOrderSupport.IsClassified(engine, order).ShouldBeTrue(
|
||||
$"PlaybackOrder.{order} is not classified for {engine}. Add it to PlaybackOrderSupport " +
|
||||
"(supported or unsupported) AND wire it into that engine's dispatch switch (#403).");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Every SchedulingEngineKind must have a matrix entry, or Matrix[engine] throws KeyNotFoundException at
|
||||
// runtime instead of failing here. This is the engine-axis counterpart to the order tripwire.
|
||||
[Test]
|
||||
public void EveryEngineKind_HasAMatrixEntry()
|
||||
{
|
||||
var classified = PlaybackOrderSupport.Engines.ToHashSet();
|
||||
|
||||
foreach (SchedulingEngineKind engine in Enum.GetValues<SchedulingEngineKind>())
|
||||
{
|
||||
classified.ShouldContain(engine,
|
||||
$"SchedulingEngineKind.{engine} has no PlaybackOrderSupport matrix entry (#403).");
|
||||
}
|
||||
}
|
||||
|
||||
// The two sets must partition the enum: no order both supported and unsupported, and together they cover
|
||||
// exactly the enum (no stale entry for a removed value, no missing value).
|
||||
[Test]
|
||||
public void SupportedAndUnsupported_ArePartition_ForEveryEngine()
|
||||
{
|
||||
var all = AllOrders.ToHashSet();
|
||||
|
||||
foreach (SchedulingEngineKind engine in PlaybackOrderSupport.Engines)
|
||||
{
|
||||
IReadOnlySet<PlaybackOrder> supported = PlaybackOrderSupport.SupportedBy(engine);
|
||||
IReadOnlySet<PlaybackOrder> unsupported = PlaybackOrderSupport.UnsupportedBy(engine);
|
||||
|
||||
supported.Intersect(unsupported).ShouldBeEmpty(
|
||||
$"{engine}: an order is listed as both supported and unsupported");
|
||||
|
||||
var union = supported.Concat(unsupported).ToHashSet();
|
||||
union.ShouldBe(all, ignoreOrder: true,
|
||||
$"{engine}: supported ∪ unsupported does not equal the PlaybackOrder enum");
|
||||
}
|
||||
}
|
||||
|
||||
// Guards the specific fragility called out in #403: Random is in Block's allow-list, so it must be
|
||||
// supported by Block (it previously worked only via the switch's coincidental Random fallback).
|
||||
[Test]
|
||||
public void Block_Supports_Random()
|
||||
{
|
||||
PlaybackOrderSupport.IsSupported(SchedulingEngineKind.Block, PlaybackOrder.Random).ShouldBeTrue();
|
||||
}
|
||||
|
||||
// WeightedShuffle (#70) is Classic-only; the other engines must classify it as unsupported so the
|
||||
// write-path guards and this matrix agree.
|
||||
[Test]
|
||||
public void WeightedShuffle_IsClassicOnly()
|
||||
{
|
||||
PlaybackOrderSupport.IsSupported(SchedulingEngineKind.Classic, PlaybackOrder.WeightedShuffle)
|
||||
.ShouldBeTrue();
|
||||
|
||||
foreach (SchedulingEngineKind engine in PlaybackOrderSupport.Engines)
|
||||
{
|
||||
if (engine == SchedulingEngineKind.Classic)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
PlaybackOrderSupport.IsSupported(engine, PlaybackOrder.WeightedShuffle).ShouldBeFalse(
|
||||
$"{engine} must not support WeightedShuffle (#70 is Classic-only)");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
@@ -433,6 +434,67 @@ public class PlaylistEnumeratorTests
|
||||
items.ShouldBe([11, 12, 10, 21, 22, 20, 12, 10, 11, 22, 20, 21]);
|
||||
}
|
||||
|
||||
// #403: an order the playlist engine doesn't handle must be dropped LOUDLY (a warning), not silently.
|
||||
[Test]
|
||||
public async Task Test_UnsupportedOrder_Drops_Item_And_Logs_Warning()
|
||||
{
|
||||
IMediaCollectionRepository repo = Substitute.For<IMediaCollectionRepository>();
|
||||
var logger = new RecordingLogger();
|
||||
|
||||
var playlistItemMap = new Dictionary<PlaylistItem, List<MediaItem>>
|
||||
{
|
||||
{
|
||||
new PlaylistItem
|
||||
{
|
||||
Id = 1,
|
||||
PlaybackOrder = PlaybackOrder.Chronological,
|
||||
PlayAll = false,
|
||||
CollectionType = CollectionType.Collection,
|
||||
CollectionId = 1
|
||||
},
|
||||
[FakeMovie(10), FakeMovie(11)]
|
||||
},
|
||||
{
|
||||
// WeightedShuffle (#70) is Classic-only; the playlist switch has no arm for it.
|
||||
new PlaylistItem
|
||||
{
|
||||
Id = 2,
|
||||
PlaybackOrder = PlaybackOrder.WeightedShuffle,
|
||||
PlayAll = false,
|
||||
CollectionType = CollectionType.Collection,
|
||||
CollectionId = 2
|
||||
},
|
||||
[FakeMovie(20), FakeMovie(21)]
|
||||
}
|
||||
};
|
||||
|
||||
PlaylistEnumerator enumerator = await PlaylistEnumerator.Create(
|
||||
repo,
|
||||
playlistItemMap,
|
||||
new CollectionEnumeratorState(),
|
||||
shufflePlaylistItems: false,
|
||||
batchSize: Option<int>.None,
|
||||
CancellationToken.None,
|
||||
logger);
|
||||
|
||||
// the unsupported item (20, 21) is dropped; only the chronological item (10, 11) cycles
|
||||
var items = new List<int>();
|
||||
for (var i = 0; i < 4; i++)
|
||||
{
|
||||
items.AddRange(enumerator.Current.Map(mi => mi.Id));
|
||||
enumerator.MoveNext(Option<DateTimeOffset>.None);
|
||||
}
|
||||
|
||||
items.ShouldContain(10);
|
||||
items.ShouldContain(11);
|
||||
items.ShouldNotContain(20);
|
||||
items.ShouldNotContain(21);
|
||||
|
||||
// and it said so, rather than dropping silently
|
||||
logger.Entries.ShouldContain(
|
||||
e => e.Level == LogLevel.Warning && e.Message.Contains("not supported by playlist"));
|
||||
}
|
||||
|
||||
private static Movie FakeMovie(int id) => new()
|
||||
{
|
||||
Id = id,
|
||||
@@ -445,4 +507,27 @@ public class PlaylistEnumeratorTests
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
private sealed class RecordingLogger : ILogger
|
||||
{
|
||||
public List<(LogLevel Level, string Message)> Entries { get; } = [];
|
||||
|
||||
public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullScope.Instance;
|
||||
|
||||
public bool IsEnabled(LogLevel logLevel) => true;
|
||||
|
||||
public void Log<TState>(
|
||||
LogLevel logLevel,
|
||||
EventId eventId,
|
||||
TState state,
|
||||
Exception exception,
|
||||
Func<TState, Exception, string> formatter) =>
|
||||
Entries.Add((logLevel, formatter(state, exception)));
|
||||
|
||||
private sealed class NullScope : IDisposable
|
||||
{
|
||||
public static readonly NullScope Instance = new();
|
||||
public void Dispose() { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,15 +51,6 @@ public class BlockPlayoutBuilder(
|
||||
referenceData.Channel.Number,
|
||||
referenceData.Channel.Name);
|
||||
|
||||
List<PlaybackOrder> allowedPlaybackOrders =
|
||||
[
|
||||
PlaybackOrder.Chronological,
|
||||
PlaybackOrder.SeasonEpisode,
|
||||
PlaybackOrder.Shuffle,
|
||||
PlaybackOrder.Random,
|
||||
PlaybackOrder.RandomRotation
|
||||
];
|
||||
|
||||
int daysToBuild = await GetDaysToBuild(cancellationToken);
|
||||
|
||||
// get blocks to schedule
|
||||
@@ -163,8 +154,14 @@ public class BlockPlayoutBuilder(
|
||||
foreach (BlockItem blockItem in effectiveBlock.Block.Items.OrderBy(i => i.Index))
|
||||
{
|
||||
// TODO: support other playback orders
|
||||
if (!allowedPlaybackOrders.Contains(blockItem.PlaybackOrder))
|
||||
if (!PlaybackOrderSupport.IsSupported(SchedulingEngineKind.Block, blockItem.PlaybackOrder))
|
||||
{
|
||||
// Skipping the item silently means it never airs and nothing says why (#403).
|
||||
logger.LogWarning(
|
||||
"Playback order {PlaybackOrder} is not supported by block scheduling; " +
|
||||
"block item {BlockItemId} will be skipped",
|
||||
blockItem.PlaybackOrder,
|
||||
blockItem.Id);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -394,14 +391,31 @@ public class BlockPlayoutBuilder(
|
||||
referenceData.PlayoutHistory.Append(result.AddedHistory).ToList(),
|
||||
blockItem,
|
||||
historyKey),
|
||||
_ => new RandomizedMediaCollectionEnumerator(
|
||||
// Random is in Block's allow-list; give it an explicit arm rather than relying on the fallback
|
||||
// below (it previously worked only by coincidence -- #403).
|
||||
PlaybackOrder.Random => new RandomizedMediaCollectionEnumerator(
|
||||
collectionItems,
|
||||
new CollectionEnumeratorState { Seed = new Random().Next(), Index = 0 })
|
||||
new CollectionEnumeratorState { Seed = new Random().Next(), Index = 0 }),
|
||||
_ => UnsupportedBlockOrderFallback(blockItem.PlaybackOrder, collectionItems)
|
||||
};
|
||||
|
||||
return enumerator;
|
||||
}
|
||||
|
||||
// Defensive: the allow-list in Build already skips unsupported orders, so this should be unreachable. If a
|
||||
// supported-but-unhandled order ever lands here, be loud instead of silently rotating Random (#403).
|
||||
private IMediaCollectionEnumerator UnsupportedBlockOrderFallback(
|
||||
PlaybackOrder playbackOrder,
|
||||
List<MediaItem> collectionItems)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Playback order {PlaybackOrder} reached the block enumerator without a handler; falling back to Random",
|
||||
playbackOrder);
|
||||
return new RandomizedMediaCollectionEnumerator(
|
||||
collectionItems,
|
||||
new CollectionEnumeratorState { Seed = new Random().Next(), Index = 0 });
|
||||
}
|
||||
|
||||
private static PlayoutBuildResult CleanUpHistory(
|
||||
PlayoutReferenceData referenceData,
|
||||
DateTimeOffset start,
|
||||
|
||||
@@ -257,7 +257,8 @@ public class SchedulingEngine(
|
||||
state,
|
||||
shufflePlaylistItems: false,
|
||||
batchSize: Option<int>.None,
|
||||
CancellationToken.None);
|
||||
CancellationToken.None,
|
||||
Optional((ILogger)logger));
|
||||
|
||||
string historyKey = HistoryDetails.KeyForSchedulingContent(key, PlaybackOrder.None);
|
||||
var details = new EnumeratorDetails(enumerator, historyKey, PlaybackOrder.None);
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using OrderSet = System.Collections.Generic.HashSet<ErsatzTV.Core.Domain.PlaybackOrder>;
|
||||
|
||||
namespace ErsatzTV.Core.Scheduling;
|
||||
|
||||
/// <summary>
|
||||
/// The scheduling engine families that turn a <see cref="PlaybackOrder" /> into an enumerator, and which
|
||||
/// orders each one actually handles. This is the single declared support matrix for #403.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Each engine keeps BOTH a <c>Supported</c> and an <c>Unsupported</c> set, maintained by hand ON PURPOSE:
|
||||
/// <c>PlaybackOrderSupportTests</c> asserts the two sets partition every <see cref="PlaybackOrder" /> value
|
||||
/// (union is total, intersection empty), so adding a new order fails that test until it is consciously
|
||||
/// classified here. Deriving <c>Unsupported</c> as "everything not supported" would let a new order fall
|
||||
/// through silently — the very defect #403 exists to kill — so it is spelled out instead.
|
||||
///
|
||||
/// Membership here mirrors the executable dispatch in each builder (<c>PlayoutBuilder</c>,
|
||||
/// <c>PlaylistEnumerator</c>, <c>BlockPlayoutBuilder</c>, <c>EnumeratorCache</c> [YAML],
|
||||
/// <c>SchedulingEngine.EnumeratorForContent</c> [Scripted]); the builders remain the real logic. Only
|
||||
/// <see cref="SchedulingEngineKind.Block" /> consumes this table at runtime (its allow-list), so the table
|
||||
/// is not merely test scaffolding. When you add a case to one of those switches, update the matching set
|
||||
/// here.
|
||||
/// </remarks>
|
||||
public static class PlaybackOrderSupport
|
||||
{
|
||||
private sealed record EngineSupport(
|
||||
IReadOnlySet<PlaybackOrder> Supported,
|
||||
IReadOnlySet<PlaybackOrder> Unsupported);
|
||||
|
||||
private static readonly IReadOnlyDictionary<SchedulingEngineKind, EngineSupport> Matrix =
|
||||
new Dictionary<SchedulingEngineKind, EngineSupport>
|
||||
{
|
||||
// PlayoutBuilder.GetMediaCollectionEnumerator switch; default arm falls back to Random (now loud).
|
||||
[SchedulingEngineKind.Classic] = new EngineSupport(
|
||||
new OrderSet
|
||||
{
|
||||
PlaybackOrder.Chronological,
|
||||
PlaybackOrder.SeasonEpisode,
|
||||
PlaybackOrder.Random,
|
||||
PlaybackOrder.Shuffle,
|
||||
PlaybackOrder.ShuffleInOrder,
|
||||
PlaybackOrder.MultiEpisodeShuffle,
|
||||
PlaybackOrder.Marathon,
|
||||
PlaybackOrder.WeightedShuffle
|
||||
},
|
||||
new OrderSet { PlaybackOrder.None, PlaybackOrder.RandomRotation }),
|
||||
|
||||
// PlaylistEnumerator.Create switch; no default arm meant a null enumerator -> silent drop (now loud).
|
||||
[SchedulingEngineKind.Playlist] = new EngineSupport(
|
||||
new OrderSet
|
||||
{
|
||||
PlaybackOrder.Chronological,
|
||||
PlaybackOrder.SeasonEpisode,
|
||||
PlaybackOrder.Random,
|
||||
PlaybackOrder.Shuffle,
|
||||
PlaybackOrder.ShuffleInOrder,
|
||||
PlaybackOrder.MultiEpisodeShuffle
|
||||
},
|
||||
new OrderSet
|
||||
{
|
||||
PlaybackOrder.None,
|
||||
PlaybackOrder.RandomRotation,
|
||||
PlaybackOrder.Marathon,
|
||||
PlaybackOrder.WeightedShuffle
|
||||
}),
|
||||
|
||||
// BlockPlayoutBuilder allow-list (this very set) + the GetEnumerator switch.
|
||||
[SchedulingEngineKind.Block] = new EngineSupport(
|
||||
new OrderSet
|
||||
{
|
||||
PlaybackOrder.Chronological,
|
||||
PlaybackOrder.SeasonEpisode,
|
||||
PlaybackOrder.Shuffle,
|
||||
PlaybackOrder.Random,
|
||||
PlaybackOrder.RandomRotation
|
||||
},
|
||||
new OrderSet
|
||||
{
|
||||
PlaybackOrder.None,
|
||||
PlaybackOrder.ShuffleInOrder,
|
||||
PlaybackOrder.MultiEpisodeShuffle,
|
||||
PlaybackOrder.Marathon,
|
||||
PlaybackOrder.WeightedShuffle
|
||||
}),
|
||||
|
||||
// EnumeratorCache (YAML, non-playlist path); unsupported orders LogWarning + None.
|
||||
[SchedulingEngineKind.Yaml] = new EngineSupport(
|
||||
new OrderSet { PlaybackOrder.Chronological, PlaybackOrder.Shuffle },
|
||||
new OrderSet
|
||||
{
|
||||
PlaybackOrder.None,
|
||||
PlaybackOrder.Random,
|
||||
PlaybackOrder.SeasonEpisode,
|
||||
PlaybackOrder.ShuffleInOrder,
|
||||
PlaybackOrder.MultiEpisodeShuffle,
|
||||
PlaybackOrder.RandomRotation,
|
||||
PlaybackOrder.Marathon,
|
||||
PlaybackOrder.WeightedShuffle
|
||||
}),
|
||||
|
||||
// SchedulingEngine.EnumeratorForContent (Scripted); unsupported orders LogWarning + None.
|
||||
[SchedulingEngineKind.Scripted] = new EngineSupport(
|
||||
new OrderSet { PlaybackOrder.Chronological, PlaybackOrder.Shuffle },
|
||||
new OrderSet
|
||||
{
|
||||
PlaybackOrder.None,
|
||||
PlaybackOrder.Random,
|
||||
PlaybackOrder.SeasonEpisode,
|
||||
PlaybackOrder.ShuffleInOrder,
|
||||
PlaybackOrder.MultiEpisodeShuffle,
|
||||
PlaybackOrder.RandomRotation,
|
||||
PlaybackOrder.Marathon,
|
||||
PlaybackOrder.WeightedShuffle
|
||||
})
|
||||
};
|
||||
|
||||
public static IReadOnlyCollection<SchedulingEngineKind> Engines => Matrix.Keys.ToList();
|
||||
|
||||
/// <summary>The orders <paramref name="engine" /> can turn into an enumerator.</summary>
|
||||
public static IReadOnlySet<PlaybackOrder> SupportedBy(SchedulingEngineKind engine) => Matrix[engine].Supported;
|
||||
|
||||
/// <summary>The orders explicitly known NOT to be handled by <paramref name="engine" />.</summary>
|
||||
public static IReadOnlySet<PlaybackOrder> UnsupportedBy(SchedulingEngineKind engine) => Matrix[engine].Unsupported;
|
||||
|
||||
public static bool IsSupported(SchedulingEngineKind engine, PlaybackOrder order) =>
|
||||
Matrix[engine].Supported.Contains(order);
|
||||
|
||||
/// <summary>
|
||||
/// True when <paramref name="order" /> appears in either set for <paramref name="engine" />. A new enum
|
||||
/// value is classified in neither until a human adds it — which is what the tripwire test asserts.
|
||||
/// </summary>
|
||||
public static bool IsClassified(SchedulingEngineKind engine, PlaybackOrder order) =>
|
||||
Matrix[engine].Supported.Contains(order) || Matrix[engine].Unsupported.Contains(order);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Extensions;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Scheduling;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ErsatzTV.Core.Scheduling;
|
||||
|
||||
@@ -121,7 +122,8 @@ public class PlaylistEnumerator : IMediaCollectionEnumerator
|
||||
CollectionEnumeratorState state,
|
||||
bool shufflePlaylistItems,
|
||||
Option<int> batchSize,
|
||||
CancellationToken cancellationToken)
|
||||
CancellationToken cancellationToken,
|
||||
Option<ILogger> logger = default)
|
||||
{
|
||||
var result = new PlaylistEnumerator
|
||||
{
|
||||
@@ -203,6 +205,19 @@ public class PlaylistEnumerator : IMediaCollectionEnumerator
|
||||
break;
|
||||
case PlaybackOrder.Random:
|
||||
enumerator = new RandomizedMediaCollectionEnumerator(items, initState);
|
||||
break;
|
||||
default:
|
||||
// An order the playlist engine doesn't handle (#403). Leaving the enumerator null drops
|
||||
// this item from the playlist silently -- and null is a legitimate state above
|
||||
// (SeasonEpisode with Count == 0), so nothing downstream can flag it. Say so here.
|
||||
foreach (ILogger log in logger)
|
||||
{
|
||||
log.LogWarning(
|
||||
"Playback order {PlaybackOrder} is not supported by playlist scheduling; " +
|
||||
"this item will be dropped from the playlist",
|
||||
playlistItem.PlaybackOrder);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1324,7 +1324,8 @@ public class PlayoutBuilder : IPlayoutBuilder
|
||||
state,
|
||||
marathonShuffleGroups,
|
||||
batchSize: Option<int>.None,
|
||||
cancellationToken);
|
||||
cancellationToken,
|
||||
Optional((ILogger)_logger));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1458,7 +1459,23 @@ public class PlayoutBuilder : IPlayoutBuilder
|
||||
goto default;
|
||||
|
||||
default:
|
||||
// TODO: handle this error case differently?
|
||||
// Say so instead of silently substituting a different, arbitrary-looking order; keep airing on
|
||||
// the Random fallback so the channel does not go dark on one misconfigured item (#403). A
|
||||
// supported order can also land here via `goto default` (Marathon that couldn't build its
|
||||
// enumerator) -- distinguish the two so the log doesn't claim a supported order is unsupported.
|
||||
if (PlaybackOrderSupport.IsSupported(SchedulingEngineKind.Classic, playbackOrder))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Could not build a {PlaybackOrder} enumerator for classic scheduling; falling back to Random",
|
||||
playbackOrder);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Playback order {PlaybackOrder} is not supported by classic scheduling; falling back to Random",
|
||||
playbackOrder);
|
||||
}
|
||||
|
||||
return new RandomizedMediaCollectionEnumerator(mediaItems, state);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace ErsatzTV.Core.Scheduling;
|
||||
|
||||
/// <summary>
|
||||
/// The scheduling engine families that dispatch a <see cref="ErsatzTV.Core.Domain.PlaybackOrder" /> into an
|
||||
/// enumerator. Used to key the <see cref="PlaybackOrderSupport" /> matrix (#403).
|
||||
/// </summary>
|
||||
public enum SchedulingEngineKind
|
||||
{
|
||||
/// <summary>Classic schedules — <c>PlayoutBuilder</c>.</summary>
|
||||
Classic,
|
||||
|
||||
/// <summary>Playlist items — <c>PlaylistEnumerator</c>.</summary>
|
||||
Playlist,
|
||||
|
||||
/// <summary>Block scheduling — <c>BlockPlayoutBuilder</c>.</summary>
|
||||
Block,
|
||||
|
||||
/// <summary>YAML (sequential) scheduling — <c>EnumeratorCache</c>.</summary>
|
||||
Yaml,
|
||||
|
||||
/// <summary>Scripted scheduling — <c>SchedulingEngine.EnumeratorForContent</c>.</summary>
|
||||
Scripted
|
||||
}
|
||||
@@ -175,7 +175,8 @@ public class EnumeratorCache(IMediaCollectionRepository mediaCollectionRepositor
|
||||
state,
|
||||
shufflePlaylistItems: false,
|
||||
batchSize: Option<int>.None,
|
||||
cancellationToken);
|
||||
cancellationToken,
|
||||
Optional(logger));
|
||||
}
|
||||
|
||||
var parsedOrder = Enum.Parse<PlaybackOrder>(content.Order, true);
|
||||
|
||||
Generated
+7260
File diff suppressed because it is too large
Load Diff
+23
@@ -0,0 +1,23 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ErsatzTV.Infrastructure.MySql.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class NullOutNeverScannedLastScan : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql("UPDATE Library SET LastScan = NULL WHERE LastScan IS NOT NULL AND LastScan < '2000-01-01'");
|
||||
migrationBuilder.Sql("UPDATE LibraryPath SET LastScan = NULL WHERE LastScan IS NOT NULL AND LastScan < '2000-01-01'");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
// irreversible data migration; there is no way to recover the original sentinel values
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+7085
File diff suppressed because it is too large
Load Diff
+23
@@ -0,0 +1,23 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Sqlite.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class NullOutNeverScannedLastScan : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql("UPDATE Library SET LastScan = NULL WHERE LastScan IS NOT NULL AND LastScan < '2000-01-01'");
|
||||
migrationBuilder.Sql("UPDATE LibraryPath SET LastScan = NULL WHERE LastScan IS NOT NULL AND LastScan < '2000-01-01'");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
// irreversible data migration; there is no way to recover the original sentinel values
|
||||
}
|
||||
}
|
||||
}
|
||||
+79
@@ -1,5 +1,6 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Jellyfin;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Jellyfin;
|
||||
@@ -95,5 +96,83 @@ public class SynchronizeJellyfinLibraryByIdHandlerTests
|
||||
Arg.Any<CancellationToken>());
|
||||
await libraryRepository.Received(1).UpdateLastScan(library);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Not_Log_Error_When_Scan_Is_Canceled()
|
||||
{
|
||||
var scannerProxy = Substitute.For<IScannerProxy>();
|
||||
var mediaSourceRepository = Substitute.For<IMediaSourceRepository>();
|
||||
var jellyfinSecretStore = Substitute.For<IJellyfinSecretStore>();
|
||||
var jellyfinMovieLibraryScanner = Substitute.For<IJellyfinMovieLibraryScanner>();
|
||||
var jellyfinTelevisionLibraryScanner = Substitute.For<IJellyfinTelevisionLibraryScanner>();
|
||||
var jellyfinMusicVideoLibraryScanner = Substitute.For<IJellyfinMusicVideoLibraryScanner>();
|
||||
var libraryRepository = Substitute.For<ILibraryRepository>();
|
||||
var configElementRepository = Substitute.For<IConfigElementRepository>();
|
||||
var logger = Substitute.For<ILogger<SynchronizeJellyfinLibraryByIdHandler>>();
|
||||
|
||||
var library = new JellyfinLibrary
|
||||
{
|
||||
Id = 42,
|
||||
Name = "Concerts",
|
||||
MediaKind = LibraryMediaKind.MusicVideos,
|
||||
MediaSourceId = 7
|
||||
};
|
||||
var mediaSource = new JellyfinMediaSource
|
||||
{
|
||||
Id = 7,
|
||||
Connections =
|
||||
[
|
||||
new JellyfinConnection
|
||||
{
|
||||
Address = "http://jellyfin.example",
|
||||
JellyfinMediaSourceId = 7
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
mediaSourceRepository.GetJellyfinByLibraryId(library.Id).Returns(Some(mediaSource).AsTask());
|
||||
mediaSourceRepository.GetJellyfinLibrary(library.Id).Returns(Some(library).AsTask());
|
||||
jellyfinSecretStore.ReadSecrets().Returns(new JellyfinSecrets
|
||||
{
|
||||
Address = "http://jellyfin.example",
|
||||
ApiKey = "abc"
|
||||
});
|
||||
configElementRepository.GetValue<int>(
|
||||
Arg.Is<ConfigElementKey>(key => key.Key == ConfigElementKey.LibraryRefreshInterval.Key),
|
||||
Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult<Option<int>>(Some(0)));
|
||||
jellyfinMusicVideoLibraryScanner.ScanLibrary(
|
||||
Arg.Any<JellyfinConnectionParameters>(),
|
||||
library,
|
||||
true,
|
||||
Arg.Any<CancellationToken>())
|
||||
.Returns(Left<BaseError, Unit>(new ScanCanceled()).AsTask());
|
||||
|
||||
var handler = new SynchronizeJellyfinLibraryByIdHandler(
|
||||
scannerProxy,
|
||||
mediaSourceRepository,
|
||||
jellyfinSecretStore,
|
||||
jellyfinMovieLibraryScanner,
|
||||
jellyfinTelevisionLibraryScanner,
|
||||
jellyfinMusicVideoLibraryScanner,
|
||||
libraryRepository,
|
||||
configElementRepository,
|
||||
logger);
|
||||
|
||||
await handler.Handle(
|
||||
new SynchronizeJellyfinLibraryById("http://ersatztv.example", library.Id, true, true),
|
||||
CancellationToken.None);
|
||||
|
||||
// a user-initiated cancellation is not a failure and must not be logged at ERROR (#410)
|
||||
await libraryRepository.DidNotReceive().UpdateLastScan(Arg.Any<Library>());
|
||||
logger.ReceivedCalls()
|
||||
.Any(call => call.GetArguments().ElementAtOrDefault(2)?.ToString()
|
||||
== "Error synchronizing jellyfin library: Scan was canceled")
|
||||
.ShouldBeFalse();
|
||||
logger.ReceivedCalls()
|
||||
.Any(call => call.GetArguments().ElementAtOrDefault(2)?.ToString()
|
||||
== "Scan of jellyfin library Concerts was canceled")
|
||||
.ShouldBeTrue();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Scanner.Application.MediaSources;
|
||||
using ErsatzTV.Scanner.Core.Interfaces;
|
||||
@@ -96,6 +97,27 @@ public class ScanLocalLibraryHandlerTests
|
||||
ShouldHaveLogged("Error scanning local library path /movies: scan failed");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Not_Log_Error_When_A_Path_Scan_Is_Canceled()
|
||||
{
|
||||
ScanResult(Left<BaseError, Unit>(new ScanCanceled()));
|
||||
|
||||
await Handler().Handle(
|
||||
new ScanLocalLibrary("http://ersatztv.example", _library.Id, true),
|
||||
CancellationToken.None);
|
||||
|
||||
// a user-initiated cancellation is not a failure and must not be logged at ERROR (#410);
|
||||
// it's still correctly excluded from the "last scan" stamp, same as any other failure
|
||||
_library.LastScan.ShouldBeNull();
|
||||
await _libraryRepository.DidNotReceive().UpdateLastScan(Arg.Any<Library>());
|
||||
|
||||
_logger.ReceivedCalls()
|
||||
.Any(call => call.GetArguments().ElementAtOrDefault(2)?.ToString()
|
||||
== "Error scanning local library path /movies: Scan was canceled")
|
||||
.ShouldBeFalse();
|
||||
ShouldHaveLogged("Scan of local library path /movies was canceled");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Not_Set_Library_LastScan_When_A_Later_Path_Fails()
|
||||
{
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Emby;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Emby;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Scanner.Core.Interfaces;
|
||||
@@ -86,7 +87,14 @@ public class SynchronizeEmbyLibraryByIdHandler : IRequestHandler<SynchronizeEmby
|
||||
|
||||
foreach (BaseError error in result.LeftToSeq())
|
||||
{
|
||||
_logger.LogError("Error synchronizing emby library: {Error}", error);
|
||||
if (error is ScanCanceled)
|
||||
{
|
||||
_logger.LogInformation("Scan of emby library {Name} was canceled", parameters.Library.Name);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogError("Error synchronizing emby library: {Error}", error);
|
||||
}
|
||||
}
|
||||
|
||||
return result.Map(_ => parameters.Library.Name);
|
||||
|
||||
+12
-2
@@ -1,5 +1,6 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Jellyfin;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Jellyfin;
|
||||
@@ -95,7 +96,16 @@ public class
|
||||
|
||||
foreach (BaseError error in result.LeftToSeq())
|
||||
{
|
||||
_logger.LogError("Error synchronizing jellyfin library: {Error}", error);
|
||||
if (error is ScanCanceled)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Scan of jellyfin library {Name} was canceled",
|
||||
parameters.Library.Name);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogError("Error synchronizing jellyfin library: {Error}", error);
|
||||
}
|
||||
}
|
||||
|
||||
return result.Map(_ => parameters.Library.Name);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Diagnostics;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Scanner.Core.Interfaces;
|
||||
using ErsatzTV.Scanner.Core.Interfaces.Metadata;
|
||||
@@ -152,13 +153,23 @@ public class ScanLocalLibraryHandler : IRequestHandler<ScanLocalLibrary, Either<
|
||||
|
||||
// a failed path now suppresses the library-level scan time below, so without this the
|
||||
// user sees "Never scanned" with nothing explaining why. The remote scanners log the
|
||||
// same way.
|
||||
// same way. A cancellation is user-initiated, not a failure, so it's logged separately
|
||||
// at a lower level; genuine errors still log at ERROR.
|
||||
foreach (BaseError error in result.LeftToSeq())
|
||||
{
|
||||
_logger.LogError(
|
||||
"Error scanning local library path {Path}: {Error}",
|
||||
libraryPath.Path,
|
||||
error);
|
||||
if (error is ScanCanceled)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Scan of local library path {Path} was canceled",
|
||||
libraryPath.Path);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogError(
|
||||
"Error scanning local library path {Path}: {Error}",
|
||||
libraryPath.Path,
|
||||
error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Interfaces.Plex;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Plex;
|
||||
@@ -96,7 +97,14 @@ public class SynchronizePlexLibraryByIdHandler : IRequestHandler<SynchronizePlex
|
||||
|
||||
foreach (BaseError error in result.LeftToSeq())
|
||||
{
|
||||
_logger.LogError("Error synchronizing plex library: {Error}", error);
|
||||
if (error is ScanCanceled)
|
||||
{
|
||||
_logger.LogInformation("Scan of plex library {Name} was canceled", parameters.Library.Name);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogError("Error synchronizing plex library: {Error}", error);
|
||||
}
|
||||
}
|
||||
|
||||
return result.Map(_ => parameters.Library.Name);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using ErsatzTV.Application.Channels;
|
||||
using ErsatzTV.Core;
|
||||
using LanguageExt;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Tests.Support;
|
||||
@@ -114,6 +115,77 @@ public class UpdateChannelHandlerTests : ChannelHandlerTestBase
|
||||
error.Value.ShouldContain("FFmpegProfile");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Reject_Mirror_Transition_When_Channel_Has_Playout()
|
||||
{
|
||||
await SeedFFmpegProfile();
|
||||
await SeedChannel(1, "5"); // has a playout below
|
||||
await SeedChannel(2, "6"); // valid mirror source (Generated, no playouts of its own)
|
||||
await SeedPlayout(1, channelId: 1);
|
||||
|
||||
UpdateChannel update = MakeUpdate(1, number: "5") with
|
||||
{
|
||||
PlayoutSource = ChannelPlayoutSource.Mirror,
|
||||
MirrorSourceChannelId = 2
|
||||
};
|
||||
|
||||
Either<BaseError, ChannelViewModel> result = await MakeHandler().Handle(update, CancellationToken.None);
|
||||
|
||||
BaseError error = LeftOf(result);
|
||||
error.ShouldNotBeOfType<NotFoundError>();
|
||||
error.Value.ShouldContain("Mirror");
|
||||
|
||||
// the channel must NOT have been silently coerced/saved as Generated (issue #401: no
|
||||
// silent 200, the caller's requested transition is rejected outright)
|
||||
await using TvContext context = Db.CreateContext();
|
||||
var channel = await context.Channels.SingleAsync(c => c.Id == 1);
|
||||
channel.PlayoutSource.ShouldBe(ChannelPlayoutSource.Generated);
|
||||
channel.MirrorSourceChannelId.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Allow_GetPut_Roundtrip_Of_Generated_Channel_With_Playout()
|
||||
{
|
||||
await SeedFFmpegProfile();
|
||||
await SeedChannel(1, "5");
|
||||
await SeedPlayout(1, channelId: 1);
|
||||
|
||||
// client GETs the channel (PlayoutSource: Generated) and PUTs the same value back
|
||||
// unchanged; this must still succeed even though the channel has a playout.
|
||||
Either<BaseError, ChannelViewModel> result =
|
||||
await MakeHandler().Handle(MakeUpdate(1, number: "5", name: "Renamed"), CancellationToken.None);
|
||||
|
||||
result.IsRight.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Should_Null_Mirror_Only_Fields_When_Saving_Generated_Channel_With_Playout()
|
||||
{
|
||||
await SeedFFmpegProfile();
|
||||
await SeedChannel(1, "5"); // has a playout below
|
||||
await SeedChannel(2, "6"); // stray reference target
|
||||
await SeedPlayout(1, channelId: 1);
|
||||
|
||||
// request keeps PlayoutSource: Generated but carries stray Mirror-only fields — e.g. a
|
||||
// client that never cleared the fields after flipping the UI back from Mirror. These
|
||||
// must never persist onto a Generated channel.
|
||||
UpdateChannel update = MakeUpdate(1, number: "5") with
|
||||
{
|
||||
MirrorSourceChannelId = 2,
|
||||
PlayoutOffset = TimeSpan.FromHours(1)
|
||||
};
|
||||
|
||||
Either<BaseError, ChannelViewModel> result = await MakeHandler().Handle(update, CancellationToken.None);
|
||||
|
||||
result.IsRight.ShouldBeTrue();
|
||||
|
||||
await using TvContext context = Db.CreateContext();
|
||||
var channel = await context.Channels.SingleAsync(c => c.Id == 1);
|
||||
channel.PlayoutSource.ShouldBe(ChannelPlayoutSource.Generated);
|
||||
channel.MirrorSourceChannelId.ShouldBeNull();
|
||||
channel.PlayoutOffset.ShouldBeNull();
|
||||
}
|
||||
|
||||
private static BaseError LeftOf<TR>(Either<BaseError, TR> either) =>
|
||||
either.Match(Left: e => e, Right: _ => throw new AssertionException("Expected a Left result"));
|
||||
}
|
||||
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
using ErsatzTV.Application;
|
||||
using ErsatzTV.Application.MediaCollections;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Tests.Support;
|
||||
using LanguageExt;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
using static LanguageExt.Prelude;
|
||||
|
||||
namespace ErsatzTV.Tests.Application.MediaCollections;
|
||||
|
||||
/// <summary>
|
||||
/// Issue #327: ReplacePlaylistItemsHandler (playlist rename) did no name validation, so a playlist
|
||||
/// could be renamed to an empty/whitespace or over-long name even though CreatePlaylistHandler
|
||||
/// already refuses those on create. Mirrors RenamePlaylistGroupHandler's ValidateName combinator
|
||||
/// (NotEmpty + NotLongerThan(50)) so create and rename enforce the same rule.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class ReplacePlaylistItemsHandlerNameValidationTests
|
||||
{
|
||||
private InMemoryTvContext _db = null!;
|
||||
|
||||
[SetUp]
|
||||
public async Task SetUp() => _db = await InMemoryTvContext.CreateAsync();
|
||||
|
||||
[TearDown]
|
||||
public async Task TearDown() => await _db.DisposeAsync();
|
||||
|
||||
private async Task SeedPlaylistAsync()
|
||||
{
|
||||
await using TvContext ctx = _db.CreateContext();
|
||||
ctx.Playlists.Add(
|
||||
new Playlist
|
||||
{
|
||||
Id = 1,
|
||||
PlaylistGroupId = 1,
|
||||
Name = "Kids",
|
||||
IsSystem = false,
|
||||
Version = 1,
|
||||
Items = new List<PlaylistItem>()
|
||||
});
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private static ReplacePlaylistItems Command(string name) =>
|
||||
new(
|
||||
1,
|
||||
name,
|
||||
new List<ReplacePlaylistItem>
|
||||
{
|
||||
new(0, CollectionType.Movie, null, null, null, 55, PlaybackOrder.Shuffle, null, false, true)
|
||||
},
|
||||
None);
|
||||
|
||||
private async Task<string> ReadNameAsync()
|
||||
{
|
||||
await using TvContext ctx = _db.CreateContext();
|
||||
return await ctx.Playlists.Where(p => p.Id == 1).Select(p => p.Name).SingleAsync();
|
||||
}
|
||||
|
||||
private static BaseError? LeftOrNull<T>(Either<BaseError, T> result) =>
|
||||
result.Match<BaseError?>(Right: _ => null, Left: e => e);
|
||||
|
||||
[Test]
|
||||
public async Task Empty_Name_Should_Be_Rejected_And_Not_Mutate()
|
||||
{
|
||||
await SeedPlaylistAsync();
|
||||
var handler = new ReplacePlaylistItemsHandler(_db.Factory);
|
||||
|
||||
Either<BaseError, List<PlaylistItemViewModel>> result =
|
||||
await handler.Handle(Command(string.Empty), CancellationToken.None);
|
||||
|
||||
LeftOrNull(result).ShouldNotBeNull();
|
||||
(await ReadNameAsync()).ShouldBe("Kids");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Whitespace_Name_Should_Be_Rejected_And_Not_Mutate()
|
||||
{
|
||||
await SeedPlaylistAsync();
|
||||
var handler = new ReplacePlaylistItemsHandler(_db.Factory);
|
||||
|
||||
Either<BaseError, List<PlaylistItemViewModel>> result =
|
||||
await handler.Handle(Command(" "), CancellationToken.None);
|
||||
|
||||
LeftOrNull(result).ShouldNotBeNull();
|
||||
(await ReadNameAsync()).ShouldBe("Kids");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Over_Long_Name_Should_Be_Rejected_And_Not_Mutate()
|
||||
{
|
||||
await SeedPlaylistAsync();
|
||||
var handler = new ReplacePlaylistItemsHandler(_db.Factory);
|
||||
|
||||
string tooLong = new string('a', 51);
|
||||
Either<BaseError, List<PlaylistItemViewModel>> result =
|
||||
await handler.Handle(Command(tooLong), CancellationToken.None);
|
||||
|
||||
LeftOrNull(result).ShouldNotBeNull();
|
||||
(await ReadNameAsync()).ShouldBe("Kids");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Valid_Rename_Should_Succeed()
|
||||
{
|
||||
await SeedPlaylistAsync();
|
||||
var handler = new ReplacePlaylistItemsHandler(_db.Factory);
|
||||
|
||||
string maxLength = new string('a', 50);
|
||||
Either<BaseError, List<PlaylistItemViewModel>> result =
|
||||
await handler.Handle(Command(maxLength), CancellationToken.None);
|
||||
|
||||
result.IsRight.ShouldBeTrue();
|
||||
(await ReadNameAsync()).ShouldBe(maxLength);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using ErsatzTV.Application.MediaSources;
|
||||
using ErsatzTV.Core.Api.MediaSources;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Tests.Support;
|
||||
@@ -66,6 +67,54 @@ public class GetAllMediaSourcesForApiHandlerTests
|
||||
result.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Report_Sentinel_And_Null_LastScan_As_Null()
|
||||
{
|
||||
await using (TvContext context = _db.CreateContext())
|
||||
{
|
||||
var source = new LocalMediaSource
|
||||
{
|
||||
Libraries =
|
||||
[
|
||||
new LocalLibrary
|
||||
{
|
||||
Name = "Never Scanned Sentinel",
|
||||
MediaKind = LibraryMediaKind.Movies,
|
||||
LastScan = new DateTime(1, 1, 1),
|
||||
Paths = [MakePath("/media/sentinel", 0)]
|
||||
},
|
||||
new LocalLibrary
|
||||
{
|
||||
Name = "Never Scanned Null",
|
||||
MediaKind = LibraryMediaKind.Movies,
|
||||
LastScan = null,
|
||||
Paths = [MakePath("/media/nullscan", 0)]
|
||||
},
|
||||
new LocalLibrary
|
||||
{
|
||||
Name = "Really Scanned",
|
||||
MediaKind = LibraryMediaKind.Movies,
|
||||
LastScan = new DateTime(2026, 7, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
Paths = [MakePath("/media/scanned", 0)]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
context.MediaSources.Add(source);
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var handler = new GetAllMediaSourcesForApiHandler(_db.Factory);
|
||||
var result = await handler.Handle(new GetAllMediaSourcesForApi(), CancellationToken.None);
|
||||
|
||||
MediaSourceLibraryResponseModel[] libraries = result.Single().Libraries.ToArray();
|
||||
|
||||
libraries.Single(l => l.Name == "Never Scanned Sentinel").LastScan.ShouldBeNull();
|
||||
libraries.Single(l => l.Name == "Never Scanned Null").LastScan.ShouldBeNull();
|
||||
libraries.Single(l => l.Name == "Really Scanned").LastScan
|
||||
.ShouldBe(new DateTime(2026, 7, 1, 0, 0, 0, DateTimeKind.Utc));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Handle_Should_Exclude_Unconfigured_And_Not_Synced_Libraries()
|
||||
{
|
||||
|
||||
@@ -64,6 +64,19 @@ public abstract class ChannelHandlerTestBase
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
protected async Task SeedPlayout(int id, int channelId)
|
||||
{
|
||||
await using TvContext context = Db.CreateContext();
|
||||
context.Playouts.Add(
|
||||
new Playout
|
||||
{
|
||||
Id = id,
|
||||
ChannelId = channelId,
|
||||
ScheduleKind = PlayoutScheduleKind.Classic
|
||||
});
|
||||
await context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
protected static CreateChannel MakeCreate(
|
||||
string number = "5",
|
||||
int ffmpegProfileId = 1,
|
||||
|
||||
+68
-9
@@ -369,9 +369,55 @@ API path / no `.cs` changed → they pass in ~5s), so they needed no change. `do
|
||||
`decisions-guard` and `ci-image-pin` keep running on docs-only changes — the first two are *about*
|
||||
docs and must.
|
||||
|
||||
Not in scope: the separate redundancy of running the **whole matrix on a PR and again on the
|
||||
merge-to-`main`** over identical code (ersatztv#420), and the within-run triple `dotnet build`
|
||||
(ersatztv#398).
|
||||
Not in scope: the within-run triple `dotnet build` (ersatztv#398; measured and rejected as
|
||||
build-once — see `docs/decisions.md`). The separate redundancy of running the **whole matrix on a
|
||||
PR and again on the merge-to-`main`** over identical code (ersatztv#420) is addressed below.
|
||||
|
||||
### Cross-run tree-identity skip (ersatztv#420)
|
||||
|
||||
A merge to `main` re-runs the entire matrix over code the PR's last run already validated — the
|
||||
same redundancy as docs-only, but for identical code rather than docs. On a `push` to `main` that
|
||||
is a real merge commit, `test`/`migrations`/`functional-e2e` each run
|
||||
`scripts/ci-detect-already-validated.sh` as a **second** detect step (`id: revalidate`, right after
|
||||
the docs-only detect), and every heavy step gains an added `&& steps.revalidate.outputs.skip !=
|
||||
'true'` to its existing `if:`.
|
||||
|
||||
**Skip condition — all four required, else fail-safe `skip=false`:**
|
||||
|
||||
- the event is a push to `refs/heads/main`;
|
||||
- `HEAD` has a second parent `HEAD^2` (a real merge commit — the PR head CI already validated;
|
||||
squash, rebase, fast-forward, or a direct push have no `HEAD^2`, so they run);
|
||||
- `git rev-parse HEAD^{tree}` equals `HEAD^2^{tree}` — main did not advance since the PR's last run,
|
||||
a byte-identical tree;
|
||||
- `HEAD^2` has a **green Gitea combined commit status**, queried via the API with
|
||||
`ETV_STATUS_AUTH`. Trusting the aggregate `.state` is sound: a `skipped` context does **not** drag
|
||||
the combined state below `success` (verified live against this instance — a real merge commit with
|
||||
four `skipped` PR-only contexts still reported `.state == success`), and the two required jobs
|
||||
never report `skipped` (they always run and report a real `success`/`failure`), so
|
||||
`.state == success` implies they were green.
|
||||
|
||||
Those three jobs check out `fetch-depth: 2` so `HEAD^2` and its tree resolve.
|
||||
|
||||
**Why it's safe: `build` is not gated.** The three heavy jobs skip their *steps* (same
|
||||
required-context reasoning as docs-only — they still run and report `success` in seconds), but
|
||||
**`build` always runs on `main`, ungated**, building and pushing the image from that identical,
|
||||
already-validated tree. No image ships from unvalidated source. The required contexts are
|
||||
unchanged (`Build & test (.NET)`, `EF migration integrity (SQLite + MySql)`) — no branch-protection
|
||||
change.
|
||||
|
||||
**Fail-safe bias.** Any uncertainty — not a main push, no `HEAD^2`, a differing tree, a
|
||||
missing/failing/non-`success` status, missing auth — resolves to `skip=false` and runs the full
|
||||
matrix. A false skip could ship an under-validated image, so every ambiguous case runs everything.
|
||||
|
||||
**Honest limitation — this fires rarely here, by design.** The tree is identical only on a
|
||||
*fast-forward-equivalent* merge: main did not advance since the PR's last green run **and** the PR
|
||||
head was not rebased at merge time. Two routine patterns defeat it in this repo: (1) under parallel
|
||||
merges main usually advances; and (2) — the bigger one — the standard workflow **rebases a PR
|
||||
before merging** to resolve the append-only `docs/decisions.md` conflict (see MEMORY: the
|
||||
"decisions.md conflict treadmill"), which mints a new head SHA whose tree was never itself
|
||||
CI-validated, so the tree-match check correctly declines. So the skip is a genuine but *occasional*
|
||||
win (clean, up-to-date, un-rebased merges in quiet periods) — correct-but-conservative by
|
||||
construction, not a general dedup. It never fires unsafely; when in doubt it runs the full matrix.
|
||||
|
||||
### `docs-reminder` job (non-blocking, PR-only)
|
||||
|
||||
@@ -566,12 +612,25 @@ running product from outside our C#/review stack.
|
||||
several minutes, and is noisy (expect to tune, not take raw). The continuous layer is the per-PR
|
||||
white-box gates + the weekly `dependency-scan`; this is the per-release black-box pass. Re-run it each
|
||||
release and before any change to the exposure posture.
|
||||
- **Triage.** ZAP exits non-zero on any FAIL-level alert; the tooling is noisy, so triage each finding
|
||||
false-positive vs real. Real, in-scope, go-live-blocking findings get fixed (e.g. the security headers
|
||||
from the #319 baseline; the Microsoft.OpenApi pin above); LAN-expected noise (Private-IP disclosure) is
|
||||
revisited only for genuine remote exposure. nuclei (template-based CVE fingerprinting) is an optional
|
||||
third pass — deferred while its template fetch is blocked in the runner env (pre-seed a template volume
|
||||
to add it); ZAP covers the DAST baseline and semgrep the SAST, so it is not on the critical path.
|
||||
- **Exit-code contract (ersatztv#338).** `zap-api-scan.py`'s raw exit code is NOT a simple pass/fail — it
|
||||
conflates a clean run with a warnings-only run unless you know its wrapper contract: **0** clean (no
|
||||
FAIL or WARN alerts), **2** WARN-only (triage required, but **not** release-blocking), **1** FAIL (at
|
||||
least one FAIL-level alert — release-blocking), **124** the script's own `timeout` wrapper killed a
|
||||
hung post-scan cleanup (the report written before the hang is still usable — triage it), any other
|
||||
code means the scanner/tool itself errored (not a scan result at all). `scripts/security-scan.sh`
|
||||
encodes this in `classify_zap_exit()` and prints an unambiguous `==> ZAP result: <PASS|WARN|FAIL|
|
||||
TIMEOUT|TOOL ERROR> ...` line; the script's own exit status reflects that classification (0 for
|
||||
clean/WARN, 1 for FAIL/timeout/tool-error) rather than ZAP's raw code, so a warnings-only run no longer
|
||||
reads as a failed scan. Found when the v26.8.0 release scan (#335) returned raw exit 2 for a report with
|
||||
`FAIL-NEW: 0` and two known/expected warning classes — the shell result looked like a failure though the
|
||||
release gate had actually passed. Run `scripts/security-scan.sh --selftest` for a docker-free regression
|
||||
check of the classification logic.
|
||||
- **Triage.** Triage each WARN/FAIL finding false-positive vs real. Real, in-scope, go-live-blocking
|
||||
findings get fixed (e.g. the security headers from the #319 baseline; the Microsoft.OpenApi pin above);
|
||||
LAN-expected noise (Private-IP disclosure) is revisited only for genuine remote exposure. nuclei
|
||||
(template-based CVE fingerprinting) is an optional third pass — deferred while its template fetch is
|
||||
blocked in the runner env (pre-seed a template volume to add it); ZAP covers the DAST baseline and
|
||||
semgrep the SAST, so it is not on the critical path.
|
||||
|
||||
## Static analysis & formatting
|
||||
|
||||
|
||||
@@ -96,6 +96,9 @@ in-file entries.
|
||||
- [2026-07-18 — SmartCollection rule builder: compile-only closed subset, no stored AST, one-level nesting (#176)](#2026-07-18--smartcollection-rule-builder-compile-only-closed-subset-no-stored-ast-one-level-nesting-176)
|
||||
- [2026-07-18 — Auto-Tune per-source weights ride #70's MultiCollection machinery; created at tune time, not a post-hoc PUT (#425)](#2026-07-18--auto-tune-per-source-weights-ride-70s-multicollection-machinery-created-at-tune-time-not-a-post-hoc-put-425)
|
||||
- [2026-07-18 — Search all-items is paged to cap DoS exposure; SPA add-all pages to completeness (#293)](#2026-07-18--search-all-items-is-paged-to-cap-dos-exposure-spa-add-all-pages-to-completeness-293)
|
||||
- [2026-07-18 — Unsupported PlaybackOrder is loud at build time; a declared support matrix and tripwire test make new orders safe by construction (#403)](#2026-07-18--unsupported-playbackorder-is-loud-at-build-time-a-declared-support-matrix-and-tripwire-test-make-new-orders-safe-by-construction-403)
|
||||
- [2026-07-18 — CI build-once was measured and rejected; keep the #420 tree-skip](#2026-07-18--ci-build-once-was-measured-and-rejected-keep-the-420-tree-skip)
|
||||
- [2026-07-18 — Never-scanned `LastScan` surfaces as null at the API boundary, not the 0001-01-01 MinValue sentinel (#409)](#2026-07-18--never-scanned-lastscan-surfaces-as-null-at-the-api-boundary-not-the-0001-01-01-minvalue-sentinel-409)
|
||||
|
||||
---
|
||||
|
||||
@@ -1807,3 +1810,135 @@ page to completeness** — rather than option (b) (a generous cap + truncation s
|
||||
instead of the single-shot fetch; the #221 stale-query guard and the single add POST are unchanged.
|
||||
- **Out of scope (unchanged):** the add POST itself still accepts the full merged id set in one request body
|
||||
— bounding *that* surface is a separate concern (see #308 for the add path); #293 is the GET.
|
||||
|
||||
## 2026-07-18 — Collapsible sidebar + nav-group accordions: two `ctv-sidebar-*` localStorage keys, labeled groups default-collapsed (#396)
|
||||
|
||||
The shell sidebar (`web/src/app/AppShell.tsx`) gained (a) a header toggle that collapses it to a 60px
|
||||
icon rail and (b) collapsible accordions per **labeled** nav group (Media, System); the unlabeled
|
||||
**Primary** group is always open. Mirrors the Claude Design prototype's updated `Sidebar`.
|
||||
|
||||
- **State lives in a small hook, not App.** `web/src/app/sidebarState.ts` `useSidebarState()` owns both
|
||||
pieces of state + persistence; `AppShell` consumes it (nothing else needs it) and stamps
|
||||
`ctv-app-shell-collapsed` on the shell root so the collapse is CSS-driven from one class.
|
||||
- **Persistence keys use the established `ctv-` hyphen convention, NOT the prototype's dotted names.**
|
||||
The issue quoted `ctv.sidebar.collapsed` / `ctv.sidebar.groups`, but every existing client-local pref
|
||||
is hyphenated (`ctv-theme`, `ctv-logs-page-size` — spa-conventions §5d), so we use
|
||||
**`ctv-sidebar-collapsed`** (`"1"`/`"0"`) and **`ctv-sidebar-groups`** (JSON `{groupKey: boolean}`,
|
||||
boolean = *collapsed*). Deliberate deviation from the issue's literal key text in favour of the repo
|
||||
convention the issue itself points to; helpers validate/parse defensively (bad JSON / non-boolean
|
||||
values → default).
|
||||
- **Labeled groups default to COLLAPSED** (absent `ctv-sidebar-groups` entry ⇒ collapsed), so a fresh
|
||||
load shows only Primary — matching the prototype ("default-collapsed, leaving only Primary visible").
|
||||
A behavior change for existing users; `App.test.tsx`'s shell/nav suite seeds the two groups open
|
||||
because it clicks Media/System nav links directly (the accordion behavior is covered in its own
|
||||
describe).
|
||||
- **Accordions apply only in the expanded sidebar.** In the rail, group-collapse is ignored — every
|
||||
item renders as an icon (label kept in the a11y tree via an sr-only span so the accessible name/tests
|
||||
survive; surfaced as a native `title` tooltip), groups separated by a hairline divider, numeric
|
||||
badges shown as a corner dot. The active-route indicator (left rail bar + active background) works in
|
||||
both states.
|
||||
- **Group keys are explicit + stable** (`SidebarNavGroupDefinition.key`: `'media'`, `'system'`) rather
|
||||
than derived from the label, so renaming a label doesn't silently orphan persisted state.
|
||||
- No route/screen was added or redirected (shell-chrome only), so no `blazor-route-parity.md` change.
|
||||
|
||||
## 2026-07-18 — Unsupported PlaybackOrder is loud at build time; a declared support matrix and tripwire test make new orders safe by construction (#403)
|
||||
|
||||
`#70` closed the *persistence* hole for `WeightedShuffle` (the write path rejects it on the engines that
|
||||
can't handle it) and made **YAML + Scripted** log a warning; `MultiCollectionGroup` already threw. It left
|
||||
the three still-**silent** build-time dispatch sites — the ones this issue names — as an explicit non-goal.
|
||||
`#403` makes those loud and adds the by-construction net.
|
||||
|
||||
- **Loud, but NON-FATAL, at every build site.** On an order it doesn't handle, each site now logs a
|
||||
`Warning` (naming the order + engine + the fallback taken) instead of silently substituting/dropping/
|
||||
skipping: Classic's `PlayoutBuilder` `default:` arm (was `// TODO`, silently returned Random), the
|
||||
`PlaylistEnumerator.Create` switch (had **no** `default:` arm → null enumerator → item dropped), and
|
||||
`BlockPlayoutBuilder`'s allow-list `continue` (silent skip). The actual fallback is **preserved** — a
|
||||
live channel airing wrong-but-something beats going dark on one misconfigured item — so scheduler goldens
|
||||
do not move. This matches the log-and-continue posture `#70` already shipped for YAML/Scripted.
|
||||
- `PlaylistEnumerator.Create` was `static` with **no logger**, which is *why* the playlist drop was
|
||||
unreportable. It gained an optional `Option<ILogger> logger = default` (mirrors the existing
|
||||
`GetStartTimeAfter(..., Option<ILogger>.None)` pattern); the three callers that have a logger pass it,
|
||||
the rest default to `None`.
|
||||
- `BlockPlayoutBuilder`'s `GetEnumerator` switch gained an explicit `PlaybackOrder.Random` arm. Random is
|
||||
in Block's allow-list but previously reached an enumerator only via the switch's coincidental `_ =>`
|
||||
Random fallback; the fallback is now a loud helper (defensive — the allow-list should make it
|
||||
unreachable).
|
||||
|
||||
- **Safe-by-construction net: a declared support matrix + a tripwire test.** `PlaybackOrderSupport`
|
||||
(`ErsatzTV.Core/Scheduling/`) declares, per `SchedulingEngineKind` (Classic / Playlist / Block / Yaml /
|
||||
Scripted), BOTH a `Supported` and an `Unsupported` set. `PlaybackOrderSupportTests` asserts the two sets
|
||||
**partition** every `PlaybackOrder` value (union total, intersection empty) for every engine — so adding a
|
||||
new enum value lands in neither set and **fails the test until it is consciously classified** and wired
|
||||
into the matching dispatch switch. Both sets are hand-maintained on purpose: deriving `Unsupported` as
|
||||
"everything not supported" would let a new order fall through silently, which is the exact defect being
|
||||
killed. The matrix is not pure test scaffolding — `BlockPlayoutBuilder` consumes it at runtime for its
|
||||
allow-list (replacing the previously duplicated inline list).
|
||||
|
||||
- **Write-path rejection is deliberately UNCHANGED.** `#70`'s per-engine guards already close the real
|
||||
persistence exposure, and the decisions log records that the "which order reaches which engine" perimeter
|
||||
has been wrong three times — always by enumerating from a list instead of grepping every writer.
|
||||
Broadening write-path rejection here would risk newly-`400`ing configs that currently save-and-fall-back,
|
||||
for no safety gain, so it was left alone. This PR hardens the *build-time* sites and adds the tripwire.
|
||||
|
||||
- **Reverse-mappings deferred (a different axis).** The three `_ => PlaybackOrder.None` arms that map an
|
||||
enumerator *class* back to a `PlaybackOrder` for `PlayoutHistory` were left alone: that path is reached in
|
||||
normal operation by legitimately-supported enumerator types (`ShuffleInOrderCollectionEnumerator`,
|
||||
`SeasonEpisodeMediaCollectionEnumerator`) that simply aren't reverse-mapped, so making it "loud" would
|
||||
emit false-positive warnings. Completing that reverse map is a separate concern from "an unsupported
|
||||
*order* degrades silently" and is not part of #403's scope.
|
||||
## 2026-07-18 — CI build-once was measured and rejected; keep the #420 tree-skip
|
||||
|
||||
Build-once (a `compile` job producing a single artifact, consumed by `test`/`migrations`/
|
||||
`functional-e2e` via `--no-build`) was fully implemented and went **green on CI** (PR #455, run
|
||||
830), then **rejected on measurement**: it traded a ~12% slot-occupancy saving for a ~40–85%
|
||||
**per-run wall-clock regression**.
|
||||
|
||||
- **Why it regressed.** The `bin`+`obj` artifact is 2.5 GB raw / 972 MB gz; tar alone costs ~82s CPU
|
||||
plus ~180s transport, consuming most of the ~465s the shared compile was meant to save. Worse,
|
||||
`compile` serializes **before** `migrations`' long ef-replay, which is pure DB work a shared build
|
||||
cannot shorten — the bottleneck was never the redundant compiles.
|
||||
- **Incidental finding worth recording.** `actions/upload-artifact@v4` does not work on this Gitea
|
||||
instance — it throws `GHESNotSupportedError`, because the `@actions/artifact` v2 client library
|
||||
rejects any non-`github.com` host. `@v3` is required for any future artifact use here.
|
||||
- **Kept: the #420 cross-run tree-identity skip** (`docs/ci-cd.md` → Cross-run tree-identity skip).
|
||||
It is independent of build-once — it only *skips* redundant work on identical-tree main pushes, at
|
||||
zero wall-clock cost, rather than trying to share a build across jobs. Don't re-attempt build-once
|
||||
unless the runner's artifact storage or network changes materially.
|
||||
|
||||
Refs: #398 (closed), #420, PR #455.
|
||||
|
||||
## 2026-07-18 — Never-scanned `LastScan` surfaces as null at the API boundary, not the 0001-01-01 MinValue sentinel (#409)
|
||||
|
||||
`Library.LastScan` / `LibraryPath.LastScan` are `DateTime?`; a never-scanned library is `null` at
|
||||
runtime for a freshly-created row. But the `0001-01-01 00:00:00` MinValue sentinel still appears in the
|
||||
DB from **two** sources — and the second is ongoing, not historical:
|
||||
1. Old `Reset_*` migrations wrote it via raw SQL (`UPDATE Library SET LastScan = '0001-01-01 00:00:00'`).
|
||||
2. **Live code still writes it today**: `MediaSourceRepository` sets `library.LastScan =
|
||||
SystemTime.MinValueUtc` on the Plex/Jellyfin/Emby remove-and-recreate (disable-sync) flows
|
||||
(`MediaSourceRepository.cs:480/611/976`). So the sentinel keeps being written during normal use.
|
||||
|
||||
`GetAllMediaSourcesForApiHandler` projected `l.LastScan` straight onto its `DateTime?` DTO, so that
|
||||
sentinel leaked to API/MCP clients as a fake midnight timestamp — the SPA papered over it with a
|
||||
client-side year<1900 heuristic (#409 first pass). Decision: the API is the right place to be honest, so
|
||||
**never-scanned reports as null** for API/MCP parity with the UI, via two layers:
|
||||
|
||||
- **Read-boundary coercion — the load-bearing, ongoing guard** (provider/history-independent):
|
||||
`GetAllMediaSourcesForApiHandler.NormalizeLastScan` maps any `< 2000-01-01` value to null. Because
|
||||
source #2 above keeps writing the sentinel, this coercion is *permanent*, not a stopgap — a
|
||||
migration-only fix would regress the next time a user toggles a library's sync off.
|
||||
- **Data migration** (`NullOutNeverScannedLastScan`, dual-provider): a one-time cleanup of the historical
|
||||
residue — `UPDATE Library/LibraryPath SET LastScan = NULL WHERE LastScan IS NOT NULL AND LastScan <
|
||||
'2000-01-01'`. Data-only (empty `Up`/`Down` otherwise, both `TvContextModelSnapshot.cs` byte-identical).
|
||||
The `< '2000-01-01'` predicate matches the `0001-01-01` sentinel robustly on both providers (ISO-text
|
||||
compare on SQLite, whatever the out-of-range zero date stored on MySQL — where no `Reset_*LastScan`
|
||||
migration ever ran, so it's a safe no-op there) without depending on the exact stored bytes; no real
|
||||
scan predates ErsatzTV. `Down` is a no-op — the original sentinel is unrecoverable and worthless.
|
||||
|
||||
`GetAllMediaSourcesForApiHandler` is the **only** API/MCP-facing consumer of `LastScan` (grepped
|
||||
`LastScan` under `ErsatzTV.Application/**/Queries` and `ErsatzTV/Controllers`); the other reads are
|
||||
internal scanner code that coalesces to `SystemTime.MinValueUtc` for its own non-nullable
|
||||
`DateTimeOffset` scan-comparison needs and never serializes it to a client. The DTO field was already
|
||||
`DateTime?`, so the OpenAPI schema is unchanged (no regen). The SPA's `hasScanned` heuristic in
|
||||
`LibrariesScreen.tsx` was removed — both call sites revert to a plain null/truthy check now that the API
|
||||
is honest. (Follow-up option, not done here: have `MediaSourceRepository` write `null` instead of
|
||||
`MinValue` so the data is clean at rest too; the read coercion makes that non-urgent.)
|
||||
|
||||
@@ -459,3 +459,34 @@ integration.
|
||||
This module is intentionally reusable beyond SmartCollections — ChannelBuilder and Auto-Tune's
|
||||
inline query editing (#69) are candidate future consumers, tracked as separate follow-up issues
|
||||
rather than wired in #176.
|
||||
|
||||
## 13. Collapsible sidebar + nav-group accordions (#396)
|
||||
|
||||
The shell sidebar (`web/src/app/AppShell.tsx`) supports two independent, persisted collapse states.
|
||||
Both are shell chrome — no screen participates.
|
||||
|
||||
- **State + persistence** live in `web/src/app/sidebarState.ts` (`useSidebarState()`), consumed by
|
||||
`AppShell` alone. It follows the §5d client-local-prefs pattern (a try/catch `getStorage()`, a
|
||||
validating getter, a write-through setter) over two namespaced keys:
|
||||
- `ctv-sidebar-collapsed` — `"1"`/`"0"`; is the sidebar collapsed to the 60px **icon rail**?
|
||||
- `ctv-sidebar-groups` — JSON `{groupKey: boolean}` where the boolean is **collapsed**. A labeled
|
||||
group with **no stored entry defaults to collapsed**, so a fresh load shows only the always-open
|
||||
**Primary** group. (Keys are the `ctv-` hyphen form, not the prototype's `ctv.sidebar.*` — see
|
||||
`decisions.md` 2026-07-18.)
|
||||
- **`AppShell` stamps `ctv-app-shell-collapsed` on the shell root** when collapsed; the rail look is
|
||||
entirely CSS-driven from that one class (`shell.css` narrows the tracked `--sidebar-w` to 60px and
|
||||
transitions `grid-template-columns`; `@media (prefers-reduced-motion: reduce)` drops the transition).
|
||||
- **Nav is inventory-driven** from `sidebarNavGroups` (`app/routes.tsx`). Only **labeled** groups are
|
||||
collapsible; each has an explicit stable `key` (`'media'`, `'system'`) used for the persisted map —
|
||||
don't derive the key from the label (a rename would orphan persisted state). The unlabeled Primary
|
||||
group is always rendered.
|
||||
- **Accordions apply only in the expanded sidebar.** In the rail, group-collapse is ignored: every
|
||||
item renders (icon-only), groups separated by a `.ctv-nav-divider`. The nav item's **label stays in
|
||||
the a11y tree** (visually hidden via CSS, not `display:none`) so the accessible name — and every
|
||||
`getByRole('link', { name })` test — still resolves; the label is also passed as the native `title`
|
||||
tooltip (`NavItem` gained a `title` prop). Numeric badges collapse to a corner dot. The active-route
|
||||
indicator works in both states.
|
||||
- **Testing note**: `App.test.tsx`'s shell/nav suite clicks Media/System nav links directly, so its
|
||||
`beforeEach` **seeds both groups open** (`ctv-sidebar-groups`); the default-collapsed / accordion /
|
||||
rail behavior is covered in its own `describe('collapsible sidebar (#396)')`, and the persistence
|
||||
helpers have a colocated `sidebarState.test.ts`.
|
||||
|
||||
Executable
+90
@@ -0,0 +1,90 @@
|
||||
#!/usr/bin/env bash
|
||||
# scripts/ci-detect-already-validated.sh — emit `skip=true|false` to $GITHUB_OUTPUT for the
|
||||
# docker-build.yml cross-run tree-identity gate (ersatztv#420). On a merge-to-main push, if the
|
||||
# merged tree is byte-identical to a PR head that ALREADY passed `test`+`migrations` (a GREEN
|
||||
# combined commit status), the heavy compile/test/migrations work is redundant -- the exact same
|
||||
# source was already validated. `build` still runs and still builds+pushes the image, so no image
|
||||
# ever ships from unvalidated source.
|
||||
#
|
||||
# The bias is ALWAYS toward running MORE, never less: a false 'true' would ship (or claim to
|
||||
# validate) unreviewed/unvalidated source, so every ambiguous or unverifiable case resolves to
|
||||
# skip=false. It is fine (just wasteful) to re-run on an identical tree; it is a correctness bug
|
||||
# to skip validation on a tree that differs or was never proven green.
|
||||
#
|
||||
# Runs identically locally and in CI. Locally (no $GITHUB_OUTPUT) it prints the decision to
|
||||
# stdout; e.g. GITHUB_EVENT_NAME=push GITHUB_REF=refs/heads/main scripts/ci-detect-already-validated.sh
|
||||
set -euo pipefail
|
||||
|
||||
out="${GITHUB_OUTPUT:-/dev/stdout}"
|
||||
event="${GITHUB_EVENT_NAME:-}"
|
||||
ref="${GITHUB_REF:-}"
|
||||
|
||||
emit() {
|
||||
echo "skip=$1" >> "$out"
|
||||
echo "-> skip=$1"
|
||||
}
|
||||
|
||||
# Only a push directly to main can possibly be a merge-to-main we can cross-check against an
|
||||
# already-validated PR head. Everything else (pull_request, tag push, workflow_dispatch, a push
|
||||
# to any other branch) -> always run.
|
||||
if [ "$event" != "push" ] || [ "$ref" != "refs/heads/main" ]; then
|
||||
echo "event='${event:-<none>}' ref='${ref:-<none>}' (need push to refs/heads/main); running full validation (safe default)"
|
||||
emit false
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Deepen history so HEAD's second parent (and its tree) are resolvable -- a shallow checkout may
|
||||
# have neither. Mirrors the docs-only/docs-reminder jobs' fetch style. Non-fatal: a failed
|
||||
# deepen still falls through to the HEAD^2 check below, which then fails safe.
|
||||
git fetch --deepen=2 origin 2>/dev/null || git fetch --unshallow origin 2>/dev/null || true
|
||||
|
||||
# HEAD must be a real merge commit with a second parent -- that second parent is the PR head CI
|
||||
# actually validated. No second parent (a direct/fast-forward/squash push) -> nothing to compare
|
||||
# against -> always run.
|
||||
pr_head="$(git rev-parse --verify -q HEAD^2 || true)"
|
||||
if [ -z "$pr_head" ]; then
|
||||
echo "HEAD has no second parent (not a merge commit); running full validation (safe default)"
|
||||
emit false
|
||||
exit 0
|
||||
fi
|
||||
|
||||
merge_tree="$(git rev-parse --verify -q 'HEAD^{tree}' || true)"
|
||||
pr_tree="$(git rev-parse --verify -q "${pr_head}^{tree}" || true)"
|
||||
if [ -z "$merge_tree" ] || [ -z "$pr_tree" ]; then
|
||||
echo "could not resolve HEAD or HEAD^2 tree; running full validation (safe default)"
|
||||
emit false
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$merge_tree" != "$pr_tree" ]; then
|
||||
echo "merged tree ($merge_tree) != PR head $pr_head tree ($pr_tree) -- main advanced since the PR was validated; running full validation"
|
||||
emit false
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Merge tree matches the PR head tree exactly. Confirm that PR head was actually validated green
|
||||
# before trusting it -- query the Gitea combined commit status API. Auth is required (private
|
||||
# instance); a missing/failing/non-success response always falls through to skip=false.
|
||||
if [ -z "${ETV_STATUS_AUTH:-}" ]; then
|
||||
echo "ETV_STATUS_AUTH not set; cannot verify PR head status; running full validation (safe default)"
|
||||
emit false
|
||||
exit 0
|
||||
fi
|
||||
|
||||
status_url="http://192.168.1.95:3000/api/v1/repos/timothy/ersatztv/commits/${pr_head}/status"
|
||||
status_json="$(curl -sf -u "$ETV_STATUS_AUTH" "$status_url" || true)"
|
||||
if [ -z "$status_json" ]; then
|
||||
echo "status API request for PR head ${pr_head} failed; running full validation (safe default)"
|
||||
emit false
|
||||
exit 0
|
||||
fi
|
||||
|
||||
state="$(printf '%s' "$status_json" | jq -r '.state // empty' 2>/dev/null || true)"
|
||||
if [ "$state" != "success" ]; then
|
||||
echo "PR head ${pr_head} combined status is '${state:-<unknown>}', not 'success'; running full validation (safe default)"
|
||||
emit false
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "merged tree == green PR head ${pr_head} (status=success) -> skipping re-validation"
|
||||
emit true
|
||||
@@ -23,12 +23,60 @@
|
||||
# ETV_SCAN_OUT host dir for reports (default: /tmp/etv-scan-out)
|
||||
# ETV_SCAN_SKIP_SEMGREP=1 skip the SAST pass (DAST only)
|
||||
#
|
||||
# Reports land in $ETV_SCAN_OUT: zap-api-report.{html,md,json}. Exit code is ZAP's:
|
||||
# 0 = no FAIL-level alerts, non-zero = at least one FAIL (triage it — this tooling is noisy; expect to
|
||||
# tune the ignore list in .zap/ rather than take the raw report at face value).
|
||||
# Reports land in $ETV_SCAN_OUT: zap-api-report.{html,md,json}. zap-api-scan.py's exit code is NOT a
|
||||
# simple pass/fail — its wrapper contract (ersatztv#338, found via the #335 exact-image release scan
|
||||
# returning 2 for a clean-enough report):
|
||||
# 0 clean — no FAIL or WARN alerts
|
||||
# 2 WARN — only WARN-level alerts (triage, but NOT release-blocking) — do not fail the job on this
|
||||
# 1 FAIL — at least one FAIL-level alert — release-blocking
|
||||
# 124 our own `timeout` wrapper killed a hung wrapper (see the NOTE below) — report is still usable
|
||||
# other the scanner/tool itself errored (bad args, crash) — not a scan result at all
|
||||
# classify_zap_exit() (below) is the single place this contract is encoded; the script's own final exit
|
||||
# code reflects the CLASSIFICATION (0 for clean/WARN, 1 for FAIL/timeout/tool-error), not ZAP's raw code.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# classify_zap_exit RC — sets ZAP_CLASS_MSG to an unambiguous one-line classification of ZAP's raw exit
|
||||
# code and returns the exit status this SCRIPT should use for that outcome (see contract above). Kept as
|
||||
# a standalone function (not inlined) so `--selftest` can exercise it without docker/ZAP.
|
||||
classify_zap_exit() {
|
||||
local rc="$1"
|
||||
case "$rc" in
|
||||
0) ZAP_CLASS_MSG="PASS — no FAIL or WARN alerts"; return 0 ;;
|
||||
2) ZAP_CLASS_MSG="WARN — WARN-level alerts only; triage required, NOT release-blocking"; return 0 ;;
|
||||
1) ZAP_CLASS_MSG="FAIL — at least one FAIL-level alert; release-blocking, do not ship"; return 1 ;;
|
||||
124) ZAP_CLASS_MSG="TIMEOUT — wrapper hit ETV_SCAN_TIMEOUT; triage the partial report before shipping"; return 1 ;;
|
||||
*) ZAP_CLASS_MSG="TOOL ERROR (raw exit $rc) — scanner itself failed, this is not a scan result"; return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
if [ "${1:-}" = "--selftest" ]; then
|
||||
# Lightweight regression check for the classification contract above (ersatztv#338 Done-when) — no
|
||||
# docker/ZAP required, so it can run anywhere (incl. off bumblebee, incl. in per-PR CI if ever added).
|
||||
selftest_failed=0
|
||||
selftest_check() {
|
||||
local rc="$1" want_status="$2" want_substr="$3" got_status
|
||||
if classify_zap_exit "$rc"; then got_status=0; else got_status=$?; fi
|
||||
if [ "$got_status" != "$want_status" ] || [[ "$ZAP_CLASS_MSG" != *"$want_substr"* ]]; then
|
||||
echo "SELFTEST FAIL: rc=$rc -> status=$got_status msg='$ZAP_CLASS_MSG' (want status=$want_status, msg containing '$want_substr')" >&2
|
||||
selftest_failed=1
|
||||
else
|
||||
echo "selftest ok: rc=$rc -> status=$got_status ($ZAP_CLASS_MSG)"
|
||||
fi
|
||||
}
|
||||
selftest_check 0 0 "PASS"
|
||||
selftest_check 2 0 "WARN"
|
||||
selftest_check 1 1 "FAIL"
|
||||
selftest_check 124 1 "TIMEOUT"
|
||||
selftest_check 3 1 "TOOL ERROR"
|
||||
selftest_check 77 1 "TOOL ERROR"
|
||||
if [ "$selftest_failed" = 0 ]; then
|
||||
echo "selftest: all ZAP exit classifications OK"; exit 0
|
||||
else
|
||||
echo "selftest: FAILURES ABOVE" >&2; exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
IMAGE="${1:-192.168.1.95:3000/timothy/ersatztv:latest}"
|
||||
PORT="${2:-8411}"
|
||||
OUT="${ETV_SCAN_OUT:-/tmp/etv-scan-out}"
|
||||
@@ -87,10 +135,13 @@ timeout "${ETV_SCAN_TIMEOUT:-45m}" docker run --rm --network host -v "$OUT":/zap
|
||||
-z "replacer.full_list(0).description=apikey;replacer.full_list(0).enabled=true;replacer.full_list(0).matchtype=REQ_HEADER;replacer.full_list(0).matchstr=X-Api-Key;replacer.full_list(0).regex=false;replacer.full_list(0).replacement=$KEY"
|
||||
zap_rc=$?
|
||||
[ "$zap_rc" = 124 ] && echo "NOTE: ZAP wrapper hit the ${ETV_SCAN_TIMEOUT:-45m} timeout (usually a post-scan cleanup hang) — the report written before the hang is still valid; triage it."
|
||||
classify_zap_exit "$zap_rc"
|
||||
zap_class_status=$?
|
||||
set -e
|
||||
# (The ZAP container + target + temp dir are reaped by cleanup() on the EXIT trap — every path, incl.
|
||||
# a `timeout` kill or Ctrl-C mid-scan.)
|
||||
echo "==> ZAP report: $OUT/zap-api-report.html (+ .md/.json). Exit code: $zap_rc"
|
||||
echo "==> ZAP report: $OUT/zap-api-report.html (+ .md/.json)"
|
||||
echo "==> ZAP result: $ZAP_CLASS_MSG (raw exit $zap_rc)"
|
||||
|
||||
if [ "${ETV_SCAN_SKIP_SEMGREP:-0}" = "1" ]; then
|
||||
echo "==> semgrep SAST skipped (ETV_SCAN_SKIP_SEMGREP=1)"
|
||||
@@ -104,4 +155,4 @@ else
|
||||
"$REPO_ROOT" || echo "(semgrep reported findings — triage in the report above)"
|
||||
fi
|
||||
|
||||
exit "$zap_rc"
|
||||
exit "$zap_class_status"
|
||||
|
||||
@@ -16,6 +16,13 @@ describe('ChicoryTV SPA scaffold', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
// #396: the sidebar's Media/System nav groups now default to COLLAPSED. The shell/nav tests
|
||||
// below click nav links inside those groups directly, so keep them expanded here; the
|
||||
// collapse/accordion behavior itself is covered in its own describe block at the end.
|
||||
window.localStorage.setItem(
|
||||
'ctv-sidebar-groups',
|
||||
JSON.stringify({ media: false, system: false })
|
||||
);
|
||||
document.documentElement.removeAttribute('data-theme');
|
||||
window.history.replaceState(null, '', '/app');
|
||||
vi.restoreAllMocks();
|
||||
@@ -632,6 +639,104 @@ describe('ChicoryTV SPA scaffold', () => {
|
||||
expect(window.location.pathname).toBe('/app/settings/scanner');
|
||||
expect(window.fetch).toHaveBeenCalledWith('/api/v1/settings/scanner', expect.any(Object));
|
||||
});
|
||||
|
||||
// #396 — collapsible sidebar + collapsible nav groups. These tests want the real defaults
|
||||
// (Media/System groups collapsed), so undo the outer beforeEach's "keep groups open" seed.
|
||||
describe('collapsible sidebar (#396)', () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.removeItem('ctv-sidebar-groups');
|
||||
});
|
||||
|
||||
it('collapses Media/System nav groups by default, leaving Primary visible', () => {
|
||||
render(<App />);
|
||||
|
||||
// Primary group items always render.
|
||||
expect(screen.getByRole('link', { name: 'Dashboard' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: 'Channels' })).toBeInTheDocument();
|
||||
|
||||
// Labeled groups start collapsed → their items are not rendered, but the accordion headers are.
|
||||
expect(screen.queryByRole('link', { name: 'Logs' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('link', { name: 'Libraries' })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Media', expanded: false })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'System', expanded: false })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('expands a nav group when its header is clicked and persists the open state', () => {
|
||||
render(<App />);
|
||||
|
||||
const systemHeader = screen.getByRole('button', { name: 'System', expanded: false });
|
||||
fireEvent.click(systemHeader);
|
||||
|
||||
expect(screen.getByRole('button', { name: 'System', expanded: true })).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: 'Logs' })).toBeInTheDocument();
|
||||
|
||||
const stored = JSON.parse(window.localStorage.getItem('ctv-sidebar-groups') ?? '{}');
|
||||
expect(stored.system).toBe(false);
|
||||
});
|
||||
|
||||
it('restores expanded groups from localStorage on load', () => {
|
||||
window.localStorage.setItem('ctv-sidebar-groups', JSON.stringify({ media: false }));
|
||||
|
||||
render(<App />);
|
||||
|
||||
// Media restored open; System still default-collapsed.
|
||||
expect(screen.getByRole('link', { name: 'Libraries' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('link', { name: 'Logs' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('collapses the sidebar to the icon rail and persists it', () => {
|
||||
const { container } = render(<App />);
|
||||
|
||||
const shell = container.querySelector('.ctv-app-shell');
|
||||
expect(shell).not.toHaveClass('ctv-app-shell-collapsed');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Collapse sidebar' }));
|
||||
|
||||
expect(shell).toHaveClass('ctv-app-shell-collapsed');
|
||||
expect(window.localStorage.getItem('ctv-sidebar-collapsed')).toBe('1');
|
||||
// The toggle now offers to expand again.
|
||||
expect(screen.getByRole('button', { name: 'Expand sidebar' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows every group item in the rail regardless of accordion state (labels kept for a11y)', () => {
|
||||
// Sidebar collapsed on load; groups at their default (collapsed) accordion state.
|
||||
window.localStorage.setItem('ctv-sidebar-collapsed', '1');
|
||||
|
||||
const { container } = render(<App />);
|
||||
|
||||
expect(container.querySelector('.ctv-app-shell')).toHaveClass('ctv-app-shell-collapsed');
|
||||
// Accordions do not apply in the rail: Media/System items are all present (label stays in the
|
||||
// a11y tree so the link keeps its accessible name), and no accordion header is rendered.
|
||||
expect(screen.getByRole('link', { name: 'Logs' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: 'Libraries' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'System' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('restores the collapsed sidebar from localStorage on load', () => {
|
||||
window.localStorage.setItem('ctv-sidebar-collapsed', '1');
|
||||
|
||||
const { container } = render(<App />);
|
||||
|
||||
expect(container.querySelector('.ctv-app-shell')).toHaveClass('ctv-app-shell-collapsed');
|
||||
expect(screen.getByRole('button', { name: 'Expand sidebar' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('reveals + marks the active route even when its group is default-collapsed, without persisting', () => {
|
||||
// Deep-link into a route inside the default-collapsed System group (expanded sidebar).
|
||||
window.history.replaceState(null, '', '/app/settings');
|
||||
|
||||
render(<App />);
|
||||
|
||||
// The active item is rendered and marked, and its group header shows expanded…
|
||||
const settingsLink = screen.getByRole('link', { name: 'Settings' });
|
||||
expect(settingsLink).toHaveAttribute('aria-current', 'page');
|
||||
expect(screen.getByRole('button', { name: 'System', expanded: true })).toBeInTheDocument();
|
||||
// …but a sibling default-collapsed group (Media) stays collapsed…
|
||||
expect(screen.queryByRole('link', { name: 'Libraries' })).not.toBeInTheDocument();
|
||||
// …and the reveal did NOT write a stored preference (it reverts on navigating away).
|
||||
expect(window.localStorage.getItem('ctv-sidebar-groups')).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
|
||||
+99
-11
@@ -17,6 +17,8 @@ import {
|
||||
ClipboardCopy,
|
||||
Info,
|
||||
ListVideo,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
Plus,
|
||||
Search
|
||||
} from 'lucide-react';
|
||||
@@ -29,8 +31,7 @@ import {
|
||||
import {
|
||||
Button,
|
||||
IconButton,
|
||||
NavItem,
|
||||
NavSection
|
||||
NavItem
|
||||
} from '../components';
|
||||
import {
|
||||
designSystemThemes,
|
||||
@@ -38,6 +39,7 @@ import {
|
||||
} from '../designSystem';
|
||||
import { usePrimaryActionHandler } from '../primaryAction';
|
||||
import { navigateToPath } from '../routing';
|
||||
import { useSidebarState } from './sidebarState';
|
||||
import { DashboardHealthSummary } from '../screens/DashboardScreen';
|
||||
import { UnauthorizedBanner } from '../UnauthorizedBanner';
|
||||
import { UserMenu } from '../UserMenu';
|
||||
@@ -53,10 +55,12 @@ type NavigateHandler = (route: ScreenRoute, event: MouseEvent) => void;
|
||||
|
||||
function SidebarNavGroup({
|
||||
activeRoute,
|
||||
collapsed,
|
||||
ids,
|
||||
onNavigate
|
||||
}: {
|
||||
activeRoute: ScreenRoute | null;
|
||||
collapsed: boolean;
|
||||
ids: readonly ScreenId[];
|
||||
onNavigate: NavigateHandler;
|
||||
}) {
|
||||
@@ -70,6 +74,9 @@ function SidebarNavGroup({
|
||||
key={route.id}
|
||||
icon={route.icon}
|
||||
label={route.label}
|
||||
// In the collapsed rail, the label is visually hidden (kept for a11y) so surface it
|
||||
// as a native tooltip; expanded shows the label inline so a tooltip would be redundant.
|
||||
title={collapsed ? route.label : undefined}
|
||||
active={route.id === activeRoute?.id}
|
||||
badge={route.badge}
|
||||
badgeTone="warn"
|
||||
@@ -111,12 +118,20 @@ function ThemeSwitcher({
|
||||
|
||||
function Sidebar({
|
||||
activeRoute,
|
||||
collapsed,
|
||||
healthState,
|
||||
onNavigate
|
||||
isGroupCollapsed,
|
||||
onNavigate,
|
||||
onToggleCollapsed,
|
||||
onToggleGroup
|
||||
}: {
|
||||
activeRoute: ScreenRoute | null;
|
||||
collapsed: boolean;
|
||||
healthState: DashboardHealthQueryState;
|
||||
isGroupCollapsed: (groupKey: string) => boolean;
|
||||
onNavigate: NavigateHandler;
|
||||
onToggleCollapsed: () => void;
|
||||
onToggleGroup: (groupKey: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<aside className="ctv-sidebar">
|
||||
@@ -125,15 +140,78 @@ function Sidebar({
|
||||
<span className="ctv-brand-wordmark">
|
||||
Chicory<span>TV</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="ctv-sidebar-toggle"
|
||||
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||
aria-pressed={collapsed}
|
||||
title={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||
onClick={onToggleCollapsed}
|
||||
>
|
||||
{collapsed ? (
|
||||
<PanelLeftOpen aria-hidden="true" size={17} />
|
||||
) : (
|
||||
<PanelLeftClose aria-hidden="true" size={17} />
|
||||
)}
|
||||
</button>
|
||||
</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>
|
||||
))}
|
||||
{sidebarNavGroups.map((group) => {
|
||||
const { ids, key, label } = group;
|
||||
|
||||
// Primary (unlabeled) is always open, in both the rail and the expanded sidebar.
|
||||
if (!label) {
|
||||
return (
|
||||
<SidebarNavGroup
|
||||
key="Primary"
|
||||
activeRoute={activeRoute}
|
||||
collapsed={collapsed}
|
||||
ids={ids}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const groupKey = key ?? label;
|
||||
// Accordions only apply in the expanded sidebar; the rail always shows every item
|
||||
// (separated by a hairline divider), so group-collapse is ignored there.
|
||||
const groupCollapsed = isGroupCollapsed(groupKey);
|
||||
// Always reveal the group that contains the active route so its active item is visible +
|
||||
// highlighted in the expanded sidebar, even when the group's stored state is collapsed
|
||||
// (e.g. a fresh deep-link to /app/settings). This does NOT persist — navigating away
|
||||
// reverts to the stored preference (acceptance: "active route marked in both states").
|
||||
const containsActive =
|
||||
activeRoute != null && ids.includes(activeRoute.id);
|
||||
const expanded = containsActive || !groupCollapsed;
|
||||
const showItems = collapsed || expanded;
|
||||
|
||||
return (
|
||||
<Fragment key={groupKey}>
|
||||
{collapsed ? (
|
||||
<div className="ctv-nav-divider" role="presentation" />
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="ctv-nav-group-header"
|
||||
aria-expanded={expanded}
|
||||
onClick={() => onToggleGroup(groupKey)}
|
||||
>
|
||||
<span>{label}</span>
|
||||
<ChevronDown className="ctv-nav-group-chevron" aria-hidden="true" size={14} />
|
||||
</button>
|
||||
)}
|
||||
{showItems ? (
|
||||
<SidebarNavGroup
|
||||
activeRoute={activeRoute}
|
||||
collapsed={collapsed}
|
||||
ids={ids}
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
) : null}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="ctv-sidebar-health">
|
||||
@@ -348,9 +426,19 @@ export function AppShell({
|
||||
route: ScreenRoute | null;
|
||||
theme: DesignSystemThemeId;
|
||||
}) {
|
||||
const { collapsed, isGroupCollapsed, toggleCollapsed, toggleGroup } = useSidebarState();
|
||||
|
||||
return (
|
||||
<div className="ctv-app-shell">
|
||||
<Sidebar activeRoute={route} healthState={healthState} onNavigate={onNavigate} />
|
||||
<div className={`ctv-app-shell${collapsed ? ' ctv-app-shell-collapsed' : ''}`}>
|
||||
<Sidebar
|
||||
activeRoute={route}
|
||||
collapsed={collapsed}
|
||||
healthState={healthState}
|
||||
isGroupCollapsed={isGroupCollapsed}
|
||||
onNavigate={onNavigate}
|
||||
onToggleCollapsed={toggleCollapsed}
|
||||
onToggleGroup={toggleGroup}
|
||||
/>
|
||||
<div className="ctv-shell-body">
|
||||
<TopBar route={route} />
|
||||
<UnauthorizedBanner />
|
||||
|
||||
@@ -464,6 +464,9 @@ export const routeById = new Map(routes.map((route) => [route.id, route]));
|
||||
|
||||
export interface SidebarNavGroupDefinition {
|
||||
label?: string;
|
||||
// Stable key for persisted collapse state (ctv-sidebar-groups). Only labeled (collapsible)
|
||||
// groups need one; the unlabeled Primary group is always open (#396).
|
||||
key?: string;
|
||||
ids: readonly ScreenId[];
|
||||
}
|
||||
|
||||
@@ -485,6 +488,7 @@ export const sidebarNavGroups: SidebarNavGroupDefinition[] = [
|
||||
},
|
||||
{
|
||||
label: 'Media',
|
||||
key: 'media',
|
||||
ids: [
|
||||
'media',
|
||||
'search',
|
||||
@@ -500,6 +504,7 @@ export const sidebarNavGroups: SidebarNavGroupDefinition[] = [
|
||||
},
|
||||
{
|
||||
label: 'System',
|
||||
key: 'system',
|
||||
ids: [
|
||||
'settings',
|
||||
'apiKey',
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
getStoredSidebarCollapsed,
|
||||
getStoredSidebarGroups,
|
||||
useSidebarState
|
||||
} from './sidebarState';
|
||||
|
||||
describe('sidebarState persistence (#396)', () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
it('defaults to expanded when nothing is stored', () => {
|
||||
expect(getStoredSidebarCollapsed()).toBe(false);
|
||||
expect(getStoredSidebarGroups()).toEqual({});
|
||||
});
|
||||
|
||||
it('reads the collapsed flag only from the exact "1" sentinel', () => {
|
||||
window.localStorage.setItem('ctv-sidebar-collapsed', '1');
|
||||
expect(getStoredSidebarCollapsed()).toBe(true);
|
||||
|
||||
window.localStorage.setItem('ctv-sidebar-collapsed', '0');
|
||||
expect(getStoredSidebarCollapsed()).toBe(false);
|
||||
});
|
||||
|
||||
it('ignores malformed or non-boolean group JSON', () => {
|
||||
window.localStorage.setItem('ctv-sidebar-groups', 'not json');
|
||||
expect(getStoredSidebarGroups()).toEqual({});
|
||||
|
||||
window.localStorage.setItem('ctv-sidebar-groups', JSON.stringify(['media']));
|
||||
expect(getStoredSidebarGroups()).toEqual({});
|
||||
|
||||
window.localStorage.setItem(
|
||||
'ctv-sidebar-groups',
|
||||
JSON.stringify({ media: true, system: 'nope' })
|
||||
);
|
||||
expect(getStoredSidebarGroups()).toEqual({ media: true });
|
||||
});
|
||||
|
||||
it('treats an unknown labeled group as collapsed by default, and toggles + persists it', () => {
|
||||
const { result } = renderHook(() => useSidebarState());
|
||||
|
||||
expect(result.current.isGroupCollapsed('media')).toBe(true);
|
||||
|
||||
act(() => result.current.toggleGroup('media'));
|
||||
|
||||
expect(result.current.isGroupCollapsed('media')).toBe(false);
|
||||
expect(getStoredSidebarGroups()).toEqual({ media: false });
|
||||
});
|
||||
|
||||
it('toggles + persists the sidebar collapsed flag', () => {
|
||||
const { result } = renderHook(() => useSidebarState());
|
||||
|
||||
expect(result.current.collapsed).toBe(false);
|
||||
|
||||
act(() => result.current.toggleCollapsed());
|
||||
|
||||
expect(result.current.collapsed).toBe(true);
|
||||
expect(getStoredSidebarCollapsed()).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
// Persisted UI state for the shell sidebar (issue #396). Two client-local preferences,
|
||||
// namespaced `ctv-*` per the persisted-UI-state convention (spa-conventions.md §5d):
|
||||
// - ctv-sidebar-collapsed: "1" | "0" — is the sidebar collapsed to the icon rail?
|
||||
// - ctv-sidebar-groups: JSON {groupKey: boolean} — is a labeled nav group collapsed?
|
||||
// A labeled group with no stored entry defaults to COLLAPSED, so a fresh load shows only the
|
||||
// always-open Primary group. (The prototype named these keys `ctv.sidebar.*`; we use the
|
||||
// established `ctv-` hyphen form — see docs/decisions.md 2026-07-18.)
|
||||
|
||||
const COLLAPSED_KEY = 'ctv-sidebar-collapsed';
|
||||
const GROUPS_KEY = 'ctv-sidebar-groups';
|
||||
|
||||
function getStorage(): Storage | null {
|
||||
try {
|
||||
return window.localStorage;
|
||||
} catch {
|
||||
// Storage can be disabled/unavailable (privacy mode, sandboxed iframe) — degrade to defaults.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getStoredSidebarCollapsed(): boolean {
|
||||
return getStorage()?.getItem(COLLAPSED_KEY) === '1';
|
||||
}
|
||||
|
||||
function setStoredSidebarCollapsed(collapsed: boolean): void {
|
||||
try {
|
||||
getStorage()?.setItem(COLLAPSED_KEY, collapsed ? '1' : '0');
|
||||
} catch {
|
||||
// ignore write failures (quota / unavailable) — in-memory state still updates
|
||||
}
|
||||
}
|
||||
|
||||
export function getStoredSidebarGroups(): Record<string, boolean> {
|
||||
const raw = getStorage()?.getItem(GROUPS_KEY);
|
||||
if (!raw) {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
const out: Record<string, boolean> = {};
|
||||
for (const [key, value] of Object.entries(parsed as Record<string, unknown>)) {
|
||||
if (typeof value === 'boolean') {
|
||||
out[key] = value;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
} catch {
|
||||
// malformed JSON — fall through to defaults
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
function setStoredSidebarGroups(groups: Record<string, boolean>): void {
|
||||
try {
|
||||
getStorage()?.setItem(GROUPS_KEY, JSON.stringify(groups));
|
||||
} catch {
|
||||
// ignore write failures — in-memory state still updates
|
||||
}
|
||||
}
|
||||
|
||||
export interface SidebarState {
|
||||
/** Is the sidebar collapsed to the icon rail? */
|
||||
collapsed: boolean;
|
||||
toggleCollapsed: () => void;
|
||||
/** Is a labeled nav group collapsed? Unknown groups default to collapsed. */
|
||||
isGroupCollapsed: (groupKey: string) => boolean;
|
||||
toggleGroup: (groupKey: string) => void;
|
||||
}
|
||||
|
||||
export function useSidebarState(): SidebarState {
|
||||
const [collapsed, setCollapsed] = useState<boolean>(getStoredSidebarCollapsed);
|
||||
const [groups, setGroups] = useState<Record<string, boolean>>(getStoredSidebarGroups);
|
||||
|
||||
const toggleCollapsed = useCallback(() => {
|
||||
setCollapsed((previous) => {
|
||||
const next = !previous;
|
||||
setStoredSidebarCollapsed(next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const isGroupCollapsed = useCallback(
|
||||
// Absent entry ⇒ default collapsed for labeled groups.
|
||||
(groupKey: string) => groups[groupKey] ?? true,
|
||||
[groups]
|
||||
);
|
||||
|
||||
const toggleGroup = useCallback((groupKey: string) => {
|
||||
setGroups((previous) => {
|
||||
const currentlyCollapsed = previous[groupKey] ?? true;
|
||||
const next = { ...previous, [groupKey]: !currentlyCollapsed };
|
||||
setStoredSidebarGroups(next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return { collapsed, toggleCollapsed, isGroupCollapsed, toggleGroup };
|
||||
}
|
||||
@@ -861,6 +861,88 @@
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* Collapsible nav-group header (accordion toggle) — the interactive replacement for the static
|
||||
NavSection label in the expanded sidebar (#396). */
|
||||
.ctv-nav-group-header {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin: 8px 0 2px;
|
||||
padding: 6px 10px;
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--text-disabled);
|
||||
cursor: pointer;
|
||||
font-size: var(--text-2xs);
|
||||
font-weight: var(--weight-semibold);
|
||||
letter-spacing: var(--tracking-caps);
|
||||
line-height: 1;
|
||||
text-align: left;
|
||||
text-transform: uppercase;
|
||||
transition: color var(--dur-fast) var(--ease-standard);
|
||||
}
|
||||
|
||||
.ctv-nav-group-header:hover {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.ctv-nav-group-header:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--ring-focus);
|
||||
}
|
||||
|
||||
.ctv-nav-group-header > span {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ctv-nav-group-chevron {
|
||||
flex: 0 0 auto;
|
||||
transition: transform var(--dur-fast) var(--ease-standard);
|
||||
}
|
||||
|
||||
/* Collapsed group ⇒ chevron points right (closed). */
|
||||
.ctv-nav-group-header[aria-expanded='false'] .ctv-nav-group-chevron {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
/* ---- Collapsed (icon-rail) nav items (#396) ---- */
|
||||
.ctv-app-shell-collapsed .ctv-nav-item {
|
||||
justify-content: center;
|
||||
gap: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Keep the label in the a11y tree (accessible name) but visually hidden in the rail; the native
|
||||
title tooltip surfaces it for sighted mouse users. */
|
||||
.ctv-app-shell-collapsed .ctv-nav-label {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* A numeric badge collapses to a small corner dot in the rail. */
|
||||
.ctv-app-shell-collapsed .ctv-nav-badge {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
right: 9px;
|
||||
min-width: 0;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
padding: 0;
|
||||
font-size: 0;
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
.ctv-tabs {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
|
||||
@@ -13,6 +13,9 @@ export interface NavItemProps {
|
||||
href?: string;
|
||||
onClick?: (e: MouseEvent) => void;
|
||||
style?: CSSProperties;
|
||||
// Native tooltip; used by the collapsed sidebar rail to surface the label that is visually
|
||||
// hidden there (the label stays in the a11y tree for the accessible name — #396).
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export function NavItem({
|
||||
@@ -23,7 +26,8 @@ export function NavItem({
|
||||
badgeTone = 'warn',
|
||||
href,
|
||||
onClick,
|
||||
style
|
||||
style,
|
||||
title
|
||||
}: NavItemProps) {
|
||||
const content = (
|
||||
<>
|
||||
@@ -44,6 +48,7 @@ export function NavItem({
|
||||
onClick={onClick}
|
||||
className={classNames('ctv-nav-item', active && 'ctv-nav-item-active')}
|
||||
style={style}
|
||||
title={title}
|
||||
>
|
||||
{content}
|
||||
</a>
|
||||
@@ -57,6 +62,7 @@ export function NavItem({
|
||||
onClick={onClick}
|
||||
className={classNames('ctv-nav-item', active && 'ctv-nav-item-active')}
|
||||
style={style}
|
||||
title={title}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
|
||||
@@ -57,7 +57,7 @@ const MESSAGES: Record<PinFlowStatus, string> = {
|
||||
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.'
|
||||
'budget-exhausted': 'Still working — this can take a while on a large first sync. Check again to see if it finished.'
|
||||
};
|
||||
|
||||
const TERMINAL: ReadonlySet<PinFlowStatus> = new Set<PinFlowStatus>([
|
||||
|
||||
@@ -186,6 +186,28 @@ describe('LibrariesScreen', () => {
|
||||
expect(handle.fetchSpy).toHaveBeenCalledWith('/api/v1/libraries/scan-status', expect.any(Object));
|
||||
});
|
||||
|
||||
it('renders "Never scanned" for a null lastScan (#409)', async () => {
|
||||
// The API reports never-scanned libraries as a null lastScan (not the historical
|
||||
// 0001-01-01 MinValue sentinel - see docs/decisions.md #409), so the SPA only needs a
|
||||
// plain null/truthy check here.
|
||||
mockApi({
|
||||
mediaSources: [
|
||||
mediaSource({
|
||||
libraries: [
|
||||
library({ id: 31, itemCount: 0, lastScan: null, mediaKind: 'Movies', name: 'Movies' }),
|
||||
library({ id: 32, itemCount: 0, lastScan: null, mediaKind: 'Shows', name: 'TV Shows' })
|
||||
]
|
||||
})
|
||||
]
|
||||
});
|
||||
|
||||
render(<LibrariesScreen />);
|
||||
|
||||
expect((await screen.findAllByText('Movies')).length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText('Never scanned').length).toBeGreaterThanOrEqual(2);
|
||||
expect(screen.queryByText(/Last scan/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('converts 0 and 1 fractional scan-status percents to 0% and 100%', async () => {
|
||||
mockApi({
|
||||
libraryScanStatuses: [
|
||||
@@ -255,8 +277,18 @@ describe('LibrariesScreen', () => {
|
||||
const sourceFetchesBeforeCompletion = fetchCount(handle, '/api/v1/media-sources');
|
||||
await runPollTick(intervalHandlers);
|
||||
|
||||
expect(screen.queryByText('75%')).not.toBeInTheDocument();
|
||||
expect(fetchCount(handle, '/api/v1/media-sources')).toBe(sourceFetchesBeforeCompletion + 1);
|
||||
// loadScanStatuses (the interval handler) kicks off its own Promise.all(...).then(...) chain
|
||||
// (libraries.ts) rather than being itself async/awaitable, so `handler()` returns void - the
|
||||
// state update that clears the progress bar and the follow-up loadSources() refetch both land a
|
||||
// few microtask ticks after runPollTick's `act(async () => ...)` callback has already resolved.
|
||||
// Asserting synchronously right after the tick races that chain (#447); waitFor lets both
|
||||
// settle before asserting, without weakening either check.
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('75%')).not.toBeInTheDocument();
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(fetchCount(handle, '/api/v1/media-sources')).toBe(sourceFetchesBeforeCompletion + 1);
|
||||
});
|
||||
});
|
||||
|
||||
it('reconciles a 409 "already scanning" against scan-status without an error toast (#232)', async () => {
|
||||
|
||||
@@ -266,4 +266,46 @@ describe('PlexSourceScreen', () => {
|
||||
expect(screen.queryByText(/Your Plex account is authorized/i)).toBeNull();
|
||||
expect(screen.queryByText(/no eligible servers were discovered/i)).toBeNull();
|
||||
});
|
||||
|
||||
// #367: budget-exhausted told the user to "Use Refresh to check again", but the Servers card (the
|
||||
// only place Refresh lives) doesn't render with zero servers, so the instruction pointed at nothing.
|
||||
// The message now describes checking again generically, and a global "Check again" button re-checks
|
||||
// GET /api/v1/media-sources/plex without resuming the (already spent) timed poll loop.
|
||||
it('budget-exhausted with no servers offers a working "Check again" control instead of a dead Refresh reference', 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: 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 message no longer references a "Refresh" control that isn't rendered (no Servers card yet).
|
||||
expect(screen.queryByText(/Use Refresh/i)).toBeNull();
|
||||
expect(screen.queryByRole('heading', { name: 'Servers' })).toBeNull();
|
||||
|
||||
// A real, working "Check again" control is offered instead.
|
||||
const checkAgainButton = screen.getByRole('button', { name: 'Check again' });
|
||||
expect(checkAgainButton).toBeTruthy();
|
||||
|
||||
const callsBeforeCheck = counter.state;
|
||||
|
||||
// The next sync completes and a server is discovered by the time the user checks again.
|
||||
stateRef.current = { isAuthorized: true, isLocked: false, servers: [{ id: 3, name: 'Attic Server', address: 'http://plex:32400' }] };
|
||||
fireEvent.click(checkAgainButton);
|
||||
|
||||
// Check again performs its own fetch (does not rely on the exhausted timed poll loop resuming).
|
||||
await vi.waitFor(() => expect(counter.state).toBeGreaterThan(callsBeforeCheck));
|
||||
await vi.waitFor(() => expect(screen.getByRole('heading', { name: 'Servers' })).toBeTruthy());
|
||||
expect(screen.getByText('Attic Server')).toBeTruthy();
|
||||
expect(screen.getByText('Connected to Plex.')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,13 +28,16 @@ type BootState =
|
||||
// — the poll runs against GET /api/v1/media-sources/plex every 2s and, crucially, keeps polling while
|
||||
// authorized-but-still-locked ("finalizing / discovering servers"); the terminal success signal is
|
||||
// the lock RELEASING (see pinFlowPoll.ts). Server rows offer Refresh / Edit Libraries / Edit Path
|
||||
// Replacements. Refresh is disabled while the Plex lock is held.
|
||||
// Replacements; Refresh is disabled while the Plex lock is held. The budget-exhausted terminal (no
|
||||
// servers may exist yet) instead offers a global "Check again" affordance (#367) so its message
|
||||
// always points at a control that's actually rendered.
|
||||
export function PlexSourceScreen() {
|
||||
const [boot, setBoot] = useState<BootState>({ status: 'loading' });
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const [confirmSignOut, setConfirmSignOut] = useState(false);
|
||||
const [signingOut, setSigningOut] = useState(false);
|
||||
const [refreshingId, setRefreshingId] = useState<number | null>(null);
|
||||
const [checkingAgain, setCheckingAgain] = useState(false);
|
||||
|
||||
// Pin-flow UI state.
|
||||
const [pinState, setPinState] = useState<PinFlowState | null>(null);
|
||||
@@ -162,6 +165,41 @@ export function PlexSourceScreen() {
|
||||
});
|
||||
};
|
||||
|
||||
// The global "Check again" affordance for the budget-exhausted terminal (#367): that state has no
|
||||
// per-server Refresh (the Servers card only renders once a server is discovered), so the message
|
||||
// must point at something that actually exists. This does a single one-off re-check of
|
||||
// GET /api/v1/media-sources/plex rather than resuming the timed poll loop — the 150s budget is
|
||||
// spent; re-arming it silently would contradict "exhausted" and could spin forever on a sync that
|
||||
// never finishes.
|
||||
const checkAgain = () => {
|
||||
if (checkingAgain) {
|
||||
return;
|
||||
}
|
||||
setCheckingAgain(true);
|
||||
setActionError(null);
|
||||
getPlexState()
|
||||
.then((state) => {
|
||||
if (!activeRef.current) {
|
||||
return;
|
||||
}
|
||||
setBoot({ status: 'ready', state });
|
||||
const elapsed = Date.now() - startedAtRef.current;
|
||||
const next = evaluatePinFlow(
|
||||
{ isLocked: state.isLocked, isAuthorized: state.isAuthorized, hasServers: (state.servers ?? []).length > 0 },
|
||||
{ budgetExhausted: isPinFlowBudgetExhausted(elapsed) }
|
||||
);
|
||||
setPinState(next);
|
||||
setCheckingAgain(false);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (!activeRef.current) {
|
||||
return;
|
||||
}
|
||||
setCheckingAgain(false);
|
||||
setActionError(messageFromMediaSourcesError(error, 'Unable to check Plex status'));
|
||||
});
|
||||
};
|
||||
|
||||
const refresh = (serverId: number) => {
|
||||
setRefreshingId(serverId);
|
||||
setActionError(null);
|
||||
@@ -284,6 +322,18 @@ export function PlexSourceScreen() {
|
||||
{(popupBlocked || (polling && authUrl)) && authUrl && (
|
||||
<a href={authUrl} target="_blank" rel="noreferrer noopener">Open the Plex sign-in page</a>
|
||||
)}
|
||||
{pinState?.status === 'budget-exhausted' && (
|
||||
<Button
|
||||
disabled={checkingAgain}
|
||||
loading={checkingAgain}
|
||||
onClick={checkAgain}
|
||||
size="sm"
|
||||
startIcon={<RefreshCw aria-hidden="true" size={14} />}
|
||||
variant="secondary"
|
||||
>
|
||||
Check again
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!polling && !pinState && !hasPlexAccount && (
|
||||
|
||||
+96
-1
@@ -26,6 +26,21 @@ body {
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-sans, system-ui, sans-serif);
|
||||
font-size: var(--text-sm, 13px);
|
||||
/* Animate the sidebar collapse/expand (#396). Only the grid track width transitions so the
|
||||
content reflows smoothly without a layout jump. */
|
||||
transition: grid-template-columns var(--dur-base, 140ms) var(--ease-standard);
|
||||
}
|
||||
|
||||
/* Collapsed sidebar = the 60px icon rail (#396). Narrowing the tracked custom property drives the
|
||||
grid-template-columns transition above. */
|
||||
.ctv-app-shell-collapsed {
|
||||
--sidebar-w: 60px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.ctv-app-shell {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
.ctv-sidebar {
|
||||
@@ -97,6 +112,72 @@ body {
|
||||
font-size: var(--text-2xs, 11px);
|
||||
}
|
||||
|
||||
/* Sidebar collapse toggle in the brand header (#396). */
|
||||
.ctv-sidebar-toggle {
|
||||
margin-left: auto;
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm, 5px);
|
||||
background: transparent;
|
||||
color: var(--text-disabled);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background var(--dur-fast) var(--ease-standard),
|
||||
color var(--dur-fast) var(--ease-standard);
|
||||
}
|
||||
|
||||
.ctv-sidebar-toggle:hover {
|
||||
background: var(--ctv-accent-soft);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.ctv-sidebar-toggle:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--ring-focus);
|
||||
}
|
||||
|
||||
/* Hairline divider shown between nav groups in the collapsed rail (accordions don't apply
|
||||
there, so the group label/chevron is replaced by a plain separator — #396). */
|
||||
.ctv-nav-divider {
|
||||
height: 1px;
|
||||
margin: var(--space-4, 8px) var(--space-4, 8px);
|
||||
background: var(--border-hairline);
|
||||
}
|
||||
|
||||
/* ---- Collapsed (icon-rail) sidebar overrides (#396) ---- */
|
||||
.ctv-app-shell-collapsed .ctv-brand {
|
||||
padding: 0;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.ctv-app-shell-collapsed .ctv-brand img,
|
||||
.ctv-app-shell-collapsed .ctv-brand-wordmark {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ctv-app-shell-collapsed .ctv-sidebar-toggle {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
/* Footer: hide the version text block and the health label, keep the status dot centered. */
|
||||
.ctv-app-shell-collapsed .ctv-sidebar-health {
|
||||
justify-content: center;
|
||||
padding: var(--space-6, 12px) 0;
|
||||
}
|
||||
|
||||
.ctv-app-shell-collapsed .ctv-sidebar-health > div {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ctv-app-shell-collapsed .ctv-sidebar-health .ctv-status-dot > span:last-child {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ctv-shell-body {
|
||||
min-width: 0;
|
||||
height: 100vh;
|
||||
@@ -3730,10 +3811,24 @@ body {
|
||||
}
|
||||
|
||||
.ctv-nav,
|
||||
.ctv-sidebar-health {
|
||||
.ctv-sidebar-health,
|
||||
.ctv-sidebar-toggle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* The collapse feature is desktop-only (the nav is hidden here). If the sidebar was collapsed on
|
||||
desktop, don't let the rail's brand-hiding leave an empty header on mobile — restore the brand
|
||||
(#396). */
|
||||
.ctv-app-shell-collapsed .ctv-brand {
|
||||
padding: 0 14px;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.ctv-app-shell-collapsed .ctv-brand img,
|
||||
.ctv-app-shell-collapsed .ctv-brand-wordmark {
|
||||
display: revert;
|
||||
}
|
||||
|
||||
.ctv-topbar {
|
||||
flex-wrap: wrap;
|
||||
height: auto;
|
||||
|
||||
Reference in New Issue
Block a user