M0: remote access server - Kestrel embedded (token auth/pair/queue API/library/range streaming/web companion zero-install), QR pairing window, DownloadQueueAdapter, auto-start+palette commands. Server E2E green (102 tests)
This commit is contained in:
parent
40c1b39b70
commit
bb569acd96
15 changed files with 734 additions and 0 deletions
65
VideoDownloader.App/Browser/PairingWindow.cs
Normal file
65
VideoDownloader.App/Browser/PairingWindow.cs
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Interop;
|
||||
using System.Windows.Media.Imaging;
|
||||
using QRCoder;
|
||||
using VideoDownloader.Server;
|
||||
|
||||
namespace VideoDownloader.App.Browser;
|
||||
|
||||
/// <summary>M0 원격 접근 페어링: QR(주소+토큰 동시 전달) + 상태 표시.</summary>
|
||||
public sealed class PairingWindow : Window
|
||||
{
|
||||
public PairingWindow(ServerHost server, string deviceName)
|
||||
{
|
||||
Title = "기기 연결 — Video Downloader";
|
||||
Width = 420; Height = 560;
|
||||
WindowStartupLocation = WindowStartupLocation.CenterOwner;
|
||||
Background = System.Windows.Media.Brushes.White;
|
||||
|
||||
var lanIp = GetLanIp();
|
||||
var url = $"http://{lanIp}:{server.Port}/#t={server.Token}";
|
||||
|
||||
var qrPayload = new QRCodeGenerator().CreateQrCode(url, QRCodeGenerator.ECCLevel.M);
|
||||
var qr = new QRCode(qrPayload);
|
||||
var bmp = qr.GetGraphic(12);
|
||||
|
||||
var stack = new StackPanel { Margin = new Thickness(24) };
|
||||
var title = new TextBlock { Text = "📱 폰 카메라로 QR 스캔", FontSize = 16, FontWeight = FontWeights.Bold, Margin = new Thickness(0, 0, 0, 4) };
|
||||
var sub = new TextBlock { Text = "브라우저에서 바로 열립니다(앱 설치 불필요). 같은 Wi-Fi 에 있어야 합니다.", FontSize = 12, Foreground = System.Windows.Media.Brushes.Gray, TextWrapping = TextWrapping.Wrap, Margin = new Thickness(0, 0, 0, 16) };
|
||||
var img = new Image { Width = 300, Height = 300 };
|
||||
using var ms = new MemoryStream();
|
||||
bmp.Save(ms, System.Drawing.Imaging.ImageFormat.Png);
|
||||
ms.Position = 0;
|
||||
img.Source = BitmapFrame.Create(ms, BitmapCreateOptions.None, BitmapCacheOption.OnLoad);
|
||||
|
||||
var urlBox = new TextBox { Text = url, IsReadOnly = true, FontSize = 11, Margin = new Thickness(0, 12, 0, 4), TextWrapping = TextWrapping.Wrap };
|
||||
var copyBtn = new Button { Content = "주소 복사", Padding = new Thickness(14, 6, 14, 6), HorizontalAlignment = HorizontalAlignment.Left, Margin = new Thickness(0, 8, 0, 0) };
|
||||
copyBtn.Click += (_, _) => { try { Clipboard.SetText(url); } catch { } };
|
||||
var note = new TextBlock { Text = $"포트 {server.Port} · Windows 방화벽 확인이 뜨면 '허용'을 선택하세요\n토큰은 이 기기와 페어링된 폰에서만 유효합니다",
|
||||
FontSize = 11, Foreground = System.Windows.Media.Brushes.Gray, TextWrapping = TextWrapping.Wrap, Margin = new Thickness(0, 12, 0, 0) };
|
||||
|
||||
stack.Children.Add(title);
|
||||
stack.Children.Add(sub);
|
||||
stack.Children.Add(img);
|
||||
stack.Children.Add(urlBox);
|
||||
stack.Children.Add(copyBtn);
|
||||
stack.Children.Add(note);
|
||||
Content = stack;
|
||||
}
|
||||
|
||||
/// <summary>LAN IPv4(게이트웨이 있는 첫 후보) — 없으면 loopback.</summary>
|
||||
private static string GetLanIp()
|
||||
{
|
||||
try
|
||||
{
|
||||
using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, 0);
|
||||
socket.Connect("8.8.8.8", 65530); // 외부 행선지 지정 → 라우팅되는 인터페이스 IP 획득(패킷 전송 없음)
|
||||
return (socket.LocalEndPoint as IPEndPoint)?.Address.ToString() ?? "127.0.0.1";
|
||||
}
|
||||
catch { return "127.0.0.1"; }
|
||||
}
|
||||
}
|
||||
|
|
@ -18,6 +18,7 @@ namespace VideoDownloader.App.Core;
|
|||
public class DownloadManager
|
||||
{
|
||||
private readonly AppConfig _config;
|
||||
public AppConfig Config => _config;
|
||||
private readonly Func<Uri, Task<List<CoreWebView2Cookie>>> _getCookies;
|
||||
private readonly Action<string> _log;
|
||||
|
||||
|
|
|
|||
|
|
@ -40,6 +40,9 @@ public partial class MainWindow : Window
|
|||
private readonly ObservableCollection<HistoryEntry> _history = new();
|
||||
private bool _suggesting; // 주소창 인라인 자동완성 도중 (TextChanged 재귀 방지)
|
||||
|
||||
// M0: 원격 접근 서버(모바일 연동)
|
||||
private Server.ServerHost? _remoteServer;
|
||||
|
||||
// Phase D: 미디어 재생목록 (T2-10)
|
||||
private readonly MediaPlaylist _playlist = new();
|
||||
private PlayerWindow? _player;
|
||||
|
|
@ -243,6 +246,23 @@ public partial class MainWindow : Window
|
|||
};
|
||||
_speedTimer.Start();
|
||||
|
||||
// M0: 설정돼 있으면 원격 접근 서버 자동 시작
|
||||
if (_config.RemoteAccessEnabled && !string.IsNullOrEmpty(_config.RemoteAccessToken))
|
||||
{
|
||||
try
|
||||
{
|
||||
_remoteServer = await Server.ServerHost.StartAsync(new Server.ServerOptions
|
||||
{
|
||||
Port = 0,
|
||||
Token = _config.RemoteAccessToken!,
|
||||
LibraryFolder = Path.GetFullPath(_config.OutputFolder),
|
||||
Queue = new Services.DownloadQueueAdapter(_manager, Dispatcher),
|
||||
});
|
||||
Log($"원격 접근 서버 포트 {_remoteServer.Port}");
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
// T2-2 탭 절전: 1분마다 유휴(10분+) 비활성 탭 서스펜드
|
||||
_suspendService = new TabSuspendService(TimeSpan.FromMinutes(10), tab =>
|
||||
{
|
||||
|
|
@ -879,6 +899,7 @@ public partial class MainWindow : Window
|
|||
// T1-10 세션 저장: 탭 URL/활성/핀 보존
|
||||
try { if (_tabs != null) TabSessionStore.Save(SessionPath, _tabs.CaptureSession()); } catch { }
|
||||
try { _playlist.Save(Path.Combine(AppDataDir, "playlist.json")); } catch { } // T2-10
|
||||
try { if (_remoteServer != null) _ = _remoteServer.DisposeAsync(); } catch { } // M0
|
||||
}
|
||||
|
||||
// ---- 개인정보 보호 모드 + 브라우저 데이터 관리 ----
|
||||
|
|
@ -1152,6 +1173,8 @@ public partial class MainWindow : Window
|
|||
_palette.Register("이 페이지 즐겨찾기", "bookmark add star", () => AddBookmark_Click(this, new RoutedEventArgs()));
|
||||
_palette.Register("미디어 재생목록", "playlist play media 음악 영상", () => { PlaylistPopup.IsOpen = true; });
|
||||
_palette.Register("재생목록 재생", "playlist play start", OpenPlayer);
|
||||
_palette.Register("기기 연결 (QR 페어링)", "remote pair qr mobile phone", ShowPairing);
|
||||
_palette.Register("원격 접근 켜기/끄기", "remote server toggle lan", async () => await ToggleRemoteAccess());
|
||||
_palette.RegisterSeparator();
|
||||
_palette.RegisterDynamic(() => _tabs.Tabs
|
||||
.Select(t => new PaletteCommand($"탭 전환: {Trunc(t.Title, 40)}", "tab go switch", () => _tabs.Activate(t)))
|
||||
|
|
@ -1486,6 +1509,56 @@ public partial class MainWindow : Window
|
|||
Log($"워크스페이스 '{ws.Name}' 전환 ({ws.Urls.Count} 탭)");
|
||||
}
|
||||
|
||||
// ---- M0 원격 접근(모바일 연동) ----
|
||||
private async Task ToggleRemoteAccess()
|
||||
{
|
||||
if (_remoteServer != null)
|
||||
{
|
||||
await _remoteServer.DisposeAsync();
|
||||
_remoteServer = null;
|
||||
_config.RemoteAccessEnabled = false;
|
||||
ConfigStore.Save(_config);
|
||||
Log("원격 접근을 껐습니다");
|
||||
return;
|
||||
}
|
||||
|
||||
var token = _config.RemoteAccessToken;
|
||||
if (string.IsNullOrEmpty(token))
|
||||
{
|
||||
token = Convert.ToBase64String(System.Security.Cryptography.RandomNumberGenerator.GetBytes(24))
|
||||
.TrimEnd('=').Replace('+', '-').Replace('/', '_');
|
||||
_config.RemoteAccessToken = token;
|
||||
}
|
||||
_config.RemoteAccessEnabled = true;
|
||||
ConfigStore.Save(_config);
|
||||
|
||||
try
|
||||
{
|
||||
_remoteServer = await Server.ServerHost.StartAsync(new Server.ServerOptions
|
||||
{
|
||||
Port = 0,
|
||||
Token = token,
|
||||
LibraryFolder = Path.GetFullPath(_config.OutputFolder),
|
||||
Queue = new Services.DownloadQueueAdapter(_manager, Dispatcher),
|
||||
});
|
||||
Log($"원격 접근 켜짐 — 포트 {_remoteServer.Port}");
|
||||
var pair = new Browser.PairingWindow(_remoteServer, Environment.MachineName) { Owner = this };
|
||||
pair.Show();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_remoteServer = null;
|
||||
Log($"원격 접근 시작 실패(방화벽 확인): {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private async void ShowPairing()
|
||||
{
|
||||
if (_remoteServer == null) { await ToggleRemoteAccess(); return; }
|
||||
var pair = new Browser.PairingWindow(_remoteServer, Environment.MachineName) { Owner = this };
|
||||
pair.Show();
|
||||
}
|
||||
|
||||
// ---- T2-10 미디어 재생목록 ----
|
||||
private void AddToPlaylist(string url, string title, string kind, string? referer)
|
||||
{
|
||||
|
|
|
|||
67
VideoDownloader.App/Services/DownloadQueueAdapter.cs
Normal file
67
VideoDownloader.App/Services/DownloadQueueAdapter.cs
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
using System.Windows.Threading;
|
||||
using VideoDownloader.App.ViewModels;
|
||||
using VideoDownloader.Server;
|
||||
using VideoDownloader.Core.Detection;
|
||||
|
||||
namespace VideoDownloader.App.Services;
|
||||
|
||||
/// <summary>
|
||||
/// DownloadManager(UI 컬렉션) ↔ 서버 큐 API 브릿지. 모든 접근을 UI 디스패처로 마샬링.
|
||||
/// </summary>
|
||||
public sealed class DownloadQueueAdapter : IQueueAdapter
|
||||
{
|
||||
private readonly Core.DownloadManager _manager;
|
||||
private readonly Dispatcher _dispatcher;
|
||||
|
||||
public DownloadQueueAdapter(Core.DownloadManager manager, Dispatcher dispatcher)
|
||||
{
|
||||
_manager = manager;
|
||||
_dispatcher = dispatcher;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<QueueItemDto>> GetItemsAsync() => _dispatcher.InvokeAsync(() =>
|
||||
(IReadOnlyList<QueueItemDto>)_manager.Items.Select(Map).ToList()).Task;
|
||||
|
||||
public Task<string> AddAsync(string url, string? title)
|
||||
{
|
||||
// 서버에서 온 URL → 감지 파이프라인으로(소셜/HLS/DASH 자동 분류)
|
||||
return _dispatcher.InvokeAsync(() =>
|
||||
{
|
||||
if (Uri.TryCreate(url, UriKind.Absolute, out var uri) &&
|
||||
SocialSiteDetector.TryMatch(uri, out var normalized, out var label))
|
||||
{
|
||||
_manager.OnDetected(new DetectionContext(normalized, normalized, MediaKind.Social, Tag: label));
|
||||
return normalized;
|
||||
}
|
||||
var kind = MediaDetector.Classify(url, "", -1, _manager.Config) ?? MediaKind.Direct;
|
||||
_manager.OnDetected(new DetectionContext(url, url, kind, ""));
|
||||
return url;
|
||||
}).Task;
|
||||
}
|
||||
|
||||
public Task PauseAsync(string id) => _dispatcher.InvokeAsync(() =>
|
||||
{
|
||||
var item = Find(id);
|
||||
if (item != null) _manager.Cancel(item); // v1: 제어 매핑 — 진행 항목 취소
|
||||
}).Task;
|
||||
|
||||
public Task ResumeAsync(string id) => _dispatcher.InvokeAsync(() =>
|
||||
{
|
||||
var item = Find(id);
|
||||
if (item != null) _ = _manager.RetryDownload(item);
|
||||
}).Task;
|
||||
|
||||
public Task CancelAsync(string id) => _dispatcher.InvokeAsync(() =>
|
||||
{
|
||||
var item = Find(id);
|
||||
if (item != null) _manager.Cancel(item);
|
||||
}).Task;
|
||||
|
||||
public event Action? Changed { add { } remove { } }
|
||||
|
||||
private DownloadItemViewModel? Find(string id) =>
|
||||
_manager.Items.FirstOrDefault(i => i.Id == id);
|
||||
|
||||
private static QueueItemDto Map(DownloadItemViewModel i) =>
|
||||
new(i.Id, i.Title, i.Url ?? "", i.Status ?? "", i.Progress);
|
||||
}
|
||||
|
|
@ -22,6 +22,7 @@
|
|||
<ItemGroup>
|
||||
<ProjectReference Include="..\VideoDownloader.Core\VideoDownloader.Core.csproj" />
|
||||
<ProjectReference Include="..\VideoDownloader.Browser\VideoDownloader.Browser.csproj" />
|
||||
<ProjectReference Include="..\VideoDownloader.Server\VideoDownloader.Server.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- .NET 10 SDK 의 net8.0-windows WPF implicit usings 보강 -->
|
||||
|
|
@ -32,6 +33,7 @@
|
|||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Web.WebView2" Version="1.0.4078.44" />
|
||||
<PackageReference Include="QRCoder" Version="1.8.0" />
|
||||
<PackageReference Include="System.Speech" Version="8.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,11 @@ public class DownloadItemViewModel : INotifyPropertyChanged
|
|||
|
||||
private string _title;
|
||||
public string Title { get => _title; set => SetField(ref _title, value); }
|
||||
|
||||
/// <summary>항목 고유 ID(원격 API 노출용).</summary>
|
||||
public string Id { get; } = Guid.NewGuid().ToString("N");
|
||||
/// <summary>미디어 URL(원격 API 노출용).</summary>
|
||||
public string? Url => MediaUrl?.ToString();
|
||||
public string FormatTag { get; init; } = "FILE";
|
||||
|
||||
// 다운로드 실행에 필요한 컨텍스트 (감지 시 고정)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue