Refactor code structure for improved readability and maintainability

This commit is contained in:
MaddoScientisto 2026-02-21 10:40:12 +01:00
commit 4f488bae45
78 changed files with 3309 additions and 1570 deletions

View file

@ -0,0 +1,77 @@
---
description: 'Blazor component and application patterns'
applyTo: '**/*.razor, **/*.razor.cs, **/*.razor.css'
---
## Blazor Code Style and Structure
- Write idiomatic and efficient Blazor and C# code.
- Follow .NET and Blazor conventions.
- Use Razor Components appropriately for component-based UI development.
- Prefer inline functions for smaller components but separate complex logic into code-behind or service classes.
- Async/await should be used where applicable to ensure non-blocking UI operations.
## Naming Conventions
- Follow PascalCase for component names, method names, and public members.
- Use camelCase for private fields and local variables.
- Prefix interface names with "I" (e.g., IUserService).
## Blazor and .NET Specific Guidelines
- Utilize Blazor's built-in features for component lifecycle (e.g., OnInitializedAsync, OnParametersSetAsync).
- Use data binding effectively with @bind.
- Leverage Dependency Injection for services in Blazor.
- Structure Blazor components and services following Separation of Concerns.
- Always use the latest version C#, currently C# 13 features like record types, pattern matching, and global usings.
## Error Handling and Validation
- Implement proper error handling for Blazor pages and API calls.
- Use logging for error tracking in the backend and consider capturing UI-level errors in Blazor with tools like ErrorBoundary.
- Implement validation using FluentValidation or DataAnnotations in forms.
## Blazor API and Performance Optimization
- Utilize Blazor server-side or WebAssembly optimally based on the project requirements.
- Use asynchronous methods (async/await) for API calls or UI actions that could block the main thread.
- Optimize Razor components by reducing unnecessary renders and using StateHasChanged() efficiently.
- Minimize the component render tree by avoiding re-renders unless necessary, using ShouldRender() where appropriate.
- Use EventCallbacks for handling user interactions efficiently, passing only minimal data when triggering events.
## Caching Strategies
- Implement in-memory caching for frequently used data, especially for Blazor Server apps. Use IMemoryCache for lightweight caching solutions.
- For Blazor WebAssembly, utilize localStorage or sessionStorage to cache application state between user sessions.
- Consider Distributed Cache strategies (like Redis or SQL Server Cache) for larger applications that need shared state across multiple users or clients.
- Cache API calls by storing responses to avoid redundant calls when data is unlikely to change, thus improving the user experience.
## State Management Libraries
- Use Blazor's built-in Cascading Parameters and EventCallbacks for basic state sharing across components.
- Implement advanced state management solutions using libraries like Fluxor or BlazorState when the application grows in complexity.
- For client-side state persistence in Blazor WebAssembly, consider using Blazored.LocalStorage or Blazored.SessionStorage to maintain state between page reloads.
- For server-side Blazor, use Scoped Services and the StateContainer pattern to manage state within user sessions while minimizing re-renders.
## API Design and Integration
- Use HttpClient or other appropriate services to communicate with external APIs or your own backend.
- Implement error handling for API calls using try-catch and provide proper user feedback in the UI.
## Testing and Debugging in Visual Studio
- All unit testing and integration testing should be done in Visual Studio.
- Test Blazor components and services using xUnit, NUnit, or MSTest.
- Use Moq or NSubstitute for mocking dependencies during tests.
- Debug Blazor UI issues using browser developer tools and Visual Studio's debugging tools for backend and server-side issues.
- For performance profiling and optimization, rely on Visual Studio's diagnostics tools.
## Security and Authentication
- Implement Authentication and Authorization in the Blazor app where necessary using ASP.NET Identity or JWT tokens for API authentication.
- Use HTTPS for all web communication and ensure proper CORS policies are implemented.
## API Documentation and Swagger
- Use Swagger/OpenAPI for API documentation for your backend API services.
- Ensure XML documentation for models and API methods for enhancing Swagger documentation.

44
.gitignore vendored
View file

@ -22,3 +22,47 @@ venv3/**
bin/** bin/**
\n+# Ignore any virtual environment directories starting with 'venv' (venv, venv3, venv314, etc.) \n+# Ignore any virtual environment directories starting with 'venv' (venv, venv3, venv314, etc.)
venv*/ venv*/
.vs/ProjectEvaluation/twitch-archive-2.metadata.v10.bin
.vs/ProjectEvaluation/twitch-archive-2.projects.v10.bin
.vs/ProjectEvaluation/twitch-archive-2.strings.v10.bin
.vs/Twitch-Archive-2/CopilotIndices/18.3.508.13148/CodeChunks.db
.vs/Twitch-Archive-2/CopilotIndices/18.3.508.13148/SemanticSymbols.db
.vs/Twitch-Archive-2/DesignTimeBuild/.dtbcache.v2
.vs/Twitch-Archive-2/FileContentIndex/843065c8-d80f-4907-b0ae-6d010b3a5699.vsidx
.vs/Twitch-Archive-2/FileContentIndex/ef7e1a3c-80cd-4867-a9a8-2e5099471227.vsidx
.vs/Twitch-Archive-2/v18/.futdcache.v2
.vs/Twitch-Archive-2/v18/.suo
.vs/Twitch-Archive-2/v18/DocumentLayout.backup.json
.vs/Twitch-Archive-2/v18/DocumentLayout.json
.vscode/settings.json
# C# / Visual Studio
# Build Folders
bin/
obj/
# Visual Studio files
*.user
*.suo
*.userprefs
*.csproj.user
*.pidb
*.pdb
*.cache
*.ilk
*.log
*.vspscc
*.vssscc
# Test results and packages
TestResults/
packages/
*.nupkg
# Database and backup
*.dbmdl
*.bak
*.backup
*.orig
dotnet/.vs/**

32
Twitch-Archive-2.sln Normal file
View file

@ -0,0 +1,32 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.5.2.0
MinimumVisualStudioVersion = 10.0.40219.1
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "dotnet", "dotnet", "{71E6E750-85FD-B5BC-4321-E01377EC6231}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{D90AB541-7400-80B1-A0B4-F58D0D439F55}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TwitchArchive.Core", "dotnet\src\TwitchArchive.Core\TwitchArchive.Core.csproj", "{1D11D744-6D0D-BB4D-8B77-30B5CE764821}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{1D11D744-6D0D-BB4D-8B77-30B5CE764821}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{1D11D744-6D0D-BB4D-8B77-30B5CE764821}.Debug|Any CPU.Build.0 = Debug|Any CPU
{1D11D744-6D0D-BB4D-8B77-30B5CE764821}.Release|Any CPU.ActiveCfg = Release|Any CPU
{1D11D744-6D0D-BB4D-8B77-30B5CE764821}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{D90AB541-7400-80B1-A0B4-F58D0D439F55} = {71E6E750-85FD-B5BC-4321-E01377EC6231}
{1D11D744-6D0D-BB4D-8B77-30B5CE764821} = {D90AB541-7400-80B1-A0B4-F58D0D439F55}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {D101688C-0CA3-4CFB-96D4-E1AB9A62EC51}
EndGlobalSection
EndGlobal

214
UpgradePlan.md Normal file
View file

@ -0,0 +1,214 @@
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

78
UpgradePlan2.md Normal file
View file

@ -0,0 +1,78 @@
<div class="page"> <nav class="sidebar"><NavMenu /></nav> <main class="main">@Body</main> </div> ``` Add a top bar (`<header class="topbar">`) with the app title "Twitch Archive" and a hamburger toggle `<button>` that toggles a `sidebarCollapsed` bool field to add/remove a CSS class.
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 <span> 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), <ProcessConsole Streamer="@Username" />. Route parameter [Parameter] public string Username.
GlobalConfig.razor (/config/global) — new file. <EditForm> 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 <InputCheckbox> "Override" toggle; when unchecked the <InputText> 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.
Steps
Add <PackageReference Include="BCrypt.Net-Next" Version="4.*" /> 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 <InputText type="password"> in an <EditForm>. 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 <RouteView> with <AuthorizeRouteView>, wrap in <CascadingAuthenticationState>. 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.

18
dotnet/Dockerfile Normal file
View file

@ -0,0 +1,18 @@
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY . .
RUN dotnet publish src/TwitchArchive.Web -c Release -o /app/publish
FROM mcr.microsoft.com/dotnet/aspnet:10.0
RUN apt-get update && apt-get install -y --no-install-recommends \
ffmpeg python3-pip rclone curl ca-certificates && rm -rf /var/lib/apt/lists/*
RUN pip3 install --no-cache-dir streamlink
WORKDIR /app
COPY --from=build /app/publish ./
# Download TwitchDownloaderCLI linux-x64 binary (if available)
RUN mkdir -p /app/bin && \
curl -fsSL -o /app/bin/TwitchDownloaderCLI https://github.com/Franiac/TwitchDownloader/releases/latest/download/TwitchDownloaderCLI-linux-x64 && \
chmod +x /app/bin/TwitchDownloaderCLI || true
EXPOSE 8080
ENV ASPNETCORE_URLS=http://+:8080
ENTRYPOINT ["dotnet", "TwitchArchive.Web.dll"]

14
dotnet/README.md Normal file
View file

@ -0,0 +1,14 @@
Twitch Archive (.NET) - Initial Scaffold
This folder contains the initial .NET 10 scaffold for the Twitch Archive rewrite.
Quick commands (from `dotnet/`):
```bash
dotnet build ./src/TwitchArchive.Core
dotnet test ./src/TwitchArchive.Tests
```
This initial commit includes a pure `RecoveryPolicy` implementation and unit tests.
Next steps: add services, process runner, Blazor Server app.

33
dotnet/TwitchArchive.sln Normal file
View file

@ -0,0 +1,33 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 18
VisualStudioVersion = 18.3.11512.155 d18.3
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TwitchArchive.Core", "src\TwitchArchive.Core\TwitchArchive.Core.csproj", "{9E6F1C3D-8B2A-4F21-9E9E-4A1B2C3D4E5F}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TwitchArchive.Web", "src\TwitchArchive.Web\TwitchArchive.Web.csproj", "{C2B1A3D4-5E6F-4721-9ABC-DEF012345679}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TwitchArchive.Tests", "src\TwitchArchive.Tests\TwitchArchive.Tests.csproj", "{D3E4F5A6-B7C8-49D0-9ABC-DEF012345670}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{9E6F1C3D-8B2A-4F21-9E9E-4A1B2C3D4E5F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{9E6F1C3D-8B2A-4F21-9E9E-4A1B2C3D4E5F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{9E6F1C3D-8B2A-4F21-9E9E-4A1B2C3D4E5F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{9E6F1C3D-8B2A-4F21-9E9E-4A1B2C3D4E5F}.Release|Any CPU.Build.0 = Release|Any CPU
{C2B1A3D4-5E6F-4721-9ABC-DEF012345679}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C2B1A3D4-5E6F-4721-9ABC-DEF012345679}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C2B1A3D4-5E6F-4721-9ABC-DEF012345679}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C2B1A3D4-5E6F-4721-9ABC-DEF012345679}.Release|Any CPU.Build.0 = Release|Any CPU
{D3E4F5A6-B7C8-49D0-9ABC-DEF012345670}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{D3E4F5A6-B7C8-49D0-9ABC-DEF012345670}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D3E4F5A6-B7C8-49D0-9ABC-DEF012345670}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D3E4F5A6-B7C8-49D0-9ABC-DEF012345670}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
EndGlobal

11
dotnet/docker-compose.yml Normal file
View file

@ -0,0 +1,11 @@
version: '3.8'
services:
twitcharchive:
build: .
ports:
- "8080:8080"
volumes:
- ./config:/app/config
- ./archive:/app/archive
environment:
- ASPNETCORE_ENVIRONMENT=Production

View file

@ -0,0 +1,17 @@
using System.Threading;
using System.Threading.Tasks;
using System.Net.Http;
using System.Collections.Generic;
namespace TwitchArchive.Core.Api
{
public record LiveStreamInfo(string? Title, string? CreatedAt, Dictionary<string, object>? Raw);
public record VodInfo(string Id, string Title, string RecordedAt);
public interface ITwitchApiClient
{
Task<string> GetOauthTokenAsync(CancellationToken ct = default);
Task<LiveStreamInfo?> GetStreamStatusAsync(string username, CancellationToken ct = default);
Task<VodInfo?> GetLatestVodAsync(string username, CancellationToken ct = default);
}
}

View file

@ -0,0 +1,111 @@
using System;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Polly;
using Polly.Retry;
using System.Collections.Generic;
namespace TwitchArchive.Core.Api
{
public class TwitchApiClient : ITwitchApiClient
{
private readonly HttpClient _http;
private readonly AsyncRetryPolicy<HttpResponseMessage> _retryPolicy;
private string? _cachedToken;
private DateTime _tokenExpiryUtc;
private const string OAuthUrl = "https://id.twitch.tv/oauth2/token";
private const string GqlUrl = "https://gql.twitch.tv/gql";
private const string HelixUsersUrl = "https://api.twitch.tv/helix/users";
public TwitchApiClient(HttpClient http)
{
_http = http;
_retryPolicy = Policy.HandleResult<HttpResponseMessage>(r => !r.IsSuccessStatusCode)
.WaitAndRetryAsync(new[] { TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(10) });
}
public async Task<string> GetOauthTokenAsync(CancellationToken ct = default)
{
if (!string.IsNullOrEmpty(_cachedToken) && DateTime.UtcNow < _tokenExpiryUtc.AddSeconds(-30))
{
return _cachedToken!;
}
var clientId = Environment.GetEnvironmentVariable("CLIENT-ID");
var clientSecret = Environment.GetEnvironmentVariable("CLIENT-SECRET");
if (string.IsNullOrEmpty(clientId) || string.IsNullOrEmpty(clientSecret))
throw new InvalidOperationException("CLIENT-ID and CLIENT-SECRET must be set in environment");
var url = $"{OAuthUrl}?client_id={clientId}&client_secret={clientSecret}&grant_type=client_credentials";
var resp = await _retryPolicy.ExecuteAsync(() => _http.PostAsync(url, null, ct)).ConfigureAwait(false);
resp.EnsureSuccessStatusCode();
var doc = await resp.Content.ReadFromJsonAsync<JsonElement>(cancellationToken: ct).ConfigureAwait(false);
if (doc.ValueKind == JsonValueKind.Object && doc.TryGetProperty("access_token", out var tokenEl))
{
_cachedToken = tokenEl.GetString();
if (doc.TryGetProperty("expires_in", out var expiresEl) && expiresEl.TryGetInt32(out var secs))
{
_tokenExpiryUtc = DateTime.UtcNow.AddSeconds(secs);
}
return _cachedToken!;
}
throw new InvalidOperationException("Failed to obtain OAuth token from Twitch");
}
public async Task<LiveStreamInfo?> GetStreamStatusAsync(string username, CancellationToken ct = default)
{
// Simple GQL query to fetch stream data
var query = $"query{{user(login: \"{username}\") {{stream{{title createdAt archiveVideo{{id}}}}}}}}";
var payload = JsonSerializer.Serialize(new { query });
var content = new StringContent(payload, System.Text.Encoding.UTF8, "application/json");
var resp = await _retryPolicy.ExecuteAsync(() => _http.PostAsync(GqlUrl, content, ct)).ConfigureAwait(false);
if (!resp.IsSuccessStatusCode) return null;
var json = await resp.Content.ReadFromJsonAsync<JsonDocument>(cancellationToken: ct).ConfigureAwait(false);
if (json == null) return null;
// navigate JSON safely
try
{
var root = json.RootElement;
if (root.TryGetProperty("data", out var data) && data.TryGetProperty("user", out var user) && user.TryGetProperty("stream", out var stream) && stream.ValueKind != JsonValueKind.Null)
{
var title = stream.GetProperty("title").GetString();
var createdAt = stream.GetProperty("createdAt").GetString();
var raw = new Dictionary<string, object>();
return new LiveStreamInfo(title, createdAt, raw);
}
}
catch { }
return null;
}
public async Task<VodInfo?> GetLatestVodAsync(string username, CancellationToken ct = default)
{
var query = $"query {{user(login: \"{username}\") {{videos(first: 1) {{edges {{node {{id title recordedAt}}}}}}}}}}";
var payload = JsonSerializer.Serialize(new { query });
var content = new StringContent(payload, System.Text.Encoding.UTF8, "application/json");
var resp = await _retryPolicy.ExecuteAsync(() => _http.PostAsync(GqlUrl, content, ct)).ConfigureAwait(false);
if (!resp.IsSuccessStatusCode) return null;
var json = await resp.Content.ReadFromJsonAsync<JsonDocument>(cancellationToken: ct).ConfigureAwait(false);
if (json == null) return null;
try
{
var root = json.RootElement;
var node = root.GetProperty("data").GetProperty("user").GetProperty("videos").GetProperty("edges")[0].GetProperty("node");
var id = node.GetProperty("id").GetString()!;
var title = node.GetProperty("title").GetString()!;
var recordedAt = node.GetProperty("recordedAt").GetString()!;
return new VodInfo(id, title, recordedAt);
}
catch { }
return null;
}
}
}

View file

@ -0,0 +1,22 @@
using System.Text.Json.Serialization;
namespace TwitchArchive.Core.Config
{
public class AppSettings
{
[JsonPropertyName("streamlink_path")]
public string? StreamlinkPath { get; set; }
[JsonPropertyName("ffmpeg_path")]
public string? FfmpegPath { get; set; }
[JsonPropertyName("twitchdownloader_path")]
public string? TwitchDownloaderPath { get; set; }
[JsonPropertyName("rclone_path")]
public string? RclonePath { get; set; }
[JsonPropertyName("password_hash")]
public string? PasswordHash { get; set; }
}
}

View file

@ -0,0 +1,88 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
namespace TwitchArchive.Core.Config
{
public class ConfigurationService : IConfigurationService
{
private readonly string _basePath;
private readonly string _streamersPath;
private readonly JsonSerializerOptions _jsonOptions = new JsonSerializerOptions { WriteIndented = true };
public ConfigurationService(string? basePath = null)
{
_basePath = basePath ?? Path.Combine(AppContext.BaseDirectory, "config");
_streamersPath = Path.Combine(_basePath, "streamers");
Directory.CreateDirectory(_basePath);
Directory.CreateDirectory(_streamersPath);
}
public GlobalConfig LoadGlobal()
{
var file = Path.Combine(_basePath, "global.json");
if (!File.Exists(file)) return new GlobalConfig();
var txt = File.ReadAllText(file);
return JsonSerializer.Deserialize<GlobalConfig>(txt, _jsonOptions) ?? new GlobalConfig();
}
public void SaveGlobal(GlobalConfig cfg)
{
var file = Path.Combine(_basePath, "global.json");
var txt = JsonSerializer.Serialize(cfg, _jsonOptions);
File.WriteAllText(file, txt);
}
public StreamerConfig? LoadStreamer(string username)
{
if (string.IsNullOrWhiteSpace(username)) return null;
var file = Path.Combine(_streamersPath, username + ".json");
if (!File.Exists(file)) return null;
var txt = File.ReadAllText(file);
return JsonSerializer.Deserialize<StreamerConfig>(txt, _jsonOptions);
}
public void SaveStreamer(StreamerConfig cfg)
{
if (cfg == null) throw new ArgumentNullException(nameof(cfg));
if (string.IsNullOrWhiteSpace(cfg.Username)) throw new ArgumentException("Username required");
var file = Path.Combine(_streamersPath, cfg.Username + ".json");
var txt = JsonSerializer.Serialize(cfg, _jsonOptions);
File.WriteAllText(file, txt);
}
public IEnumerable<StreamerConfig> GetAllStreamers()
{
if (!Directory.Exists(_streamersPath)) return Enumerable.Empty<StreamerConfig>();
var files = Directory.EnumerateFiles(_streamersPath, "*.json");
var list = new List<StreamerConfig>();
foreach (var f in files)
{
try
{
var txt = File.ReadAllText(f);
var s = JsonSerializer.Deserialize<StreamerConfig>(txt, _jsonOptions);
if (s != null) list.Add(s);
}
catch { }
}
return list;
}
public EffectiveConfig GetEffectiveConfig(string username)
{
var global = LoadGlobal();
var streamer = LoadStreamer(username);
return EffectiveConfig.Merge(global, streamer);
}
public void DeleteStreamer(string username)
{
if (string.IsNullOrWhiteSpace(username)) return;
var file = Path.Combine(_streamersPath, username + ".json");
try { if (File.Exists(file)) File.Delete(file); } catch { }
}
}
}

View file

@ -0,0 +1,36 @@
using System;
namespace TwitchArchive.Core.Config
{
public class EffectiveConfig
{
public string? ArchiveRoot { get; init; }
public string? StreamlinkPath { get; init; }
public string? FfmpegPath { get; init; }
public string? TwitchDownloaderPath { get; init; }
public string? RclonePath { get; init; }
public bool UploadToCloud { get; init; }
public string? UploadDestination { get; init; }
public int RefreshIntervalSeconds { get; init; }
public int StreamSegmentThreads { get; init; }
public string? DefaultQuality { get; init; }
public static EffectiveConfig Merge(GlobalConfig global, StreamerConfig? streamer)
{
streamer ??= new StreamerConfig();
return new EffectiveConfig
{
ArchiveRoot = streamer.Username != null ? (global.ArchiveRoot ?? string.Empty) : (global.ArchiveRoot ?? string.Empty),
StreamlinkPath = streamer.StreamlinkPath ?? global.StreamlinkPath,
FfmpegPath = global.FfmpegPath,
TwitchDownloaderPath = global.TwitchDownloaderPath,
RclonePath = global.RclonePath,
UploadToCloud = streamer.UploadToCloud ?? global.UploadToCloud,
UploadDestination = streamer.UploadDestination ?? global.UploadDestination,
RefreshIntervalSeconds = global.RefreshIntervalSeconds,
StreamSegmentThreads = global.StreamSegmentThreads,
DefaultQuality = streamer.Quality ?? global.DefaultQuality
};
}
}
}

View file

@ -0,0 +1,37 @@
using System.Text.Json.Serialization;
namespace TwitchArchive.Core.Config
{
public class GlobalConfig
{
[JsonPropertyName("archive_root")]
public string? ArchiveRoot { get; set; }
[JsonPropertyName("streamlink_path")]
public string? StreamlinkPath { get; set; }
[JsonPropertyName("ffmpeg_path")]
public string? FfmpegPath { get; set; }
[JsonPropertyName("twitchdownloader_path")]
public string? TwitchDownloaderPath { get; set; }
[JsonPropertyName("rclone_path")]
public string? RclonePath { get; set; }
[JsonPropertyName("upload_to_cloud")]
public bool UploadToCloud { get; set; } = false;
[JsonPropertyName("upload_destination")]
public string? UploadDestination { get; set; }
[JsonPropertyName("refresh_interval_seconds")]
public int RefreshIntervalSeconds { get; set; } = 60;
[JsonPropertyName("stream_segment_threads")]
public int StreamSegmentThreads { get; set; } = 4;
[JsonPropertyName("default_quality")]
public string? DefaultQuality { get; set; } = "best";
}
}

View file

@ -0,0 +1,18 @@
using System.Collections.Generic;
namespace TwitchArchive.Core.Config
{
public interface IConfigurationService
{
GlobalConfig LoadGlobal();
void SaveGlobal(GlobalConfig cfg);
StreamerConfig? LoadStreamer(string username);
void SaveStreamer(StreamerConfig cfg);
void DeleteStreamer(string username);
IEnumerable<StreamerConfig> GetAllStreamers();
EffectiveConfig GetEffectiveConfig(string username);
}
}

View file

@ -0,0 +1,25 @@
using System.Text.Json.Serialization;
namespace TwitchArchive.Core.Config
{
public class StreamerConfig
{
[JsonPropertyName("username")]
public string Username { get; set; } = string.Empty;
[JsonPropertyName("enabled")]
public bool Enabled { get; set; } = true;
[JsonPropertyName("quality")]
public string? Quality { get; set; }
[JsonPropertyName("upload_to_cloud")]
public bool? UploadToCloud { get; set; }
[JsonPropertyName("upload_destination")]
public string? UploadDestination { get; set; }
[JsonPropertyName("streamlink_path")]
public string? StreamlinkPath { get; set; }
}
}

View file

@ -0,0 +1,35 @@
using System.Runtime.InteropServices;
namespace TwitchArchive.Core.Config
{
public static class ToolPathResolver
{
public static string DefaultStreamlinkPath()
{
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
return "streamlink.exe";
return "/usr/local/bin/streamlink";
}
public static string DefaultFfmpegPath()
{
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
return "ffmpeg.exe";
return "/usr/bin/ffmpeg";
}
public static string DefaultTwitchDownloaderPath()
{
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
return "TwitchDownloaderCLI.exe";
return "/app/bin/TwitchDownloaderCLI";
}
public static string DefaultRclonePath()
{
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
return "rclone.exe";
return "/usr/bin/rclone";
}
}
}

View file

@ -0,0 +1,17 @@
using System.Threading;
using System.Threading.Tasks;
namespace TwitchArchive.Core.Monitoring
{
/// <summary>
/// Dummy implementation used for initial scaffolding — always reports offline (false).
/// Replace this with a Twitch API backed implementation later.
/// </summary>
public class DummyLiveChecker : ILiveChecker
{
public Task<bool?> IsLiveAsync(string username, CancellationToken ct = default)
{
return Task.FromResult<bool?>(false);
}
}
}

View file

@ -0,0 +1,13 @@
using System.Threading;
using System.Threading.Tasks;
namespace TwitchArchive.Core.Monitoring
{
public interface ILiveChecker
{
/// <summary>
/// Return true = live, false = offline, null = unknown/error
/// </summary>
Task<bool?> IsLiveAsync(string username, CancellationToken ct = default);
}
}

View file

@ -0,0 +1,29 @@
using System.Threading;
using System.Threading.Tasks;
using TwitchArchive.Core.Api;
namespace TwitchArchive.Core.Monitoring
{
public class TwitchLiveChecker : ILiveChecker
{
private readonly ITwitchApiClient _api;
public TwitchLiveChecker(ITwitchApiClient api)
{
_api = api;
}
public async Task<bool?> IsLiveAsync(string username, CancellationToken ct = default)
{
try
{
var info = await _api.GetStreamStatusAsync(username, ct).ConfigureAwait(false);
return info != null;
}
catch
{
return null; // unknown / network error
}
}
}
}

View file

@ -0,0 +1,14 @@
using Microsoft.EntityFrameworkCore;
using TwitchArchive.Core.Persistence.Models;
namespace TwitchArchive.Core.Persistence
{
public class ArchiveDbContext : DbContext
{
public ArchiveDbContext(DbContextOptions<ArchiveDbContext> options) : base(options) { }
public DbSet<StreamSession> StreamSessions { get; set; } = null!;
public DbSet<ArchiveJob> ArchiveJobs { get; set; } = null!;
public DbSet<StreamerState> StreamerStates { get; set; } = null!;
}
}

View file

@ -0,0 +1,17 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using TwitchArchive.Core.Persistence.Models;
namespace TwitchArchive.Core.Persistence
{
public interface ISessionRepository
{
Task<StreamSession> CreateSessionAsync(string username, string streamId, DateTime startedAt, CancellationToken ct = default);
Task EndSessionAsync(long sessionId, DateTime endedAt, string status, CancellationToken ct = default);
Task<ArchiveJob> CreateJobAsync(long sessionId, string jobType, DateTime startedAt, CancellationToken ct = default);
Task UpdateJobAsync(ArchiveJob job, CancellationToken ct = default);
Task<List<StreamSession>> GetRecentSessionsAsync(int max = 50, CancellationToken ct = default);
Task<List<ArchiveJob>> GetJobsForSessionAsync(long sessionId, CancellationToken ct = default);
}
}

View file

@ -0,0 +1,16 @@
using System;
namespace TwitchArchive.Core.Persistence.Models
{
public class ArchiveJob
{
public long Id { get; set; }
public long StreamSessionId { get; set; }
public string JobType { get; set; } = string.Empty; // enum name
public string Status { get; set; } = string.Empty; // Pending, Running, Completed, Failed
public DateTime StartedAt { get; set; }
public DateTime? CompletedAt { get; set; }
public string? FilePath { get; set; }
public string? ErrorMessage { get; set; }
}
}

View file

@ -0,0 +1,15 @@
using System;
namespace TwitchArchive.Core.Persistence.Models
{
public class StreamSession
{
public long Id { get; set; }
public string StreamerUsername { get; set; } = string.Empty;
public string TwitchStreamId { get; set; } = string.Empty;
public string Title { get; set; } = string.Empty;
public DateTime StartedAt { get; set; }
public DateTime? EndedAt { get; set; }
public string Status { get; set; } = string.Empty; // Recording, Processing, Uploading, Complete, Failed
}
}

View file

@ -0,0 +1,13 @@
using System;
namespace TwitchArchive.Core.Persistence.Models
{
public class StreamerState
{
public long Id { get; set; }
public string Username { get; set; } = string.Empty;
public bool IsMonitoring { get; set; }
public DateTime? LastCheckedAt { get; set; }
public string? CurrentRecoveryState { get; set; }
}
}

View file

@ -0,0 +1,62 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
using TwitchArchive.Core.Persistence.Models;
namespace TwitchArchive.Core.Persistence
{
public class SessionRepository : ISessionRepository
{
private readonly IDbContextFactory<ArchiveDbContext> _factory;
public SessionRepository(IDbContextFactory<ArchiveDbContext> factory) { _factory = factory ?? throw new ArgumentNullException(nameof(factory)); }
public async Task<StreamSession> CreateSessionAsync(string username, string streamId, DateTime startedAt, CancellationToken ct = default)
{
await using var db = _factory.CreateDbContext();
var s = new StreamSession { StreamerUsername = username, TwitchStreamId = streamId, StartedAt = startedAt, Status = "Recording" };
db.StreamSessions.Add(s);
await db.SaveChangesAsync(ct).ConfigureAwait(false);
return s;
}
public async Task EndSessionAsync(long sessionId, DateTime endedAt, string status, CancellationToken ct = default)
{
await using var db = _factory.CreateDbContext();
var s = await db.StreamSessions.FindAsync(new object[] { sessionId }, ct).ConfigureAwait(false);
if (s == null) return;
s.EndedAt = endedAt;
s.Status = status;
await db.SaveChangesAsync(ct).ConfigureAwait(false);
}
public async Task<ArchiveJob> CreateJobAsync(long sessionId, string jobType, DateTime startedAt, CancellationToken ct = default)
{
await using var db = _factory.CreateDbContext();
var j = new ArchiveJob { StreamSessionId = sessionId, JobType = jobType, StartedAt = startedAt, Status = "Running" };
db.ArchiveJobs.Add(j);
await db.SaveChangesAsync(ct).ConfigureAwait(false);
return j;
}
public async Task UpdateJobAsync(ArchiveJob job, CancellationToken ct = default)
{
await using var db = _factory.CreateDbContext();
db.ArchiveJobs.Update(job);
await db.SaveChangesAsync(ct).ConfigureAwait(false);
}
public async Task<List<StreamSession>> GetRecentSessionsAsync(int max = 50, CancellationToken ct = default)
{
await using var db = _factory.CreateDbContext();
return await db.StreamSessions.OrderByDescending(s => s.StartedAt).Take(max).ToListAsync(ct).ConfigureAwait(false);
}
public async Task<List<ArchiveJob>> GetJobsForSessionAsync(long sessionId, CancellationToken ct = default)
{
await using var db = _factory.CreateDbContext();
return await db.ArchiveJobs.Where(j => j.StreamSessionId == sessionId).OrderBy(j => j.StartedAt).ToListAsync(ct).ConfigureAwait(false);
}
}
}

View file

@ -0,0 +1,131 @@
using System;
namespace TwitchArchive.Core.Recovery
{
public enum RecoveryState
{
Monitoring,
Recording,
FastReconnect,
SlowReconnect,
NetworkFault
}
public enum RecoveryAction
{
None,
StartRecording,
StartProcessing,
SleepOnly
}
public sealed class RecoveryDecision
{
public RecoveryAction Action { get; init; }
public TimeSpan Sleep { get; init; }
public static RecoveryDecision SleepFor(TimeSpan t) => new RecoveryDecision { Action = RecoveryAction.SleepOnly, Sleep = t };
public static RecoveryDecision StartRecordingNow() => new RecoveryDecision { Action = RecoveryAction.StartRecording, Sleep = TimeSpan.Zero };
public static RecoveryDecision StartProcessingAndSleep(TimeSpan sleep) => new RecoveryDecision { Action = RecoveryAction.StartProcessing, Sleep = sleep };
public static RecoveryDecision None() => new RecoveryDecision { Action = RecoveryAction.None, Sleep = TimeSpan.Zero };
}
/// <summary>
/// Pure, testable recovery policy state machine.
/// It decides whether to start recording immediately, wait a short period (fast reconnect),
/// wait longer (slow reconnect), or enter a network fault backoff.
///</summary>
public class RecoveryPolicy
{
private RecoveryState _state = RecoveryState.Monitoring;
private readonly TimeSpan _refreshInterval;
private DateTime? _fastReconnectStartUtc;
private int _networkFaultAttempts;
// constants matching requested behavior
private static readonly TimeSpan FastReconnectInterval = TimeSpan.FromSeconds(10);
private static readonly TimeSpan FastReconnectWindow = TimeSpan.FromMinutes(2);
private static readonly TimeSpan SlowReconnectInterval = TimeSpan.FromSeconds(60);
private static readonly TimeSpan NetworkFaultBase = TimeSpan.FromSeconds(30);
private static readonly TimeSpan NetworkFaultMax = TimeSpan.FromMinutes(10);
public RecoveryPolicy(TimeSpan? refreshInterval = null)
{
_refreshInterval = refreshInterval ?? TimeSpan.FromSeconds(60);
}
/// <summary>
/// Evaluate the policy given current time, whether the streamer is live (null = unknown),
/// and whether there was a network error reaching Twitch APIs.
/// Returns a RecoveryDecision describing the next action and how long to wait if sleeping.
/// </summary>
public RecoveryDecision Tick(DateTime nowUtc, bool? isLive, bool networkError)
{
// Network error handling: enter NetworkFault state with exponential backoff
if (networkError)
{
_networkFaultAttempts++;
_state = RecoveryState.NetworkFault;
var backoff = NetworkFaultBase * Math.Pow(2, Math.Max(0, _networkFaultAttempts - 1));
if (backoff > NetworkFaultMax) backoff = NetworkFaultMax;
return RecoveryDecision.SleepFor(backoff);
}
// If we recover from network fault, reset attempts
if (_state == RecoveryState.NetworkFault && !networkError)
{
_networkFaultAttempts = 0;
_state = RecoveryState.Monitoring;
}
// If live -> start recording immediately
if (isLive == true)
{
_state = RecoveryState.Recording;
_fastReconnectStartUtc = null;
return RecoveryDecision.StartRecordingNow();
}
// If unknown live status, treat conservatively: sleep a short amount (refresh)
if (isLive == null)
{
return RecoveryDecision.SleepFor(_refreshInterval);
}
// isLive == false here
switch (_state)
{
case RecoveryState.Recording:
// Just transitioned from recording to not-live: start fast reconnect window
_state = RecoveryState.FastReconnect;
_fastReconnectStartUtc = nowUtc;
return RecoveryDecision.SleepFor(FastReconnectInterval);
case RecoveryState.FastReconnect:
if (_fastReconnectStartUtc.HasValue && (nowUtc - _fastReconnectStartUtc.Value) < FastReconnectWindow)
{
// stay in fast reconnect, poll frequently
return RecoveryDecision.SleepFor(FastReconnectInterval);
}
else
{
// fast reconnect window expired -> move to slow reconnect and trigger processing
_state = RecoveryState.SlowReconnect;
_fastReconnectStartUtc = null;
return RecoveryDecision.StartProcessingAndSleep(SlowReconnectInterval);
}
case RecoveryState.SlowReconnect:
// In slow reconnect, poll less frequently
return RecoveryDecision.SleepFor(SlowReconnectInterval);
case RecoveryState.Monitoring:
default:
// Normal monitoring cadence
return RecoveryDecision.SleepFor(_refreshInterval);
}
}
public RecoveryState CurrentState => _state;
}
}

View file

@ -0,0 +1,50 @@
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
namespace TwitchArchive.Core.Services
{
public class DownloaderService : IDownloaderService
{
private readonly IProcessRunner _runner;
public DownloaderService(IProcessRunner runner)
{
_runner = runner;
}
public async Task<bool> DownloadVodAsync(TwitchArchive.Core.Api.VodInfo vod, string outputPath, CancellationToken ct = default)
{
try
{
var bin = "TwitchDownloaderCLI"; // expect to be on PATH or configured elsewhere
Directory.CreateDirectory(Path.GetDirectoryName(outputPath) ?? ".");
var args = $"videodownload -u https://www.twitch.tv/videos/{(vod.Id.StartsWith("v") ? vod.Id.Substring(1) : vod.Id)} -q best -t 10 --ffmpeg-path ffmpeg --collision Rename -o \"{outputPath}\"";
var res = await _runner.RunAsync(new ProcessRunOptions { FileName = bin, Arguments = args, RedirectOutput = true }, ct).ConfigureAwait(false);
return res.ExitCode == 0;
}
catch
{
return false;
}
}
public async Task<bool> DownloadChatJsonAsync(string vodId, string jsonPath, CancellationToken ct = default)
{
try
{
var bin = "TwitchDownloaderCLI";
Directory.CreateDirectory(Path.GetDirectoryName(jsonPath) ?? ".");
if (vodId.StartsWith("v")) vodId = vodId.Substring(1);
var args = $"chatdownload --id {vodId} --embed-images --collision Rename -o \"{jsonPath}\"";
var res = await _runner.RunAsync(new ProcessRunOptions { FileName = bin, Arguments = args, RedirectOutput = true }, ct).ConfigureAwait(false);
return res.ExitCode == 0 && File.Exists(jsonPath);
}
catch
{
return false;
}
}
}
}

View file

@ -0,0 +1,52 @@
using System;
using System.IO;
using System.Threading.Tasks;
namespace TwitchArchive.Core.Services
{
public record ArchivePaths(string Root, string RawPath, string VideoPath, string ChatJsonPath, string ChatMp4Path, string MetadataPath);
public class FileManagerService
{
private readonly string _root;
public FileManagerService(string? root = null)
{
_root = root ?? Path.Combine(Directory.GetCurrentDirectory(), "archive");
}
public ArchivePaths GetPaths(string username)
{
var root = Path.Combine(_root, username);
var raw = Path.Combine(root, "video", "raw");
var video = Path.Combine(root, "video");
var chatJson = Path.Combine(root, "chat", "json");
var chatMp4 = Path.Combine(root, "chat");
var meta = Path.Combine(root, "metadata");
return new ArchivePaths(root, raw, video, chatJson, chatMp4, meta);
}
public void EnsureDirectories(ArchivePaths paths)
{
Directory.CreateDirectory(paths.RawPath);
Directory.CreateDirectory(paths.VideoPath);
Directory.CreateDirectory(paths.ChatJsonPath);
Directory.CreateDirectory(paths.ChatMp4Path);
Directory.CreateDirectory(paths.MetadataPath);
}
public string GetUniqueFilePath(string path)
{
if (!File.Exists(path)) return path;
var dir = Path.GetDirectoryName(path) ?? string.Empty;
var name = Path.GetFileNameWithoutExtension(path);
var ext = Path.GetExtension(path);
for (int i = 1; i < 10000; i++)
{
var candidate = Path.Combine(dir, $"{name}-{i}{ext}");
if (!File.Exists(candidate)) return candidate;
}
throw new IOException("Could not create unique filename");
}
}
}

View file

@ -0,0 +1,12 @@
using System.Threading;
using System.Threading.Tasks;
using TwitchArchive.Core.Api;
namespace TwitchArchive.Core.Services
{
public interface IDownloaderService
{
Task<bool> DownloadVodAsync(VodInfo vod, string outputPath, CancellationToken ct = default);
Task<bool> DownloadChatJsonAsync(string vodId, string jsonPath, CancellationToken ct = default);
}
}

View file

@ -0,0 +1,19 @@
using System;
using System.Collections.Generic;
namespace TwitchArchive.Core.Services
{
public record OutputLine(Guid JobId, DateTime TimestampUtc, string Line, bool IsError);
public interface IProcessOutputStore
{
void AppendLine(string streamer, OutputLine line);
IReadOnlyList<OutputLine> GetRecentLines(string streamer, Guid jobId, int max = 500);
IReadOnlyList<OutputLine> GetRecentLines(string streamer, int max = 500);
/// <summary>
/// Event raised when a line is appended. Useful for UI subscriptions.
/// </summary>
event Action<string, OutputLine>? LineAppended;
}
}

View file

@ -0,0 +1,31 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace TwitchArchive.Core.Services
{
public record ProcessOutputLine(DateTime TimestampUtc, string Line, bool IsError);
public class ProcessRunResult
{
public int ExitCode { get; set; }
public List<ProcessOutputLine> Output { get; set; } = new();
}
public class ProcessRunOptions
{
public string FileName { get; init; } = string.Empty;
public string Arguments { get; init; } = string.Empty;
public string? WorkingDirectory { get; init; }
public bool RedirectOutput { get; init; } = true;
// Optional metadata for real-time output forwarding
public string? Streamer { get; init; }
public Guid? JobId { get; init; }
}
public interface IProcessRunner
{
Task<ProcessRunResult> RunAsync(ProcessRunOptions options, CancellationToken cancellationToken = default);
}
}

View file

@ -0,0 +1,11 @@
using System.Threading;
using System.Threading.Tasks;
namespace TwitchArchive.Core.Services
{
public interface IProcessorService
{
Task<bool> ProcessRawStreamAsync(string rawPath, string outputPath, string quality, CancellationToken ct = default);
Task<bool> MergeVideoAndChatAsync(string videoPath, string chatVideoPath, string outputPath, string layout, CancellationToken ct = default);
}
}

View file

@ -0,0 +1,59 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
namespace TwitchArchive.Core.Services
{
public class ProcessOutputStore : IProcessOutputStore
{
// streamer -> jobId -> circular buffer
private readonly ConcurrentDictionary<string, ConcurrentDictionary<Guid, FixedSizedQueue<OutputLine>>> _store = new();
public event Action<string, OutputLine>? LineAppended;
public void AppendLine(string streamer, OutputLine line)
{
var jobs = _store.GetOrAdd(streamer, _ => new ConcurrentDictionary<Guid, FixedSizedQueue<OutputLine>>());
var buf = jobs.GetOrAdd(line.JobId, _ => new FixedSizedQueue<OutputLine>(1000));
buf.Enqueue(line);
try { LineAppended?.Invoke(streamer, line); } catch { }
}
public IReadOnlyList<OutputLine> GetRecentLines(string streamer, Guid jobId, int max = 500)
{
if (!_store.TryGetValue(streamer, out var jobs)) return Array.Empty<OutputLine>();
if (!jobs.TryGetValue(jobId, out var buf)) return Array.Empty<OutputLine>();
var items = buf.ToList();
if (items.Count <= max) return items;
return items.Skip(items.Count - max).ToList();
}
public IReadOnlyList<OutputLine> GetRecentLines(string streamer, int max = 500)
{
if (!_store.TryGetValue(streamer, out var jobs)) return Array.Empty<OutputLine>();
var combined = new List<OutputLine>();
foreach (var kv in jobs)
{
lock (kv.Value)
{
combined.AddRange(kv.Value.ToList());
}
}
combined.Sort((a, b) => a.TimestampUtc.CompareTo(b.TimestampUtc));
if (combined.Count <= max) return combined;
return combined.Skip(combined.Count - max).ToList();
}
private class FixedSizedQueue<T> : Queue<T>
{
private readonly int _maxSize;
public FixedSizedQueue(int maxSize) { _maxSize = maxSize; }
public new void Enqueue(T item)
{
base.Enqueue(item);
while (Count > _maxSize) Dequeue();
}
}
}
}

View file

@ -0,0 +1,101 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
namespace TwitchArchive.Core.Services
{
public class ProcessRunner : IProcessRunner
{
private readonly IProcessOutputStore? _outputStore;
public ProcessRunner(IProcessOutputStore? outputStore = null)
{
_outputStore = outputStore;
}
public async Task<ProcessRunResult> RunAsync(ProcessRunOptions options, CancellationToken cancellationToken = default)
{
var result = new ProcessRunResult();
using var process = new Process();
process.StartInfo.FileName = options.FileName;
process.StartInfo.Arguments = options.Arguments;
process.StartInfo.UseShellExecute = false;
process.StartInfo.CreateNoWindow = true;
if (options.RedirectOutput)
{
process.StartInfo.RedirectStandardOutput = true;
process.StartInfo.RedirectStandardError = true;
}
var outputLines = new List<ProcessOutputLine>();
if (options.RedirectOutput)
{
process.OutputDataReceived += (s, e) =>
{
if (e.Data == null) return;
var item = new ProcessOutputLine(DateTime.UtcNow, e.Data, false);
lock (outputLines) { outputLines.Add(item); }
// forward to store if available
if (_outputStore != null && options.Streamer != null && options.JobId.HasValue)
{
try { _outputStore.AppendLine(options.Streamer, new OutputLine(options.JobId.Value, item.TimestampUtc, item.Line, item.IsError)); } catch { }
}
};
process.ErrorDataReceived += (s, e) =>
{
if (e.Data == null) return;
var item = new ProcessOutputLine(DateTime.UtcNow, e.Data, true);
lock (outputLines) { outputLines.Add(item); }
if (_outputStore != null && options.Streamer != null && options.JobId.HasValue)
{
try { _outputStore.AppendLine(options.Streamer, new OutputLine(options.JobId.Value, item.TimestampUtc, item.Line, item.IsError)); } catch { }
}
};
}
using (cancellationToken.Register(() =>
{
try { if (!process.HasExited) process.Kill(); } catch { }
}))
{
try
{
if (!process.Start())
{
result.ExitCode = -1;
return result;
}
if (options.RedirectOutput)
{
process.BeginOutputReadLine();
process.BeginErrorReadLine();
}
await Task.Run(() => process.WaitForExit(), cancellationToken).ConfigureAwait(false);
result.ExitCode = process.ExitCode;
}
catch (OperationCanceledException)
{
result.ExitCode = -1;
}
catch (Exception)
{
result.ExitCode = -1;
}
}
lock (outputLines)
{
result.Output = outputLines;
}
return result;
}
}
}

View file

@ -0,0 +1,63 @@
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
namespace TwitchArchive.Core.Services
{
public class ProcessorService : IProcessorService
{
private readonly IProcessRunner _runner;
public ProcessorService(IProcessRunner runner)
{
_runner = runner;
}
public async Task<bool> ProcessRawStreamAsync(string rawPath, string outputPath, string quality, CancellationToken ct = default)
{
if (!File.Exists(rawPath)) return false;
try
{
Directory.CreateDirectory(Path.GetDirectoryName(outputPath) ?? ".");
// If audio-only
if (quality == "audio_only")
{
var args = $"-i \"{rawPath}\" -vn -c:a aac -ar 48000 -ac 2 -b:a 192k \"{outputPath}\"";
var res = await _runner.RunAsync(new ProcessRunOptions { FileName = "ffmpeg", Arguments = args, RedirectOutput = true }, ct).ConfigureAwait(false);
return res.ExitCode == 0;
}
// default: copy streams into mp4
var args2 = $"-y -i \"{rawPath}\" -c:v copy -c:a copy -movflags +faststart \"{outputPath}\"";
var r2 = await _runner.RunAsync(new ProcessRunOptions { FileName = "ffmpeg", Arguments = args2, RedirectOutput = true }, ct).ConfigureAwait(false);
return r2.ExitCode == 0;
}
catch
{
return false;
}
}
public async Task<bool> MergeVideoAndChatAsync(string videoPath, string chatVideoPath, string outputPath, string layout, CancellationToken ct = default)
{
try
{
Directory.CreateDirectory(Path.GetDirectoryName(outputPath) ?? ".");
// Simple side-by-side using ffmpeg hstack (assumes same height)
if (layout == "side-by-side")
{
var args = $"-y -i \"{videoPath}\" -i \"{chatVideoPath}\" -filter_complex \"[0:v]scale=iw:ih[v0];[1:v]scale=iw:ih[v1];[v0][v1]hstack=inputs=2[v]\" -map \"[v]\" -map 0:a? -c:v libx264 -crf 23 -preset veryfast \"{outputPath}\"";
var res = await _runner.RunAsync(new ProcessRunOptions { FileName = "ffmpeg", Arguments = args, RedirectOutput = true }, ct).ConfigureAwait(false);
return res.ExitCode == 0;
}
// overlay or other layouts can be added later
return false;
}
catch
{
return false;
}
}
}
}

View file

@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.3" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.3">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.3" />
<PackageReference Include="Polly" Version="8.6.5" />
<PackageReference Include="NLog" Version="6.1.0" />
<PackageReference Include="NLog.Extensions.Logging" Version="6.1.1" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,178 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using System.IO;
using TwitchArchive.Core.Monitoring;
using Microsoft.Extensions.DependencyInjection;
using TwitchArchive.Core.Persistence.Models;
using TwitchArchive.Core.Recovery;
using TwitchArchive.Core.Services;
namespace TwitchArchive.Core.Workers
{
/// <summary>
/// Monitors a single streamer using a RecoveryPolicy and invokes the process runner when needed.
/// This is a lightweight, testable worker (not derived from BackgroundService).
/// </summary>
public class StreamWorker
{
private readonly string _username;
private readonly ILiveChecker _liveChecker;
private readonly IProcessRunner _processRunner;
private readonly IProcessOutputStore _outputStore;
private readonly IDownloaderService _downloader;
private readonly IProcessorService _processor;
private readonly FileManagerService _fileManager;
private readonly TwitchArchive.Core.Api.ITwitchApiClient _apiClient;
private readonly TwitchArchive.Core.Persistence.ISessionRepository? _sessionRepo;
private readonly IServiceScope? _scope;
private readonly RecoveryPolicy _policy;
private CancellationTokenSource? _cts;
private Task? _loopTask;
public StreamWorker(string username, ILiveChecker liveChecker, IProcessRunner processRunner, IProcessOutputStore outputStore, IDownloaderService downloader, IProcessorService processor, FileManagerService fileManager, TwitchArchive.Core.Api.ITwitchApiClient apiClient, TwitchArchive.Core.Persistence.ISessionRepository? sessionRepo = null, IServiceScope? scope = null, TimeSpan? refreshInterval = null)
{
_username = username;
_liveChecker = liveChecker;
_processRunner = processRunner;
_outputStore = outputStore;
_downloader = downloader;
_processor = processor;
_fileManager = fileManager;
_apiClient = apiClient;
_sessionRepo = sessionRepo;
_scope = scope;
_policy = new RecoveryPolicy(refreshInterval);
}
public void Start()
{
if (_cts != null) return;
_cts = new CancellationTokenSource();
_loopTask = Task.Run(() => MonitorLoopAsync(_cts.Token));
}
public async Task StopAsync()
{
if (_cts == null) return;
_cts.Cancel();
try { if (_loopTask != null) await _loopTask.ConfigureAwait(false); } catch { }
_cts.Dispose();
_cts = null;
_loopTask = null;
try { _scope?.Dispose(); } catch { }
}
private async Task MonitorLoopAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
bool? isLive = null;
bool networkError = false;
try
{
isLive = await _liveChecker.IsLiveAsync(_username, ct).ConfigureAwait(false);
}
catch (OperationCanceledException) { break; }
catch (Exception)
{
// treat as network error — let policy handle backoff
networkError = true;
}
var decision = _policy.Tick(DateTime.UtcNow, isLive, networkError);
if (decision.Action == RecoveryAction.StartRecording)
{
// Build output path and ensure directories
var paths = _fileManager.GetPaths(_username);
_fileManager.EnsureDirectories(paths);
var timestamp = DateTime.UtcNow.ToString("yyyyMMdd_H\'h\'mm\'m\'ss\'");
var filenameBase = timestamp;
var rawFile = Path.Combine(paths.RawPath, $"LIVE_{filenameBase}.ts");
rawFile = _fileManager.GetUniqueFilePath(rawFile);
var jobId = Guid.NewGuid();
// persist session and recording job (if repository available)
StreamSession? session = null;
ArchiveJob? recJob = null;
if (_sessionRepo != null)
{
try
{
session = await _sessionRepo.CreateSessionAsync(_username, string.Empty, DateTime.UtcNow, ct).ConfigureAwait(false);
recJob = await _sessionRepo.CreateJobAsync(session.Id, "Recording", DateTime.UtcNow, ct).ConfigureAwait(false);
}
catch { }
}
var quality = "best"; // TODO: read from config
var args = $"twitch.tv/{_username} {quality} --hls-live-restart --retry-streams 30 --force -o \"{rawFile}\"";
var options = new ProcessRunOptions { FileName = "streamlink", Arguments = args, Streamer = _username, JobId = jobId };
try
{
var res = await _processRunner.RunAsync(options, ct).ConfigureAwait(false);
if (recJob != null)
{
recJob.Status = res.ExitCode == 0 ? "Completed" : "Failed";
recJob.CompletedAt = DateTime.UtcNow;
recJob.FilePath = rawFile;
try { await _sessionRepo!.UpdateJobAsync(recJob, ct).ConfigureAwait(false); } catch { }
}
// recording finished; now trigger processing
var procOutput = Path.Combine(paths.VideoPath, $"LIVE_{filenameBase}.mp4");
// create processing job
ArchiveJob? procJob = null;
if (_sessionRepo != null && session != null)
{
try { procJob = await _sessionRepo.CreateJobAsync(session.Id, "Processing", DateTime.UtcNow, ct).ConfigureAwait(false); } catch { }
}
await _processor.ProcessRawStreamAsync(rawFile, procOutput, quality, ct).ConfigureAwait(false);
if (procJob != null)
{
procJob.Status = "Completed";
procJob.CompletedAt = DateTime.UtcNow;
procJob.FilePath = procOutput;
try { await _sessionRepo.UpdateJobAsync(procJob, ct).ConfigureAwait(false); } catch { }
}
// after processing, try to find matching VOD and download it
var vod = await _apiClient.GetLatestVodAsync(_username, ct).ConfigureAwait(false);
if (vod != null)
{
var vodPath = Path.Combine(paths.VideoPath, $"VOD_{filenameBase}.mp4");
ArchiveJob? vodJob = null;
if (_sessionRepo != null && session != null)
{
try { vodJob = await _sessionRepo.CreateJobAsync(session.Id, "DownloadVOD", DateTime.UtcNow, ct).ConfigureAwait(false); } catch { }
}
await _downloader.DownloadVodAsync(vod, vodPath, ct).ConfigureAwait(false);
if (vodJob != null)
{
vodJob.Status = "Completed";
vodJob.CompletedAt = DateTime.UtcNow;
vodJob.FilePath = vodPath;
try { await _sessionRepo.UpdateJobAsync(vodJob, ct).ConfigureAwait(false); } catch { }
}
}
}
catch { }
}
else if (decision.Action == RecoveryAction.StartProcessing)
{
// placeholder for processing triggers
}
// sleep for the suggested duration (or break if cancelled)
try
{
await Task.Delay(decision.Sleep, ct).ConfigureAwait(false);
}
catch (TaskCanceledException) { break; }
}
}
}
}

View file

@ -0,0 +1,51 @@
using System;
using Microsoft.Extensions.DependencyInjection;
using System.Collections.Concurrent;
using System.Threading.Tasks;
using TwitchArchive.Core.Monitoring;
using TwitchArchive.Core.Services;
namespace TwitchArchive.Core.Workers
{
public class StreamWorkerManager
{
private readonly IServiceProvider _services;
private readonly ConcurrentDictionary<string, StreamWorker> _workers = new();
public StreamWorkerManager(IServiceProvider services)
{
_services = services;
}
public void StartWorker(string username)
{
var worker = _workers.GetOrAdd(username, u =>
{
var scope = _services.CreateScope();
var liveChecker = scope.ServiceProvider.GetRequiredService<ILiveChecker>();
var processRunner = scope.ServiceProvider.GetRequiredService<IProcessRunner>();
var outputStore = scope.ServiceProvider.GetRequiredService<IProcessOutputStore>();
var downloader = scope.ServiceProvider.GetRequiredService<IDownloaderService>();
var processor = scope.ServiceProvider.GetRequiredService<IProcessorService>();
var fileManager = scope.ServiceProvider.GetRequiredService<FileManagerService>();
var api = scope.ServiceProvider.GetRequiredService<TwitchArchive.Core.Api.ITwitchApiClient>();
var sessionRepo = scope.ServiceProvider.GetService<TwitchArchive.Core.Persistence.ISessionRepository>();
var w = new StreamWorker(u, liveChecker, processRunner, outputStore, downloader, processor, fileManager, api, sessionRepo, scope);
return w;
});
worker.Start();
}
public async Task StopWorkerAsync(string username)
{
if (_workers.TryRemove(username, out var worker))
{
await worker.StopAsync().ConfigureAwait(false);
}
}
public bool IsRunning(string username) => _workers.ContainsKey(username);
}
}

View file

@ -0,0 +1,39 @@
using System;
using System.IO;
using Xunit;
using TwitchArchive.Core.Config;
namespace TwitchArchive.Tests
{
public class ConfigurationServiceTests : IDisposable
{
private readonly string _tmp;
public ConfigurationServiceTests()
{
_tmp = Path.Combine(Path.GetTempPath(), "ta_test_" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_tmp);
}
[Fact]
public void SaveAndLoadGlobalAndStreamer_Roundtrip()
{
var svc = new ConfigurationService(_tmp);
var g = new GlobalConfig { ArchiveRoot = "x:\\archive", DefaultQuality = "best" };
svc.SaveGlobal(g);
var loaded = svc.LoadGlobal();
Assert.Equal(g.ArchiveRoot, loaded.ArchiveRoot);
var s = new StreamerConfig { Username = "alice", Enabled = true, Quality = "720p" };
svc.SaveStreamer(s);
var loadedS = svc.LoadStreamer("alice");
Assert.NotNull(loadedS);
Assert.Equal("720p", loadedS!.Quality);
}
public void Dispose()
{
try { Directory.Delete(_tmp, true); } catch { }
}
}
}

View file

@ -0,0 +1,55 @@
using System;
using System.IO;
using System.Threading.Tasks;
using Moq;
using Xunit;
using TwitchArchive.Core.Services;
using TwitchArchive.Core.Api;
namespace TwitchArchive.Tests
{
public class DownloaderServiceTests : IDisposable
{
private readonly string _tmp;
public DownloaderServiceTests()
{
_tmp = Path.Combine(Path.GetTempPath(), "ta_dl_" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_tmp);
}
[Fact]
public async Task DownloadVod_CallsRunner_WithExpectedArgs()
{
var mock = new Mock<IProcessRunner>();
mock.Setup(r => r.RunAsync(It.IsAny<ProcessRunOptions>(), default)).ReturnsAsync(new ProcessRunResult { ExitCode = 0 });
var svc = new DownloaderService(mock.Object);
var vod = new VodInfo("v123", "title", "2020-01-01T00:00:00Z");
var outp = Path.Combine(_tmp, "vod.mp4");
var ok = await svc.DownloadVodAsync(vod, outp);
Assert.True(ok);
mock.Verify(r => r.RunAsync(It.Is<ProcessRunOptions>(o => o.FileName.Contains("TwitchDownloader") && o.Arguments.Contains("-u") && o.Arguments.Contains("-o \"" + outp + "\"")), default), Times.Once);
}
[Fact]
public async Task DownloadChatJson_CreatesFileAndReturnsTrue()
{
var mock = new Mock<IProcessRunner>();
mock.Setup(r => r.RunAsync(It.IsAny<ProcessRunOptions>(), default)).ReturnsAsync(new ProcessRunResult { ExitCode = 0 });
var svc = new DownloaderService(mock.Object);
var jsonPath = Path.Combine(_tmp, "chat.json");
// ensure file exists to satisfy Post-run check
File.WriteAllText(jsonPath, "{}");
var ok = await svc.DownloadChatJsonAsync("v123", jsonPath);
Assert.True(ok);
mock.Verify(r => r.RunAsync(It.Is<ProcessRunOptions>(o => o.FileName.Contains("TwitchDownloader") && o.Arguments.Contains("chatdownload")), default), Times.Once);
}
public void Dispose()
{
try { Directory.Delete(_tmp, true); } catch { }
}
}
}

View file

@ -0,0 +1,27 @@
using Xunit;
using TwitchArchive.Core.Config;
namespace TwitchArchive.Tests
{
public class EffectiveConfigTests
{
[Fact]
public void StreamerOverrideWins_WhenPresent()
{
var global = new GlobalConfig { DefaultQuality = "best", UploadToCloud = false };
var streamer = new StreamerConfig { Username = "bob", Quality = "480p", UploadToCloud = true };
var eff = EffectiveConfig.Merge(global, streamer);
Assert.Equal("480p", eff.DefaultQuality);
Assert.True(eff.UploadToCloud);
}
[Fact]
public void GlobalUsed_WhenStreamerNull()
{
var global = new GlobalConfig { DefaultQuality = "best", UploadToCloud = false };
var eff = EffectiveConfig.Merge(global, null);
Assert.Equal("best", eff.DefaultQuality);
Assert.False(eff.UploadToCloud);
}
}
}

View file

@ -0,0 +1,38 @@
using System;
using System.IO;
using Xunit;
using TwitchArchive.Core.Services;
namespace TwitchArchive.Tests
{
public class FileManagerServiceTests : IDisposable
{
private readonly string _root;
public FileManagerServiceTests()
{
_root = Path.Combine(Path.GetTempPath(), "ta_fm_" + Guid.NewGuid().ToString("N"));
}
[Fact]
public void EnsureDirectories_CreatesPaths_And_GetUniquePath_AppendsSuffix()
{
var svc = new FileManagerService(_root);
var paths = svc.GetPaths("alice");
svc.EnsureDirectories(paths);
Assert.True(Directory.Exists(paths.RawPath));
Assert.True(Directory.Exists(paths.VideoPath));
var file = Path.Combine(paths.RawPath, "file.ts");
File.WriteAllText(file, "x");
var unique = svc.GetUniqueFilePath(file);
Assert.NotEqual(file, unique);
Assert.Contains("file-", Path.GetFileName(unique));
}
public void Dispose()
{
try { Directory.Delete(_root, true); } catch { }
}
}
}

View file

@ -0,0 +1,57 @@
using System;
using System.IO;
using System.Threading.Tasks;
using Moq;
using Xunit;
using TwitchArchive.Core.Services;
namespace TwitchArchive.Tests
{
public class ProcessorServiceTests : IDisposable
{
private readonly string _tmp;
public ProcessorServiceTests()
{
_tmp = Path.Combine(Path.GetTempPath(), "ta_proc_" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_tmp);
}
[Fact]
public async Task ProcessRawStream_CallsFfmpeg_CopyMode()
{
var mock = new Mock<IProcessRunner>();
mock.Setup(r => r.RunAsync(It.IsAny<ProcessRunOptions>(), default)).ReturnsAsync(new ProcessRunResult { ExitCode = 0 });
var svc = new ProcessorService(mock.Object);
var raw = Path.Combine(_tmp, "in.ts");
var outp = Path.Combine(_tmp, "out.mp4");
File.WriteAllText(raw, "x");
var ok = await svc.ProcessRawStreamAsync(raw, outp, "best");
Assert.True(ok);
mock.Verify(r => r.RunAsync(It.Is<ProcessRunOptions>(o => o.FileName == "ffmpeg" && o.Arguments.Contains("-c:v copy")), default), Times.Once);
}
[Fact]
public async Task ProcessRawStream_AudioOnly_UsesAac()
{
var mock = new Mock<IProcessRunner>();
mock.Setup(r => r.RunAsync(It.IsAny<ProcessRunOptions>(), default)).ReturnsAsync(new ProcessRunResult { ExitCode = 0 });
var svc = new ProcessorService(mock.Object);
var raw = Path.Combine(_tmp, "in.ts");
var outp = Path.Combine(_tmp, "out.mp4");
File.WriteAllText(raw, "x");
var ok = await svc.ProcessRawStreamAsync(raw, outp, "audio_only");
Assert.True(ok);
mock.Verify(r => r.RunAsync(It.Is<ProcessRunOptions>(o => o.FileName == "ffmpeg" && o.Arguments.Contains("-vn") && o.Arguments.Contains("-c:a aac")), default), Times.Once);
}
public void Dispose()
{
try { Directory.Delete(_tmp, true); } catch { }
}
}
}

View file

@ -0,0 +1,86 @@
using System;
using TwitchArchive.Core.Recovery;
using Xunit;
namespace TwitchArchive.Tests
{
public class RecoveryPolicyTests
{
[Fact]
public void Monitoring_WhenNotLive_SleepsRefresh()
{
var policy = new RecoveryPolicy(TimeSpan.FromSeconds(60));
var now = DateTime.UtcNow;
var decision = policy.Tick(now, isLive: false, networkError: false);
Assert.Equal(RecoveryAction.SleepOnly, decision.Action);
Assert.Equal(TimeSpan.FromSeconds(60), decision.Sleep);
Assert.Equal(RecoveryState.Monitoring, policy.CurrentState);
}
[Fact]
public void WhenLive_StartsRecording()
{
var policy = new RecoveryPolicy(TimeSpan.FromSeconds(60));
var now = DateTime.UtcNow;
var decision = policy.Tick(now, isLive: true, networkError: false);
Assert.Equal(RecoveryAction.StartRecording, decision.Action);
Assert.Equal(TimeSpan.Zero, decision.Sleep);
Assert.Equal(RecoveryState.Recording, policy.CurrentState);
}
[Fact]
public void RecordingToFastReconnect_ThenToSlowReconnect()
{
var policy = new RecoveryPolicy(TimeSpan.FromSeconds(60));
var t0 = DateTime.UtcNow;
// Start recording
var d1 = policy.Tick(t0, isLive: true, networkError: false);
Assert.Equal(RecoveryAction.StartRecording, d1.Action);
Assert.Equal(RecoveryState.Recording, policy.CurrentState);
// Recording ended -> fast reconnect begins
var t1 = t0.AddMinutes(10); // time advanced
var d2 = policy.Tick(t1, isLive: false, networkError: false);
Assert.Equal(RecoveryAction.SleepOnly, d2.Action);
Assert.Equal(TimeSpan.FromSeconds(10), d2.Sleep);
Assert.Equal(RecoveryState.FastReconnect, policy.CurrentState);
// Still within fast reconnect window -> continue fast polling
var t2 = t1.AddSeconds(30);
var d3 = policy.Tick(t2, isLive: false, networkError: false);
Assert.Equal(RecoveryAction.SleepOnly, d3.Action);
Assert.Equal(TimeSpan.FromSeconds(10), d3.Sleep);
Assert.Equal(RecoveryState.FastReconnect, policy.CurrentState);
// After fast window expires -> move to slow reconnect and start processing
var t3 = t1.AddMinutes(3); // beyond 2-minute window
var d4 = policy.Tick(t3, isLive: false, networkError: false);
Assert.Equal(RecoveryAction.StartProcessing, d4.Action);
Assert.Equal(TimeSpan.FromSeconds(60), d4.Sleep);
Assert.Equal(RecoveryState.SlowReconnect, policy.CurrentState);
}
[Fact]
public void NetworkFault_Backoff_IncreasesAndRecovers()
{
var policy = new RecoveryPolicy(TimeSpan.FromSeconds(60));
var now = DateTime.UtcNow;
var d1 = policy.Tick(now, isLive: false, networkError: true);
Assert.Equal(RecoveryAction.SleepOnly, d1.Action);
Assert.Equal(TimeSpan.FromSeconds(30), d1.Sleep);
Assert.Equal(RecoveryState.NetworkFault, policy.CurrentState);
var d2 = policy.Tick(now.AddSeconds(1), isLive: false, networkError: true);
Assert.Equal(RecoveryAction.SleepOnly, d2.Action);
Assert.True(d2.Sleep >= TimeSpan.FromSeconds(30));
// Recover
var d3 = policy.Tick(now.AddMinutes(1), isLive: false, networkError: false);
Assert.Equal(RecoveryAction.SleepOnly, d3.Action);
Assert.Equal(TimeSpan.FromSeconds(60), d3.Sleep);
Assert.Equal(RecoveryState.Monitoring, policy.CurrentState);
}
}
}

View file

@ -0,0 +1,49 @@
using System;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
using TwitchArchive.Core.Persistence;
using TwitchArchive.Core.Persistence.Models;
using Xunit;
namespace TwitchArchive.Tests
{
public class SessionRepositoryTests
{
private ArchiveDbContext CreateContext()
{
var opts = new DbContextOptionsBuilder<ArchiveDbContext>()
.UseInMemoryDatabase("ta_test_db_" + Guid.NewGuid().ToString("N"))
.Options;
return new ArchiveDbContext(opts);
}
private class InMemoryFactory : IDbContextFactory<ArchiveDbContext>
{
private readonly DbContextOptions<ArchiveDbContext> _opts;
public InMemoryFactory(DbContextOptions<ArchiveDbContext> opts) { _opts = opts; }
public ArchiveDbContext CreateDbContext() => new ArchiveDbContext(_opts);
}
[Fact]
public async Task AddSessionAndJob_AndQuery()
{
var opts = new DbContextOptionsBuilder<ArchiveDbContext>()
.UseInMemoryDatabase("ta_test_db_" + Guid.NewGuid().ToString("N"))
.Options;
var factory = new InMemoryFactory(opts);
var repo = new SessionRepository(factory);
var session = await repo.CreateSessionAsync("charlie", "", DateTime.UtcNow);
Assert.NotNull(session);
var job = await repo.CreateJobAsync(session.Id, "Recording", DateTime.UtcNow);
Assert.NotNull(job);
var recent = await repo.GetRecentSessionsAsync(10);
Assert.Contains(recent, s => s.Id == session.Id);
job.Status = "Completed";
await repo.UpdateJobAsync(job);
var jobs = await repo.GetJobsForSessionAsync(session.Id);
Assert.Contains(jobs, j => j.Status == "Completed");
}
}
}

View file

@ -0,0 +1,67 @@
using System;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Threading;
using System.Threading.Tasks;
using System.Text.Json;
using Xunit;
using TwitchArchive.Core.Api;
namespace TwitchArchive.Tests
{
class FakeHandler : HttpMessageHandler
{
private readonly Func<HttpRequestMessage, HttpResponseMessage> _responder;
public FakeHandler(Func<HttpRequestMessage, HttpResponseMessage> responder) => _responder = responder;
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
=> Task.FromResult(_responder(request));
}
public class TwitchApiClientTests
{
[Fact]
public async Task GetOauthToken_CachesAndReturnsToken()
{
Environment.SetEnvironmentVariable("CLIENT-ID", "x");
Environment.SetEnvironmentVariable("CLIENT-SECRET", "y");
var handler = new FakeHandler(req =>
{
var obj = JsonSerializer.Serialize(new { access_token = "tok123", expires_in = 3600 });
return new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(obj) };
});
var client = new HttpClient(handler);
var api = new TwitchApiClient(client);
var t1 = await api.GetOauthTokenAsync();
Assert.Equal("tok123", t1);
var t2 = await api.GetOauthTokenAsync();
Assert.Equal("tok123", t2);
}
[Fact]
public async Task GetStreamStatus_ReturnsInfo_WhenLive()
{
var gqlResp = JsonSerializer.Serialize(new { data = new { user = new { stream = new { title = "hi", createdAt = "2020-01-01T00:00:00Z", archiveVideo = (object?)null } } } });
var handler = new FakeHandler(req => new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(gqlResp) });
var client = new HttpClient(handler);
var api = new TwitchApiClient(client);
var info = await api.GetStreamStatusAsync("alice");
Assert.NotNull(info);
Assert.Equal("hi", info!.Title);
}
[Fact]
public async Task GetLatestVod_ReturnsVod_WhenPresent()
{
var vodJson = JsonSerializer.Serialize(new { data = new { user = new { videos = new { edges = new[] { new { node = new { id = "v123", title = "vod", recordedAt = "2020-01-01T00:00:00Z" } } } } } } });
var handler = new FakeHandler(req => new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(vodJson) });
var client = new HttpClient(handler);
var api = new TwitchApiClient(client);
var vod = await api.GetLatestVodAsync("bob");
Assert.NotNull(vod);
Assert.Equal("v123", vod!.Id);
}
}
}

View file

@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<IsPackable>false</IsPackable>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.3" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.0" />
<PackageReference Include="coverlet.collector" Version="8.0.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\TwitchArchive.Core\TwitchArchive.Core.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,14 @@
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Authorization
@using TwitchArchive.Web.Shared
<CascadingAuthenticationState>
<Router AppAssembly="typeof(Program).Assembly">
<Found Context="routeData">
<AuthorizeRouteView RouteData="routeData" DefaultLayout="typeof(MainLayout)" />
</Found>
<NotFound>
<p>Sorry, there's nothing at this address.</p>
</NotFound>
</Router>
</CascadingAuthenticationState>

View file

@ -0,0 +1,20 @@
using Microsoft.AspNetCore.SignalR;
using System.Threading.Tasks;
namespace TwitchArchive.Web.Hubs
{
public class ProcessOutputHub : Hub
{
// methods are server-driven; clients listen on ReceiveLine
public Task JoinStreamerGroup(string streamer)
{
return Groups.AddToGroupAsync(Context.ConnectionId, streamer);
}
public Task LeaveStreamerGroup(string streamer)
{
return Groups.RemoveFromGroupAsync(Context.ConnectionId, streamer);
}
}
}

View file

@ -0,0 +1,29 @@
@page "/config/new"
@inject TwitchArchive.Core.Config.IConfigurationService ConfigService
@inject NavigationManager Nav
<h3>Add Streamer</h3>
<EditForm Model="model" OnValidSubmit="Save">
<div>
<label>Username</label>
<InputText @bind-Value="model.Username" />
</div>
<div>
<label>Enabled</label>
<InputCheckbox @bind-Value="model.Enabled" />
</div>
<button type="submit">Create</button>
</EditForm>
@code {
private TwitchArchive.Core.Config.StreamerConfig model = new() { Enabled = true };
private void Save()
{
model.Username = model.Username?.Trim().ToLowerInvariant() ?? string.Empty;
if (string.IsNullOrWhiteSpace(model.Username)) return;
ConfigService.SaveStreamer(model);
Nav.NavigateTo($"/config/{model.Username}");
}
}

View file

@ -0,0 +1,92 @@
@page "/settings"
@using System.Text.Json
@inject TwitchArchive.Web.Services.IAuthService Auth
<h3>App Settings</h3>
@if (saved)
{
<div class="alert">Saved.</div>
}
<EditForm Model="model" OnValidSubmit="Save">
<div>
<label>Streamlink Path</label>
<InputText @bind-value="model.StreamlinkPath" />
</div>
<div>
<label>FFmpeg Path</label>
<InputText @bind-value="model.FfmpegPath" />
</div>
<div>
<label>TwitchDownloader Path</label>
<InputText @bind-value="model.TwitchDownloaderPath" />
</div>
<div>
<label>Rclone Path</label>
<InputText @bind-value="model.RclonePath" />
</div>
<button type="submit">Save</button>
</EditForm>
<h4>Change Password</h4>
@if (!string.IsNullOrEmpty(pwError))
{
<div class="alert">@pwError</div>
}
<div>
<input type="password" @bind="currentPw" placeholder="Current password" />
<input type="password" @bind="newPw" placeholder="New password" />
<input type="password" @bind="confirmPw" placeholder="Confirm" />
<button @onclick="ChangePassword">Change</button>
</div>
@code {
private TwitchArchive.Core.Config.AppSettings model = new();
private bool saved = false;
private string currentPw = string.Empty;
private string newPw = string.Empty;
private string confirmPw = string.Empty;
private string pwError = string.Empty;
protected override void OnInitialized()
{
Load();
}
private void Load()
{
var file = Path.Combine(AppContext.BaseDirectory, "appsettings.json");
try
{
if (!File.Exists(file)) { model = new(); return; }
var txt = File.ReadAllText(file);
model = JsonSerializer.Deserialize<TwitchArchive.Core.Config.AppSettings>(txt) ?? new TwitchArchive.Core.Config.AppSettings();
}
catch { model = new(); }
}
private void Save()
{
var file = Path.Combine(AppContext.BaseDirectory, "appsettings.json");
try
{
var txt = JsonSerializer.Serialize(model, new JsonSerializerOptions { WriteIndented = true });
File.WriteAllText(file, txt);
saved = true;
Auth.Refresh();
}
catch { }
}
private void ChangePassword()
{
pwError = string.Empty;
if (!Auth.ValidatePassword(currentPw)) { pwError = "Current password incorrect"; return; }
if (string.IsNullOrWhiteSpace(newPw)) { pwError = "New password required"; return; }
if (newPw != confirmPw) { pwError = "Passwords do not match"; return; }
var hash = BCrypt.Net.BCrypt.HashPassword(newPw);
Auth.SetPasswordHash(hash);
pwError = string.Empty;
}
}

View file

@ -0,0 +1,45 @@
@page "/config/global"
@inject TwitchArchive.Core.Config.IConfigurationService ConfigService
<h3>Global Configuration</h3>
@if (saved)
{
<div class="alert">Saved.</div>
}
<EditForm Model="model" OnValidSubmit="Save">
<div>
<label>Archive Root</label>
<InputText @bind-value="model.ArchiveRoot" />
</div>
<div>
<label>Streamlink Path</label>
<InputText @bind-value="model.StreamlinkPath" />
</div>
<div>
<label>FFmpeg Path</label>
<InputText @bind-value="model.FfmpegPath" />
</div>
<div>
<label>Default Quality</label>
<InputText @bind-value="model.DefaultQuality" />
</div>
<button type="submit">Save</button>
</EditForm>
@code {
private TwitchArchive.Core.Config.GlobalConfig model = new();
private bool saved = false;
protected override void OnInitialized()
{
model = ConfigService.LoadGlobal();
}
private void Save()
{
ConfigService.SaveGlobal(model);
saved = true;
}
}

View file

@ -0,0 +1,64 @@
@page "/"
@inject TwitchArchive.Core.Workers.StreamWorkerManager WorkerManager
@inject TwitchArchive.Web.Services.SessionCacheService SessionCache
<h2>Dashboard</h2>
<div class="cards">
@foreach (var s in streamers)
{
<div class="card">
<div class="card-header">
<a href="/streamer/@s">@s</a>
<span class="badge">@(WorkerManager.IsRunning(s) ? "Live" : "Offline")</span>
</div>
<div class="card-body">
<div>Last session: @(lastStarts.ContainsKey(s) ? lastStarts[s].ToLocalTime().ToString() : "-")</div>
<div class="actions">
<button @onclick="() => Start(s)">Start</button>
<button @onclick="() => Stop(s)">Stop</button>
</div>
</div>
</div>
}
</div>
@code {
private List<string> streamers = new();
private Dictionary<string, DateTime> lastStarts = new();
private void OnCacheUpdatedHandler()
{
_ = InvokeAsync(() => {
lastStarts = SessionCache.GetSnapshot();
StateHasChanged();
});
}
protected override async Task OnInitializedAsync()
{
LoadStreamers();
lastStarts = SessionCache.GetSnapshot();
SessionCache.Updated += OnCacheUpdatedHandler;
}
private void LoadStreamers()
{
var cfgDir = Path.Combine(Environment.CurrentDirectory, "config", "streamers");
if (Directory.Exists(cfgDir))
{
streamers = Directory.GetFiles(cfgDir, "*.json").Select(f => Path.GetFileNameWithoutExtension(f)).ToList();
}
}
// Index reads from the singleton SessionCacheService; updates are pushed via the Updated event.
private void Start(string u) { WorkerManager.StartWorker(u); }
private async Task Stop(string u) { await WorkerManager.StopWorkerAsync(u); }
public async ValueTask DisposeAsync()
{
SessionCache.Updated -= OnCacheUpdatedHandler;
await Task.CompletedTask;
}
}

View file

@ -0,0 +1,20 @@
@page "/login"
@attribute [AllowAnonymous]
@using Microsoft.AspNetCore.Components
<h3>Login</h3>
@if (error)
{
<div class="alert">Invalid password</div>
}
<form method="post" action="/auth/login">
<input type="password" name="password" placeholder="Password" />
<button type="submit">Login</button>
</form>
@code {
[Parameter]
public bool error { get; set; }
}

View file

@ -0,0 +1,37 @@
@page "/monitor"
@inject TwitchArchive.Core.Workers.StreamWorkerManager WorkerManager
@inject TwitchArchive.Core.Services.IProcessOutputStore OutputStore
@using TwitchArchive.Web.Shared
<h2>Streamer Monitor</h2>
<div>
<input @bind="username" placeholder="username" />
<button @onclick="Start">Start</button>
<button @onclick="Stop">Stop</button>
</div>
<div style="margin-top:1rem">
<strong>Status:</strong> @status
</div>
<ProcessConsole Streamer="username" />
@code {
private string username = "hackerling";
private string status = "idle";
private void Start()
{
if (string.IsNullOrWhiteSpace(username)) return;
WorkerManager.StartWorker(username);
status = WorkerManager.IsRunning(username) ? "running" : "starting";
}
private async Task Stop()
{
if (string.IsNullOrWhiteSpace(username)) return;
await WorkerManager.StopWorkerAsync(username);
status = WorkerManager.IsRunning(username) ? "running" : "stopped";
}
}

View file

@ -0,0 +1,73 @@
@page "/sessions"
@using TwitchArchive.Core.Persistence.Models
@inject TwitchArchive.Core.Persistence.ISessionRepository SessionRepo
<h3>Sessions</h3>
<button @onclick="Refresh">Refresh</button>
<table class="table">
<thead>
<tr><th>Id</th><th>Streamer</th><th>Started</th><th>Ended</th><th>Status</th><th>Jobs</th></tr>
</thead>
<tbody>
@foreach (var s in sessions)
{
<tr>
<td>@s.Id</td>
<td>@s.StreamerUsername</td>
<td>@s.StartedAt.ToLocalTime()</td>
<td>@(s.EndedAt?.ToLocalTime().ToString() ?? "-")</td>
<td>@s.Status</td>
<td><button @onclick="() => ToggleJobs(s.Id)">Toggle</button></td>
</tr>
@if (expandedSession == s.Id)
{
<tr><td colspan="6">
<ul>
@if (jobs?.Any() ?? false)
{
@foreach (var j in jobs)
{
<li>@j.Id - @j.JobType - @j.Status - @j.StartedAt.ToLocalTime() - @(j.FilePath ?? "")</li>
}
}
else
{
<li>No jobs</li>
}
</ul>
</td></tr>
}
}
</tbody>
</table>
@code {
private List<StreamSession> sessions = new();
private List<ArchiveJob>? jobs;
private long? expandedSession;
protected override async Task OnInitializedAsync()
{
await Refresh();
}
private async Task Refresh()
{
sessions = await SessionRepo.GetRecentSessionsAsync(50);
StateHasChanged();
}
private async Task ToggleJobs(long sessionId)
{
if (expandedSession == sessionId)
{
expandedSession = null;
jobs = null;
return;
}
jobs = await SessionRepo.GetJobsForSessionAsync(sessionId);
expandedSession = sessionId;
}
}

View file

@ -0,0 +1,71 @@
@page "/config/{Username}"
@inject TwitchArchive.Core.Config.IConfigurationService ConfigService
@inject NavigationManager Nav
<h3>Streamer Config: @Username</h3>
<EditForm Model="model" OnValidSubmit="Save">
<div>
<label>Enabled</label>
<InputCheckbox @bind="model.Enabled" />
</div>
<div>
<label>Quality</label>
<InputCheckbox @bind="overrideQuality" /> Override
<InputText @bind="model.Quality" disabled="@(!overrideQuality)" placeholder="@(global?.DefaultQuality ?? "")" />
</div>
<div>
<label>Upload to Cloud</label>
<InputCheckbox @bind="overrideUpload" /> Override
<InputCheckbox @bind="model.UploadToCloud" disabled="@(!overrideUpload)" />
</div>
<div>
<label>Upload Destination</label>
<InputText @bind="model.UploadDestination" />
</div>
<div>
<label>Streamlink Path (override)</label>
<InputCheckbox @bind="overrideStreamlink" /> Override
<InputText @bind="model.StreamlinkPath" disabled="@(!overrideStreamlink)" placeholder="@(global?.StreamlinkPath ?? "")" />
</div>
<button type="submit">Save</button>
<button type="button" @onclick="Delete">Delete</button>
</EditForm>
@code {
[Parameter]
public string Username { get; set; } = string.Empty;
private TwitchArchive.Core.Config.StreamerConfig model = new();
private TwitchArchive.Core.Config.GlobalConfig? global;
private bool overrideQuality = false;
private bool overrideUpload = false;
private bool overrideStreamlink = false;
protected override void OnInitialized()
{
global = ConfigService.LoadGlobal();
var s = ConfigService.LoadStreamer(Username);
if (s != null) model = s;
}
private void Save()
{
model.Username = Username;
if (!overrideQuality) model.Quality = null;
if (!overrideUpload) model.UploadToCloud = null;
if (!overrideStreamlink) model.StreamlinkPath = null;
ConfigService.SaveStreamer(model);
}
private void Delete()
{
ConfigService.DeleteStreamer(Username);
Nav.NavigateTo("/");
}
}

View file

@ -0,0 +1,22 @@
@page "/streamer/{Username}"
@using TwitchArchive.Core.Workers
@inject StreamWorkerManager WorkerManager
<h2>Streamer: @Username</h2>
<div class="streamer-header">
<span class="status">Status: <strong>@(WorkerManager.IsRunning(Username) ? "Live" : "Offline")</strong></span>
</div>
<div class="pipeline">
<div class="step done">Record</div>
<div class="step">Process</div>
<div class="step">Upload</div>
</div>
<ProcessConsole Streamer="Username" />
@code {
[Parameter]
public string Username { get; set; } = string.Empty;
}

View file

@ -0,0 +1,19 @@
@page "/"
@namespace TwitchArchive.Web.Pages
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Twitch Archive</title>
<base href="~/" />
<link href="css/app.css" rel="stylesheet" />
</head>
<body>
<app>
<component type="typeof(App)" render-mode="ServerPrerendered" />
</app>
<script src="_framework/blazor.server.js"></script>
</body>
</html>

View file

@ -0,0 +1,104 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.EntityFrameworkCore;
using TwitchArchive.Core.Services;
using TwitchArchive.Core.Config;
using TwitchArchive.Core.Persistence;
using TwitchArchive.Web.Hubs;
using TwitchArchive.Web.Services;
var builder = WebApplication.CreateBuilder(args);
// Add services
builder.Services.AddRazorPages();
builder.Services.AddServerSideBlazor();
builder.Services.AddSignalR();
// Authentication
builder.Services.AddSingleton<TwitchArchive.Web.Services.IAuthService, TwitchArchive.Web.Services.AuthService>();
builder.Services.AddAuthentication(options =>
{
options.DefaultScheme = Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationDefaults.AuthenticationScheme;
})
.AddCookie(options => { options.LoginPath = "/login"; });
// Register Core services
builder.Services.AddSingleton<IProcessRunner, ProcessRunner>();
builder.Services.AddSingleton<IProcessOutputStore, ProcessOutputStore>();
builder.Services.AddSingleton<FileManagerService>();
builder.Services.AddHttpClient<TwitchArchive.Core.Api.ITwitchApiClient, TwitchArchive.Core.Api.TwitchApiClient>(client => { /* base config if needed */ });
builder.Services.AddSingleton<TwitchArchive.Core.Monitoring.ILiveChecker, TwitchArchive.Core.Monitoring.TwitchLiveChecker>();
builder.Services.AddSingleton<TwitchArchive.Core.Workers.StreamWorkerManager>();
builder.Services.AddSingleton<IDownloaderService, DownloaderService>();
builder.Services.AddSingleton<IProcessorService, ProcessorService>();
// Configuration service for global + per-streamer JSON files in ./config
builder.Services.AddScoped<IConfigurationService, ConfigurationService>();
// Broadcaster forwards output store events to the SignalR hub
builder.Services.AddSingleton<ProcessOutputBroadcaster>();
// SQLite DB (file in app folder)
var conn = "Data Source=archive.db";
// Provide a factory for creating DbContext instances for background work
builder.Services.AddDbContextFactory<ArchiveDbContext>(opt => opt.UseSqlite(conn));
// persistence
builder.Services.AddScoped<TwitchArchive.Core.Persistence.ISessionRepository, TwitchArchive.Core.Persistence.SessionRepository>();
// Session cache and background refresh
builder.Services.AddSingleton<TwitchArchive.Web.Services.SessionCacheService>();
builder.Services.AddHostedService<TwitchArchive.Web.Services.SessionRefreshHostedService>();
var app = builder.Build();
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Error");
}
// Ensure DB schema exists (creates DB when missing)
using (var scope = app.Services.CreateScope())
{
var factory = scope.ServiceProvider.GetRequiredService<IDbContextFactory<ArchiveDbContext>>();
try
{
using var db = factory.CreateDbContext();
db.Database.EnsureCreated();
}
catch { }
}
app.UseStaticFiles();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
// login endpoint
app.MapPost("/auth/login", async (Microsoft.AspNetCore.Http.HttpContext http, TwitchArchive.Web.Services.IAuthService auth) =>
{
try
{
var form = await http.Request.ReadFormAsync();
var pwd = form["password"].ToString();
if (auth.ValidatePassword(pwd))
{
var claims = new[] { new System.Security.Claims.Claim(System.Security.Claims.ClaimTypes.Name, "admin") };
var id = new System.Security.Claims.ClaimsIdentity(claims, Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationDefaults.AuthenticationScheme);
var principal = new System.Security.Claims.ClaimsPrincipal(id);
await http.SignInAsync(Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationDefaults.AuthenticationScheme, principal);
http.Response.Redirect("/");
return;
}
}
catch { }
http.Response.Redirect("/login?error=1");
});
app.MapBlazorHub();
app.MapHub<ProcessOutputHub>("/processOutputHub");
app.MapFallbackToPage("/_Host");
app.Run();

View file

@ -0,0 +1,12 @@
{
"profiles": {
"TwitchArchive.Web": {
"commandName": "Project",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"applicationUrl": "https://localhost:64466;http://localhost:64467"
}
}
}

View file

@ -0,0 +1,50 @@
using System;
using System.IO;
using System.Text.Json;
using TwitchArchive.Core.Config;
namespace TwitchArchive.Web.Services
{
public class AuthService : IAuthService
{
private readonly string _file;
private AppSettings _settings = new();
public AuthService()
{
_file = Path.Combine(AppContext.BaseDirectory, "appsettings.json");
Refresh();
}
public void Refresh()
{
try
{
if (!File.Exists(_file)) { _settings = new AppSettings(); return; }
var txt = File.ReadAllText(_file);
_settings = JsonSerializer.Deserialize<AppSettings>(txt) ?? new AppSettings();
}
catch { _settings = new AppSettings(); }
}
public bool ValidatePassword(string plain)
{
if (string.IsNullOrWhiteSpace(_settings.PasswordHash)) return true;
try { return BCrypt.Net.BCrypt.Verify(plain ?? string.Empty, _settings.PasswordHash); }
catch { return false; }
}
public void SetPasswordHash(string hash)
{
try
{
if (string.IsNullOrWhiteSpace(_file)) return;
_settings.PasswordHash = hash;
var txt = JsonSerializer.Serialize(_settings, new JsonSerializerOptions { WriteIndented = true });
File.WriteAllText(_file, txt);
Refresh();
}
catch { }
}
}
}

View file

@ -0,0 +1,11 @@
using System.Threading.Tasks;
namespace TwitchArchive.Web.Services
{
public interface IAuthService
{
bool ValidatePassword(string plain);
void Refresh();
void SetPasswordHash(string hash);
}
}

View file

@ -0,0 +1,35 @@
using Microsoft.AspNetCore.SignalR;
using System;
using TwitchArchive.Core.Services;
using TwitchArchive.Web.Hubs;
namespace TwitchArchive.Web.Services
{
public class ProcessOutputBroadcaster : IDisposable
{
private readonly IProcessOutputStore _store;
private readonly IHubContext<ProcessOutputHub> _hub;
public ProcessOutputBroadcaster(IProcessOutputStore store, IHubContext<ProcessOutputHub> hub)
{
_store = store;
_hub = hub;
_store.LineAppended += OnLineAppended;
}
private void OnLineAppended(string streamer, OutputLine line)
{
try
{
// send to clients; clients should listen on "ReceiveLine"
_hub.Clients.Group(streamer).SendAsync("ReceiveLine", streamer, line);
}
catch { }
}
public void Dispose()
{
_store.LineAppended -= OnLineAppended;
}
}
}

View file

@ -0,0 +1,38 @@
using System;
using System.Collections.Generic;
using TwitchArchive.Core.Persistence.Models;
namespace TwitchArchive.Web.Services
{
public class SessionCacheService
{
private readonly object _lock = new();
private Dictionary<string, DateTime> _snapshot = new();
public event Action? Updated;
public void Update(IEnumerable<StreamSession> sessions)
{
var next = new Dictionary<string, DateTime>();
foreach (var s in sessions)
{
if (!next.ContainsKey(s.StreamerUsername)) next[s.StreamerUsername] = s.StartedAt;
}
lock (_lock)
{
_snapshot = next;
}
Updated?.Invoke();
}
public Dictionary<string, DateTime> GetSnapshot()
{
lock (_lock)
{
return new Dictionary<string, DateTime>(_snapshot);
}
}
}
}

View file

@ -0,0 +1,50 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using TwitchArchive.Core.Persistence;
namespace TwitchArchive.Web.Services
{
public class SessionRefreshHostedService : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly SessionCacheService _cache;
private readonly ILogger<SessionRefreshHostedService> _logger;
private readonly TimeSpan _interval;
public SessionRefreshHostedService(IServiceScopeFactory scopeFactory, SessionCacheService cache, ILogger<SessionRefreshHostedService> logger)
{
_scopeFactory = scopeFactory ?? throw new ArgumentNullException(nameof(scopeFactory));
_cache = cache ?? throw new ArgumentNullException(nameof(cache));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_interval = TimeSpan.FromSeconds(10);
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(_interval);
try
{
while (await timer.WaitForNextTickAsync(stoppingToken))
{
try
{
using var scope = _scopeFactory.CreateScope();
var repo = scope.ServiceProvider.GetRequiredService<ISessionRepository>();
var sessions = await repo.GetRecentSessionsAsync(200, stoppingToken).ConfigureAwait(false);
_cache.Update(sessions);
}
catch (OperationCanceledException) { break; }
catch (Exception ex)
{
_logger.LogError(ex, "Error while refreshing sessions");
}
}
}
catch (OperationCanceledException) { }
}
}
}

View file

@ -0,0 +1,21 @@
@inherits LayoutComponentBase
<header class="topbar">
<button class="hamburger" @onclick="ToggleSidebar">☰</button>
<h1 class="title">Twitch Archive</h1>
</header>
<div class="page">
<nav class="sidebar @(sidebarCollapsed ? "collapsed" : "")">
<h3>Twitch Archive</h3>
<NavLink href="/" class="nav-link">Dashboard</NavLink>
<NavLink href="/config/global" class="nav-link">Global Config</NavLink>
<NavLink href="/settings" class="nav-link">Settings</NavLink>
</nav>
<main class="main">
@Body
</main>
</div>
@code {
bool sidebarCollapsed;
void ToggleSidebar() => sidebarCollapsed = !sidebarCollapsed;
}

View file

@ -0,0 +1,93 @@
@using TwitchArchive.Core.Services
@using Microsoft.AspNetCore.Components
@using Microsoft.AspNetCore.SignalR.Client
@using System.Linq
@inject IProcessOutputStore OutputStore
@inject NavigationManager Navigation
@inherits ComponentBase
<div class="console" style="background:#111;color:#eee;padding:8px;height:400px;overflow:auto;font-family:monospace;white-space:pre-wrap;">
@foreach (var line in lines)
{
<div style="color:@(line.IsError ? "#ff8888" : "#ddd")">@line.TimestampUtc.ToLocalTime().ToString("HH:mm:ss") - @line.Line</div>
}
</div>
@code {
[Parameter]
public string Streamer { get; set; } = string.Empty;
private List<OutputLine> lines = new();
private HubConnection? hubConnection;
private string? _currentGroup;
protected override async Task OnInitializedAsync()
{
OutputStore.LineAppended += OnLineAppended;
hubConnection = new HubConnectionBuilder()
.WithUrl(Navigation.ToAbsoluteUri("/processOutputHub"))
.WithAutomaticReconnect()
.Build();
hubConnection.On<string, OutputLine>("ReceiveLine", (streamer, line) =>
{
if (!string.Equals(streamer, Streamer, StringComparison.OrdinalIgnoreCase)) return;
// avoid duplicates
if (lines.Any(l => l.TimestampUtc == line.TimestampUtc && l.Line == line.Line)) return;
lines.Add(line);
if (lines.Count > 1000) lines.RemoveAt(0);
InvokeAsync(StateHasChanged);
});
await hubConnection.StartAsync();
if (!string.IsNullOrWhiteSpace(Streamer))
{
await hubConnection.SendAsync("JoinStreamerGroup", Streamer);
_currentGroup = Streamer;
}
await base.OnInitializedAsync();
}
protected override async Task OnParametersSetAsync()
{
if (hubConnection == null) return;
if (!string.IsNullOrWhiteSpace(_currentGroup) && !string.Equals(_currentGroup, Streamer, StringComparison.OrdinalIgnoreCase))
{
try { await hubConnection.SendAsync("LeaveStreamerGroup", _currentGroup); } catch { }
_currentGroup = null;
}
if (!string.IsNullOrWhiteSpace(Streamer) && !string.Equals(_currentGroup, Streamer, StringComparison.OrdinalIgnoreCase))
{
try { await hubConnection.SendAsync("JoinStreamerGroup", Streamer); _currentGroup = Streamer; } catch { }
}
await base.OnParametersSetAsync();
}
private void OnLineAppended(string streamer, OutputLine line)
{
if (!string.Equals(streamer, Streamer, StringComparison.OrdinalIgnoreCase)) return;
lines.Add(line);
if (lines.Count > 1000) lines.RemoveAt(0);
InvokeAsync(StateHasChanged);
}
public async ValueTask DisposeAsync()
{
OutputStore.LineAppended -= OnLineAppended;
if (hubConnection != null)
{
try
{
if (!string.IsNullOrWhiteSpace(_currentGroup)) await hubConnection.SendAsync("LeaveStreamerGroup", _currentGroup);
}
catch { }
try { await hubConnection.StopAsync(); } catch { }
try { await hubConnection.DisposeAsync(); } catch { }
hubConnection = null;
}
}
}

View file

@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\TwitchArchive.Core\TwitchArchive.Core.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="10.0.3" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.3" />
<PackageReference Include="NLog.Web.AspNetCore" Version="6.1.1" />
<PackageReference Include="BCrypt.Net-Next" Version="4.*" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,10 @@
@using System
@using System.Net.Http
@using Microsoft.AspNetCore.Components
@using Microsoft.AspNetCore.Components.Web
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Authorization
@using TwitchArchive.Core
@using TwitchArchive.Core.Services
@using TwitchArchive.Web.Shared

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,16 @@
/* App layout styles for Twitch Archive */
.page { display:flex; height:100vh; }
.sidebar { width:220px; flex-shrink:0; background:#1e1e2e; color:#cdd6f4; overflow-y:auto; }
.sidebar.collapsed { display:none; }
.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; }
.topbar { display:none; background:#111; color:#fff; padding:0.6rem 1rem; align-items:center; }
.topbar .hamburger { font-size:1.2rem; margin-right:1rem; background:transparent; border:none; color:inherit; }
@media(max-width:768px) {
.sidebar { display:none; }
.topbar { display:flex; }
}
@media(min-width:769px) {
.topbar { display:none; }
}

View file

@ -228,7 +228,11 @@ class TwitchArchive:
elif self.os == 'linux': subprocess.call([bin_path+"/TwitchDownloaderCLI", "chatupdate", "-i", chat_json_path, "-o", chat_html_path, "-E", "--temp-path", f"{bin_path}/temp"]) elif self.os == 'linux': subprocess.call([bin_path+"/TwitchDownloaderCLI", "chatupdate", "-i", chat_json_path, "-o", chat_html_path, "-E", "--temp-path", f"{bin_path}/temp"])
if self.username == 'KalathrasLolweapon': if self.username == 'KalathrasLolweapon':
print('Uploading html chat to b2 bucket') print('Uploading html chat to b2 bucket')
subprocess.call(['rclone', 'copy', chat_html_path, 'b2:kala-help/chat_html', '--progress']) proc = subprocess.Popen(['rclone', 'copy', chat_html_path, 'b2:kala-help/chat_html', '--progress'], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
if proc.stdout:
for line in proc.stdout:
print(line, end='')
proc.wait()
except Exception as e: except Exception as e:
print('A ERROR has ocurred and chat will need to be updated to html manually') print('A ERROR has ocurred and chat will need to be updated to html manually')
@ -236,9 +240,22 @@ class TwitchArchive:
print('Uploading files:') print('Uploading files:')
if self.os == 'windows': if self.os == 'windows':
if self.username == 'KalathrasLolweapon': if self.username == 'KalathrasLolweapon':
subprocess.call(['rclone', 'copy', str(pathlib.Path(__file__).parent.resolve())+'/VODS', 'GD:VODS', '--progress']) proc = subprocess.Popen(['rclone', 'copy', str(pathlib.Path(__file__).parent.resolve())+'/VODS', 'GD:VODS', '--progress'], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
subprocess.call(['rclone', 'copy', str(pathlib.Path(__file__).parent.resolve())+'/Chat', 'GD:Chat', '--progress']) if proc.stdout:
else:subprocess.call(['rclone', 'copy', str(pathlib.Path(__file__).parent.resolve())+'/VODS', 'GD:VODS', '--progress']) for line in proc.stdout:
print(line, end='')
proc.wait()
proc = subprocess.Popen(['rclone', 'copy', str(pathlib.Path(__file__).parent.resolve())+'/Chat', 'GD:Chat', '--progress'], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
if proc.stdout:
for line in proc.stdout:
print(line, end='')
proc.wait()
else:
proc = subprocess.Popen(['rclone', 'copy', str(pathlib.Path(__file__).parent.resolve())+'/VODS', 'GD:VODS', '--progress'], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
if proc.stdout:
for line in proc.stdout:
print(line, end='')
proc.wait()
elif self.os == 'linux':subprocess.call([bin_path+'/upload.sh', str(pathlib.Path(self.root_path).resolve()),self.username]) elif self.os == 'linux':subprocess.call([bin_path+'/upload.sh', str(pathlib.Path(self.root_path).resolve()),self.username])
if self.deleteFiles == 1: if self.deleteFiles == 1:

File diff suppressed because it is too large Load diff