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
149
VideoDownloader.Server/WebCompanion.cs
Normal file
149
VideoDownloader.Server/WebCompanion.cs
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
namespace VideoDownloader.Server;
|
||||
|
||||
/// <summary>
|
||||
/// 제로 설치 웹 컴패니언(PairDrop/Jellyfin 패턴): 폰 카메라로 QR 스캔 →
|
||||
/// 브라우저에서 바로 큐 조회/URL 추가. 토큰은 URL 프래그먼트(#t=)로 전달되어
|
||||
/// 서버 로그/리퍼러에 남지 않는다.
|
||||
/// </summary>
|
||||
public static class WebCompanion
|
||||
{
|
||||
public const string Html = """
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>VideoDownloader 원격</title>
|
||||
<style>
|
||||
:root { color-scheme: dark; }
|
||||
body { background:#14151d; color:#e6e6ec; font-family:'Segoe UI',system-ui,sans-serif; margin:0; padding:20px 16px 40px; max-width:680px; margin-inline:auto; }
|
||||
h1 { font-size:1.25rem; display:flex; align-items:center; gap:10px; }
|
||||
.badge { background:#5b8def; color:#fff; border-radius:8px; padding:2px 10px; font-size:.75rem; }
|
||||
.card { background:#1e2030; border:1px solid #2a2c42; border-radius:14px; padding:14px; margin:14px 0; }
|
||||
input[type=text] { width:100%; box-sizing:border-box; padding:12px 14px; border-radius:10px; border:1px solid #3a3c55; background:#14151d; color:#e6e6ec; font-size:15px; }
|
||||
button { background:#5b8def; color:#fff; border:none; border-radius:10px; padding:11px 18px; font-size:14px; font-weight:600; margin-top:10px; cursor:pointer; }
|
||||
button.sec { background:#2a2c42; }
|
||||
.item { background:#1e2030; border:1px solid #2a2c42; border-radius:12px; padding:12px; margin:8px 0; display:flex; justify-content:space-between; align-items:center; gap:10px; }
|
||||
.item .t { font-weight:600; font-size:.95rem; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.item .s { color:#9a9ab0; font-size:.8rem; }
|
||||
.pbar { height:6px; background:#2a2c42; border-radius:4px; overflow:hidden; flex:1; }
|
||||
.pbar>div { height:100%; background:#5b8def; }
|
||||
.row { display:flex; gap:8px; align-items:center; }
|
||||
.muted { color:#9a9ab0; font-size:.85rem; }
|
||||
a { color:#5b8def; }
|
||||
video { width:100%; border-radius:12px; background:#000; margin-top:8px; }
|
||||
.err { color:#ff6b6b; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>📺 VideoDownloader <span class="badge" id="dev">연결중</span></h1>
|
||||
|
||||
<div class="card">
|
||||
<b>링크 보내 PC에서 받기</b>
|
||||
<div class="muted">YouTube·웹페이지 URL 을 붙여넣으세요</div>
|
||||
<input type="text" id="url" placeholder="https://... " autocomplete="off">
|
||||
<button onclick="addUrl()">받기 요청</button>
|
||||
<span id="addMsg" class="muted"></span>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<b>다운로드 큐</b> <span class="muted" id="qcount"></span>
|
||||
<div id="queue"><div class="muted">불러오는 중…</div></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<b>PC 라이브러리</b> <span class="muted">탭하여 스트리밍 재생</span>
|
||||
<div id="lib"><div class="muted">불러오는 중…</div></div>
|
||||
</div>
|
||||
|
||||
<div class="muted" style="margin-top:24px">같은 Wi-Fi 에서만 접속됩니다 · LAN 직결(계정 없음)</div>
|
||||
|
||||
<script>
|
||||
const token = (location.hash.match(/t=([^&]+)/) || [])[1] || '';
|
||||
if (!token) document.body.innerHTML = '<div class="card err">토큰이 없습니다. PC 앱의 QR 코드로 다시 접속하세요.</div>';
|
||||
const H = { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' };
|
||||
|
||||
async function api(path, opts) {
|
||||
const r = await fetch(path, Object.assign({ headers: H }, opts));
|
||||
if (r.status === 401) { document.getElementById('dev').textContent = '인증 실패'; throw new Error('401'); }
|
||||
return r;
|
||||
}
|
||||
|
||||
async function addUrl() {
|
||||
const url = document.getElementById('url').value.trim();
|
||||
if (!url) return;
|
||||
document.getElementById('addMsg').textContent = '전송 중…';
|
||||
try {
|
||||
const r = await api('/api/queue', { method: 'POST', body: JSON.stringify({ url, title: url }) });
|
||||
document.getElementById('addMsg').textContent = r.ok ? '✅ PC 대기열에 추가됨' : '❌ 실패';
|
||||
document.getElementById('url').value = '';
|
||||
refreshQueue();
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
async function refreshQueue() {
|
||||
try {
|
||||
const items = await (await api('/api/queue')).json();
|
||||
const el = document.getElementById('queue');
|
||||
document.getElementById('qcount').textContent = items.length + '건';
|
||||
if (!items.length) { el.innerHTML = '<div class="muted">대기 중인 항목이 없습니다</div>'; return; }
|
||||
el.innerHTML = items.map(i => `
|
||||
<div class="item">
|
||||
<div style="flex:1;min-width:0">
|
||||
<div class="t">${esc(i.title)}</div>
|
||||
<div class="row" style="margin-top:6px">
|
||||
<div class="pbar"><div style="width:${Math.round(i.percent)}%"></div></div>
|
||||
<span class="s">${esc(i.status)} ${Math.round(i.percent)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<button class="sec" onclick="ctl('${i.id}','pause')">⏸</button>
|
||||
<button class="sec" onclick="ctl('${i.id}','resume')">▶</button>
|
||||
<button class="sec" onclick="ctl('${i.id}','cancel')">✕</button>
|
||||
</div>
|
||||
</div>`).join('');
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
async function ctl(id, action) { try { await api('/api/queue/' + id + '/' + action, { method: 'POST' }); refreshQueue(); } catch (e) {} }
|
||||
|
||||
async function refreshLib() {
|
||||
try {
|
||||
const items = await (await api('/api/library')).json();
|
||||
const el = document.getElementById('lib');
|
||||
if (!items.length) { el.innerHTML = '<div class="muted">저장 폴더가 비었습니다</div>'; return; }
|
||||
el.innerHTML = items.map(i => `
|
||||
<div class="item">
|
||||
<div class="t" style="flex:1">${esc(i.fileName)}</div>
|
||||
<span class="s">${fmtSize(i.sizeBytes)}</span>
|
||||
<button class="sec" onclick="play('${i.id}')">▶ 재생</button>
|
||||
</div>`).join('');
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
function play(id) {
|
||||
let v = document.getElementById('pv');
|
||||
if (!v) {
|
||||
v = document.createElement('video');
|
||||
v.id = 'pv'; v.controls = true;
|
||||
document.body.appendChild(v);
|
||||
v.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
v.src = '/stream/' + id + '?t=' + encodeURIComponent(token); // <video> 는 헤더 불가 → 쿼리 토큰
|
||||
v.play().catch(()=>{});
|
||||
}
|
||||
|
||||
function esc(s) { return String(s ?? '').replace(/[&<>"]/g, c => ({'&':'&','<':'<','>':'>','"':'"'}[c])); }
|
||||
function fmtSize(b) { return b > 1048576 ? (b/1048576).toFixed(1) + ' MB' : (b/1024).toFixed(0) + ' KB'; }
|
||||
|
||||
(async function init() {
|
||||
const r = await fetch('/api/pair', { method: 'POST', headers: H, body: JSON.stringify({ token, device: navigator.userAgent.slice(0, 40) }) });
|
||||
document.getElementById('dev').textContent = r.ok ? '연결됨' : '인증 실패';
|
||||
refreshQueue(); refreshLib();
|
||||
setInterval(refreshQueue, 2000); // v1 폴링 (SignalR 는 앱 확장 시)
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
""";
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue