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
|
|
@ -191,6 +191,14 @@
|
|||
<Button Name="HistoryBtn" Style="{StaticResource IconButton}" Content="" ToolTip="방문 기록" Margin="0,0,2,0" Click="ToggleHistory"/>
|
||||
<Button Name="TabSearchBtn" Style="{StaticResource IconButton}" Content="" ToolTip="탭 검색 (Ctrl+Shift+A)" Margin="0,0,2,0" Click="ToggleTabSearch"/>
|
||||
<Button Name="DownloadsBtn" Style="{StaticResource IconButton}" Content="" ToolTip="다운로드 (Ctrl+J)" Margin="0,0,2,0" Click="ToggleDownloads"/>
|
||||
<!-- T3-14 추적 방지 실드 -->
|
||||
<Grid Margin="0,0,2,0">
|
||||
<Button Name="ShieldBtn" Style="{StaticResource IconButton}" Content="" ToolTip="추적 방지" Click="Shield_Click"/>
|
||||
<Border Name="ShieldBadge" CornerRadius="8" Background="#FF4CAF50" Padding="5,1"
|
||||
HorizontalAlignment="Right" VerticalAlignment="Top" Margin="0,2,2,0" Visibility="Collapsed">
|
||||
<TextBlock Name="ShieldBadgeText" Text="" FontSize="9" Foreground="White"/>
|
||||
</Border>
|
||||
</Grid>
|
||||
<Button Name="PlaylistBtn" Style="{StaticResource IconButton}" Content="" ToolTip="미디어 재생목록" Margin="0,0,2,0" Click="TogglePlaylistPanel"/>
|
||||
<Button Style="{StaticResource IconButton}" Content="" ToolTip="저장 폴더 열기" Margin="0,0,2,0" Click="OpenFolder_Click"/>
|
||||
<Button Name="LinkBtn" Style="{StaticResource IconButton}" Content="" ToolTip="링크로 받기 (YouTube·Instagram·X)" Margin="0,0,2,0" Click="ToggleLink"/>
|
||||
|
|
@ -208,10 +216,25 @@
|
|||
<!-- 주소창 -->
|
||||
<Border Background="{StaticResource GlassBg}" BorderBrush="{StaticResource BorderBrush}"
|
||||
BorderThickness="1" CornerRadius="8" Height="32" VerticalAlignment="Center">
|
||||
<Grid>
|
||||
<TextBox Name="AddressBox" Style="{StaticResource DarkTextBox}"
|
||||
PreviewKeyDown="AddressBox_PreviewKeyDown" TextChanged="AddressBox_TextChanged"
|
||||
BorderThickness="0" Background="Transparent"
|
||||
Padding="14,0" FontSize="13" VerticalContentAlignment="Center"/>
|
||||
Padding="14,0" FontSize="13" VerticalContentAlignment="Center">
|
||||
<TextBox.ContextMenu>
|
||||
<ContextMenu>
|
||||
<MenuItem Header="붙여넣기" Command="ApplicationCommands.Paste"/>
|
||||
<MenuItem Header="복사" Command="ApplicationCommands.Copy"/>
|
||||
<Separator/>
|
||||
<MenuItem Header="파라미터 제거 후 복사" Click="CopyCleanUrl_Click" InputGestureText="T3-15"/>
|
||||
</ContextMenu>
|
||||
</TextBox.ContextMenu>
|
||||
</TextBox>
|
||||
<!-- T3-15 주소창 진행 바 -->
|
||||
<ProgressBar Name="NavProgress" Height="2" VerticalAlignment="Bottom" MinHeight="2"
|
||||
Minimum="0" Maximum="100" IsIndeterminate="True" Visibility="Collapsed"
|
||||
Foreground="{StaticResource AccentBrush}" Background="Transparent" BorderThickness="0"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
|
|
@ -527,6 +550,21 @@
|
|||
</Popup>
|
||||
|
||||
<!-- ===== 다운로드 플라이아웃 (T1-5) ===== -->
|
||||
<!-- T3-14 추적 방지 실드 팝업 -->
|
||||
<Popup Name="ShieldPopup" PlacementTarget="{Binding ElementName=ShieldBtn}"
|
||||
Placement="Bottom" HorizontalOffset="-10" VerticalOffset="6"
|
||||
StaysOpen="False" AllowsTransparency="True" PopupAnimation="Fade">
|
||||
<Border Background="{StaticResource CardBg}" BorderBrush="{StaticResource BorderBrush}"
|
||||
BorderThickness="1" CornerRadius="12" Padding="10" Width="320" MaxHeight="360">
|
||||
<StackPanel>
|
||||
<TextBlock Text="추적 방지 — 차단된 요청" Foreground="{StaticResource TextPrimary}"
|
||||
FontWeight="SemiBold" Margin="6,4,6,8"/>
|
||||
<ListBox Name="ShieldList" Background="Transparent" BorderThickness="0"
|
||||
Foreground="{StaticResource TextSecondary}" FontSize="12"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Popup>
|
||||
|
||||
<Popup Name="DownloadsPopup" PlacementTarget="{Binding ElementName=DownloadsBtn}"
|
||||
Placement="Bottom" HorizontalOffset="-10" VerticalOffset="6"
|
||||
StaysOpen="False" AllowsTransparency="True" PopupAnimation="Fade">
|
||||
|
|
|
|||
|
|
@ -214,10 +214,33 @@ public partial class MainWindow : Window
|
|||
_views.GlanceRequested += (_, link) => Dispatcher.BeginInvoke(new Action(() => OpenGlance(link))); // T2-6
|
||||
_views.SpeakRequested += (_, text) => Dispatcher.BeginInvoke(new Action(() => SpeakText(text))); // T3-9
|
||||
|
||||
_views.ContextMenuBuild += OnContextMenuBuild;
|
||||
_views.BoostScriptProvider = url => BoostCssBuilder.BuildScript(url, _boostStore.Get(url)); // T2-7
|
||||
_views.GlanceRequested += (_, link) => Dispatcher.BeginInvoke(new Action(() => OpenGlance(link))); // T2-6
|
||||
_views.SpeakRequested += (_, text) => Dispatcher.BeginInvoke(new Action(() => SpeakText(text))); // T3-9
|
||||
// T3-15 주소창 진행 바: 전 탭 네비게이션 집계 → 표시
|
||||
_navTracker = new NavProgressTracker();
|
||||
_navTracker.PropertyChanged += (_, e) =>
|
||||
NavProgress.Visibility = e.PropertyName == nameof(NavProgressTracker.IsNavigating) && _navTracker.IsNavigating
|
||||
? Visibility.Visible : Visibility.Collapsed;
|
||||
_views.NavigationStarted += _navTracker.Started;
|
||||
_views.NavigationCompleted += _navTracker.Completed;
|
||||
|
||||
// T3-11 권한 다이얼로그: 예=항상 허용 / 아니요=항상 거절 / 취소=이번만 거절
|
||||
_views.PermissionPrompt += (cv, e) => Dispatcher.BeginInvoke(new Action(() =>
|
||||
{
|
||||
var kind = e.PermissionKind.ToString();
|
||||
var origin = Services.TabViewService.OriginOf(e.Uri);
|
||||
var r = System.Windows.MessageBox.Show(this,
|
||||
$"{origin} 사이트가 {KoreanPermission(kind)} 권한을 요청합니다.\n허용하시겠습니까?\n\n예: 항상 허용 · 아니요: 항상 거절 · 취소: 이번만 거절",
|
||||
"권한 요청", MessageBoxButton.YesNoCancel, MessageBoxImage.Question);
|
||||
e.State = r == MessageBoxResult.Yes ? Microsoft.Web.WebView2.Core.CoreWebView2PermissionState.Allow
|
||||
: Microsoft.Web.WebView2.Core.CoreWebView2PermissionState.Deny;
|
||||
e.SavesInProfile = r != MessageBoxResult.Cancel; // 항상 선택만 프로필 영속
|
||||
e.Handled = true;
|
||||
if (r != MessageBoxResult.Cancel)
|
||||
_views.Permissions.Remember(kind, origin,
|
||||
r == MessageBoxResult.Yes ? PermissionVerdict.Allow : PermissionVerdict.Deny);
|
||||
}));
|
||||
|
||||
// T3-14 실드 배지 갱신
|
||||
_views.TrackerBlocked += UpdateShield;
|
||||
|
||||
_tabs = new TabManager(() => new TabItem(), _views.AttachView);
|
||||
_tabs.ActiveChanged += OnActiveTabChanged;
|
||||
|
|
@ -284,6 +307,52 @@ public partial class MainWindow : Window
|
|||
/// <summary>탭별 WebView2 생명주기·배선 전담 서비스(재구성 ④).</summary>
|
||||
private Services.TabViewService? _views;
|
||||
|
||||
/// <summary>T3-15 주소창 진행 바 상태(전 탭 네비게이션 집계).</summary>
|
||||
private NavProgressTracker? _navTracker;
|
||||
|
||||
/// <summary>T3-15: 파라미터 제거 후 클립보드 복사(주소창 우클릭).</summary>
|
||||
private void CopyCleanUrl_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var clean = UrlCleaner.StripQuery(AddressBox.Text);
|
||||
if (string.IsNullOrEmpty(clean)) return;
|
||||
try { Clipboard.SetText(clean); Log($"파라미터 제거 URL 복사: {clean}"); } catch { }
|
||||
}
|
||||
|
||||
/// <summary>T3-14: 실드 배지 갱신.</summary>
|
||||
private void UpdateShield()
|
||||
{
|
||||
if (_views == null) return;
|
||||
var n = _views.TrackerCounter.Total;
|
||||
ShieldBadgeText.Text = n > 0 ? n.ToString() : "";
|
||||
ShieldBadge.Visibility = n > 0 ? Visibility.Visible : Visibility.Collapsed;
|
||||
ShieldBtn.ToolTip = n > 0 ? $"추적 요청 {n}건 차단됨" : "추적 방지";
|
||||
}
|
||||
|
||||
/// <summary>T3-14: 실드 클릭 → 차단 도메인 목록 팝업.</summary>
|
||||
private void Shield_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_views == null) return;
|
||||
var byDomain = _views.TrackerCounter.ByDomain;
|
||||
ShieldList.ItemsSource = byDomain.Count == 0
|
||||
? new[] { "차단된 추적 요청이 없습니다" }
|
||||
: byDomain.OrderByDescending(kv => kv.Value).Select(kv => $"{kv.Value,3} × {kv.Key}").ToList();
|
||||
ShieldPopup.IsOpen = true;
|
||||
}
|
||||
|
||||
/// <summary>T3-11: 권한 종류 한글 표기.</summary>
|
||||
private static string KoreanPermission(string kind) => kind switch
|
||||
{
|
||||
"Microphone" => "마이크",
|
||||
"Camera" => "카메라",
|
||||
"Geolocation" => "위치",
|
||||
"Notifications" => "알림",
|
||||
"ClipboardRead" => "클립보드 읽기",
|
||||
"Autoplay" => "자동 재생",
|
||||
"FileReadWrite" => "파일 읽기/쓰기",
|
||||
"PersistentStorage" => "영구 저장소",
|
||||
_ => kind,
|
||||
};
|
||||
|
||||
private void OnUrlChanged(TabItem tab, string url)
|
||||
{
|
||||
if (tab.PendingHistoryUrl != null) RecordHistory(tab.PendingHistoryUrl, tab.PendingHistoryUrl);
|
||||
|
|
|
|||
|
|
@ -55,6 +55,27 @@ public sealed class TabViewService
|
|||
/// <summary>T2-4 리더 내 낭독(TTS) 요청 — 리더 본문 텍스트.</summary>
|
||||
public event Action<CoreWebView2, string>? SpeakRequested;
|
||||
|
||||
/// <summary>T3-15 주소창 진행 바: 네비게이션 시작(전 탭 집계).</summary>
|
||||
public event Action? NavigationStarted;
|
||||
|
||||
/// <summary>T3-15 주소창 진행 바: 네비게이션 완료(전 탭 집계).</summary>
|
||||
public event Action? NavigationCompleted;
|
||||
|
||||
/// <summary>T3-11 사이트별 권한 정책(출처×종류 기억).</summary>
|
||||
public PermissionGate Permissions { get; set; } = new();
|
||||
|
||||
/// <summary>T3-11 Ask 판정 시 호스트 다이얼로그 — State/Handled/SavesInProfile 설정은 호스트 책임.</summary>
|
||||
public event Action<CoreWebView2, CoreWebView2PermissionRequestedEventArgs>? PermissionPrompt;
|
||||
|
||||
/// <summary>T3-14 추적 방지 블랙리스트(교체 주입 가능).</summary>
|
||||
public TrackerBlocklist TrackerBlocklist { get; set; } = new();
|
||||
|
||||
/// <summary>T3-14 차단 카운터(실드 배지·팝업 데이터).</summary>
|
||||
public TrackerCounter TrackerCounter { get; } = new();
|
||||
|
||||
/// <summary>T3-14 차단 발생 알림(배지 갱신).</summary>
|
||||
public event Action? TrackerBlocked;
|
||||
|
||||
/// <summary>T3-3 InPrivate 탭: 다음에 만들어지는 탭에 적용(1회성).</summary>
|
||||
public bool NextTabIsPrivate { get; set; }
|
||||
|
||||
|
|
@ -166,10 +187,11 @@ public sealed class TabViewService
|
|||
}));
|
||||
|
||||
// 네비게이션 상태
|
||||
cv.NavigationStarting += (_, _) => _dispatcher.BeginInvoke(new Action(() => tab.IsLoading = true));
|
||||
cv.NavigationStarting += (_, _) => _dispatcher.BeginInvoke(new Action(() => { tab.IsLoading = true; NavigationStarted?.Invoke(); }));
|
||||
cv.NavigationCompleted += (_, _) => _dispatcher.BeginInvoke(new Action(() =>
|
||||
{
|
||||
tab.IsLoading = false;
|
||||
NavigationCompleted?.Invoke();
|
||||
try { if (tab.View != null) tab.View.ZoomFactor = _zoomStore.Get(cv.Source); } catch { } // T1-7 사이트별 줌
|
||||
try // T2-7 부스트(강제 다크/커스텀 CSS) 적용
|
||||
{
|
||||
|
|
@ -180,6 +202,37 @@ public sealed class TabViewService
|
|||
}));
|
||||
cv.HistoryChanged += (_, _) => HistoryChanged?.Invoke();
|
||||
|
||||
// T3-11 사이트별 권한: 정책 Ask 만 프롬프트, Allow/Deny 는 즉시 응답
|
||||
cv.PermissionRequested += (_, e) =>
|
||||
{
|
||||
var kind = e.PermissionKind.ToString();
|
||||
var origin = OriginOf(e.Uri);
|
||||
switch (Permissions.Decide(kind, origin))
|
||||
{
|
||||
case PermissionVerdict.Allow:
|
||||
e.State = CoreWebView2PermissionState.Allow; e.Handled = true;
|
||||
break;
|
||||
case PermissionVerdict.Deny:
|
||||
e.State = CoreWebView2PermissionState.Deny; e.Handled = true;
|
||||
break;
|
||||
default:
|
||||
PermissionPrompt?.Invoke(cv, e); // 호스트 다이얼로그가 State/Handled 설정
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
// T3-14 추적 방지: 블랙리스트 요청 차단 + 카운터
|
||||
cv.AddWebResourceRequestedFilter("*", CoreWebView2WebResourceContext.All);
|
||||
cv.WebResourceRequested += (_, e) =>
|
||||
{
|
||||
Uri u;
|
||||
try { u = new Uri(e.Request.Uri); } catch { return; }
|
||||
if (!TrackerBlocklist.Matches(u)) return;
|
||||
try { e.Response = _env.CreateWebResourceResponse(null, 204, "Blocked", ""); } catch { }
|
||||
TrackerCounter.Record(e.Request.Uri);
|
||||
_dispatcher.BeginInvoke(new Action(() => TrackerBlocked?.Invoke()));
|
||||
};
|
||||
|
||||
cv.SourceChanged += (_, _) => _dispatcher.BeginInvoke(new Action(() =>
|
||||
{
|
||||
var url = cv.Source ?? "";
|
||||
|
|
@ -250,4 +303,12 @@ public sealed class TabViewService
|
|||
|
||||
/// <summary>활성 탭 검색 상태 표시 요청(FindStatus 텍스트 갱신 — 호스트가 구독).</summary>
|
||||
public event Action<TabItem>? FindStateChanged;
|
||||
|
||||
/// <summary>권한 기준 출처(scheme://host:port) 추출.</summary>
|
||||
public static string OriginOf(string? uri)
|
||||
{
|
||||
if (string.IsNullOrEmpty(uri)) return "";
|
||||
try { var u = new Uri(uri); return u.GetComponents(UriComponents.SchemeAndServer, UriFormat.Unescaped); }
|
||||
catch { return uri; }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
78
VideoDownloader.Browser/PermissionGate.cs
Normal file
78
VideoDownloader.Browser/PermissionGate.cs
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
namespace VideoDownloader.Browser;
|
||||
|
||||
public enum PermissionVerdict { Ask, Allow, Deny }
|
||||
|
||||
/// <summary>
|
||||
/// T3-11 사이트별 권한 정책 — 출처×종류 단위 기억. WebView2 PermissionRequested 에서
|
||||
/// Ask 만 프롬프트로 올리고 Allow/Deny 는 즉시 응답. "항상" 선택은 네이티브
|
||||
/// Profile.SetPermissionStateAsync 로도 영속(세션 밖 저장).
|
||||
/// </summary>
|
||||
public sealed class PermissionGate
|
||||
{
|
||||
/// <summary>프롬프트 없이 거절: 센서류(피싱 표면 축소).</summary>
|
||||
private static readonly HashSet<string> AutoDeny = new(StringComparer.OrdinalIgnoreCase) { "OtherSensors" };
|
||||
/// <summary>프롬프트 없이 허용: 연속 다운로드(다운로더 앱 본업).</summary>
|
||||
private static readonly HashSet<string> AutoAllow = new(StringComparer.OrdinalIgnoreCase) { "MultipleAutomaticDownloads" };
|
||||
|
||||
private readonly Dictionary<(string origin, string kind), PermissionVerdict> _saved = new();
|
||||
|
||||
public PermissionVerdict Decide(string kind, string origin)
|
||||
{
|
||||
if (AutoDeny.Contains(kind)) return PermissionVerdict.Deny;
|
||||
if (AutoAllow.Contains(kind)) return PermissionVerdict.Allow;
|
||||
return _saved.GetValueOrDefault((origin, kind), PermissionVerdict.Ask);
|
||||
}
|
||||
|
||||
public void Remember(string kind, string origin, PermissionVerdict verdict)
|
||||
=> _saved[(origin, kind)] = verdict;
|
||||
}
|
||||
|
||||
/// <summary>T3-14: 추적 스크립트 블랙리스트 — 호스트 또는 서브도메인 매칭.</summary>
|
||||
public sealed class TrackerBlocklist
|
||||
{
|
||||
/// <summary>주요 광고/분석 도메인(EasyList 공통 항목 발췌). 생성자 주입으로 교체 가능(테스트).</summary>
|
||||
public static readonly string[] DefaultDomains =
|
||||
{
|
||||
"google-analytics.com", "googletagmanager.com", "doubleclick.net", "googlesyndication.com",
|
||||
"googleadservices.com", "scorecardresearch.com", "hotjar.com", "mixpanel.com", "segment.io",
|
||||
"facebook.net", "connect.facebook.net", "amplitude.com", "branch.io", "appsflyer.com",
|
||||
"criteo.com", "taboola.com", "outbrain.com", "adnxs.com", "pubmatic.com", "rubiconproject.com",
|
||||
};
|
||||
|
||||
private readonly HashSet<string> _domains;
|
||||
|
||||
public TrackerBlocklist() : this(DefaultDomains) { }
|
||||
public TrackerBlocklist(IEnumerable<string> domains) =>
|
||||
_domains = new HashSet<string>(domains, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public bool Matches(Uri url)
|
||||
{
|
||||
var host = url.Host;
|
||||
foreach (var d in _domains)
|
||||
if (host.Equals(d, StringComparison.OrdinalIgnoreCase) ||
|
||||
host.EndsWith("." + d, StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>T3-14: 차단 카운터 — 총계/도메인별 집계, 변경 통지(실드 배지 갱신용).</summary>
|
||||
public sealed class TrackerCounter
|
||||
{
|
||||
private int _total;
|
||||
public int Total => Volatile.Read(ref _total);
|
||||
public Dictionary<string, int> ByDomain { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
public event Action? Changed;
|
||||
|
||||
public void Record(string url)
|
||||
{
|
||||
var host = new Uri(url).Host;
|
||||
var baseDomain = host.StartsWith("www.", StringComparison.OrdinalIgnoreCase) ? host[4..] : host;
|
||||
Interlocked.Increment(ref _total);
|
||||
lock (ByDomain)
|
||||
{
|
||||
ByDomain[baseDomain] = ByDomain.GetValueOrDefault(baseDomain) + 1;
|
||||
}
|
||||
Changed?.Invoke();
|
||||
}
|
||||
}
|
||||
43
VideoDownloader.Browser/UrlCleaner.cs
Normal file
43
VideoDownloader.Browser/UrlCleaner.cs
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
using System.ComponentModel;
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace VideoDownloader.Browser;
|
||||
|
||||
/// <summary>T3-15: "파라미터 제거 복사" — 추적용 쿼리·프래그먼트를 뺀 URL 반환.</summary>
|
||||
public static class UrlCleaner
|
||||
{
|
||||
public static string? StripQuery(string? url)
|
||||
{
|
||||
if (url == null) return null;
|
||||
var q = url.IndexOf('?');
|
||||
if (q >= 0) url = url[..q];
|
||||
var f = url.IndexOf('#');
|
||||
if (f >= 0) url = url[..f];
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>T3-15: 주소창 진행 바 상태 — 진행 중 네비게이션 개수 집계(탭·리다이렉트 중복 안전).</summary>
|
||||
public sealed class NavProgressTracker : INotifyPropertyChanged
|
||||
{
|
||||
private int _count;
|
||||
|
||||
public bool IsNavigating => Volatile.Read(ref _count) > 0;
|
||||
|
||||
public event PropertyChangedEventHandler? PropertyChanged;
|
||||
|
||||
public void Started() => Update(1);
|
||||
public void Completed() => Update(-1);
|
||||
|
||||
private void Update(int delta)
|
||||
{
|
||||
var before = IsNavigating;
|
||||
var c = _count + delta;
|
||||
Volatile.Write(ref _count, Math.Max(0, c)); // 과잉 완료 방지
|
||||
if (before != IsNavigating)
|
||||
OnPropertyChanged(nameof(IsNavigating));
|
||||
}
|
||||
|
||||
private void OnPropertyChanged([CallerMemberName] string? name = null) =>
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
|
||||
}
|
||||
21
VideoDownloader.Mobile.Core/ConnectionTroubleshoot.cs
Normal file
21
VideoDownloader.Mobile.Core/ConnectionTroubleshoot.cs
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
namespace VideoDownloader.Mobile;
|
||||
|
||||
/// <summary>
|
||||
/// 외부망/네트워크 불통 안내(MOBILE_PLAN §7 성공 기준) —
|
||||
/// "같은 네트워크 필요 / 외부망에서는 Tailscale 등 VPN 안내".
|
||||
/// </summary>
|
||||
public static class ConnectionTroubleshoot
|
||||
{
|
||||
public const string NotConfigured = "먼저 연결 탭에서 PC 와 페어링하세요";
|
||||
|
||||
public const string Unreachable =
|
||||
"PC 에 연결할 수 없습니다. 폰과 PC 가 같은 네트워크(Wi-Fi)에 있는지 확인하세요. " +
|
||||
"외부망에서 접속하려면 Tailscale 등 VPN 으로 PC 의 사설망에 접근해야 합니다.";
|
||||
|
||||
public static string Message(bool isConfigured, string? lastError)
|
||||
{
|
||||
if (!isConfigured) return NotConfigured;
|
||||
if (!string.IsNullOrEmpty(lastError)) return Unreachable;
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
|
@ -10,16 +10,15 @@ public sealed record OfflineItem(string Id, string FileName, long SizeBytes);
|
|||
/// </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()
|
||||
/// <summary>baseDir 아래 offline/ 폴더 사용(호스트가 앱 데이터 디렉터리 주입).</summary>
|
||||
public OfflineStore(string baseDir)
|
||||
{
|
||||
_dir = Path.Combine(FileSystem.AppDataDirectory, "offline");
|
||||
_dir = Path.Combine(baseDir, "offline");
|
||||
_manifestPath = Path.Combine(_dir, "manifest.json");
|
||||
try
|
||||
{
|
||||
18
VideoDownloader.Mobile.Core/QueueCompletionDetector.cs
Normal file
18
VideoDownloader.Mobile.Core/QueueCompletionDetector.cs
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
namespace VideoDownloader.Mobile;
|
||||
|
||||
/// <summary>U5 완료 알림: 이전/다음 큐 스냅샷에서 새로 완료("완료")된 항목 ID 감지.
|
||||
/// 이전 스냅샷이 비면(앱 시작 직후 첫 폴링) 무시 — 과거 완료 알림 스팸 방지.</summary>
|
||||
public sealed class QueueCompletionDetector
|
||||
{
|
||||
public IReadOnlyList<string> Detect(
|
||||
IReadOnlyList<(string Id, string Status)> previous,
|
||||
IReadOnlyList<(string Id, string Status)> next)
|
||||
{
|
||||
if (previous.Count == 0) return Array.Empty<string>();
|
||||
var prev = previous.ToDictionary(x => x.Id, x => x.Status);
|
||||
return next
|
||||
.Where(n => n.Status == "완료" && prev.TryGetValue(n.Id, out var s) && s != "완료")
|
||||
.Select(n => n.Id)
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<RootNamespace>VideoDownloader.Mobile</RootNamespace>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>latest</LangVersion>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
|
|
@ -116,6 +116,7 @@ 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}";
|
||||
public string ThumbUrl(LibraryItem item) => $"{BaseUrl}/api/library/{item.Id}/thumb?t={Token}";
|
||||
|
||||
/// <summary>이어보기 위치 조회(U3). 실패/미저장 시 0.</summary>
|
||||
public async Task<double> GetPositionAsync(string id)
|
||||
|
|
@ -188,6 +189,6 @@ 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 LibraryItem(string Id, string FileName, long SizeBytes, DateTime Modified, string? Resolution = null);
|
||||
|
||||
public sealed record PositionDto(double PositionSec);
|
||||
|
|
|
|||
7
VideoDownloader.Mobile/AppOfflineStore.cs
Normal file
7
VideoDownloader.Mobile/AppOfflineStore.cs
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
namespace VideoDownloader.Mobile;
|
||||
|
||||
/// <summary>앱 데이터 디렉터리를 주입하는 오프라인 저장소 기본 인스턴스(Core 는 MAUI 비의존).</summary>
|
||||
public static class AppOfflineStore
|
||||
{
|
||||
public static OfflineStore Default { get; } = new(FileSystem.AppDataDirectory);
|
||||
}
|
||||
|
|
@ -70,13 +70,16 @@ public sealed class ConnectPage : ContentPage
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>M1 큐 페이지: 목록(2초 폴링) + URL 추가 + 제어.</summary>
|
||||
/// <summary>M1 큐 페이지: 목록(2초 폴링) + URL 추가 + 제어 + U5 완료 알림 + 외부망 안내.</summary>
|
||||
public sealed class QueuePage : ContentPage
|
||||
{
|
||||
private readonly Entry _url = new() { Placeholder = "YouTube·영상 URL 붙여넣기", Keyboard = Keyboard.Url };
|
||||
private readonly Label _summary = new();
|
||||
private readonly Label _trouble = new() { FontSize = 12, TextColor = Colors.OrangeRed, IsVisible = false };
|
||||
private readonly CollectionView _list = new();
|
||||
private readonly IDispatcherTimer _timer;
|
||||
private readonly QueueCompletionDetector _completions = new();
|
||||
private List<QueueItem> _last = new();
|
||||
|
||||
public QueuePage()
|
||||
{
|
||||
|
|
@ -131,19 +134,51 @@ public sealed class QueuePage : ContentPage
|
|||
Content = new VerticalStackLayout
|
||||
{
|
||||
Padding = 16, Spacing = 10,
|
||||
Children = { _url, addBtn, _summary, _list }
|
||||
Children = { _url, addBtn, _summary, _trouble, _list }
|
||||
};
|
||||
}
|
||||
|
||||
private async Task Refresh()
|
||||
{
|
||||
if (!ApiClient.Instance.IsConfigured) { _summary.Text = "⚠️ 먼저 연결하세요"; return; }
|
||||
var trouble = ConnectionTroubleshoot.Message(ApiClient.Instance.IsConfigured, ApiClient.Instance.LastError);
|
||||
_trouble.Text = trouble;
|
||||
_trouble.IsVisible = trouble.Length > 0;
|
||||
if (!ApiClient.Instance.IsConfigured) { _summary.Text = "⚠️ 먼저 연결하세요"; _last = new(); return; }
|
||||
var items = await ApiClient.Instance.GetQueueAsync();
|
||||
_list.ItemsSource = items;
|
||||
|
||||
// U5: 이전 스냅샷 대비 새로 완료된 항목 → 로컬 알림
|
||||
var done = _completions.Detect(
|
||||
_last.Select(i => (i.Id, i.Status)).ToList(),
|
||||
items.Select(i => (i.Id, i.Status)).ToList());
|
||||
foreach (var id in done)
|
||||
{
|
||||
var item = items.First(i => i.Id == id);
|
||||
_ = NotifyCompletionAsync(item.Title);
|
||||
_summary.Text = $"🔔 완료: {item.Title}";
|
||||
}
|
||||
_last = items;
|
||||
|
||||
var err = ApiClient.Instance.LastError;
|
||||
_summary.Text = err != null ? $"❌ {err}" : items.Count == 0 ? "대기 중인 항목이 없습니다" : $"{items.Count}건";
|
||||
}
|
||||
|
||||
/// <summary>U5: 완료 로컬 알림(Android 13+ 는 최초 1회 권한 요청).</summary>
|
||||
private static async Task NotifyCompletionAsync(string title)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (OperatingSystem.IsAndroidVersionAtLeast(33))
|
||||
await Plugin.LocalNotification.LocalNotificationCenter.Current.RequestNotificationPermission();
|
||||
Plugin.LocalNotification.LocalNotificationCenter.Current.Show(new Plugin.LocalNotification.NotificationRequest
|
||||
{
|
||||
Title = "다운로드 완료",
|
||||
Description = title,
|
||||
});
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
private sealed class PercentConverter : IValueConverter
|
||||
{
|
||||
public object Convert(object? value, Type t, object? p, System.Globalization.CultureInfo c) => Math.Clamp(value is double d ? d / 100.0 : 0, 0, 1);
|
||||
|
|
@ -159,11 +194,14 @@ public sealed class LibraryPage : ContentPage
|
|||
public LibraryPage()
|
||||
{
|
||||
Title = "라이브러리";
|
||||
_list.ItemsLayout = new GridItemsLayout(2, ItemsLayoutOrientation.Vertical); // M2 썸네일 그리드
|
||||
_list.ItemTemplate = new DataTemplate(() =>
|
||||
{
|
||||
var name = new Label { FontSize = 14, FontAttributes = FontAttributes.Bold, LineBreakMode = LineBreakMode.TailTruncation };
|
||||
var thumb = new Image { HeightRequest = 96, Aspect = Aspect.AspectFill, BackgroundColor = Colors.Black };
|
||||
thumb.SetBinding(Image.SourceProperty, new Binding(".", converter: new ThumbConverter()));
|
||||
var name = new Label { FontSize = 13, FontAttributes = FontAttributes.Bold, LineBreakMode = LineBreakMode.TailTruncation, LineHeight = 1.1 };
|
||||
name.SetBinding(Label.TextProperty, "FileName");
|
||||
var size = new Label { FontSize = 11, TextColor = Colors.Gray };
|
||||
var size = new Label { FontSize = 10, TextColor = Colors.Gray };
|
||||
size.SetBinding(Label.TextProperty, new Binding("SizeBytes", stringFormat: "{0:N0} bytes"));
|
||||
|
||||
// U4 폰 저장: 카드 우측 버튼, 진행률을 버튼 텍스트로 표시
|
||||
|
|
@ -172,8 +210,8 @@ public sealed class LibraryPage : ContentPage
|
|||
|
||||
var card = new Frame
|
||||
{
|
||||
Margin = new Thickness(0, 4), Padding = 12, CornerRadius = 10,
|
||||
Content = new HorizontalStackLayout { Spacing = 8, Children = { new VerticalStackLayout { Children = { name, size } }, save } }
|
||||
Margin = new Thickness(6, 4), Padding = 8, CornerRadius = 10,
|
||||
Content = new VerticalStackLayout { Spacing = 4, Children = { thumb, name, size, save } }
|
||||
};
|
||||
// CollectionView.SelectionChanged 는 Android 에서 발화 불안정 → 템플릿 카드에 탭 제스처
|
||||
var tap = new TapGestureRecognizer();
|
||||
|
|
@ -194,15 +232,15 @@ public sealed class LibraryPage : ContentPage
|
|||
private static async Task SaveOffline(Button button, LibraryItem? item)
|
||||
{
|
||||
if (item == null) return;
|
||||
if (OfflineStore.Default.Exists(item.Id)) { button.Text = "✓ 저장됨"; return; }
|
||||
if (AppOfflineStore.Default.Exists(item.Id)) { button.Text = "✓ 저장됨"; return; }
|
||||
button.Text = "0%";
|
||||
try
|
||||
{
|
||||
var dest = OfflineStore.Default.PathFor(item.Id);
|
||||
var dest = AppOfflineStore.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);
|
||||
AppOfflineStore.Default.Record(item.Id, item.FileName);
|
||||
button.Text = "✓ 저장됨";
|
||||
}
|
||||
catch (Exception e)
|
||||
|
|
@ -247,7 +285,7 @@ public sealed class DownloadsPage : ContentPage
|
|||
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(); }
|
||||
if ((del.BindingContext as OfflineItem) is { } it) { AppOfflineStore.Default.Delete(it.Id); Reload(); }
|
||||
};
|
||||
|
||||
var card = new Frame
|
||||
|
|
@ -260,7 +298,7 @@ public sealed class DownloadsPage : ContentPage
|
|||
{
|
||||
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))));
|
||||
new NavigationPage(new PlayerPage(new LibraryItem(it.Id, it.FileName, it.SizeBytes, DateTime.MinValue), AppOfflineStore.Default.PathFor(it.Id))));
|
||||
};
|
||||
card.GestureRecognizers.Add(tap);
|
||||
return card;
|
||||
|
|
@ -277,7 +315,7 @@ public sealed class DownloadsPage : ContentPage
|
|||
|
||||
private void Reload()
|
||||
{
|
||||
var items = OfflineStore.Default.List();
|
||||
var items = AppOfflineStore.Default.List();
|
||||
_list.ItemsSource = items.Count > 0 ? items : null;
|
||||
_empty.IsVisible = items.Count == 0;
|
||||
}
|
||||
|
|
@ -321,14 +359,55 @@ public sealed class PlayerPage : ContentPage
|
|||
});
|
||||
}
|
||||
Content = _media;
|
||||
// M2: 재생 중 미디어 알림(ongoing 노티) — 재생은 앱 내(포그라운드) 한정
|
||||
StartPlaybackNotification(item.FileName);
|
||||
}
|
||||
|
||||
protected override void OnDisappearing()
|
||||
{
|
||||
if (_sync && _itemId != null && _media.Position > TimeSpan.Zero)
|
||||
_ = ApiClient.Instance.PostPositionAsync(_itemId, _media.Position.TotalSeconds);
|
||||
StopPlaybackNotification();
|
||||
_media.Stop();
|
||||
_media.Handler?.DisconnectHandler(); // 재생 리소스 해제
|
||||
base.OnDisappearing();
|
||||
}
|
||||
|
||||
private const int PlaybackNotificationId = 4711;
|
||||
|
||||
/// <summary>M2: 재생 중 미디어 알림 — ongoing 노티(닫으면 자동 소멸).</summary>
|
||||
private static void StartPlaybackNotification(string title)
|
||||
{
|
||||
try
|
||||
{
|
||||
Plugin.LocalNotification.LocalNotificationCenter.Current.Show(new Plugin.LocalNotification.NotificationRequest
|
||||
{
|
||||
NotificationId = PlaybackNotificationId,
|
||||
Title = "재생 중",
|
||||
Description = title,
|
||||
Android =
|
||||
{
|
||||
ChannelId = "vd_playback",
|
||||
Ongoing = true,
|
||||
AutoCancel = false,
|
||||
},
|
||||
});
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
private static void StopPlaybackNotification()
|
||||
{
|
||||
try { Plugin.LocalNotification.LocalNotificationCenter.Current.Cancel(PlaybackNotificationId); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>M2: LibraryItem → 썸네일 URL 변환(바인딩 컨버터).</summary>
|
||||
public sealed class ThumbConverter : IValueConverter
|
||||
{
|
||||
public object? Convert(object? value, Type t, object? p, System.Globalization.CultureInfo c)
|
||||
=> value is LibraryItem it && ApiClient.Instance.IsConfigured
|
||||
? Microsoft.Maui.Controls.ImageSource.FromUri(new Uri(ApiClient.Instance.ThumbUrl(it)))
|
||||
: null;
|
||||
public object ConvertBack(object? v, Type t, object? p, System.Globalization.CultureInfo c) => throw new NotSupportedException();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,5 +4,9 @@
|
|||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<!-- U5 완료 알림(Android 13+) -->
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<!-- M2 재생중 미디어 알림(포그라운드 서비스 mediaPlayback) -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||
</manifest>
|
||||
|
|
@ -66,8 +66,14 @@
|
|||
<MauiAsset Include="Resources\Raw\**" LogicalName="%(RecursiveDir)%(Filename)%(Extension)" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\VideoDownloader.Mobile.Core\VideoDownloader.Mobile.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Maui.Controls" Version="$(MauiVersion)" />
|
||||
<!-- U5 완료 알림 -->
|
||||
<PackageReference Include="Plugin.LocalNotification" Version="11.1.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Debug" Version="10.0.0" />
|
||||
<PackageReference Include="ZXing.Net.Maui.Controls" Version="0.4.0" />
|
||||
<PackageReference Include="CommunityToolkit.Maui.MediaElement" Version="3.2.0" />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
46
VideoDownloader.Tests/Browser/PermissionGateTests.cs
Normal file
46
VideoDownloader.Tests/Browser/PermissionGateTests.cs
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
using VideoDownloader.Browser;
|
||||
|
||||
namespace VideoDownloader.Tests.Browser;
|
||||
|
||||
/// <summary>T3-11: 사이트별 권한 정책 — 기억된 상태 우선, 미기억은 Ask(프롬프트).</summary>
|
||||
public class PermissionGateTests
|
||||
{
|
||||
[Fact]
|
||||
public void 미기억_권한은_Ask()
|
||||
=> Assert.Equal(PermissionVerdict.Ask, new PermissionGate().Decide("notifications", "https://x.com"));
|
||||
|
||||
[Theory]
|
||||
[InlineData("allow")]
|
||||
[InlineData("deny")]
|
||||
public void 기억된_판정은_그대로_반환(string v)
|
||||
{
|
||||
var g = new PermissionGate();
|
||||
g.Remember("notifications", "https://x.com", v == "allow" ? PermissionVerdict.Allow : PermissionVerdict.Deny);
|
||||
Assert.Equal(v == "allow" ? PermissionVerdict.Allow : PermissionVerdict.Deny,
|
||||
g.Decide("notifications", "https://x.com"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void 판정은_출처_단위_격리()
|
||||
{
|
||||
var g = new PermissionGate();
|
||||
g.Remember("geolocation", "https://a.com", PermissionVerdict.Allow);
|
||||
Assert.Equal(PermissionVerdict.Ask, g.Decide("geolocation", "https://b.com"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void 판정은_종류_단위_격리()
|
||||
{
|
||||
var g = new PermissionGate();
|
||||
g.Remember("notifications", "https://a.com", PermissionVerdict.Allow);
|
||||
Assert.Equal(PermissionVerdict.Ask, g.Decide("camera", "https://a.com"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OtherSensors_등_민감_종류는_자동_거절()
|
||||
=> Assert.Equal(PermissionVerdict.Deny, new PermissionGate().Decide("OtherSensors", "https://x.com"));
|
||||
|
||||
[Fact]
|
||||
public void MultipleAutomaticDownloads_파일_다운로드는_자동_허용()
|
||||
=> Assert.Equal(PermissionVerdict.Allow, new PermissionGate().Decide("MultipleAutomaticDownloads", "https://x.com"));
|
||||
}
|
||||
40
VideoDownloader.Tests/Browser/TrackerBlockTests.cs
Normal file
40
VideoDownloader.Tests/Browser/TrackerBlockTests.cs
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
using VideoDownloader.Browser;
|
||||
|
||||
namespace VideoDownloader.Tests.Browser;
|
||||
|
||||
/// <summary>T3-14: 추적 방지 — 블랙리스트 도메인 매칭(서브도메인 포함) + 차단 카운터.</summary>
|
||||
public class TrackerBlockTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("https://google-analytics.com/analytics.js", true)] // 정확히 도메인
|
||||
[InlineData("https://www.google-analytics.com/g/collect", true)] // 서브도메인
|
||||
[InlineData("https://x.com/notgoogle-analytics.com/a", false)] // 접미사 함정 제외
|
||||
[InlineData("https://doubleclick.net/instream/ad_status.js", true)]
|
||||
[InlineData("https://safe.example.com/lib.js", false)] // 무관 도메인
|
||||
public void Matches_블랙리스트_도메인_판정(string url, bool expected)
|
||||
=> Assert.Equal(expected, new TrackerBlocklist().Matches(new Uri(url)));
|
||||
|
||||
[Fact]
|
||||
public void 커스텀_도메인_주입_가능()
|
||||
{
|
||||
var bl = new TrackerBlocklist(new[] { "blocked.test" });
|
||||
Assert.True(bl.Matches(new Uri("https://t.blocked.test/x.js")));
|
||||
Assert.False(bl.Matches(new Uri("https://ok.com/x.js")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Counter_차단_누적_도메인별_집계_변경_통지()
|
||||
{
|
||||
var c = new TrackerCounter();
|
||||
var changed = 0;
|
||||
c.Changed += () => changed++;
|
||||
Assert.Equal(0, c.Total);
|
||||
c.Record("https://www.google-analytics.com/a.js");
|
||||
c.Record("https://google-analytics.com/b.js");
|
||||
c.Record("https://scorecardresearch.com/c.js");
|
||||
Assert.Equal(3, c.Total);
|
||||
Assert.Equal(2, c.ByDomain["google-analytics.com"]);
|
||||
Assert.Equal(1, c.ByDomain["scorecardresearch.com"]);
|
||||
Assert.Equal(3, changed);
|
||||
}
|
||||
}
|
||||
80
VideoDownloader.Tests/Browser/UrlCleanerTests.cs
Normal file
80
VideoDownloader.Tests/Browser/UrlCleanerTests.cs
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
using VideoDownloader.Browser;
|
||||
|
||||
namespace VideoDownloader.Tests.Browser;
|
||||
|
||||
/// <summary>T3-15: 파라미터 제거 복사 + 주소창 진행 상태.</summary>
|
||||
public class UrlCleanerTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("https://x.com/a?utm_source=1&b=2", "https://x.com/a")] // 쿼리 제거
|
||||
[InlineData("https://x.com/a#frag", "https://x.com/a")] // 프래그먼트 제거
|
||||
[InlineData("https://x.com/a?b=1#f", "https://x.com/a")] // 둘 다
|
||||
[InlineData("https://x.com/a/", "https://x.com/a/")] // 없으면 무변경
|
||||
[InlineData("https://x.com/a?q=한글", "https://x.com/a")] // 유니코드 쿼리
|
||||
[InlineData("x.com/a?b=1", "x.com/a")] // 스킴 없어도 동작
|
||||
public void StripQuery_파라미터와_프래그먼트를_제거(string input, string expected)
|
||||
=> Assert.Equal(expected, UrlCleaner.StripQuery(input));
|
||||
|
||||
[Fact]
|
||||
public void StripQuery_빈값_null_안전()
|
||||
{
|
||||
Assert.Equal("", UrlCleaner.StripQuery(""));
|
||||
Assert.Null(UrlCleaner.StripQuery(null));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>T3-15: 탭 전체 네비게이션 진행 집계 — 주소창 진행 바 표시 상태.</summary>
|
||||
public class NavProgressTrackerTests
|
||||
{
|
||||
[Fact]
|
||||
public void 초기값은_비진행()
|
||||
=> Assert.False(new NavProgressTracker().IsNavigating);
|
||||
|
||||
[Fact]
|
||||
public void 시작하면_진행()
|
||||
{
|
||||
var t = new NavProgressTracker();
|
||||
t.Started();
|
||||
Assert.True(t.IsNavigating);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void 완료되면_비진행()
|
||||
{
|
||||
var t = new NavProgressTracker();
|
||||
t.Started();
|
||||
t.Completed();
|
||||
Assert.False(t.IsNavigating);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void 동시_내비게이션은_마지막_완료까지_진행() // 프레임 리다이렉트·중복 시작
|
||||
{
|
||||
var t = new NavProgressTracker();
|
||||
t.Started(); t.Started();
|
||||
t.Completed();
|
||||
Assert.True(t.IsNavigating);
|
||||
t.Completed();
|
||||
Assert.False(t.IsNavigating);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void 과잉_완료로_음수_되지_않음()
|
||||
{
|
||||
var t = new NavProgressTracker();
|
||||
t.Completed();
|
||||
Assert.False(t.IsNavigating);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsNavigating_변경_시_INPC_발화()
|
||||
{
|
||||
var t = new NavProgressTracker();
|
||||
var fired = new List<string?>();
|
||||
t.PropertyChanged += (_, e) => fired.Add(e.PropertyName);
|
||||
t.Started();
|
||||
t.Completed();
|
||||
t.Completed(); // false→false → 이벤트 없음
|
||||
Assert.Equal(new[] { nameof(NavProgressTracker.IsNavigating), nameof(NavProgressTracker.IsNavigating) }, fired);
|
||||
}
|
||||
}
|
||||
109
VideoDownloader.Tests/E2E/PermissionTrackerE2ETests.cs
Normal file
109
VideoDownloader.Tests/E2E/PermissionTrackerE2ETests.cs
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
using System.Windows;
|
||||
using System.Windows.Threading;
|
||||
using Microsoft.Web.WebView2.Core;
|
||||
using VideoDownloader.Browser;
|
||||
using VideoDownloader.Tests.Infrastructure;
|
||||
|
||||
namespace VideoDownloader.Tests.E2E;
|
||||
|
||||
/// <summary>
|
||||
/// E2E (T3-11/T3-14): 실제 WebView2 에서 권한 정책 응답과 추적 요청 차단·집계를 검증.
|
||||
/// TabViewService 와 동일한 배선(PermissionGate/TrackerBlocklist/TrackerCounter)을 사용한다.
|
||||
/// </summary>
|
||||
[Collection("E2E")]
|
||||
public class PermissionTrackerE2ETests
|
||||
{
|
||||
[Fact]
|
||||
public async Task 권한_요청이_정책을_거쳐_허용된다_T3_11()
|
||||
{
|
||||
using var sta = StaDispatcher.Start();
|
||||
var udf = Path.Combine(Path.GetTempPath(), $"vd_e2e_{Guid.NewGuid():N}");
|
||||
var result = await sta.InvokeAsync(async () =>
|
||||
{
|
||||
var window = new Window();
|
||||
var browser = new Microsoft.Web.WebView2.Wpf.WebView2();
|
||||
window.Content = browser;
|
||||
window.Show();
|
||||
|
||||
var env = await CoreWebView2Environment.CreateAsync(userDataFolder: udf);
|
||||
await browser.EnsureCoreWebView2Async(env);
|
||||
var cv = browser.CoreWebView2;
|
||||
|
||||
// TabViewService 배선과 동일: 기억된 판정은 즉시 응답(출처는 이벤트에서 관측된 것으로 기억)
|
||||
var gate = new PermissionGate();
|
||||
cv.PermissionRequested += (_, e) =>
|
||||
{
|
||||
var o = VideoDownloader.App.Services.TabViewService.OriginOf(e.Uri);
|
||||
gate.Remember("Notifications", o, PermissionVerdict.Allow); // E2E: 알림은 허용 정책
|
||||
var verdict = gate.Decide(e.PermissionKind.ToString(), o);
|
||||
e.State = verdict == PermissionVerdict.Allow ? CoreWebView2PermissionState.Allow : CoreWebView2PermissionState.Deny;
|
||||
e.Handled = true;
|
||||
};
|
||||
|
||||
var html = "<html><body><script>Notification.requestPermission().then(p => { document.title = 'PERM=' + p; });</script></body></html>";
|
||||
cv.NavigateToString(html);
|
||||
await WaitUntilNavCompleted(cv);
|
||||
var title = await Wait.Until(() => Task.FromResult(cv.DocumentTitle ?? ""),
|
||||
t => t.StartsWith("PERM=", StringComparison.Ordinal), 10000);
|
||||
|
||||
window.Close();
|
||||
return title;
|
||||
});
|
||||
|
||||
try { Directory.Delete(udf, true); } catch { }
|
||||
Assert.Equal("PERM=granted", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task 블랙리스트_스크립트가_차단되고_카운터에_집계된다_T3_14()
|
||||
{
|
||||
using var sta = StaDispatcher.Start();
|
||||
var udf = Path.Combine(Path.GetTempPath(), $"vd_e2e_{Guid.NewGuid():N}");
|
||||
var result = await sta.InvokeAsync(async () =>
|
||||
{
|
||||
var window = new Window();
|
||||
var browser = new Microsoft.Web.WebView2.Wpf.WebView2();
|
||||
window.Content = browser;
|
||||
window.Show();
|
||||
|
||||
var env = await CoreWebView2Environment.CreateAsync(userDataFolder: udf);
|
||||
await browser.EnsureCoreWebView2Async(env);
|
||||
var cv = browser.CoreWebView2;
|
||||
|
||||
var bl = new TrackerBlocklist(new[] { "blocked.test" });
|
||||
var counter = new TrackerCounter();
|
||||
cv.AddWebResourceRequestedFilter("*", CoreWebView2WebResourceContext.All);
|
||||
cv.WebResourceRequested += (_, e) =>
|
||||
{
|
||||
if (!bl.Matches(new Uri(e.Request.Uri))) return;
|
||||
e.Response = env.CreateWebResourceResponse(null, 204, "Blocked", "");
|
||||
counter.Record(e.Request.Uri);
|
||||
};
|
||||
|
||||
var html = "<html><body><p>tracker-e2e</p>" +
|
||||
"<script src=\"https://www.blocked.test/a.js\"></script>" +
|
||||
"<script src=\"https://www.blocked.test/b.js\"></script>" +
|
||||
"<script src=\"https://ok.example.com/c.js\"></script>" +
|
||||
"</body></html>";
|
||||
cv.NavigateToString(html);
|
||||
await WaitUntilNavCompleted(cv);
|
||||
await Wait.Until(() => Task.FromResult(counter.Total), t => t >= 2, 10000);
|
||||
|
||||
window.Close();
|
||||
return counter.Total;
|
||||
});
|
||||
|
||||
try { Directory.Delete(udf, true); } catch { }
|
||||
Assert.Equal(2, result); // 정상 도메인(ok.example.com)은 집계되지 않음
|
||||
}
|
||||
|
||||
private static async Task WaitUntilNavCompleted(CoreWebView2 cv)
|
||||
{
|
||||
var tcs = new TaskCompletionSource();
|
||||
EventHandler<CoreWebView2NavigationCompletedEventArgs> h = (_, _) => tcs.TrySetResult();
|
||||
cv.NavigationCompleted += h;
|
||||
if (tcs.Task.IsCompleted) return;
|
||||
await Task.WhenAny(tcs.Task, Task.Delay(15000));
|
||||
cv.NavigationCompleted -= h;
|
||||
}
|
||||
}
|
||||
106
VideoDownloader.Tests/Mobile/MobileCoreTests.cs
Normal file
106
VideoDownloader.Tests/Mobile/MobileCoreTests.cs
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
using VideoDownloader.Mobile;
|
||||
|
||||
namespace VideoDownloader.Tests.Mobile;
|
||||
|
||||
/// <summary>Mobile.Core 순수 로직 — U5 완료 감지, 외부망 안내, U4 저장소(파일 시스템).</summary>
|
||||
public class MobileCoreTests
|
||||
{
|
||||
// ── U5 완료 알림 감지 ──
|
||||
|
||||
[Fact]
|
||||
public void 완료_전환_항목_ID_반환()
|
||||
{
|
||||
var d = new QueueCompletionDetector();
|
||||
var prev = new[] { ("a", "다운로드 중"), ("b", "대기") };
|
||||
var next = new[] { ("a", "완료"), ("b", "다운로드 중") };
|
||||
Assert.Equal(new[] { "a" }, d.Detect(prev, next));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void 이미_완료였던_항목은_재알림_없음()
|
||||
{
|
||||
var d = new QueueCompletionDetector();
|
||||
var prev = new[] { ("a", "완료") };
|
||||
var next = new[] { ("a", "완료") };
|
||||
Assert.Empty(d.Detect(prev, next));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void 첫_폴링에서_이미_완료된_항목은_무시() // 앱 시작 시 과거 완료 스팸 방지
|
||||
{
|
||||
var d = new QueueCompletionDetector();
|
||||
Assert.Empty(d.Detect(Array.Empty<(string, string)>(), new[] { ("a", "완료") }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void 목록에서_사라진_항목은_알림_없음()
|
||||
{
|
||||
var d = new QueueCompletionDetector();
|
||||
var prev = new[] { ("a", "다운로드 중") };
|
||||
Assert.Empty(d.Detect(prev, Array.Empty<(string, string)>()));
|
||||
}
|
||||
|
||||
// ── 외부망 안내(MOBILE_PLAN §7) ──
|
||||
|
||||
[Fact]
|
||||
public void 미연결_안내()
|
||||
{
|
||||
var m = ConnectionTroubleshoot.Message(isConfigured: false, lastError: null);
|
||||
Assert.Contains("연결", m);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void 연결됐지만_실패_시_같은네트워크_VPN_안내()
|
||||
{
|
||||
var m = ConnectionTroubleshoot.Message(isConfigured: true, lastError: "No such host is known.");
|
||||
Assert.Contains("같은 네트워크", m);
|
||||
Assert.Contains("Tailscale", m); // 계획 §3: 자체 릴레이 대신 Tailscale 문서화
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void 정상_시_빈_문자열()
|
||||
=> Assert.Equal("", ConnectionTroubleshoot.Message(isConfigured: true, lastError: null));
|
||||
|
||||
// ── U4 저장소(임시 디렉터리로 파일 시스템 검증) ──
|
||||
|
||||
[Fact]
|
||||
public void OfflineStore_기록_목록_삭제_라운드트립()
|
||||
{
|
||||
var baseDir = Path.Combine(Path.GetTempPath(), $"vd_core_{Guid.NewGuid():N}");
|
||||
var store = new OfflineStore(baseDir);
|
||||
Assert.Empty(store.List());
|
||||
|
||||
var p = store.PathFor("id1");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(p)!);
|
||||
File.WriteAllBytes(p, new byte[123]);
|
||||
store.Record("id1", "video.mp4");
|
||||
|
||||
Assert.True(store.Exists("id1"));
|
||||
var list = store.List();
|
||||
Assert.Single(list);
|
||||
Assert.Equal(("id1", "video.mp4", 123L), (list[0].Id, list[0].FileName, list[0].SizeBytes));
|
||||
|
||||
store.Delete("id1");
|
||||
Assert.False(store.Exists("id1"));
|
||||
Assert.Empty(store.List());
|
||||
|
||||
Directory.Delete(baseDir, true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OfflineStore_재시작_시_매니페스트_유지()
|
||||
{
|
||||
var baseDir = Path.Combine(Path.GetTempPath(), $"vd_core_{Guid.NewGuid():N}");
|
||||
var store = new OfflineStore(baseDir);
|
||||
var p = store.PathFor("id2");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(p)!);
|
||||
File.WriteAllBytes(p, new byte[10]);
|
||||
store.Record("id2", "keep.mp4");
|
||||
|
||||
var reopened = new OfflineStore(baseDir); // 새 인스턴스 = 앱 재시작
|
||||
Assert.True(reopened.Exists("id2"));
|
||||
Assert.Equal("keep.mp4", reopened.List().Single().FileName);
|
||||
|
||||
Directory.Delete(baseDir, true);
|
||||
}
|
||||
}
|
||||
|
|
@ -155,6 +155,44 @@ public class RemoteServerE2ETests : IDisposable
|
|||
Assert.Equal(93.5, persisted!.PositionSec);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task 썸네일_API_E2E_생성_캐시_404()
|
||||
{
|
||||
Directory.CreateDirectory(_libDir);
|
||||
var mediaPath = Path.Combine(_libDir, "clip.mp4");
|
||||
File.WriteAllBytes(mediaPath, new byte[2048]);
|
||||
|
||||
// 가짜 ffmpeg: 출력 경로에 JPEG 생성
|
||||
var fake = new Tests.Platform.FakeProcessRunner
|
||||
{
|
||||
Behavior = (spec, _, _) =>
|
||||
{
|
||||
File.WriteAllBytes(spec.Args[^1], new byte[] { 0xFF, 0xD8, 0xFF, 0xE0, 0x01 });
|
||||
return Task.FromResult(new VideoDownloader.Core.Platform.ProcessResult(0, "", ""));
|
||||
},
|
||||
};
|
||||
|
||||
await using var server = await ServerHost.StartAsync(new ServerOptions
|
||||
{
|
||||
Port = 0, Token = "thumb-token", LibraryFolder = _libDir,
|
||||
Queue = new FakeQueue(),
|
||||
Thumbnailer = new Thumbnailer(fake),
|
||||
});
|
||||
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/library/xyz/thumb")).StatusCode);
|
||||
|
||||
// 2) 없는 항목 404
|
||||
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", "thumb-token");
|
||||
Assert.Equal(System.Net.HttpStatusCode.NotFound, (await http.GetAsync("/api/library/xyz/thumb")).StatusCode);
|
||||
|
||||
// 3) 실제 항목 → 200 + JPEG 매직
|
||||
var lib = (await http.GetFromJsonAsync<List<LibraryItemDto>>("/api/library"))!;
|
||||
var thumb = await http.GetByteArrayAsync($"/api/library/{lib[0].Id}/thumb");
|
||||
Assert.True(thumb.Length >= 2 && thumb[0] == 0xFF && thumb[1] == 0xD8, "JPEG 매직 바이트");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { Directory.Delete(_libDir, true); } catch { }
|
||||
|
|
|
|||
84
VideoDownloader.Tests/Server/ThumbnailerTests.cs
Normal file
84
VideoDownloader.Tests/Server/ThumbnailerTests.cs
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
using VideoDownloader.Core.Platform;
|
||||
using VideoDownloader.Server;
|
||||
using VideoDownloader.Tests.Platform;
|
||||
|
||||
namespace VideoDownloader.Tests.Server;
|
||||
|
||||
/// <summary>M2 라이브러리 썸네일/해상도 — ffmpeg 서브프로세스를 Fake 러너로 검증(사이드카 캐시).</summary>
|
||||
public class ThumbnailerTests
|
||||
{
|
||||
private string _dir = "";
|
||||
|
||||
private string NewMedia()
|
||||
{
|
||||
_dir = Path.Combine(Path.GetTempPath(), $"vd_thumb_{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_dir);
|
||||
var p = Path.Combine(_dir, "clip.mp4");
|
||||
File.WriteAllBytes(p, new byte[100]);
|
||||
return p;
|
||||
}
|
||||
|
||||
private static Task<ProcessResult> FakeFfmpegCreatesJpg(ProcessSpec spec, Action<string, string>? _, CancellationToken __)
|
||||
{
|
||||
// 출력 경로(마지막 인자)에 가짜 JPEG 생성
|
||||
var outPath = spec.Args[^1];
|
||||
File.WriteAllBytes(outPath, new byte[] { 0xFF, 0xD8, 0xFF, 0xE0 }); // JPEG SOI/APP0
|
||||
return Task.FromResult(new ProcessResult(0, "", ""));
|
||||
}
|
||||
|
||||
private static Task<ProcessResult> FakeFfmpegReportsResolution(ProcessSpec spec, Action<string, string>? onLine, CancellationToken _)
|
||||
{
|
||||
var line = " Stream #0:0[0x1](und): Video: h264 (High), yuv420p, 1920x1080 [SAR 1:1 DAR 16:9], 5000 kb/s";
|
||||
onLine?.Invoke("err", line);
|
||||
return Task.FromResult(new ProcessResult(0, "", line));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task 썸네일_생성후_사이드카_캐시로_재실행_없음()
|
||||
{
|
||||
var media = NewMedia();
|
||||
var runner = new FakeProcessRunner { Behavior = FakeFfmpegCreatesJpg };
|
||||
var t = new Thumbnailer(runner);
|
||||
|
||||
var bytes1 = await t.GetThumbnailAsync("ffmpeg", media);
|
||||
Assert.NotNull(bytes1);
|
||||
Assert.True(bytes1!.Length == 4);
|
||||
|
||||
runner.Behavior = (_, _, _) => throw new Xunit.Sdk.XunitException("캐시 히트 시 ffmpeg 재실행 없음");
|
||||
var bytes2 = await t.GetThumbnailAsync("ffmpeg", media); // 캐시
|
||||
Assert.Equal(bytes1, bytes2);
|
||||
Directory.Delete(_dir, true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ffmpeg_실패_시_null()
|
||||
{
|
||||
var media = NewMedia();
|
||||
var runner = new FakeProcessRunner { Behavior = (_, _, _) => Task.FromResult(new ProcessResult(1, "", "boom")) };
|
||||
Assert.Null(await new Thumbnailer(runner).GetThumbnailAsync("ffmpeg", media));
|
||||
Directory.Delete(_dir, true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task 해상도_추출후_사이드카_캐시()
|
||||
{
|
||||
var media = NewMedia();
|
||||
var runner = new FakeProcessRunner { Behavior = FakeFfmpegReportsResolution };
|
||||
var t = new Thumbnailer(runner);
|
||||
|
||||
Assert.Equal("1920x1080", await t.GetResolutionAsync("ffmpeg", media));
|
||||
|
||||
runner.Behavior = (_, _, _) => throw new Xunit.Sdk.XunitException("캐시 히트 시 재실행 없음");
|
||||
Assert.Equal("1920x1080", await t.GetResolutionAsync("ffmpeg", media));
|
||||
Directory.Delete(_dir, true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task 해상기_추출_불가_시_null()
|
||||
{
|
||||
var media = NewMedia();
|
||||
var runner = new FakeProcessRunner { Behavior = (_, _, _) => Task.FromResult(new ProcessResult(0, "", "no streams")) };
|
||||
Assert.Null(await new Thumbnailer(runner).GetResolutionAsync("ffmpeg", media));
|
||||
Directory.Delete(_dir, true);
|
||||
}
|
||||
}
|
||||
|
|
@ -16,5 +16,6 @@
|
|||
<ProjectReference Include="..\VideoDownloader.App\VideoDownloader.App.csproj" />
|
||||
<ProjectReference Include="..\VideoDownloader.Core\VideoDownloader.Core.csproj" />
|
||||
<ProjectReference Include="..\VideoDownloader.Server\VideoDownloader.Server.csproj" />
|
||||
<ProjectReference Include="..\VideoDownloader.Mobile.Core\VideoDownloader.Mobile.Core.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue