youtubeDownloader/client/src/views/queuePanel/QueuePanel.ts
Nicolas a8cd7ae907
Some checks failed
Publier l'image Docker / test (pull_request) Failing after 4s
Publier l'image Docker / publish (pull_request) Has been skipped
fix: miniatures manquantes (resultats de recherche + file d'attente)
Deux causes distinctes au meme symptome (aucune ligne de liste n'affichait
de miniature) :

- ChannelDiscoveryService : en --flat-playlist, l'extracteur YouTube de
  yt-dlp ne renseigne quasiment jamais le champ singulier `thumbnail`,
  seulement `thumbnails` (tableau, trie par resolution croissante). Le
  parsing ne lisait que `thumbnail` -> thumbnailUrl toujours null. Passe
  inapercu en test car le mock ne reproduisait que la forme theorique,
  jamais la forme reelle observee en prod. extractThumbnailUrl() lit
  desormais aussi `thumbnails`, en repli.
- QueuePanel (file d'attente) : aucun champ thumbnailUrl n'existait sur
  QueueItem/QueueItemDto - fonctionnalite jamais implementee, pas de
  regression. Ajoutee de bout en bout : POST /api/queue accepte
  thumbnailUrl (purement informatif, jamais transmis a yt-dlp),
  QueueService la conserve et l'expose, DownloadForm la transmet depuis
  les resultats de recherche, QueuePanel l'affiche (miniature 96x54 a
  gauche de chaque ligne).

Verifie en navigateur reel (pas seulement les tests) : element ajoute via
POST direct avec thumbnailUrl, image reellement chargee (naturalWidth 480)
et positionnee dans le DOM rendu.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 21:43:25 +02:00

235 lines
7 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,
queueDownloadAllUrl,
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;
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 le téléchargement d'un fichier (ou d'un zip groupé) via un lien
* `<a>` temporaire — `target="_blank"` en filet de sécurité : si jamais
* 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 href - URL à déclencher en téléchargement.
*/
function triggerDownload(href: string): void {
const link = document.createElement('a');
link.href = href;
link.target = '_blank';
link.rel = 'noopener';
document.body.append(link);
link.click();
link.remove();
}
/**
* 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}`;
if (item.thumbnailUrl) {
const img = document.createElement('img');
img.src = item.thumbnailUrl;
img.alt = '';
img.className = 'queue-panel__item-thumb';
li.append(img);
}
const content = document.createElement('div');
content.className = 'queue-panel__item-content';
const title = document.createElement('p');
title.className = 'queue-panel__item-title';
title.textContent = item.title ?? item.url;
content.append(title);
const meta = document.createElement('p');
meta.className = 'queue-panel__item-meta';
meta.textContent = `${MODE_LABELS[item.mode.kind]} · ${STATUS_LABELS[item.status]}`;
content.append(meta);
if (isActive(item)) {
const progress = document.createElement('progress');
progress.className = 'queue-panel__item-progress';
progress.max = 100;
progress.value = item.progress;
content.append(progress);
}
if (item.status === 'failed' && item.error) {
const error = document.createElement('p');
error.className = 'queue-panel__item-error';
error.textContent = item.error;
content.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é';
content.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.target = '_blank';
link.rel = 'noopener';
link.textContent = 'Télécharger';
link.className = 'queue-panel__item-download';
actions.append(link);
}
actions.append(removeButton);
content.append(actions);
li.append(content);
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', () => {
triggerDownload(queueDownloadAllUrl());
});
refresh();
setInterval(refresh, POLL_INTERVAL_MS);
return { refresh };
}