Merge branch 'fix/layout-scroll-and-thumbnails' into main (rattrapage : contenait la PR #7 mergée par erreur dessus au lieu de main)
This commit is contained in:
commit
25c6c7c9e4
11 changed files with 1290 additions and 76 deletions
|
|
@ -136,3 +136,15 @@ export async function removeQueueItem(id: string): Promise<void> {
|
||||||
export function queueItemFileUrl(id: string): string {
|
export function queueItemFileUrl(id: string): string {
|
||||||
return `/api/queue/${encodeURIComponent(id)}/file`;
|
return `/api/queue/${encodeURIComponent(id)}/file`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construit l'URL de téléchargement groupé, en zip, de tous les fichiers
|
||||||
|
* actuellement disponibles (`GET /api/queue/download-all`) — destinée à un
|
||||||
|
* lien `<a>`, pas à un appel `fetch` : c'est le navigateur qui doit gérer
|
||||||
|
* le téléchargement.
|
||||||
|
*
|
||||||
|
* @returns L'URL à utiliser comme `href`.
|
||||||
|
*/
|
||||||
|
export function queueDownloadAllUrl(): string {
|
||||||
|
return '/api/queue/download-all';
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<div class="queue-panel">
|
<div class="queue-panel">
|
||||||
<div class="queue-panel__header">
|
<div class="queue-panel__header">
|
||||||
<h2>File d'attente</h2>
|
<h2>File d'attente</h2>
|
||||||
<button type="button" class="secondary" data-role="download-all" hidden>Tout télécharger</button>
|
<button type="button" class="secondary" data-role="download-all" hidden>Tout télécharger (.zip)</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="queue-panel__empty" data-role="empty">Aucun élément pour l'instant.</p>
|
<p class="queue-panel__empty" data-role="empty">Aucun élément pour l'instant.</p>
|
||||||
<ul class="queue-panel__list" data-role="list"></ul>
|
<ul class="queue-panel__list" data-role="list"></ul>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import {
|
import {
|
||||||
type QueueItemDto,
|
type QueueItemDto,
|
||||||
listQueueItems,
|
listQueueItems,
|
||||||
|
queueDownloadAllUrl,
|
||||||
queueItemFileUrl,
|
queueItemFileUrl,
|
||||||
removeQueueItem,
|
removeQueueItem,
|
||||||
} from '../../api/queueApi';
|
} from '../../api/queueApi';
|
||||||
|
|
@ -10,9 +11,6 @@ import './QueuePanel.scss';
|
||||||
/** Intervalle de sondage de `GET /api/queue`, en l'absence de push temps réel (à venir). */
|
/** Intervalle de sondage de `GET /api/queue`, en l'absence de push temps réel (à venir). */
|
||||||
const POLL_INTERVAL_MS = 1500;
|
const POLL_INTERVAL_MS = 1500;
|
||||||
|
|
||||||
/** Délai entre deux déclenchements de téléchargement lors d'un "tout télécharger" (limite les blocages navigateur sur les téléchargements multiples). */
|
|
||||||
const BULK_DOWNLOAD_STAGGER_MS = 250;
|
|
||||||
|
|
||||||
const STATUS_LABELS: Record<QueueItemDto['status'], string> = {
|
const STATUS_LABELS: Record<QueueItemDto['status'], string> = {
|
||||||
pending: 'En attente',
|
pending: 'En attente',
|
||||||
running: 'En cours',
|
running: 'En cours',
|
||||||
|
|
@ -72,32 +70,22 @@ async function handleRemove(
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Déclenche, avec un léger décalage entre chacun, le téléchargement de tous
|
* Déclenche le téléchargement d'un fichier (ou d'un zip groupé) via un lien
|
||||||
* les fichiers actuellement disponibles (évite le blocage navigateur des
|
* `<a>` temporaire — `target="_blank"` en filet de sécurité : si jamais
|
||||||
* téléchargements multiples déclenchés au même instant).
|
* rien n'est disponible côté serveur (ex. état changé juste avant le
|
||||||
|
* clic), l'éventuelle réponse JSON d'erreur s'ouvre dans un nouvel onglet
|
||||||
|
* plutôt que de remplacer l'application.
|
||||||
*
|
*
|
||||||
* @param button - Bouton "Tout télécharger" cliqué, désactivé pendant l'opération.
|
* @param href - URL à déclencher en téléchargement.
|
||||||
*/
|
*/
|
||||||
async function handleDownloadAll(button: HTMLButtonElement): Promise<void> {
|
function triggerDownload(href: string): void {
|
||||||
button.disabled = true;
|
const link = document.createElement('a');
|
||||||
try {
|
link.href = href;
|
||||||
const items = await listQueueItems();
|
link.target = '_blank';
|
||||||
const downloadable = items.filter(isDownloadable);
|
link.rel = 'noopener';
|
||||||
|
document.body.append(link);
|
||||||
downloadable.forEach((item, index) => {
|
link.click();
|
||||||
setTimeout(() => {
|
link.remove();
|
||||||
const link = document.createElement('a');
|
|
||||||
link.href = queueItemFileUrl(item.id);
|
|
||||||
document.body.append(link);
|
|
||||||
link.click();
|
|
||||||
link.remove();
|
|
||||||
}, index * BULK_DOWNLOAD_STAGGER_MS);
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Échec du téléchargement groupé', error);
|
|
||||||
} finally {
|
|
||||||
button.disabled = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -159,6 +147,8 @@ function renderItem(item: QueueItemDto, onChanged: () => void): HTMLLIElement {
|
||||||
if (isDownloadable(item)) {
|
if (isDownloadable(item)) {
|
||||||
const link = document.createElement('a');
|
const link = document.createElement('a');
|
||||||
link.href = queueItemFileUrl(item.id);
|
link.href = queueItemFileUrl(item.id);
|
||||||
|
link.target = '_blank';
|
||||||
|
link.rel = 'noopener';
|
||||||
link.textContent = 'Télécharger';
|
link.textContent = 'Télécharger';
|
||||||
link.className = 'queue-panel__item-download';
|
link.className = 'queue-panel__item-download';
|
||||||
actions.append(link);
|
actions.append(link);
|
||||||
|
|
@ -223,9 +213,7 @@ export function mountQueuePanel(root: HTMLElement): QueuePanelController {
|
||||||
|
|
||||||
const downloadAllButton = root.querySelector<HTMLButtonElement>('[data-role="download-all"]');
|
const downloadAllButton = root.querySelector<HTMLButtonElement>('[data-role="download-all"]');
|
||||||
downloadAllButton?.addEventListener('click', () => {
|
downloadAllButton?.addEventListener('click', () => {
|
||||||
handleDownloadAll(downloadAllButton).catch((error: unknown) => {
|
triggerDownload(queueDownloadAllUrl());
|
||||||
console.error('Erreur inattendue lors du téléchargement groupé', error);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
refresh();
|
refresh();
|
||||||
|
|
|
||||||
1027
server/package-lock.json
generated
1027
server/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -16,9 +16,11 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/static": "^10.1.3",
|
"@fastify/static": "^10.1.3",
|
||||||
|
"archiver": "^7.0.1",
|
||||||
"fastify": "^5.2.1"
|
"fastify": "^5.2.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/archiver": "^6.0.3",
|
||||||
"@types/node": "^22.10.7",
|
"@types/node": "^22.10.7",
|
||||||
"tsx": "^4.19.2",
|
"tsx": "^4.19.2",
|
||||||
"typescript": "^5.7.3",
|
"typescript": "^5.7.3",
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { createReadStream } from 'node:fs';
|
import { createReadStream } from 'node:fs';
|
||||||
|
import archiver from 'archiver';
|
||||||
import type { FastifyInstance, FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify';
|
import type { FastifyInstance, FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify';
|
||||||
import type { ServerConfig } from '../config.js';
|
import type { ServerConfig } from '../config.js';
|
||||||
import { QueueService } from '../services/QueueService.js';
|
import { QueueService } from '../services/QueueService.js';
|
||||||
|
|
@ -71,11 +72,41 @@ function toDownloadMode(body: CreateQueueItemBody): DownloadMode {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rend un nom de fichier unique au sein d'un zip : ajoute un suffixe
|
||||||
|
* `" (2)"`, `" (3)"`, etc. avant l'extension en cas de collision (deux
|
||||||
|
* vidéos au même titre produiraient sinon le même nom de fichier).
|
||||||
|
*
|
||||||
|
* @param used - Ensemble des noms déjà attribués dans l'archive en cours ; mis à jour par effet de bord.
|
||||||
|
* @param fileName - Nom de fichier souhaité.
|
||||||
|
* @returns Un nom de fichier garanti absent de `used`.
|
||||||
|
*/
|
||||||
|
function uniqueArchiveName(used: Set<string>, fileName: string): string {
|
||||||
|
if (!used.has(fileName)) {
|
||||||
|
used.add(fileName);
|
||||||
|
return fileName;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dotIndex = fileName.lastIndexOf('.');
|
||||||
|
const base = dotIndex === -1 ? fileName : fileName.slice(0, dotIndex);
|
||||||
|
const ext = dotIndex === -1 ? '' : fileName.slice(dotIndex);
|
||||||
|
|
||||||
|
let counter = 2;
|
||||||
|
let candidate = `${base} (${counter})${ext}`;
|
||||||
|
while (used.has(candidate)) {
|
||||||
|
counter += 1;
|
||||||
|
candidate = `${base} (${counter})${ext}`;
|
||||||
|
}
|
||||||
|
used.add(candidate);
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Construit le plugin Fastify exposant les routes de file d'attente :
|
* Construit le plugin Fastify exposant les routes de file d'attente :
|
||||||
* `POST /api/queue` (ajout), `GET /api/queue` (liste), `DELETE
|
* `POST /api/queue` (ajout), `GET /api/queue` (liste), `DELETE
|
||||||
* /api/queue/:id` (suppression, quel que soit l'état) et `GET
|
* /api/queue/:id` (suppression, quel que soit l'état), `GET
|
||||||
* /api/queue/:id/file` (livraison du fichier terminé).
|
* /api/queue/:id/file` (livraison du fichier terminé) et `GET
|
||||||
|
* /api/queue/download-all` (livraison groupée en zip).
|
||||||
*
|
*
|
||||||
* @param config - Configuration runtime (dossier temporaire, concurrence).
|
* @param config - Configuration runtime (dossier temporaire, concurrence).
|
||||||
* @returns Le plugin Fastify à enregistrer.
|
* @returns Le plugin Fastify à enregistrer.
|
||||||
|
|
@ -144,5 +175,45 @@ export function createQueueRoutes(
|
||||||
return reply.send(createReadStream(result.path));
|
return reply.send(createReadStream(result.path));
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
app.get('/api/queue/download-all', async (_request: FastifyRequest, reply: FastifyReply) => {
|
||||||
|
const deliveries = queueService.consumeAllCompletedFiles();
|
||||||
|
if (deliveries.length === 0) {
|
||||||
|
return reply.status(404).send({ error: 'Aucun fichier disponible au téléchargement.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const archive = archiver('zip', { zlib: { level: 9 } });
|
||||||
|
archive.on('error', (error: Error) => {
|
||||||
|
app.log.error({ err: error }, "File d'attente : échec de la création du zip");
|
||||||
|
});
|
||||||
|
|
||||||
|
const usedNames = new Set<string>();
|
||||||
|
for (const delivery of deliveries) {
|
||||||
|
archive.file(delivery.path, { name: uniqueArchiveName(usedNames, delivery.fileName) });
|
||||||
|
}
|
||||||
|
|
||||||
|
reply.header('Content-Disposition', 'attachment; filename="youtubedownloader.zip"');
|
||||||
|
reply.header('Content-Type', 'application/zip');
|
||||||
|
reply.raw.once('finish', () => {
|
||||||
|
Promise.all(deliveries.map((delivery) => delivery.onSent())).catch((error: unknown) => {
|
||||||
|
app.log.error(
|
||||||
|
{ err: error },
|
||||||
|
"File d'attente : échec du nettoyage après téléchargement groupé",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// L'envoi de la réponse doit démarrer la consommation du flux
|
||||||
|
// AVANT finalize() : sur une grosse archive, finalize() n'aboutit
|
||||||
|
// qu'une fois tout le contenu écrit dans le flux, ce qui bloquerait
|
||||||
|
// indéfiniment si rien ne le lit encore (mémoire tampon pleine,
|
||||||
|
// plus d'écriture possible côté archiver).
|
||||||
|
const response = reply.send(archive);
|
||||||
|
archive.finalize().catch((error: unknown) => {
|
||||||
|
app.log.error({ err: error }, "File d'attente : échec de la finalisation du zip");
|
||||||
|
});
|
||||||
|
|
||||||
|
return response;
|
||||||
|
});
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -177,28 +177,22 @@ export class QueueService {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retourne le chemin absolu du fichier produit par un élément terminé,
|
* Construit la livraison d'un fichier terminé : son chemin absolu, et un
|
||||||
* et programme la suppression de son dossier de sortie une fois `onSent`
|
* `onSent` qui nettoie son dossier de sortie une fois appelé (le fichier
|
||||||
* résolu (le fichier a été livré, ou l'envoi a échoué). Le serveur ne
|
* a été livré, ou l'envoi a échoué). Le serveur ne conserve donc jamais
|
||||||
* conserve donc jamais durablement les fichiers : ils sont livrés au
|
* durablement les fichiers : ils sont livrés au navigateur puis
|
||||||
* navigateur puis nettoyés.
|
* nettoyés.
|
||||||
*
|
*
|
||||||
* @param id - Identifiant de l'élément terminé.
|
* @param item - Élément terminé dont le fichier doit être livré.
|
||||||
* @returns Le chemin absolu du fichier et son nom, ou `null` si l'élément n'est pas terminé.
|
* @param fileName - Nom du fichier à livrer (déjà vérifié non nul par l'appelant).
|
||||||
|
* @returns Le chemin absolu du fichier, son nom, et le callback de nettoyage post-livraison.
|
||||||
*/
|
*/
|
||||||
public consumeCompletedFile(
|
private buildDelivery(
|
||||||
id: string,
|
item: InternalQueueItem,
|
||||||
): { path: string; fileName: string; onSent: () => Promise<void> } | null {
|
fileName: string,
|
||||||
const item = this.items.get(id);
|
): { path: string; fileName: string; onSent: () => Promise<void> } {
|
||||||
if (!item || item.status !== 'completed' || !item.fileName) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const path = join(item.outputDir, item.fileName);
|
|
||||||
const fileName = item.fileName;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
path,
|
path: join(item.outputDir, fileName),
|
||||||
fileName,
|
fileName,
|
||||||
onSent: () =>
|
onSent: () =>
|
||||||
this.cleanupDir(item)
|
this.cleanupDir(item)
|
||||||
|
|
@ -218,6 +212,46 @@ export class QueueService {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retourne le chemin absolu du fichier produit par un élément terminé,
|
||||||
|
* et programme la suppression de son dossier de sortie une fois `onSent`
|
||||||
|
* résolu.
|
||||||
|
*
|
||||||
|
* @param id - Identifiant de l'élément terminé.
|
||||||
|
* @returns Le chemin absolu du fichier et son nom, ou `null` si l'élément n'est pas terminé.
|
||||||
|
*/
|
||||||
|
public consumeCompletedFile(
|
||||||
|
id: string,
|
||||||
|
): { path: string; fileName: string; onSent: () => Promise<void> } | null {
|
||||||
|
const item = this.items.get(id);
|
||||||
|
if (!item || item.status !== 'completed' || !item.fileName) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return this.buildDelivery(item, item.fileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retourne tous les éléments terminés dont le fichier est encore
|
||||||
|
* disponible, chacun avec son propre `onSent` (mêmes règles de nettoyage
|
||||||
|
* que {@link consumeCompletedFile}, appliquées indépendamment par
|
||||||
|
* fichier) - destiné au téléchargement groupé en zip.
|
||||||
|
*
|
||||||
|
* @returns La liste des fichiers disponibles au téléchargement, dans l'ordre d'ajout.
|
||||||
|
*/
|
||||||
|
public consumeAllCompletedFiles(): Array<{
|
||||||
|
path: string;
|
||||||
|
fileName: string;
|
||||||
|
onSent: () => Promise<void>;
|
||||||
|
}> {
|
||||||
|
const deliveries: Array<{ path: string; fileName: string; onSent: () => Promise<void> }> = [];
|
||||||
|
for (const item of this.items.values()) {
|
||||||
|
if (item.status === 'completed' && item.fileName) {
|
||||||
|
deliveries.push(this.buildDelivery(item, item.fileName));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return deliveries;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lance autant d'éléments en attente que la concurrence disponible le
|
* Lance autant d'éléments en attente que la concurrence disponible le
|
||||||
* permet.
|
* permet.
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ vi.mock('../src/services/ytDlpRunner.js', async (importOriginal) => {
|
||||||
import { QueueService } from '../src/services/QueueService.js';
|
import { QueueService } from '../src/services/QueueService.js';
|
||||||
import type { DownloadMode } from '../src/services/downloadPlan.js';
|
import type { DownloadMode } from '../src/services/downloadPlan.js';
|
||||||
import { YtDlpError, runYtDlp } from '../src/services/ytDlpRunner.js';
|
import { YtDlpError, runYtDlp } from '../src/services/ytDlpRunner.js';
|
||||||
|
import { extractOutputDir, waitFor } from './helpers.js';
|
||||||
|
|
||||||
const noopLogger = { info: () => {}, error: () => {} } as unknown as FastifyBaseLogger;
|
const noopLogger = { info: () => {}, error: () => {} } as unknown as FastifyBaseLogger;
|
||||||
const url = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ';
|
const url = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ';
|
||||||
|
|
@ -21,25 +22,6 @@ const videoMode: DownloadMode = {
|
||||||
options: { subtitles: false, embedThumbnail: false },
|
options: { subtitles: false, embedThumbnail: false },
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Extrait le dossier de sortie passé à yt-dlp via `-o` (voir downloadPlan.ts). */
|
|
||||||
function extractOutputDir(args: readonly string[]): string {
|
|
||||||
const oIndex = args.indexOf('-o');
|
|
||||||
const template = args[oIndex + 1] ?? '';
|
|
||||||
const withoutPrefix = template.replace(/^thumbnail:/, '');
|
|
||||||
return withoutPrefix.slice(0, withoutPrefix.lastIndexOf('/'));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Attend qu'une condition devienne vraie, en pollant (pas de timers factices ici : vraie I/O fichier en jeu). */
|
|
||||||
async function waitFor(predicate: () => boolean, timeoutMs = 2000): Promise<void> {
|
|
||||||
const start = Date.now();
|
|
||||||
while (!predicate()) {
|
|
||||||
if (Date.now() - start > timeoutMs) {
|
|
||||||
throw new Error('waitFor: timeout');
|
|
||||||
}
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let baseDir: string;
|
let baseDir: string;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
|
|
|
||||||
34
server/test/helpers.ts
Normal file
34
server/test/helpers.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
/**
|
||||||
|
* Extrait le dossier de sortie passé à yt-dlp via `-o` (voir downloadPlan.ts).
|
||||||
|
*
|
||||||
|
* @param args - Arguments passés au mock de `runYtDlp`.
|
||||||
|
* @returns Le dossier de sortie déduit du gabarit `-o`.
|
||||||
|
*/
|
||||||
|
export function extractOutputDir(args: readonly string[]): string {
|
||||||
|
const oIndex = args.indexOf('-o');
|
||||||
|
const template = args[oIndex + 1] ?? '';
|
||||||
|
const withoutPrefix = template.replace(/^thumbnail:/, '');
|
||||||
|
return withoutPrefix.slice(0, withoutPrefix.lastIndexOf('/'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attend qu'une condition devienne vraie, en pollant (pas de timers
|
||||||
|
* factices : vraie I/O fichier/réseau en jeu dans les tests qui l'utilisent).
|
||||||
|
* Accepte un prédicat synchrone ou asynchrone.
|
||||||
|
*
|
||||||
|
* @param predicate - Condition à satisfaire.
|
||||||
|
* @param timeoutMs - Délai maximal avant d'abandonner.
|
||||||
|
* @throws {Error} Si le délai est dépassé sans que la condition ne devienne vraie.
|
||||||
|
*/
|
||||||
|
export async function waitFor(
|
||||||
|
predicate: () => boolean | Promise<boolean>,
|
||||||
|
timeoutMs = 2000,
|
||||||
|
): Promise<void> {
|
||||||
|
const start = Date.now();
|
||||||
|
while (!(await predicate())) {
|
||||||
|
if (Date.now() - start > timeoutMs) {
|
||||||
|
throw new Error('waitFor: timeout');
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { mkdtemp, rm } from 'node:fs/promises';
|
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import Fastify from 'fastify';
|
import Fastify from 'fastify';
|
||||||
|
|
@ -10,6 +10,8 @@ vi.mock('../src/services/ytDlpRunner.js', async (importOriginal) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
import { createQueueRoutes } from '../src/routes/queue.js';
|
import { createQueueRoutes } from '../src/routes/queue.js';
|
||||||
|
import { runYtDlp } from '../src/services/ytDlpRunner.js';
|
||||||
|
import { extractOutputDir, waitFor } from './helpers.js';
|
||||||
|
|
||||||
const validUrl = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ';
|
const validUrl = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ';
|
||||||
|
|
||||||
|
|
@ -126,4 +128,67 @@ describe('routes de file d’attente', () => {
|
||||||
|
|
||||||
expect(response.statusCode).toBe(404);
|
expect(response.statusCode).toBe(404);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('GET /api/queue/download-all', () => {
|
||||||
|
it('renvoie 404 quand aucun fichier n’est disponible', async () => {
|
||||||
|
const app = Fastify();
|
||||||
|
await app.register(createQueueRoutes({ tmpDir, maxConcurrentDownloads: 2 }));
|
||||||
|
|
||||||
|
const response = await app.inject({ method: 'GET', url: '/api/queue/download-all' });
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renvoie un zip contenant les fichiers terminés, puis les nettoie', async () => {
|
||||||
|
// Simule deux téléchargements terminés en écrivant directement le
|
||||||
|
// fichier attendu dans le dossier de sortie fourni à yt-dlp.
|
||||||
|
vi.mocked(runYtDlp).mockImplementationOnce(async (args) => {
|
||||||
|
await writeFile(join(extractOutputDir(args), 'Video A.mp4'), 'contenu A');
|
||||||
|
return '';
|
||||||
|
});
|
||||||
|
vi.mocked(runYtDlp).mockImplementationOnce(async (args) => {
|
||||||
|
await writeFile(join(extractOutputDir(args), 'Video B.mp4'), 'contenu B');
|
||||||
|
return '';
|
||||||
|
});
|
||||||
|
|
||||||
|
const app = Fastify();
|
||||||
|
await app.register(createQueueRoutes({ tmpDir, maxConcurrentDownloads: 2 }));
|
||||||
|
|
||||||
|
await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/queue',
|
||||||
|
payload: { url: validUrl, mode: 'video' },
|
||||||
|
});
|
||||||
|
await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/queue',
|
||||||
|
payload: { url: validUrl, mode: 'video' },
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(async () => {
|
||||||
|
const list = await app.inject({ method: 'GET', url: '/api/queue' });
|
||||||
|
const items: Array<{ status: string }> = list.json().items;
|
||||||
|
return items.length === 2 && items.every((item) => item.status === 'completed');
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.inject({ method: 'GET', url: '/api/queue/download-all' });
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.headers['content-type']).toBe('application/zip');
|
||||||
|
expect(response.headers['content-disposition']).toContain('youtubedownloader.zip');
|
||||||
|
// Signature ZIP standard (PK\x03\x04) en tête du fichier.
|
||||||
|
expect(response.rawPayload.subarray(0, 4)).toEqual(Buffer.from([0x50, 0x4b, 0x03, 0x04]));
|
||||||
|
|
||||||
|
// Les deux éléments ont bien été livrés : plus de fichier disponible
|
||||||
|
// (et un second appel ne renverrait donc plus rien).
|
||||||
|
await waitFor(async () => {
|
||||||
|
const after = await app.inject({ method: 'GET', url: '/api/queue' });
|
||||||
|
const items: Array<{ fileName: string | null }> = after.json().items;
|
||||||
|
return items.every((item) => item.fileName === null);
|
||||||
|
});
|
||||||
|
|
||||||
|
const secondAttempt = await app.inject({ method: 'GET', url: '/api/queue/download-all' });
|
||||||
|
expect(secondAttempt.statusCode).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -25,9 +25,9 @@ Pas de `docker-compose.yml` pour l'instant (une seule image, pas encore de dépe
|
||||||
- `ChannelDiscoveryService.ts` : liste les vidéos/streams d'une chaîne (`yt-dlp --flat-playlist -j`), filtre par mots-clés sur le titre. Valide strictement le handle/URL de chaîne en entrée (premier caractère forcément alphanumérique) et ajoute un séparateur `--` avant l'URL passée à yt-dlp, en défense en profondeur contre l'injection d'options.
|
- `ChannelDiscoveryService.ts` : liste les vidéos/streams d'une chaîne (`yt-dlp --flat-playlist -j`), filtre par mots-clés sur le titre. Valide strictement le handle/URL de chaîne en entrée (premier caractère forcément alphanumérique) et ajoute un séparateur `--` avant l'URL passée à yt-dlp, en défense en profondeur contre l'injection d'options.
|
||||||
- `videoUrl.ts` : valide qu'une URL de vidéo est bien sur `youtube.com`/`youtu.be` (formats `watch`/`shorts`/`live`/court) avant de la transmettre à yt-dlp. N'accepte aucun autre domaine : yt-dlp sait suivre des centaines de sites via un extracteur générique capable de récupérer une URL quelconque, ce qui en ferait un relais SSRF si cette validation n'existait pas.
|
- `videoUrl.ts` : valide qu'une URL de vidéo est bien sur `youtube.com`/`youtu.be` (formats `watch`/`shorts`/`live`/court) avant de la transmettre à yt-dlp. N'accepte aucun autre domaine : yt-dlp sait suivre des centaines de sites via un extracteur générique capable de récupérer une URL quelconque, ce qui en ferait un relais SSRF si cette validation n'existait pas.
|
||||||
- `downloadPlan.ts` : fonctions pures construisant les arguments yt-dlp selon le mode (vidéo/audio/miniature) et parsant la progression (`[download] 42.0% ...`) depuis stdout. Toujours un séparateur `--` avant l'URL (même défense qu'au-dessus).
|
- `downloadPlan.ts` : fonctions pures construisant les arguments yt-dlp selon le mode (vidéo/audio/miniature) et parsant la progression (`[download] 42.0% ...`) depuis stdout. Toujours un séparateur `--` avant l'URL (même défense qu'au-dessus).
|
||||||
- `QueueService.ts` : file d'attente en mémoire, exécution avec concurrence bornée (`MAX_CONCURRENT_DOWNLOADS`, défaut 2). Chaque élément a son propre sous-dossier sous `TMP_DIR`, supprimé après échec/annulation ou après livraison du fichier au client (`consumeCompletedFile` -> `onSent`) : le serveur ne conserve jamais durablement les fichiers.
|
- `QueueService.ts` : file d'attente en mémoire, exécution avec concurrence bornée (`MAX_CONCURRENT_DOWNLOADS`, défaut 2). Chaque élément a son propre sous-dossier sous `TMP_DIR`, supprimé après échec/annulation ou après livraison du fichier au client (`consumeCompletedFile`/`consumeAllCompletedFiles` -> `onSent`, logique de livraison partagée via `buildDelivery`) : le serveur ne conserve jamais durablement les fichiers.
|
||||||
- Le serveur sert aussi les fichiers statiques de `client/dist` via `@fastify/static`.
|
- Le serveur sert aussi les fichiers statiques de `client/dist` via `@fastify/static`.
|
||||||
- Tests : Vitest (`server/test/`). Trois niveaux : fonctions pures testées directement (`downloadPlan.ts`, `videoUrl.ts`), services testés avec `ytDlpRunner` mocké mais un vrai système de fichiers temporaire (`QueueService.test.ts`, plus réaliste qu'un mock fs), et routes testées via `app.inject()`. Les trois niveaux comptent : un test de service avec un exemple d'entrée mal choisi avait laissé passer une regex de validation trop permissive (`--exec` accepté), repérée seulement par le test de route avec un autre exemple (voir `channelDiscoveryService.test.ts`/`channelRoute.test.ts`).
|
- Tests : Vitest (`server/test/`). Trois niveaux : fonctions pures testées directement (`downloadPlan.ts`, `videoUrl.ts`), services testés avec `ytDlpRunner` mocké mais un vrai système de fichiers temporaire (`QueueService.test.ts`, plus réaliste qu'un mock fs), et routes testées via `app.inject()`. Les trois niveaux comptent : un test de service avec un exemple d'entrée mal choisi avait laissé passer une regex de validation trop permissive (`--exec` accepté), repérée seulement par le test de route avec un autre exemple (voir `channelDiscoveryService.test.ts`/`channelRoute.test.ts`). Helpers communs (`waitFor`, `extractOutputDir`) factorisés dans `test/helpers.ts` - `waitFor` accepte un prédicat sync ou async (un prédicat async passé à une version qui n'attendait que du sync serait toujours vu comme "vrai" immédiatement, puisqu'un objet Promise est truthy).
|
||||||
|
|
||||||
## Frontend (`client/`)
|
## Frontend (`client/`)
|
||||||
|
|
||||||
|
|
@ -36,7 +36,7 @@ Pas de `docker-compose.yml` pour l'instant (une seule image, pas encore de dépe
|
||||||
- Pas de router : `src/shell/Shell.ts` monte deux vues fixes côte à côte (`views/downloadForm/`, `views/queuePanel/`), reliées par un callback simple (`onItemQueued`) plutôt qu'un bus d'événements généraliste - au-delà de deux vues qui coexistent sur un seul écran, un vrai routeur deviendrait nécessaire.
|
- Pas de router : `src/shell/Shell.ts` monte deux vues fixes côte à côte (`views/downloadForm/`, `views/queuePanel/`), reliées par un callback simple (`onItemQueued`) plutôt qu'un bus d'événements généraliste - au-delà de deux vues qui coexistent sur un seul écran, un vrai routeur deviendrait nécessaire.
|
||||||
- `src/api/` : clients fetch vers le backend (`channelApi.ts`, `queueApi.ts`), avec leurs propres types DTO - pas de paquet de types partagé avec `server/` (monorepo simple, pas de workspace npm).
|
- `src/api/` : clients fetch vers le backend (`channelApi.ts`, `queueApi.ts`), avec leurs propres types DTO - pas de paquet de types partagé avec `server/` (monorepo simple, pas de workspace npm).
|
||||||
- `src/views/downloadForm/` : formulaire d'ajout, deux entrées possibles (URL directe, ou recherche de chaîne via `GET /api/channel` puis ajout par résultat) partageant un même sélecteur de mode (vidéo/audio/miniature + options). Le mode courant est lu depuis le DOM à l'ajout, pas stocké en état séparé.
|
- `src/views/downloadForm/` : formulaire d'ajout, deux entrées possibles (URL directe, ou recherche de chaîne via `GET /api/channel` puis ajout par résultat) partageant un même sélecteur de mode (vidéo/audio/miniature + options). Le mode courant est lu depuis le DOM à l'ajout, pas stocké en état séparé.
|
||||||
- `src/views/queuePanel/` : sonde `GET /api/queue` toutes les 1,5s (pas de push temps réel pour l'instant, voir Statut) et affiche chaque élément (titre ou URL à défaut, statut, barre de progression, erreur). Un même bouton retire l'élément quel que soit son état - libellé "Annuler" s'il est en attente/en cours, "Supprimer" une fois terminé (`DELETE /api/queue/:id` gère les deux cas côté serveur). Lien "Télécharger" natif (`<a download>`) uniquement si un fichier est encore disponible ; bouton "Tout télécharger" dans l'en-tête qui déclenche, avec un léger décalage entre chacun (évite le blocage navigateur des téléchargements multiples simultanés), tous les téléchargements prêts. Expose `refresh()` pour un rafraîchissement immédiat après un ajout.
|
- `src/views/queuePanel/` : sonde `GET /api/queue` toutes les 1,5s (pas de push temps réel pour l'instant, voir Statut) et affiche chaque élément (titre ou URL à défaut, statut, barre de progression, erreur). Un même bouton retire l'élément quel que soit son état - libellé "Annuler" s'il est en attente/en cours, "Supprimer" une fois terminé (`DELETE /api/queue/:id` gère les deux cas côté serveur). Lien "Télécharger" natif uniquement si un fichier est encore disponible ; bouton "Tout télécharger (.zip)" dans l'en-tête (visible dès qu'au moins un fichier est prêt) qui déclenche un unique téléchargement zip via `GET /api/queue/download-all` - remplace une première version qui déclenchait un téléchargement par fichier avec un décalage entre chacun (fonctionnait, mais le zip évite complètement le blocage navigateur sur téléchargements multiples plutôt que de le contourner). Tous les liens de téléchargement (individuel et groupé) portent `target="_blank" rel="noopener"` : filet de sécurité si jamais rien n'est disponible côté serveur au moment du clic (état changé entre le rendu et le clic), l'éventuelle réponse d'erreur s'ouvre dans un nouvel onglet plutôt que de remplacer l'application. Expose `refresh()` pour un rafraîchissement immédiat après un ajout.
|
||||||
- Piège CSS relevé en testant dans un vrai navigateur (pas seulement les tests) : un attribut `hidden` combiné à une règle `display: flex` sur le même sélecteur se fait écraser par cette règle (même spécificité, la feuille de l'auteur gagne sur celle du user-agent) - toujours ajouter `&[hidden] { display: none; }` quand un conteneur togglé porte aussi un `display` explicite.
|
- Piège CSS relevé en testant dans un vrai navigateur (pas seulement les tests) : un attribut `hidden` combiné à une règle `display: flex` sur le même sélecteur se fait écraser par cette règle (même spécificité, la feuille de l'auteur gagne sur celle du user-agent) - toujours ajouter `&[hidden] { display: none; }` quand un conteneur togglé porte aussi un `display` explicite.
|
||||||
- Layout en hauteur pleine page (`html`/`body`/`.shell` à `height:100%`/`100vh`, `overflow:hidden` sur `body`) : la page elle-même ne défile jamais, seules `download-form__results` et `queue-panel__list` le font (chacune `flex:1 1 0; min-height:0; overflow-y:auto` dans un parent qui propage `height:100%`/`min-height:0` depuis `.shell__panel`). `min-height: 0` est nécessaire à *chaque* niveau flex descendant - un enfant flex ne rétrécit jamais sous la taille de son contenu par défaut, ce qui ferait déborder la page entière au lieu de faire défiler juste la liste. Chaque vue neutralise le grow/shrink de ses enfants directs non-scrollables via `> * { flex: 0 0 auto; }`, puis la règle plus spécifique de l'élément scrollable (même spécificité, déclarée après donc prioritaire) le fait remplir l'espace restant.
|
- Layout en hauteur pleine page (`html`/`body`/`.shell` à `height:100%`/`100vh`, `overflow:hidden` sur `body`) : la page elle-même ne défile jamais, seules `download-form__results` et `queue-panel__list` le font (chacune `flex:1 1 0; min-height:0; overflow-y:auto` dans un parent qui propage `height:100%`/`min-height:0` depuis `.shell__panel`). `min-height: 0` est nécessaire à *chaque* niveau flex descendant - un enfant flex ne rétrécit jamais sous la taille de son contenu par défaut, ce qui ferait déborder la page entière au lieu de faire défiler juste la liste. Chaque vue neutralise le grow/shrink de ses enfants directs non-scrollables via `> * { flex: 0 0 auto; }`, puis la règle plus spécifique de l'élément scrollable (même spécificité, déclarée après donc prioritaire) le fait remplir l'espace restant.
|
||||||
- Tests : Vitest (`client/test/`), uniquement sur les fonctions pures (`format.ts`) pour l'instant - pas de configuration jsdom, le câblage DOM (formulaires, rendu de liste) est vérifié manuellement dans le navigateur plutôt que testé automatiquement.
|
- Tests : Vitest (`client/test/`), uniquement sur les fonctions pures (`format.ts`) pour l'instant - pas de configuration jsdom, le câblage DOM (formulaires, rendu de liste) est vérifié manuellement dans le navigateur plutôt que testé automatiquement.
|
||||||
|
|
@ -50,6 +50,7 @@ Pas de `docker-compose.yml` pour l'instant (une seule image, pas encore de dépe
|
||||||
- `GET /api/queue` : liste tous les éléments (`{ items: [...] }`), avec leur `status` (`pending`/`running`/`completed`/`failed`/`cancelled`), `progress` (0-100, best-effort) et `error`.
|
- `GET /api/queue` : liste tous les éléments (`{ items: [...] }`), avec leur `status` (`pending`/`running`/`completed`/`failed`/`cancelled`), `progress` (0-100, best-effort) et `error`.
|
||||||
- `DELETE /api/queue/:id` : supprime un élément (`204`), quel que soit son état - s'il était en attente/en cours, il est d'abord annulé, puis retiré de la liste dans tous les cas (dossier de sortie éventuel nettoyé). `404` seulement si l'identifiant est inconnu.
|
- `DELETE /api/queue/:id` : supprime un élément (`204`), quel que soit son état - s'il était en attente/en cours, il est d'abord annulé, puis retiré de la liste dans tous les cas (dossier de sortie éventuel nettoyé). `404` seulement si l'identifiant est inconnu.
|
||||||
- `GET /api/queue/:id/file` : télécharge le fichier produit par un élément `completed` (`Content-Disposition: attachment`). Le dossier de sortie de l'élément est supprimé juste après l'envoi, et `fileName` repassé à `null` (un second appel renvoie `404`, pas une tentative de relire un fichier déjà supprimé). `404` tant que l'élément n'est pas `completed` avec un fichier disponible.
|
- `GET /api/queue/:id/file` : télécharge le fichier produit par un élément `completed` (`Content-Disposition: attachment`). Le dossier de sortie de l'élément est supprimé juste après l'envoi, et `fileName` repassé à `null` (un second appel renvoie `404`, pas une tentative de relire un fichier déjà supprimé). `404` tant que l'élément n'est pas `completed` avec un fichier disponible.
|
||||||
|
- `GET /api/queue/download-all` : télécharge en une seule archive zip (streamée via `archiver`, jamais bufferisée en mémoire) tous les fichiers `completed` disponibles ; noms dédupliqués en cas de titres identiques (`Vidéo (2).mp4`). Même sémantique de nettoyage post-livraison que `.../file`, appliquée indépendamment par fichier. `404` si aucun fichier n'est disponible. Le flux est envoyé à la réponse (`reply.send`) *avant* `archive.finalize()` (pas l'inverse) : sur une grosse archive, `finalize()` n'aboutit qu'une fois tout le contenu écrit dans le flux, ce qui bloquerait indéfiniment si personne ne le lit encore.
|
||||||
|
|
||||||
## Lint
|
## Lint
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue