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>
281 lines
8.9 KiB
TypeScript
281 lines
8.9 KiB
TypeScript
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);
|
||
}
|