diff --git a/public/playlist.js b/public/playlist.js index b2338af..b1e676c 100644 --- a/public/playlist.js +++ b/public/playlist.js @@ -42,6 +42,12 @@ var zeroPadToggle = document.getElementById('zeroPadToggle') var registerBtn = document.getElementById('registerBtn') var registerStatus = document.getElementById('registerStatus') + var probeSection = document.getElementById('probeSection') + var progressSection = document.getElementById('progressSection') + var progressList = document.getElementById('progressList') + var progressSummary = document.getElementById('progressSummary') + var progressOverall = document.getElementById('progressOverall') + var progressDoneBtn = document.getElementById('progressDoneBtn') // ─── 유틸 ───────────────────────────────────────────────────────────── function randomId() { @@ -484,13 +490,7 @@ updateRegisterEnable() return } - setRegisterStatus( - (res.body.jobs ? res.body.jobs.length : 0) + ' 개 다운로드가 큐에 등록되었습니다. 폴더 페이지로 이동합니다…', - 'idStatusOk' - ) - setTimeout(function () { - location.href = folderBaseUrl - }, 800) + startDownloadProgress(res.body.jobs || []) }) .catch(function (err) { state.busy = false @@ -499,6 +499,130 @@ }) }) + // ─── 다운로드 진행 화면 ──────────────────────────────────────────────── + // 등록 후 폴더로 바로 나가지 않고, 각 잡을 폴링하며 진행률을 보여준다. + // (백엔드는 잡을 큐에 넣고 순차 처리 — queued/downloading/done/error.) + function startDownloadProgress(jobs) { + if (probeSection) probeSection.hidden = true + if (entriesSection) entriesSection.hidden = true + if (progressSection) progressSection.hidden = false + var items = jobs.map(function (j, i) { + var title = (state.entries[i] && state.entries[i].title) || j.videoId + return { + jobId: j.jobId, + videoId: j.videoId, + title: title, + status: 'queued', + progress: 0, + message: '대기 중', + done: false, + error: false, + barEl: null, + msgEl: null + } + }) + renderProgressRows(items) + updateProgressUI(items) + if (items.length === 0) { finishProgress(items); return } + pollAllJobs(items) + } + + function renderProgressRows(items) { + progressList.innerHTML = '' + items.forEach(function (it) { + var li = document.createElement('li') + li.className = 'progressRow' + var name = document.createElement('div') + name.className = 'progressName' + name.textContent = it.title + var bar = document.createElement('progress') + bar.className = 'progressBar' + bar.max = 100 + bar.value = 0 + var msg = document.createElement('div') + msg.className = 'progressMsg muted' + msg.textContent = it.message + li.appendChild(name) + li.appendChild(bar) + li.appendChild(msg) + progressList.appendChild(li) + it.barEl = bar + it.msgEl = msg + it.rowEl = li + }) + } + + function pollAllJobs(items) { + var pending = items.filter(function (it) { return !it.done }) + if (pending.length === 0) { finishProgress(items); return } + Promise.all(pending.map(function (it) { + return fetch('/op/job/' + encodeURIComponent(it.jobId), { cache: 'no-store' }) + .then(function (r) { return r.json() }) + .then(function (j) { + if (j && j.ok && j.job) { + it.status = j.job.status + it.message = j.job.message || '' + it.progress = typeof j.job.progress === 'number' ? j.job.progress : it.progress + if (j.job.status === 'done') { + it.done = true + it.progress = 100 + it.message = '완료' + } else if (j.job.status === 'error') { + it.done = true + it.error = true + it.message = '실패: ' + (j.job.error || j.job.message || '') + } + } + }) + .catch(function () { /* 일시 오류는 다음 틱에 재시도 */ }) + })).then(function () { + updateProgressUI(items) + if (items.some(function (it) { return !it.done })) { + setTimeout(function () { pollAllJobs(items) }, 1500) + } else { + finishProgress(items) + } + }) + } + + function updateProgressUI(items) { + var total = items.length + var doneCount = 0 + var sum = 0 + items.forEach(function (it) { + if (it.barEl) { + it.barEl.value = it.progress + if (it.error) it.barEl.classList.add('progressBarError') + } + if (it.msgEl) it.msgEl.textContent = it.message + if (it.rowEl) { + it.rowEl.classList.toggle('progressRowDone', it.done && !it.error) + it.rowEl.classList.toggle('progressRowError', it.error) + } + if (it.done) doneCount += 1 + sum += it.progress + }) + if (progressOverall) progressOverall.value = total ? Math.round(sum / total) : 0 + if (progressSummary) progressSummary.textContent = '다운로드 ' + doneCount + ' / ' + total + ' 완료' + } + + function finishProgress(items) { + var errCount = items.filter(function (it) { return it.error }).length + if (progressOverall) progressOverall.value = 100 + if (progressSummary) { + progressSummary.textContent = errCount + ? ('완료 — ' + items.length + ' 개 중 ' + errCount + ' 개 실패') + : ('전체 ' + items.length + ' 개 다운로드 완료') + } + var statusEl = document.getElementById('progressStatus') + if (statusEl) { + statusEl.textContent = errCount + ? '실패한 항목은 폴더에서 개별로 다시 시도할 수 있습니다.' + : '모든 영상이 폴더에 추가되었습니다.' + } + if (progressDoneBtn) progressDoneBtn.hidden = false + } + // ─── 시간 파싱/표시 + YouTube id 추출 ────────────────────────────────── // "ss" / "mm:ss" / "hh:mm:ss" / 소수 초 를 모두 받아 초(number)로. 빈칸/오류는 null. function parseClock(str) { diff --git a/public/styles.css b/public/styles.css index fbe7443..6a2957a 100644 --- a/public/styles.css +++ b/public/styles.css @@ -467,3 +467,36 @@ body.siteBody.centerLayout { display: flex; align-items: center; justify-content display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin-top: 8px; } + +/* ─── 플레이리스트 다운로드 진행 화면 ─────────────────────────────── */ +.playlistProgressSection { + margin-top: 18px; + display: flex; flex-direction: column; gap: 14px; +} +.progressHeader { + display: flex; align-items: center; gap: 14px; flex-wrap: wrap; + background: var(--bg-card); border: 1px solid var(--border); + border-radius: 10px; padding: 12px 16px; +} +.progressHeader #progressSummary { font-size: 14px; color: var(--text); } +.progressHeader #progressOverall { flex: 1; min-width: 200px; height: 12px; } +.progressList { list-style: none; margin: 0; padding: 0; + display: flex; flex-direction: column; gap: 8px; } +.progressRow { + display: grid; grid-template-columns: 1fr 180px auto; align-items: center; + gap: 12px; background: var(--bg-card); border: 1px solid var(--border); + border-radius: 10px; padding: 10px 14px; +} +.progressName { + color: var(--text); font-size: 14px; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.progressBar { width: 100%; height: 10px; } +.progressBar.progressBarError::-webkit-progress-value { background: #e5534b; } +.progressMsg { font-size: 12px; white-space: nowrap; text-align: right; } +.progressRowDone { border-color: #2ea043; } +.progressRowError { border-color: #e5534b; } +@media (max-width: 640px) { + .progressRow { grid-template-columns: 1fr; gap: 6px; } + .progressMsg { text-align: left; } +} diff --git a/views/op/playlist.ejs b/views/op/playlist.ejs index aabb510..5abc90d 100644 --- a/views/op/playlist.ejs +++ b/views/op/playlist.ejs @@ -20,7 +20,7 @@
-
+

유튜브 플레이리스트 주소를 입력하면 항목 목록을 불러옵니다.

@@ -58,6 +58,18 @@
+ +