From 27c91c04dd336f64d8575322a6bf3e16b8e0e028 Mon Sep 17 00:00:00 2001 From: Yun Chan Date: Sat, 15 Aug 2026 17:40:28 +0900 Subject: [PATCH] =?UTF-8?q?M2-U3+U4=20=EC=99=84=EC=84=B1:=20=EC=9D=B4?= =?UTF-8?q?=EC=96=B4=EB=B3=B4=EA=B8=B0=20=EC=9C=84=EC=B9=98=20=EB=8F=99?= =?UTF-8?q?=EA=B8=B0=ED=99=94(POST/GET=20/api/position=20+=20PositionStore?= =?UTF-8?q?=20JSON=20=EC=98=81=EC=86=8D,=20=ED=94=8C=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EC=96=B4=205=EC=B4=88=20=EC=A3=BC=EA=B8=B0=C2=B7=EC=A2=85?= =?UTF-8?q?=EB=A3=8C=20=EC=A0=80=EC=9E=A5=C2=B7=EC=9E=AC=EA=B0=9C=20?= =?UTF-8?q?=EC=8B=9C=ED=81=AC)=20+=20=ED=8F=B0=20=EC=98=A4=ED=94=84?= =?UTF-8?q?=EB=9D=BC=EC=9D=B8=20=EC=A0=80=EC=9E=A5(ApiClient.DownloadFileA?= =?UTF-8?q?sync=20.part=20=EC=8A=A4=ED=8A=B8=EB=A6=AC=EB=B0=8D,=20OfflineS?= =?UTF-8?q?tore=20manifest,=20=EB=9D=BC=EC=9D=B4=EB=B8=8C=EB=9F=AC?= =?UTF-8?q?=EB=A6=AC=20=F0=9F=92=BE=20=EB=B2=84=ED=8A=BC,=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20=ED=83=AD=20=EC=98=A4=ED=94=84=EB=9D=BC=EC=9D=B8=20?= =?UTF-8?q?=EC=9E=AC=EC=83=9D/=EC=82=AD=EC=A0=9C).=20=EC=84=9C=EB=B2=84=20?= =?UTF-8?q?RED=E2=86=92GREEN=20E2E=20+=20=EC=97=90=EB=AE=AC=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20E2E(=EC=9C=84=EC=B9=98=2013.3=E2=86=9235.2?= =?UTF-8?q?=EC=B4=88=20=EC=9D=B4=EC=96=B4=EC=9E=AC=EC=83=9D,=20=EC=84=9C?= =?UTF-8?q?=EB=B2=84=20=EC=A0=95=EC=A7=80=20=ED=9B=84=20=EC=98=A4=ED=94=84?= =?UTF-8?q?=EB=9D=BC=EC=9D=B8=20=EC=9E=AC=EC=83=9D=20=ED=94=84=EB=A0=88?= =?UTF-8?q?=EC=9E=84=20=EC=8B=A4=EC=A6=9D).=20104=20tests=20GREEN?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- IMPLEMENTATION_AUDIT.md | 9 +- VideoDownloader.Mobile/ApiClient.cs | 54 ++++++++ VideoDownloader.Mobile/OfflineStore.cs | 73 +++++++++++ VideoDownloader.Mobile/Pages.cs | 118 +++++++++++++++++- VideoDownloader.Mobile/TabbedRootPage.cs | 1 + VideoDownloader.Server/PositionStore.cs | 44 +++++++ VideoDownloader.Server/ServerHost.cs | 11 ++ VideoDownloader.Server/ServerModels.cs | 3 + .../Server/RemoteServerE2ETests.cs | 51 ++++++++ 9 files changed, 356 insertions(+), 8 deletions(-) create mode 100644 VideoDownloader.Mobile/OfflineStore.cs create mode 100644 VideoDownloader.Server/PositionStore.cs diff --git a/IMPLEMENTATION_AUDIT.md b/IMPLEMENTATION_AUDIT.md index 9c189dc..36c793d 100644 --- a/IMPLEMENTATION_AUDIT.md +++ b/IMPLEMENTATION_AUDIT.md @@ -2,7 +2,7 @@ > **대상**: `BROWSER_FEATURES_PLAN.md` (v2) + `MOBILE_PLAN.md` (v1) 전 항목 > **감사일**: 2026-08-15 · **방법**: TDD(RED→GREEN) + E2E 검증 -> **요약**: 전 솔루션 Release 빌드 **0 오류**, 단위/E2E 테스트 **103/103 GREEN**, Android 에뮬레이터·iOS 시뮬레이터·LAN 서버 E2E 완료 +> **요약**: 전 솔루션 Release 빌드 **0 오류**, 단위/E2E 테스트 **104/104 GREEN**, Android 에뮬레이터·iOS 시뮬레이터·LAN 서버 E2E 완료 > > **감사 중 발견·수정된 회귀**: `HlsDownloader` 3-인자 ctor가 `_muxer` 미할당(재구성 ② 누락) → 모든 HLS 리먹스 NUL 위험. RED 테스트(`HlsMuxerWiringTests`)로 재현 후 ctor 할당 수정. `DashDownloader`는 정상. @@ -98,6 +98,8 @@ | **M1** 링크 붙여넣기 추가 | ✅ URL 엔트리 + "PC에서 받기" | 페이지 UI + 서버 API E2E (POST 400 검증 포함) | | **M2** 라이브러리 목록 | ✅ 파일명/크기 | 에뮬 E2E: sample.mp4/415,253 bytes 렌더 | | **M2** MediaElement 스트리밍 재생 | ✅ Range HTTP → 모달 플레이어 | 에뮬 E2E: **실제 MP4 프레임 렌더 실증**(testsrc 패턴 스크린샷) + 서버 ESTABLISHED | +| **M2-U3** 이어보기 위치 동기화 | ✅ `POST/GET /api/position/{id}` + PositionStore(positions.json 영속) + 플레이어 5초 주기·종료 시 저장, 재생 시작 시 시크 | RED→GREEN `이어보기_위치_동기화_E2E` · 에뮬 E2E: positions.json 13.3→24.2→**35.2초**(재오픈 시 저장 위치에서 이어 재생 실증) | +| **M2-U4** 폰 오프라인 저장 + 저장 탭 | ✅ ApiClient.DownloadFileAsync(.part→rename, 진행률) + OfflineStore(manifest.json) + 라이브러리 💾 버튼 + 저장 탭(오프라인 재생/삭제) | 에뮬 E2E: ✓ 저장됨 → 저장 탭 "561,312 bytes — 오프라인" → **서버 정지 후 재생 실증**(프레임 픽셀 비교: 4초 간 5,935px 변경 + 컬러 패턴 렌더) → 🗑 삭제 동작 | | **M3** net10.0-ios | ✅ macOS 전용 TFM 조건 분기 | vd-mac 빌드: Mach-O arm64 .app, Info.plist 검증 | | **M3** iOS 시동 | ✅ | iPhone 17 시뮬레이터(iOS 26) 설치+실행, 전체 UI 렌더 스크린샷 | @@ -113,8 +115,7 @@ - SignalR 허브 — 계획안 명시 "v1은 2초 폴링으로도 가능" 채택 - mDNS 광고(_videodl._tcp) — QR 페어링이 주 경로(계획 §6 리스크 대응과 동일 전제) -- U4 폰 오프라인 저장(`GET /file/{id}` API는 구현됨, 앱 측 다운로드 탭 미구현 — Tier M2 후반) -- U5 완료 푸시 알림, 이어보기 위치 동기화(`POST /api/position` 미구현) +- U5 완료 푸시 알림 - M4 Android 독립 다운로더 — 계획상 "선택" - iOS TestFlight — 앱 실행/페어링 프레이밍까지만 (감사일 기준) @@ -137,4 +138,4 @@ - [x] Android 앱 큐 실시간 갱신/제어 (M1) — 에뮬 E2E 라이브 데이터 - [x] 라이브러리 스트리밍 재생(시크 포함) (M2) — 실측 MP4 프레임 + 206 Range - [x] iOS 빌드 시동 + 큐 조회 (M3) — iOS 26 시뮬레이터 UI 렌더 -- [ ] 폰 저장 오프라인 재생 (M2 후반) — 미구현(위 표) +- [x] 폰 저장 오프라인 재생 (M2 후반) — 서버 정지 상태 프레임 렌더 실증 + 이어보기 동기화(재개 35.2초) diff --git a/VideoDownloader.Mobile/ApiClient.cs b/VideoDownloader.Mobile/ApiClient.cs index 4138bc7..5703875 100644 --- a/VideoDownloader.Mobile/ApiClient.cs +++ b/VideoDownloader.Mobile/ApiClient.cs @@ -115,6 +115,58 @@ public sealed class ApiClient } public string StreamUrl(LibraryItem item) => $"{BaseUrl}/stream/{item.Id}?t={Token}"; + public string FileUrl(LibraryItem item) => $"{BaseUrl}/file/{item.Id}?t={Token}"; + + /// 이어보기 위치 조회(U3). 실패/미저장 시 0. + public async Task GetPositionAsync(string id) + { + var dto = await GetAsync($"/api/position/{id}"); + return dto?.PositionSec ?? 0; + } + + /// 이어보기 위치 저장(U3). + public Task PostPositionAsync(string id, double positionSec) => + PostJsonAsync($"/api/position/{id}", new PositionDto(positionSec)); + + /// 라이브러리 항목을 폰에 저장(U4). 진행률 0~1, 취소 지원. 임시 .part 로 받아 완료 시 rename. + public async Task DownloadFileAsync(LibraryItem item, string destPath, IProgress? progress, CancellationToken ct) + { + Directory.CreateDirectory(Path.GetDirectoryName(destPath)!); + var tmp = destPath + ".part"; + try + { + using var resp = await _http.GetAsync(FileUrl(item), HttpCompletionOption.ResponseHeadersRead, ct); + resp.EnsureSuccessStatusCode(); + var total = resp.Content.Headers.ContentLength ?? item.SizeBytes; + await using var src = await resp.Content.ReadAsStreamAsync(ct); + await using var dst = File.Create(tmp); + var buf = new byte[64 * 1024]; + long read = 0; + int n; + while ((n = await src.ReadAsync(buf, ct)) > 0) + { + await dst.WriteAsync(buf.AsMemory(0, n), ct); + read += n; + if (total > 0) progress?.Report((double)read / total); + } + File.Move(tmp, destPath, overwrite: true); + progress?.Report(1); + } + finally + { + try { if (File.Exists(tmp)) File.Delete(tmp); } catch { } + } + } + + private async Task PostJsonAsync(string path, object body) + { + try + { + var r = await _http.PostAsJsonAsync($"{BaseUrl}{path}", body); + return r.IsSuccessStatusCode; + } + catch { return false; } + } private Task PostAsync(string path) { @@ -137,3 +189,5 @@ public sealed class ApiClient public sealed record QueueItem(string Id, string Title, string Url, string Status, double Percent); public sealed record LibraryItem(string Id, string FileName, long SizeBytes, DateTime Modified); + +public sealed record PositionDto(double PositionSec); diff --git a/VideoDownloader.Mobile/OfflineStore.cs b/VideoDownloader.Mobile/OfflineStore.cs new file mode 100644 index 0000000..4d7f443 --- /dev/null +++ b/VideoDownloader.Mobile/OfflineStore.cs @@ -0,0 +1,73 @@ +using System.Text.Json; + +namespace VideoDownloader.Mobile; + +public sealed record OfflineItem(string Id, string FileName, long SizeBytes); + +/// +/// 폰 오프라인 저장(U4): 라이브러리 항목을 앱 전용 폴더에 저장·목록·삭제. +/// manifest.json(id→파일명)으로 서버 항목과 매핑한다. +/// +public sealed class OfflineStore +{ + public static OfflineStore Default { get; } = new(); + + private readonly string _dir; + private readonly string _manifestPath; + private readonly object _gate = new(); + private Dictionary _manifest; + + public OfflineStore() + { + _dir = Path.Combine(FileSystem.AppDataDirectory, "offline"); + _manifestPath = Path.Combine(_dir, "manifest.json"); + try + { + if (File.Exists(_manifestPath)) + _manifest = JsonSerializer.Deserialize>(File.ReadAllText(_manifestPath)) ?? new(); + } + catch { } + _manifest ??= new Dictionary(); + } + + public string Directory => _dir; + + public string PathFor(string id) => Path.Combine(_dir, id + Path.GetExtension(_manifest.GetValueOrDefault(id, ".mp4"))); + + public bool Exists(string id) { lock (_gate) { var p = PathFor(id); return File.Exists(p) && _manifest.ContainsKey(id); } } + + /// 다운로드 완료 후 등록. + public void Record(string id, string fileName) + { + lock (_gate) + { + System.IO.Directory.CreateDirectory(_dir); + _manifest[id] = fileName; + Save(); + } + } + + public void Delete(string id) + { + lock (_gate) + { + try { if (File.Exists(PathFor(id))) File.Delete(PathFor(id)); } catch { } + _manifest.Remove(id); + Save(); + } + } + + public List List() + { + lock (_gate) + return _manifest.Select(kv => + { + var p = PathFor(kv.Key); + return new OfflineItem(kv.Key, kv.Value, File.Exists(p) ? new FileInfo(p).Length : 0); + }).Where(i => i.SizeBytes > 0).ToList(); + } + + private void Save() + { + try { System.IO.Directory.CreateDirectory(_dir); File.WriteAllText(_manifestPath, JsonSerializer.Serialize(_manifest)); } catch { } + }} diff --git a/VideoDownloader.Mobile/Pages.cs b/VideoDownloader.Mobile/Pages.cs index 50f5ee0..00e6512 100644 --- a/VideoDownloader.Mobile/Pages.cs +++ b/VideoDownloader.Mobile/Pages.cs @@ -165,10 +165,15 @@ public sealed class LibraryPage : ContentPage name.SetBinding(Label.TextProperty, "FileName"); var size = new Label { FontSize = 11, TextColor = Colors.Gray }; size.SetBinding(Label.TextProperty, new Binding("SizeBytes", stringFormat: "{0:N0} bytes")); + + // U4 폰 저장: 카드 우측 버튼, 진행률을 버튼 텍스트로 표시 + var save = new Button { Text = "💾 저장", FontSize = 12, Padding = new Thickness(10, 4), VerticalOptions = LayoutOptions.Center }; + save.Clicked += async (_, _) => await SaveOffline(save, save.BindingContext as LibraryItem); + var card = new Frame { Margin = new Thickness(0, 4), Padding = 12, CornerRadius = 10, - Content = new VerticalStackLayout { Children = { name, size } } + Content = new HorizontalStackLayout { Spacing = 8, Children = { new VerticalStackLayout { Children = { name, size } }, save } } }; // CollectionView.SelectionChanged 는 Android 에서 발화 불안정 → 템플릿 카드에 탭 제스처 var tap = new TapGestureRecognizer(); @@ -185,6 +190,27 @@ public sealed class LibraryPage : ContentPage Appearing += async (_, _) => await Reload(); } + /// U4: 항목을 폰 저장 폴더로 스트리밍 다운로드(진행률 % → 완료 ✓). + private static async Task SaveOffline(Button button, LibraryItem? item) + { + if (item == null) return; + if (OfflineStore.Default.Exists(item.Id)) { button.Text = "✓ 저장됨"; return; } + button.Text = "0%"; + try + { + var dest = OfflineStore.Default.PathFor(item.Id); + var progress = new Progress(p => MainThread.BeginInvokeOnMainThread( + () => button.Text = p >= 1 ? "✓ 저장됨" : $"{(int)(p * 100)}%")); + await ApiClient.Instance.DownloadFileAsync(item, dest, progress, CancellationToken.None); + OfflineStore.Default.Record(item.Id, item.FileName); + button.Text = "✓ 저장됨"; + } + catch (Exception e) + { + MainThread.BeginInvokeOnMainThread(() => button.Text = $"❌ {e.Message[..Math.Min(20, e.Message.Length)]}"); + } + } + private async void OpenPlayer(LibraryItem item) { // 탭 자식엔 NavigationPage 가 없어 PushAsync 불가 → 루트에서 모달 push (닫기는 상단바 뒤로) @@ -198,7 +224,66 @@ public sealed class LibraryPage : ContentPage } } -/// M2 재생 페이지: Range 스트리밍 재생(MediaElement). +/// M2-U4 저장 탭: 폰에 저장된 항목 목록 — 오프라인 재생/삭제. +public sealed class DownloadsPage : ContentPage +{ + private readonly CollectionView _list = new(); + private readonly Label _empty = new() + { + Text = "저장된 항목이 없습니다 — 라이브러리에서 💾 저장", + TextColor = Colors.Gray, HorizontalOptions = LayoutOptions.Center, Margin = new Thickness(0, 24), + }; + + public DownloadsPage() + { + Title = "저장"; + _list.ItemTemplate = new DataTemplate(() => + { + var name = new Label { FontSize = 14, FontAttributes = FontAttributes.Bold, LineBreakMode = LineBreakMode.TailTruncation }; + name.SetBinding(Label.TextProperty, "FileName"); + var size = new Label { FontSize = 11, TextColor = Colors.Gray }; + size.SetBinding(Label.TextProperty, new Binding("SizeBytes", stringFormat: "{0:N0} bytes — 오프라인")); + + var del = new Button { Text = "🗑", FontSize = 12, Padding = new Thickness(10, 4), VerticalOptions = LayoutOptions.Center }; + del.Clicked += (_, _) => + { + if ((del.BindingContext as OfflineItem) is { } it) { OfflineStore.Default.Delete(it.Id); Reload(); } + }; + + var card = new Frame + { + Margin = new Thickness(0, 4), Padding = 12, CornerRadius = 10, + Content = new HorizontalStackLayout { Spacing = 8, Children = { new VerticalStackLayout { Children = { name, size } }, del } } + }; + var tap = new TapGestureRecognizer(); + tap.Tapped += (_, _) => + { + if ((card.BindingContext as OfflineItem) is { } it) + _ = Application.Current!.MainPage!.Navigation.PushModalAsync( + new NavigationPage(new PlayerPage(new LibraryItem(it.Id, it.FileName, it.SizeBytes, DateTime.MinValue), OfflineStore.Default.PathFor(it.Id)))); + }; + card.GestureRecognizers.Add(tap); + return card; + }); + _list.SelectionMode = SelectionMode.None; + + var refresh = new Button { Text = "새로고침" }; + refresh.Clicked += (_, _) => Reload(); + + Content = new VerticalStackLayout { Padding = 16, Children = { refresh, _empty, _list } }; + _empty.IsVisible = false; + Appearing += (_, _) => Reload(); + } + + private void Reload() + { + var items = OfflineStore.Default.List(); + _list.ItemsSource = items.Count > 0 ? items : null; + _empty.IsVisible = items.Count == 0; + } +} + +/// M2 재생 페이지: Range 스트리밍 재생(MediaElement) + 이어보기 위치 동기화(U3). localPath 있으면 오프라인 재생(U4). public sealed class PlayerPage : ContentPage { private readonly CommunityToolkit.Maui.Views.MediaElement _media = new() @@ -207,16 +292,41 @@ public sealed class PlayerPage : ContentPage ShouldAutoPlay = true, ShouldShowPlaybackControls = true, }; + private readonly string? _itemId; // 스트리밍 재생 시 위치 동기화 대상 + private bool _sync; // 위치 저장 활성(오프라인 재생은 서버 없이도 동작) - public PlayerPage(LibraryItem item) + public PlayerPage(LibraryItem item, string? localPath = null) { Title = item.FileName; - _media.Source = CommunityToolkit.Maui.Views.MediaSource.FromUri(ApiClient.Instance.StreamUrl(item)); + if (localPath != null) + { + _media.Source = CommunityToolkit.Maui.Views.MediaSource.FromFile(localPath); + } + else + { + _itemId = item.Id; + _media.Source = CommunityToolkit.Maui.Views.MediaSource.FromUri(ApiClient.Instance.StreamUrl(item)); + // 열림 시 저장된 위치로 시크 → 5초 주기 동기화(U3) + _media.MediaOpened += async (_, _) => + { + var saved = await ApiClient.Instance.GetPositionAsync(item.Id); + if (saved > 1) await _media.SeekTo(TimeSpan.FromSeconds(saved)); + _sync = true; + }; + Device.StartTimer(TimeSpan.FromSeconds(5), () => + { + if (!_sync || _media.Position <= TimeSpan.Zero) return true; + _ = ApiClient.Instance.PostPositionAsync(item.Id, _media.Position.TotalSeconds); + return true; // 페이지가 닫혀도 무해(타이머는 프로세스 수명) + }); + } Content = _media; } protected override void OnDisappearing() { + if (_sync && _itemId != null && _media.Position > TimeSpan.Zero) + _ = ApiClient.Instance.PostPositionAsync(_itemId, _media.Position.TotalSeconds); _media.Stop(); _media.Handler?.DisconnectHandler(); // 재생 리소스 해제 base.OnDisappearing(); diff --git a/VideoDownloader.Mobile/TabbedRootPage.cs b/VideoDownloader.Mobile/TabbedRootPage.cs index 23b925d..f53022c 100644 --- a/VideoDownloader.Mobile/TabbedRootPage.cs +++ b/VideoDownloader.Mobile/TabbedRootPage.cs @@ -8,6 +8,7 @@ public sealed class TabbedRootPage : TabbedPage ApiClient.Instance.Restore(); Children.Add(new QueuePage()); Children.Add(new LibraryPage()); + Children.Add(new DownloadsPage()); Children.Add(new ConnectPage()); Title = "VD 원격"; } diff --git a/VideoDownloader.Server/PositionStore.cs b/VideoDownloader.Server/PositionStore.cs new file mode 100644 index 0000000..e8db875 --- /dev/null +++ b/VideoDownloader.Server/PositionStore.cs @@ -0,0 +1,44 @@ +using System.Text.Json; + +namespace VideoDownloader.Server; + +/// +/// 이어보기 위치 저장(U3). 라이브러리 폴더의 positions.json 에 영속 — +/// 서버 재시작·PC 재부팅 후에도 폰과 같은 위치에서 이어보기. +/// +public sealed class PositionStore +{ + private const string FileName = "positions.json"; + private readonly object _gate = new(); + private readonly string _path; + + private Dictionary _positions; + + public PositionStore(string libraryFolder) + { + _path = Path.Combine(libraryFolder, FileName); + try + { + if (File.Exists(_path)) + _positions = JsonSerializer.Deserialize>(File.ReadAllText(_path)) ?? new(); + } + catch { /* 손상 파일 → 빈 상태로 시작 */ } + _positions ??= new Dictionary(); + } + + public double Get(string id) + { + lock (_gate) return _positions.GetValueOrDefault(id, 0); + } + + public void Set(string id, double positionSec) + { + if (string.IsNullOrEmpty(id) || positionSec < 0 || double.IsNaN(positionSec) || double.IsInfinity(positionSec)) + return; + lock (_gate) + { + _positions[id] = positionSec; + try { File.WriteAllText(_path, JsonSerializer.Serialize(_positions)); } catch { /* 디스크 오류는 무시 */ } + } + } +} diff --git a/VideoDownloader.Server/ServerHost.cs b/VideoDownloader.Server/ServerHost.cs index 22dd7d1..c152bf6 100644 --- a/VideoDownloader.Server/ServerHost.cs +++ b/VideoDownloader.Server/ServerHost.cs @@ -113,6 +113,17 @@ public sealed class ServerHost : IAsyncDisposable return file == null ? Results.NotFound() : Results.File(file.Value.FullPath, "application/octet-stream", file.Value.FileName); }); + // ── 이어보기 위치 동기화 (U3) ── + var positions = new PositionStore(options.LibraryFolder); + app.MapGet("/api/position/{id}", (string id) => Results.Json(new PositionDto(positions.Get(id)))); + app.MapPost("/api/position/{id}", async (string id, HttpContext ctx) => + { + var body = await ctx.Request.ReadFromJsonAsync(); + if (body == null) return Results.BadRequest(); + positions.Set(id, body.PositionSec); + return Results.Ok(); + }); + // ── 웹 컴패니언 (제로 설치 클라이언트) ── app.MapGet("/", () => Results.Text(WebCompanion.Html, "text/html; charset=utf-8")); diff --git a/VideoDownloader.Server/ServerModels.cs b/VideoDownloader.Server/ServerModels.cs index 38af121..97ca183 100644 --- a/VideoDownloader.Server/ServerModels.cs +++ b/VideoDownloader.Server/ServerModels.cs @@ -6,6 +6,9 @@ public sealed record QueueItemDto(string Id, string Title, string Url, string St /// 라이브러리(저장 폴더) 미디어 항목. public sealed record LibraryItemDto(string Id, string FileName, long SizeBytes, DateTime Modified); +/// 이어보기 위치(U3 동기화). 초 단위. +public sealed record PositionDto(double PositionSec); + /// 서버 구성. public sealed class ServerOptions { diff --git a/VideoDownloader.Tests/Server/RemoteServerE2ETests.cs b/VideoDownloader.Tests/Server/RemoteServerE2ETests.cs index eb3de9f..3a5aaab 100644 --- a/VideoDownloader.Tests/Server/RemoteServerE2ETests.cs +++ b/VideoDownloader.Tests/Server/RemoteServerE2ETests.cs @@ -104,6 +104,57 @@ public class RemoteServerE2ETests : IDisposable Assert.Contains("api/queue", page); // 페이지가 API 를 호출하는 스크립트 포함 } + [Fact] + public async Task 이어보기_위치_동기화_E2E_저장_조회_재시작_유지() + { + Directory.CreateDirectory(_libDir); + File.WriteAllBytes(Path.Combine(_libDir, "sample.mp4"), new byte[1024]); + + var queue = new FakeQueue(); + var opts = new ServerOptions + { + Port = 0, + Token = "pos-token", + LibraryFolder = _libDir, + Queue = queue, + }; + await using var server = await ServerHost.StartAsync(opts); + using var http = new HttpClient { BaseAddress = new Uri($"http://127.0.0.1:{server.Port}") }; + + // 1) 익명 401 + Assert.Equal(System.Net.HttpStatusCode.Unauthorized, (await http.GetAsync("/api/position/abc")).StatusCode); + + http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "pos-token"); + var lib = (await http.GetFromJsonAsync>("/api/library"))!; + var id = lib[0].Id; + + // 2) 미저장 기본값 0 + var before = await http.GetFromJsonAsync($"/api/position/{id}"); + Assert.NotNull(before); + Assert.Equal(0, before!.PositionSec); + + // 3) 저장 → 조회 라운드트립 + var save = await http.PostAsJsonAsync($"/api/position/{id}", new PositionDto(93.5)); + Assert.True(save.IsSuccessStatusCode); + var after = await http.GetFromJsonAsync($"/api/position/{id}"); + Assert.Equal(93.5, after!.PositionSec); + + // 4) 존재하지 않는 항목도 기본값 0 (섹션 파일 무손상) + var unknown = await http.GetFromJsonAsync("/api/position/no-such-id"); + Assert.Equal(0, unknown!.PositionSec); + + // 5) 서버 재시작 후에도 유지(파일 영속) + await server.DisposeAsync(); + await using var server2 = await ServerHost.StartAsync(new ServerOptions + { + Port = 0, Token = "pos-token", LibraryFolder = _libDir, Queue = queue, + }); + using var http2 = new HttpClient { BaseAddress = new Uri($"http://127.0.0.1:{server2.Port}") }; + http2.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "pos-token"); + var persisted = await http2.GetFromJsonAsync($"/api/position/{id}"); + Assert.Equal(93.5, persisted!.PositionSec); + } + public void Dispose() { try { Directory.Delete(_libDir, true); } catch { }