TwitchDownloader/UpgradePlan.md

214 lines
12 KiB
Markdown
Raw Normal View History

Plan: C# .NET 10 Twitch Archive Rewrite
A complete port of the Python archiver to C# .NET 10 with Blazor Server UI, real-time process output, SQLite state tracking, full DI/service pattern, NLog logging, and a resilient recording engine. Placed under dotnet/ in the existing repo.
Project Layout
Step 1 — Solution & Project scaffolding
Create the SLN and three projects:
TwitchArchive.Core — classlib, targets net10.0
TwitchArchive.Web — Blazor Server (blazorserver), targets net10.0
TwitchArchive.Tests — xUnit, targets net10.0
NuGet packages:
Core: Microsoft.EntityFrameworkCore.Sqlite, Polly, NLog, NLog.Extensions.Logging
Web: all Core packages + Microsoft.AspNetCore.SignalR, NLog.Web.AspNetCore
Tests: xunit, Moq, coverlet.collector, Microsoft.EntityFrameworkCore.InMemory
Step 2 — Configuration models
Mirror the existing JSON schemas as C# POCOs with System.Text.Json attributes:
GlobalConfig.cs — one property per key in config/global.json.example
StreamerConfig.cs — all fields nullable (override semantics), only Username and Enabled required
EffectiveConfig.cs — computed merge of global + per-streamer; exposes resolved values
AppSettings.cs — app-level settings (password hash, tool paths, .env secrets)
IConfigurationService / ConfigurationService:
LoadGlobal() / SaveGlobal(GlobalConfig)
LoadStreamer(string username) / SaveStreamer(StreamerConfig)
GetAllStreamers(), GetEffectiveConfig(string username) (merge logic)
Reads/writes global.json and config/streamers/*.json — same files as Python
Step 3 — Infrastructure layer
TwitchApiClient (injectable, mockable):
GetOAuthTokenAsync() — POST to https://id.twitch.tv/oauth2/token, caches token, refreshes on 401
CheckStreamStatusAsync(string username) — GQL query for live stream + archiveVideo.id
GetLatestVodAsync(string username) — GQL query for most recent VOD
ValidateUsernameAsync(string username) — Helix /users endpoint
Credentials read from environment (CLIENT-ID, CLIENT-SECRET, OAUTH-PRIVATE-TOKEN)
All methods return typed result objects, never throw on network errors — return Result<T> (or OneOf)
HttpResiliencePolicy (Polly):
Wraps HttpClient for TwitchApiClient
WaitAndRetryForever with exponential backoff starting at 15 s, doubling, capped at 10 minutes
Only applies to transient errors (5xx, timeout, HttpRequestException) — not 401/404
Logged via NLog on each retry attempt
ProcessRunner (injectable + mockable for tests):
RunAsync(ProcessRunOptions options, CancellationToken ct) → int exitCode
StartAsync(ProcessRunOptions options, CancellationToken ct) → IRunningProcess handle (for long-lived processes like streamlink)
Reads stdout and stderr line by line asynchronously
Reports each line to IProcessOutputStore (streamer + job context)
Forwards to NLog
ProcessRunOptions: FileName, Arguments, WorkingDirectory, RedirectOutput
Step 4 — Core services (all behind interfaces)
IStreamMonitorService / StreamMonitorService
Wraps TwitchApiClient
CheckIsLiveAsync(string username) → LiveStreamInfo?
GetLatestVodAsync(string username) → VodInfo?
IRecorderService / RecorderService
StartRecordingAsync(string username, string quality, string outputPath, CancellationToken ct) → Task<RecordingResult>
Invokes streamlink via ProcessRunner
Passes --hls-live-restart, --stream-segment-threads, optional OAuth header
Returns when streamlink exits (either stream ended or ct cancelled)
IProcessorService / ProcessorService
ProcessRawStreamAsync(string rawPath, string outputPath, EffectiveConfig cfg, CancellationToken ct)
Builds ffmpeg args: hwaccel, thread count, error recovery flags, faststart, copy codecs
MergeVideoChatAsync(string videoPath, string chatVideoPath, string outputPath, string layout, CancellationToken ct)
IDownloaderService / DownloaderService
DownloadVodAsync(VodInfo vod, string outputPath, EffectiveConfig cfg, CancellationToken ct) → bool
Invokes TwitchDownloaderCLI videodownload
Chat download methods stubbed with NotImplementedException / commented structure; interface is defined now to keep the architecture clean
IUploadService / UploadService
UploadAsync(string localRoot, IEnumerable<string> relativeFilePaths, string rcloneDest, CancellationToken ct) → bool
Writes a temp files-from list, invokes rclone copy --files-from
Returns success/failure; preserves local files on failure
IFileManagerService / FileManagerService
InitializeDirectories(string rootPath, string username)
GetPaths(string rootPath, string username, string filenameBase) → ArchivePaths record (all expected paths)
CleanRawFile(string path, bool cleanRaw)
DeleteLocalFiles(ArchivePaths paths, EffectiveConfig cfg)
GetUniquePath(string path) → adds numeric suffix if file exists
Step 5 — Recording resilience engine
RecoveryPolicy (POCO, unit-testable, no DI deps):
Encodes a state machine with these states:
State Meaning
Monitoring Normal polling at refresh interval
Recording streamlink subprocess active
FastReconnect Stream ended; checking every 10 s for up to 2 minutes
SlowReconnect Still not live after 2 min; checking every 60 s concurrently with post-processing
PostProcessing Confirmed ended; ffmpeg / VOD download / upload running
NetworkFault Twitch API unreachable; exponential back-off (30 s → capped at 10 min)
Transitions:
Recording → streamlink exits → enter FastReconnect, record phase start time
FastReconnect → live confirmed → start new Recording (new filename/segment)
FastReconnect (2 min elapsed) → enter SlowReconnect + kick off PostProcessing concurrently
SlowReconnect → live confirmed → start new Recording
SlowReconnect / Monitoring → API call throws network error → enter NetworkFault
NetworkFault → successful API response → return to previous state (Monitoring or re-enter FastReconnect if we were mid-reconnect)
NetworkFault backoff: 30s * 2^attempt, capped at 600s
RecoveryPolicy is a pure class with a Tick(DateTime now, bool? isLive, bool networkError) method → returns RecoveryDecision (what to do next + sleep duration). Fully unit-testable with no async or DI.
StreamWorker : BackgroundService
One instance per enabled streamer
Holds RecoveryPolicy instance
Main loop: evaluate policy decision → execute the corresponding service call → loop
Started/stopped by StreamWorkerManager
Writes job records to SQLite on start/complete/fail
StreamWorkerManager
StartWorker(string username), StopWorker(string username), RestartWorker(string username)
Called at app startup for all enabled streamers
Called from Web UI on enable/disable/config change
Workers stored in ConcurrentDictionary<string, (StreamWorker, CancellationTokenSource)>
Step 6 — Persistence (SQLite + EF Core)
ArchiveDbContext with three tables:
StreamSessions: Id, StreamerUsername, TwitchStreamId, Title, StartedAt, EndedAt, Status (Recording/Processing/Uploading/Complete/Failed)
ArchiveJobs: Id, SessionId, JobType (enum: RecordLive, ProcessLive, DownloadVod, ProcessVod, UploadCloud, DeleteLocal), Status, StartedAt, CompletedAt, FilePath, ErrorMessage
StreamerStates: Username, IsMonitoring, LastCheckedAt, CurrentRecoveryState
Migrations via EF Core CLI. ISessionRepository / IJobRepository interfaces for testability with in-memory EF provider in tests.
Step 7 — Process output streaming
IProcessOutputStore:
AppendLine(string streamerId, Guid jobId, string line, bool isError)
GetRecentLines(string streamerId, Guid jobId, int count = 500) → IReadOnlyList<OutputLine>
In-memory circular buffer (1000 lines per job, last 20 jobs per streamer)
ProcessOutputHub : Hub (SignalR):
Clients call SubscribeToStreamer(string username) → join group streamer:{username}
Clients call SubscribeToJob(Guid jobId) → join group job:{jobId}
Server pushes ReceiveLine(OutputLine line) from ProcessRunner via IHubContext<ProcessOutputHub>
On subscribe: server immediately sends buffered lines from IProcessOutputStore
Step 8 — Blazor Server Web UI
Authentication: Cookie-based single-password auth via ASP.NET Core minimal auth middleware. Password stored as BCrypt hash in AppSettings. Login.razor page at /login. Protected routes with [Authorize].
Pages & Components:
Dashboard.razor (/) — grid of all configured streamers showing: username, live/offline badge, current recovery state, last recorded session, quick Start/Stop monitoring toggle
StreamerDetail.razor (/streamer/{username}) — live status, current job pipeline steps (record → process → upload with progress), ProcessOutputConsole.razor showing real-time terminal output via SignalR
ProcessOutputConsole.razor — reusable Blazor component; subscribes to SignalR on mount, renders an auto-scrolling <pre> with colored output (stdout = white, stderr = orange/red), handles reconnect
Sessions.razor (/sessions) — paginated list of past archive sessions with job statuses and expandable per-job output
GlobalConfig.razor (/config/global) — EditForm bound to GlobalConfig model with data annotations validation, Save button calls IConfigurationService.SaveGlobal()
StreamerConfig.razor (/config/{username}) — similar form for per-streamer overrides; each field has a nullable toggle (inherit from global vs override)
AddStreamer.razor (/config/new) — minimal form: username + enabled; creates new config/streamers/{username}.json
AppSettings.razor (/settings) — tool paths (streamlink, ffmpeg, TwitchDownloaderCLI, rclone), change password
Step 9 — NLog configuration
nlog.config (XML): two targets:
Console (colored, with level formatting)
File rolling (logs/archive-${shortdate}.log, keep 30 days)
Log structured context: StreamerUsername, JobId, JobType as NLog ScopeContext properties. Service methods open a scope via ILogger.BeginScope(...).
Step 10 — Docker
Dockerfile (multi-stage):
Build stage: mcr.microsoft.com/dotnet/sdk:10.0
Runtime stage: mcr.microsoft.com/dotnet/aspnet:10.0 (Linux)
Install ffmpeg, streamlink (via pip), download TwitchDownloaderCLI binary for linux-x64
rclone installed via shell script or apt
Expose port 8080
ENTRYPOINT ["dotnet", "TwitchArchive.Web.dll"]
docker-compose.yml:
Volume mounts: ./config:/app/config, ./archive:/app/archive, ./logs:/app/logs
Environment variables: CLIENT-ID, CLIENT-SECRET, OAUTH-PRIVATE-TOKEN
Windows dev: run directly with dotnet run; tool paths auto-detected (Windows vs Linux) via RuntimeInformation.IsOSPlatform(OSPlatform.Windows) in ToolPathResolver
Step 11 — Unit tests
TwitchArchive.Tests covers:
RecoveryPolicyTests — state machine transitions, timing phases, network fault backoff; pure synchronous tests
ConfigurationServiceTests — JSON load/save/merge with temp files
TwitchApiClientTests — mocked HttpMessageHandler; OAuth, GQL queries, 401 refresh, network errors
FileManagerServiceTests — path generation, directory creation with temp directories
DownloaderServiceTests / RecorderServiceTests — mocked ProcessRunner; verify correct CLI arguments
UploadServiceTests — mocked ProcessRunner; verify rclone argument construction
SessionRepositoryTests — EF Core in-memory provider
EffectiveConfigTests — global + streamer override merge logic
Verification
dotnet build dotnet/TwitchArchive.sln — zero warnings, zero errors
dotnet test dotnet/TwitchArchive.Tests/ — all tests green
docker compose up --build in the dotnet/ folder → app reachable at http://localhost:8080
Manual: add a test streamer config, enable monitoring, confirm it polls and records a live stream, runs ffmpeg, uploads via rclone
Resilience manual test: kill network during recording → verify FastReconnect phase kicks in, resumes after connectivity restored
Decisions
Blazor Server chosen for real-time terminal output without a separate API; no WASM needed
streamlink for live + TwitchDownloaderCLI for VOD (same split as Python; streamlink gives better live resilience)
Simple BCrypt password auth (not full Identity — this is a single-user tool)
RecoveryPolicy as a pure POCO state machine keeps the resilience logic fully unit-testable without async/mocking
Polly WaitAndRetryForever on HttpClient handles persistent network failure independently of the application-level state machine; they are complementary — Polly handles individual HTTP call retries, RecoveryPolicy handles the overall workflow state
Chat download service interface is defined in Step 4 but methods are stubbed — adding implementation later requires only filling in DownloaderService without touching any other layer
Config files remain in the same format/location so the Python and C# versions can share the same config directory