diff --git a/README.md b/README.md index aaab39d..aa3c831 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ Ce projet reprend et remplace une série de scripts `.cmd` (téléchargement vid - **Parcourir une chaîne** : recherche par chaîne (URL ou `@handle`), liste de ses vidéos/streams avec filtre par mots-clés dans le titre. - **Ajout par URL** : ajout direct d'une ou plusieurs URLs YouTube. +- **Playlists** : coller un lien de playlist (`youtube.com/playlist?list=...`) affiche toutes ses vidéos, à ajouter une par une ou d'un coup (« Tout ajouter à la file »). - **File d'attente** : chaque élément ajouté est configurable individuellement : - **Vidéo** : qualité, sous-titres (fr/en), miniature intégrée, métadonnées. - **Audio seul** : extraction dans un format courant (mp3/m4a/opus). diff --git a/client/src/api/channelApi.ts b/client/src/api/channelApi.ts index 558703a..f54c893 100644 --- a/client/src/api/channelApi.ts +++ b/client/src/api/channelApi.ts @@ -1,18 +1,4 @@ -/** Vidéo/stream listé depuis une chaîne (miroir du DTO serveur). */ -export interface ChannelVideoDto { - /** Identifiant YouTube de la vidéo. */ - id: string; - /** Titre de la vidéo. */ - title: string; - /** URL complète de la vidéo. */ - url: string; - /** URL de la miniature, si connue. */ - thumbnailUrl: string | null; - /** Durée en secondes, si connue. */ - durationSeconds: number | null; - /** Date de mise en ligne au format `YYYYMMDD`, si connue. */ - uploadDate: string | null; -} +import type { VideoDto } from './videoDto'; /** Section d'une chaîne YouTube à parcourir. */ export type ChannelSection = 'videos' | 'streams'; @@ -30,7 +16,7 @@ export async function searchChannel( channel: string, section: ChannelSection, keywords: string, -): Promise { +): Promise { const params = new URLSearchParams({ channel, section }); if (keywords.trim().length > 0) { params.set('keywords', keywords.trim()); @@ -55,7 +41,7 @@ export async function searchChannel( } try { - const body = (await response.json()) as { videos: ChannelVideoDto[] }; + const body = (await response.json()) as { videos: VideoDto[] }; return body.videos; } catch (error) { throw new Error('Réponse du serveur invalide.', { cause: error }); diff --git a/client/src/api/playlistApi.ts b/client/src/api/playlistApi.ts new file mode 100644 index 0000000..2b90790 --- /dev/null +++ b/client/src/api/playlistApi.ts @@ -0,0 +1,44 @@ +import type { VideoDto } from './videoDto'; + +/** Contenu d'une playlist, tel que renvoyé par le serveur (miroir du DTO serveur). */ +export interface PlaylistDto { + /** Titre de la playlist, s'il est connu. */ + title: string | null; + /** Vidéos de la playlist, dans l'ordre de la playlist. */ + videos: VideoDto[]; +} + +/** + * Interroge `GET /api/playlist` pour lister les vidéos d'une playlist. + * + * @param url - Lien de la playlist YouTube (`youtube.com/playlist?list=...`). + * @returns Le titre et les vidéos de la playlist. + * @throws {Error} Si la requête échoue ou si le serveur répond une erreur. + */ +export async function fetchPlaylist(url: string): Promise { + const params = new URLSearchParams({ url }); + + let response: Response; + try { + response = await fetch(`/api/playlist?${params.toString()}`); + } catch (error) { + throw new Error('Impossible de contacter le serveur.', { cause: error }); + } + + if (!response.ok) { + let message = `Échec du chargement de la playlist (${response.status}).`; + try { + const body = (await response.json()) as { error?: string }; + message = body.error ?? message; + } catch { + // Réponse d'erreur sans JSON exploitable : on garde le message par défaut. + } + throw new Error(message); + } + + try { + return (await response.json()) as PlaylistDto; + } catch (error) { + throw new Error('Réponse du serveur invalide.', { cause: error }); + } +} diff --git a/client/src/api/videoDto.ts b/client/src/api/videoDto.ts new file mode 100644 index 0000000..fadc70f --- /dev/null +++ b/client/src/api/videoDto.ts @@ -0,0 +1,15 @@ +/** Vidéo listée depuis une chaîne ou une playlist (miroir du DTO serveur). */ +export interface VideoDto { + /** Identifiant YouTube de la vidéo. */ + id: string; + /** Titre de la vidéo. */ + title: string; + /** URL complète de la vidéo. */ + url: string; + /** URL de la miniature, si connue. */ + thumbnailUrl: string | null; + /** Durée en secondes, si connue. */ + durationSeconds: number | null; + /** Date de mise en ligne au format `YYYYMMDD`, si connue. */ + uploadDate: string | null; +} diff --git a/client/src/playlistUrl.ts b/client/src/playlistUrl.ts new file mode 100644 index 0000000..359c426 --- /dev/null +++ b/client/src/playlistUrl.ts @@ -0,0 +1,36 @@ +// Mêmes domaines que ceux acceptés côté serveur (server/src/services/playlistUrl.ts, +// qui reste l'autorité : ce test n'est là que pour aiguiller la saisie). +const PLAYLIST_HOSTS: ReadonlySet = new Set([ + 'youtube.com', + 'www.youtube.com', + 'm.youtube.com', + 'music.youtube.com', +]); + +/** + * Indique si un texte saisi est un lien de playlist YouTube + * (`youtube.com/playlist?list=...`), par opposition à un lien de vidéo. + * + * Sert uniquement à choisir entre « parcourir la playlist » et « ajouter la + * vidéo » : la validation stricte reste côté serveur. Un lien de vidéo + * portant un paramètre `list` (`watch?v=...&list=...`) n'est volontairement + * pas une playlist - il désigne une vidéo, comme côté serveur. + * + * @param input - Texte saisi par l'utilisateur. + * @returns `true` si `input` ressemble à un lien de playlist YouTube. + */ +export function isPlaylistUrl(input: string): boolean { + let url: URL; + try { + url = new URL(input.trim()); + } catch { + return false; + } + + return ( + url.protocol === 'https:' && + PLAYLIST_HOSTS.has(url.hostname) && + url.pathname === '/playlist' && + (url.searchParams.get('list') ?? '') !== '' + ); +} diff --git a/client/src/views/downloadForm/DownloadForm.html b/client/src/views/downloadForm/DownloadForm.html index 56fe1ee..28e27cc 100644 --- a/client/src/views/downloadForm/DownloadForm.html +++ b/client/src/views/downloadForm/DownloadForm.html @@ -25,10 +25,10 @@
- +
@@ -53,7 +53,12 @@ - + +
    diff --git a/client/src/views/downloadForm/DownloadForm.scss b/client/src/views/downloadForm/DownloadForm.scss index 25e5435..c93274c 100644 --- a/client/src/views/downloadForm/DownloadForm.scss +++ b/client/src/views/downloadForm/DownloadForm.scss @@ -87,6 +87,29 @@ margin: 0; } + // En-tête de la liste de résultats : statut à gauche, « Tout ajouter » à + // droite, et une éventuelle erreur d'ajout sur toute la largeur en dessous. + &__results-header { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 0.4rem 0.75rem; + + &[hidden] { + display: none; + } + + button { + font-size: 0.8rem; + padding: 0.35rem 0.7rem; + } + + .download-form__error { + flex-basis: 100%; + } + } + &__results { list-style: none; margin: 0; diff --git a/client/src/views/downloadForm/DownloadForm.ts b/client/src/views/downloadForm/DownloadForm.ts index f252a5d..a4f6a48 100644 --- a/client/src/views/downloadForm/DownloadForm.ts +++ b/client/src/views/downloadForm/DownloadForm.ts @@ -1,15 +1,64 @@ -import { type ChannelSection, type ChannelVideoDto, searchChannel } from '../../api/channelApi'; +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 élément à la file d'attente. */ + /** 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(root: HTMLElement, selector: string): T { + const element = root.querySelector(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. * @@ -24,6 +73,15 @@ function setMessage(el: HTMLElement | null, message: string | null): void { 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. @@ -73,103 +131,258 @@ function wireModeToggle(root: HTMLElement): void { } /** - * Ajoute un résultat de recherche de chaîne à la file d'attente, avec - * retour visuel sur le bouton concerné. + * 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 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. + * @param row - Ligne de résultat à ajouter. + * @param mode - Mode de téléchargement à appliquer. + * @throws {Error} Si l'ajout échoue. */ -async function handleAddResult( - video: ChannelVideoDto, - formRoot: HTMLElement, - button: HTMLButtonElement, - errorEl: HTMLElement | null, - callbacks: DownloadFormCallbacks, -): Promise { - button.disabled = true; - setMessage(errorEl, null); - +async function enqueueResult(row: ResultRow, mode: EnqueueMode): Promise { + row.button.disabled = true; try { - await enqueueItem(video.url, readCurrentMode(formRoot), video.title, video.thumbnailUrl); - button.textContent = 'Ajouté ✓'; - callbacks.onItemQueued(); + await enqueueItem(row.video.url, mode, row.video.title, row.video.thumbnailUrl); + row.button.textContent = 'Ajouté ✓'; } catch (error) { - button.disabled = false; - setMessage(errorEl, error instanceof Error ? error.message : "Échec de l'ajout."); + row.button.disabled = false; + throw error; } } /** - * Affiche la liste des résultats d'une recherche de chaîne. + * 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 container - Élément `
      ` 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. + * @param video - Vidéo à afficher. + * @returns L'élément `
    • ` et son bouton « Ajouter ». */ -function renderResults( - container: HTMLElement, - videos: readonly ChannelVideoDto[], - formRoot: HTMLElement, - errorEl: HTMLElement | null, - callbacks: DownloadFormCallbacks, -): void { - container.replaceChildren(); +function renderResultItem(video: VideoDto): { + element: HTMLLIElement; + button: HTMLButtonElement; +} { + const element = document.createElement('li'); + element.className = 'download-form__result'; - 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); + 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 le formulaire d'ajout direct par URL. + * 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 wireUrlForm(root: HTMLElement, callbacks: DownloadFormCallbacks): void { +function createResultsView(root: HTMLElement, callbacks: DownloadFormCallbacks): ResultsView { + const header = requireElement(root, '[data-role="results-header"]'); + const statusEl = requireElement(root, '[data-role="results-status"]'); + const addAllButton = requireElement(root, '[data-role="add-all"]'); + const errorEl = requireElement(root, '[data-role="results-error"]'); + const list = requireElement(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 => { + 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 { + 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('form[data-form="url"]'); const errorEl = root.querySelector('[data-role="url-error"]'); if (!form) { @@ -190,11 +403,15 @@ function wireUrlForm(root: HTMLElement, callbacks: DownloadFormCallbacks): void } try { - await enqueueItem(url, readCurrentMode(root), null); + if (isPlaylistUrl(url)) { + await browsePlaylist(url, results); + } else { + await enqueueItem(url, readCurrentMode(root), null); + callbacks.onItemQueued(); + } form.reset(); - callbacks.onItemQueued(); } catch (error) { - setMessage(errorEl, error instanceof Error ? error.message : "Échec de l'ajout à la file."); + setMessage(errorEl, describeError(error, "Échec de l'ajout à la file.")); } finally { if (submitButton) { submitButton.disabled = false; @@ -204,7 +421,7 @@ function wireUrlForm(root: HTMLElement, callbacks: DownloadFormCallbacks): void form.addEventListener('submit', (event) => { handleSubmit(event).catch((error: unknown) => { - console.error('Erreur inattendue lors de l’ajout par URL', error); + console.error("Erreur inattendue lors de l'ajout par URL", error); }); }); } @@ -213,14 +430,12 @@ function wireUrlForm(root: HTMLElement, callbacks: DownloadFormCallbacks): void * Câble le formulaire de recherche de chaîne. * * @param root - Racine du formulaire monté. - * @param callbacks - Rappels du formulaire. + * @param results - Zone de résultats partagée. */ -function wireChannelForm(root: HTMLElement, callbacks: DownloadFormCallbacks): void { +function wireChannelForm(root: HTMLElement, results: ResultsView): void { const form = root.querySelector('form[data-form="channel"]'); const errorEl = root.querySelector('[data-role="channel-error"]'); - const statusEl = root.querySelector('[data-role="channel-status"]'); - const resultsEl = root.querySelector('[data-role="results"]'); - if (!form || !resultsEl) { + if (!form) { return; } @@ -235,8 +450,7 @@ function wireChannelForm(root: HTMLElement, callbacks: DownloadFormCallbacks): v } setMessage(errorEl, null); - setMessage(statusEl, 'Recherche en cours...'); - resultsEl.replaceChildren(); + results.showLoading('Recherche en cours...'); const submitButton = form.querySelector('button[type="submit"]'); if (submitButton) { submitButton.disabled = true; @@ -244,14 +458,13 @@ function wireChannelForm(root: HTMLElement, callbacks: DownloadFormCallbacks): v 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); + results.showVideos(videos, { + status: videos.length === 0 ? 'Aucun résultat.' : `${videos.length} résultat(s).`, + allowAddAll: false, + }); } catch (error) { - setMessage(statusEl, null); - setMessage(errorEl, error instanceof Error ? error.message : 'Échec de la recherche.'); + results.clear(); + setMessage(errorEl, describeError(error, 'Échec de la recherche.')); } finally { if (submitButton) { submitButton.disabled = false; @@ -267,15 +480,16 @@ function wireChannelForm(root: HTMLElement, callbacks: DownloadFormCallbacks): v } /** - * Monte le formulaire d'ajout (URL directe + recherche de chaîne) dans - * l'élément racine fourni. + * 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, callbacks); - wireChannelForm(root, callbacks); + wireUrlForm(root, results, callbacks); + wireChannelForm(root, results); } diff --git a/client/test/playlistUrl.test.ts b/client/test/playlistUrl.test.ts new file mode 100644 index 0000000..1dae93c --- /dev/null +++ b/client/test/playlistUrl.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; +import { isPlaylistUrl } from '../src/playlistUrl'; + +describe('isPlaylistUrl', () => { + it.each([ + 'https://www.youtube.com/playlist?list=PLKDZ1ig0uz-U', + 'https://youtube.com/playlist?list=PLKDZ1ig0uz-U', + 'https://m.youtube.com/playlist?list=PLKDZ1ig0uz-U', + 'https://music.youtube.com/playlist?list=OLAK5uy_abc', + 'https://www.youtube.com/playlist?si=abc&list=PLKDZ1ig0uz-U&index=2', + ' https://www.youtube.com/playlist?list=PLKDZ1ig0uz-U ', + ])('reconnaît %j comme une playlist', (input) => { + expect(isPlaylistUrl(input)).toBe(true); + }); + + it.each([ + ['un lien de vidéo', 'https://www.youtube.com/watch?v=dQw4w9WgXcQ'], + [ + 'un lien de vidéo dans une playlist', + 'https://www.youtube.com/watch?v=dQw4w9WgXcQ&list=PLabc', + ], + ['un lien court dans une playlist', 'https://youtu.be/dQw4w9WgXcQ?list=PLabc'], + ['une playlist sans identifiant', 'https://www.youtube.com/playlist'], + ['une playlist à identifiant vide', 'https://www.youtube.com/playlist?list='], + ['un autre domaine', 'https://example.com/playlist?list=PLabc'], + ['un schéma http', 'http://www.youtube.com/playlist?list=PLabc'], + ['du texte quelconque', 'pas une url'], + ['une chaîne vide', ''], + ])('ne prend pas %s pour une playlist', (_label, input) => { + expect(isPlaylistUrl(input)).toBe(false); + }); +}); diff --git a/server/src/index.ts b/server/src/index.ts index 8f764be..76ebebb 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -3,6 +3,7 @@ import Fastify, { type FastifyInstance } from 'fastify'; import { type ServerConfig, loadConfig } from './config.js'; import { channelRoutes } from './routes/channel.js'; import { healthRoutes } from './routes/health.js'; +import { playlistRoutes } from './routes/playlist.js'; import { createQueueRoutes } from './routes/queue.js'; /** @@ -22,6 +23,7 @@ async function buildServer(config: ServerConfig): Promise { }); await app.register(healthRoutes); await app.register(channelRoutes); + await app.register(playlistRoutes); await app.register(createQueueRoutes(config)); } catch (error) { app.log.error({ err: error }, "Échec de l'enregistrement des plugins Fastify"); diff --git a/server/src/routes/playlist.ts b/server/src/routes/playlist.ts new file mode 100644 index 0000000..c668ce7 --- /dev/null +++ b/server/src/routes/playlist.ts @@ -0,0 +1,54 @@ +import type { FastifyInstance, FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify'; +import { PlaylistDiscoveryService } from '../services/PlaylistDiscoveryService.js'; +import { InvalidPlaylistUrlError } from '../services/playlistUrl.js'; + +/** Paramètres de requête acceptés par `GET /api/playlist`. */ +interface GetPlaylistQuerystring { + /** Lien de la playlist YouTube à parcourir. */ + url: string; +} + +const querystringSchema = { + type: 'object', + required: ['url'], + properties: { + url: { type: 'string', minLength: 1, maxLength: 300 }, + }, +} as const; + +/** + * Plugin Fastify exposant les routes de découverte de playlist : + * `GET /api/playlist` liste les vidéos d'une playlist YouTube. + * + * @param app - Instance Fastify sur laquelle enregistrer les routes. + */ +export const playlistRoutes: FastifyPluginAsync = async (app: FastifyInstance): Promise => { + const discoveryService = new PlaylistDiscoveryService(app.log); + + app.get<{ Querystring: GetPlaylistQuerystring }>( + '/api/playlist', + { schema: { querystring: querystringSchema } }, + async ( + request: FastifyRequest<{ Querystring: GetPlaylistQuerystring }>, + reply: FastifyReply, + ) => { + try { + return await discoveryService.listPlaylist(request.query.url); + } catch (error) { + if (error instanceof InvalidPlaylistUrlError) { + return reply.status(400).send({ error: error.message }); + } + // assertValidPlaylistUrl() lève InvalidPlaylistUrlError de façon + // synchrone, avant tout appel à yt-dlp : toute autre erreur ici + // vient forcément de runYtDlp (binaire introuvable, playlist + // introuvable ou privée, sortie illisible...), qu'elle soit une + // YtDlpError ou une erreur brute - toutes sont un échec côté + // yt-dlp/YouTube. + return reply.status(502).send({ + error: + 'Échec de la récupération de la playlist depuis YouTube (yt-dlp) : elle est peut-être introuvable ou privée.', + }); + } + }, + ); +}; diff --git a/server/src/services/ChannelDiscoveryService.ts b/server/src/services/ChannelDiscoveryService.ts index c48fd8e..3cc37f5 100644 --- a/server/src/services/ChannelDiscoveryService.ts +++ b/server/src/services/ChannelDiscoveryService.ts @@ -1,25 +1,10 @@ import type { FastifyBaseLogger } from 'fastify'; +import { type VideoSummary, summarizeEntry } from './videoSummary.js'; import { runYtDlp } from './ytDlpRunner.js'; /** Section d'une chaîne YouTube à parcourir. */ export type ChannelSection = 'videos' | 'streams'; -/** Résumé d'une vidéo/stream listé depuis une chaîne. */ -export interface ChannelVideoSummary { - /** Identifiant YouTube de la vidéo. */ - readonly id: string; - /** Titre de la vidéo. */ - readonly title: string; - /** URL complète de la vidéo. */ - readonly url: string; - /** URL de la miniature, si connue. */ - readonly thumbnailUrl: string | null; - /** Durée en secondes, si connue (absente pour un live en cours). */ - readonly durationSeconds: number | null; - /** Date de mise en ligne au format `YYYYMMDD`, si connue. */ - readonly uploadDate: string | null; -} - /** * Erreur levée quand l'identifiant de chaîne fourni ne correspond à aucun * format accepté (ni handle, ni URL de chaîne YouTube). @@ -84,30 +69,6 @@ function matchesKeywords(title: string, keywords: readonly string[]): boolean { return keywords.every((keyword) => lowerTitle.includes(keyword.toLowerCase())); } -/** - * Extrait l'URL de miniature d'une entrée yt-dlp. En mode `--flat-playlist`, - * l'extracteur YouTube ne renseigne quasiment jamais le champ singulier - * `thumbnail` : il fournit uniquement `thumbnails`, un tableau d'objets - * `{url, width?, height?}` généralement trié par résolution croissante. - * - * @param entry - Entrée JSON déjà parsée. - * @returns L'URL de la miniature de meilleure résolution connue, ou `null`. - */ -function extractThumbnailUrl(entry: Record): string | null { - if (typeof entry.thumbnail === 'string') { - return entry.thumbnail; - } - - if (Array.isArray(entry.thumbnails) && entry.thumbnails.length > 0) { - const best = entry.thumbnails.at(-1) as Record | undefined; - if (best && typeof best.url === 'string') { - return best.url; - } - } - - return null; -} - /** * Convertit une ligne de sortie `yt-dlp --flat-playlist -j` en résumé de * vidéo. Retourne `null` pour toute ligne illisible ou incomplète plutôt @@ -116,29 +77,13 @@ function extractThumbnailUrl(entry: Record): string | null { * @param line - Une ligne de la sortie yt-dlp, censée contenir un objet JSON. * @returns Le résumé extrait, ou `null` si la ligne n'a pas pu être exploitée. */ -function parseEntryLine(line: string): ChannelVideoSummary | null { +function parseEntryLine(line: string): VideoSummary | null { if (line.trim().length === 0) { return null; } try { - const entry = JSON.parse(line) as Record; - const id = typeof entry.id === 'string' ? entry.id : null; - const title = typeof entry.title === 'string' ? entry.title : null; - const url = typeof entry.url === 'string' ? entry.url : null; - - if (!id || !title || !url) { - return null; - } - - return { - id, - title, - url, - thumbnailUrl: extractThumbnailUrl(entry), - durationSeconds: typeof entry.duration === 'number' ? entry.duration : null, - uploadDate: typeof entry.upload_date === 'string' ? entry.upload_date : null, - }; + return summarizeEntry(JSON.parse(line)); } catch { return null; } @@ -174,7 +119,7 @@ export class ChannelDiscoveryService { channel: string, section: ChannelSection, keywords: readonly string[] = [], - ): Promise { + ): Promise { const url = buildChannelUrl(channel, section); this.logger.info({ channel, section, keywords }, 'Découverte de chaîne : requête yt-dlp'); @@ -196,7 +141,7 @@ export class ChannelDiscoveryService { const results = stdout .split('\n') .map(parseEntryLine) - .filter((entry): entry is ChannelVideoSummary => entry !== null) + .filter((entry): entry is VideoSummary => entry !== null) .filter((entry) => matchesKeywords(entry.title, keywords)); this.logger.info( diff --git a/server/src/services/PlaylistDiscoveryService.ts b/server/src/services/PlaylistDiscoveryService.ts new file mode 100644 index 0000000..882d1fd --- /dev/null +++ b/server/src/services/PlaylistDiscoveryService.ts @@ -0,0 +1,108 @@ +import type { FastifyBaseLogger } from 'fastify'; +import { assertValidPlaylistUrl } from './playlistUrl.js'; +import { type VideoSummary, summarizeEntry } from './videoSummary.js'; +import { runYtDlp } from './ytDlpRunner.js'; + +/** + * Délai maximal accordé au listing d'une playlist : YouTube pagine par + * centaines d'entrées, donc une très longue playlist (mesuré : ~1600 + * vidéos en ~11 s) demande sensiblement plus qu'une chaîne ordinaire. + */ +const LIST_TIMEOUT_MS = 120_000; + +/** Contenu d'une playlist YouTube. */ +export interface PlaylistListing { + /** Titre de la playlist, s'il est connu. */ + readonly title: string | null; + /** Vidéos de la playlist, dans l'ordre de la playlist. */ + readonly videos: VideoSummary[]; +} + +/** + * Convertit la sortie JSON unique de `yt-dlp --flat-playlist -J` (un objet + * `{title, entries: [...]}`) en contenu de playlist. Tolère une forme + * inattendue plutôt que de lever : une playlist vide ou sans titre reste + * un résultat valide. + * + * @param parsed - Valeur JSON déjà parsée, de forme non garantie. + * @returns Le titre et les vidéos exploitables de la playlist. + */ +function toListing(parsed: unknown): PlaylistListing { + const root = + typeof parsed === 'object' && parsed !== null ? (parsed as Record) : {}; + const title = typeof root.title === 'string' && root.title.length > 0 ? root.title : null; + const entries: unknown[] = Array.isArray(root.entries) ? root.entries : []; + + const videos = entries + .map((entry) => summarizeEntry(entry)) + .filter((video): video is VideoSummary => video !== null); + + return { title, videos }; +} + +/** + * Service de découverte des vidéos d'une playlist YouTube, via + * `yt-dlp --flat-playlist -J` (énumération rapide, sans extraction complète + * de chaque vidéo). `-J` (un seul objet JSON) plutôt que `-j` (une ligne + * par entrée) : le titre de la playlist n'existe qu'au niveau racine, et une + * playlist vide reste ainsi un résultat avec son titre. + */ +export class PlaylistDiscoveryService { + private readonly logger: FastifyBaseLogger; + + /** + * @param logger - Logger utilisé pour tracer les requêtes et leurs échecs. + */ + public constructor(logger: FastifyBaseLogger) { + this.logger = logger; + } + + /** + * Liste les vidéos d'une playlist. + * + * @param url - Lien de la playlist (`youtube.com/playlist?list=...`). + * @returns Le titre et les vidéos de la playlist, dans l'ordre de la playlist. + * @throws {InvalidPlaylistUrlError} Si `url` n'est pas un lien de playlist YouTube reconnu. + * @throws {YtDlpError} Si yt-dlp échoue (playlist introuvable ou privée, réseau, etc.). + * @throws {Error} Si la sortie de yt-dlp n'est pas un JSON valide. + */ + public async listPlaylist(url: string): Promise { + const playlistUrl = assertValidPlaylistUrl(url); + + this.logger.info({ playlistUrl }, 'Découverte de playlist : requête yt-dlp'); + + let stdout: string; + try { + // `--` marque la fin des options : `playlistUrl` est reconstruite à + // partir d'un identifiant validé (jamais l'entrée brute), mais la + // défense en profondeur reste peu coûteuse. + stdout = await runYtDlp(['--flat-playlist', '-J', '--', playlistUrl], { + timeoutMs: LIST_TIMEOUT_MS, + }); + } catch (error) { + this.logger.error( + { err: error, playlistUrl }, + 'Découverte de playlist : échec de la requête yt-dlp', + ); + throw error; + } + + let parsed: unknown; + try { + parsed = JSON.parse(stdout); + } catch (error) { + this.logger.error( + { err: error, playlistUrl }, + 'Découverte de playlist : sortie yt-dlp illisible', + ); + throw new Error('Sortie yt-dlp illisible (JSON invalide).', { cause: error }); + } + + const listing = toListing(parsed); + this.logger.info( + { playlistUrl, title: listing.title, count: listing.videos.length }, + 'Découverte de playlist : requête terminée', + ); + return listing; + } +} diff --git a/server/src/services/playlistUrl.ts b/server/src/services/playlistUrl.ts new file mode 100644 index 0000000..023d671 --- /dev/null +++ b/server/src/services/playlistUrl.ts @@ -0,0 +1,67 @@ +/** + * Erreur levée quand l'URL de playlist fournie n'est pas une URL de + * playlist YouTube reconnue. + */ +export class InvalidPlaylistUrlError extends Error { + /** + * @param url - Valeur fournie par l'appelant, refusée telle quelle. + */ + public constructor(url: string) { + super(`URL de playlist YouTube invalide : "${url}"`); + this.name = 'InvalidPlaylistUrlError'; + } +} + +// Seuls les domaines YouTube eux-mêmes sont acceptés - jamais un domaine +// arbitraire : yt-dlp gère des centaines de sites via un extracteur générique +// capable de récupérer une URL quelconque, ce qui en ferait un relais SSRF +// (même raisonnement que videoUrl.ts). +const PLAYLIST_HOSTS: ReadonlySet = new Set([ + 'youtube.com', + 'www.youtube.com', + 'm.youtube.com', + 'music.youtube.com', +]); + +// Identifiant de playlist : alphabet [A-Za-z0-9_-], longueur variable selon le +// type de playlist (PL..., UU..., OLAK5uy_..., etc.), donc une plage plutôt +// qu'une longueur fixe. +const PLAYLIST_ID_PATTERN = /^[\w-]{2,64}$/; + +/** + * Valide qu'une chaîne est bien un lien de playlist YouTube + * (`youtube.com/playlist?list=`) et retourne sa forme canonique. + * + * L'URL retournée est reconstruite à partir du seul identifiant validé : + * rien de ce que l'appelant a fourni d'autre (paramètres annexes, domaine + * `music.`/`m.`, etc.) n'est jamais transmis à yt-dlp. Un lien de vidéo + * portant un paramètre `list` (`watch?v=...&list=...`) n'est volontairement + * pas une playlist : il désigne une vidéo, traitée comme telle. + * + * @param url - URL fournie par l'appelant. + * @returns L'URL canonique `https://www.youtube.com/playlist?list=`. + * @throws {InvalidPlaylistUrlError} Si `url` n'est pas un lien de playlist YouTube reconnu. + */ +export function assertValidPlaylistUrl(url: string): string { + let parsed: URL; + try { + parsed = new URL(url.trim()); + } catch { + throw new InvalidPlaylistUrlError(url); + } + + const id = parsed.searchParams.get('list'); + const isPlainYoutubeUrl = + parsed.protocol === 'https:' && + PLAYLIST_HOSTS.has(parsed.hostname) && + parsed.port === '' && + parsed.username === '' && + parsed.password === '' && + parsed.pathname === '/playlist'; + + if (!isPlainYoutubeUrl || id === null || !PLAYLIST_ID_PATTERN.test(id)) { + throw new InvalidPlaylistUrlError(url); + } + + return `https://www.youtube.com/playlist?list=${id}`; +} diff --git a/server/src/services/videoSummary.ts b/server/src/services/videoSummary.ts new file mode 100644 index 0000000..17fcae6 --- /dev/null +++ b/server/src/services/videoSummary.ts @@ -0,0 +1,72 @@ +/** Résumé d'une vidéo listée depuis une chaîne ou une playlist. */ +export interface VideoSummary { + /** Identifiant YouTube de la vidéo. */ + readonly id: string; + /** Titre de la vidéo. */ + readonly title: string; + /** URL complète de la vidéo. */ + readonly url: string; + /** URL de la miniature, si connue. */ + readonly thumbnailUrl: string | null; + /** Durée en secondes, si connue (absente pour un live en cours). */ + readonly durationSeconds: number | null; + /** Date de mise en ligne au format `YYYYMMDD`, si connue. */ + readonly uploadDate: string | null; +} + +/** + * Extrait l'URL de miniature d'une entrée yt-dlp. En mode `--flat-playlist`, + * l'extracteur YouTube ne renseigne quasiment jamais le champ singulier + * `thumbnail` : il fournit uniquement `thumbnails`, un tableau d'objets + * `{url, width?, height?}` généralement trié par résolution croissante. + * + * @param entry - Entrée JSON déjà parsée. + * @returns L'URL de la miniature de meilleure résolution connue, ou `null`. + */ +function extractThumbnailUrl(entry: Record): string | null { + if (typeof entry.thumbnail === 'string') { + return entry.thumbnail; + } + + if (Array.isArray(entry.thumbnails) && entry.thumbnails.length > 0) { + const best = entry.thumbnails.at(-1) as Record | undefined; + if (best && typeof best.url === 'string') { + return best.url; + } + } + + return null; +} + +/** + * Convertit une entrée de la sortie `yt-dlp --flat-playlist` (ligne de `-j` + * ou élément de `entries` de `-J`) en résumé de vidéo. Retourne `null` pour + * toute entrée inexploitable (pas un objet, ou sans identifiant, titre ou + * URL) plutôt que d'interrompre le traitement des autres entrées. + * + * @param raw - Valeur JSON déjà parsée, de forme non garantie. + * @returns Le résumé extrait, ou `null` si l'entrée n'a pas pu être exploitée. + */ +export function summarizeEntry(raw: unknown): VideoSummary | null { + if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) { + return null; + } + + const entry = raw as Record; + const id = typeof entry.id === 'string' ? entry.id : null; + const title = typeof entry.title === 'string' ? entry.title : null; + const url = typeof entry.url === 'string' ? entry.url : null; + + if (!id || !title || !url) { + return null; + } + + return { + id, + title, + url, + thumbnailUrl: extractThumbnailUrl(entry), + durationSeconds: typeof entry.duration === 'number' ? entry.duration : null, + uploadDate: typeof entry.upload_date === 'string' ? entry.upload_date : null, + }; +} diff --git a/server/test/playlistDiscoveryService.test.ts b/server/test/playlistDiscoveryService.test.ts new file mode 100644 index 0000000..9571af6 --- /dev/null +++ b/server/test/playlistDiscoveryService.test.ts @@ -0,0 +1,142 @@ +import type { FastifyBaseLogger } from 'fastify'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../src/services/ytDlpRunner.js', () => ({ + runYtDlp: vi.fn(), +})); + +import { PlaylistDiscoveryService } from '../src/services/PlaylistDiscoveryService.js'; +import { InvalidPlaylistUrlError } from '../src/services/playlistUrl.js'; +import { runYtDlp } from '../src/services/ytDlpRunner.js'; + +const noopLogger = { info: () => {}, error: () => {} } as unknown as FastifyBaseLogger; +const playlistUrl = 'https://www.youtube.com/playlist?list=PLKDZ1ig0uz-U'; + +// Forme réelle de `yt-dlp --flat-playlist -J` sur une playlist (relevée avec +// yt-dlp 2026.07.04) : un objet racine avec `title`, et des entrées `url` +// portant `thumbnails` (tableau, jamais `thumbnail`) et pas de `upload_date`. +const realShapeOutput = JSON.stringify({ + _type: 'playlist', + id: 'PLKDZ1ig0uz-U', + title: 'Deep clean dreams', + playlist_count: 3, + entries: [ + { + _type: 'url', + ie_key: 'Youtube', + id: '9rqQgOERpDo', + url: 'https://www.youtube.com/watch?v=9rqQgOERpDo', + title: 'Restoring an ABANDONED 1963 Split Window Corvette!', + duration: 1917, + thumbnails: [ + { url: 'https://i.ytimg.com/vi/9rqQgOERpDo/small.jpg', width: 168, height: 94 }, + { url: 'https://i.ytimg.com/vi/9rqQgOERpDo/large.jpg', width: 336, height: 188 }, + ], + }, + { + _type: 'url', + id: 'aaaaaaaaaaa', + url: 'https://www.youtube.com/watch?v=aaaaaaaaaaa', + title: 'Deuxième vidéo', + duration: 60, + thumbnails: [{ url: 'https://i.ytimg.com/vi/aaaaaaaaaaa/large.jpg' }], + }, + { _type: 'url', id: 'bbbbbbbbbbb', title: 'Entrée sans URL (indisponible)' }, + ], +}); + +beforeEach(() => { + vi.mocked(runYtDlp).mockReset(); +}); + +describe('PlaylistDiscoveryService', () => { + it('parse la sortie réelle de yt-dlp : titre, vidéos dans l’ordre, entrées inexploitables ignorées', async () => { + vi.mocked(runYtDlp).mockResolvedValue(realShapeOutput); + + const service = new PlaylistDiscoveryService(noopLogger); + const listing = await service.listPlaylist(playlistUrl); + + expect(listing).toEqual({ + title: 'Deep clean dreams', + videos: [ + { + id: '9rqQgOERpDo', + title: 'Restoring an ABANDONED 1963 Split Window Corvette!', + url: 'https://www.youtube.com/watch?v=9rqQgOERpDo', + thumbnailUrl: 'https://i.ytimg.com/vi/9rqQgOERpDo/large.jpg', + durationSeconds: 1917, + uploadDate: null, + }, + { + id: 'aaaaaaaaaaa', + title: 'Deuxième vidéo', + url: 'https://www.youtube.com/watch?v=aaaaaaaaaaa', + thumbnailUrl: 'https://i.ytimg.com/vi/aaaaaaaaaaa/large.jpg', + durationSeconds: 60, + uploadDate: null, + }, + ], + }); + }); + + it('appelle yt-dlp avec -J, l’URL canonique après "--", et un délai adapté aux longues playlists', async () => { + vi.mocked(runYtDlp).mockResolvedValue(realShapeOutput); + + const service = new PlaylistDiscoveryService(noopLogger); + await service.listPlaylist( + ' https://music.youtube.com/playlist?si=zzz&list=PLKDZ1ig0uz-U&index=2 ', + ); + + expect(runYtDlp).toHaveBeenCalledTimes(1); + expect(runYtDlp).toHaveBeenCalledWith(['--flat-playlist', '-J', '--', playlistUrl], { + timeoutMs: 120_000, + }); + }); + + it('renvoie une playlist vide avec son titre quand entries est vide', async () => { + vi.mocked(runYtDlp).mockResolvedValue( + JSON.stringify({ _type: 'playlist', title: 'Vide', entries: [] }), + ); + + const service = new PlaylistDiscoveryService(noopLogger); + + await expect(service.listPlaylist(playlistUrl)).resolves.toEqual({ title: 'Vide', videos: [] }); + }); + + it('tolère l’absence de titre et d’entries (titre null, aucune vidéo)', async () => { + vi.mocked(runYtDlp).mockResolvedValue(JSON.stringify({ _type: 'playlist' })); + + const service = new PlaylistDiscoveryService(noopLogger); + + await expect(service.listPlaylist(playlistUrl)).resolves.toEqual({ title: null, videos: [] }); + }); + + it('rejette une URL invalide sans jamais appeler yt-dlp', async () => { + const service = new PlaylistDiscoveryService(noopLogger); + + await expect( + service.listPlaylist('https://www.youtube.com/watch?v=dQw4w9WgXcQ&list=PLabc'), + ).rejects.toBeInstanceOf(InvalidPlaylistUrlError); + expect(runYtDlp).not.toHaveBeenCalled(); + }); + + it('propage l’échec de yt-dlp', async () => { + const failure = new Error('yt-dlp a échoué (code 1) : The playlist does not exist.'); + vi.mocked(runYtDlp).mockRejectedValue(failure); + + const service = new PlaylistDiscoveryService(noopLogger); + + await expect(service.listPlaylist(playlistUrl)).rejects.toBe(failure); + }); + + it('lève une erreur explicite (avec la cause) quand la sortie n’est pas du JSON', async () => { + vi.mocked(runYtDlp).mockResolvedValue('ceci n’est pas du JSON'); + + const service = new PlaylistDiscoveryService(noopLogger); + + await expect(service.listPlaylist(playlistUrl)).rejects.toMatchObject({ + message: expect.stringContaining('illisible'), + cause: expect.any(SyntaxError), + }); + }); +}); diff --git a/server/test/playlistRoute.test.ts b/server/test/playlistRoute.test.ts new file mode 100644 index 0000000..9fbd880 --- /dev/null +++ b/server/test/playlistRoute.test.ts @@ -0,0 +1,117 @@ +import Fastify from 'fastify'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../src/services/ytDlpRunner.js', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, runYtDlp: vi.fn() }; +}); + +import { playlistRoutes } from '../src/routes/playlist.js'; +import { YtDlpError, runYtDlp } from '../src/services/ytDlpRunner.js'; + +const validUrl = encodeURIComponent('https://www.youtube.com/playlist?list=PLKDZ1ig0uz-U'); + +beforeEach(() => { + vi.mocked(runYtDlp).mockReset(); +}); + +describe('GET /api/playlist', () => { + it('retourne le titre et les vidéos d’une playlist valide', async () => { + vi.mocked(runYtDlp).mockResolvedValue( + JSON.stringify({ + _type: 'playlist', + title: 'Ma playlist', + entries: [ + { + id: 'v1', + title: 'Vidéo 1', + url: 'https://www.youtube.com/watch?v=v1', + duration: 90, + thumbnails: [{ url: 'https://x/v1.jpg' }], + }, + ], + }), + ); + + const app = Fastify(); + await app.register(playlistRoutes); + + const response = await app.inject({ method: 'GET', url: `/api/playlist?url=${validUrl}` }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ + title: 'Ma playlist', + videos: [ + { + id: 'v1', + title: 'Vidéo 1', + url: 'https://www.youtube.com/watch?v=v1', + thumbnailUrl: 'https://x/v1.jpg', + durationSeconds: 90, + uploadDate: null, + }, + ], + }); + }); + + it.each([ + ['un domaine étranger', 'https://evil.com/playlist?list=PLabc'], + ['un lien de vidéo', 'https://www.youtube.com/watch?v=dQw4w9WgXcQ'], + ['un lien de vidéo avec list', 'https://www.youtube.com/watch?v=dQw4w9WgXcQ&list=PLabc'], + ['du texte quelconque', 'pas une url'], + ])('renvoie 400 pour %s, sans appeler yt-dlp', async (_label, url) => { + const app = Fastify(); + await app.register(playlistRoutes); + + const response = await app.inject({ + method: 'GET', + url: `/api/playlist?url=${encodeURIComponent(url)}`, + }); + + expect(response.statusCode).toBe(400); + expect(runYtDlp).not.toHaveBeenCalled(); + }); + + it('renvoie 400 si le paramètre url est manquant (validation de schéma)', async () => { + const app = Fastify(); + await app.register(playlistRoutes); + + const response = await app.inject({ method: 'GET', url: '/api/playlist' }); + + expect(response.statusCode).toBe(400); + }); + + it('renvoie 502 quand yt-dlp échoue (ex. playlist introuvable)', async () => { + vi.mocked(runYtDlp).mockRejectedValue(new YtDlpError(1, 'The playlist does not exist.')); + + const app = Fastify(); + await app.register(playlistRoutes); + + const response = await app.inject({ method: 'GET', url: `/api/playlist?url=${validUrl}` }); + + expect(response.statusCode).toBe(502); + }); + + it('renvoie 502 (pas 500) quand le binaire yt-dlp est introuvable (ENOENT)', async () => { + const spawnError = Object.assign(new Error('spawn yt-dlp ENOENT'), { code: 'ENOENT' }); + vi.mocked(runYtDlp).mockRejectedValue(spawnError); + + const app = Fastify(); + await app.register(playlistRoutes); + + const response = await app.inject({ method: 'GET', url: `/api/playlist?url=${validUrl}` }); + + expect(response.statusCode).toBe(502); + }); + + it('renvoie 502 (pas 500) quand la sortie de yt-dlp est illisible', async () => { + vi.mocked(runYtDlp).mockResolvedValue('pas du JSON'); + + const app = Fastify(); + await app.register(playlistRoutes); + + const response = await app.inject({ method: 'GET', url: `/api/playlist?url=${validUrl}` }); + + expect(response.statusCode).toBe(502); + }); +}); diff --git a/server/test/playlistUrl.test.ts b/server/test/playlistUrl.test.ts new file mode 100644 index 0000000..5518cb1 --- /dev/null +++ b/server/test/playlistUrl.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest'; +import { InvalidPlaylistUrlError, assertValidPlaylistUrl } from '../src/services/playlistUrl.js'; + +describe('assertValidPlaylistUrl', () => { + it.each([ + ['https://www.youtube.com/playlist?list=PLKDZ1ig0uz-U', 'PLKDZ1ig0uz-U'], + ['https://youtube.com/playlist?list=PLKDZ1ig0uz-U', 'PLKDZ1ig0uz-U'], + ['https://m.youtube.com/playlist?list=PLKDZ1ig0uz-U', 'PLKDZ1ig0uz-U'], + ['https://music.youtube.com/playlist?list=OLAK5uy_abcDEF123', 'OLAK5uy_abcDEF123'], + [ + ' https://www.youtube.com/playlist?list=UUBR8-60-B28hp2BmDPdntcQ ', + 'UUBR8-60-B28hp2BmDPdntcQ', + ], + ])('accepte %j et retourne l’URL canonique', (input, id) => { + expect(assertValidPlaylistUrl(input)).toBe(`https://www.youtube.com/playlist?list=${id}`); + }); + + it('ignore les paramètres annexes, quel que soit leur ordre', () => { + expect( + assertValidPlaylistUrl( + 'https://youtube.com/playlist?si=abc123&list=PLxyz_-9&index=4&foo=bar', + ), + ).toBe('https://www.youtube.com/playlist?list=PLxyz_-9'); + }); + + it.each([ + ['un schéma http', 'http://www.youtube.com/playlist?list=PLabc'], + ['un autre domaine', 'https://evil.com/playlist?list=PLabc'], + ['un sous-domaine trompeur', 'https://youtube.com.evil.com/playlist?list=PLabc'], + ['un domaine préfixé', 'https://evilyoutube.com/playlist?list=PLabc'], + ['un domaine avec point final', 'https://www.youtube.com./playlist?list=PLabc'], + ['des identifiants dans l’URL', 'https://user:pass@www.youtube.com/playlist?list=PLabc'], + ['un port explicite', 'https://www.youtube.com:8443/playlist?list=PLabc'], + [ + 'un lien de vidéo avec paramètre list', + 'https://www.youtube.com/watch?v=dQw4w9WgXcQ&list=PLabc', + ], + ['un lien court avec paramètre list', 'https://youtu.be/dQw4w9WgXcQ?list=PLabc'], + ['un chemin voisin', 'https://www.youtube.com/playlists?list=PLabc'], + ['un chemin plus profond', 'https://www.youtube.com/playlist/extra?list=PLabc'], + ['un paramètre list absent', 'https://www.youtube.com/playlist'], + ['un paramètre list vide', 'https://www.youtube.com/playlist?list='], + ['un identifiant trop court', 'https://www.youtube.com/playlist?list=P'], + ['un identifiant trop long', `https://www.youtube.com/playlist?list=${'a'.repeat(65)}`], + ['un identifiant avec un slash encodé', 'https://www.youtube.com/playlist?list=PL%2Fabc'], + ['un identifiant avec une espace encodée', 'https://www.youtube.com/playlist?list=PL%20abc'], + ['un identifiant ressemblant à une option', 'https://www.youtube.com/playlist?list=--exec=rm'], + ['un schéma javascript', 'javascript:alert(1)'], + ['un schéma file', 'file:///etc/passwd'], + ['du texte quelconque', 'pas une url'], + ['une chaîne vide', ''], + ])('rejette %s', (_label, input) => { + expect(() => assertValidPlaylistUrl(input)).toThrow(InvalidPlaylistUrlError); + }); +}); diff --git a/server/test/videoSummary.test.ts b/server/test/videoSummary.test.ts new file mode 100644 index 0000000..4bd889e --- /dev/null +++ b/server/test/videoSummary.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest'; +import { summarizeEntry } from '../src/services/videoSummary.js'; + +const base = { id: 'abc', title: 'Titre', url: 'https://www.youtube.com/watch?v=abc' }; + +describe('summarizeEntry', () => { + it('extrait les champs connus et met null pour les champs absents', () => { + expect(summarizeEntry(base)).toEqual({ + ...base, + thumbnailUrl: null, + durationSeconds: null, + uploadDate: null, + }); + }); + + it('lit durée et date de mise en ligne quand elles sont bien typées', () => { + const summary = summarizeEntry({ ...base, duration: 120, upload_date: '20260101' }); + + expect(summary?.durationSeconds).toBe(120); + expect(summary?.uploadDate).toBe('20260101'); + }); + + it('ignore une durée ou une date de mauvais type', () => { + const summary = summarizeEntry({ ...base, duration: '120', upload_date: 20260101 }); + + expect(summary?.durationSeconds).toBeNull(); + expect(summary?.uploadDate).toBeNull(); + }); + + it('préfère le champ singulier thumbnail au tableau thumbnails', () => { + const summary = summarizeEntry({ + ...base, + thumbnail: 'https://x/single.jpg', + thumbnails: [{ url: 'https://x/array.jpg' }], + }); + + expect(summary?.thumbnailUrl).toBe('https://x/single.jpg'); + }); + + it('retombe sur la dernière miniature du tableau thumbnails', () => { + const summary = summarizeEntry({ + ...base, + thumbnails: [{ url: 'https://x/small.jpg' }, { url: 'https://x/large.jpg' }], + }); + + expect(summary?.thumbnailUrl).toBe('https://x/large.jpg'); + }); + + it('ignore un tableau thumbnails vide ou mal formé', () => { + expect(summarizeEntry({ ...base, thumbnails: [] })?.thumbnailUrl).toBeNull(); + expect(summarizeEntry({ ...base, thumbnails: [{ width: 10 }] })?.thumbnailUrl).toBeNull(); + expect(summarizeEntry({ ...base, thumbnails: [null] })?.thumbnailUrl).toBeNull(); + }); + + it.each([ + ['sans id', { title: 'T', url: 'https://x' }], + ['sans titre', { id: 'a', url: 'https://x' }], + ['sans url', { id: 'a', title: 'T' }], + ['avec un id non textuel', { id: 42, title: 'T', url: 'https://x' }], + ])('retourne null pour une entrée %s', (_label, entry) => { + expect(summarizeEntry(entry)).toBeNull(); + }); + + it.each([null, undefined, 'texte', 42, [], [base]])( + 'retourne null pour une valeur qui n’est pas un objet (%j)', + (value) => { + expect(summarizeEntry(value)).toBeNull(); + }, + ); +}); diff --git a/spec/architecture.md b/spec/architecture.md index c4fa222..ccd185f 100644 --- a/spec/architecture.md +++ b/spec/architecture.md @@ -19,23 +19,29 @@ Pas de `docker-compose.yml` pour l'instant (une seule image, pas encore de dépe - Fastify 5, TypeScript strict, ESM (`"type": "module"`, imports relatifs en `.js`, résolution `NodeNext`). - `src/index.ts` : bootstrap (plugins, écoute, arrêt propre SIGINT/SIGTERM). - `src/config.ts` : configuration runtime depuis l'environnement (`PORT`, `HOST`, `CLIENT_DIST_PATH`, `TMP_DIR`, `MAX_CONCURRENT_DOWNLOADS`). Les valeurs par défaut de `CLIENT_DIST_PATH`/`TMP_DIR` sont résolues depuis l'emplacement du module (`import.meta.url`), pas `process.cwd()` : reste correct quel que soit le répertoire depuis lequel le process est lancé (ex. `npm --prefix server run dev` depuis la racine du dépôt, utilisé par `.claude/launch.json` pour la prévisualisation - non versionné, voir `.gitignore`). -- `src/routes/` : routes Fastify enregistrées comme plugins (`health.ts`, `channel.ts`, `queue.ts`). Chaque domaine métier suit le même principe : un plugin de route fin sous `src/routes/`, qui délègue à un service sous `src/services/`. +- `src/routes/` : routes Fastify enregistrées comme plugins (`health.ts`, `channel.ts`, `playlist.ts`, `queue.ts`). Chaque domaine métier suit le même principe : un plugin de route fin sous `src/routes/`, qui délègue à un service sous `src/services/`. - `src/services/` : - `ytDlpRunner.ts` : exécution bas niveau de yt-dlp (`node:child_process.spawn`, jamais de shell - arguments toujours passés en tableau, jamais concaténés dans une chaîne, pour exclure toute injection de commande). Supporte le suivi ligne par ligne de stdout (`onStdoutLine`, pour la progression) et l'annulation via `AbortSignal`. - - `ChannelDiscoveryService.ts` : liste les vidéos/streams d'une chaîne (`yt-dlp --flat-playlist -j`), filtre par mots-clés sur le titre. Valide strictement le handle/URL de chaîne en entrée (premier caractère forcément alphanumérique) et ajoute un séparateur `--` avant l'URL passée à yt-dlp, en défense en profondeur contre l'injection d'options. Miniature (`thumbnailUrl`) lue en priorité sur `thumbnail` (champ singulier), sinon sur le dernier élément de `thumbnails` (tableau) : en `--flat-playlist`, l'extracteur YouTube ne renseigne quasiment jamais le champ singulier, seulement le tableau (trié par résolution croissante) - un bug réel (miniatures jamais affichées) passé inaperçu en test tant que le mock ne reproduisait que la forme théorique (`thumbnail`) et jamais la forme réelle. + - `ChannelDiscoveryService.ts` : liste les vidéos/streams d'une chaîne (`yt-dlp --flat-playlist -j`), filtre par mots-clés sur le titre. Valide strictement le handle/URL de chaîne en entrée (premier caractère forcément alphanumérique) et ajoute un séparateur `--` avant l'URL passée à yt-dlp, en défense en profondeur contre l'injection d'options. Une ligne de sortie = une entrée, convertie par `videoSummary.ts`. + - `PlaylistDiscoveryService.ts` : liste les vidéos d'une playlist (`yt-dlp --flat-playlist -J -- `, délai de 120 s : ~1600 vidéos mesurées en ~11 s, YouTube pagine par centaines). `-J` (un seul objet JSON `{title, entries: [...]}`) plutôt que `-j` (une ligne par entrée, comme pour les chaînes) : le titre de la playlist n'existe qu'à la racine, et une playlist vide reste un résultat valide avec son titre. Les entrées sont converties par le même `videoSummary.ts` que pour les chaînes. + - `playlistUrl.ts` : valide qu'un lien est bien une playlist YouTube (`youtube.com/playlist?list=`, domaines `youtube.com`/`www.`/`m.`/`music.`, https, sans port ni identifiants) et retourne l'URL **canonique** reconstruite depuis le seul identifiant validé (`[\w-]{2,64}` - longueur variable selon le type de playlist) : rien d'autre de ce que l'appelant a fourni n'est jamais transmis à yt-dlp. Même garde anti-SSRF que `videoUrl.ts`. Un lien de vidéo portant un paramètre `list` (`watch?v=...&list=...`) n'est volontairement **pas** une playlist : il désigne une vidéo (et `downloadPlan.ts` passe déjà `--no-playlist`, donc seule cette vidéo est téléchargée). + - `videoSummary.ts` : type `VideoSummary` et `summarizeEntry()`, conversion partagée d'une entrée `--flat-playlist` (chaîne ou playlist) en résumé de vidéo ; retourne `null` pour une entrée inexploitable plutôt que d'interrompre le reste. Miniature (`thumbnailUrl`) lue en priorité sur `thumbnail` (champ singulier), sinon sur le dernier élément de `thumbnails` (tableau) : en `--flat-playlist`, l'extracteur YouTube ne renseigne quasiment jamais le champ singulier, seulement le tableau (trié par résolution croissante) - un bug réel (miniatures jamais affichées) passé inaperçu en test tant que le mock ne reproduisait que la forme théorique (`thumbnail`) et jamais la forme réelle. `upload_date` est absent en pratique des entrées `--flat-playlist` (il n'y a qu'un `timestamp`) : `uploadDate` vaut donc `null`. - `videoUrl.ts` : valide qu'une URL de vidéo est bien sur `youtube.com`/`youtu.be` (formats `watch`/`shorts`/`live`/court) avant de la transmettre à yt-dlp. N'accepte aucun autre domaine : yt-dlp sait suivre des centaines de sites via un extracteur générique capable de récupérer une URL quelconque, ce qui en ferait un relais SSRF si cette validation n'existait pas. - `downloadPlan.ts` : fonctions pures construisant les arguments yt-dlp selon le mode (vidéo/audio/miniature) et parsant la progression (`[download] 42.0% ...`) depuis stdout. Toujours un séparateur `--` avant l'URL (même défense qu'au-dessus). - `QueueService.ts` : file d'attente en mémoire, exécution avec concurrence bornée (`MAX_CONCURRENT_DOWNLOADS`, défaut 2). Chaque élément a son propre sous-dossier sous `TMP_DIR`, supprimé après échec/annulation ou après livraison du fichier au client (`consumeCompletedFile`/`consumeAllCompletedFiles` -> `onSent`, logique de livraison partagée via `buildDelivery`) : le serveur ne conserve jamais durablement les fichiers. `title`/`thumbnailUrl` sont de simples informations transmises par l'appelant à `enqueue()` (purement informatif, jamais utilisés pour construire la commande yt-dlp) et renvoyées telles quelles dans la vue publique. - Le serveur sert aussi les fichiers statiques de `client/dist` via `@fastify/static`. -- Tests : Vitest (`server/test/`). Trois niveaux : fonctions pures testées directement (`downloadPlan.ts`, `videoUrl.ts`), services testés avec `ytDlpRunner` mocké mais un vrai système de fichiers temporaire (`QueueService.test.ts`, plus réaliste qu'un mock fs), et routes testées via `app.inject()`. Les trois niveaux comptent : un test de service avec un exemple d'entrée mal choisi avait laissé passer une regex de validation trop permissive (`--exec` accepté), repérée seulement par le test de route avec un autre exemple (voir `channelDiscoveryService.test.ts`/`channelRoute.test.ts`). Helpers communs (`waitFor`, `extractOutputDir`) factorisés dans `test/helpers.ts` - `waitFor` accepte un prédicat sync ou async (un prédicat async passé à une version qui n'attendait que du sync serait toujours vu comme "vrai" immédiatement, puisqu'un objet Promise est truthy). +- Tests : Vitest (`server/test/`). Trois niveaux : fonctions pures testées directement (`downloadPlan.ts`, `videoUrl.ts`), services testés avec `ytDlpRunner` mocké mais un vrai système de fichiers temporaire (`QueueService.test.ts`, plus réaliste qu'un mock fs), et routes testées via `app.inject()`. Les trois niveaux comptent : un test de service avec un exemple d'entrée mal choisi avait laissé passer une regex de validation trop permissive (`--exec` accepté), repérée seulement par le test de route avec un autre exemple (voir `channelDiscoveryService.test.ts`/`channelRoute.test.ts`). Les mocks de sortie yt-dlp doivent reproduire la **forme réelle** observée, pas la forme théorique (voir le bug des miniatures ci-dessus) : les fixtures de `playlistDiscoveryService.test.ts` reprennent la sortie réelle de `yt-dlp --flat-playlist -J` (yt-dlp 2026.07.04) - relevée en lançant le vrai binaire (`downloader/yt-dlp.exe`, non versionné) via une entrée locale de `.claude/launch.json` (`YTDLP_PATH`), aussi utilisée pour vérifier la fonctionnalité de bout en bout dans le navigateur. Helpers communs (`waitFor`, `extractOutputDir`) factorisés dans `test/helpers.ts` - `waitFor` accepte un prédicat sync ou async (un prédicat async passé à une version qui n'attendait que du sync serait toujours vu comme "vrai" immédiatement, puisqu'un objet Promise est truthy). ## Frontend (`client/`) - TypeScript vanilla, pas de framework. Chaque vue = un dossier avec `Vue.ts` / `Vue.html` / `Vue.scss` (gabarit importé comme chaîne brute, style Sass importé et compilé). - Build via `build.mjs` (esbuild), pas d'outil de build plus lourd (Vite, webpack) : le script compile `src/app.ts` en bundle ESM, copie `src/index.html`, et compile/regroupe les `.scss` importés en un `app.css` voisin (plugin esbuild maison qui délègue au paquet `sass`). Cible ES2022 (nécessaire pour `Error.cause`, utilisé dans `api/`). - Pas de router : `src/shell/Shell.ts` monte deux vues fixes côte à côte (`views/downloadForm/`, `views/queuePanel/`), reliées par un callback simple (`onItemQueued`) plutôt qu'un bus d'événements généraliste - au-delà de deux vues qui coexistent sur un seul écran, un vrai routeur deviendrait nécessaire. -- `src/api/` : clients fetch vers le backend (`channelApi.ts`, `queueApi.ts`), avec leurs propres types DTO - pas de paquet de types partagé avec `server/` (monorepo simple, pas de workspace npm). -- `src/views/downloadForm/` : formulaire d'ajout, deux entrées possibles (URL directe, ou recherche de chaîne via `GET /api/channel` puis ajout par résultat) partageant un même sélecteur de mode (vidéo/audio/miniature + options). Le mode courant est lu depuis le DOM à l'ajout, pas stocké en état séparé. +- `src/api/` : clients fetch vers le backend (`channelApi.ts`, `playlistApi.ts`, `queueApi.ts`), avec leurs propres types DTO (`videoDto.ts` pour la vidéo listée, commune aux chaînes et aux playlists) - pas de paquet de types partagé avec `server/` (monorepo simple, pas de workspace npm). +- `src/playlistUrl.ts` : `isPlaylistUrl()`, fonction pure qui aiguille la saisie (lien de playlist -> parcourir ses vidéos, sinon -> ajouter la vidéo). Les mêmes domaines que le serveur, qui reste l'autorité de validation ; un lien `watch?v=...&list=...` n'est volontairement pas une playlist (comme côté serveur). +- `src/views/downloadForm/` : formulaire d'ajout, deux entrées possibles partageant un même sélecteur de mode (vidéo/audio/miniature + options) : (1) un champ « URL (vidéo ou playlist) » - un lien de vidéo est ajouté directement à la file, un lien de playlist charge ses vidéos via `GET /api/playlist` ; (2) la recherche de chaîne via `GET /api/channel`. Les deux alimentent **une même zone de résultats** (`createResultsView`) : statut, une ligne par vidéo avec son bouton « Ajouter », et pour une playlist seulement un bouton « Tout ajouter à la file ». Le mode courant est lu depuis le DOM à l'ajout, pas stocké en état séparé (lu **une seule fois** au début d'un ajout groupé, pour ne pas mélanger les modes au sein d'un lot). + - « Tout ajouter à la file » enchaîne des `POST /api/queue` **séquentiels** côté client (pas d'endpoint groupé : un corps de plusieurs milliers d'éléments dépasserait la limite par défaut de Fastify, et le rythme séquentiel conserve l'ordre de la playlist dans la file). Les lignes déjà ajoutées (bouton désactivé) sont ignorées, donc aucun doublon ; un échec sur une vidéo n'interrompt pas les suivantes - en fin de lot le nombre d'échecs et le premier message d'erreur sont affichés, seules les lignes en échec redeviennent ajoutables, et relancer le bouton ne réajoute que celles-là. Un compteur de « génération » (incrémenté à chaque remplacement de la liste) empêche un lot encore en cours sur une ancienne liste de modifier l'affichage de la nouvelle. Pas de boîte de confirmation : le nombre de vidéos est affiché dans le statut juste à côté du bouton. + - Miniatures de résultats en `loading="lazy"` : une playlist ou une chaîne peut compter des centaines de vidéos (1607 mesurées), sans quoi chaque miniature serait demandée d'un coup. Pas appliqué dans la file d'attente, dont la liste est reconstruite à chaque sondage. - `src/views/queuePanel/` : sonde `GET /api/queue` toutes les 1,5s (pas de push temps réel pour l'instant, voir Statut) et affiche chaque élément (miniature si connue, titre ou URL à défaut, statut, barre de progression, erreur). Un même bouton retire l'élément quel que soit son état - libellé "Annuler" s'il est en attente/en cours, "Supprimer" une fois terminé (`DELETE /api/queue/:id` gère les deux cas côté serveur). Lien "Télécharger" natif uniquement si un fichier est encore disponible ; bouton "Tout télécharger (.zip)" dans l'en-tête (visible dès qu'au moins un fichier est prêt) qui déclenche un unique téléchargement zip via `GET /api/queue/download-all` - remplace une première version qui déclenchait un téléchargement par fichier avec un décalage entre chacun (fonctionnait, mais le zip évite complètement le blocage navigateur sur téléchargements multiples plutôt que de le contourner). Tous les liens de téléchargement (individuel et groupé) portent `target="_blank" rel="noopener"` : filet de sécurité si jamais rien n'est disponible côté serveur au moment du clic (état changé entre le rendu et le clic), l'éventuelle réponse d'erreur s'ouvre dans un nouvel onglet plutôt que de remplacer l'application. Expose `refresh()` pour un rafraîchissement immédiat après un ajout. - Piège CSS relevé en testant dans un vrai navigateur (pas seulement les tests) : un attribut `hidden` combiné à une règle `display: flex` sur le même sélecteur se fait écraser par cette règle (même spécificité, la feuille de l'auteur gagne sur celle du user-agent) - toujours ajouter `&[hidden] { display: none; }` quand un conteneur togglé porte aussi un `display` explicite. - Layout en hauteur pleine page (`html`/`body`/`.shell` à `height:100%`/`100vh`, `overflow:hidden` sur `body`) : la page elle-même ne défile jamais, seules `download-form__results` et `queue-panel__list` le font (chacune `flex:1 1 0; min-height:0; overflow-y:auto` dans un parent qui propage `height:100%`/`min-height:0` depuis `.shell__panel`). `min-height: 0` est nécessaire à *chaque* niveau flex descendant - un enfant flex ne rétrécit jamais sous la taille de son contenu par défaut, ce qui ferait déborder la page entière au lieu de faire défiler juste la liste. Chaque vue neutralise le grow/shrink de ses enfants directs non-scrollables via `> * { flex: 0 0 auto; }`, puis la règle plus spécifique de l'élément scrollable (même spécificité, déclarée après donc prioritaire) le fait remplir l'espace restant. @@ -46,6 +52,7 @@ Pas de `docker-compose.yml` pour l'instant (une seule image, pas encore de dépe - `GET /health` : statut du service (utilisé par le `HEALTHCHECK` Docker). - `GET /api/channel?channel=<@handle|URL>§ion=&keywords=` : liste les vidéos/streams d'une chaîne, filtrés par mots-clés (ET, insensible à la casse) sur le titre. `section` par défaut `videos`. Réponses : `200 { videos: [...] }`, `400` si `channel` est absent/invalide, `502` si yt-dlp échoue. +- `GET /api/playlist?url=` : liste les vidéos d'une playlist YouTube, dans l'ordre de la playlist. Réponses : `200 { title, videos: [...] }` (`title` vaut `null` si inconnu, `videos` est vide pour une playlist vide - même forme de vidéo que `/api/channel`), `400` si `url` est absent ou n'est pas un lien de playlist YouTube (y compris un lien de vidéo, même avec un paramètre `list`), `502` si yt-dlp échoue (playlist introuvable ou privée, binaire absent, sortie illisible). - `POST /api/queue` : ajoute un élément (`{ url, title?, thumbnailUrl?, mode: "video"|"audio"|"thumbnail", subtitles?, embedThumbnail?, audioFormat? }`). `title`/`thumbnailUrl` sont purement informatifs (affichage), jamais utilisés pour construire la commande yt-dlp. `201 { item }`, `400` si l'URL n'est pas une URL de vidéo YouTube reconnue. - `GET /api/queue` : liste tous les éléments (`{ items: [...] }`), avec leur `status` (`pending`/`running`/`completed`/`failed`/`cancelled`), `progress` (0-100, best-effort) et `error`. - `DELETE /api/queue/:id` : supprime un élément (`204`), quel que soit son état - s'il était en attente/en cours, il est d'abord annulé, puis retiré de la liste dans tous les cas (dossier de sortie éventuel nettoyé). `404` seulement si l'identifiant est inconnu. @@ -65,5 +72,5 @@ Biome.js à la racine (`biome.json`), applique aux deux paquets (`server/src`, ` ## Statut -- Fait : infrastructure (serveur, Docker, CI), découverte de chaîne (`GET /api/channel`), file d'attente + téléchargement vidéo/audio/miniature avec concurrence bornée (`/api/queue*`), frontend complet (formulaire d'ajout + file d'attente, sondage 1,5s). +- Fait : infrastructure (serveur, Docker, CI), découverte de chaîne (`GET /api/channel`) et de playlist (`GET /api/playlist`, ajout par vidéo ou d'un coup), file d'attente + téléchargement vidéo/audio/miniature avec concurrence bornée (`/api/queue*`), frontend complet (formulaire d'ajout + file d'attente, sondage 1,5s). - À venir (PR successives) : notifications temps réel (WebSocket, progression poussée plutôt que sondage), tests client sur le câblage DOM (jsdom), sélection de qualité vidéo (actuellement fixe : meilleur mp4 disponible).