Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6902f559f0 | ||
|
|
0290594f0b |
@@ -24,42 +24,16 @@ 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(
|
||||
movies.Result,
|
||||
shows.Result,
|
||||
seasons.Result,
|
||||
artists.Result,
|
||||
episodes.Result,
|
||||
musicVideos.Result,
|
||||
songs.Result,
|
||||
otherVideos.Result,
|
||||
images.Result,
|
||||
remoteStreams.Result);
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,26 +23,37 @@ public class ApiControllerSecurityTests
|
||||
[Test]
|
||||
public void Every_Mutating_Api_Action_Should_Be_Globally_Protected_Or_Explicitly_Exempt()
|
||||
{
|
||||
// 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);
|
||||
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)
|
||||
];
|
||||
|
||||
foreach (Type controllerType in apiControllers)
|
||||
{
|
||||
|
||||
+9
-5
@@ -16,11 +16,14 @@ Read in this order at session start:
|
||||
5. **`docs/spa-conventions.md`** — playbook for adding a screen to the ChicoryTV React SPA.
|
||||
6. **`docs/e2e-local.md`** (+ `scripts/e2e-local.sh`) — how to run a live local instance for manual
|
||||
or Playwright-MCP verification.
|
||||
7. **`docs/blazor-route-parity.md`** — the #91 phase (b) tracker: which Blazor routes are
|
||||
7. **`docs/testing.md`** — testing map: what each `*.Tests` project / `web` suite covers,
|
||||
golden-file nets, the timezone-independence rule, how to run subsets, the per-PR verification
|
||||
gate.
|
||||
8. **`docs/blazor-route-parity.md`** — the #91 phase (b) tracker: which Blazor routes are
|
||||
redirected, SPA-ready-but-not-redirected, or still Blazor-only (and which issue blocks each).
|
||||
8. **`docs/decisions.md`** — append-only "why" log. Check here before challenging an existing
|
||||
9. **`docs/decisions.md`** — append-only "why" log. Check here before challenging an existing
|
||||
convention.
|
||||
9. **`docs/ci-cd.md`** — build/test/release pipeline, versioning, dependency management.
|
||||
10. **`docs/ci-cd.md`** — build/test/release pipeline, versioning, dependency management.
|
||||
|
||||
Also present in `docs/`:
|
||||
|
||||
@@ -30,8 +33,9 @@ Also present in `docs/`:
|
||||
- **`docs/m3u-xmltv.md`** — M3U/XMLTV generation overview (`ChannelPlaylist`, `GetChannelGuideHandler`).
|
||||
- **`docs/fork-strategy.md`** — divergence policy vs upstream ErsatzTV.
|
||||
- **`docs/design-sync.md`** — Claude Design ↔ repo screen workflow (#92).
|
||||
- **`docs/endpoint-index.md`** — generated REST endpoint index (method/path/operationId/summary per
|
||||
OpenAPI tag). Do not edit by hand; regenerated by `scripts/generate-endpoint-index.py` /
|
||||
`scripts/update-openapi.sh`.
|
||||
- **`docs/handoffs/chicorytv-issue-queue.md`** — living session-to-session handoff: current queue
|
||||
state, what's next. Check this for what's actively in flight before starting new work.
|
||||
- **`docs/handoffs/rest-api.md`** — original handoff prompt for kicking off the REST API work (#2).
|
||||
|
||||
Still to come (tracked under #185): a testing map and a generated-endpoint index.
|
||||
|
||||
+10
-10
@@ -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/`): 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.
|
||||
- **`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.
|
||||
- **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
|
||||
|
||||
+5
-12
@@ -111,18 +111,11 @@ exceptions for control flow.
|
||||
|
||||
## 8. Testing
|
||||
|
||||
- **NUnit** (`[TestFixture]`/`[Test]`/`[TestCase]`) + **Shouldly** (`.ShouldBe(...)`) + **NSubstitute**
|
||||
+ Testably.Abstractions for a fake filesystem. **xUnit is not used.** Tests live in `*.Tests`
|
||||
projects mirroring the source.
|
||||
- Established kinds: **FFmpeg command-string assertions** (build a pipeline → assert the exact arg
|
||||
string — `ErsatzTV.FFmpeg.Tests/PipelineBuilderBaseTests.cs`); **golden-file tests** for
|
||||
Jellyfin-facing output (`ErsatzTV.Core.Tests/Iptv/ChannelPlaylistGoldenTests.cs`, regen via
|
||||
`ETV_UPDATE_GOLDENS=1`, ersatztv#11); **architecture tests** (§1, ersatztv#12).
|
||||
- The suite is **timezone-independent** — the previously timezone-sensitive `DateTimeOffset`
|
||||
filler-scheduling tests were fixed (ersatztv#24) by giving fixtures realistic times instead of the
|
||||
default `DateTime.MinValue`, which underflowed `DateTimeOffset.MinValue` under a non-UTC offset.
|
||||
When constructing test `PlayoutItem`s, set a real `Start` (e.g. `startState.CurrentTime.UtcDateTime`),
|
||||
not the default. CI runs UTC and uses `dotnet test … --blame-hang-timeout 2m`.
|
||||
**NUnit** (`[TestFixture]`/`[Test]`/`[TestCase]`) + **Shouldly** + **NSubstitute**; xUnit is not
|
||||
used. Golden-file tests guard the M3U/XMLTV output formats and a hard fail on a missing/changed
|
||||
golden means broken code, not a stale fixture — never regenerate via `ETV_UPDATE_GOLDENS=1` in CI
|
||||
or from an agent. Full testing map (per-project coverage, golden-file details, timezone notes,
|
||||
how to run subsets, the per-PR gate): **`docs/testing.md`**.
|
||||
|
||||
## 9. Build / CI
|
||||
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
# API endpoint index
|
||||
|
||||
*Generated by `scripts/generate-endpoint-index.py` from `ErsatzTV/wwwroot/openapi/v1.json`. Do not edit by hand -- regenerated by `scripts/update-openapi.sh`.*
|
||||
|
||||
114 endpoints, 175 operations.
|
||||
|
||||
## Artists
|
||||
|
||||
| Method | Path | Operation | Summary |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/artists/{id}` | GetArtistById | Get an artist by id |
|
||||
|
||||
## Artwork
|
||||
|
||||
| Method | Path | Operation | Summary |
|
||||
|---|---|---|---|
|
||||
| POST | `/api/artwork/uploads` | UploadArtwork | Upload channel logo or watermark artwork |
|
||||
|
||||
## Blocks
|
||||
|
||||
| Method | Path | Operation | Summary |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/blocks` | | Get all blocks |
|
||||
| POST | `/api/blocks` | | Create a block |
|
||||
| GET | `/api/blocks/groups` | GetBlockGroups | Get all block groups |
|
||||
| POST | `/api/blocks/groups` | | Create a block group |
|
||||
| DELETE | `/api/blocks/groups/{id}` | | Delete a block group |
|
||||
| DELETE | `/api/blocks/{id}` | | Delete a block |
|
||||
| GET | `/api/blocks/{id}` | GetBlockById | Get a block by id |
|
||||
| PUT | `/api/blocks/{id}` | | Replace a block and its items |
|
||||
| POST | `/api/blocks/{id}/copy` | | Copy a block |
|
||||
| GET | `/api/blocks/{id}/items` | | Get block items |
|
||||
| POST | `/api/blocks/{id}/preview` | | Preview a block playout |
|
||||
|
||||
## Channel
|
||||
|
||||
| Method | Path | Operation | Summary |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/channels` | | |
|
||||
|
||||
## Channel Templates
|
||||
|
||||
| Method | Path | Operation | Summary |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/channel-templates` | GetChannelTemplates | Get all channel templates |
|
||||
| POST | `/api/channel-templates` | CreateChannelTemplate | Create a channel template |
|
||||
| GET | `/api/channel-templates/default` | GetDefaultChannelTemplate | Get the default channel template |
|
||||
| PUT | `/api/channel-templates/default/{id}` | SetDefaultChannelTemplate | Set the default channel template |
|
||||
| DELETE | `/api/channel-templates/{id}` | DeleteChannelTemplate | Delete a channel template |
|
||||
| GET | `/api/channel-templates/{id}` | GetChannelTemplateById | Get a channel template by id |
|
||||
| PUT | `/api/channel-templates/{id}` | UpdateChannelTemplate | Update a channel template |
|
||||
|
||||
## Channels
|
||||
|
||||
| Method | Path | Operation | Summary |
|
||||
|---|---|---|---|
|
||||
| POST | `/api/channels` | | Create a channel |
|
||||
| POST | `/api/channels/bulk/delete` | | Delete channels |
|
||||
| POST | `/api/channels/bulk/group` | | Move channels to a group |
|
||||
| POST | `/api/channels/bulk/renumber` | | Renumber channels |
|
||||
| POST | `/api/channels/from-lineup` | CreateChannelFromLineup | Create a channel from a library lineup |
|
||||
| GET | `/api/channels/state` | | Get channel runtime state |
|
||||
| POST | `/api/channels/{channelNumber}/playout/reset` | | Reset channel playout |
|
||||
| DELETE | `/api/channels/{id}` | | Delete a channel |
|
||||
| GET | `/api/channels/{id}` | GetChannelById | Get a channel by id |
|
||||
| PUT | `/api/channels/{id}` | | Update a channel |
|
||||
| GET | `/api/guide` | | Get the JSON channel guide (EPG) |
|
||||
|
||||
## Collections
|
||||
|
||||
| Method | Path | Operation | Summary |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/collections` | | Get all collections |
|
||||
| POST | `/api/collections` | | Create a collection |
|
||||
| DELETE | `/api/collections/{id}` | | Delete a collection |
|
||||
| GET | `/api/collections/{id}` | GetCollectionById | Get a collection by id |
|
||||
| PUT | `/api/collections/{id}` | | Update a collection |
|
||||
| POST | `/api/collections/{id}/items` | | Add items to a collection |
|
||||
| DELETE | `/api/collections/{id}/items/{mediaItemId}` | | Remove an item from a collection |
|
||||
|
||||
## DecoTemplates
|
||||
|
||||
| Method | Path | Operation | Summary |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/deco-templates` | | Get all deco templates |
|
||||
| POST | `/api/deco-templates` | | Create a deco template |
|
||||
| GET | `/api/deco-templates/groups` | GetDecoTemplateGroups | Get all deco template groups |
|
||||
| POST | `/api/deco-templates/groups` | | Create a deco template group |
|
||||
| DELETE | `/api/deco-templates/groups/{id}` | | Delete a deco template group |
|
||||
| DELETE | `/api/deco-templates/{id}` | | Delete a deco template |
|
||||
| GET | `/api/deco-templates/{id}` | GetDecoTemplateById | Get a deco template by id |
|
||||
| PUT | `/api/deco-templates/{id}` | | Replace a deco template and its items |
|
||||
| GET | `/api/deco-templates/{id}/items` | | Get deco template items |
|
||||
|
||||
## Decos
|
||||
|
||||
| Method | Path | Operation | Summary |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/decos` | | Get all decos |
|
||||
| POST | `/api/decos` | | Create a deco |
|
||||
| GET | `/api/decos/groups` | GetDecoGroups | Get all deco groups |
|
||||
| POST | `/api/decos/groups` | | Create a deco group |
|
||||
| DELETE | `/api/decos/groups/{id}` | | Delete a deco group |
|
||||
| DELETE | `/api/decos/{id}` | | Delete a deco |
|
||||
| GET | `/api/decos/{id}` | GetDecoById | Get a deco by id |
|
||||
| PUT | `/api/decos/{id}` | | Replace a deco |
|
||||
|
||||
## FFmpeg Profiles
|
||||
|
||||
| Method | Path | Operation | Summary |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/ffmpeg/hardware-acceleration-kinds` | GetSupportedHardwareAccelerationKinds | Get supported hardware acceleration kinds |
|
||||
| GET | `/api/ffmpeg/profiles` | GetFFmpegProfiles | Get all FFmpeg profiles |
|
||||
| POST | `/api/ffmpeg/profiles` | CreateFFmpegProfile | Create an FFmpeg profile |
|
||||
| DELETE | `/api/ffmpeg/profiles/{id}` | DeleteFFmpegProfile | Delete an FFmpeg profile |
|
||||
| GET | `/api/ffmpeg/profiles/{id}` | GetFFmpegProfileById | Get an FFmpeg profile by id |
|
||||
| PUT | `/api/ffmpeg/profiles/{id}` | UpdateFFmpegProfile | Update an FFmpeg profile |
|
||||
|
||||
## Filler Presets
|
||||
|
||||
| Method | Path | Operation | Summary |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/filler-presets` | GetFillerPresets | Get all filler presets |
|
||||
| POST | `/api/filler-presets` | CreateFillerPreset | Create a filler preset |
|
||||
| DELETE | `/api/filler-presets/{id}` | DeleteFillerPreset | Delete a filler preset |
|
||||
| GET | `/api/filler-presets/{id}` | GetFillerPresetById | Get a filler preset by id |
|
||||
| PUT | `/api/filler-presets/{id}` | UpdateFillerPreset | Update a filler preset |
|
||||
|
||||
## Graphics Elements
|
||||
|
||||
| Method | Path | Operation | Summary |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/graphics-elements` | GetGraphicsElements | Get all graphics elements |
|
||||
|
||||
## Health
|
||||
|
||||
| Method | Path | Operation | Summary |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/health` | GetHealthChecks | Get health check results |
|
||||
|
||||
## Images
|
||||
|
||||
| Method | Path | Operation | Summary |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/images/folders` | GetImageFolders | List image library folders |
|
||||
| PUT | `/api/images/folders/{id}/duration` | UpdateImageFolderDuration | Set or clear an image folder's playout duration |
|
||||
|
||||
## Libraries
|
||||
|
||||
| Method | Path | Operation | Summary |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/libraries/scan-status` | GetLibraryScanStatus | Get active library scan status |
|
||||
| POST | `/api/libraries/{id}/scan` | | Scan library |
|
||||
| POST | `/api/libraries/{id}/scan-show` | | Scan show |
|
||||
| GET | `/api/library/browse` | BrowseLibrary | Browse and search library items |
|
||||
|
||||
## Logs
|
||||
|
||||
| Method | Path | Operation | Summary |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/logs` | GetLogs | Get recent log entries |
|
||||
|
||||
## Maintenance
|
||||
|
||||
| Method | Path | Operation | Summary |
|
||||
|---|---|---|---|
|
||||
| POST | `/api/maintenance/clean_artwork` | | Clean artwork cache |
|
||||
| POST | `/api/maintenance/empty_trash` | | Empty trash |
|
||||
| GET | `/api/maintenance/gc` | | Garbage collect |
|
||||
|
||||
## Media Items
|
||||
|
||||
| Method | Path | Operation | Summary |
|
||||
|---|---|---|---|
|
||||
| DELETE | `/api/media-items` | DeleteMediaItems | Delete media items from the database |
|
||||
| GET | `/api/media-items/{id}/info` | GetMediaItemInfo | Get technical media info for a media item |
|
||||
|
||||
## Media Sources
|
||||
|
||||
| Method | Path | Operation | Summary |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/media-sources` | GetMediaSources | Get all media sources with their libraries |
|
||||
|
||||
## Movies
|
||||
|
||||
| Method | Path | Operation | Summary |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/movies/{id}` | GetMovieById | Get a movie by id |
|
||||
|
||||
## Playlists
|
||||
|
||||
| Method | Path | Operation | Summary |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/playlists` | GetPlaylists | Get playlists in a playlist group |
|
||||
| GET | `/api/playlists/groups` | GetPlaylistGroups | Get all playlist groups |
|
||||
|
||||
## Playouts
|
||||
|
||||
| Method | Path | Operation | Summary |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/playouts` | GetPlayouts | List playouts |
|
||||
| POST | `/api/playouts` | | Create a playout |
|
||||
| GET | `/api/playouts/history/{id}` | GetPlayoutHistoryDetails | Decode a playout history row |
|
||||
| POST | `/api/playouts/reset-all` | ResetAllPlayouts | Reset all playouts |
|
||||
| GET | `/api/playouts/warnings/count` | GetPlayoutWarningsCount | Count playouts with a failed build |
|
||||
| DELETE | `/api/playouts/{id}` | | Delete a playout |
|
||||
| GET | `/api/playouts/{id}` | GetPlayoutById | Get a playout by id |
|
||||
| PUT | `/api/playouts/{id}` | | Update playout scheduling details |
|
||||
| GET | `/api/playouts/{id}/alternate-schedules` | GetPlayoutAlternateSchedules | Get a classic playout's alternate schedules |
|
||||
| PUT | `/api/playouts/{id}/alternate-schedules` | | Replace a classic playout's alternate schedules |
|
||||
| GET | `/api/playouts/{id}/blocks` | GetPlayoutBlocks | Get the blocks scheduled by a block playout |
|
||||
| GET | `/api/playouts/{id}/blocks/{blockId}/history` | GetPlayoutBlockHistory | Get a block's playout history |
|
||||
| PUT | `/api/playouts/{id}/deco` | | Set (or clear) a playout's default deco |
|
||||
| GET | `/api/playouts/{id}/items` | GetPlayoutItems | Get upcoming items (and unscheduled gaps) for a playout |
|
||||
| GET | `/api/playouts/{id}/templates` | GetPlayoutTemplates | Get a block playout's templates |
|
||||
| PUT | `/api/playouts/{id}/templates` | | Replace a block playout's templates |
|
||||
|
||||
## Resolution
|
||||
|
||||
| Method | Path | Operation | Summary |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/ffmpeg/resolution/by-name/{name}` | GetResolutionByName | |
|
||||
|
||||
## Schedules
|
||||
|
||||
| Method | Path | Operation | Summary |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/schedules` | | Get all schedules |
|
||||
| POST | `/api/schedules` | | Create a schedule |
|
||||
| DELETE | `/api/schedules/{id}` | | Delete a schedule |
|
||||
| GET | `/api/schedules/{id}` | GetScheduleById | Get a schedule by id |
|
||||
| PUT | `/api/schedules/{id}` | | Update a schedule |
|
||||
| GET | `/api/schedules/{id}/items` | | Get schedule items |
|
||||
| POST | `/api/schedules/{id}/items` | | Add a schedule item |
|
||||
| PUT | `/api/schedules/{id}/items` | | Replace schedule items |
|
||||
| DELETE | `/api/schedules/{id}/items/{itemId}` | | Delete a schedule item |
|
||||
|
||||
## Search
|
||||
|
||||
| Method | Path | Operation | Summary |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/search` | Search | Search library items across all media kinds |
|
||||
| GET | `/api/search/artists` | SearchArtists | Search artists by name |
|
||||
| GET | `/api/search/collections` | SearchCollections | Search collections by name |
|
||||
| GET | `/api/search/multi-collections` | SearchMultiCollections | Search multi collections by name |
|
||||
| GET | `/api/search/smart-collections` | SearchSmartCollections | Search smart collections by name |
|
||||
| GET | `/api/search/television-seasons` | SearchTelevisionSeasons | Search television seasons by name |
|
||||
| GET | `/api/search/television-shows` | SearchTelevisionShows | Search television shows by name |
|
||||
|
||||
## Sessions
|
||||
|
||||
| Method | Path | Operation | Summary |
|
||||
|---|---|---|---|
|
||||
| DELETE | `/api/session/{channelNumber}` | | Stop session |
|
||||
| GET | `/api/sessions` | | Get sessions |
|
||||
|
||||
## Settings
|
||||
|
||||
| Method | Path | Operation | Summary |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/settings/ffmpeg` | GetFfmpegSettings | Get global FFmpeg settings |
|
||||
| PUT | `/api/settings/ffmpeg` | UpdateFfmpegSettings | Update global FFmpeg settings |
|
||||
| GET | `/api/settings/hdhr` | GetHdhrSettings | Get HDHomeRun emulation settings |
|
||||
| PUT | `/api/settings/hdhr` | UpdateHdhrSettings | Update HDHomeRun emulation settings |
|
||||
| GET | `/api/settings/logging` | GetLoggingSettings | Get per-area minimum log levels |
|
||||
| PUT | `/api/settings/logging` | UpdateLoggingSettings | Update per-area minimum log levels |
|
||||
| GET | `/api/settings/playout` | GetPlayoutSettings | Get global playout settings |
|
||||
| PUT | `/api/settings/playout` | UpdatePlayoutSettings | Update global playout settings |
|
||||
| GET | `/api/settings/resolutions` | GetResolutions | Get all resolutions, including custom resolutions |
|
||||
| POST | `/api/settings/resolutions` | CreateResolution | Create a custom resolution |
|
||||
| DELETE | `/api/settings/resolutions/{id}` | DeleteResolution | Delete a custom resolution |
|
||||
| GET | `/api/settings/scanner` | GetScannerSettings | Get library scan cadence |
|
||||
| PUT | `/api/settings/scanner` | UpdateScannerSettings | Update library scan cadence |
|
||||
| GET | `/api/settings/ui` | GetUiSettings | Get UI preferences |
|
||||
| PUT | `/api/settings/ui` | UpdateUiSettings | Update UI preferences |
|
||||
| GET | `/api/settings/xmltv` | GetXmltvSettings | Get global XMLTV settings |
|
||||
| PUT | `/api/settings/xmltv` | UpdateXmltvSettings | Update global XMLTV settings |
|
||||
|
||||
## Smart Collections
|
||||
|
||||
| Method | Path | Operation | Summary |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/smart-collections` | | Get all smart collections |
|
||||
| POST | `/api/smart-collections` | | Create a smart collection |
|
||||
| DELETE | `/api/smart-collections/{id}` | | Delete a smart collection |
|
||||
| GET | `/api/smart-collections/{id}` | GetSmartCollectionById | Get a smart collection by id |
|
||||
| PUT | `/api/smart-collections/{id}` | | Update a smart collection |
|
||||
|
||||
## Television
|
||||
|
||||
| Method | Path | Operation | Summary |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/seasons/{id}` | GetSeasonById | Get a television season by id |
|
||||
| GET | `/api/shows/{id}` | GetShowById | Get a television show by id |
|
||||
|
||||
## Templates
|
||||
|
||||
| Method | Path | Operation | Summary |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/templates` | | Get all templates |
|
||||
| POST | `/api/templates` | | Create a template |
|
||||
| GET | `/api/templates/groups` | GetTemplateGroups | Get all template groups |
|
||||
| POST | `/api/templates/groups` | | Create a template group |
|
||||
| DELETE | `/api/templates/groups/{id}` | | Delete a template group |
|
||||
| DELETE | `/api/templates/{id}` | | Delete a template |
|
||||
| GET | `/api/templates/{id}` | GetTemplateById | Get a template by id |
|
||||
| PUT | `/api/templates/{id}` | | Replace a template and its items |
|
||||
| POST | `/api/templates/{id}/copy` | | Copy a template |
|
||||
| GET | `/api/templates/{id}/items` | | Get template items |
|
||||
|
||||
## Trakt
|
||||
|
||||
| Method | Path | Operation | Summary |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/trakt/lists` | GetTraktLists | Get paged Trakt lists |
|
||||
| POST | `/api/trakt/lists` | | Add a Trakt list by URL |
|
||||
| DELETE | `/api/trakt/lists/{id}` | | Delete a Trakt list |
|
||||
| GET | `/api/trakt/lists/{id}` | GetTraktListById | Get a Trakt list by id |
|
||||
| PUT | `/api/trakt/lists/{id}` | | Update a Trakt list's settings |
|
||||
| POST | `/api/trakt/lists/{id}/match` | | Match a Trakt list's items |
|
||||
| GET | `/api/trakt/status` | GetTraktStatus | Get Trakt background operation status |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Method | Path | Operation | Summary |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/troubleshoot/info` | GetTroubleshootingInfo | Get troubleshooting diagnostic info |
|
||||
| GET | `/api/troubleshoot/playback.m3u8` | | Start a troubleshooting playback session |
|
||||
| HEAD | `/api/troubleshoot/playback.m3u8` | | Start a troubleshooting playback session |
|
||||
| GET | `/api/troubleshoot/playback/archive` | | Download the last troubleshooting playback session archive |
|
||||
| HEAD | `/api/troubleshoot/playback/archive` | | Download the last troubleshooting playback session archive |
|
||||
| GET | `/api/troubleshoot/playback/sample/{mediaItemId}` | | Download a media sample archive for troubleshooting |
|
||||
| HEAD | `/api/troubleshoot/playback/sample/{mediaItemId}` | | Download a media sample archive for troubleshooting |
|
||||
| POST | `/api/troubleshoot/validate-schedule` | ValidateSequentialSchedule | Validate a sequential schedule YAML document |
|
||||
|
||||
## Version
|
||||
|
||||
| Method | Path | Operation | Summary |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/version` | GetVersion | Get version |
|
||||
|
||||
## Watermarks
|
||||
|
||||
| Method | Path | Operation | Summary |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/watermarks` | GetWatermarks | Get all watermarks |
|
||||
| POST | `/api/watermarks` | CreateWatermark | Create a watermark |
|
||||
| DELETE | `/api/watermarks/{id}` | DeleteWatermark | Delete a watermark |
|
||||
| GET | `/api/watermarks/{id}` | GetWatermarkById | Get a watermark by id |
|
||||
| PUT | `/api/watermarks/{id}` | UpdateWatermark | Update a watermark |
|
||||
@@ -0,0 +1,79 @@
|
||||
# Testing map
|
||||
|
||||
Purpose: authoritative map of what each test project/suite covers and how to run it. Read this
|
||||
before adding tests, not just `docs/contributing.md` §8 (which now just points here).
|
||||
|
||||
## Test projects
|
||||
|
||||
| Project | Covers | Notes |
|
||||
|---|---|---|
|
||||
| `ErsatzTV.Tests` | API controllers + MediatR handlers | In-memory SQLite fixture: a shared `SqliteConnection("Data Source=:memory:;Foreign Keys=False")` kept open + `EnsureCreatedAsync()` (**not** full migration replay) + `PRAGMA foreign_keys=OFF`, then seed; a tiny `IDbContextFactory` wraps `new TvContext(...)`. 828 tests currently. |
|
||||
| `ErsatzTV.Core.Tests` | Domain logic, scheduling, IPTV/XMLTV generation | References `ErsatzTV.Application` directly — there is no separate `Application.Tests` project. 493 tests + 1 skipped. |
|
||||
| `ErsatzTV.Architecture.Tests` | Layering rules via NetArchTest.eNhancedEdition | Core↛Infra/App/EF; FFmpeg↛all; App↛concrete providers. 5 tests. See `docs/contributing.md` §1. |
|
||||
| `ErsatzTV.FFmpeg.Tests` | FFmpeg command construction | Build a pipeline, assert the exact rendered arg string (`PipelineBuilderBaseTests.cs`). |
|
||||
| `web/` (vitest) | React SPA unit tests | 330 tests; run alongside typecheck + build (see below). |
|
||||
|
||||
## Golden-file nets
|
||||
|
||||
Two golden-file suites guard the Jellyfin-facing output formats:
|
||||
|
||||
- **M3U**: `ErsatzTV.Core.Tests/Iptv/ChannelPlaylistGoldenTests.cs` (ersatztv#11)
|
||||
- **XMLTV**: `ChannelGuideGoldenTests` (ersatztv#28)
|
||||
|
||||
Both locate their golden files via `[CallerFilePath]`. A missing golden is a hard fail, not a
|
||||
skip. Regenerate via `ETV_UPDATE_GOLDENS=1 dotnet test ...`.
|
||||
|
||||
**Never set `ETV_UPDATE_GOLDENS` in CI or from an agent.** A golden diff during normal test runs
|
||||
means the code broke the output format — regenerating to make the diff go away hides the bug
|
||||
instead of fixing it. Only a human who has confirmed the format change is intentional should
|
||||
regenerate.
|
||||
|
||||
## Timezone independence
|
||||
|
||||
The suite is timezone-independent (ersatztv#24). When constructing test `PlayoutItem`s, always
|
||||
set a real `Start` (e.g. `startState.CurrentTime.UtcDateTime`) — never rely on the default
|
||||
`DateTime.MinValue`, which underflows `DateTimeOffset.MinValue` once a non-UTC local offset is
|
||||
applied (`StartOffset` calls `ToLocalTime()`). CI runs in UTC; local runs may not.
|
||||
|
||||
## Running tests
|
||||
|
||||
Full .NET gate:
|
||||
|
||||
```bash
|
||||
dotnet build ErsatzTV.sln
|
||||
TZ=UTC dotnet test
|
||||
```
|
||||
|
||||
CI adds `--blame-hang-timeout 2m` to catch hangs.
|
||||
|
||||
Fast subsets:
|
||||
|
||||
```bash
|
||||
# single project
|
||||
dotnet test ErsatzTV.Tests
|
||||
|
||||
# filtered
|
||||
dotnet test ErsatzTV.Core.Tests --filter FullyQualifiedName~ChannelPlaylistGoldenTests
|
||||
```
|
||||
|
||||
Web (`web/`):
|
||||
|
||||
```bash
|
||||
npm test # vitest
|
||||
npm run typecheck # tsc -b --pretty false
|
||||
npm run lint # eslint .
|
||||
npm run build # tsc -b && vite build
|
||||
```
|
||||
|
||||
## Per-PR verification gate
|
||||
|
||||
Before opening a PR: build the solution, run both .NET test projects (plus
|
||||
`ErsatzTV.Architecture.Tests` and `ErsatzTV.FFmpeg.Tests` if touched), and run the web test/lint/
|
||||
typecheck/build steps above. All must be green. A golden-file diff or an architecture-test
|
||||
failure is a hard stop — fix the code, don't regenerate/relax the test.
|
||||
|
||||
## See also
|
||||
|
||||
- `docs/ci-cd.md` — CI pipeline (test → migrations → build), versioning, dependency management.
|
||||
- `docs/contributing.md` §1 — layering rules enforced by `ErsatzTV.Architecture.Tests`.
|
||||
- `docs/contributing.md` §8 — short pointer back to this doc.
|
||||
Executable
+95
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate docs/endpoint-index.md from the OpenAPI spec.
|
||||
|
||||
Do not edit docs/endpoint-index.md by hand -- it is generated by this script
|
||||
from ErsatzTV/wwwroot/openapi/v1.json. Regenerated automatically as part of
|
||||
scripts/update-openapi.sh.
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
HTTP_METHODS = ("get", "put", "post", "delete", "options", "head", "patch", "trace")
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
SPEC_PATH = REPO_ROOT / "ErsatzTV" / "wwwroot" / "openapi" / "v1.json"
|
||||
OUTPUT_PATH = REPO_ROOT / "docs" / "endpoint-index.md"
|
||||
|
||||
UNTAGGED = "(untagged)"
|
||||
|
||||
|
||||
def load_operations(spec: dict) -> list[dict]:
|
||||
"""Flatten the OpenAPI paths object into a list of per-operation records."""
|
||||
operations = []
|
||||
for path, path_item in spec.get("paths", {}).items():
|
||||
for method, operation in path_item.items():
|
||||
if method.lower() not in HTTP_METHODS:
|
||||
continue
|
||||
operations.append(
|
||||
{
|
||||
"path": path,
|
||||
"method": method.upper(),
|
||||
"operationId": operation.get("operationId", ""),
|
||||
"summary": operation.get("summary", "") or "",
|
||||
"tags": operation.get("tags") or [UNTAGGED],
|
||||
}
|
||||
)
|
||||
return operations
|
||||
|
||||
|
||||
def group_by_tag(operations: list[dict]) -> dict[str, list[dict]]:
|
||||
grouped: dict[str, list[dict]] = {}
|
||||
for op in operations:
|
||||
for tag in op["tags"]:
|
||||
grouped.setdefault(tag, []).append(op)
|
||||
return grouped
|
||||
|
||||
|
||||
def render(spec: dict) -> str:
|
||||
operations = load_operations(spec)
|
||||
grouped = group_by_tag(operations)
|
||||
|
||||
tags = sorted(t for t in grouped if t != UNTAGGED)
|
||||
if UNTAGGED in grouped:
|
||||
tags.append(UNTAGGED)
|
||||
|
||||
lines = []
|
||||
lines.append("# API endpoint index")
|
||||
lines.append("")
|
||||
lines.append(
|
||||
"*Generated by `scripts/generate-endpoint-index.py` from "
|
||||
"`ErsatzTV/wwwroot/openapi/v1.json`. Do not edit by hand -- regenerated by "
|
||||
"`scripts/update-openapi.sh`.*"
|
||||
)
|
||||
lines.append("")
|
||||
lines.append(
|
||||
f"{len(spec.get('paths', {}))} endpoints, {len(operations)} operations."
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
for tag in tags:
|
||||
lines.append(f"## {tag}")
|
||||
lines.append("")
|
||||
lines.append("| Method | Path | Operation | Summary |")
|
||||
lines.append("|---|---|---|---|")
|
||||
for op in sorted(grouped[tag], key=lambda o: (o["path"], o["method"])):
|
||||
lines.append(
|
||||
f"| {op['method']} | `{op['path']}` | {op['operationId']} | {op['summary']} |"
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines).rstrip("\n") + "\n"
|
||||
|
||||
|
||||
def main() -> None:
|
||||
spec = json.loads(SPEC_PATH.read_text())
|
||||
OUTPUT_PATH.write_text(render(spec))
|
||||
operations = load_operations(spec)
|
||||
print(
|
||||
f"Wrote {OUTPUT_PATH.relative_to(REPO_ROOT)}: "
|
||||
f"{len(spec.get('paths', {}))} endpoints, {len(operations)} operations."
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,4 +1,11 @@
|
||||
#! /usr/bin/env bash
|
||||
|
||||
cd "$(git rev-parse --show-toplevel)" || exit
|
||||
cd ErsatzTV && dotnet build -t:GenerateOpenApiDocuments
|
||||
REPO_ROOT="$(pwd)"
|
||||
|
||||
# Only regenerate the endpoint index if the spec build succeeded, so a failed
|
||||
# build can't render docs/endpoint-index.md from a stale/partial v1.json.
|
||||
(cd ErsatzTV && dotnet build -t:GenerateOpenApiDocuments) || exit
|
||||
|
||||
cd "$REPO_ROOT" || exit
|
||||
python3 scripts/generate-endpoint-index.py
|
||||
|
||||
+4
-4
@@ -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: '',
|
||||
primaryAction: 'New Group',
|
||||
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: '',
|
||||
primaryAction: 'New Group',
|
||||
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: '',
|
||||
primaryAction: 'New Group',
|
||||
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: '',
|
||||
primaryAction: 'New Group',
|
||||
placeholder: 'Deco templates workspace',
|
||||
allowSubPaths: true
|
||||
},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { ArrowLeft, Pencil, Plus, RefreshCw, Search, Trash2, TriangleAlert } from 'lucide-react';
|
||||
import { ArrowLeft, ExternalLink, 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,8 +16,11 @@ import {
|
||||
|
||||
const TRAKT_BASE_PATH = '/app/trakt-lists';
|
||||
|
||||
function viewMatchedItems(traktId: number) {
|
||||
navigateToPath(`/app/search?query=${encodeURIComponent(`trakt_list:${traktId}`)}`);
|
||||
// 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 traktListIdFromPathname(pathname: string): number | null {
|
||||
@@ -459,14 +462,15 @@ export function TraktListsScreen() {
|
||||
>
|
||||
<RefreshCw aria-hidden="true" size={14} />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
disabled={busy}
|
||||
onClick={() => viewMatchedItems(list.traktId)}
|
||||
size="sm"
|
||||
title="View matched items"
|
||||
<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)"
|
||||
>
|
||||
<Search aria-hidden="true" size={14} />
|
||||
</IconButton>
|
||||
<ExternalLink aria-hidden="true" size={14} />
|
||||
</a>
|
||||
<IconButton
|
||||
disabled={busy}
|
||||
onClick={() => setDeleteTarget(list)}
|
||||
|
||||
Reference in New Issue
Block a user