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

@@ -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. */

View File

@@ -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<Dow
const trim: VideoTrim | null =
startSec > 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<Dow
finishedAt: null,
outputFile: null,
error: null,
trim
trim: p.trim
}
jobs.set(job.id, job)
void persistJob(job)
// 같은 tick 에서 여러 startYoutubeDownload 가 연달아 호출될 수 있으니
// pump 는 다음 tick 에 한 번만 돌아도 충분 (반복 호출이 안전하긴 함).
return job
}
/**
* 실패한 다운로드를 같은 영상 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)
return job
}