Create (or extend) dotnet/src/TwitchArchive.Web/wwwroot/css/app.css: .page: display:flex; height:100vh .sidebar: width:220px; flex-shrink:0; background:#1e1e2e; color:#cdd6f4; overflow-y:auto .main: flex:1; overflow-y:auto; padding:1.5rem .nav-link: display:block; padding:0.6rem 1rem; color:#cdd6f4; text-decoration:none .nav-link.active: background:#313244; border-left:3px solid #89b4fa Media query @media(max-width:768px): .sidebar.collapsed { display:none }, .topbar { display:flex }, else .topbar { display:none } Step G — Full Blazor UI pages (Plan Step 8) Goal: implement the missing pages referenced by the NavMenu.
Steps
Dashboard.razor (/) — replace dotnet/src/TwitchArchive.Web/Pages/Index.razor. Display a CSS grid of streamer cards, each showing: username (link to /streamer/{username}), live/offline badge, current RecoveryState text from WorkerManager.GetState(username), last session start from ISessionRepository, Start/Stop buttons. Poll every 10 s via PeriodicTimer in OnInitializedAsync, disposed in IAsyncDisposable.DisposeAsync.
StreamerDetail.razor (/streamer/{username}) — new file. Live status badge, pipeline step bar (Record → Process → Upload using CSS flex row), . Route parameter [Parameter] public string Username.
GlobalConfig.razor (/config/global) — new file. bound to a GlobalConfig loaded via IConfigurationService.LoadGlobal(). On valid submit: await ConfigService.SaveGlobal(model), show a dismissible success alert.
StreamerConfig.razor (/config/{username}) — new file. Per-field nullable override: each field has an "Override" toggle; when unchecked the is disabled and shows the global default as placeholder. Save calls SaveStreamer. Delete button removes the config file and navigates to /.
AddStreamer.razor (/config/new) — new file. Two fields: Username (required, lowercase) and Enabled checkbox. On submit: await ConfigService.SaveStreamer(new StreamerConfig { Username, Enabled }), then Nav.NavigateTo($"/config/{model.Username}").
AppSettings.razor (/settings) — new file. Tool-path fields bound to AppSettings. Change-password section: current password (validated against BCrypt hash) + new + confirm fields. On save: update appsettings.json and call IAuthService to refresh the cached hash.
Step H — Authentication (Plan Step 8) Goal: single-password BCrypt cookie auth protecting all pages except /login.
Add to TwitchArchive.Web.csproj. Create dotnet/src/TwitchArchive.Web/Services/IAuthService.cs + AuthService.cs — ValidatePassword(string plain) → bool using BCrypt.Net.BCrypt.Verify against AppSettings.PasswordHash. If hash is empty (first-run), any password is accepted. Create dotnet/src/TwitchArchive.Web/Pages/Login.razor (/login) — password in an . Posts to /auth/login minimal-API endpoint via form navigation. Add minimal API endpoint POST /auth/login in Program.cs — reads password from form body, calls IAuthService.ValidatePassword, calls HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, ...), redirects to /. On failure, redirects to /login?error=1. In Program.cs add builder.Services.AddAuthentication(...).AddCookie(opt => opt.LoginPath = "/login"), app.UseAuthentication(), app.UseAuthorization(). Update App.razor — replace with , wrap in . Add @attribute [Authorize] to all pages except Login.razor. Step I — Docker (Plan Step 10) Steps
Create dotnet/Dockerfile:
Build stage (sdk:10.0): dotnet publish src/TwitchArchive.Web -c Release -o /app/publish Runtime stage (aspnet:10.0): apt-get install -y ffmpeg python3-pip rclone, pip3 install streamlink, download TwitchDownloaderCLI linux-x64 binary to /app/bin/ and chmod +x. EXPOSE 8080, ENV ASPNETCORE_URLS=http://+:8080, ENTRYPOINT ["dotnet","TwitchArchive.Web.dll"] Create dotnet/docker-compose.yml:
Create dotnet/src/TwitchArchive.Core/Config/ToolPathResolver.cs — static helper using RuntimeInformation.IsOSPlatform(OSPlatform.Windows) to resolve default binary paths; used by AppSettings property defaults and RecorderService/ProcessorService.
Step J — Extended unit tests (Plan Step 11) Steps
ConfigurationServiceTests.cs — load/save/merge in Path.GetTempPath() temp dir; assert roundtrip and merge precedence. TwitchApiClientTests.cs — mock HttpMessageHandler; token caching, GQL stream-status (live + offline), GetLatestVodAsync, network error → null. FileManagerServiceTests.cs — InitializeDirectories, GetPaths, GetUniquePath collision suffix on temp dirs. RecorderServiceTests.cs — mock IProcessRunner capturing ProcessRunOptions.Arguments; assert --hls-live-restart and resolved quality. UploadServiceTests.cs — mock IProcessRunner; assert rclone arguments contain copy --files-from and correct dest; assert temp list file is cleaned up. EffectiveConfigTests.cs — all merge-precedence cases: null streamer field → global default; non-null streamer field → override wins. SessionRepositoryTests.cs — EF in-memory; AddSessionAsync, GetRecentSessionsAsync, UpdateSessionAsync status change. Verification Manual checks:
All NavMenu links resolve without 404; active link is highlighted Login page blocks unauthenticated access; correct password grants access; wrong password shows error Dashboard renders streamer cards; Start/Stop toggles update Recovery State badge Global Config saves to global.json; reload confirms persistence Add Streamer creates config/streamers/{name}.json and redirects to per-streamer config page Sessions page shows rows after a recording completes with expandable job list Live Monitor shows real-time ProcessConsole output via SignalR Decisions NavMenu uses Blazor's built-in NavLink with pure CSS sidebar — no Bootstrap dependency to keep the bundle small Config service reads/writes the same config directory as the Python side — both runtimes can share config files without conversion EnsureCreated → Migrate() — migrations support future schema changes without data loss BCrypt single-password auth over full Identity — this is a single-user self-hosted tool; Identity adds unnecessary overhead IRecorderService extracted from StreamWorker — the recording path becomes independently testable without running the full state machine Or simply paste the content above into a new file UpgradePlan-Part2.md at the workspace root. Once terminal/file-edit tools are re-enabled, I can write it directly.