diff --git a/public/playlist.js b/public/playlist.js index 1c7e662..1335080 100644 --- a/public/playlist.js +++ b/public/playlist.js @@ -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 diff --git a/public/styles.css b/public/styles.css index 24540ab..848cfa5 100644 --- a/public/styles.css +++ b/public/styles.css @@ -505,7 +505,7 @@ body.siteBody.centerLayout { display: flex; align-items: center; justify-content .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; + display: grid; grid-template-columns: 1fr 180px auto auto; align-items: center; gap: 12px; background: var(--bg-card); border: 1px solid var(--border); 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.progressBarError::-webkit-progress-value { background: #e5534b; } .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; } .progressRowError { border-color: #e5534b; } @media (max-width: 640px) { .progressRow { grid-template-columns: 1fr; gap: 6px; } .progressMsg { text-align: left; } + .progressRetry { justify-self: start; } } diff --git a/src/routes/op.ts b/src/routes/op.ts index 3a4e978..b63289a 100644 --- a/src/routes/op.ts +++ b/src/routes/op.ts @@ -33,7 +33,8 @@ import { getJob, probeYoutube, probeYoutubePlaylist, - startYoutubeDownload + startYoutubeDownload, + retryDownloadJob } from '../youtube.js' import { FfmpegUnavailableError, applyTrimToVideo } from '../editor.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 }) }) +/** 실패한 다운로드를 같은 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 는 폴더 안에서만 유일) ─────── /** POST body 의 folderId 를 검증해 폴더를 돌려준다. 없거나 못 찾으면 throw. */ diff --git a/src/youtube.ts b/src/youtube.ts index acca4cc..e4ecac8 100644 --- a/src/youtube.ts +++ b/src/youtube.ts @@ -215,6 +215,8 @@ export interface DownloadJob { folderId: number videoId: string url: string + /** 영상 제목. 재시도 시 레코드가 지워졌을 때 복원용으로도 쓴다. */ + title: string status: JobStatus progress: number // 0..100 message: string @@ -334,12 +336,34 @@ export async function startYoutubeDownload(opts: StartDownloadOpts): Promise 0.01 || endSec != null ? { startSec, endSec } : null - const now = new Date().toISOString() - const job: DownloadJob = { - id: 'job-' + Math.random().toString(36).slice(2, 14), + const job = enqueueJob({ folderId: opts.folderId, videoId: video.id, 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', progress: 0, message: '대기 중', @@ -347,12 +371,47 @@ export async function startYoutubeDownload(opts: StartDownloadOpts): Promise { + 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) return job }