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') var msg = document.createElement('div')
msg.className = 'progressMsg muted' msg.className = 'progressMsg muted'
msg.textContent = it.message 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(name)
li.appendChild(bar) li.appendChild(bar)
li.appendChild(msg) li.appendChild(msg)
li.appendChild(retry)
progressList.appendChild(li) progressList.appendChild(li)
it.barEl = bar it.barEl = bar
it.msgEl = msg it.msgEl = msg
it.rowEl = li 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) { function pollAllJobs(items) {
var pending = items.filter(function (it) { return !it.done }) 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) { Promise.all(pending.map(function (it) {
return fetch('/op/job/' + encodeURIComponent(it.jobId), { cache: 'no-store' }) return fetch('/op/job/' + encodeURIComponent(it.jobId), { cache: 'no-store' })
.then(function (r) { return r.json() }) .then(function (r) { return r.json() })
@@ -580,6 +627,7 @@
if (items.some(function (it) { return !it.done })) { if (items.some(function (it) { return !it.done })) {
setTimeout(function () { pollAllJobs(items) }, 1500) setTimeout(function () { pollAllJobs(items) }, 1500)
} else { } else {
polling = false
finishProgress(items) finishProgress(items)
} }
}) })
@@ -599,6 +647,7 @@
it.rowEl.classList.toggle('progressRowDone', it.done && !it.error) it.rowEl.classList.toggle('progressRowDone', it.done && !it.error)
it.rowEl.classList.toggle('progressRowError', it.error) it.rowEl.classList.toggle('progressRowError', it.error)
} }
if (it.retryEl && !it.retrying) it.retryEl.hidden = !it.error
if (it.done) doneCount += 1 if (it.done) doneCount += 1
sum += it.progress sum += it.progress
}) })
@@ -617,7 +666,7 @@
var statusEl = document.getElementById('progressStatus') var statusEl = document.getElementById('progressStatus')
if (statusEl) { if (statusEl) {
statusEl.textContent = errCount statusEl.textContent = errCount
? '실패한 항목은 폴더에서 개별로 다시 시도할 수 있습니다.' ? '실패한 항목은 아래 "재시도" 버튼으로 다시 받을 수 있습니다. (연령 제한 영상은 먼저 대시보드에서 YouTube 쿠키를 등록하세요.)'
: '모든 영상이 폴더에 추가되었습니다.' : '모든 영상이 폴더에 추가되었습니다.'
} }
if (progressDoneBtn) progressDoneBtn.hidden = false if (progressDoneBtn) progressDoneBtn.hidden = false

View File

@@ -505,7 +505,7 @@ body.siteBody.centerLayout { display: flex; align-items: center; justify-content
.progressList { list-style: none; margin: 0; padding: 0; .progressList { list-style: none; margin: 0; padding: 0;
display: flex; flex-direction: column; gap: 8px; } display: flex; flex-direction: column; gap: 8px; }
.progressRow { .progressRow {
display: grid; grid-template-columns: 1fr 180px auto; align-items: center; display: grid; grid-template-columns: 1fr 180px auto auto; align-items: center;
gap: 12px; background: var(--bg-card); border: 1px solid var(--border); gap: 12px; background: var(--bg-card); border: 1px solid var(--border);
border-radius: 10px; padding: 10px 14px; border-radius: 10px; padding: 10px 14px;
} }
@@ -516,9 +516,11 @@ body.siteBody.centerLayout { display: flex; align-items: center; justify-content
.progressBar { width: 100%; height: 10px; } .progressBar { width: 100%; height: 10px; }
.progressBar.progressBarError::-webkit-progress-value { background: #e5534b; } .progressBar.progressBarError::-webkit-progress-value { background: #e5534b; }
.progressMsg { font-size: 12px; white-space: nowrap; text-align: right; } .progressMsg { font-size: 12px; white-space: nowrap; text-align: right; }
.progressRetry { padding: 4px 12px; font-size: 12px; white-space: nowrap; justify-self: end; }
.progressRowDone { border-color: #2ea043; } .progressRowDone { border-color: #2ea043; }
.progressRowError { border-color: #e5534b; } .progressRowError { border-color: #e5534b; }
@media (max-width: 640px) { @media (max-width: 640px) {
.progressRow { grid-template-columns: 1fr; gap: 6px; } .progressRow { grid-template-columns: 1fr; gap: 6px; }
.progressMsg { text-align: left; } .progressMsg { text-align: left; }
.progressRetry { justify-self: start; }
} }

View File

@@ -33,7 +33,8 @@ import {
getJob, getJob,
probeYoutube, probeYoutube,
probeYoutubePlaylist, probeYoutubePlaylist,
startYoutubeDownload startYoutubeDownload,
retryDownloadJob
} from '../youtube.js' } from '../youtube.js'
import { FfmpegUnavailableError, applyTrimToVideo } from '../editor.js' import { FfmpegUnavailableError, applyTrimToVideo } from '../editor.js'
import { getToolsStatus, updateFfmpeg, updateYtDlp } from '../tools.js' import { getToolsStatus, updateFfmpeg, updateYtDlp } from '../tools.js'
@@ -585,6 +586,20 @@ opRouter.get('/op/job/:id', requireAuth, (req, res) => {
res.json({ ok: true, job }) res.json({ ok: true, job })
}) })
/** 실패한 다운로드를 같은 ID·URL·자르기 설정으로 재시도 (기존 영상 레코드 재사용). */
opRouter.post('/op/job/:id/retry', requireAuth, async (req, res) => {
try {
const job = await retryDownloadJob(req.params.id)
res.json({ ok: true, jobId: job.id, videoId: job.videoId })
} catch (err) {
if (err instanceof YtDlpUnavailableError) {
res.status(503).json({ ok: false, message: err.message, code: 'NO_YTDLP' })
return
}
res.status(400).json({ ok: false, message: (err as Error).message })
}
})
// ─── 영상 mutation (folderId + id 기반 — ID 는 폴더 안에서만 유일) ─────── // ─── 영상 mutation (folderId + id 기반 — ID 는 폴더 안에서만 유일) ───────
/** POST body 의 folderId 를 검증해 폴더를 돌려준다. 없거나 못 찾으면 throw. */ /** POST body 의 folderId 를 검증해 폴더를 돌려준다. 없거나 못 찾으면 throw. */

View File

@@ -215,6 +215,8 @@ export interface DownloadJob {
folderId: number folderId: number
videoId: string videoId: string
url: string url: string
/** 영상 제목. 재시도 시 레코드가 지워졌을 때 복원용으로도 쓴다. */
title: string
status: JobStatus status: JobStatus
progress: number // 0..100 progress: number // 0..100
message: string message: string
@@ -334,12 +336,34 @@ export async function startYoutubeDownload(opts: StartDownloadOpts): Promise<Dow
const trim: VideoTrim | null = const trim: VideoTrim | null =
startSec > 0.01 || endSec != null ? { startSec, endSec } : null startSec > 0.01 || endSec != null ? { startSec, endSec } : null
const now = new Date().toISOString() const job = enqueueJob({
const job: DownloadJob = {
id: 'job-' + Math.random().toString(36).slice(2, 14),
folderId: opts.folderId, folderId: opts.folderId,
videoId: video.id, videoId: video.id,
url: opts.url, url: opts.url,
title: video.title,
trim
})
// 같은 tick 에서 여러 startYoutubeDownload 가 연달아 호출될 수 있으니
// pump 는 다음 tick 에 한 번만 돌아도 충분 (반복 호출이 안전하긴 함).
setImmediate(pump)
return job
}
/** DownloadJob 을 만들어 jobs 맵에 등록하고 디스크에 기록한다 (pump 는 호출자 책임). */
function enqueueJob(p: {
folderId: number
videoId: string
url: string
title: string
trim: VideoTrim | null
}): DownloadJob {
const now = new Date().toISOString()
const job: DownloadJob = {
id: 'job-' + Math.random().toString(36).slice(2, 14),
folderId: p.folderId,
videoId: p.videoId,
url: p.url,
title: p.title,
status: 'queued', status: 'queued',
progress: 0, progress: 0,
message: '대기 중', message: '대기 중',
@@ -347,12 +371,47 @@ export async function startYoutubeDownload(opts: StartDownloadOpts): Promise<Dow
finishedAt: null, finishedAt: null,
outputFile: null, outputFile: null,
error: null, error: null,
trim trim: p.trim
} }
jobs.set(job.id, job) jobs.set(job.id, job)
void persistJob(job) void persistJob(job)
// 같은 tick 에서 여러 startYoutubeDownload 가 연달아 호출될 수 있으니 return job
// pump 는 다음 tick 에 한 번만 돌아도 충분 (반복 호출이 안전하긴 함). }
/**
* 실패한 다운로드를 같은 영상 ID·URL·자르기 설정으로 다시 시도한다.
*
* 핵심: 첫 시도에서 이미 만들어진 영상 레코드를 재사용해 같은 디렉터리로 다시 받는다.
* (createVideo 를 다시 부르면 UNIQUE(folder_id,id) 에 걸려 "이미 사용 중" 으로 막히므로,
* 레코드가 남아 있으면 건드리지 않고, 사용자가 지웠을 때만 같은 ID 로 복원한다.)
*/
export async function retryDownloadJob(jobId: string): Promise<DownloadJob> {
const prev = jobs.get(jobId)
if (!prev) throw new Error('원본 작업을 찾을 수 없습니다.')
if (prev.status !== 'error') {
throw new Error('실패한 작업만 다시 시도할 수 있습니다.')
}
if (!getFolder(prev.folderId)) throw new Error('폴더를 찾을 수 없습니다.')
// yt-dlp 가 없으면 다시 시도해도 실패하므로 사전에 던진다.
getYtDlpPath()
if (!getVideoInFolder(prev.folderId, prev.videoId)) {
// 사용자가 실패 카드를 지웠다면 같은 ID 로 레코드를 다시 만든다.
await createVideo({
id: prev.videoId,
folderId: prev.folderId,
title: prev.title || '제목 없음',
originalFile: 'original.%(ext)s',
sourceType: 'youtube',
sourceUrl: prev.url
})
}
const job = enqueueJob({
folderId: prev.folderId,
videoId: prev.videoId,
url: prev.url,
title: prev.title,
trim: prev.trim
})
setImmediate(pump) setImmediate(pump)
return job return job
} }