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
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; }
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue