Deux causes distinctes au meme symptome (aucune ligne de liste n'affichait de miniature) : - ChannelDiscoveryService : en --flat-playlist, l'extracteur YouTube de yt-dlp ne renseigne quasiment jamais le champ singulier `thumbnail`, seulement `thumbnails` (tableau, trie par resolution croissante). Le parsing ne lisait que `thumbnail` -> thumbnailUrl toujours null. Passe inapercu en test car le mock ne reproduisait que la forme theorique, jamais la forme reelle observee en prod. extractThumbnailUrl() lit desormais aussi `thumbnails`, en repli. - QueuePanel (file d'attente) : aucun champ thumbnailUrl n'existait sur QueueItem/QueueItemDto - fonctionnalite jamais implementee, pas de regression. Ajoutee de bout en bout : POST /api/queue accepte thumbnailUrl (purement informatif, jamais transmis a yt-dlp), QueueService la conserve et l'expose, DownloadForm la transmet depuis les resultats de recherche, QueuePanel l'affiche (miniature 96x54 a gauche de chaque ligne). Verifie en navigateur reel (pas seulement les tests) : element ajoute via POST direct avec thumbnailUrl, image reellement chargee (naturalWidth 480) et positionnee dans le DOM rendu. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
213 lines
8.1 KiB
TypeScript
213 lines
8.1 KiB
TypeScript
import { existsSync } from 'node:fs';
|
||
import { mkdtemp, rm, stat, writeFile } from 'node:fs/promises';
|
||
import { tmpdir } from 'node:os';
|
||
import { dirname, join } from 'node:path';
|
||
import type { FastifyBaseLogger } from 'fastify';
|
||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||
|
||
vi.mock('../src/services/ytDlpRunner.js', async (importOriginal) => {
|
||
const actual = await importOriginal<typeof import('../src/services/ytDlpRunner.js')>();
|
||
return { ...actual, runYtDlp: vi.fn() };
|
||
});
|
||
|
||
import { QueueService } from '../src/services/QueueService.js';
|
||
import type { DownloadMode } from '../src/services/downloadPlan.js';
|
||
import { YtDlpError, runYtDlp } from '../src/services/ytDlpRunner.js';
|
||
import { extractOutputDir, waitFor } from './helpers.js';
|
||
|
||
const noopLogger = { info: () => {}, error: () => {} } as unknown as FastifyBaseLogger;
|
||
const url = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ';
|
||
const videoMode: DownloadMode = {
|
||
kind: 'video',
|
||
options: { subtitles: false, embedThumbnail: false },
|
||
};
|
||
|
||
let baseDir: string;
|
||
|
||
beforeEach(async () => {
|
||
baseDir = await mkdtemp(join(tmpdir(), 'ytdl-queue-test-'));
|
||
vi.mocked(runYtDlp).mockReset();
|
||
});
|
||
|
||
afterEach(async () => {
|
||
await rm(baseDir, { recursive: true, force: true });
|
||
});
|
||
|
||
describe('QueueService', () => {
|
||
it('exécute un élément jusqu’à complétion et retrouve le fichier produit', async () => {
|
||
vi.mocked(runYtDlp).mockImplementation(async (args, options) => {
|
||
options?.onStdoutLine?.('[download] 50.0% of 10.00MiB');
|
||
await writeFile(join(extractOutputDir(args), 'My Video.mp4'), 'fake');
|
||
return '';
|
||
});
|
||
|
||
const service = new QueueService(noopLogger, baseDir, 2);
|
||
const item = service.enqueue(url, videoMode);
|
||
|
||
await waitFor(() => service.get(item.id)?.status === 'completed');
|
||
|
||
const final = service.get(item.id);
|
||
expect(final?.fileName).toBe('My Video.mp4');
|
||
expect(final?.progress).toBe(100);
|
||
expect(final?.error).toBeNull();
|
||
});
|
||
|
||
it('conserve la miniature fournie à enqueue() dans la vue publique', async () => {
|
||
vi.mocked(runYtDlp).mockImplementation(() => new Promise(() => {}));
|
||
|
||
const service = new QueueService(noopLogger, baseDir, 1);
|
||
const item = service.enqueue(url, videoMode, 'Titre', 'https://x/thumb.jpg');
|
||
|
||
expect(item.thumbnailUrl).toBe('https://x/thumb.jpg');
|
||
expect(service.get(item.id)?.thumbnailUrl).toBe('https://x/thumb.jpg');
|
||
});
|
||
|
||
it('respecte la concurrence maximale configurée', async () => {
|
||
let concurrentCount = 0;
|
||
let maxObservedConcurrent = 0;
|
||
const releaseFns: Array<() => void> = [];
|
||
|
||
vi.mocked(runYtDlp).mockImplementation(async (args) => {
|
||
concurrentCount += 1;
|
||
maxObservedConcurrent = Math.max(maxObservedConcurrent, concurrentCount);
|
||
await new Promise<void>((resolve) => releaseFns.push(resolve));
|
||
concurrentCount -= 1;
|
||
await writeFile(join(extractOutputDir(args), 'v.mp4'), 'x');
|
||
return '';
|
||
});
|
||
|
||
const service = new QueueService(noopLogger, baseDir, 2);
|
||
const first = service.enqueue(url, videoMode);
|
||
const second = service.enqueue(url, videoMode);
|
||
const third = service.enqueue(url, videoMode);
|
||
const items = [first, second, third];
|
||
|
||
await waitFor(() => releaseFns.length === 2);
|
||
expect(maxObservedConcurrent).toBe(2);
|
||
expect(service.get(third.id)?.status).toBe('pending');
|
||
|
||
releaseFns[0]?.();
|
||
await waitFor(() => releaseFns.length === 3);
|
||
releaseFns[1]?.();
|
||
releaseFns[2]?.();
|
||
|
||
await waitFor(() => items.every((item) => service.get(item.id)?.status === 'completed'));
|
||
expect(maxObservedConcurrent).toBeLessThanOrEqual(2);
|
||
});
|
||
|
||
it('marque un élément en échec et nettoie son dossier de sortie quand yt-dlp échoue', async () => {
|
||
vi.mocked(runYtDlp).mockRejectedValue(new YtDlpError(1, 'network unreachable'));
|
||
|
||
const service = new QueueService(noopLogger, baseDir, 2);
|
||
const item = service.enqueue(url, videoMode);
|
||
|
||
await waitFor(() => service.get(item.id)?.status === 'failed');
|
||
|
||
expect(service.get(item.id)?.error).toContain('network unreachable');
|
||
await expect(stat(join(baseDir, item.id))).rejects.toThrow();
|
||
});
|
||
|
||
it('remove() supprime un élément encore en attente sans jamais appeler yt-dlp pour lui', async () => {
|
||
vi.mocked(runYtDlp).mockImplementation(() => new Promise(() => {})); // occupe le seul slot indéfiniment
|
||
|
||
const service = new QueueService(noopLogger, baseDir, 1);
|
||
service.enqueue(url, videoMode);
|
||
const second = service.enqueue(url, videoMode);
|
||
|
||
expect(service.get(second.id)?.status).toBe('pending');
|
||
expect(service.remove(second.id)).toBe(true);
|
||
expect(service.get(second.id)).toBeNull();
|
||
|
||
// Le premier élément appelle yt-dlp de façon asynchrone (après son
|
||
// `mkdir`) : on attend cet appel plutôt que de supposer qu'il a déjà
|
||
// eu lieu au moment où enqueue() rend la main.
|
||
await waitFor(() => vi.mocked(runYtDlp).mock.calls.length === 1);
|
||
expect(runYtDlp).toHaveBeenCalledTimes(1); // jamais appelé pour le second, supprimé avant de démarrer
|
||
});
|
||
|
||
it("remove() supprime un élément en cours en déclenchant son signal d'annulation", async () => {
|
||
vi.mocked(runYtDlp).mockImplementation(
|
||
(_args, options) =>
|
||
new Promise((_resolve, reject) => {
|
||
const rejectAborted = () => {
|
||
const error = new Error('aborted');
|
||
error.name = 'AbortError';
|
||
reject(error);
|
||
};
|
||
// Le signal peut déjà être annulé si remove() a été appelé avant
|
||
// que ce mock ne soit invoqué (après le `mkdir` de runItem) : un
|
||
// listener "abort" posé après coup ne se déclencherait jamais.
|
||
if (options?.signal?.aborted) {
|
||
rejectAborted();
|
||
return;
|
||
}
|
||
options?.signal?.addEventListener('abort', rejectAborted);
|
||
}),
|
||
);
|
||
|
||
const service = new QueueService(noopLogger, baseDir, 1);
|
||
const item = service.enqueue(url, videoMode);
|
||
|
||
await waitFor(() => vi.mocked(runYtDlp).mock.calls.length === 1);
|
||
expect(service.remove(item.id)).toBe(true);
|
||
// Retiré immédiatement de la liste, pas seulement une fois l'abort
|
||
// effectif côté yt-dlp.
|
||
expect(service.get(item.id)).toBeNull();
|
||
});
|
||
|
||
it('remove() retourne false pour un identifiant inconnu', () => {
|
||
const service = new QueueService(noopLogger, baseDir, 1);
|
||
expect(service.remove('inconnu')).toBe(false);
|
||
});
|
||
|
||
it('remove() supprime un élément terminé et nettoie le fichier jamais téléchargé', async () => {
|
||
vi.mocked(runYtDlp).mockImplementation(async (args) => {
|
||
await writeFile(join(extractOutputDir(args), 'My Video.mp4'), 'fake');
|
||
return '';
|
||
});
|
||
|
||
const service = new QueueService(noopLogger, baseDir, 1);
|
||
const item = service.enqueue(url, videoMode);
|
||
await waitFor(() => service.get(item.id)?.status === 'completed');
|
||
|
||
expect(service.remove(item.id)).toBe(true);
|
||
expect(service.get(item.id)).toBeNull();
|
||
await waitFor(() => !existsSync(join(baseDir, item.id)));
|
||
});
|
||
|
||
it('consumeCompletedFile retourne null pour un élément inconnu ou non terminé', () => {
|
||
vi.mocked(runYtDlp).mockImplementation(() => new Promise(() => {}));
|
||
|
||
const service = new QueueService(noopLogger, baseDir, 1);
|
||
const item = service.enqueue(url, videoMode);
|
||
|
||
expect(service.consumeCompletedFile('inconnu')).toBeNull();
|
||
expect(service.consumeCompletedFile(item.id)).toBeNull(); // encore en cours
|
||
});
|
||
|
||
it('consumeCompletedFile expose le fichier puis onSent nettoie le dossier', async () => {
|
||
vi.mocked(runYtDlp).mockImplementation(async (args) => {
|
||
await writeFile(join(extractOutputDir(args), 'My Video.mp4'), 'fake');
|
||
return '';
|
||
});
|
||
|
||
const service = new QueueService(noopLogger, baseDir, 1);
|
||
const item = service.enqueue(url, videoMode);
|
||
await waitFor(() => service.get(item.id)?.status === 'completed');
|
||
|
||
const result = service.consumeCompletedFile(item.id);
|
||
if (!result) {
|
||
throw new Error('expected consumeCompletedFile to return a result');
|
||
}
|
||
expect(result.fileName).toBe('My Video.mp4');
|
||
expect(result.path).toBe(join(baseDir, item.id, 'My Video.mp4'));
|
||
|
||
await result.onSent();
|
||
await expect(stat(dirname(result.path))).rejects.toThrow();
|
||
|
||
// Le fichier a été livré : un second appel ne doit plus rien exposer,
|
||
// sous peine de tenter de relire un fichier qui n'existe plus.
|
||
expect(service.get(item.id)?.fileName).toBeNull();
|
||
expect(service.consumeCompletedFile(item.id)).toBeNull();
|
||
});
|
||
});
|