feat(playlist): show download progress page after import instead of navigating away

플레이리스트 등록 후 폴더로 바로 나가지 않고, 각 다운로드 잡을 폴링하며
항목별/전체 진행률을 보여주는 진행 화면을 추가. 단일 영상 추가 흐름과 동일하게
/op/job/:id 를 폴링하고, 모두 끝나면 "폴더로 이동" 버튼을 노출한다.

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
This commit is contained in:
Claude (owner)
2026-06-05 02:26:05 +09:00
parent e9924805cf
commit f9a26aaa86
3 changed files with 177 additions and 8 deletions

View File

@@ -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) {

View File

@@ -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; }
}

View File

@@ -20,7 +20,7 @@
</header>
<main class="editorMain">
<section class="playlistProbeSection">
<section class="playlistProbeSection" id="probeSection">
<p class="muted">유튜브 플레이리스트 주소를 입력하면 항목 목록을 불러옵니다.</p>
<div class="playlistProbeRow">
<input type="text" id="playlistUrl" placeholder="https://www.youtube.com/playlist?list=..." />
@@ -58,6 +58,18 @@
<button type="button" class="primaryButton" id="registerBtn">등록 &amp; 다운로드 시작</button>
</div>
</section>
<section class="playlistProgressSection" id="progressSection" hidden>
<div class="progressHeader">
<span class="idModeLabel" id="progressSummary">다운로드 진행 중…</span>
<progress id="progressOverall" value="0" max="100"></progress>
</div>
<ol class="progressList" id="progressList"></ol>
<div class="playlistFooter">
<p id="progressStatus" class="muted">페이지를 닫아도 다운로드는 백그라운드에서 계속됩니다.</p>
<a class="primaryButton" id="progressDoneBtn" href="<%= folderHref %>" hidden>폴더로 이동</a>
</div>
</section>
</main>
<!-- 항목 우클릭 메뉴 -->