M2-U3+U4 완성: 이어보기 위치 동기화(POST/GET /api/position + PositionStore JSON 영속, 플레이어 5초 주기·종료 저장·재개 시크) + 폰 오프라인 저장(ApiClient.DownloadFileAsync .part 스트리밍, OfflineStore manifest, 라이브러리 💾 버튼, 저장 탭 오프라인 재생/삭제). 서버 RED→GREEN E2E + 에뮬레이터 E2E(위치 13.3→35.2초 이어재생, 서버 정지 후 오프라인 재생 프레임 실증). 104 tests GREEN
This commit is contained in:
parent
17f3e41cd2
commit
27c91c04dd
9 changed files with 356 additions and 8 deletions
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
> **대상**: `BROWSER_FEATURES_PLAN.md` (v2) + `MOBILE_PLAN.md` (v1) 전 항목
|
> **대상**: `BROWSER_FEATURES_PLAN.md` (v2) + `MOBILE_PLAN.md` (v1) 전 항목
|
||||||
> **감사일**: 2026-08-15 · **방법**: TDD(RED→GREEN) + E2E 검증
|
> **감사일**: 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`는 정상.
|
> **감사 중 발견·수정된 회귀**: `HlsDownloader` 3-인자 ctor가 `_muxer` 미할당(재구성 ② 누락) → 모든 HLS 리먹스 NUL 위험. RED 테스트(`HlsMuxerWiringTests`)로 재현 후 ctor 할당 수정. `DashDownloader`는 정상.
|
||||||
|
|
||||||
|
|
@ -98,6 +98,8 @@
|
||||||
| **M1** 링크 붙여넣기 추가 | ✅ URL 엔트리 + "PC에서 받기" | 페이지 UI + 서버 API E2E (POST 400 검증 포함) |
|
| **M1** 링크 붙여넣기 추가 | ✅ URL 엔트리 + "PC에서 받기" | 페이지 UI + 서버 API E2E (POST 400 검증 포함) |
|
||||||
| **M2** 라이브러리 목록 | ✅ 파일명/크기 | 에뮬 E2E: sample.mp4/415,253 bytes 렌더 |
|
| **M2** 라이브러리 목록 | ✅ 파일명/크기 | 에뮬 E2E: sample.mp4/415,253 bytes 렌더 |
|
||||||
| **M2** MediaElement 스트리밍 재생 | ✅ Range HTTP → 모달 플레이어 | 에뮬 E2E: **실제 MP4 프레임 렌더 실증**(testsrc 패턴 스크린샷) + 서버 ESTABLISHED |
|
| **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** net10.0-ios | ✅ macOS 전용 TFM 조건 분기 | vd-mac 빌드: Mach-O arm64 .app, Info.plist 검증 |
|
||||||
| **M3** iOS 시동 | ✅ | iPhone 17 시뮬레이터(iOS 26) 설치+실행, 전체 UI 렌더 스크린샷 |
|
| **M3** iOS 시동 | ✅ | iPhone 17 시뮬레이터(iOS 26) 설치+실행, 전체 UI 렌더 스크린샷 |
|
||||||
|
|
||||||
|
|
@ -113,8 +115,7 @@
|
||||||
|
|
||||||
- SignalR 허브 — 계획안 명시 "v1은 2초 폴링으로도 가능" 채택
|
- SignalR 허브 — 계획안 명시 "v1은 2초 폴링으로도 가능" 채택
|
||||||
- mDNS 광고(_videodl._tcp) — QR 페어링이 주 경로(계획 §6 리스크 대응과 동일 전제)
|
- mDNS 광고(_videodl._tcp) — QR 페어링이 주 경로(계획 §6 리스크 대응과 동일 전제)
|
||||||
- U4 폰 오프라인 저장(`GET /file/{id}` API는 구현됨, 앱 측 다운로드 탭 미구현 — Tier M2 후반)
|
- U5 완료 푸시 알림
|
||||||
- U5 완료 푸시 알림, 이어보기 위치 동기화(`POST /api/position` 미구현)
|
|
||||||
- M4 Android 독립 다운로더 — 계획상 "선택"
|
- M4 Android 독립 다운로더 — 계획상 "선택"
|
||||||
- iOS TestFlight — 앱 실행/페어링 프레이밍까지만 (감사일 기준)
|
- iOS TestFlight — 앱 실행/페어링 프레이밍까지만 (감사일 기준)
|
||||||
|
|
||||||
|
|
@ -137,4 +138,4 @@
|
||||||
- [x] Android 앱 큐 실시간 갱신/제어 (M1) — 에뮬 E2E 라이브 데이터
|
- [x] Android 앱 큐 실시간 갱신/제어 (M1) — 에뮬 E2E 라이브 데이터
|
||||||
- [x] 라이브러리 스트리밍 재생(시크 포함) (M2) — 실측 MP4 프레임 + 206 Range
|
- [x] 라이브러리 스트리밍 재생(시크 포함) (M2) — 실측 MP4 프레임 + 206 Range
|
||||||
- [x] iOS 빌드 시동 + 큐 조회 (M3) — iOS 26 시뮬레이터 UI 렌더
|
- [x] iOS 빌드 시동 + 큐 조회 (M3) — iOS 26 시뮬레이터 UI 렌더
|
||||||
- [ ] 폰 저장 오프라인 재생 (M2 후반) — 미구현(위 표)
|
- [x] 폰 저장 오프라인 재생 (M2 후반) — 서버 정지 상태 프레임 렌더 실증 + 이어보기 동기화(재개 35.2초)
|
||||||
|
|
|
||||||
|
|
@ -115,6 +115,58 @@ public sealed class ApiClient
|
||||||
}
|
}
|
||||||
|
|
||||||
public string StreamUrl(LibraryItem item) => $"{BaseUrl}/stream/{item.Id}?t={Token}";
|
public string StreamUrl(LibraryItem item) => $"{BaseUrl}/stream/{item.Id}?t={Token}";
|
||||||
|
public string FileUrl(LibraryItem item) => $"{BaseUrl}/file/{item.Id}?t={Token}";
|
||||||
|
|
||||||
|
/// <summary>이어보기 위치 조회(U3). 실패/미저장 시 0.</summary>
|
||||||
|
public async Task<double> GetPositionAsync(string id)
|
||||||
|
{
|
||||||
|
var dto = await GetAsync<PositionDto>($"/api/position/{id}");
|
||||||
|
return dto?.PositionSec ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>이어보기 위치 저장(U3).</summary>
|
||||||
|
public Task<bool> PostPositionAsync(string id, double positionSec) =>
|
||||||
|
PostJsonAsync($"/api/position/{id}", new PositionDto(positionSec));
|
||||||
|
|
||||||
|
/// <summary>라이브러리 항목을 폰에 저장(U4). 진행률 0~1, 취소 지원. 임시 .part 로 받아 완료 시 rename.</summary>
|
||||||
|
public async Task DownloadFileAsync(LibraryItem item, string destPath, IProgress<double>? 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<bool> PostJsonAsync(string path, object body)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var r = await _http.PostAsJsonAsync($"{BaseUrl}{path}", body);
|
||||||
|
return r.IsSuccessStatusCode;
|
||||||
|
}
|
||||||
|
catch { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
private Task<bool> PostAsync(string path)
|
private Task<bool> 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 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 LibraryItem(string Id, string FileName, long SizeBytes, DateTime Modified);
|
||||||
|
|
||||||
|
public sealed record PositionDto(double PositionSec);
|
||||||
|
|
|
||||||
73
VideoDownloader.Mobile/OfflineStore.cs
Normal file
73
VideoDownloader.Mobile/OfflineStore.cs
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace VideoDownloader.Mobile;
|
||||||
|
|
||||||
|
public sealed record OfflineItem(string Id, string FileName, long SizeBytes);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 폰 오프라인 저장(U4): 라이브러리 항목을 앱 전용 폴더에 저장·목록·삭제.
|
||||||
|
/// manifest.json(id→파일명)으로 서버 항목과 매핑한다.
|
||||||
|
/// </summary>
|
||||||
|
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<string, string> _manifest;
|
||||||
|
|
||||||
|
public OfflineStore()
|
||||||
|
{
|
||||||
|
_dir = Path.Combine(FileSystem.AppDataDirectory, "offline");
|
||||||
|
_manifestPath = Path.Combine(_dir, "manifest.json");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (File.Exists(_manifestPath))
|
||||||
|
_manifest = JsonSerializer.Deserialize<Dictionary<string, string>>(File.ReadAllText(_manifestPath)) ?? new();
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
_manifest ??= new Dictionary<string, string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
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); } }
|
||||||
|
|
||||||
|
/// <summary>다운로드 완료 후 등록.</summary>
|
||||||
|
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<OfflineItem> 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 { }
|
||||||
|
}}
|
||||||
|
|
@ -165,10 +165,15 @@ public sealed class LibraryPage : ContentPage
|
||||||
name.SetBinding(Label.TextProperty, "FileName");
|
name.SetBinding(Label.TextProperty, "FileName");
|
||||||
var size = new Label { FontSize = 11, TextColor = Colors.Gray };
|
var size = new Label { FontSize = 11, TextColor = Colors.Gray };
|
||||||
size.SetBinding(Label.TextProperty, new Binding("SizeBytes", stringFormat: "{0:N0} bytes"));
|
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
|
var card = new Frame
|
||||||
{
|
{
|
||||||
Margin = new Thickness(0, 4), Padding = 12, CornerRadius = 10,
|
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 에서 발화 불안정 → 템플릿 카드에 탭 제스처
|
// CollectionView.SelectionChanged 는 Android 에서 발화 불안정 → 템플릿 카드에 탭 제스처
|
||||||
var tap = new TapGestureRecognizer();
|
var tap = new TapGestureRecognizer();
|
||||||
|
|
@ -185,6 +190,27 @@ public sealed class LibraryPage : ContentPage
|
||||||
Appearing += async (_, _) => await Reload();
|
Appearing += async (_, _) => await Reload();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>U4: 항목을 폰 저장 폴더로 스트리밍 다운로드(진행률 % → 완료 ✓).</summary>
|
||||||
|
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<double>(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)
|
private async void OpenPlayer(LibraryItem item)
|
||||||
{
|
{
|
||||||
// 탭 자식엔 NavigationPage 가 없어 PushAsync 불가 → 루트에서 모달 push (닫기는 상단바 뒤로)
|
// 탭 자식엔 NavigationPage 가 없어 PushAsync 불가 → 루트에서 모달 push (닫기는 상단바 뒤로)
|
||||||
|
|
@ -198,7 +224,66 @@ public sealed class LibraryPage : ContentPage
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>M2 재생 페이지: Range 스트리밍 재생(MediaElement).</summary>
|
/// <summary>M2-U4 저장 탭: 폰에 저장된 항목 목록 — 오프라인 재생/삭제.</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>M2 재생 페이지: Range 스트리밍 재생(MediaElement) + 이어보기 위치 동기화(U3). localPath 있으면 오프라인 재생(U4).</summary>
|
||||||
public sealed class PlayerPage : ContentPage
|
public sealed class PlayerPage : ContentPage
|
||||||
{
|
{
|
||||||
private readonly CommunityToolkit.Maui.Views.MediaElement _media = new()
|
private readonly CommunityToolkit.Maui.Views.MediaElement _media = new()
|
||||||
|
|
@ -207,16 +292,41 @@ public sealed class PlayerPage : ContentPage
|
||||||
ShouldAutoPlay = true,
|
ShouldAutoPlay = true,
|
||||||
ShouldShowPlaybackControls = true,
|
ShouldShowPlaybackControls = true,
|
||||||
};
|
};
|
||||||
|
private readonly string? _itemId; // 스트리밍 재생 시 위치 동기화 대상
|
||||||
|
private bool _sync; // 위치 저장 활성(오프라인 재생은 서버 없이도 동작)
|
||||||
|
|
||||||
public PlayerPage(LibraryItem item)
|
public PlayerPage(LibraryItem item, string? localPath = null)
|
||||||
{
|
{
|
||||||
Title = item.FileName;
|
Title = item.FileName;
|
||||||
|
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));
|
_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;
|
Content = _media;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override void OnDisappearing()
|
protected override void OnDisappearing()
|
||||||
{
|
{
|
||||||
|
if (_sync && _itemId != null && _media.Position > TimeSpan.Zero)
|
||||||
|
_ = ApiClient.Instance.PostPositionAsync(_itemId, _media.Position.TotalSeconds);
|
||||||
_media.Stop();
|
_media.Stop();
|
||||||
_media.Handler?.DisconnectHandler(); // 재생 리소스 해제
|
_media.Handler?.DisconnectHandler(); // 재생 리소스 해제
|
||||||
base.OnDisappearing();
|
base.OnDisappearing();
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ public sealed class TabbedRootPage : TabbedPage
|
||||||
ApiClient.Instance.Restore();
|
ApiClient.Instance.Restore();
|
||||||
Children.Add(new QueuePage());
|
Children.Add(new QueuePage());
|
||||||
Children.Add(new LibraryPage());
|
Children.Add(new LibraryPage());
|
||||||
|
Children.Add(new DownloadsPage());
|
||||||
Children.Add(new ConnectPage());
|
Children.Add(new ConnectPage());
|
||||||
Title = "VD 원격";
|
Title = "VD 원격";
|
||||||
}
|
}
|
||||||
|
|
|
||||||
44
VideoDownloader.Server/PositionStore.cs
Normal file
44
VideoDownloader.Server/PositionStore.cs
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace VideoDownloader.Server;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 이어보기 위치 저장(U3). 라이브러리 폴더의 positions.json 에 영속 —
|
||||||
|
/// 서버 재시작·PC 재부팅 후에도 폰과 같은 위치에서 이어보기.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class PositionStore
|
||||||
|
{
|
||||||
|
private const string FileName = "positions.json";
|
||||||
|
private readonly object _gate = new();
|
||||||
|
private readonly string _path;
|
||||||
|
|
||||||
|
private Dictionary<string, double> _positions;
|
||||||
|
|
||||||
|
public PositionStore(string libraryFolder)
|
||||||
|
{
|
||||||
|
_path = Path.Combine(libraryFolder, FileName);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (File.Exists(_path))
|
||||||
|
_positions = JsonSerializer.Deserialize<Dictionary<string, double>>(File.ReadAllText(_path)) ?? new();
|
||||||
|
}
|
||||||
|
catch { /* 손상 파일 → 빈 상태로 시작 */ }
|
||||||
|
_positions ??= new Dictionary<string, double>();
|
||||||
|
}
|
||||||
|
|
||||||
|
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 { /* 디스크 오류는 무시 */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
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<PositionDto>();
|
||||||
|
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"));
|
app.MapGet("/", () => Results.Text(WebCompanion.Html, "text/html; charset=utf-8"));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,9 @@ public sealed record QueueItemDto(string Id, string Title, string Url, string St
|
||||||
/// <summary>라이브러리(저장 폴더) 미디어 항목.</summary>
|
/// <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);
|
||||||
|
|
||||||
|
/// <summary>이어보기 위치(U3 동기화). 초 단위.</summary>
|
||||||
|
public sealed record PositionDto(double PositionSec);
|
||||||
|
|
||||||
/// <summary>서버 구성.</summary>
|
/// <summary>서버 구성.</summary>
|
||||||
public sealed class ServerOptions
|
public sealed class ServerOptions
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -104,6 +104,57 @@ public class RemoteServerE2ETests : IDisposable
|
||||||
Assert.Contains("api/queue", page); // 페이지가 API 를 호출하는 스크립트 포함
|
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<List<LibraryItemDto>>("/api/library"))!;
|
||||||
|
var id = lib[0].Id;
|
||||||
|
|
||||||
|
// 2) 미저장 기본값 0
|
||||||
|
var before = await http.GetFromJsonAsync<PositionDto>($"/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<PositionDto>($"/api/position/{id}");
|
||||||
|
Assert.Equal(93.5, after!.PositionSec);
|
||||||
|
|
||||||
|
// 4) 존재하지 않는 항목도 기본값 0 (섹션 파일 무손상)
|
||||||
|
var unknown = await http.GetFromJsonAsync<PositionDto>("/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<PositionDto>($"/api/position/{id}");
|
||||||
|
Assert.Equal(93.5, persisted!.PositionSec);
|
||||||
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
try { Directory.Delete(_libDir, true); } catch { }
|
try { Directory.Delete(_libDir, true); } catch { }
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue