feat: 플레이리스트 실패 항목 재시도 버튼 + API

실패한 다운로드는 영상 레코드가 남아 같은 ID 재등록이 막혔고, 진행 화면의
"개별로 다시 시도" 안내에 실제 버튼이 없었다. 실패 행에 "재시도" 버튼을 추가하고
POST /op/job/:id/retry 로 같은 ID·URL·자르기 설정으로 새 잡을 시작한다.
retryDownloadJob 은 기존 영상 레코드를 재사용(레코드가 지워졌으면 같은 ID로 복원)
하므로 UNIQUE 충돌 없이 재시도된다. 연령 제한 영상은 쿠키 등록 후 재시도하면 받힌다.

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
This commit is contained in:
Claude (owner)
2026-06-06 18:11:59 +09:00
parent 6ade552683
commit 6850313c0b
4 changed files with 135 additions and 10 deletions

View File

@@ -542,19 +542,66 @@
var msg = document.createElement('div')
msg.className = 'progressMsg muted'
msg.textContent = it.message
var retry = document.createElement('button')
retry.type = 'button'
retry.className = 'secondaryButton progressRetry'
retry.textContent = '재시도'
retry.hidden = true
retry.addEventListener('click', function () { retryItem(it, items) })
li.appendChild(name)
li.appendChild(bar)
li.appendChild(msg)
li.appendChild(retry)
progressList.appendChild(li)
it.barEl = bar
it.msgEl = msg
it.rowEl = li
it.retryEl = retry
})
}
// 실패한 항목을 같은 ID·URL·자르기 설정으로 다시 받는다. 백엔드가 기존 영상
// 레코드를 재사용하므로 ID 충돌 없이 새 잡이 시작된다.
function retryItem(it, items) {
if (!it.error || it.retrying) return
it.retrying = true
if (it.retryEl) { it.retryEl.disabled = true; it.retryEl.textContent = '재시도 중…' }
fetch('/op/job/' + encodeURIComponent(it.jobId) + '/retry', { method: 'POST' })
.then(function (r) { return r.json() })
.then(function (j) {
if (j && j.ok) {
it.jobId = j.jobId
it.done = false
it.error = false
it.retrying = false
it.status = 'queued'
it.progress = 0
it.message = '재시도 대기 중'
if (it.barEl) it.barEl.classList.remove('progressBarError')
if (it.retryEl) { it.retryEl.hidden = true; it.retryEl.disabled = false; it.retryEl.textContent = '재시도' }
updateProgressUI(items)
if (progressDoneBtn) progressDoneBtn.hidden = true
if (!polling) pollAllJobs(items)
} else {
it.retrying = false
if (it.retryEl) { it.retryEl.disabled = false; it.retryEl.textContent = '재시도' }
it.message = '재시도 실패: ' + ((j && j.message) || '오류')
updateProgressUI(items)
}
})
.catch(function (err) {
it.retrying = false
if (it.retryEl) { it.retryEl.disabled = false; it.retryEl.textContent = '재시도' }
it.message = '재시도 실패: ' + (err && err.message ? err.message : '요청 오류')
updateProgressUI(items)
})
}
var polling = false
function pollAllJobs(items) {
var pending = items.filter(function (it) { return !it.done })
if (pending.length === 0) { finishProgress(items); return }
if (pending.length === 0) { polling = false; finishProgress(items); return }
polling = true
Promise.all(pending.map(function (it) {
return fetch('/op/job/' + encodeURIComponent(it.jobId), { cache: 'no-store' })
.then(function (r) { return r.json() })
@@ -580,6 +627,7 @@
if (items.some(function (it) { return !it.done })) {
setTimeout(function () { pollAllJobs(items) }, 1500)
} else {
polling = false
finishProgress(items)
}
})
@@ -599,6 +647,7 @@
it.rowEl.classList.toggle('progressRowDone', it.done && !it.error)
it.rowEl.classList.toggle('progressRowError', it.error)
}
if (it.retryEl && !it.retrying) it.retryEl.hidden = !it.error
if (it.done) doneCount += 1
sum += it.progress
})
@@ -617,7 +666,7 @@
var statusEl = document.getElementById('progressStatus')
if (statusEl) {
statusEl.textContent = errCount
? '실패한 항목은 폴더에서 개별로 다시 시도할 수 있습니다.'
? '실패한 항목은 아래 "재시도" 버튼으로 다시 받을 수 있습니다. (연령 제한 영상은 먼저 대시보드에서 YouTube 쿠키를 등록하세요.)'
: '모든 영상이 폴더에 추가되었습니다.'
}
if (progressDoneBtn) progressDoneBtn.hidden = false