From 71de97e04f2c87432099d8317d4f8c146c87079d Mon Sep 17 00:00:00 2001 From: Pat Hartl Date: Mon, 27 Apr 2026 17:58:48 -0500 Subject: [PATCH] FIx preview page uploader. Add progress when downloading videos. Fix large video downloads. --- .../Abstractions/IMediaGrabberService.cs | 3 + .../CompositeMediaGrabberService.cs | 7 +- .../MediaGrabbers/SteamGridDBMediaGrabber.cs | 15 +++- .../MediaGrabbers/SteamMediaGrabber.cs | 15 +++- .../MediaGrabbers/YouTubeMediaGrabber.cs | 63 ++++++++++++++-- .../Models/MediaDownloadProgress.cs | 13 ++++ .../Models/MediaGrabberDownloadResult.cs | 8 ++ .../Models/ProgressStream.cs | 72 ++++++++++++++++++ .../UI/Components/MediaGrabberDialog.razor | 75 ++++++++++++++++--- .../Games/Components/GameDetailPreview.razor | 26 ++++--- .../Pages/Games/Components/MediaEditor.razor | 12 ++- .../Components/DomHelper/DomHelper.ts | 12 +++ LANCommander.UI/_Imports.razor.ts | 3 +- 13 files changed, 282 insertions(+), 42 deletions(-) create mode 100644 LANCommander.Server.Services/Models/MediaDownloadProgress.cs create mode 100644 LANCommander.Server.Services/Models/MediaGrabberDownloadResult.cs create mode 100644 LANCommander.Server.Services/Models/ProgressStream.cs create mode 100644 LANCommander.UI/Components/DomHelper/DomHelper.ts diff --git a/LANCommander.Server.Services/Abstractions/IMediaGrabberService.cs b/LANCommander.Server.Services/Abstractions/IMediaGrabberService.cs index ca7ba6c6..7bc992a8 100644 --- a/LANCommander.Server.Services/Abstractions/IMediaGrabberService.cs +++ b/LANCommander.Server.Services/Abstractions/IMediaGrabberService.cs @@ -11,6 +11,9 @@ namespace LANCommander.Server.Services.Abstractions Task> SearchAsync(MediaType type, string keywords); Task DownloadAsync(MediaGrabberResult result); + Task DownloadAsync(MediaGrabberResult result, IProgress? progress) + => DownloadAsync(result); + async IAsyncEnumerable> SearchStreamAsync( MediaType type, string keywords, [EnumeratorCancellation] CancellationToken cancellationToken = default) diff --git a/LANCommander.Server.Services/MediaGrabbers/CompositeMediaGrabberService.cs b/LANCommander.Server.Services/MediaGrabbers/CompositeMediaGrabberService.cs index 6fe05d6d..a4ad0974 100644 --- a/LANCommander.Server.Services/MediaGrabbers/CompositeMediaGrabberService.cs +++ b/LANCommander.Server.Services/MediaGrabbers/CompositeMediaGrabberService.cs @@ -66,11 +66,16 @@ namespace LANCommander.Server.Services.MediaGrabbers } public async Task DownloadAsync(MediaGrabberResult result) + { + return await DownloadAsync(result, null); + } + + public async Task DownloadAsync(MediaGrabberResult result, IProgress? progress) { var grabber = _grabbers.FirstOrDefault(g => g.Name == result.GrabberName) ?? _grabbers.First(); - return await grabber.DownloadAsync(result); + return await grabber.DownloadAsync(result, progress); } } } diff --git a/LANCommander.Server.Services/MediaGrabbers/SteamGridDBMediaGrabber.cs b/LANCommander.Server.Services/MediaGrabbers/SteamGridDBMediaGrabber.cs index 17bc8c78..078c923a 100644 --- a/LANCommander.Server.Services/MediaGrabbers/SteamGridDBMediaGrabber.cs +++ b/LANCommander.Server.Services/MediaGrabbers/SteamGridDBMediaGrabber.cs @@ -69,10 +69,19 @@ namespace LANCommander.Server.Services.MediaGrabbers return results; } - public async Task DownloadAsync(MediaGrabberResult result) + public Task DownloadAsync(MediaGrabberResult result) + => DownloadAsync(result, null); + + public async Task DownloadAsync(MediaGrabberResult result, IProgress? progress) { - var http = new HttpClient(); - var stream = await http.GetStreamAsync(result.SourceUrl); + using var http = new HttpClient { Timeout = TimeSpan.FromMinutes(30) }; + + using var response = await http.GetAsync(result.SourceUrl, HttpCompletionOption.ResponseHeadersRead); + response.EnsureSuccessStatusCode(); + + var totalBytes = response.Content.Headers.ContentLength; + var stream = await ProgressStream.CopyToTempFileAsync( + await response.Content.ReadAsStreamAsync(), totalBytes, progress); return new MediaGrabberDownload { diff --git a/LANCommander.Server.Services/MediaGrabbers/SteamMediaGrabber.cs b/LANCommander.Server.Services/MediaGrabbers/SteamMediaGrabber.cs index 4f406eb9..7c8f1613 100644 --- a/LANCommander.Server.Services/MediaGrabbers/SteamMediaGrabber.cs +++ b/LANCommander.Server.Services/MediaGrabbers/SteamMediaGrabber.cs @@ -43,10 +43,19 @@ namespace LANCommander.Server.Services.MediaGrabbers }; } - public async Task DownloadAsync(MediaGrabberResult result) + public Task DownloadAsync(MediaGrabberResult result) + => DownloadAsync(result, null); + + public async Task DownloadAsync(MediaGrabberResult result, IProgress? progress) { - var http = new HttpClient(); - var stream = await http.GetStreamAsync(result.SourceUrl); + using var http = new HttpClient { Timeout = TimeSpan.FromMinutes(30) }; + + using var response = await http.GetAsync(result.SourceUrl, HttpCompletionOption.ResponseHeadersRead); + response.EnsureSuccessStatusCode(); + + var totalBytes = response.Content.Headers.ContentLength; + var stream = await ProgressStream.CopyToTempFileAsync( + await response.Content.ReadAsStreamAsync(), totalBytes, progress); return new MediaGrabberDownload { diff --git a/LANCommander.Server.Services/MediaGrabbers/YouTubeMediaGrabber.cs b/LANCommander.Server.Services/MediaGrabbers/YouTubeMediaGrabber.cs index aa93e7c6..192076c9 100644 --- a/LANCommander.Server.Services/MediaGrabbers/YouTubeMediaGrabber.cs +++ b/LANCommander.Server.Services/MediaGrabbers/YouTubeMediaGrabber.cs @@ -9,7 +9,7 @@ using YoutubeExplode.Common; namespace LANCommander.Server.Services.MediaGrabbers { - public class YouTubeMediaGrabber(ILogger logger) : IMediaGrabberService + public partial class YouTubeMediaGrabber(ILogger logger) : IMediaGrabberService { public string Name => "YouTube"; @@ -39,12 +39,21 @@ namespace LANCommander.Server.Services.MediaGrabbers return results; } - public async Task DownloadAsync(MediaGrabberResult result) + public Task DownloadAsync(MediaGrabberResult result) + => DownloadAsync(result, null); + + public async Task DownloadAsync(MediaGrabberResult result, IProgress? progress) { if (!IsYouTubeUrl(result.SourceUrl)) { - var http = new HttpClient(); - var stream = await http.GetStreamAsync(result.SourceUrl); + using var http = new HttpClient { Timeout = TimeSpan.FromMinutes(30) }; + + using var response = await http.GetAsync(result.SourceUrl, HttpCompletionOption.ResponseHeadersRead); + response.EnsureSuccessStatusCode(); + + var totalBytes = response.Content.Headers.ContentLength; + var stream = await ProgressStream.CopyToTempFileAsync( + await response.Content.ReadAsStreamAsync(), totalBytes, progress); return new MediaGrabberDownload { @@ -60,11 +69,13 @@ namespace LANCommander.Server.Services.MediaGrabbers var outputTemplate = Path.Combine(tempDir, "video.%(ext)s"); + progress?.Report(new MediaDownloadProgress { Status = "Starting yt-dlp..." }); + using var process = new Process(); process.StartInfo = new ProcessStartInfo { FileName = ytdlpPath, - Arguments = $"--no-playlist -f \"bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best\" --merge-output-format mp4 -o \"{outputTemplate}\" \"{result.SourceUrl}\"", + Arguments = $"--no-playlist --newline -f \"bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best\" --merge-output-format mp4 -o \"{outputTemplate}\" \"{result.SourceUrl}\"", RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, @@ -72,16 +83,49 @@ namespace LANCommander.Server.Services.MediaGrabbers }; process.Start(); + + // Parse yt-dlp stdout for progress lines like "[download] 45.2% of 120.00MiB ..." + var stdoutTask = Task.Run(async () => + { + while (await process.StandardOutput.ReadLineAsync() is { } line) + { + if (progress == null) + continue; + + var match = YtDlpProgressRegex().Match(line); + if (match.Success && double.TryParse(match.Groups[1].Value, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var pct)) + { + progress.Report(new MediaDownloadProgress + { + BytesTransferred = (long)pct, + TotalBytes = 100, + Status = $"Downloading video... {pct:F1}%" + }); + } + else if (line.Contains("[Merger]") || line.Contains("Merging")) + { + progress.Report(new MediaDownloadProgress + { + BytesTransferred = 100, + TotalBytes = 100, + Status = "Merging audio and video..." + }); + } + } + }); + + var stderrTask = process.StandardError.ReadToEndAsync(); + await process.WaitForExitAsync(); + await stdoutTask; + var stderr = await stderrTask; if (process.ExitCode != 0) { - var error = await process.StandardError.ReadToEndAsync(); - if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true); - throw new Exception($"yt-dlp failed with exit code {process.ExitCode}: {error}"); + throw new Exception($"yt-dlp failed with exit code {process.ExitCode}: {stderr}"); } var outputFile = Directory.GetFiles(tempDir).FirstOrDefault(); @@ -111,6 +155,9 @@ namespace LANCommander.Server.Services.MediaGrabbers }; } + [System.Text.RegularExpressions.GeneratedRegex(@"\[download\]\s+([\d.]+)%")] + private static partial System.Text.RegularExpressions.Regex YtDlpProgressRegex(); + private static bool IsYouTubeUrl(string url) => url.Contains("youtube.com/") || url.Contains("youtu.be/"); diff --git a/LANCommander.Server.Services/Models/MediaDownloadProgress.cs b/LANCommander.Server.Services/Models/MediaDownloadProgress.cs new file mode 100644 index 00000000..c4408016 --- /dev/null +++ b/LANCommander.Server.Services/Models/MediaDownloadProgress.cs @@ -0,0 +1,13 @@ +namespace LANCommander.Server.Services.Models +{ + public class MediaDownloadProgress + { + public long BytesTransferred { get; set; } + public long? TotalBytes { get; set; } + public string Status { get; set; } = "Downloading..."; + + public double? Percent => TotalBytes > 0 + ? Math.Round((double)BytesTransferred / TotalBytes.Value * 100, 1) + : null; + } +} diff --git a/LANCommander.Server.Services/Models/MediaGrabberDownloadResult.cs b/LANCommander.Server.Services/Models/MediaGrabberDownloadResult.cs new file mode 100644 index 00000000..5f7c0e18 --- /dev/null +++ b/LANCommander.Server.Services/Models/MediaGrabberDownloadResult.cs @@ -0,0 +1,8 @@ +namespace LANCommander.Server.Services.Models +{ + public class MediaGrabberDownloadResult + { + public MediaGrabberResult Result { get; set; } + public MediaGrabberDownload Download { get; set; } + } +} diff --git a/LANCommander.Server.Services/Models/ProgressStream.cs b/LANCommander.Server.Services/Models/ProgressStream.cs new file mode 100644 index 00000000..4b26bad3 --- /dev/null +++ b/LANCommander.Server.Services/Models/ProgressStream.cs @@ -0,0 +1,72 @@ +namespace LANCommander.Server.Services.Models +{ + /// + /// Copies a source stream to a temporary file while reporting download progress. + /// The returned stream owns the temp file and deletes it on dispose. + /// + public static class ProgressStream + { + private const int BufferSize = 1024 * 1024; // 1 MB + + public static async Task CopyToTempFileAsync( + Stream source, long? totalBytes, IProgress? progress, + CancellationToken cancellationToken = default) + { + var tempPath = Path.GetTempFileName(); + long bytesTransferred = 0; + + try + { + using (var fs = new FileStream(tempPath, FileMode.Create, FileAccess.Write, FileShare.None, BufferSize)) + { + var buffer = new byte[BufferSize]; + int bytesRead; + + while ((bytesRead = await source.ReadAsync(buffer, 0, buffer.Length, cancellationToken)) > 0) + { + await fs.WriteAsync(buffer, 0, bytesRead, cancellationToken); + bytesTransferred += bytesRead; + + progress?.Report(new MediaDownloadProgress + { + BytesTransferred = bytesTransferred, + TotalBytes = totalBytes, + Status = "Downloading..." + }); + } + } + + return new TempFileStream(tempPath); + } + catch + { + if (File.Exists(tempPath)) + File.Delete(tempPath); + throw; + } + } + + private sealed class TempFileStream : FileStream + { + private readonly string _path; + + public TempFileStream(string path) + : base(path, FileMode.Open, FileAccess.Read, FileShare.Read) + { + _path = path; + } + + protected override void Dispose(bool disposing) + { + base.Dispose(disposing); + + try + { + if (File.Exists(_path)) + File.Delete(_path); + } + catch { } + } + } + } +} diff --git a/LANCommander.Server/UI/Components/MediaGrabberDialog.razor b/LANCommander.Server/UI/Components/MediaGrabberDialog.razor index c910668f..fb7602e4 100644 --- a/LANCommander.Server/UI/Components/MediaGrabberDialog.razor +++ b/LANCommander.Server/UI/Components/MediaGrabberDialog.razor @@ -1,5 +1,6 @@ -@inherits FeedbackComponent +@inherits FeedbackComponent @using LANCommander.Server.Services.Abstractions +@using LANCommander.Server.Services.Models @using LANCommander.SDK.Enums @inject IMediaGrabberService MediaGrabberService @inject IMessageService MessageService @@ -89,6 +90,17 @@ } +@if (_isDownloading) +{ +
+ + @_downloadStatus +
+ +
+
+} + @code { [Parameter] public string Search { get; set; } [Parameter] public MediaType Type { get; set; } @@ -98,6 +110,9 @@ double Size { get; set; } = 200; bool Loading { get; set; } = true; + bool _isDownloading; + double _downloadPercent; + string _downloadStatus = "Downloading..."; CancellationTokenSource _searchCts; List _allResults = new(); @@ -175,9 +190,7 @@ private async Task OnResultPicked(MediaGrabberResult result) { Media = result; - - await base.OkCancelRefWithResult!.OnOk(Media); - await CloseFeedbackAsync(); + await DownloadAndClose(); } private void OnImageSelected(string uniqueKey) @@ -188,9 +201,7 @@ private async Task OnImagePicked(string uniqueKey) { OnImageSelected(uniqueKey); - - await base.OkCancelRefWithResult!.OnOk(Media); - await CloseFeedbackAsync(); + await DownloadAndClose(); } private void PreviewVideo(MediaGrabberResult result) @@ -211,8 +222,54 @@ public override async Task OnFeedbackOkAsync(ModalClosingEventArgs args) { - await base.OkCancelRefWithResult!.OnOk(Media); - await CloseFeedbackAsync(); + args.Cancel = true; + await DownloadAndClose(); + } + + public override async Task OnFeedbackCancelAsync(ModalClosingEventArgs args) + { + _preview = null; + await base.OnFeedbackCancelAsync(args); + } + + private async Task DownloadAndClose() + { + if (Media == null) + return; + + _preview = null; + _isDownloading = true; + _downloadPercent = 0; + _downloadStatus = "Downloading..."; + await InvokeAsync(StateHasChanged); + + try + { + var progress = new Progress(p => + { + _downloadPercent = p.Percent ?? 0; + _downloadStatus = p.Status ?? "Downloading..."; + InvokeAsync(StateHasChanged); + }); + + var download = await MediaGrabberService.DownloadAsync(Media, progress); + + var result = new MediaGrabberDownloadResult + { + Result = Media, + Download = download + }; + + await base.OkCancelRefWithResult!.OnOk(result); + await CloseFeedbackAsync(); + } + catch (Exception ex) + { + _isDownloading = false; + await InvokeAsync(StateHasChanged); + MessageService.Error("Download failed. Please try again."); + Logger.LogError(ex, "Failed to download media"); + } } private class GroupedResults diff --git a/LANCommander.Server/UI/Pages/Games/Components/GameDetailPreview.razor b/LANCommander.Server/UI/Pages/Games/Components/GameDetailPreview.razor index c0836556..5fbf16d2 100644 --- a/LANCommander.Server/UI/Pages/Games/Components/GameDetailPreview.razor +++ b/LANCommander.Server/UI/Pages/Games/Components/GameDetailPreview.razor @@ -1,8 +1,10 @@ @using Microsoft.Extensions.Options @using LANCommander.Server.Settings @using LANCommander.Server.Services.Abstractions +@using LANCommander.Server.Services.Models @using Microsoft.EntityFrameworkCore @using System.Net.Mime +@using LANCommander.UI.Providers @inject MediaService MediaService @inject GameService GameService @inject IMediaGrabberService MediaGrabberService @@ -10,7 +12,7 @@ @inject IMessageService MessageService @inject IOptions Settings @inject ILogger Logger -@inject IJSRuntime JS +@inject ScriptProvider ScriptProvider
@@ -322,6 +324,7 @@ List _media = new(); Game _game; + IJSObjectReference _domHelper; protected override async Task OnInitializedAsync() { @@ -341,8 +344,14 @@ _media = _game.Media?.ToList() ?? new(); } + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + _domHelper = await ScriptProvider.ImportModuleAsync("DomHelper"); + } + async Task ClickFileInput(string inputId) => - await JS.InvokeVoidAsync("clickElement", inputId); + await _domHelper.InvokeVoidAsync("ClickElement", inputId); Media? GetMedia(MediaType type) => _media.FirstOrDefault(m => m.Type == type); @@ -431,13 +440,11 @@ Search = _game.Title }; - var modalRef = await ModalService.CreateModalAsync(modalOptions, grabberOptions); + var modalRef = await ModalService.CreateModalAsync(modalOptions, grabberOptions); - modalRef.OnOk = async (result) => + modalRef.OnOk = async (downloadResult) => { - modalRef.Config.ConfirmLoading = true; - - using var download = await MediaGrabberService.DownloadAsync(result); + using var download = downloadResult.Download; var isSingular = type != MediaType.Screenshot && type != MediaType.Video; var existing = isSingular ? GetMedia(type) : null; @@ -447,7 +454,7 @@ if (existing != null) { media = existing; - media.SourceUrl = result.SourceUrl; + media.SourceUrl = downloadResult.Result.SourceUrl; media.MimeType = download.MimeType; MediaService.DeleteLocalMediaFile(media); @@ -459,7 +466,7 @@ { GameId = GameId, Type = type, - SourceUrl = result.SourceUrl, + SourceUrl = downloadResult.Result.SourceUrl, MimeType = download.MimeType, StorageLocation = await MediaService.GetDefaultStorageLocationAsync(), Crc32 = string.Empty, @@ -470,7 +477,6 @@ } await RefreshMedia(); - await InvokeAsync(StateHasChanged); }; } diff --git a/LANCommander.Server/UI/Pages/Games/Components/MediaEditor.razor b/LANCommander.Server/UI/Pages/Games/Components/MediaEditor.razor index aabdb576..0d704a2e 100644 --- a/LANCommander.Server/UI/Pages/Games/Components/MediaEditor.razor +++ b/LANCommander.Server/UI/Pages/Games/Components/MediaEditor.razor @@ -1,6 +1,7 @@ @using Microsoft.Extensions.Options @using LANCommander.Server.Settings @using LANCommander.Server.Services.Abstractions +@using LANCommander.Server.Services.Models @using Microsoft.EntityFrameworkCore @using System.Net.Mime @inject MediaService MediaService @@ -203,15 +204,13 @@ Search = _game.Title }; - var modalRef = await ModalService.CreateModalAsync(modalOptions, grabberOptions); + var modalRef = await ModalService.CreateModalAsync(modalOptions, grabberOptions); - modalRef.OnOk = async (result) => + modalRef.OnOk = async (downloadResult) => { - modalRef.Config.ConfirmLoading = true; + using var download = downloadResult.Download; - using var download = await MediaGrabberService.DownloadAsync(result); - - media.SourceUrl = result.SourceUrl; + media.SourceUrl = downloadResult.Result.SourceUrl; media.MimeType = download.MimeType; if (media.Id == Guid.Empty) @@ -221,7 +220,6 @@ else { MediaService.DeleteLocalMediaFile(media); - media = await MediaService.WriteToFileAsync(media, download.Stream); } diff --git a/LANCommander.UI/Components/DomHelper/DomHelper.ts b/LANCommander.UI/Components/DomHelper/DomHelper.ts new file mode 100644 index 00000000..c20dcf1a --- /dev/null +++ b/LANCommander.UI/Components/DomHelper/DomHelper.ts @@ -0,0 +1,12 @@ +export class DomHelper +{ + public static Create(): DomHelper + { + return new DomHelper(); + } + + public ClickElement(id: string): void + { + document.getElementById(id)?.click(); + } +} diff --git a/LANCommander.UI/_Imports.razor.ts b/LANCommander.UI/_Imports.razor.ts index c0573b5a..09a6efac 100644 --- a/LANCommander.UI/_Imports.razor.ts +++ b/LANCommander.UI/_Imports.razor.ts @@ -4,4 +4,5 @@ export { InfiniteScroll } from "./Components/InfiniteLoader/InfiniteScroll"; export { ChunkUploader } from "./Components/ChunkUploader/ChunkUploader"; export { SplitPane } from "./Components/SplitPane/SplitPane"; export { TimeProvider } from "./Components/LocalTime/TimeProvider"; -export { Terminal } from "./Components/Terminal/Terminal"; \ No newline at end of file +export { Terminal } from "./Components/Terminal/Terminal"; +export { DomHelper } from "./Components/DomHelper/DomHelper"; \ No newline at end of file