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:
@@ -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
|
||||||
|
|||||||
@@ -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; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user