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>
124 lines
3.6 KiB
TypeScript
124 lines
3.6 KiB
TypeScript
import type { FastifyBaseLogger } from 'fastify';
|
|
import { describe, expect, it, vi } from 'vitest';
|
|
|
|
vi.mock('../src/services/ytDlpRunner.js', () => ({
|
|
runYtDlp: vi.fn(),
|
|
}));
|
|
|
|
import {
|
|
ChannelDiscoveryService,
|
|
InvalidChannelError,
|
|
} from '../src/services/ChannelDiscoveryService.js';
|
|
import { runYtDlp } from '../src/services/ytDlpRunner.js';
|
|
|
|
const noopLogger = { info: () => {}, error: () => {} } as unknown as FastifyBaseLogger;
|
|
|
|
describe('ChannelDiscoveryService', () => {
|
|
it('parse les lignes yt-dlp et filtre par mots-clés', async () => {
|
|
vi.mocked(runYtDlp).mockResolvedValue(
|
|
[
|
|
JSON.stringify({
|
|
id: 'a1',
|
|
title: 'Finale speed 2026',
|
|
url: 'https://youtu.be/a1',
|
|
duration: 120,
|
|
thumbnail: 'https://x/a1.jpg',
|
|
upload_date: '20260101',
|
|
}),
|
|
JSON.stringify({ id: 'a2', title: 'Interview backstage', url: 'https://youtu.be/a2' }),
|
|
'',
|
|
].join('\n'),
|
|
);
|
|
|
|
const service = new ChannelDiscoveryService(noopLogger);
|
|
const results = await service.listChannel('@worldclimbing', 'streams', ['speed']);
|
|
|
|
expect(results).toEqual([
|
|
{
|
|
id: 'a1',
|
|
title: 'Finale speed 2026',
|
|
url: 'https://youtu.be/a1',
|
|
thumbnailUrl: 'https://x/a1.jpg',
|
|
durationSeconds: 120,
|
|
uploadDate: '20260101',
|
|
},
|
|
]);
|
|
expect(runYtDlp).toHaveBeenCalledWith(
|
|
['--flat-playlist', '-j', '--', 'https://www.youtube.com/@worldclimbing/streams'],
|
|
{ timeoutMs: 60_000 },
|
|
);
|
|
});
|
|
|
|
it('retombe sur le tableau "thumbnails" (forme réelle de yt-dlp en --flat-playlist)', async () => {
|
|
vi.mocked(runYtDlp).mockResolvedValue(
|
|
JSON.stringify({
|
|
id: 'd1',
|
|
title: 'Sans champ thumbnail singulier',
|
|
url: 'https://youtu.be/d1',
|
|
thumbnails: [
|
|
{ url: 'https://x/d1-small.jpg', height: 90 },
|
|
{ url: 'https://x/d1-large.jpg', height: 360 },
|
|
],
|
|
}),
|
|
);
|
|
|
|
const service = new ChannelDiscoveryService(noopLogger);
|
|
const results = await service.listChannel('@worldclimbing', 'videos');
|
|
|
|
expect(results).toHaveLength(1);
|
|
expect(results[0]?.thumbnailUrl).toBe('https://x/d1-large.jpg');
|
|
});
|
|
|
|
it('accepte une URL de chaîne complète et ignore la casse des mots-clés', async () => {
|
|
vi.mocked(runYtDlp).mockResolvedValue(
|
|
JSON.stringify({ id: 'c1', title: 'BIG Speed Final', url: 'https://youtu.be/c1' }),
|
|
);
|
|
|
|
const service = new ChannelDiscoveryService(noopLogger);
|
|
const results = await service.listChannel('https://www.youtube.com/@worldclimbing/', 'videos', [
|
|
'SPEED',
|
|
]);
|
|
|
|
expect(results).toHaveLength(1);
|
|
expect(runYtDlp).toHaveBeenCalledWith(
|
|
['--flat-playlist', '-j', '--', 'https://www.youtube.com/@worldclimbing/videos'],
|
|
{ timeoutMs: 60_000 },
|
|
);
|
|
});
|
|
|
|
it.each(['--exec=rm', '--exec', '-x', '.hidden'])(
|
|
'rejette "%s" comme identifiant de chaîne invalide sans jamais appeler yt-dlp',
|
|
async (channel) => {
|
|
const service = new ChannelDiscoveryService(noopLogger);
|
|
|
|
await expect(service.listChannel(channel, 'videos')).rejects.toBeInstanceOf(
|
|
InvalidChannelError,
|
|
);
|
|
expect(runYtDlp).not.toHaveBeenCalled();
|
|
},
|
|
);
|
|
|
|
it('ignore silencieusement les lignes JSON invalides ou incomplètes', async () => {
|
|
vi.mocked(runYtDlp).mockResolvedValue(
|
|
[
|
|
'not json',
|
|
JSON.stringify({ id: 'b1' }),
|
|
JSON.stringify({ id: 'b2', title: 'ok', url: 'https://youtu.be/b2' }),
|
|
].join('\n'),
|
|
);
|
|
|
|
const service = new ChannelDiscoveryService(noopLogger);
|
|
const results = await service.listChannel('somehandle', 'videos');
|
|
|
|
expect(results).toEqual([
|
|
{
|
|
id: 'b2',
|
|
title: 'ok',
|
|
url: 'https://youtu.be/b2',
|
|
thumbnailUrl: null,
|
|
durationSeconds: null,
|
|
uploadDate: null,
|
|
},
|
|
]);
|
|
});
|
|
});
|