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>
137 lines
4.3 KiB
TypeScript
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`;
|
|
}
|