youtubeDownloader/client/src/api/queueApi.ts
Nicolas a96322f64e
Some checks failed
Publier l'image Docker / test (pull_request) Failing after 4s
Publier l'image Docker / publish (pull_request) Has been skipped
feat: suppression d'éléments de la file, téléchargement groupé
- QueueService.remove() remplace cancel() : supprime l'élément de la
  liste quel que soit son état (annule d'abord s'il était en attente/en
  cours), au lieu de se limiter aux deux cas actifs et renvoyer false
  pour un élément déjà terminé.
- consumeCompletedFile() met fileName à null une fois le fichier livré
  (et son dossier nettoyé) : évite qu'un second appel tente de relire un
  fichier qui n'existe déjà plus.
- QueuePanel : un même bouton retire l'élément quel que soit son état -
  "Annuler" en attente/en cours, "Supprimer" une fois terminé. Bouton
  "Tout télécharger" dans l'en-tête, qui déclenche avec un léger décalage
  chaque téléchargement encore disponible (évite le blocage navigateur
  des téléchargements multiples simultanés).
- Liste de résultats de recherche de chaîne agrandie (320px -> 65vh).

11 nouveaux tests côté serveur (63 -> alignés). Vérifié en navigateur :
ajout, échec, suppression effective (l'élément disparaît de la liste).

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

138 lines
4.4 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 });
}
}
/**
* Supprime un élément de la file d'attente via `DELETE /api/queue/:id`,
* quel que soit son état (l'annule d'abord s'il était en attente/en cours).
*
* @param id - Identifiant de l'élément à supprimer.
* @throws {Error} Si la requête échoue ou si l'élément est introuvable.
*/
export async function removeQueueItem(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`;
}