youtubeDownloader/client/src/views/downloadForm/DownloadForm.ts
Nicolas f0dec86b19
Some checks failed
Publier l'image Docker / test (pull_request) Failing after 4s
Publier l'image Docker / publish (pull_request) Has been skipped
feat: playlists YouTube (parcourir, ajouter une par une ou d'un coup)
Coller un lien de playlist (youtube.com/playlist?list=...) dans le champ
URL affiche toutes ses videos dans la meme zone de resultats que la
recherche de chaine ; chaque ligne a son bouton "Ajouter", et un bouton
"Tout ajouter a la file" ajoute le reste d'un coup.

Serveur :
- GET /api/playlist (PlaylistDiscoveryService) : yt-dlp --flat-playlist
  -J. -J plutot que -j : le titre de la playlist n'existe qu'a la racine,
  et une playlist vide reste un resultat valide.
- playlistUrl.ts : n'accepte que les liens youtube.com/playlist?list=<id>
  et reconstruit l'URL canonique depuis le seul identifiant valide (rien
  d'autre de l'entree n'atteint yt-dlp). Un lien watch?v=...&list=...
  reste une video (--no-playlist etait deja passe au telechargement).
- videoSummary.ts : conversion d'une entree --flat-playlist en resume de
  video, extraite de ChannelDiscoveryService pour etre partagee (chaine
  et playlist parsent desormais identiquement). ChannelVideoSummary
  devient VideoSummary (et ChannelVideoDto -> VideoDto cote client).

Client :
- Le champ URL accepte video ou playlist (isPlaylistUrl aiguille) ; zone
  de resultats partagee (createResultsView).
- "Tout ajouter" : POST /api/queue sequentiels (ordre conserve, pas de
  doublon avec les lignes deja ajoutees). Un echec n'interrompt pas le
  lot : nombre d'echecs + premier message affiches, seules les lignes en
  echec redeviennent ajoutables. Le mode est lu une seule fois pour tout
  le lot.
- Miniatures de resultats en loading="lazy" (une playlist peut compter
  des centaines de videos).

Verifie contre le vrai yt-dlp (2026.07.04) : forme reelle de -J relevee
(fixtures de test calquees dessus), playlist de 1607 videos listee en
~11 s (delai fixe a 120 s). Teste dans le navigateur reel : playlist de
13 videos, ajout unitaire, "tout ajouter" (13 elements, aucun doublon),
echec partiel simule + reprise, playlist inexistante, lien video avec
list=, recherche de chaine (non-regression).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 14:55:38 +02:00

495 lines
16 KiB
TypeScript

import { type ChannelSection, searchChannel } from '../../api/channelApi';
import { type PlaylistDto, fetchPlaylist } from '../../api/playlistApi';
import { type EnqueueMode, enqueueItem } from '../../api/queueApi';
import type { VideoDto } from '../../api/videoDto';
import { formatDuration, formatUploadDate } from '../../format';
import { isPlaylistUrl } from '../../playlistUrl';
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 ou plusieurs éléments à la file d'attente. */
onItemQueued: () => void;
}
/** Ligne de la liste de résultats : une vidéo et son bouton d'ajout. */
interface ResultRow {
/** Vidéo affichée sur cette ligne. */
readonly video: VideoDto;
/** Bouton « Ajouter » de la ligne (désactivé une fois la vidéo ajoutée, ou pendant son ajout). */
readonly button: HTMLButtonElement;
}
/** Options d'affichage d'une liste de résultats. */
interface ShowVideosOptions {
/** Message de statut affiché au-dessus de la liste (ex. « 12 résultat(s). »). */
readonly status: string;
/** Affiche le bouton « Tout ajouter à la file ». */
readonly allowAddAll: boolean;
}
/** Zone de résultats partagée entre la recherche de chaîne et le chargement d'une playlist. */
interface ResultsView {
/** Vide la liste et affiche un message d'attente. */
showLoading: (message: string) => void;
/** Vide et masque toute la zone de résultats. */
clear: () => void;
/** Affiche les vidéos fournies, avec leur message de statut. */
showVideos: (videos: readonly VideoDto[], options: ShowVideosOptions) => void;
}
const ADD_ALL_LABEL = 'Tout ajouter à la file';
/**
* Récupère un élément du gabarit, en échouant franchement s'il manque : le
* gabarit est statique, son absence est une erreur de programmation, pas un
* cas à ignorer silencieusement.
*
* @param root - Racine du formulaire monté.
* @param selector - Sélecteur CSS de l'élément attendu.
* @returns L'élément trouvé.
* @throws {Error} Si le gabarit ne contient pas l'élément.
*/
function requireElement<T extends HTMLElement>(root: HTMLElement, selector: string): T {
const element = root.querySelector<T>(selector);
if (!element) {
throw new Error(`Élément introuvable dans le gabarit : ${selector}`);
}
return element;
}
/**
* 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 ?? '';
}
/**
* @param error - Erreur capturée (de type inconnu).
* @param fallback - Message à utiliser si `error` n'est pas une `Error`.
* @returns Le message à présenter à l'utilisateur.
*/
function describeError(error: unknown, fallback: string): string {
return error instanceof Error ? error.message : fallback;
}
/**
* 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 la vidéo d'une ligne à la file d'attente et marque son bouton
* « Ajouté ✓ ». Le bouton est désactivé dès le début (pas de double ajout
* pendant la requête) et réactivé si l'ajout échoue.
*
* @param row - Ligne de résultat à ajouter.
* @param mode - Mode de téléchargement à appliquer.
* @throws {Error} Si l'ajout échoue.
*/
async function enqueueResult(row: ResultRow, mode: EnqueueMode): Promise<void> {
row.button.disabled = true;
try {
await enqueueItem(row.video.url, mode, row.video.title, row.video.thumbnailUrl);
row.button.textContent = 'Ajouté ✓';
} catch (error) {
row.button.disabled = false;
throw error;
}
}
/**
* Construit l'élément de liste d'une vidéo (miniature, titre, durée/date et
* bouton d'ajout), sans y brancher de comportement.
*
* @param video - Vidéo à afficher.
* @returns L'élément `<li>` et son bouton « Ajouter ».
*/
function renderResultItem(video: VideoDto): {
element: HTMLLIElement;
button: HTMLButtonElement;
} {
const element = document.createElement('li');
element.className = 'download-form__result';
if (video.thumbnailUrl) {
const img = document.createElement('img');
img.src = video.thumbnailUrl;
img.alt = '';
// Une playlist ou une chaîne peut compter des centaines de vidéos :
// sans chargement différé, chaque miniature serait demandée d'un coup.
img.loading = 'lazy';
img.className = 'download-form__result-thumb';
element.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);
element.append(info);
const button = document.createElement('button');
button.type = 'button';
button.textContent = 'Ajouter';
element.append(button);
return { element, button };
}
/**
* Câble la zone de résultats partagée : liste de vidéos avec un bouton
* « Ajouter » par ligne, et, quand la liste le permet (playlist), un bouton
* « Tout ajouter à la file ».
*
* @param root - Racine du formulaire monté.
* @param callbacks - Rappels du formulaire.
* @returns La vue permettant d'y afficher des résultats.
*/
function createResultsView(root: HTMLElement, callbacks: DownloadFormCallbacks): ResultsView {
const header = requireElement<HTMLElement>(root, '[data-role="results-header"]');
const statusEl = requireElement<HTMLElement>(root, '[data-role="results-status"]');
const addAllButton = requireElement<HTMLButtonElement>(root, '[data-role="add-all"]');
const errorEl = requireElement<HTMLElement>(root, '[data-role="results-error"]');
const list = requireElement<HTMLElement>(root, '[data-role="results"]');
let rows: ResultRow[] = [];
// Incrémenté à chaque remplacement de la liste : une opération d'ajout
// encore en cours sur l'ancienne liste ne doit plus toucher à l'affichage
// (bouton, message d'erreur) de la nouvelle.
let generation = 0;
const clear = (): void => {
generation += 1;
rows = [];
list.replaceChildren();
addAllButton.hidden = true;
setMessage(errorEl, null);
header.hidden = true;
};
const showLoading = (message: string): void => {
clear();
setMessage(statusEl, message);
header.hidden = false;
};
const addOne = (row: ResultRow): void => {
const startedIn = generation;
setMessage(errorEl, null);
enqueueResult(row, readCurrentMode(root))
.then(() => {
callbacks.onItemQueued();
})
.catch((error: unknown) => {
if (startedIn === generation) {
setMessage(errorEl, describeError(error, "Échec de l'ajout."));
}
});
};
const addAll = async (): Promise<void> => {
const startedIn = generation;
const pending = rows.filter((row) => !row.button.disabled);
if (pending.length === 0) {
return;
}
// Mode lu une seule fois : changer de mode en cours d'ajout ne doit pas
// mélanger les modes au sein d'un même lot.
const mode = readCurrentMode(root);
addAllButton.disabled = true;
setMessage(errorEl, null);
let processed = 0;
let failures = 0;
let firstFailure: string | null = null;
// Ajouts séquentiels : l'ordre de la playlist est conservé dans la file.
for (const row of pending) {
if (row.button.disabled) {
// Ajoutée entre-temps depuis son propre bouton.
continue;
}
processed += 1;
if (startedIn === generation) {
addAllButton.textContent = `Ajout… ${processed}/${pending.length}`;
}
try {
await enqueueResult(row, mode);
} catch (error) {
failures += 1;
firstFailure ??= describeError(error, "Échec de l'ajout.");
}
}
callbacks.onItemQueued();
if (startedIn !== generation) {
return;
}
if (failures === 0) {
addAllButton.textContent = 'Tout ajouté ✓';
return;
}
addAllButton.disabled = false;
addAllButton.textContent = ADD_ALL_LABEL;
setMessage(
errorEl,
`${failures} vidéo(s) n'ont pas pu être ajoutée(s) : ${firstFailure ?? 'erreur inconnue'}`,
);
};
addAllButton.addEventListener('click', () => {
addAll().catch((error: unknown) => {
console.error("Erreur inattendue lors de l'ajout groupé", error);
});
});
const showVideos = (videos: readonly VideoDto[], options: ShowVideosOptions): void => {
clear();
setMessage(statusEl, options.status);
header.hidden = false;
addAllButton.hidden = !options.allowAddAll;
addAllButton.disabled = false;
addAllButton.textContent = ADD_ALL_LABEL;
const fragment = document.createDocumentFragment();
for (const video of videos) {
const { element, button } = renderResultItem(video);
const row: ResultRow = { video, button };
button.addEventListener('click', () => {
addOne(row);
});
rows.push(row);
fragment.append(element);
}
list.replaceChildren(fragment);
};
return { showLoading, clear, showVideos };
}
/**
* Décrit une playlist chargée pour la ligne de statut de la liste.
*
* @param playlist - Playlist chargée.
* @returns Un texte du type « Playlist « Titre » : 12 vidéo(s). ».
*/
function describePlaylist(playlist: PlaylistDto): string {
const name = playlist.title ? `Playlist « ${playlist.title} »` : 'Playlist';
return playlist.videos.length === 0
? `${name} : aucune vidéo.`
: `${name} : ${playlist.videos.length} vidéo(s).`;
}
/**
* Charge une playlist et affiche ses vidéos dans la zone de résultats.
*
* @param url - Lien de la playlist.
* @param results - Zone de résultats où afficher les vidéos.
* @throws {Error} Si le chargement échoue (la zone de résultats est alors vidée).
*/
async function browsePlaylist(url: string, results: ResultsView): Promise<void> {
results.showLoading('Chargement de la playlist...');
let playlist: PlaylistDto;
try {
playlist = await fetchPlaylist(url);
} catch (error) {
results.clear();
throw error;
}
results.showVideos(playlist.videos, {
status: describePlaylist(playlist),
allowAddAll: playlist.videos.length > 0,
});
}
/**
* Câble le formulaire d'ajout par URL : un lien de vidéo est ajouté
* directement à la file, un lien de playlist affiche ses vidéos dans la zone
* de résultats (à ajouter une par une ou d'un coup).
*
* @param root - Racine du formulaire monté.
* @param results - Zone de résultats partagée.
* @param callbacks - Rappels du formulaire.
*/
function wireUrlForm(
root: HTMLElement,
results: ResultsView,
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 {
if (isPlaylistUrl(url)) {
await browsePlaylist(url, results);
} else {
await enqueueItem(url, readCurrentMode(root), null);
callbacks.onItemQueued();
}
form.reset();
} catch (error) {
setMessage(errorEl, describeError(error, "É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 results - Zone de résultats partagée.
*/
function wireChannelForm(root: HTMLElement, results: ResultsView): void {
const form = root.querySelector<HTMLFormElement>('form[data-form="channel"]');
const errorEl = root.querySelector<HTMLElement>('[data-role="channel-error"]');
if (!form) {
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);
results.showLoading('Recherche en cours...');
const submitButton = form.querySelector<HTMLButtonElement>('button[type="submit"]');
if (submitButton) {
submitButton.disabled = true;
}
try {
const videos = await searchChannel(channel, section, keywords);
results.showVideos(videos, {
status: videos.length === 0 ? 'Aucun résultat.' : `${videos.length} résultat(s).`,
allowAddAll: false,
});
} catch (error) {
results.clear();
setMessage(errorEl, describeError(error, 'É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 ou de playlist, et 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;
const results = createResultsView(root, callbacks);
wireModeToggle(root);
wireUrlForm(root, results, callbacks);
wireChannelForm(root, results);
}