Compare commits

...
Author SHA1 Message Date
timothyandClaude Opus 4.8 316b673c3b docs: correct §6 wording — scan matches controller classes, not ControllerBase subtypes (review)
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m31s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m33s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 07:51:06 +02:00
timothyandClaude Opus 4.8 9f73542296 feat: quick wins — security-registry scan, Trakt SPA link, parallel search, dead "New Group" sweep
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 4m25s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 4m33s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
- #184: ApiControllerSecurityTests scans ErsatzTV.Controllers.Api assembly
  instead of a hand-maintained array (9 controllers were unlisted; 2 mutating)
- Trakt matched-items link now navigates to SPA /app/search (was Classic UI)
- GET /api/search runs its 10 per-kind queries in parallel (context-safe)
- Remove dead "New Group" header buttons from blocks/templates/decos/deco-templates
- docs/api-conventions.md §6 updated for the assembly-scan change

fixes #184

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 07:47:49 +02:00
5 changed files with 80 additions and 69 deletions
@@ -24,16 +24,42 @@ public class GetSearchResultsHandler(IMediator mediator)
return new SearchResultGroupResponseModel(paged.TotalCount, paged.Page);
}
// Each ForKind call sends through IMediator to GetLibraryBrowseItemsHandler, which creates its
// own TvContext per invocation via IDbContextFactory<TvContext> (never shares one across calls),
// so these are safe to run concurrently.
Task<SearchResultGroupResponseModel> movies = ForKind(LibraryBrowseMediaType.Movie);
Task<SearchResultGroupResponseModel> shows = ForKind(LibraryBrowseMediaType.TelevisionShow);
Task<SearchResultGroupResponseModel> seasons = ForKind(LibraryBrowseMediaType.TelevisionSeason);
Task<SearchResultGroupResponseModel> artists = ForKind(LibraryBrowseMediaType.Artist);
Task<SearchResultGroupResponseModel> episodes = ForKind(LibraryBrowseMediaType.Episode);
Task<SearchResultGroupResponseModel> musicVideos = ForKind(LibraryBrowseMediaType.MusicVideo);
Task<SearchResultGroupResponseModel> songs = ForKind(LibraryBrowseMediaType.Song);
Task<SearchResultGroupResponseModel> otherVideos = ForKind(LibraryBrowseMediaType.OtherVideo);
Task<SearchResultGroupResponseModel> images = ForKind(LibraryBrowseMediaType.Image);
Task<SearchResultGroupResponseModel> remoteStreams = ForKind(LibraryBrowseMediaType.RemoteStream);
await Task.WhenAll(
movies,
shows,
seasons,
artists,
episodes,
musicVideos,
songs,
otherVideos,
images,
remoteStreams);
return new SearchResultsResponseModel(
await ForKind(LibraryBrowseMediaType.Movie),
await ForKind(LibraryBrowseMediaType.TelevisionShow),
await ForKind(LibraryBrowseMediaType.TelevisionSeason),
await ForKind(LibraryBrowseMediaType.Artist),
await ForKind(LibraryBrowseMediaType.Episode),
await ForKind(LibraryBrowseMediaType.MusicVideo),
await ForKind(LibraryBrowseMediaType.Song),
await ForKind(LibraryBrowseMediaType.OtherVideo),
await ForKind(LibraryBrowseMediaType.Image),
await ForKind(LibraryBrowseMediaType.RemoteStream));
movies.Result,
shows.Result,
seasons.Result,
artists.Result,
episodes.Result,
musicVideos.Result,
songs.Result,
otherVideos.Result,
images.Result,
remoteStreams.Result);
}
}
@@ -23,37 +23,26 @@ public class ApiControllerSecurityTests
[Test]
public void Every_Mutating_Api_Action_Should_Be_Globally_Protected_Or_Explicitly_Exempt()
{
Type[] apiControllers =
[
typeof(ArtistsController),
typeof(BlockController),
typeof(ChannelController),
typeof(CollectionController),
typeof(DecoController),
typeof(DecoTemplateController),
typeof(FFmpegProfileController),
typeof(FillerPresetController),
typeof(ImagesController),
typeof(LibrariesController),
typeof(LogsController),
typeof(MaintenanceController),
typeof(MediaItemsController),
typeof(MoviesController),
typeof(PlayoutController),
typeof(ResolutionController),
typeof(ScannerController),
typeof(ScheduleController),
typeof(ScriptedScheduleController),
typeof(SeasonsController),
typeof(SessionController),
typeof(ShowsController),
typeof(SettingsController),
typeof(SmartCollectionController),
typeof(TemplateController),
typeof(TraktController),
typeof(TroubleshootController),
typeof(WatermarkController)
];
// Scan the assembly instead of hand-maintaining a list of controllers here: a
// hardcoded array silently drifts as new controllers are added (9 were missing at
// one point, including two with real mutating actions - ArtworkUploadController POST
// and ChannelTemplateController POST/PUT/DELETE - that were never actually checked).
// Note: not every controller in this namespace derives from ControllerBase (e.g.
// ChannelController, ScannerController, SessionController, MaintenanceController,
// TroubleshootController) - filter on [ApiController] + concrete class only, matching
// the pattern already used by ScannerController_Should_Be_Only_Api_Key_Exempt_Api_Controller
// below, so those controllers stay covered rather than silently dropping out of the scan.
Type[] apiControllers = typeof(CollectionController)
.Assembly
.GetTypes()
.Where(t => t.Namespace == typeof(CollectionController).Namespace)
.Where(t => t is { IsClass: true, IsAbstract: false })
.Where(t => t.GetCustomAttributes<ApiControllerAttribute>(inherit: true).Any())
.ToArray();
// Guard against the scan silently matching nothing (e.g. a namespace rename) and
// giving this test a false pass.
apiControllers.Length.ShouldBeGreaterThanOrEqualTo(20);
foreach (Type controllerType in apiControllers)
{
+10 -10
View File
@@ -130,16 +130,16 @@ new DTO with a `DayOfWeek` (or `DayOfWeek[]`) member — don't trust the generat
`mediator.Received(1).Send(Arg.Is<Command>(...))` call. `PlayoutControllerTests.cs` is the same
pattern for a larger, mixed-verb controller — use it as the template for a new controller with
many actions.
- **`ApiControllerSecurityTests.cs`** (`ErsatzTV.Tests/Controllers/`): has a hardcoded
`Type[] apiControllers` registry. **Add every new controller to that array** — the test walks
every mutating (POST/PUT/PATCH/DELETE) action on each listed controller and asserts it's covered
by the global `ApiKeyAuthorizationFilter` (or has an explicit `[SkipApiKeyAuthorizationAttribute]`
exemption; only `ScannerController` is exempt today). **Note**: as of this writing the array does
not include every controller under `ErsatzTV/Controllers/Api/` (e.g. `ArtworkUploadController`,
`ChannelTemplateController`, `GraphicsElementController`, `LibraryBrowseController`,
`SearchController`, `VersionController`, `HealthController`, `PlaylistController`,
`MediaSourcesController` are missing) — treat that as a pre-existing gap to close opportunistically,
not a precedent to extend.
- **`ApiControllerSecurityTests.cs`** (`ErsatzTV.Tests/Controllers/`): reflects over the
`ErsatzTV.Controllers.Api` namespace to find every concrete, `[ApiController]`-marked
controller class — no manual registry to maintain; a new controller is covered
automatically. (It intentionally does **not** filter on `ControllerBase`: several API
controllers — including the lone exempt `ScannerController` — do not derive from it, and a
`ControllerBase` filter would silently drop them.) The test walks every mutating (POST/PUT/PATCH/DELETE) action on each scanned
controller and asserts it's covered by the global `ApiKeyAuthorizationFilter` (or has an
explicit `[SkipApiKeyAuthorizationAttribute]` exemption; only `ScannerController` is exempt
today). A minimum-count guard asserts the scan found a sane number of controllers, so a
namespace rename can't silently make the scan match nothing and give this test a false pass.
- **Handler tests** (business logic behind the controller) use the shared in-memory SQLite fixture:
`ErsatzTV.Tests/Support/InMemoryTvContext.cs`. Pattern: `SqliteConnection("Data Source=:memory:;Foreign
Keys=False")` kept open for the fixture's lifetime, `EnsureCreatedAsync()` (not full migration
+4 -4
View File
@@ -290,7 +290,7 @@ const routes: ScreenRoute[] = [
kicker: 'Programming',
description: 'Group blocks and edit their items, durations, and playout preview.',
icon: <Boxes aria-hidden="true" size={16} />,
primaryAction: 'New Group',
primaryAction: '',
placeholder: 'Blocks workspace',
allowSubPaths: true
},
@@ -303,7 +303,7 @@ const routes: ScreenRoute[] = [
kicker: 'Programming',
description: 'Group templates and assign blocks to times of day.',
icon: <CalendarDays aria-hidden="true" size={16} />,
primaryAction: 'New Group',
primaryAction: '',
placeholder: 'Templates workspace',
allowSubPaths: true
},
@@ -316,7 +316,7 @@ const routes: ScreenRoute[] = [
kicker: 'Programming',
description: 'Group decos and configure watermark, graphics, break content, and filler overrides.',
icon: <Palette aria-hidden="true" size={16} />,
primaryAction: 'New Group',
primaryAction: '',
placeholder: 'Decos workspace',
allowSubPaths: true
},
@@ -329,7 +329,7 @@ const routes: ScreenRoute[] = [
kicker: 'Programming',
description: 'Group deco templates and assign decos to times of day.',
icon: <CalendarRange aria-hidden="true" size={16} />,
primaryAction: 'New Group',
primaryAction: '',
placeholder: 'Deco templates workspace',
allowSubPaths: true
},
+10 -14
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { ArrowLeft, ExternalLink, Pencil, Plus, RefreshCw, Search, Trash2, TriangleAlert } from 'lucide-react';
import { ArrowLeft, Pencil, Plus, RefreshCw, Search, Trash2, TriangleAlert } from 'lucide-react';
import { navigateToPath } from '../routing';
import { Badge, Button, Card, ConfirmDialog, Dialog, IconButton, Input, Spinner, Switch } from '../components';
import {
@@ -16,11 +16,8 @@ import {
const TRAKT_BASE_PATH = '/app/trakt-lists';
// The SPA has no search screen yet (#161) — "view matched items" opens the classic Blazor
// search page, same interim deep-link pattern used elsewhere for un-migrated screens.
function classicSearchUrl(traktId: number): string {
const params = new URLSearchParams({ query: `trakt_list:${traktId}` });
return `/search?${params.toString()}`;
function viewMatchedItems(traktId: number) {
navigateToPath(`/app/search?query=${encodeURIComponent(`trakt_list:${traktId}`)}`);
}
function traktListIdFromPathname(pathname: string): number | null {
@@ -462,15 +459,14 @@ export function TraktListsScreen() {
>
<RefreshCw aria-hidden="true" size={14} />
</IconButton>
<a
className="ctv-icon-button ctv-icon-button-sm"
href={classicSearchUrl(list.traktId)}
rel="noreferrer"
target="_blank"
title="View matched items (opens the Classic UI search page)"
<IconButton
disabled={busy}
onClick={() => viewMatchedItems(list.traktId)}
size="sm"
title="View matched items"
>
<ExternalLink aria-hidden="true" size={14} />
</a>
<Search aria-hidden="true" size={14} />
</IconButton>
<IconButton
disabled={busy}
onClick={() => setDeleteTarget(list)}