youtubeDownloader/client/src/views/queuePanel/QueuePanel.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

235 lines
7.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {
type QueueItemDto,
listQueueItems,
queueItemFileUrl,
removeQueueItem,
} from '../../api/queueApi';
import queuePanelTemplate from './QueuePanel.html';
import './QueuePanel.scss';
/** Intervalle de sondage de `GET /api/queue`, en l'absence de push temps réel (à venir). */
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> = {
pending: 'En attente',
running: 'En cours',
completed: 'Terminé',
failed: 'Échec',
cancelled: 'Annulé',
};
const MODE_LABELS: Record<QueueItemDto['mode']['kind'], string> = {
video: 'Vidéo',
audio: 'Audio',
thumbnail: 'Miniature',
};
/** Contrôleur exposé par {@link mountQueuePanel}. */
export interface QueuePanelController {
/** Recharge immédiatement la liste, sans attendre le prochain sondage. */
refresh: () => void;
}
/**
* @param item - Élément dont on veut savoir s'il est encore actionnable (en attente/en cours).
* @returns `true` si l'élément peut encore être annulé.
*/
function isActive(item: QueueItemDto): boolean {
return item.status === 'pending' || item.status === 'running';
}
/**
* @param item - Élément à tester.
* @returns `true` si un fichier est disponible au téléchargement pour cet élément.
*/
function isDownloadable(item: QueueItemDto): boolean {
return item.status === 'completed' && item.fileName !== null;
}
/**
* Supprime un élément de la file au clic sur son bouton "Annuler"/"Supprimer".
*
* @param id - Identifiant de l'élément à supprimer.
* @param button - Bouton cliqué, désactivé pendant la requête.
* @param onDone - Rappelé après la requête (succès ou échec) pour rafraîchir la liste.
*/
async function handleRemove(
id: string,
button: HTMLButtonElement,
onDone: () => void,
): Promise<void> {
button.disabled = true;
try {
await removeQueueItem(id);
} catch (error) {
console.error('Échec de la suppression', error);
} finally {
onDone();
}
}
/**
* Déclenche, avec un léger décalage entre chacun, le téléchargement de tous
* les fichiers actuellement disponibles (évite le blocage navigateur des
* téléchargements multiples déclenchés au même instant).
*
* @param button - Bouton "Tout télécharger" cliqué, désactivé pendant l'opération.
*/
async function handleDownloadAll(button: HTMLButtonElement): Promise<void> {
button.disabled = true;
try {
const items = await listQueueItems();
const downloadable = items.filter(isDownloadable);
downloadable.forEach((item, index) => {
setTimeout(() => {
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;
}
}
/**
* Construit la ligne d'affichage d'un élément de la file.
*
* @param item - Élément à afficher.
* @param onChanged - Rappelé après une action (annulation/suppression) pour rafraîchir la liste.
* @returns L'élément `<li>` prêt à être inséré dans la liste.
*/
function renderItem(item: QueueItemDto, onChanged: () => void): HTMLLIElement {
const li = document.createElement('li');
li.className = `queue-panel__item queue-panel__item--${item.status}`;
const title = document.createElement('p');
title.className = 'queue-panel__item-title';
title.textContent = item.title ?? item.url;
li.append(title);
const meta = document.createElement('p');
meta.className = 'queue-panel__item-meta';
meta.textContent = `${MODE_LABELS[item.mode.kind]} · ${STATUS_LABELS[item.status]}`;
li.append(meta);
if (isActive(item)) {
const progress = document.createElement('progress');
progress.className = 'queue-panel__item-progress';
progress.max = 100;
progress.value = item.progress;
li.append(progress);
}
if (item.status === 'failed' && item.error) {
const error = document.createElement('p');
error.className = 'queue-panel__item-error';
error.textContent = item.error;
li.append(error);
}
if (item.status === 'completed' && !item.fileName) {
const note = document.createElement('p');
note.className = 'queue-panel__item-meta';
note.textContent = 'Déjà téléchargé';
li.append(note);
}
const actions = document.createElement('div');
actions.className = 'queue-panel__item-actions';
const removeButton = document.createElement('button');
removeButton.type = 'button';
removeButton.className = 'secondary';
removeButton.textContent = isActive(item) ? 'Annuler' : 'Supprimer';
removeButton.addEventListener('click', () => {
handleRemove(item.id, removeButton, onChanged).catch((error: unknown) => {
console.error('Erreur inattendue lors de la suppression', error);
});
});
if (isDownloadable(item)) {
const link = document.createElement('a');
link.href = queueItemFileUrl(item.id);
link.textContent = 'Télécharger';
link.className = 'queue-panel__item-download';
actions.append(link);
}
actions.append(removeButton);
li.append(actions);
return li;
}
/**
* Rend la liste des éléments dans le panneau et ajuste la visibilité du
* bouton "Tout télécharger" selon qu'il existe au moins un fichier prêt.
*
* @param root - Racine du panneau monté.
* @param items - Éléments à afficher.
* @param onChanged - Rappelé après une action sur un élément (pour rafraîchir la liste).
*/
function render(root: HTMLElement, items: readonly QueueItemDto[], onChanged: () => void): void {
const list = root.querySelector<HTMLElement>('[data-role="list"]');
const empty = root.querySelector<HTMLElement>('[data-role="empty"]');
const downloadAllButton = root.querySelector<HTMLButtonElement>('[data-role="download-all"]');
if (!list || !empty) {
return;
}
empty.hidden = items.length > 0;
list.replaceChildren(...items.map((item) => renderItem(item, onChanged)));
if (downloadAllButton) {
downloadAllButton.hidden = !items.some(isDownloadable);
}
}
/**
* Monte le panneau de file d'attente dans l'élément racine fourni, et
* démarre le sondage périodique de `GET /api/queue`.
*
* @param root - Élément DOM dans lequel injecter le panneau.
* @returns Un contrôleur permettant de déclencher un rafraîchissement immédiat.
*/
export function mountQueuePanel(root: HTMLElement): QueuePanelController {
root.innerHTML = queuePanelTemplate;
let isRefreshing = false;
const refresh = (): void => {
if (isRefreshing) {
return;
}
isRefreshing = true;
listQueueItems()
.then((items) => {
render(root, items, refresh);
})
.catch((error: unknown) => {
console.error('Échec du chargement de la file d’attente', error);
})
.finally(() => {
isRefreshing = false;
});
};
const downloadAllButton = root.querySelector<HTMLButtonElement>('[data-role="download-all"]');
downloadAllButton?.addEventListener('click', () => {
handleDownloadAll(downloadAllButton).catch((error: unknown) => {
console.error('Erreur inattendue lors du téléchargement groupé', error);
});
});
refresh();
setInterval(refresh, POLL_INTERVAL_MS);
return { refresh };
}