Document the on-disk media + direct-SQLite LibraryPath + scan recipe for E2E, since a local library is not API-seedable. Capture two gotchas hit while verifying the episode-nav PR: deleting search-index/ leaves search permanently empty (migration doesn't reindex from DB; rescan skips unchanged files), and /api/search needs field/wildcard queries (title:Alpha), not bare title words. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
9.5 KiB
Local live-E2E recipe
Purpose: how to stand up a real, running instance of this fork locally (dotnet host + built SPA)
for manual or Playwright-MCP-driven end-to-end verification — no live Docker/prod dependency.
Update this doc (and scripts/e2e-local.sh) in the same PR that changes any convention below.
This is for interactive/agent-driven verification, not CI (docs/ci-cd.md covers the CI pipeline,
which never runs the app itself).
Why the steps are in this order
ErsatzTV/Startup.cs's/appSPA middleware resolves its static-file root once, at startup (SpaStaticFileRoot(), checked withDirectory.Existsand swapped to aNullFileProviderif missing — seeStartup.csaround theapp.MapWhen(... "/app" ...)block). Ifwwwroot/appdoesn't exist yet when the process starts, the SPA will 404 forever until you restart the process — copying the built files in after the fact does nothing for an already-running instance.- The dotnet host and the SPA share one port (default 8409,
ETV_UI_PORT/SystemEnvironment. UiPortinErsatzTV.Core/SystemEnvironment.cs) — there's no separate dev server/proxy in this workflow; you're testing the actual production static-hosting path. - A fresh config folder per run avoids state bleed (leftover channels/schedules/DB) between test sessions corrupting your assertions.
Steps
-
Build the prerequisites (once, or after any source change):
dotnet build ErsatzTV.sln cd web && npm run build && cd .. # → ErsatzTV/wwwroot/app (vite.config.ts outDir) -
Copy
wwwrootinto the build output (thedotnet buildoutput directory does not automatically pick upweb/'s build artifacts placed directly into the sourcewwwroot):rm -rf ErsatzTV/bin/Debug/net10.0/wwwroot cp -R ErsatzTV/wwwroot ErsatzTV/bin/Debug/net10.0/wwwrootThe
rmmatters: if the destination dir already exists (any prior run),cp -R src dstcopies into it (dst/wwwroot/...) and the server silently keeps serving the previous run's stale assets.scripts/e2e-local.shdoes the rm+copy for you. If you rebuild the SPA (npm run build) while the dotnet process from step 4 is already running, re-copy and then restart the process — see the "why" note above; it will not pick up new files live. -
Use a fresh scratch config folder — never reuse one across test runs:
CONFIG_DIR=$(mktemp -d) -
Run the app, pointed at the scratch folder:
cd ErsatzTV/bin/Debug/net10.0 ETV_CONFIG_FOLDER="$CONFIG_DIR" dotnet ErsatzTV.dllWait for the log line
Done migrating search index(emitted byRebuildSearchIndexHandlerinErsatzTV.Application/Search/Commands/, with a... in {Duration}suffix) — that's the last long-running startup step; before that, requests may 404/error. The UI and the entire/api/*surface are served on the same port, 8409 by default (override withETV_UI_PORT). -
Seed data as needed via the API — no API key is required for local mutating requests by default (
ApiKeyAuthorizationFilteronly enforces theX-Api-Keyheader whenApi:WriteKeyis configured; it's empty/unset in a fresh local config, so writes are open). Examples:- Create a channel:
POST /api/channels— check the currentErsatzTV/wwwroot/openapi/v1.json(orCreateChannelRequest.cs) for the exact required field list before assuming these are complete, but as of this writing it requires (among plain fields) these enums:PlayoutSource: "Generated",PlayoutMode: "Continuous",SongVideoMode: "Default",TranscodeMode: "OnDemand",IdleBehavior: "StopOnDisconnect". - Create a playout for an existing channel:
POST /api/playoutswith{"channelId": <id>, "scheduleKind": "Block"}(or"Classic"/"Scripted"/"Sequential"perChannelPlayoutSource/schedule-kind enums — checkv1.jsonfor the current set).
- Create a channel:
-
Tear down: kill the
dotnet ErsatzTV.dllprocess and confirm the port is freed (lsof -i :8409should return nothing) before starting another run — a stray process holding the port will make the next run's health check hang or fail confusingly.
Playwright MCP screenshots
If you're driving the browser via the Playwright MCP server for visual verification, screenshots
land in the MCP server process's own cwd (this repo's root, not wherever you ran the dotnet
process from) — expect stray *.png files at the repo root after a session; this is tolerated,
not a bug to fix, but don't check them in.
Script: scripts/e2e-local.sh
A copy of this script is included in this doc's directory; it is intended to land at
scripts/e2e-local.sh in the repo. It automates steps 2–4 above (build is assumed already done —
run dotnet build / npm run build yourself first, since rebuilding on every invocation is slow
and this script is meant to be re-run often during a debugging session).
Usage:
scripts/e2e-local.sh [CONFIG_DIR]
CONFIG_DIRdefaults to a freshmktemp -dif omitted.- Copies
ErsatzTV/wwwroot→ErsatzTV/bin/Debug/net10.0/wwwroot. - Launches
dotnet ErsatzTV.dllin the background withETV_CONFIG_FOLDERset. - Waits (up to 120s) for the
Done migrating search indexlog line. - Prints the PID and port, then exits leaving the server running — the caller is responsible
for killing the PID when done (
kill <PID>).
Seeding a local TV library for E2E
Channels/playouts are API-seedable (step 5 above), but a local media library is not — there
is no /api/* endpoint to add a local library folder. To exercise media-browse / search / detail
screens you need real scanned items. Recipe (used to verify the #220 episode-nav PR):
-
Generate tiny media files on disk — one show (one season, ~3 episodes), plus a second show whose title contains the first as a substring (good substring-search sanity data), plus a movie if you need a non-episode kind. Keep TV and movies under separate roots so each library scans cleanly (a Shows library pointed at a folder that also contains movies will try to parse the movies as shows). Each file is a 2-second
testsrcclip:MEDIA=/tmp/etv-media # any scratch path mkdir -p "$MEDIA/tv/Show Alpha/Season 01" \ "$MEDIA/tv/Show Alpha Returns/Season 01" \ "$MEDIA/movies/Test Movie (2020)" for n in 01 02 03; do ffmpeg -y -f lavfi -i testsrc=duration=2:size=320x240:rate=10 -c:v libx264 -pix_fmt yuv420p \ "$MEDIA/tv/Show Alpha/Season 01/Show Alpha - s01e$n.mkv" done ffmpeg -y -f lavfi -i testsrc=duration=2:size=320x240:rate=10 -c:v libx264 -pix_fmt yuv420p \ "$MEDIA/tv/Show Alpha Returns/Season 01/Show Alpha Returns - s01e01.mkv" ffmpeg -y -f lavfi -i testsrc=duration=2:size=320x240:rate=10 -c:v libx264 -pix_fmt yuv420p \ "$MEDIA/movies/Test Movie (2020)/Test Movie (2020).mkv" -
Attach the folders to the built-in local libraries via SQLite. A fresh config DB already has the seven default local libraries (
Libraryrows for a singleLocalMediaSource):MoviesisId=1,ShowsisId=2.LibraryPathis just(Path TEXT, LibraryId INT)— insert one row per root, pointing each at the matching library:DB="$CONFIG_DIR/ersatztv.sqlite3" # CONFIG_DIR from the run above; server may be running sqlite3 "$DB" "INSERT INTO LibraryPath (Path, LibraryId) VALUES ('$MEDIA/tv', 2);" # Shows sqlite3 "$DB" "INSERT INTO LibraryPath (Path, LibraryId) VALUES ('$MEDIA/movies', 1);" # Movies -
Trigger a scan and wait for items to appear. The scan endpoint takes an empty body:
curl -s -X POST http://localhost:8409/api/libraries/2/scan -H 'Content-Type: application/json' -d '{}' curl -s -X POST http://localhost:8409/api/libraries/1/scan -H 'Content-Type: application/json' -d '{}' # poll until episodes show up (scanner runs as a background subprocess): curl -s "http://localhost:8409/api/library/browse?mediaType=Episode&pageSize=50"The scan runs even though
LibraryPathwas inserted after startup — the scan handler re-reads the library from the DB. Browse (/api/library/browse) reads straight from the DB, so items appear there within a few seconds.
Gotchas
- Do NOT delete the
search-index/folder to "reset" search. On startup the app recreates the index empty (Search index failed to initialize; will delete and recreate→Migrating search index to version N) and that migration does not re-index from the DB — only a scan writes documents into the Lucene index. The scanner subprocess writes the index while running; a restart never rebuilds it from existing DB rows. If you wipesearch-index/, a rescan of unchanged files won't repopulate it (the scanner skips unchanged items), so search stays empty. The clean recovery is a freshCONFIG_DIR: launch → insertLibraryPath→ scan once → leave the index alone. - Search query relevance is field-scoped, not free-text. The
/api/searchdefault field does not match bare title words:AlphaandShowreturn nothing for a "Show Alpha" title, whiletitle:Alpha,Show*, or*Alpha*all match. The SPA search box forwards the query verbatim, so when driving search-result screens in E2E use a field/wildcard query (e.g.title:Alpha) to get deterministic hits. (This is pre-existing ErsatzTV search behavior, independent of any SPA change.)