T3-11/14/15 + M2 잔여: 권한 정책 UI(PermissionGate/PermissionRequested 다이얼로그+SavesInProfile), 추적 방지(TrackerBlocklist/WebResourceRequested 차단+실드 배지·팝업), 주소창 진행 바+파라미터 제거 복사(UrlCleaner/NavProgressTracker), U5 완료 알림(Mobile.Core 감지기+Plugin.LocalNotification), 재생중 ongoing 미디어 알림, 썸네일/해상도 API(Thumbnailer 사이드카 캐시+/thumb)+라이브러리 그리드, 외부망 안내(ConnectionTroubleshoot). 147 tests GREEN
This commit is contained in:
parent
27c91c04dd
commit
46c6c321f2
26 changed files with 1058 additions and 30 deletions
|
|
@ -96,8 +96,30 @@ public sealed class ServerHost : IAsyncDisposable
|
|||
app.MapPost("/api/queue/{id}/cancel", async (string id) => { await queue.CancelAsync(id); Results.Ok(); });
|
||||
|
||||
// ── 라이브러리 / 스트리밍 ──
|
||||
// ── 라이브러리 + 썸네일/해상도(M2) ──
|
||||
var thumbnailer = options.Thumbnailer ?? ServerOptionsDefaults.DefaultThumbnailer();
|
||||
var ffmpeg = VideoDownloader.Core.Platform.DesktopMediaMuxer.Default.ResolveFfmpeg(new VideoDownloader.Core.Config.AppConfig());
|
||||
app.MapGet("/api/library", async (HttpContext ctx) =>
|
||||
await ctx.Response.WriteAsJsonAsync(MediaLibrary.List(options.LibraryFolder)));
|
||||
{
|
||||
var items = MediaLibrary.List(options.LibraryFolder);
|
||||
if (ffmpeg != null)
|
||||
{
|
||||
for (var i = 0; i < items.Count; i++)
|
||||
{
|
||||
var res = await thumbnailer.GetResolutionAsync(ffmpeg, Path.Combine(options.LibraryFolder, items[i].FileName));
|
||||
items[i] = items[i] with { Resolution = res };
|
||||
}
|
||||
}
|
||||
await ctx.Response.WriteAsJsonAsync(items);
|
||||
});
|
||||
|
||||
app.MapGet("/api/library/{id}/thumb", async (string id) =>
|
||||
{
|
||||
var file = MediaLibrary.Find(options.LibraryFolder, id);
|
||||
if (file == null || ffmpeg == null) return Results.NotFound();
|
||||
var jpg = await thumbnailer.GetThumbnailAsync(ffmpeg, file.Value.FullPath);
|
||||
return jpg == null ? Results.NotFound() : Results.File(jpg, "image/jpeg");
|
||||
});
|
||||
|
||||
app.MapGet("/stream/{id}", (string id) =>
|
||||
{
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ namespace VideoDownloader.Server;
|
|||
public sealed record QueueItemDto(string Id, string Title, string Url, string Status, double Percent);
|
||||
|
||||
/// <summary>라이브러리(저장 폴더) 미디어 항목.</summary>
|
||||
public sealed record LibraryItemDto(string Id, string FileName, long SizeBytes, DateTime Modified);
|
||||
public sealed record LibraryItemDto(string Id, string FileName, long SizeBytes, DateTime Modified, string? Resolution = null);
|
||||
|
||||
/// <summary>이어보기 위치(U3 동기화). 초 단위.</summary>
|
||||
public sealed record PositionDto(double PositionSec);
|
||||
|
|
@ -22,6 +22,15 @@ public sealed class ServerOptions
|
|||
public IQueueAdapter Queue { get; init; } = new EmptyQueueAdapter();
|
||||
/// <summary>기기 표시명(웹 컴패니언 헤더용).</summary>
|
||||
public string DeviceName { get; init; } = Environment.MachineName;
|
||||
/// <summary>썸네일/해상도 생성기(기본: DesktopProcessRunner 기반 ffmpeg). 테스트 주입 가능.</summary>
|
||||
public Thumbnailer? Thumbnailer { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>기본 썸네일러 팩토리.</summary>
|
||||
public static class ServerOptionsDefaults
|
||||
{
|
||||
public static Thumbnailer DefaultThumbnailer() =>
|
||||
new(VideoDownloader.Core.Platform.DesktopProcessRunner.Default);
|
||||
}
|
||||
|
||||
/// <summary>호스트 앱의 다운로드 큐와 서버를 잇는 어댑터(UI 스레드 마샬링은 구현측 담당).</summary>
|
||||
|
|
|
|||
57
VideoDownloader.Server/Thumbnailer.cs
Normal file
57
VideoDownloader.Server/Thumbnailer.cs
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
using System.Text.RegularExpressions;
|
||||
using VideoDownloader.Core.Config;
|
||||
using VideoDownloader.Core.Platform;
|
||||
|
||||
namespace VideoDownloader.Server;
|
||||
|
||||
/// <summary>
|
||||
/// M2 라이브러리 메타데이터 — ffmpeg 기반 썸네일(.thumb.jpg)·해상도(.meta.json) 사이드카 캐시.
|
||||
/// IProcessRunner 주입(테스트는 Fake, 호스트는 DesktopProcessRunner).
|
||||
/// </summary>
|
||||
public sealed partial class Thumbnailer
|
||||
{
|
||||
private readonly IProcessRunner _runner;
|
||||
public Thumbnailer(IProcessRunner runner) => _runner = runner;
|
||||
|
||||
/// <summary>1초 지점 프레임 썸네일(320px 폭 JPEG). 실패 시 null. 캐시 히트 시 프로세스 없음.</summary>
|
||||
public async Task<byte[]?> GetThumbnailAsync(string ffmpeg, string mediaPath, CancellationToken ct = default)
|
||||
{
|
||||
var cache = mediaPath + ".thumb.jpg";
|
||||
try
|
||||
{
|
||||
if (File.Exists(cache)) return await File.ReadAllBytesAsync(cache, ct);
|
||||
var res = await _runner.RunAsync(new ProcessSpec(ffmpeg, new[]
|
||||
{
|
||||
"-y", "-hide_banner", "-loglevel", "error", "-ss", "1", "-i", mediaPath,
|
||||
"-frames:v", "1", "-vf", "scale=320:-2", cache,
|
||||
}), null, ct);
|
||||
if (res.ExitCode != 0 || !File.Exists(cache)) return null;
|
||||
return await File.ReadAllBytesAsync(cache, ct);
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
/// <summary>해상도("1920x1080"). ffmpeg -i stderr 의 Stream 라인에서 추출, .meta.json 캐시.</summary>
|
||||
public async Task<string?> GetResolutionAsync(string ffmpeg, string mediaPath, CancellationToken ct = default)
|
||||
{
|
||||
var cache = mediaPath + ".meta.json";
|
||||
try
|
||||
{
|
||||
if (File.Exists(cache))
|
||||
{
|
||||
var cached = await File.ReadAllTextAsync(cache, ct);
|
||||
return cached.Length >= 3 ? cached.Trim('"') : null;
|
||||
}
|
||||
var res = await _runner.RunAsync(new ProcessSpec(ffmpeg, new[]
|
||||
{ "-hide_banner", "-i", mediaPath }), null, ct);
|
||||
var m = ResolutionRegex().Match(res.Stderr);
|
||||
var value = m.Success ? m.Groups[1].Value : null;
|
||||
await File.WriteAllTextAsync(cache, value == null ? "-" : $"\"{value}\"", ct);
|
||||
return value;
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"Video:.*?,\s(\d{2,5}x\d{2,5})")]
|
||||
private static partial Regex ResolutionRegex();
|
||||
}
|
||||
|
|
@ -8,4 +8,7 @@
|
|||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\VideoDownloader.Core\VideoDownloader.Core.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue