Refactor code structure for improved readability and maintainability
This commit is contained in:
parent
b47641feaa
commit
4f488bae45
78 changed files with 3309 additions and 1570 deletions
77
.github/instructions/blazor.instructions.md
vendored
Normal file
77
.github/instructions/blazor.instructions.md
vendored
Normal 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.
|
||||
46
.gitignore
vendored
46
.gitignore
vendored
|
|
@ -21,4 +21,48 @@ venv3/**
|
|||
.gitignore
|
||||
bin/**
|
||||
\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
32
Twitch-Archive-2.sln
Normal 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
214
UpgradePlan.md
Normal 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
78
UpgradePlan2.md
Normal 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
18
dotnet/Dockerfile
Normal 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
14
dotnet/README.md
Normal 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
33
dotnet/TwitchArchive.sln
Normal 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
11
dotnet/docker-compose.yml
Normal 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
|
||||
17
dotnet/src/TwitchArchive.Core/Api/ITwitchApiClient.cs
Normal file
17
dotnet/src/TwitchArchive.Core/Api/ITwitchApiClient.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
111
dotnet/src/TwitchArchive.Core/Api/TwitchApiClient.cs
Normal file
111
dotnet/src/TwitchArchive.Core/Api/TwitchApiClient.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
22
dotnet/src/TwitchArchive.Core/Config/AppSettings.cs
Normal file
22
dotnet/src/TwitchArchive.Core/Config/AppSettings.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
88
dotnet/src/TwitchArchive.Core/Config/ConfigurationService.cs
Normal file
88
dotnet/src/TwitchArchive.Core/Config/ConfigurationService.cs
Normal 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 { }
|
||||
}
|
||||
}
|
||||
}
|
||||
36
dotnet/src/TwitchArchive.Core/Config/EffectiveConfig.cs
Normal file
36
dotnet/src/TwitchArchive.Core/Config/EffectiveConfig.cs
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
37
dotnet/src/TwitchArchive.Core/Config/GlobalConfig.cs
Normal file
37
dotnet/src/TwitchArchive.Core/Config/GlobalConfig.cs
Normal 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";
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
25
dotnet/src/TwitchArchive.Core/Config/StreamerConfig.cs
Normal file
25
dotnet/src/TwitchArchive.Core/Config/StreamerConfig.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
35
dotnet/src/TwitchArchive.Core/Config/ToolPathResolver.cs
Normal file
35
dotnet/src/TwitchArchive.Core/Config/ToolPathResolver.cs
Normal 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";
|
||||
}
|
||||
}
|
||||
}
|
||||
17
dotnet/src/TwitchArchive.Core/Monitoring/DummyLiveChecker.cs
Normal file
17
dotnet/src/TwitchArchive.Core/Monitoring/DummyLiveChecker.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
13
dotnet/src/TwitchArchive.Core/Monitoring/ILiveChecker.cs
Normal file
13
dotnet/src/TwitchArchive.Core/Monitoring/ILiveChecker.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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!;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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; }
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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; }
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
131
dotnet/src/TwitchArchive.Core/Recovery/RecoveryPolicy.cs
Normal file
131
dotnet/src/TwitchArchive.Core/Recovery/RecoveryPolicy.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
50
dotnet/src/TwitchArchive.Core/Services/DownloaderService.cs
Normal file
50
dotnet/src/TwitchArchive.Core/Services/DownloaderService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
52
dotnet/src/TwitchArchive.Core/Services/FileManagerService.cs
Normal file
52
dotnet/src/TwitchArchive.Core/Services/FileManagerService.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
12
dotnet/src/TwitchArchive.Core/Services/IDownloaderService.cs
Normal file
12
dotnet/src/TwitchArchive.Core/Services/IDownloaderService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
31
dotnet/src/TwitchArchive.Core/Services/IProcessRunner.cs
Normal file
31
dotnet/src/TwitchArchive.Core/Services/IProcessRunner.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
11
dotnet/src/TwitchArchive.Core/Services/IProcessorService.cs
Normal file
11
dotnet/src/TwitchArchive.Core/Services/IProcessorService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
59
dotnet/src/TwitchArchive.Core/Services/ProcessOutputStore.cs
Normal file
59
dotnet/src/TwitchArchive.Core/Services/ProcessOutputStore.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
101
dotnet/src/TwitchArchive.Core/Services/ProcessRunner.cs
Normal file
101
dotnet/src/TwitchArchive.Core/Services/ProcessRunner.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
63
dotnet/src/TwitchArchive.Core/Services/ProcessorService.cs
Normal file
63
dotnet/src/TwitchArchive.Core/Services/ProcessorService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
18
dotnet/src/TwitchArchive.Core/TwitchArchive.Core.csproj
Normal file
18
dotnet/src/TwitchArchive.Core/TwitchArchive.Core.csproj
Normal 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>
|
||||
178
dotnet/src/TwitchArchive.Core/Workers/StreamWorker.cs
Normal file
178
dotnet/src/TwitchArchive.Core/Workers/StreamWorker.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
51
dotnet/src/TwitchArchive.Core/Workers/StreamWorkerManager.cs
Normal file
51
dotnet/src/TwitchArchive.Core/Workers/StreamWorkerManager.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
39
dotnet/src/TwitchArchive.Tests/ConfigurationServiceTests.cs
Normal file
39
dotnet/src/TwitchArchive.Tests/ConfigurationServiceTests.cs
Normal 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 { }
|
||||
}
|
||||
}
|
||||
}
|
||||
55
dotnet/src/TwitchArchive.Tests/DownloaderServiceTests.cs
Normal file
55
dotnet/src/TwitchArchive.Tests/DownloaderServiceTests.cs
Normal 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 { }
|
||||
}
|
||||
}
|
||||
}
|
||||
27
dotnet/src/TwitchArchive.Tests/EffectiveConfigTests.cs
Normal file
27
dotnet/src/TwitchArchive.Tests/EffectiveConfigTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
38
dotnet/src/TwitchArchive.Tests/FileManagerServiceTests.cs
Normal file
38
dotnet/src/TwitchArchive.Tests/FileManagerServiceTests.cs
Normal 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 { }
|
||||
}
|
||||
}
|
||||
}
|
||||
57
dotnet/src/TwitchArchive.Tests/ProcessorServiceTests.cs
Normal file
57
dotnet/src/TwitchArchive.Tests/ProcessorServiceTests.cs
Normal 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 { }
|
||||
}
|
||||
}
|
||||
}
|
||||
86
dotnet/src/TwitchArchive.Tests/RecoveryPolicyTests.cs
Normal file
86
dotnet/src/TwitchArchive.Tests/RecoveryPolicyTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
49
dotnet/src/TwitchArchive.Tests/SessionRepositoryTests.cs
Normal file
49
dotnet/src/TwitchArchive.Tests/SessionRepositoryTests.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
67
dotnet/src/TwitchArchive.Tests/TwitchApiClientTests.cs
Normal file
67
dotnet/src/TwitchArchive.Tests/TwitchApiClientTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
25
dotnet/src/TwitchArchive.Tests/TwitchArchive.Tests.csproj
Normal file
25
dotnet/src/TwitchArchive.Tests/TwitchArchive.Tests.csproj
Normal 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>
|
||||
14
dotnet/src/TwitchArchive.Web/App.razor
Normal file
14
dotnet/src/TwitchArchive.Web/App.razor
Normal 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>
|
||||
20
dotnet/src/TwitchArchive.Web/Hubs/ProcessOutputHub.cs
Normal file
20
dotnet/src/TwitchArchive.Web/Hubs/ProcessOutputHub.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
29
dotnet/src/TwitchArchive.Web/Pages/AddStreamer.razor
Normal file
29
dotnet/src/TwitchArchive.Web/Pages/AddStreamer.razor
Normal 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}");
|
||||
}
|
||||
}
|
||||
92
dotnet/src/TwitchArchive.Web/Pages/AppSettings.razor
Normal file
92
dotnet/src/TwitchArchive.Web/Pages/AppSettings.razor
Normal 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;
|
||||
}
|
||||
}
|
||||
45
dotnet/src/TwitchArchive.Web/Pages/GlobalConfig.razor
Normal file
45
dotnet/src/TwitchArchive.Web/Pages/GlobalConfig.razor
Normal 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;
|
||||
}
|
||||
}
|
||||
64
dotnet/src/TwitchArchive.Web/Pages/Index.razor
Normal file
64
dotnet/src/TwitchArchive.Web/Pages/Index.razor
Normal 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;
|
||||
}
|
||||
}
|
||||
20
dotnet/src/TwitchArchive.Web/Pages/Login.razor
Normal file
20
dotnet/src/TwitchArchive.Web/Pages/Login.razor
Normal 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; }
|
||||
}
|
||||
37
dotnet/src/TwitchArchive.Web/Pages/Monitor.razor
Normal file
37
dotnet/src/TwitchArchive.Web/Pages/Monitor.razor
Normal 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";
|
||||
}
|
||||
}
|
||||
73
dotnet/src/TwitchArchive.Web/Pages/Sessions.razor
Normal file
73
dotnet/src/TwitchArchive.Web/Pages/Sessions.razor
Normal 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;
|
||||
}
|
||||
}
|
||||
71
dotnet/src/TwitchArchive.Web/Pages/StreamerConfig.razor
Normal file
71
dotnet/src/TwitchArchive.Web/Pages/StreamerConfig.razor
Normal 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("/");
|
||||
}
|
||||
}
|
||||
22
dotnet/src/TwitchArchive.Web/Pages/StreamerDetail.razor
Normal file
22
dotnet/src/TwitchArchive.Web/Pages/StreamerDetail.razor
Normal 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;
|
||||
}
|
||||
19
dotnet/src/TwitchArchive.Web/Pages/_Host.cshtml
Normal file
19
dotnet/src/TwitchArchive.Web/Pages/_Host.cshtml
Normal 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>
|
||||
104
dotnet/src/TwitchArchive.Web/Program.cs
Normal file
104
dotnet/src/TwitchArchive.Web/Program.cs
Normal 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();
|
||||
12
dotnet/src/TwitchArchive.Web/Properties/launchSettings.json
Normal file
12
dotnet/src/TwitchArchive.Web/Properties/launchSettings.json
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"profiles": {
|
||||
"TwitchArchive.Web": {
|
||||
"commandName": "Project",
|
||||
"launchBrowser": true,
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
},
|
||||
"applicationUrl": "https://localhost:64466;http://localhost:64467"
|
||||
}
|
||||
}
|
||||
}
|
||||
50
dotnet/src/TwitchArchive.Web/Services/AuthService.cs
Normal file
50
dotnet/src/TwitchArchive.Web/Services/AuthService.cs
Normal 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 { }
|
||||
}
|
||||
}
|
||||
}
|
||||
11
dotnet/src/TwitchArchive.Web/Services/IAuthService.cs
Normal file
11
dotnet/src/TwitchArchive.Web/Services/IAuthService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
38
dotnet/src/TwitchArchive.Web/Services/SessionCacheService.cs
Normal file
38
dotnet/src/TwitchArchive.Web/Services/SessionCacheService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
21
dotnet/src/TwitchArchive.Web/Shared/MainLayout.razor
Normal file
21
dotnet/src/TwitchArchive.Web/Shared/MainLayout.razor
Normal 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;
|
||||
}
|
||||
93
dotnet/src/TwitchArchive.Web/Shared/ProcessConsole.razor
Normal file
93
dotnet/src/TwitchArchive.Web/Shared/ProcessConsole.razor
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
16
dotnet/src/TwitchArchive.Web/TwitchArchive.Web.csproj
Normal file
16
dotnet/src/TwitchArchive.Web/TwitchArchive.Web.csproj
Normal 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>
|
||||
10
dotnet/src/TwitchArchive.Web/_Imports.razor
Normal file
10
dotnet/src/TwitchArchive.Web/_Imports.razor
Normal 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
|
||||
BIN
dotnet/src/TwitchArchive.Web/archive.db
Normal file
BIN
dotnet/src/TwitchArchive.Web/archive.db
Normal file
Binary file not shown.
BIN
dotnet/src/TwitchArchive.Web/archive.db-shm
Normal file
BIN
dotnet/src/TwitchArchive.Web/archive.db-shm
Normal file
Binary file not shown.
BIN
dotnet/src/TwitchArchive.Web/archive.db-wal
Normal file
BIN
dotnet/src/TwitchArchive.Web/archive.db-wal
Normal file
Binary file not shown.
16
dotnet/src/TwitchArchive.Web/wwwroot/css/app.css
Normal file
16
dotnet/src/TwitchArchive.Web/wwwroot/css/app.css
Normal 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; }
|
||||
}
|
||||
|
|
@ -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"])
|
||||
if self.username == 'KalathrasLolweapon':
|
||||
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:
|
||||
print('A ERROR has ocurred and chat will need to be updated to html manually')
|
||||
|
||||
|
|
@ -236,9 +240,22 @@ class TwitchArchive:
|
|||
print('Uploading files:')
|
||||
if self.os == 'windows':
|
||||
if self.username == 'KalathrasLolweapon':
|
||||
subprocess.call(['rclone', 'copy', str(pathlib.Path(__file__).parent.resolve())+'/VODS', 'GD:VODS', '--progress'])
|
||||
subprocess.call(['rclone', 'copy', str(pathlib.Path(__file__).parent.resolve())+'/Chat', 'GD:Chat', '--progress'])
|
||||
else: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)
|
||||
if proc.stdout:
|
||||
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])
|
||||
|
||||
if self.deleteFiles == 1:
|
||||
|
|
|
|||
1565
twitch-archive.py
1565
twitch-archive.py
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue