releases/VideoDownloader.Server/WebCompanion.cs

149 lines
6.5 KiB
C#

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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[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>
""";
}