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>
235 lines
7 KiB
TypeScript
235 lines
7 KiB
TypeScript
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 };
|
||
}
|