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