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>
495 lines
16 KiB
TypeScript
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);
|
|
}
|