fix frontend and backend source code
This commit is contained in:
parent
ba8d79910c
commit
d4292beebb
2 changed files with 321 additions and 150 deletions
|
|
@ -1,36 +1,40 @@
|
|||
const API = '';
|
||||
|
||||
const urlInput = document.getElementById('url-input');
|
||||
const clearBtn = document.getElementById('clear-btn');
|
||||
const fetchBtn = document.getElementById('fetch-btn');
|
||||
const errorMsg = document.getElementById('error-msg');
|
||||
const preview = document.getElementById('preview');
|
||||
const thumbnail = document.getElementById('thumbnail');
|
||||
const videoTitle = document.getElementById('video-title');
|
||||
const uploaderEl = document.getElementById('uploader');
|
||||
const durationEl = document.getElementById('duration');
|
||||
const urlInput = document.getElementById('url-input');
|
||||
const clearBtn = document.getElementById('clear-btn');
|
||||
const fetchBtn = document.getElementById('fetch-btn');
|
||||
const errorMsg = document.getElementById('error-msg');
|
||||
const preview = document.getElementById('preview');
|
||||
const thumbnail = document.getElementById('thumbnail');
|
||||
const videoTitle = document.getElementById('video-title');
|
||||
const uploaderEl = document.getElementById('uploader');
|
||||
const durationEl = document.getElementById('duration');
|
||||
const formatSection = document.getElementById('format-section');
|
||||
const formatSelect = document.getElementById('format-select');
|
||||
const downloadBtn = document.getElementById('download-btn');
|
||||
const progressWrap = document.getElementById('progress-wrap');
|
||||
const progressFill = document.getElementById('progress-fill');
|
||||
const formatSelect = document.getElementById('format-select');
|
||||
const downloadBtn = document.getElementById('download-btn');
|
||||
const progressWrap = document.getElementById('progress-wrap');
|
||||
const progressFill = document.getElementById('progress-fill');
|
||||
const progressLabel = document.getElementById('progress-label');
|
||||
const cancelBtn = document.getElementById('cancel-btn');
|
||||
const cancelBtn = document.getElementById('cancel-btn');
|
||||
|
||||
let currentFileId = null;
|
||||
let currentAbort = null;
|
||||
let currentSse = null;
|
||||
let currentFileId = null;
|
||||
let currentAbort = null;
|
||||
let currentSse = null;
|
||||
|
||||
// --- Cancel ---
|
||||
cancelBtn.addEventListener('click', async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Abort the pending fetch
|
||||
if (currentAbort) { currentAbort.abort(); currentAbort = null; }
|
||||
// Close SSE
|
||||
if (currentSse) { currentSse.close(); currentSse = null; }
|
||||
// Tell backend to kill the task and clean up
|
||||
if (currentAbort) {
|
||||
currentAbort.abort();
|
||||
currentAbort = null;
|
||||
}
|
||||
|
||||
if (currentSse) {
|
||||
currentSse.close();
|
||||
currentSse = null;
|
||||
}
|
||||
|
||||
if (currentFileId) {
|
||||
fetch(`${API}/cancel/${currentFileId}`, { method: 'POST' }).catch(() => {});
|
||||
currentFileId = null;
|
||||
|
|
@ -41,7 +45,6 @@ cancelBtn.addEventListener('click', async (e) => {
|
|||
setLoading(downloadBtn, false);
|
||||
});
|
||||
|
||||
// --- Reset UI ---
|
||||
function resetUI() {
|
||||
urlInput.value = '';
|
||||
clearBtn.style.display = 'none';
|
||||
|
|
@ -52,20 +55,27 @@ function resetUI() {
|
|||
progressWrap.style.display = 'none';
|
||||
thumbnail.src = '';
|
||||
}
|
||||
|
||||
window.addEventListener('pageshow', resetUI);
|
||||
|
||||
// --- Helpers ---
|
||||
function showError(msg) {
|
||||
errorMsg.textContent = msg;
|
||||
errorMsg.style.display = 'block';
|
||||
}
|
||||
function hideError() { errorMsg.style.display = 'none'; }
|
||||
|
||||
function hideError() {
|
||||
errorMsg.style.display = 'none';
|
||||
}
|
||||
|
||||
function setLoading(btn, loading, text) {
|
||||
btn.disabled = loading;
|
||||
btn.style.opacity = loading ? '0.6' : '1';
|
||||
btn.style.cursor = loading ? 'wait' : '';
|
||||
if (text) btn.dataset.label = btn.dataset.label || btn.textContent.trim();
|
||||
btn.style.cursor = loading ? 'wait' : '';
|
||||
|
||||
if (text) {
|
||||
btn.dataset.label = btn.dataset.label || btn.textContent.trim();
|
||||
}
|
||||
|
||||
if (loading && text) {
|
||||
btn.textContent = text;
|
||||
} else if (!loading && btn.dataset.label) {
|
||||
|
|
@ -83,9 +93,10 @@ function formatDuration(secs) {
|
|||
const h = Math.floor(secs / 3600);
|
||||
const m = Math.floor((secs % 3600) / 60);
|
||||
const s = secs % 60;
|
||||
|
||||
return h > 0
|
||||
? `${h}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`
|
||||
: `${m}:${String(s).padStart(2,'0')}`;
|
||||
? `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
|
||||
: `${m}:${String(s).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function formatCount(n) {
|
||||
|
|
@ -105,14 +116,19 @@ function formatBytes(bytes) {
|
|||
function formatLabel(f) {
|
||||
const parts = [];
|
||||
const res = f.resolution || '';
|
||||
|
||||
if (res && res !== 'unknown') parts.push(res);
|
||||
if (f.ext) parts.push(f.ext.toUpperCase());
|
||||
|
||||
const hasVideo = f.vcodec && f.vcodec !== 'none';
|
||||
const hasAudio = f.acodec && f.acodec !== 'none';
|
||||
|
||||
if (!hasVideo && hasAudio) parts.push('audio only');
|
||||
if (f.fps && hasVideo) parts.push(`${f.fps}fps`);
|
||||
|
||||
const size = formatBytes(f.filesize);
|
||||
if (size) parts.push(`~${size}`);
|
||||
|
||||
return parts.join(' · ') || f.format_id;
|
||||
}
|
||||
|
||||
|
|
@ -124,22 +140,32 @@ function setProgress(pct, label) {
|
|||
function formatEta(secs) {
|
||||
if (secs == null || secs < 0) return '';
|
||||
if (secs < 60) return `ETA ${secs}s`;
|
||||
|
||||
const m = Math.floor(secs / 60);
|
||||
const s = secs % 60;
|
||||
return `ETA ${m}m ${s}s`;
|
||||
}
|
||||
|
||||
// --- URL input ---
|
||||
urlInput.addEventListener('input', () => {
|
||||
clearBtn.style.display = urlInput.value ? 'flex' : 'none';
|
||||
});
|
||||
clearBtn.addEventListener('click', () => { resetUI(); urlInput.focus(); });
|
||||
urlInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') fetchBtn.click(); });
|
||||
|
||||
// --- Fetch info ---
|
||||
clearBtn.addEventListener('click', () => {
|
||||
resetUI();
|
||||
urlInput.focus();
|
||||
});
|
||||
|
||||
urlInput.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') fetchBtn.click();
|
||||
});
|
||||
|
||||
fetchBtn.addEventListener('click', async () => {
|
||||
const url = urlInput.value.trim();
|
||||
if (!url) { showError('Please paste a video URL first.'); return; }
|
||||
|
||||
if (!url) {
|
||||
showError('Please paste a video URL first.');
|
||||
return;
|
||||
}
|
||||
|
||||
hideError();
|
||||
preview.style.display = 'none';
|
||||
|
|
@ -154,15 +180,20 @@ fetchBtn.addEventListener('click', async () => {
|
|||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url }),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.detail || 'Failed to fetch video info.');
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(data.detail || 'Failed to fetch video info.');
|
||||
}
|
||||
|
||||
renderPreview(data);
|
||||
renderFormats(data.formats || []);
|
||||
preview.style.display = 'flex';
|
||||
formatSection.style.display = 'flex';
|
||||
downloadBtn.style.display = 'flex';
|
||||
} catch (err) {
|
||||
showError(err.message);
|
||||
showError(err.message || 'Failed to fetch video info.');
|
||||
} finally {
|
||||
setLoading(fetchBtn, false);
|
||||
}
|
||||
|
|
@ -172,23 +203,36 @@ function renderPreview(info) {
|
|||
thumbnail.src = info.thumbnail || '';
|
||||
thumbnail.alt = info.title || 'Video thumbnail';
|
||||
thumbnail.style.display = info.thumbnail ? 'block' : 'none';
|
||||
|
||||
videoTitle.textContent = info.title || 'Unknown title';
|
||||
|
||||
uploaderEl.textContent = info.uploader || '';
|
||||
uploaderEl.style.display = info.uploader ? '' : 'none';
|
||||
|
||||
const dur = formatDuration(info.duration);
|
||||
durationEl.textContent = dur || '';
|
||||
durationEl.style.display = dur ? '' : 'none';
|
||||
|
||||
const viewsEl = document.getElementById('views');
|
||||
const likesEl = document.getElementById('likes');
|
||||
const dateEl = document.getElementById('upload-date');
|
||||
const dateEl = document.getElementById('upload-date');
|
||||
|
||||
viewsEl.textContent = info.view_count ? '👁 ' + formatCount(info.view_count) : '';
|
||||
viewsEl.style.display = info.view_count ? '' : 'none';
|
||||
|
||||
likesEl.textContent = info.like_count ? '👍 ' + formatCount(info.like_count) : '';
|
||||
likesEl.style.display = info.like_count ? '' : 'none';
|
||||
|
||||
if (info.upload_date && info.upload_date.length === 8) {
|
||||
const d = info.upload_date;
|
||||
const formatted = new Date(`${d.slice(0,4)}-${d.slice(4,6)}-${d.slice(6,8)}`).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
|
||||
const formatted = new Date(
|
||||
`${d.slice(0, 4)}-${d.slice(4, 6)}-${d.slice(6, 8)}`
|
||||
).toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
|
||||
dateEl.textContent = '📅 ' + formatted;
|
||||
dateEl.style.display = '';
|
||||
} else {
|
||||
|
|
@ -198,38 +242,61 @@ function renderPreview(info) {
|
|||
|
||||
function renderFormats(formats) {
|
||||
formatSelect.innerHTML = '';
|
||||
const sorted = [...formats].sort((a, b) =>
|
||||
(b.height || 0) - (a.height || 0) || (b.tbr || 0) - (a.tbr || 0)
|
||||
);
|
||||
const combined = sorted.filter(f => f.vcodec !== 'none' && f.acodec !== 'none');
|
||||
const videoOnly = sorted.filter(f => f.vcodec !== 'none' && f.acodec === 'none');
|
||||
const audioOnly = sorted.filter(f => f.vcodec === 'none' && f.acodec !== 'none');
|
||||
|
||||
const addGroup = (label, items) => {
|
||||
const sorted = [...formats].sort(
|
||||
(a, b) => (b.height || 0) - (a.height || 0) || (b.tbr || 0) - (a.tbr || 0)
|
||||
);
|
||||
|
||||
const combined = sorted.filter((f) => f.vcodec !== 'none' && f.acodec !== 'none');
|
||||
const videoOnly = sorted.filter((f) => f.vcodec !== 'none' && f.acodec === 'none');
|
||||
const audioOnly = sorted.filter((f) => f.vcodec === 'none' && f.acodec !== 'none');
|
||||
|
||||
const addGroup = (label, items, transformValue = null) => {
|
||||
if (!items.length) return;
|
||||
|
||||
const group = document.createElement('optgroup');
|
||||
group.label = label;
|
||||
items.forEach(f => {
|
||||
|
||||
items.forEach((f) => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = f.format_id;
|
||||
opt.value = transformValue ? transformValue(f) : f.format_id;
|
||||
opt.textContent = formatLabel(f);
|
||||
group.appendChild(opt);
|
||||
});
|
||||
|
||||
formatSelect.appendChild(group);
|
||||
};
|
||||
|
||||
if ((videoOnly[0]?.height || 0) >= (combined[0]?.height || 0)) {
|
||||
addGroup('Video only', videoOnly);
|
||||
addGroup('Video + Audio', combined);
|
||||
} else {
|
||||
addGroup('Video + Audio', combined);
|
||||
addGroup('Video only', videoOnly);
|
||||
}
|
||||
addGroup(
|
||||
'Video only',
|
||||
videoOnly,
|
||||
(f) => `${f.format_id}+bestaudio[ext=m4a]/bestaudio`
|
||||
);
|
||||
|
||||
addGroup('Video + Audio', combined);
|
||||
addGroup('Audio only', audioOnly);
|
||||
if (sorted.length) formatSelect.value = sorted[0].format_id;
|
||||
|
||||
// Prefer highest MP4 video-only first, because backend can merge audio into MP4
|
||||
const bestMp4VideoOnly = videoOnly.find((f) => f.ext === 'mp4');
|
||||
|
||||
// Fallback to highest combined MP4
|
||||
const bestMp4Combined = combined.find((f) => f.ext === 'mp4');
|
||||
|
||||
// Fallback to any top combined format
|
||||
const bestCombined = combined[0];
|
||||
|
||||
// Final fallback
|
||||
const bestDefault =
|
||||
(bestMp4VideoOnly && `${bestMp4VideoOnly.format_id}+bestaudio[ext=m4a]/bestaudio`) ||
|
||||
(bestMp4Combined && bestMp4Combined.format_id) ||
|
||||
(bestCombined && bestCombined.format_id) ||
|
||||
(sorted[0] && sorted[0].format_id);
|
||||
|
||||
if (bestDefault) {
|
||||
formatSelect.value = bestDefault;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Download ---
|
||||
downloadBtn.addEventListener('click', async () => {
|
||||
const url = urlInput.value.trim();
|
||||
if (!url) return;
|
||||
|
|
@ -242,31 +309,72 @@ downloadBtn.addEventListener('click', async () => {
|
|||
const fileId = crypto.randomUUID();
|
||||
currentFileId = fileId;
|
||||
|
||||
// SSE for live progress
|
||||
const sse = new EventSource(`${API}/progress/${fileId}`);
|
||||
currentSse = sse;
|
||||
|
||||
sse.onmessage = (e) => {
|
||||
const d = JSON.parse(e.data);
|
||||
if (d.status === 'downloading') {
|
||||
|
||||
if (d.status === 'starting') {
|
||||
setProgress(0, '<span class="meta">Starting…</span>');
|
||||
} else if (d.status === 'downloading') {
|
||||
const pct = d.percent || 0;
|
||||
const eta = d.eta != null ? formatEta(d.eta) : '';
|
||||
const speed = d.speed != null ? `${d.speed} MB/s` : '';
|
||||
const meta = [speed, eta].filter(Boolean).join(' · ');
|
||||
setProgress(pct, `<span class="pct">${pct}%</span>${meta ? ` <span class="meta">${meta}</span>` : ''}`);
|
||||
|
||||
setProgress(
|
||||
pct,
|
||||
`<span class="pct">${pct}%</span>${meta ? ` <span class="meta">${meta}</span>` : ''}`
|
||||
);
|
||||
} else if (d.status === 'finished') {
|
||||
setProgress(100, '<span class="pct">100%</span> <span class="meta">Processing…</span>');
|
||||
setProgress(
|
||||
100,
|
||||
'<span class="pct">100%</span> <span class="meta">Processing…</span>'
|
||||
);
|
||||
} else if (d.status === 'ready') {
|
||||
sse.close();
|
||||
currentSse = null;
|
||||
currentAbort = null;
|
||||
|
||||
if (currentFileId === fileId) {
|
||||
setProgress(
|
||||
100,
|
||||
'<span class="pct">✓</span> <span class="meta">Done — saving to your downloads</span>'
|
||||
);
|
||||
|
||||
window.location.href = `${API}/file/${fileId}`;
|
||||
|
||||
setTimeout(() => {
|
||||
progressWrap.style.display = 'none';
|
||||
}, 2500);
|
||||
}
|
||||
|
||||
currentFileId = null;
|
||||
setLoading(downloadBtn, false);
|
||||
} else if (d.status === 'cancelled') {
|
||||
sse.close();
|
||||
currentSse = null;
|
||||
currentAbort = null;
|
||||
progressWrap.style.display = 'none';
|
||||
currentFileId = null;
|
||||
setLoading(downloadBtn, false);
|
||||
} else if (d.status === 'error') {
|
||||
sse.close();
|
||||
currentSse = null;
|
||||
currentAbort = null;
|
||||
showError(d.error || 'Download failed.');
|
||||
progressWrap.style.display = 'none';
|
||||
currentFileId = null;
|
||||
setLoading(downloadBtn, false);
|
||||
}
|
||||
};
|
||||
sse.onerror = () => sse.close();
|
||||
|
||||
// AbortController so cancel can kill the fetch
|
||||
sse.onerror = () => {
|
||||
sse.close();
|
||||
currentSse = null;
|
||||
};
|
||||
|
||||
const abort = new AbortController();
|
||||
currentAbort = abort;
|
||||
|
||||
|
|
@ -274,36 +382,40 @@ downloadBtn.addEventListener('click', async () => {
|
|||
const res = await fetch(`${API}/download`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url, format_id: formatSelect.value, file_id: fileId }),
|
||||
body: JSON.stringify({
|
||||
url,
|
||||
format_id: formatSelect.value,
|
||||
file_id: fileId,
|
||||
}),
|
||||
signal: abort.signal,
|
||||
});
|
||||
|
||||
sse.close();
|
||||
currentSse = null;
|
||||
currentAbort = null;
|
||||
const data = await res.json().catch(() => ({}));
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({ detail: 'Download failed.' }));
|
||||
throw new Error(data.detail || 'Download failed.');
|
||||
}
|
||||
|
||||
// Only trigger file download if not cancelled
|
||||
if (currentFileId === fileId) {
|
||||
setProgress(100, '<span class="pct">✓</span> <span class="meta">Done — saving to your downloads</span>');
|
||||
// Open in new tab so current page never navigates away
|
||||
window.open(`${API}/file/${fileId}`, '_blank');
|
||||
setTimeout(() => { progressWrap.style.display = 'none'; }, 2500);
|
||||
if (data.status !== 'started') {
|
||||
throw new Error('Unexpected server response.');
|
||||
}
|
||||
} catch (err) {
|
||||
sse.close();
|
||||
currentSse = null;
|
||||
if (currentSse) {
|
||||
currentSse.close();
|
||||
currentSse = null;
|
||||
}
|
||||
|
||||
currentAbort = null;
|
||||
|
||||
if (err.name !== 'AbortError') {
|
||||
showError(err.message);
|
||||
showError(err.message || 'Download failed.');
|
||||
progressWrap.style.display = 'none';
|
||||
}
|
||||
} finally {
|
||||
if (currentFileId === fileId) currentFileId = null;
|
||||
|
||||
if (currentFileId === fileId) {
|
||||
currentFileId = null;
|
||||
}
|
||||
|
||||
setLoading(downloadBtn, false);
|
||||
}
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue