youtubeDownloader/client/src/views/downloadForm/DownloadForm.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

281 lines
8.9 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 ChannelSection, type ChannelVideoDto, searchChannel } from '../../api/channelApi';
import { type EnqueueMode, enqueueItem } from '../../api/queueApi';
import { formatDuration, formatUploadDate } from '../../format';
import downloadFormTemplate from './DownloadForm.html';
import './DownloadForm.scss';
/** Rappels fournis par l'appelant pour réagir aux actions du formulaire. */
export interface DownloadFormCallbacks {
/** Appelé juste après l'ajout réussi d'un élément à la file d'attente. */
onItemQueued: () => void;
}
/**
* Affiche ou efface un message dans un élément de statut/erreur.
*
* @param el - Élément cible, ou `null` s'il n'a pas été trouvé dans le gabarit.
* @param message - Message à afficher, ou `null` pour masquer l'élément.
*/
function setMessage(el: HTMLElement | null, message: string | null): void {
if (!el) {
return;
}
el.hidden = message === null;
el.textContent = message ?? '';
}
/**
* Lit le mode de téléchargement et ses options depuis les contrôles
* actuellement sélectionnés dans le formulaire.
*
* @param root - Racine du formulaire monté.
* @returns Le mode à utiliser pour le prochain élément ajouté.
*/
function readCurrentMode(root: HTMLElement): EnqueueMode {
const selected =
root.querySelector<HTMLInputElement>('input[name="mode"]:checked')?.value ?? 'video';
if (selected === 'audio') {
const audioFormat =
(root.querySelector<HTMLSelectElement>('[data-field="audioFormat"]')?.value as
| 'mp3'
| 'm4a'
| 'opus'
| undefined) ?? 'mp3';
return { mode: 'audio', audioFormat };
}
if (selected === 'thumbnail') {
return { mode: 'thumbnail' };
}
return {
mode: 'video',
subtitles: root.querySelector<HTMLInputElement>('[data-field="subtitles"]')?.checked ?? false,
embedThumbnail:
root.querySelector<HTMLInputElement>('[data-field="embedThumbnail"]')?.checked ?? false,
};
}
/**
* Affiche/masque les panneaux d'options quand le mode sélectionné change.
*
* @param root - Racine du formulaire monté.
*/
function wireModeToggle(root: HTMLElement): void {
const fieldset = root.querySelector<HTMLFieldSetElement>('.download-form__mode');
const optionPanels = root.querySelectorAll<HTMLElement>('[data-mode-options]');
fieldset?.addEventListener('change', () => {
const selected = root.querySelector<HTMLInputElement>('input[name="mode"]:checked')?.value;
for (const panel of optionPanels) {
panel.hidden = panel.dataset.modeOptions !== selected;
}
});
}
/**
* Ajoute un résultat de recherche de chaîne à la file d'attente, avec
* retour visuel sur le bouton concerné.
*
* @param video - Vidéo à ajouter.
* @param formRoot - Racine du formulaire (pour lire le mode courant).
* @param button - Bouton "Ajouter" cliqué, pour le désactiver pendant la requête.
* @param errorEl - Élément où afficher une éventuelle erreur.
* @param callbacks - Rappels du formulaire.
*/
async function handleAddResult(
video: ChannelVideoDto,
formRoot: HTMLElement,
button: HTMLButtonElement,
errorEl: HTMLElement | null,
callbacks: DownloadFormCallbacks,
): Promise<void> {
button.disabled = true;
setMessage(errorEl, null);
try {
await enqueueItem(video.url, readCurrentMode(formRoot), video.title, video.thumbnailUrl);
button.textContent = 'Ajouté ✓';
callbacks.onItemQueued();
} catch (error) {
button.disabled = false;
setMessage(errorEl, error instanceof Error ? error.message : "Échec de l'ajout.");
}
}
/**
* Affiche la liste des résultats d'une recherche de chaîne.
*
* @param container - Élément `<ul>` dans lequel rendre les résultats.
* @param videos - Vidéos à afficher.
* @param formRoot - Racine du formulaire (pour lire le mode courant lors d'un ajout).
* @param errorEl - Élément où afficher une éventuelle erreur d'ajout.
* @param callbacks - Rappels du formulaire.
*/
function renderResults(
container: HTMLElement,
videos: readonly ChannelVideoDto[],
formRoot: HTMLElement,
errorEl: HTMLElement | null,
callbacks: DownloadFormCallbacks,
): void {
container.replaceChildren();
for (const video of videos) {
const item = document.createElement('li');
item.className = 'download-form__result';
if (video.thumbnailUrl) {
const img = document.createElement('img');
img.src = video.thumbnailUrl;
img.alt = '';
img.className = 'download-form__result-thumb';
item.append(img);
}
const info = document.createElement('div');
info.className = 'download-form__result-info';
const title = document.createElement('p');
title.className = 'download-form__result-title';
title.textContent = video.title;
info.append(title);
const meta = document.createElement('p');
meta.className = 'download-form__result-meta';
meta.textContent = [formatDuration(video.durationSeconds), formatUploadDate(video.uploadDate)]
.filter((part) => part.length > 0)
.join(' · ');
info.append(meta);
item.append(info);
const addButton = document.createElement('button');
addButton.type = 'button';
addButton.textContent = 'Ajouter';
addButton.addEventListener('click', () => {
handleAddResult(video, formRoot, addButton, errorEl, callbacks).catch((error: unknown) => {
console.error('Erreur inattendue lors de l’ajout depuis la recherche', error);
});
});
item.append(addButton);
container.append(item);
}
}
/**
* Câble le formulaire d'ajout direct par URL.
*
* @param root - Racine du formulaire monté.
* @param callbacks - Rappels du formulaire.
*/
function wireUrlForm(root: HTMLElement, callbacks: DownloadFormCallbacks): void {
const form = root.querySelector<HTMLFormElement>('form[data-form="url"]');
const errorEl = root.querySelector<HTMLElement>('[data-role="url-error"]');
if (!form) {
return;
}
const handleSubmit = async (event: SubmitEvent): Promise<void> => {
event.preventDefault();
const url = form.querySelector<HTMLInputElement>('input[name="url"]')?.value.trim() ?? '';
if (!url) {
return;
}
setMessage(errorEl, null);
const submitButton = form.querySelector<HTMLButtonElement>('button[type="submit"]');
if (submitButton) {
submitButton.disabled = true;
}
try {
await enqueueItem(url, readCurrentMode(root), null);
form.reset();
callbacks.onItemQueued();
} catch (error) {
setMessage(errorEl, error instanceof Error ? error.message : "Échec de l'ajout à la file.");
} finally {
if (submitButton) {
submitButton.disabled = false;
}
}
};
form.addEventListener('submit', (event) => {
handleSubmit(event).catch((error: unknown) => {
console.error('Erreur inattendue lors de l’ajout par URL', error);
});
});
}
/**
* Câble le formulaire de recherche de chaîne.
*
* @param root - Racine du formulaire monté.
* @param callbacks - Rappels du formulaire.
*/
function wireChannelForm(root: HTMLElement, callbacks: DownloadFormCallbacks): void {
const form = root.querySelector<HTMLFormElement>('form[data-form="channel"]');
const errorEl = root.querySelector<HTMLElement>('[data-role="channel-error"]');
const statusEl = root.querySelector<HTMLElement>('[data-role="channel-status"]');
const resultsEl = root.querySelector<HTMLElement>('[data-role="results"]');
if (!form || !resultsEl) {
return;
}
const handleSubmit = async (event: SubmitEvent): Promise<void> => {
event.preventDefault();
const formData = new FormData(form);
const channel = String(formData.get('channel') ?? '').trim();
const section = String(formData.get('section') ?? 'videos') as ChannelSection;
const keywords = String(formData.get('keywords') ?? '');
if (!channel) {
return;
}
setMessage(errorEl, null);
setMessage(statusEl, 'Recherche en cours...');
resultsEl.replaceChildren();
const submitButton = form.querySelector<HTMLButtonElement>('button[type="submit"]');
if (submitButton) {
submitButton.disabled = true;
}
try {
const videos = await searchChannel(channel, section, keywords);
setMessage(
statusEl,
videos.length === 0 ? 'Aucun résultat.' : `${videos.length} résultat(s).`,
);
renderResults(resultsEl, videos, root, errorEl, callbacks);
} catch (error) {
setMessage(statusEl, null);
setMessage(errorEl, error instanceof Error ? error.message : 'Échec de la recherche.');
} finally {
if (submitButton) {
submitButton.disabled = false;
}
}
};
form.addEventListener('submit', (event) => {
handleSubmit(event).catch((error: unknown) => {
console.error('Erreur inattendue lors de la recherche de chaîne', error);
});
});
}
/**
* Monte le formulaire d'ajout (URL directe + recherche de chaîne) dans
* l'élément racine fourni.
*
* @param root - Élément DOM dans lequel injecter le formulaire.
* @param callbacks - Rappels invoqués sur les actions du formulaire.
*/
export function mountDownloadForm(root: HTMLElement, callbacks: DownloadFormCallbacks): void {
root.innerHTML = downloadFormTemplate;
wireModeToggle(root);
wireUrlForm(root, callbacks);
wireChannelForm(root, callbacks);
}