FIx preview page uploader. Add progress when downloading videos. Fix large video downloads.

This commit is contained in:
Pat Hartl 2026-04-27 17:58:48 -05:00
parent f9abb08141
commit 71de97e04f
13 changed files with 282 additions and 42 deletions

View file

@ -11,6 +11,9 @@ namespace LANCommander.Server.Services.Abstractions
Task<IEnumerable<MediaGrabberResult>> SearchAsync(MediaType type, string keywords);
Task<MediaGrabberDownload> DownloadAsync(MediaGrabberResult result);
Task<MediaGrabberDownload> DownloadAsync(MediaGrabberResult result, IProgress<MediaDownloadProgress>? progress)
=> DownloadAsync(result);
async IAsyncEnumerable<IEnumerable<MediaGrabberResult>> SearchStreamAsync(
MediaType type, string keywords,
[EnumeratorCancellation] CancellationToken cancellationToken = default)

View file

@ -66,11 +66,16 @@ namespace LANCommander.Server.Services.MediaGrabbers
}
public async Task<MediaGrabberDownload> DownloadAsync(MediaGrabberResult result)
{
return await DownloadAsync(result, null);
}
public async Task<MediaGrabberDownload> DownloadAsync(MediaGrabberResult result, IProgress<MediaDownloadProgress>? progress)
{
var grabber = _grabbers.FirstOrDefault(g => g.Name == result.GrabberName)
?? _grabbers.First();
return await grabber.DownloadAsync(result);
return await grabber.DownloadAsync(result, progress);
}
}
}

View file

@ -69,10 +69,19 @@ namespace LANCommander.Server.Services.MediaGrabbers
return results;
}
public async Task<MediaGrabberDownload> DownloadAsync(MediaGrabberResult result)
public Task<MediaGrabberDownload> DownloadAsync(MediaGrabberResult result)
=> DownloadAsync(result, null);
public async Task<MediaGrabberDownload> DownloadAsync(MediaGrabberResult result, IProgress<MediaDownloadProgress>? 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
{

View file

@ -43,10 +43,19 @@ namespace LANCommander.Server.Services.MediaGrabbers
};
}
public async Task<MediaGrabberDownload> DownloadAsync(MediaGrabberResult result)
public Task<MediaGrabberDownload> DownloadAsync(MediaGrabberResult result)
=> DownloadAsync(result, null);
public async Task<MediaGrabberDownload> DownloadAsync(MediaGrabberResult result, IProgress<MediaDownloadProgress>? 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
{

View file

@ -9,7 +9,7 @@ using YoutubeExplode.Common;
namespace LANCommander.Server.Services.MediaGrabbers
{
public class YouTubeMediaGrabber(ILogger<YouTubeMediaGrabber> logger) : IMediaGrabberService
public partial class YouTubeMediaGrabber(ILogger<YouTubeMediaGrabber> logger) : IMediaGrabberService
{
public string Name => "YouTube";
@ -39,12 +39,21 @@ namespace LANCommander.Server.Services.MediaGrabbers
return results;
}
public async Task<MediaGrabberDownload> DownloadAsync(MediaGrabberResult result)
public Task<MediaGrabberDownload> DownloadAsync(MediaGrabberResult result)
=> DownloadAsync(result, null);
public async Task<MediaGrabberDownload> DownloadAsync(MediaGrabberResult result, IProgress<MediaDownloadProgress>? 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/");

View file

@ -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;
}
}

View file

@ -0,0 +1,8 @@
namespace LANCommander.Server.Services.Models
{
public class MediaGrabberDownloadResult
{
public MediaGrabberResult Result { get; set; }
public MediaGrabberDownload Download { get; set; }
}
}

View file

@ -0,0 +1,72 @@
namespace LANCommander.Server.Services.Models
{
/// <summary>
/// Copies a source stream to a temporary file while reporting download progress.
/// The returned stream owns the temp file and deletes it on dispose.
/// </summary>
public static class ProgressStream
{
private const int BufferSize = 1024 * 1024; // 1 MB
public static async Task<Stream> CopyToTempFileAsync(
Stream source, long? totalBytes, IProgress<MediaDownloadProgress>? 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 { }
}
}
}
}

View file

@ -1,5 +1,6 @@
@inherits FeedbackComponent<MediaGrabberOptions, MediaGrabberResult>
@inherits FeedbackComponent<MediaGrabberOptions, MediaGrabberDownloadResult>
@using LANCommander.Server.Services.Abstractions
@using LANCommander.Server.Services.Models
@using LANCommander.SDK.Enums
@inject IMediaGrabberService MediaGrabberService
@inject IMessageService MessageService
@ -89,6 +90,17 @@
<Empty Description="@($"No results for \"{Search}\"")" />
}
@if (_isDownloading)
{
<div style="position: absolute; inset: 0; background: rgba(0,0,0,0.75); display: flex; flex-direction: column; align-items: center; justify-content: center; z-index: 10; gap: 12px; border-radius: 8px;">
<Spin Spinning="true" />
<span>@_downloadStatus</span>
<div style="width: 60%;">
<Progress Percent="_downloadPercent" Size="ProgressSize.Small" Status="@ProgressStatus.Active" />
</div>
</div>
}
@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<MediaGrabberResult> _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<MediaDownloadProgress>(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

View file

@ -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> Settings
@inject ILogger<GameDetailPreview> Logger
@inject IJSRuntime JS
@inject ScriptProvider ScriptProvider
<div class="game-detail-preview">
<!-- ══ Hero Section ═══════════════════════════════════════════ -->
@ -322,6 +324,7 @@
List<Media> _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<MediaGrabberDialog, MediaGrabberOptions, MediaGrabberResult>(modalOptions, grabberOptions);
var modalRef = await ModalService.CreateModalAsync<MediaGrabberDialog, MediaGrabberOptions, MediaGrabberDownloadResult>(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);
};
}

View file

@ -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<MediaGrabberDialog, MediaGrabberOptions, MediaGrabberResult>(modalOptions, grabberOptions);
var modalRef = await ModalService.CreateModalAsync<MediaGrabberDialog, MediaGrabberOptions, MediaGrabberDownloadResult>(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);
}

View file

@ -0,0 +1,12 @@
export class DomHelper
{
public static Create(): DomHelper
{
return new DomHelper();
}
public ClickElement(id: string): void
{
document.getElementById(id)?.click();
}
}

View file

@ -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";
export { Terminal } from "./Components/Terminal/Terminal";
export { DomHelper } from "./Components/DomHelper/DomHelper";