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