Refactor global configuration page and navigation; add media library page; enhance streamer configuration with detailed options

- Removed the global configuration form and redirected to the consolidated settings page.
- Updated the dashboard to provide feedback when no streamers are configured and added edit links for each streamer.
- Introduced a new media library page to display media files from the configured archive root.
- Enhanced the streamer configuration page with additional options for overrides and settings, including a confirmation modal for deletion.
- Updated the layout and styles for improved user experience and navigation.
- Switched from file-based password storage to database-backed user credentials management in AuthService.
- Applied EF migrations on application startup to ensure database schema is up-to-date.
This commit is contained in:
MaddoScientisto 2026-02-22 23:06:40 +01:00
commit e5e60999bf
24 changed files with 1151 additions and 163 deletions

3
.gitignore vendored
View file

@ -65,4 +65,5 @@ packages/
*.backup
*.orig
dotnet/.vs/**
dotnet/.vs/**
.vs/**

View file

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

View file

@ -0,0 +1,17 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
using TwitchArchive.Core.Persistence;
namespace TwitchArchive.Core
{
public class ArchiveDbContextFactory : IDesignTimeDbContextFactory<ArchiveDbContext>
{
public ArchiveDbContext CreateDbContext(string[] args)
{
var builder = new DbContextOptionsBuilder<ArchiveDbContext>();
var conn = "Data Source=archive.db";
builder.UseSqlite(conn);
return new ArchiveDbContext(builder.Options);
}
}
}

View file

@ -14,12 +14,43 @@ namespace TwitchArchive.Core.Config
public ConfigurationService(string? basePath = null)
{
_basePath = basePath ?? Path.Combine(AppContext.BaseDirectory, "config");
// If a basePath was explicitly provided, use it. Otherwise try to find a
// repository-level `config` folder by walking parent directories from the
// application base. This ensures the web app uses the same config/ files
// as the repository (global.json, config/streamers/*.json) when available.
if (!string.IsNullOrWhiteSpace(basePath))
{
_basePath = basePath;
}
else
{
var found = FindExistingConfigFolder();
_basePath = found ?? Path.Combine(AppContext.BaseDirectory, "config");
}
_streamersPath = Path.Combine(_basePath, "streamers");
Directory.CreateDirectory(_basePath);
Directory.CreateDirectory(_streamersPath);
}
private string? FindExistingConfigFolder()
{
var start = AppContext.BaseDirectory ?? Environment.CurrentDirectory;
var dir = new DirectoryInfo(start);
for (int i = 0; i < 8 && dir != null; i++)
{
var candidate = Path.Combine(dir.FullName, "config");
if (Directory.Exists(candidate))
{
// prefer candidate if it contains global.json or streamers
if (File.Exists(Path.Combine(candidate, "global.json")) || Directory.Exists(Path.Combine(candidate, "streamers")))
return candidate;
}
dir = dir.Parent;
}
return null;
}
public GlobalConfig LoadGlobal()
{
var file = Path.Combine(_basePath, "global.json");

View file

@ -14,10 +14,44 @@ namespace TwitchArchive.Core.Config
public int RefreshIntervalSeconds { get; init; }
public int StreamSegmentThreads { get; init; }
public string? DefaultQuality { get; init; }
// Defaults that can be overridden per-streamer
public DefaultsSection Defaults { get; init; } = new DefaultsSection();
public static EffectiveConfig Merge(GlobalConfig global, StreamerConfig? streamer)
{
streamer ??= new StreamerConfig();
var d = new TwitchArchive.Core.Config.DefaultsSection();
// start with global defaults
if (global?.Defaults != null) d = global.Defaults;
// apply per-streamer overrides when present
var mergedDefaults = new TwitchArchive.Core.Config.DefaultsSection
{
DownloadVOD = streamer.DownloadVOD ?? d.DownloadVOD,
DownloadCHAT = streamer.DownloadCHAT ?? d.DownloadCHAT,
DownloadLiveCHAT = streamer.DownloadLiveCHAT ?? d.DownloadLiveCHAT,
MergeVideoChat = streamer.MergeVideoChat ?? d.MergeVideoChat,
MergeChatLayout = streamer.MergeChatLayout ?? d.MergeChatLayout,
VodTimeout = streamer.VodTimeout ?? d.VodTimeout,
UploadCloud = streamer.UploadCloud ?? d.UploadCloud,
UploadPreMergeVideo = streamer.UploadPreMergeVideo ?? d.UploadPreMergeVideo,
UploadMergedVideo = streamer.UploadMergedVideo ?? d.UploadMergedVideo,
UploadChatVideo = streamer.UploadChatVideo ?? d.UploadChatVideo,
DeleteFiles = streamer.DeleteFiles ?? d.DeleteFiles,
OnlyRaw = streamer.OnlyRaw ?? d.OnlyRaw,
CleanRaw = streamer.CleanRaw ?? d.CleanRaw,
HlsSegments = streamer.HlsSegments ?? d.HlsSegments,
HlsSegmentsVOD = streamer.HlsSegmentsVOD ?? d.HlsSegmentsVOD,
StreamlinkTtvlol = streamer.StreamlinkTtvlol ?? d.StreamlinkTtvlol,
FfmpegHwaccel = streamer.FfmpegHwaccel ?? d.FfmpegHwaccel,
FfmpegThreads = streamer.FfmpegThreads ?? d.FfmpegThreads,
FfmpegAudioCodec = streamer.FfmpegAudioCodec ?? d.FfmpegAudioCodec,
FfmpegAudioSamplerate = streamer.FfmpegAudioSamplerate ?? d.FfmpegAudioSamplerate,
FfmpegAudioBitrate = streamer.FfmpegAudioBitrate ?? d.FfmpegAudioBitrate,
FfmpegErrorRecovery = streamer.FfmpegErrorRecovery ?? d.FfmpegErrorRecovery,
FfmpegFaststart = streamer.FfmpegFaststart ?? d.FfmpegFaststart,
FfmpegProgress = streamer.FfmpegProgress ?? d.FfmpegProgress
};
return new EffectiveConfig
{
ArchiveRoot = streamer.Username != null ? (global.ArchiveRoot ?? string.Empty) : (global.ArchiveRoot ?? string.Empty),
@ -29,7 +63,8 @@ namespace TwitchArchive.Core.Config
UploadDestination = streamer.UploadDestination ?? global.UploadDestination,
RefreshIntervalSeconds = global.RefreshIntervalSeconds,
StreamSegmentThreads = global.StreamSegmentThreads,
DefaultQuality = streamer.Quality ?? global.DefaultQuality
DefaultQuality = streamer.Quality ?? global.DefaultQuality,
Defaults = mergedDefaults
};
}
}

View file

@ -1,3 +1,4 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
namespace TwitchArchive.Core.Config
@ -26,12 +27,98 @@ namespace TwitchArchive.Core.Config
public string? UploadDestination { get; set; }
[JsonPropertyName("refresh_interval_seconds")]
[Range(5, 86400, ErrorMessage = "Refresh interval must be between 5 and 86400 seconds.")]
public int RefreshIntervalSeconds { get; set; } = 60;
[JsonPropertyName("stream_segment_threads")]
[Range(1, 64, ErrorMessage = "Stream segment threads must be between 1 and 64.")]
public int StreamSegmentThreads { get; set; } = 4;
[JsonPropertyName("default_quality")]
public string? DefaultQuality { get; set; } = "best";
// Defaults section for per-streamer fallbacks
[JsonPropertyName("defaults")]
public DefaultsSection Defaults { get; set; } = new DefaultsSection();
}
public class DefaultsSection
{
[JsonPropertyName("downloadVOD")]
public bool DownloadVOD { get; set; } = true;
[JsonPropertyName("downloadCHAT")]
public bool DownloadCHAT { get; set; } = true;
[JsonPropertyName("downloadLiveCHAT")]
public bool DownloadLiveCHAT { get; set; } = true;
[JsonPropertyName("mergeVideoChat")]
public bool MergeVideoChat { get; set; } = false;
[JsonPropertyName("mergeChatLayout")]
public string MergeChatLayout { get; set; } = "side-by-side";
[JsonPropertyName("vodTimeout")]
[Range(0, 86400, ErrorMessage = "VOD timeout must be between 0 and 86400 seconds.")]
public int VodTimeout { get; set; } = 300;
[JsonPropertyName("uploadCloud")]
public bool UploadCloud { get; set; } = false;
[JsonPropertyName("uploadPreMergeVideo")]
public bool UploadPreMergeVideo { get; set; } = true;
[JsonPropertyName("uploadMergedVideo")]
public bool UploadMergedVideo { get; set; } = true;
[JsonPropertyName("uploadChatVideo")]
public bool UploadChatVideo { get; set; } = false;
[JsonPropertyName("deleteFiles")]
public bool DeleteFiles { get; set; } = false;
[JsonPropertyName("onlyRaw")]
public bool OnlyRaw { get; set; } = false;
[JsonPropertyName("cleanRaw")]
public bool CleanRaw { get; set; } = true;
[JsonPropertyName("hls_segments")]
[Range(1, 50, ErrorMessage = "HLS segments must be between 1 and 50.")]
public int HlsSegments { get; set; } = 3;
[JsonPropertyName("hls_segmentsVOD")]
[Range(1, 200, ErrorMessage = "HLS segments (VOD) must be between 1 and 200.")]
public int HlsSegmentsVOD { get; set; } = 10;
[JsonPropertyName("streamlink_ttvlol")]
public bool StreamlinkTtvlol { get; set; } = false;
[JsonPropertyName("ffmpeg_hwaccel")]
public string FfmpegHwaccel { get; set; } = "auto";
[JsonPropertyName("ffmpeg_threads")]
[Range(0, 128, ErrorMessage = "FFmpeg threads must be between 0 and 128.")]
public int FfmpegThreads { get; set; } = 0;
[JsonPropertyName("ffmpeg_audio_codec")]
public string FfmpegAudioCodec { get; set; } = "aac";
[JsonPropertyName("ffmpeg_audio_samplerate")]
[Range(8000, 192000, ErrorMessage = "Audio sample rate must be between 8000 and 192000.")]
public int FfmpegAudioSamplerate { get; set; } = 48000;
[JsonPropertyName("ffmpeg_audio_bitrate")]
public string FfmpegAudioBitrate { get; set; } = "192k";
[JsonPropertyName("ffmpeg_error_recovery")]
public bool FfmpegErrorRecovery { get; set; } = true;
[JsonPropertyName("ffmpeg_faststart")]
public bool FfmpegFaststart { get; set; } = true;
[JsonPropertyName("ffmpeg_progress")]
public bool FfmpegProgress { get; set; } = false;
}
}

View file

@ -1,3 +1,4 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
namespace TwitchArchive.Core.Config
@ -5,6 +6,7 @@ namespace TwitchArchive.Core.Config
public class StreamerConfig
{
[JsonPropertyName("username")]
[Required]
public string Username { get; set; } = string.Empty;
[JsonPropertyName("enabled")]
@ -21,5 +23,83 @@ namespace TwitchArchive.Core.Config
[JsonPropertyName("streamlink_path")]
public string? StreamlinkPath { get; set; }
// Per-streamer override options matching GlobalConfig.Defaults
[JsonPropertyName("downloadVOD")]
public bool? DownloadVOD { get; set; }
[JsonPropertyName("downloadCHAT")]
public bool? DownloadCHAT { get; set; }
[JsonPropertyName("downloadLiveCHAT")]
public bool? DownloadLiveCHAT { get; set; }
[JsonPropertyName("mergeVideoChat")]
public bool? MergeVideoChat { get; set; }
[JsonPropertyName("mergeChatLayout")]
public string? MergeChatLayout { get; set; }
[JsonPropertyName("vodTimeout")]
[Range(0, 86400, ErrorMessage = "VOD timeout must be between 0 and 86400 seconds.")]
public int? VodTimeout { get; set; }
[JsonPropertyName("uploadCloud")]
public bool? UploadCloud { get; set; }
[JsonPropertyName("uploadPreMergeVideo")]
public bool? UploadPreMergeVideo { get; set; }
[JsonPropertyName("uploadMergedVideo")]
public bool? UploadMergedVideo { get; set; }
[JsonPropertyName("uploadChatVideo")]
public bool? UploadChatVideo { get; set; }
[JsonPropertyName("deleteFiles")]
public bool? DeleteFiles { get; set; }
[JsonPropertyName("onlyRaw")]
public bool? OnlyRaw { get; set; }
[JsonPropertyName("cleanRaw")]
public bool? CleanRaw { get; set; }
[JsonPropertyName("hls_segments")]
[Range(1, 50, ErrorMessage = "HLS segments must be between 1 and 50.")]
public int? HlsSegments { get; set; }
[JsonPropertyName("hls_segmentsVOD")]
[Range(1, 200, ErrorMessage = "HLS segments (VOD) must be between 1 and 200.")]
public int? HlsSegmentsVOD { get; set; }
[JsonPropertyName("streamlink_ttvlol")]
public bool? StreamlinkTtvlol { get; set; }
[JsonPropertyName("ffmpeg_hwaccel")]
public string? FfmpegHwaccel { get; set; }
[JsonPropertyName("ffmpeg_threads")]
[Range(0, 128, ErrorMessage = "FFmpeg threads must be between 0 and 128.")]
public int? FfmpegThreads { get; set; }
[JsonPropertyName("ffmpeg_audio_codec")]
public string? FfmpegAudioCodec { get; set; }
[JsonPropertyName("ffmpeg_audio_samplerate")]
[Range(8000, 192000, ErrorMessage = "Audio sample rate must be between 8000 and 192000.")]
public int? FfmpegAudioSamplerate { get; set; }
[JsonPropertyName("ffmpeg_audio_bitrate")]
public string? FfmpegAudioBitrate { get; set; }
[JsonPropertyName("ffmpeg_error_recovery")]
public bool? FfmpegErrorRecovery { get; set; }
[JsonPropertyName("ffmpeg_faststart")]
public bool? FfmpegFaststart { get; set; }
[JsonPropertyName("ffmpeg_progress")]
public bool? FfmpegProgress { get; set; }
}
}

View file

@ -10,5 +10,6 @@ namespace TwitchArchive.Core.Persistence
public DbSet<StreamSession> StreamSessions { get; set; } = null!;
public DbSet<ArchiveJob> ArchiveJobs { get; set; } = null!;
public DbSet<StreamerState> StreamerStates { get; set; } = null!;
public DbSet<TwitchArchive.Core.Persistence.Models.UserCredential> UserCredentials { get; set; } = null!;
}
}

View file

@ -0,0 +1,30 @@
using System;
using Microsoft.EntityFrameworkCore;
namespace TwitchArchive.Core.Persistence
{
public static class ArchiveDbInitializer
{
public static void EnsureUserCredentialsTable(IDbContextFactory<ArchiveDbContext> factory)
{
if (factory == null) throw new ArgumentNullException(nameof(factory));
try
{
using var ctx = factory.CreateDbContext();
var conn = ctx.Database.GetDbConnection();
try { conn.Open(); } catch { /* ignore open errors */ }
using var cmd = conn.CreateCommand();
// Create table if it doesn't exist (SQLite syntax)
cmd.CommandText = @"CREATE TABLE IF NOT EXISTS UserCredentials (
Id INTEGER PRIMARY KEY AUTOINCREMENT,
PasswordHash TEXT NOT NULL
);";
cmd.ExecuteNonQuery();
}
catch
{
// Initialization should not crash the app; log if needed
}
}
}
}

View file

@ -0,0 +1,8 @@
namespace TwitchArchive.Core.Persistence.Models
{
public class UserCredential
{
public int Id { get; set; }
public string PasswordHash { get; set; } = string.Empty;
}
}

View file

@ -0,0 +1,38 @@
using Xunit;
using TwitchArchive.Core.Config;
namespace TwitchArchive.Tests
{
public class EffectiveConfigDefaultsTests
{
[Fact]
public void Merge_AppliesStreamerOverridesOverGlobalDefaults()
{
var global = new GlobalConfig
{
DefaultQuality = "best",
Defaults = new DefaultsSection
{
DownloadVOD = true,
MergeVideoChat = false,
VodTimeout = 300
}
};
var streamer = new StreamerConfig
{
Username = "test",
DownloadVOD = false,
MergeVideoChat = true,
VodTimeout = 10
};
var eff = EffectiveConfig.Merge(global, streamer);
Assert.False(eff.Defaults.DownloadVOD);
Assert.True(eff.Defaults.MergeVideoChat);
Assert.Equal(10, eff.Defaults.VodTimeout);
Assert.Equal("best", eff.DefaultQuality);
}
}
}

View file

@ -1,28 +1,207 @@
@page "/config/new"
@page "/addstreamer"
@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" />
<DataAnnotationsValidator />
<ValidationSummary />
<div class="card">
<label title="Streamer username (lowercase)">Username</label>
<InputText @bind-Value="model.Username" title="Streamer username (lowercase)" />
</div>
<div>
<label>Enabled</label>
<InputCheckbox @bind-Value="model.Enabled" />
<div class="card">
<label title="Enable/disable monitoring for this streamer">Enabled</label>
<InputCheckbox @bind-Value="model.Enabled" title="Enable/disable monitoring for this streamer" />
</div>
<div class="card">
<label title="Optional quality override (e.g., best, 720p)">Quality (optional)</label>
<InputText @bind-Value="model.Quality" placeholder="leave empty to use default" title="Optional quality override (e.g., best, 720p)" />
</div>
<div class="card">
<label title="Optional cloud upload destination (rclone remote)">Upload Destination (optional)</label>
<InputText @bind-Value="model.UploadDestination" title="Optional cloud upload destination (rclone remote)" />
</div>
<div>
<button class="btn-link" @onclick="ToggleAdvanced">@(showAdvanced ? "Hide advanced" : "Show advanced")</button>
</div>
@if (showAdvanced)
{
<div class="card">
<h4>Advanced per-streamer defaults</h4>
<div>
<label title="If enabled, VODs will be downloaded for this streamer">Download VOD</label>
<InputCheckbox @bind="downloadVOD" title="If enabled, VODs will be downloaded for this streamer" />
</div>
<div>
<label title="If enabled, chat will be downloaded for VODs">Download CHAT</label>
<InputCheckbox @bind="downloadCHAT" title="If enabled, chat will be downloaded for VODs" />
</div>
<div>
<label title="If enabled, live chat will be captured while streaming">Download Live CHAT</label>
<InputCheckbox @bind="downloadLiveCHAT" title="If enabled, live chat will be captured while streaming" />
</div>
<div>
<label title="Combine video and chat into a merged output">Merge Video & Chat</label>
<InputCheckbox @bind="mergeVideoChat" title="Combine video and chat into a merged output" />
</div>
<div>
<label title="Layout used when merging chat with video">Merge Chat Layout</label>
<InputSelect @bind-Value="model.MergeChatLayout" title="Layout used when merging chat with video">
<option value="side-by-side">Side by side</option>
<option value="stacked">Stacked</option>
<option value="overlay">Overlay</option>
</InputSelect>
</div>
<div>
<label title="Time in seconds to wait for VOD completion before timing out">VOD Timeout (sec)</label>
<InputNumber @bind-Value="vodTimeout" title="Time in seconds to wait for VOD completion before timing out" />
</div>
<div>
<label title="Upload the raw pre-merged video to cloud">Upload Pre-Merge Video</label>
<InputCheckbox @bind="uploadPreMergeVideo" title="Upload the raw pre-merged video to cloud" />
</div>
<div>
<label title="Upload the merged video to cloud">Upload Merged Video</label>
<InputCheckbox @bind="uploadMergedVideo" title="Upload the merged video to cloud" />
</div>
<div>
<label title="Upload a video composed from chat only">Upload Chat Video</label>
<InputCheckbox @bind="uploadChatVideo" title="Upload a video composed from chat only" />
</div>
<div>
<label title="Delete local files after successful upload">Delete Files</label>
<InputCheckbox @bind="deleteFiles" title="Delete local files after successful upload" />
</div>
<div>
<label title="Keep only raw recordings and skip processed outputs">Only Raw</label>
<InputCheckbox @bind="onlyRaw" title="Keep only raw recordings and skip processed outputs" />
</div>
<div>
<label title="Remove temporary raw files after processing">Clean Raw</label>
<InputCheckbox @bind="cleanRaw" title="Remove temporary raw files after processing" />
</div>
<div>
<label title="Number of HLS segments to keep for live streams">HLS segments (live)</label>
<InputNumber @bind-Value="hlsSegments" title="Number of HLS segments to keep for live streams" />
</div>
<div>
<label title="Number of HLS segments to use when producing VOD HLS">HLS segments (VOD)</label>
<InputNumber @bind-Value="hlsSegmentsVOD" title="Number of HLS segments to use when producing VOD HLS" />
</div>
<div>
<label title="Enable legacy ttvlol streamlink behavior">Streamlink ttvlol</label>
<InputCheckbox @bind="streamlinkTtvlol" title="Enable legacy ttvlol streamlink behavior" />
</div>
<div>
<label title="Hardware acceleration setting for FFmpeg">FFmpeg HW Accel</label>
<InputSelect @bind-Value="model.FfmpegHwaccel" title="Hardware acceleration setting for FFmpeg">
<option value="auto">Auto</option>
<option value="none">None</option>
<option value="vaapi">VAAPI</option>
<option value="dxva2">DXVA2</option>
<option value="qsv">QSV</option>
<option value="cuda">CUDA</option>
</InputSelect>
</div>
<div>
<label title="Threads supplied to ffmpeg (0 = auto)">FFmpeg Threads</label>
<InputNumber @bind-Value="ffmpegThreads" title="Threads supplied to ffmpeg (0 = auto)" />
</div>
<div>
<label title="Audio codec used by FFmpeg (e.g., aac)">FFmpeg Audio Codec</label>
<InputText @bind-Value="ffmpegAudioCodec" title="Audio codec used by FFmpeg (e.g., aac)" />
</div>
<div>
<label title="Audio sample rate for FFmpeg">FFmpeg Audio Sample Rate</label>
<InputNumber @bind-Value="ffmpegAudioSamplerate" title="Audio sample rate for FFmpeg" />
</div>
<div>
<label title="Audio bitrate (e.g., 192k)">FFmpeg Audio Bitrate</label>
<InputText @bind-Value="ffmpegAudioBitrate" title="Audio bitrate (e.g., 192k)" />
</div>
<div>
<label title="Enable FFmpeg error recovery options">FFmpeg Error Recovery</label>
<InputCheckbox @bind="ffmpegErrorRecovery" title="Enable FFmpeg error recovery options" />
</div>
<div>
<label title="Enable faststart flag for MP4 to allow streaming">FFmpeg Faststart</label>
<InputCheckbox @bind="ffmpegFaststart" title="Enable faststart flag for MP4 to allow streaming" />
</div>
<div>
<label title="Emit FFmpeg progress updates">FFmpeg Progress</label>
<InputCheckbox @bind="ffmpegProgress" title="Emit FFmpeg progress updates" />
</div>
</div>
}
<div class="mt-2">
<button type="submit">Create</button>
</div>
<button type="submit">Create</button>
</EditForm>
@code {
private TwitchArchive.Core.Config.StreamerConfig model = new() { Enabled = true };
private bool showAdvanced = false;
// local fields for binding nullable/global override values
private bool downloadVOD = true;
private bool downloadCHAT = true;
private bool downloadLiveCHAT = true;
private bool mergeVideoChat = false;
private int? vodTimeout;
private bool uploadPreMergeVideo = true;
private bool uploadMergedVideo = true;
private bool uploadChatVideo = false;
private bool deleteFiles = false;
private bool onlyRaw = false;
private bool cleanRaw = true;
private int? hlsSegments;
private int? hlsSegmentsVOD;
private bool streamlinkTtvlol = false;
private string? ffmpegHwaccel;
private int? ffmpegThreads;
private string? ffmpegAudioCodec;
private int? ffmpegAudioSamplerate;
private string? ffmpegAudioBitrate;
private bool ffmpegErrorRecovery = true;
private bool ffmpegFaststart = true;
private bool ffmpegProgress = false;
private void ToggleAdvanced() => showAdvanced = !showAdvanced;
private void Save()
{
model.Username = model.Username?.Trim().ToLowerInvariant() ?? string.Empty;
if (string.IsNullOrWhiteSpace(model.Username)) return;
// map local fields into nullable model properties
model.DownloadVOD = downloadVOD;
model.DownloadCHAT = downloadCHAT;
model.DownloadLiveCHAT = downloadLiveCHAT;
model.MergeVideoChat = mergeVideoChat;
model.VodTimeout = vodTimeout;
model.UploadPreMergeVideo = uploadPreMergeVideo;
model.UploadMergedVideo = uploadMergedVideo;
model.UploadChatVideo = uploadChatVideo;
model.DeleteFiles = deleteFiles;
model.OnlyRaw = onlyRaw;
model.CleanRaw = cleanRaw;
model.HlsSegments = hlsSegments;
model.HlsSegmentsVOD = hlsSegmentsVOD;
model.StreamlinkTtvlol = streamlinkTtvlol;
model.FfmpegHwaccel = ffmpegHwaccel;
model.FfmpegThreads = ffmpegThreads;
model.FfmpegAudioCodec = ffmpegAudioCodec;
model.FfmpegAudioSamplerate = ffmpegAudioSamplerate;
model.FfmpegAudioBitrate = ffmpegAudioBitrate;
model.FfmpegErrorRecovery = ffmpegErrorRecovery;
model.FfmpegFaststart = ffmpegFaststart;
model.FfmpegProgress = ffmpegProgress;
ConfigService.SaveStreamer(model);
Nav.NavigateTo($"/config/{model.Username}");
}

View file

@ -1,35 +1,174 @@
@page "/settings"
@using System.Text.Json
@inject TwitchArchive.Core.Config.IConfigurationService ConfigService
@inject TwitchArchive.Web.Services.IAuthService Auth
<h3>App Settings</h3>
<h3>Settings</h3>
@if (saved)
{
<div class="alert">Saved.</div>
}
<EditForm Model="model" OnValidSubmit="Save">
<EditForm Model="globalModel" OnValidSubmit="SaveGlobal">
<div>
<label>Archive Root</label>
<InputText @bind-value="globalModel.ArchiveRoot" />
</div>
<div>
<label>Streamlink Path</label>
<InputText @bind-value="model.StreamlinkPath" />
<InputText @bind-value="globalModel.StreamlinkPath" />
</div>
<div>
<label>FFmpeg Path</label>
<InputText @bind-value="model.FfmpegPath" />
<InputText @bind-value="globalModel.FfmpegPath" />
</div>
<div>
<label>TwitchDownloader Path</label>
<InputText @bind-value="model.TwitchDownloaderPath" />
<InputText @bind-value="globalModel.TwitchDownloaderPath" />
</div>
<div>
<label>Rclone Path</label>
<InputText @bind-value="model.RclonePath" />
<InputText @bind-value="globalModel.RclonePath" />
</div>
<div>
<label>Default Quality</label>
<InputText @bind-value="globalModel.DefaultQuality" />
</div>
<div>
<label>Upload To Cloud</label>
<InputCheckbox @bind-Value="globalModel.UploadToCloud" />
</div>
<div>
<label>Upload Destination</label>
<InputText @bind-value="globalModel.UploadDestination" />
</div>
<div>
<label>Refresh Interval (seconds)</label>
<InputNumber @bind-Value="globalModel.RefreshIntervalSeconds" />
</div>
<div>
<label>Stream Segment Threads</label>
<InputNumber @bind-Value="globalModel.StreamSegmentThreads" />
</div>
<button type="submit">Save</button>
</EditForm>
<h4>Change Password</h4>
<div class="card">
<button class="btn-link" @onclick="ToggleDefaults">@(showDefaults ? "Hide Defaults" : "Show Defaults")</button>
@if (showDefaults)
{
<EditForm Model="globalModel.Defaults" OnValidSubmit="SaveGlobal">
<DataAnnotationsValidator />
<div class="card">
<h4>Defaults</h4>
<div>
<label>Download VOD</label>
<InputCheckbox @bind-Value="globalModel.Defaults.DownloadVOD" />
</div>
<div>
<label>Download CHAT</label>
<InputCheckbox @bind-Value="globalModel.Defaults.DownloadCHAT" />
</div>
<div>
<label>Download Live CHAT</label>
<InputCheckbox @bind-Value="globalModel.Defaults.DownloadLiveCHAT" />
</div>
<div>
<label>Merge Video & Chat</label>
<InputCheckbox @bind-Value="globalModel.Defaults.MergeVideoChat" />
</div>
<div>
<label>Merge Chat Layout</label>
<InputSelect @bind-Value="globalModel.Defaults.MergeChatLayout">
<option value="side-by-side">Side by side</option>
<option value="stacked">Stacked</option>
<option value="overlay">Overlay</option>
</InputSelect>
</div>
<div>
<label>VOD Timeout (sec)</label>
<InputNumber @bind-Value="globalModel.Defaults.VodTimeout" />
</div>
<div>
<label>Upload to Cloud</label>
<InputCheckbox @bind-Value="globalModel.Defaults.UploadCloud" />
</div>
<div>
<label>Delete Files After Upload</label>
<InputCheckbox @bind-Value="globalModel.Defaults.DeleteFiles" />
</div>
<div>
<label>Only Raw</label>
<InputCheckbox @bind-Value="globalModel.Defaults.OnlyRaw" />
</div>
<div>
<label>Clean Raw</label>
<InputCheckbox @bind-Value="globalModel.Defaults.CleanRaw" />
</div>
<div>
<label>HLS segments (live)</label>
<InputNumber @bind-Value="globalModel.Defaults.HlsSegments" />
</div>
<div>
<label>HLS segments (VOD)</label>
<InputNumber @bind-Value="globalModel.Defaults.HlsSegmentsVOD" />
</div>
<div>
<label>Streamlink ttvlol</label>
<InputCheckbox @bind-Value="globalModel.Defaults.StreamlinkTtvlol" />
</div>
<div>
<label>FFmpeg HW Accel</label>
<InputSelect @bind-Value="globalModel.Defaults.FfmpegHwaccel">
<option value="auto">Auto</option>
<option value="none">None</option>
<option value="vaapi">VAAPI</option>
<option value="dxva2">DXVA2</option>
<option value="qsv">QSV</option>
<option value="cuda">CUDA</option>
</InputSelect>
</div>
<div>
<label>FFmpeg Threads</label>
<InputNumber @bind-Value="globalModel.Defaults.FfmpegThreads" />
</div>
<div>
<label>FFmpeg Audio Codec</label>
<InputText @bind-Value="globalModel.Defaults.FfmpegAudioCodec" />
</div>
<div>
<label>FFmpeg Audio Sample Rate</label>
<InputNumber @bind-Value="globalModel.Defaults.FfmpegAudioSamplerate" />
</div>
<div>
<label>FFmpeg Audio Bitrate</label>
<InputText @bind-Value="globalModel.Defaults.FfmpegAudioBitrate" />
</div>
<div>
<label>FFmpeg Error Recovery</label>
<InputCheckbox @bind-Value="globalModel.Defaults.FfmpegErrorRecovery" />
</div>
<div>
<label>FFmpeg Faststart</label>
<InputCheckbox @bind-Value="globalModel.Defaults.FfmpegFaststart" />
</div>
<div>
<label>FFmpeg Progress</label>
<InputCheckbox @bind-Value="globalModel.Defaults.FfmpegProgress" />
</div>
<div class="mt-2">
<button type="submit">Save Defaults</button>
</div>
</div>
</EditForm>
}
</div>
<div class="mt-2">
<button type="submit" @onclick="SaveGlobal">Save All</button>
</div>
<h4 class="mt-3">Change Password</h4>
@if (!string.IsNullOrEmpty(pwError))
{
<div class="alert">@pwError</div>
@ -42,8 +181,9 @@
</div>
@code {
private TwitchArchive.Core.Config.AppSettings model = new();
private TwitchArchive.Core.Config.GlobalConfig globalModel = new();
private bool saved = false;
private bool showDefaults = false;
private string currentPw = string.Empty;
private string newPw = string.Empty;
private string confirmPw = string.Empty;
@ -56,29 +196,26 @@
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();
globalModel = ConfigService.LoadGlobal() ?? new TwitchArchive.Core.Config.GlobalConfig();
}
catch { model = new(); }
catch { globalModel = new TwitchArchive.Core.Config.GlobalConfig(); }
}
private void Save()
private void SaveGlobal()
{
var file = Path.Combine(AppContext.BaseDirectory, "appsettings.json");
try
{
var txt = JsonSerializer.Serialize(model, new JsonSerializerOptions { WriteIndented = true });
File.WriteAllText(file, txt);
ConfigService.SaveGlobal(globalModel);
saved = true;
Auth.Refresh();
// optionally notify auth or other services
}
catch { }
}
private void ToggleDefaults() => showDefaults = !showDefaults;
private void ChangePassword()
{
pwError = string.Empty;
@ -87,6 +224,7 @@
if (newPw != confirmPw) { pwError = "Passwords do not match"; return; }
var hash = BCrypt.Net.BCrypt.HashPassword(newPw);
Auth.SetPasswordHash(hash);
pwError = string.Empty;
Auth.Refresh();
saved = true;
}
}

View file

@ -1,45 +1,10 @@
@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>
@inject NavigationManager Nav
@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;
// Consolidated settings are now at /settings
Nav.NavigateTo("/settings", true);
}
}

View file

@ -4,12 +4,18 @@
<h2>Dashboard</h2>
@if (streamers.Count == 0)
{
<div class="alert alert-info">No streamers configured. Add one on the <a href="/addstreamer">Add Streamer</a> page.</div>
}
<div class="cards">
@foreach (var s in streamers)
{
<div class="card">
<div class="card-header">
<a href="/streamer/@s">@s</a>
<a class="btn-link" href="/config/@s">Edit</a>
<span class="badge">@(WorkerManager.IsRunning(s) ? "Live" : "Offline")</span>
</div>
<div class="card-body">
@ -23,6 +29,12 @@
}
</div>
@* Show global feedback when there are streamers but no recent sessions *@
@if (streamers.Count > 0 && (lastStarts == null || lastStarts.Count == 0))
{
<div class="alert alert-warning mt-3">No recent sessions found for configured streamers.</div>
}
@code {
private List<string> streamers = new();
private Dictionary<string, DateTime> lastStarts = new();
@ -44,13 +56,33 @@
private void LoadStreamers()
{
var cfgDir = Path.Combine(Environment.CurrentDirectory, "config", "streamers");
if (Directory.Exists(cfgDir))
// Try to find the config/streamers folder from the app content root and parent folders.
string? cfgDir = FindConfigStreamersFolder();
if (!string.IsNullOrEmpty(cfgDir) && Directory.Exists(cfgDir))
{
streamers = Directory.GetFiles(cfgDir, "*.json").Select(f => Path.GetFileNameWithoutExtension(f)).ToList();
}
}
private string? FindConfigStreamersFolder()
{
// Prefer ContentRoot if available, fall back to Environment.CurrentDirectory.
var start = AppContext.BaseDirectory ?? Environment.CurrentDirectory;
var dir = new DirectoryInfo(start);
for (int i = 0; i < 6 && dir != null; i++)
{
var candidate = Path.Combine(dir.FullName, "config", "streamers");
if (Directory.Exists(candidate)) return candidate;
dir = dir.Parent;
}
// final attempt: repo-root relative (use project parent heuristics)
var alt = Path.Combine(Environment.CurrentDirectory, "..", "..", "..", "config", "streamers");
try { alt = Path.GetFullPath(alt); } catch { }
if (Directory.Exists(alt)) return alt;
return null;
}
// Index reads from the singleton SessionCacheService; updates are pushed via the Updated event.
private void Start(string u) { WorkerManager.StartWorker(u); }

View file

@ -0,0 +1,59 @@
@page "/media"
@inject TwitchArchive.Core.Config.IConfigurationService ConfigService
<h3>Media Library</h3>
@if (string.IsNullOrWhiteSpace(archiveRoot))
{
<div class="alert">Archive root is not configured. Set it on the <a href="/settings">Settings</a> page.</div>
}
else if (!Directory.Exists(archiveRoot))
{
<div class="alert">Archive root '@archiveRoot' does not exist on disk.</div>
}
else
{
@if (entries.Count == 0)
{
<div class="alert">No media files found in '@archiveRoot'.</div>
}
else
{
@foreach (var kv in entries)
{
<div class="card">
<h4>@kv.Key</h4>
<ul>
@foreach (var f in kv.Value)
{
<li>@f.Name - @((f.Length/1024.0/1024.0).ToString("0.00")) MB - @f.LastWriteTime.ToLocalTime()</li>
}
</ul>
</div>
}
}
}
@code {
private string? archiveRoot;
private Dictionary<string, List<FileInfo>> entries = new();
protected override void OnInitialized()
{
var g = ConfigService.LoadGlobal();
archiveRoot = g?.ArchiveRoot;
if (!string.IsNullOrWhiteSpace(archiveRoot) && Directory.Exists(archiveRoot))
{
var di = new DirectoryInfo(archiveRoot);
foreach (var dir in di.GetDirectories())
{
try
{
var files = dir.GetFiles("*.mp4").Concat(dir.GetFiles("*.mkv")).Concat(dir.GetFiles("*.ts")).Concat(dir.GetFiles("*.flv")).OrderByDescending(f => f.LastWriteTime).ToList();
if (files.Count > 0) entries[dir.Name] = files;
}
catch { }
}
}
}
}

View file

@ -5,67 +5,331 @@
<h3>Streamer Config: @Username</h3>
<EditForm Model="model" OnValidSubmit="Save">
<DataAnnotationsValidator />
<ValidationSummary />
@if (saved)
{
<div class="alert">Saved.</div>
}
<div>
<label>Enabled</label>
<InputCheckbox @bind="model.Enabled" />
<label title="Toggle whether this streamer is active">Enabled</label>
<InputCheckbox Value="model.Enabled" ValueChanged="@( (bool v) => model.Enabled = v )" ValueExpression="() => model.Enabled" title="Toggle whether this streamer is active" />
</div>
<div class="card">
<label title="Set a quality override for this streamer">Quality</label>
<InputCheckbox Value="overrideQuality" ValueChanged="@( (bool v) => overrideQuality = v )" ValueExpression="() => overrideQuality" title="Override default quality" /> Override
<InputText @bind="model.Quality" disabled="@(!overrideQuality)" placeholder="@(global?.DefaultQuality ?? "")" title="Enter a quality string (e.g., best, 720p)" />
</div>
<div>
<label>Quality</label>
<InputCheckbox @bind="overrideQuality" /> Override
<InputText @bind="model.Quality" disabled="@(!overrideQuality)" placeholder="@(global?.DefaultQuality ?? "")" />
<label title="Toggle cloud upload for this streamer">Upload to Cloud</label>
<InputCheckbox Value="overrideUpload" ValueChanged="@( (bool v) => overrideUpload = v )" ValueExpression="() => overrideUpload" title="Override upload-to-cloud setting" /> Override
<InputCheckbox Value="uploadToCloudVal" ValueChanged="@( (bool v) => uploadToCloudVal = v )" ValueExpression="() => uploadToCloudVal" disabled="@(!overrideUpload)" title="Upload to configured cloud destination" />
</div>
<div>
<label>Upload to Cloud</label>
<InputCheckbox @bind="overrideUpload" /> Override
<InputCheckbox @bind="model.UploadToCloud" disabled="@(!overrideUpload)" />
<div class="card">
<label title="Cloud destination (e.g., rclone remote) for this streamer">Upload Destination</label>
<InputText @bind="model.UploadDestination" title="Cloud destination (e.g., rclone remote)" />
</div>
<div>
<label>Upload Destination</label>
<InputText @bind="model.UploadDestination" />
<div class="card">
<label title="Optional: override system Streamlink path for this streamer">Streamlink Path (override)</label>
<InputCheckbox Value="overrideStreamlink" ValueChanged="@( (bool v) => overrideStreamlink = v )" ValueExpression="() => overrideStreamlink" title="Override streamlink path" /> Override
<InputText @bind="model.StreamlinkPath" disabled="@(!overrideStreamlink)" placeholder="@(global?.StreamlinkPath ?? "")" title="Full path to streamlink executable" />
</div>
<div>
<label>Streamlink Path (override)</label>
<InputCheckbox @bind="overrideStreamlink" /> Override
<InputText @bind="model.StreamlinkPath" disabled="@(!overrideStreamlink)" placeholder="@(global?.StreamlinkPath ?? "")" />
<div class="card">
<h4>Per-streamer overrides</h4>
<div>
<label>Download VOD</label>
<InputCheckbox Value="overrideDownloadVOD" ValueChanged="@( (bool v) => overrideDownloadVOD = v )" ValueExpression="() => overrideDownloadVOD" /> Override
<InputCheckbox Value="downloadVODVal" ValueChanged="@( (bool v) => downloadVODVal = v )" ValueExpression="() => downloadVODVal" disabled="@(!overrideDownloadVOD)" />
</div>
<div>
<label>Download CHAT</label>
<InputCheckbox Value="overrideDownloadCHAT" ValueChanged="@( (bool v) => overrideDownloadCHAT = v )" ValueExpression="() => overrideDownloadCHAT" /> Override
<InputCheckbox Value="downloadCHATVal" ValueChanged="@( (bool v) => downloadCHATVal = v )" ValueExpression="() => downloadCHATVal" disabled="@(!overrideDownloadCHAT)" />
</div>
<div>
<label>Merge Video & Chat</label>
<InputCheckbox Value="overrideMergeVideoChat" ValueChanged="@( (bool v) => overrideMergeVideoChat = v )" ValueExpression="() => overrideMergeVideoChat" /> Override
<InputCheckbox Value="mergeVideoChatVal" ValueChanged="@( (bool v) => mergeVideoChatVal = v )" ValueExpression="() => mergeVideoChatVal" disabled="@(!overrideMergeVideoChat)" />
</div>
<div>
<label>Merge Chat Layout</label>
<InputCheckbox Value="overrideMergeChatLayout" ValueChanged="@( (bool v) => overrideMergeChatLayout = v )" ValueExpression="() => overrideMergeChatLayout" /> Override
<InputSelect @bind-Value="mergeChatLayoutVal" disabled="@(!overrideMergeChatLayout)">
<option value="side-by-side">Side by side</option>
<option value="stacked">Stacked</option>
<option value="overlay">Overlay</option>
</InputSelect>
</div>
<div>
<label>VOD Timeout (sec)</label>
<InputCheckbox Value="overrideVodTimeout" ValueChanged="@( (bool v) => overrideVodTimeout = v )" ValueExpression="() => overrideVodTimeout" /> Override
<InputNumber @bind-Value="vodTimeoutVal" disabled="@(!overrideVodTimeout)" />
</div>
<div>
<label>Delete Files</label>
<InputCheckbox Value="overrideDeleteFiles" ValueChanged="@( (bool v) => overrideDeleteFiles = v )" ValueExpression="() => overrideDeleteFiles" /> Override
<InputCheckbox Value="deleteFilesVal" ValueChanged="@( (bool v) => deleteFilesVal = v )" ValueExpression="() => deleteFilesVal" disabled="@(!overrideDeleteFiles)" />
</div>
<div>
<label>HLS Segments (live)</label>
<InputCheckbox Value="overrideHlsSegments" ValueChanged="@( (bool v) => overrideHlsSegments = v )" ValueExpression="() => overrideHlsSegments" /> Override
<InputNumber @bind-Value="hlsSegmentsVal" disabled="@(!overrideHlsSegments)" />
</div>
<div>
<label>FFmpeg HW Accel</label>
<InputCheckbox Value="overrideFfmpegHwaccel" ValueChanged="@( (bool v) => overrideFfmpegHwaccel = v )" ValueExpression="() => overrideFfmpegHwaccel" /> Override
<InputSelect @bind-Value="ffmpegHwaccelVal" disabled="@(!overrideFfmpegHwaccel)">
<option value="auto">Auto</option>
<option value="none">None</option>
<option value="vaapi">VAAPI</option>
<option value="dxva2">DXVA2</option>
<option value="qsv">QSV</option>
<option value="cuda">CUDA</option>
</InputSelect>
</div>
<div>
<label>FFmpeg Threads</label>
<InputCheckbox Value="overrideFfmpegThreads" ValueChanged="@( (bool v) => overrideFfmpegThreads = v )" ValueExpression="() => overrideFfmpegThreads" /> Override
<InputNumber @bind-Value="ffmpegThreadsVal" disabled="@(!overrideFfmpegThreads)" />
</div>
<div>
<label>FFmpeg Audio Bitrate</label>
<InputCheckbox Value="overrideFfmpegAudioBitrate" ValueChanged="@( (bool v) => overrideFfmpegAudioBitrate = v )" ValueExpression="() => overrideFfmpegAudioBitrate" /> Override
<InputText @bind-Value="ffmpegAudioBitrateVal" disabled="@(!overrideFfmpegAudioBitrate)" />
</div>
<div>
<label>Download Live CHAT</label>
<InputCheckbox Value="overrideDownloadLiveCHAT" ValueChanged="@( (bool v) => overrideDownloadLiveCHAT = v )" ValueExpression="() => overrideDownloadLiveCHAT" /> Override
<InputCheckbox Value="downloadLiveCHATVal" ValueChanged="@( (bool v) => downloadLiveCHATVal = v )" ValueExpression="() => downloadLiveCHATVal" disabled="@(!overrideDownloadLiveCHAT)" />
</div>
<div>
<label>Upload Pre-Merge Video</label>
<InputCheckbox Value="overrideUploadPreMergeVideo" ValueChanged="@( (bool v) => overrideUploadPreMergeVideo = v )" ValueExpression="() => overrideUploadPreMergeVideo" /> Override
<InputCheckbox Value="uploadPreMergeVideoVal" ValueChanged="@( (bool v) => uploadPreMergeVideoVal = v )" ValueExpression="() => uploadPreMergeVideoVal" disabled="@(!overrideUploadPreMergeVideo)" />
</div>
<div>
<label>Upload Merged Video</label>
<InputCheckbox Value="overrideUploadMergedVideo" ValueChanged="@( (bool v) => overrideUploadMergedVideo = v )" ValueExpression="() => overrideUploadMergedVideo" /> Override
<InputCheckbox Value="uploadMergedVideoVal" ValueChanged="@( (bool v) => uploadMergedVideoVal = v )" ValueExpression="() => uploadMergedVideoVal" disabled="@(!overrideUploadMergedVideo)" />
</div>
<div>
<label>Upload Chat Video</label>
<InputCheckbox Value="overrideUploadChatVideo" ValueChanged="@( (bool v) => overrideUploadChatVideo = v )" ValueExpression="() => overrideUploadChatVideo" /> Override
<InputCheckbox Value="uploadChatVideoVal" ValueChanged="@( (bool v) => uploadChatVideoVal = v )" ValueExpression="() => uploadChatVideoVal" disabled="@(!overrideUploadChatVideo)" />
</div>
<div>
<label>Only Raw</label>
<InputCheckbox Value="overrideOnlyRaw" ValueChanged="@( (bool v) => overrideOnlyRaw = v )" ValueExpression="() => overrideOnlyRaw" /> Override
<InputCheckbox Value="onlyRawVal" ValueChanged="@( (bool v) => onlyRawVal = v )" ValueExpression="() => onlyRawVal" disabled="@(!overrideOnlyRaw)" />
</div>
<div>
<label>Clean Raw</label>
<InputCheckbox Value="overrideCleanRaw" ValueChanged="@( (bool v) => overrideCleanRaw = v )" ValueExpression="() => overrideCleanRaw" /> Override
<InputCheckbox Value="cleanRawVal" ValueChanged="@( (bool v) => cleanRawVal = v )" ValueExpression="() => cleanRawVal" disabled="@(!overrideCleanRaw)" />
</div>
<div>
<label>HLS Segments (VOD)</label>
<InputCheckbox Value="overrideHlsSegmentsVOD" ValueChanged="@( (bool v) => overrideHlsSegmentsVOD = v )" ValueExpression="() => overrideHlsSegmentsVOD" /> Override
<InputNumber @bind-Value="hlsSegmentsVODVal" disabled="@(!overrideHlsSegmentsVOD)" />
</div>
<div>
<label>Streamlink ttvlol</label>
<InputCheckbox Value="overrideStreamlinkTtvlol" ValueChanged="@( (bool v) => overrideStreamlinkTtvlol = v )" ValueExpression="() => overrideStreamlinkTtvlol" /> Override
<InputCheckbox Value="streamlinkTtvlolVal" ValueChanged="@( (bool v) => streamlinkTtvlolVal = v )" ValueExpression="() => streamlinkTtvlolVal" disabled="@(!overrideStreamlinkTtvlol)" />
</div>
<div>
<label>FFmpeg Audio Codec</label>
<InputCheckbox Value="overrideFfmpegAudioCodec" ValueChanged="@( (bool v) => overrideFfmpegAudioCodec = v )" ValueExpression="() => overrideFfmpegAudioCodec" /> Override
<InputText @bind-Value="ffmpegAudioCodecVal" disabled="@(!overrideFfmpegAudioCodec)" />
</div>
<div>
<label>FFmpeg Audio Sample Rate</label>
<InputCheckbox Value="overrideFfmpegAudioSamplerate" ValueChanged="@( (bool v) => overrideFfmpegAudioSamplerate = v )" ValueExpression="() => overrideFfmpegAudioSamplerate" /> Override
<InputNumber @bind-Value="ffmpegAudioSamplerateVal" disabled="@(!overrideFfmpegAudioSamplerate)" />
</div>
<div>
<label>FFmpeg Error Recovery</label>
<InputCheckbox Value="overrideFfmpegErrorRecovery" ValueChanged="@( (bool v) => overrideFfmpegErrorRecovery = v )" ValueExpression="() => overrideFfmpegErrorRecovery" /> Override
<InputCheckbox Value="ffmpegErrorRecoveryVal" ValueChanged="@( (bool v) => ffmpegErrorRecoveryVal = v )" ValueExpression="() => ffmpegErrorRecoveryVal" disabled="@(!overrideFfmpegErrorRecovery)" />
</div>
<div>
<label>FFmpeg Faststart</label>
<InputCheckbox Value="overrideFfmpegFaststart" ValueChanged="@( (bool v) => overrideFfmpegFaststart = v )" ValueExpression="() => overrideFfmpegFaststart" /> Override
<InputCheckbox Value="ffmpegFaststartVal" ValueChanged="@( (bool v) => ffmpegFaststartVal = v )" ValueExpression="() => ffmpegFaststartVal" disabled="@(!overrideFfmpegFaststart)" />
</div>
<div>
<label>FFmpeg Progress</label>
<InputCheckbox Value="overrideFfmpegProgress" ValueChanged="@( (bool v) => overrideFfmpegProgress = v )" ValueExpression="() => overrideFfmpegProgress" /> Override
<InputCheckbox Value="ffmpegProgressVal" ValueChanged="@( (bool v) => ffmpegProgressVal = v )" ValueExpression="() => ffmpegProgressVal" disabled="@(!overrideFfmpegProgress)" />
</div>
</div>
<button type="submit">Save</button>
<button type="button" @onclick="Delete">Delete</button>
<button type="submit" title="Save streamer configuration">Save</button>
<button type="button" @onclick="() => showConfirm = true" title="Delete this streamer and its configuration">Delete</button>
</EditForm>
@* Confirmation modal *@
@if (showConfirm)
{
<div class="modal-backdrop">
<div class="modal card">
<div class="card-body">
<h4>Confirm delete</h4>
<p>Delete streamer '@Username'? This will remove its config file.</p>
<div class="actions">
<button class="btn-link" @onclick="ConfirmDelete">Yes, delete</button>
<button class="btn-link" @onclick="() => showConfirm = false">Cancel</button>
</div>
</div>
</div>
</div>
}
@code {
[Parameter]
public string Username { get; set; } = string.Empty;
private TwitchArchive.Core.Config.StreamerConfig model = new();
private TwitchArchive.Core.Config.GlobalConfig? global;
private bool saved = false;
private bool showConfirm = false;
private bool overrideQuality = false;
private bool overrideUpload = false;
private bool overrideStreamlink = false;
private bool overrideDownloadVOD = false;
private bool overrideDownloadCHAT = false;
private bool overrideMergeVideoChat = false;
private bool overrideMergeChatLayout = false;
private bool overrideVodTimeout = false;
private bool overrideDeleteFiles = false;
private bool overrideHlsSegments = false;
private bool overrideFfmpegHwaccel = false;
private bool overrideFfmpegThreads = false;
private bool overrideFfmpegAudioBitrate = false;
private bool overrideDownloadLiveCHAT = false;
private bool overrideUploadPreMergeVideo = false;
private bool overrideUploadMergedVideo = false;
private bool overrideUploadChatVideo = false;
private bool overrideOnlyRaw = false;
private bool overrideCleanRaw = false;
private bool overrideHlsSegmentsVOD = false;
private bool overrideStreamlinkTtvlol = false;
private bool overrideFfmpegAudioCodec = false;
private bool overrideFfmpegAudioSamplerate = false;
private bool overrideFfmpegErrorRecovery = false;
private bool overrideFfmpegFaststart = false;
private bool overrideFfmpegProgress = false;
// local values for nullable per-streamer settings (bind safely)
private bool downloadVODVal;
private bool downloadCHATVal;
private bool downloadLiveCHATVal;
private bool mergeVideoChatVal;
private string mergeChatLayoutVal = "side-by-side";
private int? vodTimeoutVal;
private bool deleteFilesVal;
private int? hlsSegmentsVal;
private string ffmpegHwaccelVal = "auto";
private int? ffmpegThreadsVal;
private string? ffmpegAudioBitrateVal;
private bool uploadPreMergeVideoVal;
private bool uploadMergedVideoVal;
private bool uploadChatVideoVal;
private bool onlyRawVal;
private bool cleanRawVal;
private int? hlsSegmentsVODVal;
private bool streamlinkTtvlolVal;
private string? ffmpegAudioCodecVal;
private int? ffmpegAudioSamplerateVal;
private bool ffmpegErrorRecoveryVal;
private bool ffmpegFaststartVal;
private bool ffmpegProgressVal;
private bool uploadToCloudVal;
protected override void OnInitialized()
{
global = ConfigService.LoadGlobal();
var s = ConfigService.LoadStreamer(Username);
if (s != null) model = s;
// initialize local values from model or global defaults
downloadVODVal = model.DownloadVOD ?? global?.Defaults.DownloadVOD ?? true;
downloadCHATVal = model.DownloadCHAT ?? global?.Defaults.DownloadCHAT ?? true;
downloadLiveCHATVal = model.DownloadLiveCHAT ?? global?.Defaults.DownloadLiveCHAT ?? true;
mergeVideoChatVal = model.MergeVideoChat ?? global?.Defaults.MergeVideoChat ?? false;
mergeChatLayoutVal = model.MergeChatLayout ?? global?.Defaults.MergeChatLayout ?? "side-by-side";
vodTimeoutVal = model.VodTimeout ?? global?.Defaults.VodTimeout ?? 300;
deleteFilesVal = model.DeleteFiles ?? global?.Defaults.DeleteFiles ?? false;
hlsSegmentsVal = model.HlsSegments ?? global?.Defaults.HlsSegments ?? 3;
ffmpegHwaccelVal = model.FfmpegHwaccel ?? global?.Defaults.FfmpegHwaccel ?? "auto";
ffmpegThreadsVal = model.FfmpegThreads ?? global?.Defaults.FfmpegThreads ?? 0;
ffmpegAudioBitrateVal = model.FfmpegAudioBitrate ?? global?.Defaults.FfmpegAudioBitrate ?? "192k";
uploadPreMergeVideoVal = model.UploadPreMergeVideo ?? global?.Defaults.UploadPreMergeVideo ?? true;
uploadMergedVideoVal = model.UploadMergedVideo ?? global?.Defaults.UploadMergedVideo ?? true;
uploadChatVideoVal = model.UploadChatVideo ?? global?.Defaults.UploadChatVideo ?? false;
onlyRawVal = model.OnlyRaw ?? global?.Defaults.OnlyRaw ?? false;
cleanRawVal = model.CleanRaw ?? global?.Defaults.CleanRaw ?? true;
hlsSegmentsVODVal = model.HlsSegmentsVOD ?? global?.Defaults.HlsSegmentsVOD ?? 10;
streamlinkTtvlolVal = model.StreamlinkTtvlol ?? global?.Defaults.StreamlinkTtvlol ?? false;
ffmpegAudioCodecVal = model.FfmpegAudioCodec ?? global?.Defaults.FfmpegAudioCodec ?? "aac";
ffmpegAudioSamplerateVal = model.FfmpegAudioSamplerate ?? global?.Defaults.FfmpegAudioSamplerate ?? 48000;
ffmpegErrorRecoveryVal = model.FfmpegErrorRecovery ?? global?.Defaults.FfmpegErrorRecovery ?? true;
ffmpegFaststartVal = model.FfmpegFaststart ?? global?.Defaults.FfmpegFaststart ?? true;
ffmpegProgressVal = model.FfmpegProgress ?? global?.Defaults.FfmpegProgress ?? false;
uploadToCloudVal = model.UploadToCloud ?? global?.UploadToCloud ?? false;
}
private void Save()
{
model.Username = Username;
if (!overrideQuality) model.Quality = null;
if (!overrideUpload) model.UploadToCloud = null;
if (!overrideStreamlink) model.StreamlinkPath = null;
// Upload to cloud
model.UploadToCloud = overrideUpload ? uploadToCloudVal : (bool?)null;
// Streamlink path
model.StreamlinkPath = overrideStreamlink ? model.StreamlinkPath : null;
// Per-streamer values: map local values when overridden, otherwise clear
model.DownloadVOD = overrideDownloadVOD ? downloadVODVal : (bool?)null;
model.DownloadCHAT = overrideDownloadCHAT ? downloadCHATVal : (bool?)null;
model.DownloadLiveCHAT = overrideDownloadLiveCHAT ? downloadLiveCHATVal : (bool?)null;
model.MergeVideoChat = overrideMergeVideoChat ? mergeVideoChatVal : (bool?)null;
model.MergeChatLayout = overrideMergeChatLayout ? mergeChatLayoutVal : null;
model.VodTimeout = overrideVodTimeout ? vodTimeoutVal : (int?)null;
model.DeleteFiles = overrideDeleteFiles ? deleteFilesVal : (bool?)null;
model.HlsSegments = overrideHlsSegments ? hlsSegmentsVal : (int?)null;
model.FfmpegHwaccel = overrideFfmpegHwaccel ? ffmpegHwaccelVal : null;
model.FfmpegThreads = overrideFfmpegThreads ? ffmpegThreadsVal : (int?)null;
model.FfmpegAudioBitrate = overrideFfmpegAudioBitrate ? ffmpegAudioBitrateVal : null;
model.UploadPreMergeVideo = overrideUploadPreMergeVideo ? uploadPreMergeVideoVal : (bool?)null;
model.UploadMergedVideo = overrideUploadMergedVideo ? uploadMergedVideoVal : (bool?)null;
model.UploadChatVideo = overrideUploadChatVideo ? uploadChatVideoVal : (bool?)null;
model.OnlyRaw = overrideOnlyRaw ? onlyRawVal : (bool?)null;
model.CleanRaw = overrideCleanRaw ? cleanRawVal : (bool?)null;
model.HlsSegmentsVOD = overrideHlsSegmentsVOD ? hlsSegmentsVODVal : (int?)null;
model.StreamlinkTtvlol = overrideStreamlinkTtvlol ? streamlinkTtvlolVal : (bool?)null;
model.FfmpegAudioCodec = overrideFfmpegAudioCodec ? ffmpegAudioCodecVal : null;
model.FfmpegAudioSamplerate = overrideFfmpegAudioSamplerate ? ffmpegAudioSamplerateVal : (int?)null;
model.FfmpegErrorRecovery = overrideFfmpegErrorRecovery ? ffmpegErrorRecoveryVal : (bool?)null;
model.FfmpegFaststart = overrideFfmpegFaststart ? ffmpegFaststartVal : (bool?)null;
model.FfmpegProgress = overrideFfmpegProgress ? ffmpegProgressVal : (bool?)null;
ConfigService.SaveStreamer(model);
saved = true;
}
private void Delete()
private void ConfirmDelete()
{
ConfigService.DeleteStreamer(Username);
Nav.NavigateTo("/");
try
{
ConfigService.DeleteStreamer(Username);
showConfirm = false;
Nav.NavigateTo("/");
}
catch
{
// ignore
}
}
}

View file

@ -6,6 +6,7 @@
<div class="streamer-header">
<span class="status">Status: <strong>@(WorkerManager.IsRunning(Username) ? "Live" : "Offline")</strong></span>
<span class="actions"><a class="btn-link" href="/config/@Username">Edit Settings</a></span>
</div>
<div class="pipeline">

View file

@ -65,7 +65,8 @@ using (var scope = app.Services.CreateScope())
try
{
using var db = factory.CreateDbContext();
db.Database.EnsureCreated();
// Apply any pending EF migrations (creates/updates schema as needed)
db.Database.Migrate();
}
catch { }
}

View file

@ -1,18 +1,22 @@
using System;
using System.IO;
using System.Text.Json;
using TwitchArchive.Core.Config;
using System.Linq;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using TwitchArchive.Core.Persistence;
using TwitchArchive.Core.Persistence.Models;
namespace TwitchArchive.Web.Services
{
public class AuthService : IAuthService
{
private readonly string _file;
private AppSettings _settings = new();
private readonly IDbContextFactory<ArchiveDbContext> _dbFactory;
private readonly ILogger<AuthService> _log;
private string? _cachedHash;
public AuthService()
public AuthService(IDbContextFactory<ArchiveDbContext> dbFactory, ILogger<AuthService> log)
{
_file = Path.Combine(AppContext.BaseDirectory, "appsettings.json");
_dbFactory = dbFactory ?? throw new ArgumentNullException(nameof(dbFactory));
_log = log;
Refresh();
}
@ -20,31 +24,52 @@ namespace TwitchArchive.Web.Services
{
try
{
if (!File.Exists(_file)) { _settings = new AppSettings(); return; }
var txt = File.ReadAllText(_file);
_settings = JsonSerializer.Deserialize<AppSettings>(txt) ?? new AppSettings();
using var ctx = _dbFactory.CreateDbContext();
var u = ctx.UserCredentials.AsNoTracking().FirstOrDefault();
_cachedHash = u?.PasswordHash;
}
catch (Exception ex)
{
_log?.LogWarning(ex, "Failed to read password from database");
_cachedHash = null;
}
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; }
// If no password configured, allow access (default open)
if (string.IsNullOrWhiteSpace(_cachedHash)) return true;
try { return BCrypt.Net.BCrypt.Verify(plain ?? string.Empty, _cachedHash); }
catch (Exception ex)
{
_log?.LogWarning(ex, "Password verification failed");
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();
using var ctx = _dbFactory.CreateDbContext();
var existing = ctx.UserCredentials.FirstOrDefault();
if (existing == null)
{
existing = new UserCredential { PasswordHash = hash };
ctx.UserCredentials.Add(existing);
}
else
{
existing.PasswordHash = hash;
ctx.UserCredentials.Update(existing);
}
ctx.SaveChanges();
_cachedHash = hash;
}
catch (Exception ex)
{
_log?.LogError(ex, "Failed to save password to database");
}
catch { }
}
}
}

View file

@ -1,17 +1,27 @@
@inherits LayoutComponentBase
<header class="topbar">
<button class="hamburger" @onclick="ToggleSidebar">☰</button>
<h1 class="title">Twitch Archive</h1>
<div class="topbar-inner">
<button class="hamburger" @onclick="ToggleSidebar">☰</button>
<h1 class="title">Twitch Archive</h1>
<nav class="top-actions">
<NavLink href="/login" class="action">Login</NavLink>
</nav>
</div>
</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>
<div class="brand">Twitch Archive</div>
<ul class="nav-list">
<li><NavLink href="/" class="nav-link">Dashboard</NavLink></li>
<li><NavLink href="/" class="nav-link">Streamers</NavLink></li>
<li><NavLink href="/addstreamer" class="nav-link">Add Streamer</NavLink></li>
<li><NavLink href="/sessions" class="nav-link">Sessions</NavLink></li>
<li><NavLink href="/monitor" class="nav-link">Monitor</NavLink></li>
<li><NavLink href="/settings" class="nav-link">Settings</NavLink></li>
</ul>
</nav>
<main class="main">
@Body
<div class="container">@Body</div>
</main>
</div>

View file

@ -1,16 +1,34 @@
/* 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; }
:root{
--bg:#0f1720; --panel:#111322; --muted:#9aa4c5; --accent:#7dd3fc; --accent-2:#89b4fa; --card:#0b1220;
}
html,body { height:100%; margin:0; font-family:Segoe UI, Roboto, -apple-system, sans-serif; background:var(--bg); color:#e6eef8; }
.page { display:flex; min-height:100vh; }
.sidebar { width:220px; flex-shrink:0; background:var(--panel); color:var(--muted); overflow-y:auto; padding:1rem 0; box-shadow:2px 0 8px rgba(6,6,10,0.6); }
.sidebar.collapsed { width:66px; }
.brand { font-weight:600; padding:0 1rem 0.8rem 1rem; color:#fff; }
.nav-list { list-style:none; margin:0; padding:0; }
.nav-list li { margin:0.2rem 0; }
.nav-link { display:block; padding:0.6rem 1rem; color:var(--muted); text-decoration:none; border-left:4px solid transparent; }
.nav-link.active, .nav-link:hover { background:linear-gradient(90deg, rgba(255,255,255,0.02), transparent); color:#fff; border-left-color:var(--accent-2); }
.topbar { display:flex; align-items:center; background:var(--panel); padding:0.6rem 1rem; box-shadow:0 2px 6px rgba(0,0,0,0.4); }
.topbar .topbar-inner { display:flex; align-items:center; width:100%; }
.hamburger { font-size:1.2rem; margin-right:1rem; background:transparent; border:none; color:inherit; cursor:pointer; }
.title { margin:0; font-size:1.05rem; flex:1; }
.top-actions .action { color:var(--muted); text-decoration:none; margin-left:1rem; }
.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; }
.container { max-width:1100px; margin:0 auto; }
/* Cards and forms */
.card { background:var(--card); padding:1rem; border-radius:8px; box-shadow:0 2px 8px rgba(2,6,23,0.6); margin-bottom:1rem; }
.card-header { display:flex; justify-content:space-between; align-items:center; font-weight:600; }
.card-body { margin-top:0.5rem; color:var(--muted); }
button { background:var(--accent); border:none; color:#002; padding:0.45rem 0.8rem; border-radius:6px; cursor:pointer; }
button:hover { opacity:0.95; }
input[type=password], input[type=text], input[type=number], .input, InputText { padding:0.5rem; border-radius:6px; border:1px solid rgba(255,255,255,0.06); background:transparent; color:inherit; }
@media(max-width:768px) {
.sidebar { display:none; }
.topbar { display:flex; }
}
@media(min-width:769px) {
.topbar { display:none; }
}