youtubeDownloader/client/src/api/queueApi.ts
Nicolas 725c5a5a7f feat(client): frontend (formulaire d'ajout + file d'attente)
Interface web complète : formulaire d'ajout (URL directe ou recherche de
chaîne) et panneau de file d'attente avec sondage de la progression.

- views/downloadForm/ : sélecteur de mode (vidéo/audio/miniature) partagé,
  ajout direct par URL, recherche de chaîne (GET /api/channel) avec ajout
  par résultat. Construction DOM via createElement/textContent (jamais
  d'innerHTML avec du texte venant de YouTube), pas juste par prudence :
  ça évite aussi tout risque d'injection depuis un titre de vidéo.
- views/queuePanel/ : sonde GET /api/queue toutes les 1,5s, affiche statut/
  progression/erreur par élément, annulation et lien de téléchargement
  natif (<a download>, pas de fetch+blob).
- api/ : clients fetch typés (channelApi, queueApi), chaque await dans son
  propre try/catch avec message d'erreur adapté (réseau vs HTTP vs JSON).
- QueueItem gagne un champ title optionnel (purement informatif, jamais
  utilisé pour construire la commande yt-dlp) pour un affichage plus
  parlant qu'une URL brute.
- client/tsconfig.json + build.mjs : cible ES2022 (nécessaire pour
  Error.cause, jusque-là ES2020).
- Vitest ajouté côté client (fonctions pures de src/format.ts ; le câblage
  DOM est vérifié manuellement, pas de jsdom pour l'instant).

Deux bugs réels trouvés et corrigés en testant dans un vrai navigateur
(pas seulement en lisant le code) :
- CSS : un attribut `hidden` sur un conteneur `display: flex` se faisait
  écraser par cette règle (même spécificité, la feuille de l'auteur gagne)
  - les deux panneaux d'options restaient visibles en même temps.
- server/src/routes/channel.ts : ne mappait vers 502 que les YtDlpError
  (code de sortie non nul), pas les erreurs brutes de spawn (ex. binaire
  introuvable - ENOENT), qui remontaient donc en 500 générique. Corrigé +
  test de non-régression.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 09:28:49 +02:00

137 lines
4.3 KiB
TypeScript

/** État d'avancement d'un élément de la file d'attente (miroir du DTO serveur). */
export type QueueItemStatus = 'pending' | 'running' | 'completed' | 'failed' | 'cancelled';
/** Mode de téléchargement d'un élément (miroir du DTO serveur). */
export type QueueItemMode =
| { kind: 'video'; options: { subtitles: boolean; embedThumbnail: boolean } }
| { kind: 'audio'; format: 'mp3' | 'm4a' | 'opus' }
| { kind: 'thumbnail' };
/** Élément de la file d'attente, tel que renvoyé par le serveur. */
export interface QueueItemDto {
id: string;
url: string;
title: string | null;
mode: QueueItemMode;
status: QueueItemStatus;
progress: number;
error: string | null;
fileName: string | null;
createdAt: string;
}
/** Entrée acceptée par {@link enqueueItem} pour choisir le mode et ses options. */
export type EnqueueMode =
| { mode: 'video'; subtitles: boolean; embedThumbnail: boolean }
| { mode: 'audio'; audioFormat: 'mp3' | 'm4a' | 'opus' }
| { mode: 'thumbnail' };
/**
* Extrait le message d'erreur d'une réponse HTTP en échec, avec un message
* de repli si le corps n'est pas un JSON exploitable.
*
* @param response - Réponse HTTP dont le statut n'est pas OK.
* @returns Le message d'erreur à afficher.
*/
async function readErrorMessage(response: Response): Promise<string> {
try {
const body = (await response.json()) as { error?: string };
return body.error ?? `Échec de la requête (${response.status}).`;
} catch {
return `Échec de la requête (${response.status}).`;
}
}
/**
* Ajoute un élément à la file d'attente via `POST /api/queue`.
*
* @param url - URL de la vidéo YouTube à télécharger.
* @param mode - Mode de téléchargement et ses options.
* @param title - Titre connu de la vidéo, s'il y en a un (affichage uniquement).
* @returns L'élément créé.
* @throws {Error} Si la requête échoue ou si le serveur refuse l'ajout.
*/
export async function enqueueItem(
url: string,
mode: EnqueueMode,
title: string | null,
): Promise<QueueItemDto> {
let response: Response;
try {
response = await fetch('/api/queue', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url, title: title ?? undefined, ...mode }),
});
} catch (error) {
throw new Error('Impossible de contacter le serveur.', { cause: error });
}
if (!response.ok) {
throw new Error(await readErrorMessage(response));
}
try {
const body = (await response.json()) as { item: QueueItemDto };
return body.item;
} catch (error) {
throw new Error('Réponse du serveur invalide.', { cause: error });
}
}
/**
* Liste tous les éléments de la file d'attente via `GET /api/queue`.
*
* @returns Les éléments de la file, dans leur ordre d'ajout.
* @throws {Error} Si la requête échoue.
*/
export async function listQueueItems(): Promise<QueueItemDto[]> {
let response: Response;
try {
response = await fetch('/api/queue');
} catch (error) {
throw new Error('Impossible de contacter le serveur.', { cause: error });
}
if (!response.ok) {
throw new Error(await readErrorMessage(response));
}
try {
const body = (await response.json()) as { items: QueueItemDto[] };
return body.items;
} catch (error) {
throw new Error('Réponse du serveur invalide.', { cause: error });
}
}
/**
* Annule un élément de la file d'attente via `DELETE /api/queue/:id`.
*
* @param id - Identifiant de l'élément à annuler.
* @throws {Error} Si la requête échoue ou si l'élément est introuvable.
*/
export async function cancelQueueItem(id: string): Promise<void> {
let response: Response;
try {
response = await fetch(`/api/queue/${encodeURIComponent(id)}`, { method: 'DELETE' });
} catch (error) {
throw new Error('Impossible de contacter le serveur.', { cause: error });
}
if (!response.ok && response.status !== 204) {
throw new Error(await readErrorMessage(response));
}
}
/**
* Construit l'URL de téléchargement du fichier produit par un élément
* terminé (`GET /api/queue/:id/file`) — destinée à un lien `<a>`, pas à un
* appel `fetch` : c'est le navigateur qui doit gérer le téléchargement.
*
* @param id - Identifiant de l'élément terminé.
* @returns L'URL à utiliser comme `href`.
*/
export function queueItemFileUrl(id: string): string {
return `/api/queue/${encodeURIComponent(id)}/file`;
}