fix: harden youtube retry against data corruption

Add a one-shot retry guard (retriedBy) so concurrent retries of the
same failed job can't spawn multiple yt-dlp processes into the same
directory, and a sourceType/sourceUrl match check so retrying a job
whose video ID was repurposed for a different video is rejected
instead of overwriting it.

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
This commit is contained in:
Claude (owner)
2026-06-06 18:18:02 +09:00
parent 6850313c0b
commit 584ec8b92b

View File

@@ -226,6 +226,8 @@ export interface DownloadJob {
error: string | null error: string | null
/** 다운로드 완료 후 적용할 자르기 구간. null 이면 자르기 없음(전체). */ /** 다운로드 완료 후 적용할 자르기 구간. null 이면 자르기 없음(전체). */
trim: VideoTrim | null trim: VideoTrim | null
/** 이 (실패한) 잡을 재시도해 만든 새 잡 id. 한 잡당 재시도 1회만 허용하는 가드. */
retriedBy: string | null
} }
const jobs = new Map<string, DownloadJob>() const jobs = new Map<string, DownloadJob>()
@@ -371,7 +373,8 @@ function enqueueJob(p: {
finishedAt: null, finishedAt: null,
outputFile: null, outputFile: null,
error: null, error: null,
trim: p.trim trim: p.trim,
retriedBy: null
} }
jobs.set(job.id, job) jobs.set(job.id, job)
void persistJob(job) void persistJob(job)
@@ -391,10 +394,22 @@ export async function retryDownloadJob(jobId: string): Promise<DownloadJob> {
if (prev.status !== 'error') { if (prev.status !== 'error') {
throw new Error('실패한 작업만 다시 시도할 수 있습니다.') throw new Error('실패한 작업만 다시 시도할 수 있습니다.')
} }
// 한 잡당 재시도 1회만. 같은 실패 잡으로 retry 가 동시에 여러 번 들어오면
// 같은 디렉터리로 yt-dlp 가 여럿 떠 파일이 깨진다. 아래 sync 검증을 통과한
// 직후(첫 await 전에) 즉시 표시해 두 번째 호출이 곧바로 거절되게 한다.
if (prev.retriedBy) throw new Error('이미 재시도한 작업입니다. 새로 생성된 작업에서 다시 시도해 주세요.')
if (!getFolder(prev.folderId)) throw new Error('폴더를 찾을 수 없습니다.') if (!getFolder(prev.folderId)) throw new Error('폴더를 찾을 수 없습니다.')
// yt-dlp 가 없으면 다시 시도해도 실패하므로 사전에 던진다. // yt-dlp 가 없으면 다시 시도해도 실패하므로 사전에 던진다.
getYtDlpPath() getYtDlpPath()
if (!getVideoInFolder(prev.folderId, prev.videoId)) { const existing = getVideoInFolder(prev.folderId, prev.videoId)
// 사용자가 실패 카드를 지운 뒤 같은 ID 로 다른 영상을 추가했다면, 그 영상 디렉터리에
// 옛 URL 을 덮어쓰면 안 된다. 남아 있는 레코드가 이 잡과 같은 영상일 때만 재시도한다.
if (existing && (existing.sourceType !== 'youtube' || existing.sourceUrl !== prev.url)) {
throw new Error('이 영상 ID 는 다른 영상으로 교체되어 재시도할 수 없습니다.')
}
// 검증 통과 — 이 시점부터 같은 잡의 중복 재시도를 막는다 (createVideo await 이전).
prev.retriedBy = 'pending'
if (!existing) {
// 사용자가 실패 카드를 지웠다면 같은 ID 로 레코드를 다시 만든다. // 사용자가 실패 카드를 지웠다면 같은 ID 로 레코드를 다시 만든다.
await createVideo({ await createVideo({
id: prev.videoId, id: prev.videoId,
@@ -412,6 +427,8 @@ export async function retryDownloadJob(jobId: string): Promise<DownloadJob> {
title: prev.title, title: prev.title,
trim: prev.trim trim: prev.trim
}) })
prev.retriedBy = job.id
void persistJob(prev)
setImmediate(pump) setImmediate(pump)
return job return job
} }