From 584ec8b92b478717f11d99b5066db11c99a502ba Mon Sep 17 00:00:00 2001 From: "Claude (owner)" Date: Sat, 6 Jun 2026 18:18:02 +0900 Subject: [PATCH] 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 --- src/youtube.ts | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/src/youtube.ts b/src/youtube.ts index e4ecac8..e08fe45 100644 --- a/src/youtube.ts +++ b/src/youtube.ts @@ -226,6 +226,8 @@ export interface DownloadJob { error: string | null /** 다운로드 완료 후 적용할 자르기 구간. null 이면 자르기 없음(전체). */ trim: VideoTrim | null + /** 이 (실패한) 잡을 재시도해 만든 새 잡 id. 한 잡당 재시도 1회만 허용하는 가드. */ + retriedBy: string | null } const jobs = new Map() @@ -371,7 +373,8 @@ function enqueueJob(p: { finishedAt: null, outputFile: null, error: null, - trim: p.trim + trim: p.trim, + retriedBy: null } jobs.set(job.id, job) void persistJob(job) @@ -391,10 +394,22 @@ export async function retryDownloadJob(jobId: string): Promise { if (prev.status !== 'error') { throw new Error('실패한 작업만 다시 시도할 수 있습니다.') } + // 한 잡당 재시도 1회만. 같은 실패 잡으로 retry 가 동시에 여러 번 들어오면 + // 같은 디렉터리로 yt-dlp 가 여럿 떠 파일이 깨진다. 아래 sync 검증을 통과한 + // 직후(첫 await 전에) 즉시 표시해 두 번째 호출이 곧바로 거절되게 한다. + if (prev.retriedBy) throw new Error('이미 재시도한 작업입니다. 새로 생성된 작업에서 다시 시도해 주세요.') if (!getFolder(prev.folderId)) throw new Error('폴더를 찾을 수 없습니다.') // yt-dlp 가 없으면 다시 시도해도 실패하므로 사전에 던진다. 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 로 레코드를 다시 만든다. await createVideo({ id: prev.videoId, @@ -412,6 +427,8 @@ export async function retryDownloadJob(jobId: string): Promise { title: prev.title, trim: prev.trim }) + prev.retriedBy = job.id + void persistJob(prev) setImmediate(pump) return job }