download job: use the panel's exact url list (no server re-expansion)
A YouTube Mix/radio playlist (list=RD…) returns a different set of items on every /api/playlist call (observed 779 / 1485 / 529 for the same URL). The job used to re-expand the playlist server-side, so it would download a different list than the count shown on the decision screen. The panel now passes the already-expanded `urls[]` into the job, and the executor uses them verbatim (falling back to expanding `url` only when no list is given). The job downloads exactly what you decided on. Endpoint takes `urls[]` (stored as inputs.urls) or `url`. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,10 +1,12 @@
|
|||||||
import { reclipPlaylist, reclipDownloadOne } from '../../reclip-client';
|
import { reclipPlaylist, reclipDownloadOne } from '../../reclip-client';
|
||||||
|
|
||||||
// The download-job executor — pure scripting, no agent. One phase: expand the playlist, then one download
|
// The download-job executor — pure scripting, no agent. One download request per item in the chosen
|
||||||
// request per item in the chosen format (audio/video), skipping anything that fails (private / deleted /
|
// format (audio/video), skipping anything that fails (private / deleted / download error). No metadata
|
||||||
// download error). No metadata prefetch — ReClip names the file from the video title itself. Emits a
|
// prefetch — ReClip names the file from the video title itself. The item list comes from the caller
|
||||||
// compact `download:progress` snapshot (counters, not per-item events — a playlist can be thousands of
|
// (inputs.urls — the exact list the panel already expanded, so a Mix/radio playlist that returns a
|
||||||
// items). Throws on abort or a fatal error (playlist expansion); per-item errors are counted + skipped.
|
// different set each call can't drift); it falls back to expanding inputs.url server-side. Emits a compact
|
||||||
|
// `download:progress` snapshot (counters, not per-item events — a playlist can be thousands of items).
|
||||||
|
// Throws on abort or a fatal error; per-item errors are counted + skipped.
|
||||||
|
|
||||||
export type DownloadProgress = {
|
export type DownloadProgress = {
|
||||||
phase: 'expanding' | 'download' | 'done';
|
phase: 'expanding' | 'download' | 'done';
|
||||||
@@ -30,8 +32,8 @@ export type ExecuteDownloadParams = {
|
|||||||
const EMIT_THROTTLE_MS = 750;
|
const EMIT_THROTTLE_MS = 750;
|
||||||
|
|
||||||
export async function executeDownload(params: ExecuteDownloadParams): Promise<void> {
|
export async function executeDownload(params: ExecuteDownloadParams): Promise<void> {
|
||||||
const { url, format, absDir } = params.inputs;
|
const { url, urls: urlsJson, format, absDir } = params.inputs;
|
||||||
if (!url || !absDir) throw new Error('download job missing url or target directory');
|
if (!absDir) throw new Error('download job missing target directory');
|
||||||
const audioOnly = format !== 'video'; // default to audio
|
const audioOnly = format !== 'video'; // default to audio
|
||||||
|
|
||||||
const progress: DownloadProgress = { phase: 'expanding', done: 0, failed: 0, total: 0 };
|
const progress: DownloadProgress = { phase: 'expanding', done: 0, failed: 0, total: 0 };
|
||||||
@@ -48,12 +50,20 @@ export async function executeDownload(params: ExecuteDownloadParams): Promise<vo
|
|||||||
|
|
||||||
emit(true);
|
emit(true);
|
||||||
|
|
||||||
// ── Expand ──
|
// ── Resolve the item list: the caller's exact list, else expand the url server-side ──
|
||||||
let urls = [url];
|
let urls: string[];
|
||||||
if (url.includes('list=')) {
|
if (urlsJson) {
|
||||||
const pl = await reclipPlaylist(url);
|
urls = JSON.parse(urlsJson) as string[];
|
||||||
if (pl.error) throw new Error(pl.error);
|
} else if (url) {
|
||||||
if (pl.urls?.length) urls = pl.urls;
|
if (url.includes('list=')) {
|
||||||
|
const pl = await reclipPlaylist(url);
|
||||||
|
if (pl.error) throw new Error(pl.error);
|
||||||
|
urls = pl.urls?.length ? pl.urls : [url];
|
||||||
|
} else {
|
||||||
|
urls = [url];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error('download job missing url(s)');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Download: one request per item, skip failures ──
|
// ── Download: one request per item, skip failures ──
|
||||||
|
|||||||
@@ -72,21 +72,26 @@ pipelineJobsRouter.post('/', async (c) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// POST /download — enqueue a video/audio download job (ReClip). Runs in its own lane, needs no capability
|
// POST /download — enqueue a video/audio download job (ReClip). Runs in its own lane, needs no capability
|
||||||
// task. Body: { url (video or playlist), format:'audio'|'video', dir (target folder, home-relative),
|
// task. Body: { urls[] (the exact list to download — preferred), OR url (expanded server-side),
|
||||||
// root?, label? }. The job expands + fetches metadata (phase 1) then downloads survivors (phase 2).
|
// format:'audio'|'video', dir (target folder, home-relative), root?, label? }. Passing `urls` locks the
|
||||||
|
// job to the list the panel already expanded, so a Mix/radio playlist can't drift between fetch + job.
|
||||||
pipelineJobsRouter.post('/download', async (c) => {
|
pipelineJobsRouter.post('/download', async (c) => {
|
||||||
const user = c.get('user');
|
const user = c.get('user');
|
||||||
const body = await c.req.json<{
|
const body = await c.req.json<{
|
||||||
url: string;
|
url?: string;
|
||||||
|
urls?: string[];
|
||||||
format?: 'audio' | 'video';
|
format?: 'audio' | 'video';
|
||||||
dir?: string;
|
dir?: string;
|
||||||
root?: string;
|
root?: string;
|
||||||
label?: string;
|
label?: string;
|
||||||
}>();
|
}>();
|
||||||
if (!body.url) throw errors.BAD_REQUEST('url is required');
|
if (!body.url && !body.urls?.length) throw errors.BAD_REQUEST('url or urls is required');
|
||||||
const rootDir = getRootDir(user, body.root);
|
const rootDir = getRootDir(user, body.root);
|
||||||
const absDir = resolveUserPath(rootDir, body.dir ?? '/'); // traversal-guarded
|
const absDir = resolveUserPath(rootDir, body.dir ?? '/'); // traversal-guarded
|
||||||
const format = body.format === 'video' ? 'video' : 'audio';
|
const format = body.format === 'video' ? 'video' : 'audio';
|
||||||
|
const inputs: Record<string, string> = body.urls?.length
|
||||||
|
? { urls: JSON.stringify(body.urls), format, absDir }
|
||||||
|
: { url: body.url!, format, absDir };
|
||||||
const { jobId, status } = await jobManager.enqueueJob(
|
const { jobId, status } = await jobManager.enqueueJob(
|
||||||
{
|
{
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
@@ -95,7 +100,7 @@ pipelineJobsRouter.post('/download', async (c) => {
|
|||||||
mode: 'download',
|
mode: 'download',
|
||||||
taskDirName: 'video-download',
|
taskDirName: 'video-download',
|
||||||
taskName: body.label || (format === 'audio' ? 'Audio download' : 'Video download'),
|
taskName: body.label || (format === 'audio' ? 'Audio download' : 'Video download'),
|
||||||
inputs: { url: body.url, format, absDir },
|
inputs,
|
||||||
cwd: body.dir ?? '/',
|
cwd: body.dir ?? '/',
|
||||||
config: {},
|
config: {},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -250,7 +250,7 @@ export const VideoDownloadPanel = () => {
|
|||||||
const startJob = async () => {
|
const startJob = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await client.post<{ jobId: string; status: string }>('/jobs/download', {
|
const res = await client.post<{ jobId: string; status: string }>('/jobs/download', {
|
||||||
url: url.trim(),
|
urls: expandedUrls, // the exact list we counted — a Mix playlist won't re-expand to a different set
|
||||||
format: jobFormat,
|
format: jobFormat,
|
||||||
dir: targetDir(),
|
dir: targetDir(),
|
||||||
root,
|
root,
|
||||||
|
|||||||
Reference in New Issue
Block a user