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:
@@ -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) {
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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">등록 & 다운로드 시작</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>
|
||||
|
||||
<!-- 항목 우클릭 메뉴 -->
|
||||
|
||||
Reference in New Issue
Block a user