Compare commits

..

8 commits

Author SHA1 Message Date
47cea3704e Merge branch 'main' of https://git.dev.kyuno.fr/kyuno/youtubeDownloader
All checks were successful
Publier l'image Docker / test (push) Successful in 25s
Publier l'image Docker / publish (push) Successful in 45s
2026-09-19 19:02:13 +02:00
ad477fa035 Merge pull request 'feat: playlists YouTube (parcourir, ajouter une par une ou d'un coup)' (#10) from feature/playlist-support into main
All checks were successful
Publier l'image Docker / test (push) Successful in 24s
Publier l'image Docker / publish (push) Successful in 3m4s
Reviewed-on: #10
2026-09-19 15:49:37 +02:00
374cfbb192 feat: recherche YouTube globale, avec defilement infini
Nouveau formulaire "Recherche YouTube" (mots-cles + limite optionnelle),
branche sur la zone de resultats existante (miniatures, bouton "Ajouter"
par ligne ; pas de "Tout ajouter", comme demande).

- Avec une limite : tous les resultats d'un coup (une seule page).
- Sans limite : defilement infini. Un element sentinelle en fin de liste,
  observe par un IntersectionObserver (racine = la liste, marge 300 px),
  charge la page suivante ; il est rearme apres chaque page, affiche
  chargement / erreur avec "Reessayer" / "Fin des resultats", et une page
  en echec ne fait pas perdre les precedentes.

Serveur (GET/POST/DELETE /api/search) : une "session" = un seul yt-dlp
(--flat-playlist --lazy-playlist -j -- ytsearch1000:<requete>) servi page
par page depuis un tampon. Pourquoi pas un appel yt-dlp par page - mesure
avec le vrai yt-dlp : l'ordre des resultats de YouTube n'est pas
deterministe meme pour deux appels identiques (5/20 aux memes positions,
69 % de recouvrement sur 100), donc paginer en relancant la recherche
donnait 15 a 30 % de doublons des la 3e-4e page et des trous ; YouTube
s'arrete vers ~590 resultats ; ~7 % de doublons internes (dedoublonnes a
l'ingestion) ; sans --lazy-playlist yt-dlp n'imprime rien avant la fin
(~23 s), avec il emet au fil des pages (20 en ~2,3 s).

Le parcours n'avance qu'a la demande : quand le tampon a 20 resultats
d'avance, la lecture de stdout est suspendue (nouvelle option onSpawn de
runYtDlp) ; le tube se remplit et yt-dlp bloque (mesure : plus aucune
ligne pendant 10 s de pause). On n'aspire donc pas ~590 resultats, et ~30
requetes vers YouTube, quand l'utilisateur ne defile pas.

Cycle de vie : 4 sessions simultanees max (la moins recemment utilisee est
fermee), expiration a 10 min d'inactivite / 30 min de vie, fermeture des
que la derniere page est servie, sur DELETE (liste remplacee cote client),
au pagehide du client, et a l'arret du serveur. Requete validee (1 a 200
caracteres, sans caractere de controle) et transmise en un seul argument
ytsearchN:<requete>, jamais interpretable comme une option.

Mise en page : ce troisieme formulaire laissait a la liste ~104 px (une
ligne) a 1366x650. Une liste non vide garde min-height 13rem et la colonne
du formulaire defile au lieu de rogner (la page ne defile toujours pas).

Verifie : contre le vrai yt-dlp (page tamponnee en 4 ms, page 200 apres
12 s d'inactivite en ~4 s : la suspension tient ; 29 pages -> 576 videos,
576 uniques ; processus fermes a la fin / sur DELETE / au plafond) et dans
le navigateur (580 resultats sans doublon jusqu'a "Fin des resultats",
requetes de page uniques malgre deux signaux simultanes, echec + reprise,
session expiree, remplacement de la recherche, mode limite, ajout d'un
resultat a la file, pagehide, playlist et chaine en non-regression). Le
volet IntersectionObserver a ete pilote par un observateur factice : le
panneau de navigation n'affichait plus de frames (rAF/IO natifs muets).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 15:43:46 +02:00
f0dec86b19 feat: playlists YouTube (parcourir, ajouter une par une ou d'un coup)
Some checks failed
Publier l'image Docker / test (pull_request) Failing after 4s
Publier l'image Docker / publish (pull_request) Has been skipped
Coller un lien de playlist (youtube.com/playlist?list=...) dans le champ
URL affiche toutes ses videos dans la meme zone de resultats que la
recherche de chaine ; chaque ligne a son bouton "Ajouter", et un bouton
"Tout ajouter a la file" ajoute le reste d'un coup.

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 14:55:38 +02:00
383e6767a2 feat: theme sombre/clair selon la preference navigateur
All checks were successful
Publier l'image Docker / test (push) Successful in 23s
Publier l'image Docker / publish (push) Successful in 42s
Un seul bloc @media (prefers-color-scheme: dark) dans base.scss
redefinit les custom properties CSS existantes (--color-*) - tout le
reste du CSS de l'app y puise deja, rien d'autre a toucher. Pas de
bascule manuelle demandee.

Verifie en navigateur reel dans les deux modes (emulation
prefers-color-scheme), y compris les etats de couleur de la file
d'attente (echec/termine/en cours) qui restent lisibles en sombre.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 21:46:25 +02:00
a8cd7ae907 fix: miniatures manquantes (resultats de recherche + file d'attente)
Some checks failed
Publier l'image Docker / test (pull_request) Failing after 4s
Publier l'image Docker / publish (pull_request) Has been skipped
Deux causes distinctes au meme symptome (aucune ligne de liste n'affichait
de miniature) :

- ChannelDiscoveryService : en --flat-playlist, l'extracteur YouTube de
  yt-dlp ne renseigne quasiment jamais le champ singulier `thumbnail`,
  seulement `thumbnails` (tableau, trie par resolution croissante). Le
  parsing ne lisait que `thumbnail` -> thumbnailUrl toujours null. Passe
  inapercu en test car le mock ne reproduisait que la forme theorique,
  jamais la forme reelle observee en prod. extractThumbnailUrl() lit
  desormais aussi `thumbnails`, en repli.
- QueuePanel (file d'attente) : aucun champ thumbnailUrl n'existait sur
  QueueItem/QueueItemDto - fonctionnalite jamais implementee, pas de
  regression. Ajoutee de bout en bout : POST /api/queue accepte
  thumbnailUrl (purement informatif, jamais transmis a yt-dlp),
  QueueService la conserve et l'expose, DownloadForm la transmet depuis
  les resultats de recherche, QueuePanel l'affiche (miniature 96x54 a
  gauche de chaque ligne).

Verifie en navigateur reel (pas seulement les tests) : element ajoute via
POST direct avec thumbnailUrl, image reellement chargee (naturalWidth 480)
et positionnee dans le DOM rendu.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 21:43:25 +02:00
25c6c7c9e4 Merge branch 'fix/layout-scroll-and-thumbnails' into main (rattrapage : contenait la PR #7 mergée par erreur dessus au lieu de main)
All checks were successful
Publier l'image Docker / test (push) Successful in 22s
Publier l'image Docker / publish (push) Successful in 48s
2026-09-17 21:33:44 +02:00
f67a138cda Merge pull request 'fix(client): page sans overflow, listes scrollables, miniatures agrandies' (#6) from fix/layout-scroll-and-thumbnails into main
All checks were successful
Publier l'image Docker / test (push) Successful in 21s
Publier l'image Docker / publish (push) Successful in 26s
Reviewed-on: #6
2026-09-16 23:24:27 +02:00
43 changed files with 3268 additions and 179 deletions

View file

@ -8,6 +8,8 @@ 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. - **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. - **Ajout par URL** : ajout direct d'une ou plusieurs URLs YouTube.
- **Recherche YouTube** : recherche globale par mots-clés, avec une limite de résultats optionnelle ; sans limite, la liste se prolonge en défilant jusqu'à la fin des résultats de 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 : - **File d'attente** : chaque élément ajouté est configurable individuellement :
- **Vidéo** : qualité, sous-titres (fr/en), miniature intégrée, métadonnées. - **Vidéo** : qualité, sous-titres (fr/en), miniature intégrée, métadonnées.
- **Audio seul** : extraction dans un format courant (mp3/m4a/opus). - **Audio seul** : extraction dans un format courant (mp3/m4a/opus).

View file

@ -1,18 +1,4 @@
/** Vidéo/stream listé depuis une chaîne (miroir du DTO serveur). */ import type { VideoDto } from './videoDto';
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;
}
/** Section d'une chaîne YouTube à parcourir. */ /** Section d'une chaîne YouTube à parcourir. */
export type ChannelSection = 'videos' | 'streams'; export type ChannelSection = 'videos' | 'streams';
@ -30,7 +16,7 @@ export async function searchChannel(
channel: string, channel: string,
section: ChannelSection, section: ChannelSection,
keywords: string, keywords: string,
): Promise<ChannelVideoDto[]> { ): Promise<VideoDto[]> {
const params = new URLSearchParams({ channel, section }); const params = new URLSearchParams({ channel, section });
if (keywords.trim().length > 0) { if (keywords.trim().length > 0) {
params.set('keywords', keywords.trim()); params.set('keywords', keywords.trim());
@ -55,7 +41,7 @@ export async function searchChannel(
} }
try { try {
const body = (await response.json()) as { videos: ChannelVideoDto[] }; const body = (await response.json()) as { videos: VideoDto[] };
return body.videos; return body.videos;
} catch (error) { } catch (error) {
throw new Error('Réponse du serveur invalide.', { cause: error }); throw new Error('Réponse du serveur invalide.', { cause: error });

View file

@ -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<PlaylistDto> {
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 });
}
}

View file

@ -12,6 +12,7 @@ export interface QueueItemDto {
id: string; id: string;
url: string; url: string;
title: string | null; title: string | null;
thumbnailUrl: string | null;
mode: QueueItemMode; mode: QueueItemMode;
status: QueueItemStatus; status: QueueItemStatus;
progress: number; progress: number;
@ -48,6 +49,7 @@ async function readErrorMessage(response: Response): Promise<string> {
* @param url - URL de la vidéo YouTube à télécharger. * @param url - URL de la vidéo YouTube à télécharger.
* @param mode - Mode de téléchargement et ses options. * @param mode - Mode de téléchargement et ses options.
* @param title - Titre connu de la vidéo, s'il y en a un (affichage uniquement). * @param title - Titre connu de la vidéo, s'il y en a un (affichage uniquement).
* @param thumbnailUrl - URL de miniature connue, s'il y en a une (affichage uniquement).
* @returns L'élément créé. * @returns L'élément créé.
* @throws {Error} Si la requête échoue ou si le serveur refuse l'ajout. * @throws {Error} Si la requête échoue ou si le serveur refuse l'ajout.
*/ */
@ -55,13 +57,19 @@ export async function enqueueItem(
url: string, url: string,
mode: EnqueueMode, mode: EnqueueMode,
title: string | null, title: string | null,
thumbnailUrl: string | null = null,
): Promise<QueueItemDto> { ): Promise<QueueItemDto> {
let response: Response; let response: Response;
try { try {
response = await fetch('/api/queue', { response = await fetch('/api/queue', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url, title: title ?? undefined, ...mode }), body: JSON.stringify({
url,
title: title ?? undefined,
thumbnailUrl: thumbnailUrl ?? undefined,
...mode,
}),
}); });
} catch (error) { } catch (error) {
throw new Error('Impossible de contacter le serveur.', { cause: error }); throw new Error('Impossible de contacter le serveur.', { cause: error });

126
client/src/api/searchApi.ts Normal file
View file

@ -0,0 +1,126 @@
import type { VideoPage } from './videoDto';
/** Recherche ouverte côté serveur : son identifiant et sa première page. */
export interface SearchStart extends VideoPage {
/** Identifiant à fournir pour lire les pages suivantes et fermer la recherche. */
id: string;
}
/**
* Extrait le message d'erreur d'une réponse HTTP en échec, avec un message
* de repli si le corps n'est pas un JSON exploitable.
*
* @param response - Réponse HTTP dont le statut n'est pas OK.
* @param fallback - Message à utiliser si le corps ne fournit pas d'erreur.
* @returns Le message d'erreur à afficher.
*/
async function readErrorMessage(response: Response, fallback: string): Promise<string> {
try {
const body = (await response.json()) as { error?: string };
return body.error ?? fallback;
} catch {
return fallback;
}
}
/**
* Ouvre une recherche YouTube via `POST /api/search` et retourne sa première
* page. Le serveur garde la recherche ouverte pour servir les pages suivantes
* (voir {@link fetchSearchPage}) ; la fermer avec {@link closeSearch}.
*
* @param query - Mots-clés de la recherche.
* @param max - Nombre maximal de résultats au total, ou `null` pour aucune limite.
* @param limit - Taille de la première page.
* @returns L'identifiant de la recherche et sa première page.
* @throws {Error} Si la requête échoue ou si le serveur répond une erreur.
*/
export async function startSearch(
query: string,
max: number | null,
limit: number,
): Promise<SearchStart> {
let response: Response;
try {
response = await fetch('/api/search', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query, limit, max: max ?? undefined }),
});
} catch (error) {
throw new Error('Impossible de contacter le serveur.', { cause: error });
}
if (!response.ok) {
throw new Error(
await readErrorMessage(response, `Échec de la recherche (${response.status}).`),
);
}
try {
return (await response.json()) as SearchStart;
} catch (error) {
throw new Error('Réponse du serveur invalide.', { cause: error });
}
}
/**
* Lit une page d'une recherche ouverte, via `GET /api/search/:id`.
*
* @param id - Identifiant retourné par {@link startSearch}.
* @param offset - Position du premier résultat de la page (le `nextOffset` précédent).
* @param limit - Nombre de résultats demandés.
* @returns La page et la position de la suivante.
* @throws {Error} Si la requête échoue, ou si la recherche a expiré côté serveur.
*/
export async function fetchSearchPage(
id: string,
offset: number,
limit: number,
): Promise<VideoPage> {
const params = new URLSearchParams({ offset: String(offset), limit: String(limit) });
let response: Response;
try {
response = await fetch(`/api/search/${encodeURIComponent(id)}?${params.toString()}`);
} catch (error) {
throw new Error('Impossible de contacter le serveur.', { cause: error });
}
if (!response.ok) {
throw new Error(
await readErrorMessage(response, `Échec du chargement des résultats (${response.status}).`),
);
}
try {
return (await response.json()) as VideoPage;
} catch (error) {
throw new Error('Réponse du serveur invalide.', { cause: error });
}
}
/**
* Ferme une recherche côté serveur (`DELETE /api/search/:id`), ce qui libère
* son processus yt-dlp. Une recherche déjà fermée ou expirée (`404`) n'est
* pas une erreur.
*
* @param id - Identifiant de la recherche à fermer.
* @throws {Error} Si le serveur est injoignable ou répond une erreur autre que « introuvable ».
*/
export async function closeSearch(id: string): Promise<void> {
let response: Response;
try {
response = await fetch(`/api/search/${encodeURIComponent(id)}`, {
method: 'DELETE',
keepalive: true,
});
} catch (error) {
throw new Error('Impossible de contacter le serveur.', { cause: error });
}
if (!response.ok && response.status !== 404) {
throw new Error(
await readErrorMessage(response, `Échec de la fermeture (${response.status}).`),
);
}
}

View file

@ -0,0 +1,23 @@
/** 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;
}
/** Une page de vidéos, avec la position de la suivante (miroir du DTO serveur). */
export interface VideoPage {
/** Vidéos de la page. */
videos: VideoDto[];
/** Position à demander pour la page suivante, ou `null` s'il n'y a plus de résultats. */
nextOffset: number | null;
}

36
client/src/playlistUrl.ts Normal file
View file

@ -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<string> = 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') ?? '') !== ''
);
}

16
client/src/searchLimit.ts Normal file
View file

@ -0,0 +1,16 @@
/**
* Lit le champ « Limite » du formulaire de recherche.
*
* @param raw - Contenu brut du champ.
* @returns Le nombre maximal de résultats voulu (entier >= 1), ou `null`
* quand il n'y a pas de limite (champ vide, ou valeur qui n'est pas un
* entier positif) - la recherche défile alors à l'infini.
*/
export function parseSearchLimit(raw: string): number | null {
const trimmed = raw.trim();
if (!/^\d+$/.test(trimmed)) {
return null;
}
const value = Number.parseInt(trimmed, 10);
return value >= 1 ? value : null;
}

View file

@ -18,6 +18,27 @@
--radius: 8px; --radius: 8px;
} }
// Thème sombre selon la préférence système (`prefers-color-scheme`), pas
// de bascule manuelle demandée : mêmes custom properties, valeurs
// réadaptées pour le contraste (tout le reste du CSS n'y touche jamais
// directement, voir plus bas - seule cette feuille définit des couleurs).
@media (prefers-color-scheme: dark) {
:root {
--color-bg: #16181d;
--color-surface: #1f2229;
--color-border: #33363f;
--color-text: #e7e8ea;
--color-text-muted: #9498a3;
--color-primary: #5b84ff;
--color-primary-hover: #7899ff;
--color-danger-bg: #3a2222;
--color-danger-text: #e59a97;
--color-danger-border: #6b3937;
--color-success-border: #4f9c72;
--color-neutral-border: #494d59;
}
}
* { * {
box-sizing: border-box; box-sizing: border-box;
} }
@ -52,6 +73,7 @@ button {
input[type='text'], input[type='text'],
input[type='url'], input[type='url'],
input[type='number'],
select { select {
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: var(--radius); border-radius: var(--radius);

View file

@ -25,15 +25,28 @@
<form class="download-form__form" data-form="url"> <form class="download-form__form" data-form="url">
<label class="download-form__field"> <label class="download-form__field">
URL directe URL (vidéo ou playlist)
<input type="url" name="url" placeholder="https://www.youtube.com/watch?v=..." required /> <input type="url" name="url" placeholder="https://www.youtube.com/..." required />
</label> </label>
<button type="submit">Ajouter à la file</button> <button type="submit">Ajouter</button>
</form> </form>
<p class="download-form__error" data-role="url-error" hidden></p> <p class="download-form__error" data-role="url-error" hidden></p>
<hr /> <hr />
<form class="download-form__form" data-form="search">
<label class="download-form__field">
Recherche YouTube
<input type="text" name="query" placeholder="Mots-clés" maxlength="200" required />
</label>
<label class="download-form__field download-form__field--narrow">
Limite (optionnel)
<input type="number" name="limit" min="1" max="1000" step="1" inputmode="numeric" placeholder="Illimité" />
</label>
<button type="submit">Rechercher</button>
</form>
<p class="download-form__error" data-role="search-error" hidden></p>
<form class="download-form__form" data-form="channel"> <form class="download-form__form" data-form="channel">
<label class="download-form__field"> <label class="download-form__field">
Chaîne (@handle ou URL) Chaîne (@handle ou URL)
@ -53,7 +66,12 @@
<button type="submit">Rechercher</button> <button type="submit">Rechercher</button>
</form> </form>
<p class="download-form__error" data-role="channel-error" hidden></p> <p class="download-form__error" data-role="channel-error" hidden></p>
<p class="download-form__status" data-role="channel-status" hidden></p>
<div class="download-form__results-header" data-role="results-header" hidden>
<p class="download-form__status" data-role="results-status"></p>
<button type="button" class="secondary" data-role="add-all" hidden>Tout ajouter à la file</button>
<p class="download-form__error" data-role="results-error" hidden></p>
</div>
<ul class="download-form__results" data-role="results"></ul> <ul class="download-form__results" data-role="results"></ul>
</div> </div>

View file

@ -4,6 +4,11 @@
gap: 0.85rem; gap: 0.85rem;
height: 100%; height: 100%;
min-height: 0; min-height: 0;
// Filet de sécurité pour les écrans courts : quand les formulaires et la
// liste (hauteur minimale, voir __results) ne tiennent plus, la colonne
// défile au lieu de rogner la liste ou des champs, la page elle-même ne
// défilant jamais. Sans effet tant que tout tient.
overflow-y: auto;
// Par défaut, aucun enfant direct ne grandit/rétrécit (taille fixée à // Par défaut, aucun enfant direct ne grandit/rétrécit (taille fixée à
// son contenu) - seul __results (règle plus bas, même spécificité // son contenu) - seul __results (règle plus bas, même spécificité
@ -75,6 +80,12 @@
flex: 1 1 160px; flex: 1 1 160px;
} }
// Champ étroit (ex. une limite numérique) : ne prend que la place utile
// au lieu de se partager la ligne à parts égales.
&__field--narrow {
flex: 0 1 8rem;
}
&__error { &__error {
color: var(--color-danger-text); color: var(--color-danger-text);
font-size: 0.85rem; font-size: 0.85rem;
@ -87,6 +98,29 @@
margin: 0; 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 { &__results {
list-style: none; list-style: none;
margin: 0; margin: 0;
@ -97,6 +131,32 @@
flex: 1 1 0; flex: 1 1 0;
min-height: 0; min-height: 0;
overflow-y: auto; overflow-y: auto;
// Une liste qui contient des résultats garde au moins ~2 lignes de
// hauteur, même si les formulaires au-dessus prennent presque toute la
// place (c'est alors la colonne qui défile, voir .download-form).
&:not(:empty) {
min-height: 13rem;
}
}
// Dernier élément d'une liste à défilement infini : sa visibilité
// déclenche le chargement de la suite, et il affiche l'état du chargement
// (en cours, erreur avec « Réessayer », fin des résultats).
&__results-sentinel {
display: flex;
align-items: center;
justify-content: center;
gap: 0.6rem;
min-height: 2.4rem;
flex: 0 0 auto;
font-size: 0.8rem;
color: var(--color-text-muted);
button {
font-size: 0.8rem;
padding: 0.3rem 0.65rem;
}
} }
&__result { &__result {

View file

@ -1,15 +1,108 @@
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 EnqueueMode, enqueueItem } from '../../api/queueApi';
import { closeSearch, fetchSearchPage, startSearch } from '../../api/searchApi';
import type { VideoDto, VideoPage } from '../../api/videoDto';
import { formatDuration, formatUploadDate } from '../../format'; import { formatDuration, formatUploadDate } from '../../format';
import { isPlaylistUrl } from '../../playlistUrl';
import { parseSearchLimit } from '../../searchLimit';
import downloadFormTemplate from './DownloadForm.html'; import downloadFormTemplate from './DownloadForm.html';
import './DownloadForm.scss'; import './DownloadForm.scss';
/** Rappels fournis par l'appelant pour réagir aux actions du formulaire. */ /** Rappels fournis par l'appelant pour réagir aux actions du formulaire. */
export interface DownloadFormCallbacks { 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; 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 complète. */
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;
}
/** Options d'affichage d'une liste paginée en défilement infini. */
interface ShowPagedOptions {
/** Première page, déjà chargée. */
readonly firstPage: VideoPage;
/** Charge la page qui commence à la position `offset` (le `nextOffset` de la page précédente). */
readonly loadMore: (offset: number) => Promise<VideoPage>;
/** Message de statut pour `count` vidéos affichées ; `hasMore` indique qu'il en reste à charger. */
readonly formatStatus: (count: number, hasMore: boolean) => string;
/**
* Appelé une seule fois quand la liste est remplacée ou vidée alors qu'il
* restait des pages à charger (ex. pour libérer la recherche côté
* serveur). Jamais appelé si tout tenait dans la première page.
*/
readonly onClose: () => void;
}
/** Défilement infini de la liste actuellement affichée. */
interface Pager {
/** Options fournies par l'appelant. */
readonly options: ShowPagedOptions;
/** Dernier élément de la liste : sa visibilité déclenche le chargement de la suite. */
readonly sentinel: HTMLLIElement;
/** Observateur de visibilité de {@link Pager.sentinel} dans la zone de défilement. */
readonly observer: IntersectionObserver;
/** Position de la prochaine page, ou `null` quand tout est chargé. */
nextOffset: number | null;
/** Une page est en cours de chargement. */
loading: boolean;
}
/** État affiché par l'élément de fin de liste d'un défilement infini. */
type SentinelState =
| { readonly kind: 'idle' }
| { readonly kind: 'loading' }
| { readonly kind: 'end' }
| { readonly kind: 'error'; readonly message: string; readonly onRetry: () => void };
/** Zone de résultats partagée entre la recherche de chaîne, de playlist et globale. */
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 d'un coup, avec leur message de statut. */
showVideos: (videos: readonly VideoDto[], options: ShowVideosOptions) => void;
/** Affiche une première page, puis charge la suite au fil du défilement. */
showPaged: (options: ShowPagedOptions) => void;
}
const ADD_ALL_LABEL = 'Tout ajouter à la file';
/** Nombre de résultats demandés par page pour une recherche sans limite. */
const SEARCH_PAGE_SIZE = 20;
/**
* 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. * Affiche ou efface un message dans un élément de statut/erreur.
* *
@ -24,6 +117,15 @@ function setMessage(el: HTMLElement | null, message: string | null): void {
el.textContent = message ?? ''; 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 * Lit le mode de téléchargement et ses options depuis les contrôles
* actuellement sélectionnés dans le formulaire. * actuellement sélectionnés dans le formulaire.
@ -73,103 +175,428 @@ function wireModeToggle(root: HTMLElement): void {
} }
/** /**
* Ajoute un résultat de recherche de chaîne à la file d'attente, avec * Ajoute la vidéo d'une ligne à la file d'attente et marque son bouton
* retour visuel sur le bouton concerné. * « 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 row - Ligne de résultat à ajouter.
* @param formRoot - Racine du formulaire (pour lire le mode courant). * @param mode - Mode de téléchargement à appliquer.
* @param button - Bouton "Ajouter" cliqué, pour le désactiver pendant la requête. * @throws {Error} Si l'ajout échoue.
* @param errorEl - Élément où afficher une éventuelle erreur.
* @param callbacks - Rappels du formulaire.
*/ */
async function handleAddResult( async function enqueueResult(row: ResultRow, mode: EnqueueMode): Promise<void> {
video: ChannelVideoDto, row.button.disabled = true;
formRoot: HTMLElement,
button: HTMLButtonElement,
errorEl: HTMLElement | null,
callbacks: DownloadFormCallbacks,
): Promise<void> {
button.disabled = true;
setMessage(errorEl, null);
try { try {
await enqueueItem(video.url, readCurrentMode(formRoot), video.title); await enqueueItem(row.video.url, mode, row.video.title, row.video.thumbnailUrl);
button.textContent = 'Ajouté ✓'; row.button.textContent = 'Ajouté ✓';
callbacks.onItemQueued();
} catch (error) { } catch (error) {
button.disabled = false; row.button.disabled = false;
setMessage(errorEl, error instanceof Error ? error.message : "Échec de l'ajout."); 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 `<ul>` dans lequel rendre les résultats. * @param video - Vidéo à afficher.
* @param videos - Vidéos à afficher. * @returns L'élément `<li>` et son bouton « Ajouter ».
* @param formRoot - Racine du formulaire (pour lire le mode courant lors d'un ajout).
* @param errorEl - Élément où afficher une éventuelle erreur d'ajout.
* @param callbacks - Rappels du formulaire.
*/ */
function renderResults( function renderResultItem(video: VideoDto): {
container: HTMLElement, element: HTMLLIElement;
videos: readonly ChannelVideoDto[], button: HTMLButtonElement;
formRoot: HTMLElement, } {
errorEl: HTMLElement | null, const element = document.createElement('li');
callbacks: DownloadFormCallbacks, element.className = 'download-form__result';
): void {
container.replaceChildren();
for (const video of videos) { if (video.thumbnailUrl) {
const item = document.createElement('li'); const img = document.createElement('img');
item.className = 'download-form__result'; 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);
}
if (video.thumbnailUrl) { const info = document.createElement('div');
const img = document.createElement('img'); info.className = 'download-form__result-info';
img.src = video.thumbnailUrl;
img.alt = ''; const title = document.createElement('p');
img.className = 'download-form__result-thumb'; title.className = 'download-form__result-title';
item.append(img); 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 };
}
/**
* Affiche l'état d'un élément de fin de liste (chargement en cours, erreur
* avec « Réessayer », fin des résultats, ou rien).
*
* @param sentinel - Élément de fin de liste.
* @param state - État à afficher.
*/
function renderSentinel(sentinel: HTMLElement, state: SentinelState): void {
sentinel.replaceChildren();
switch (state.kind) {
case 'idle':
return;
case 'loading':
sentinel.textContent = 'Chargement…';
return;
case 'end':
sentinel.textContent = 'Fin des résultats.';
return;
case 'error': {
const message = document.createElement('span');
message.textContent = state.message;
const retry = document.createElement('button');
retry.type = 'button';
retry.className = 'secondary';
retry.textContent = 'Réessayer';
retry.addEventListener('click', state.onRetry);
sentinel.append(message, retry);
return;
} }
const info = document.createElement('div');
info.className = 'download-form__result-info';
const title = document.createElement('p');
title.className = 'download-form__result-title';
title.textContent = video.title;
info.append(title);
const meta = document.createElement('p');
meta.className = 'download-form__result-meta';
meta.textContent = [formatDuration(video.durationSeconds), formatUploadDate(video.uploadDate)]
.filter((part) => part.length > 0)
.join(' · ');
info.append(meta);
item.append(info);
const addButton = document.createElement('button');
addButton.type = 'button';
addButton.textContent = 'Ajouter';
addButton.addEventListener('click', () => {
handleAddResult(video, formRoot, addButton, errorEl, callbacks).catch((error: unknown) => {
console.error('Erreur inattendue lors de l’ajout depuis la recherche', error);
});
});
item.append(addButton);
container.append(item);
} }
} }
/** /**
* Câble le formulaire d'ajout direct par URL. * Câble la zone de résultats partagée : liste de vidéos avec un bouton
* « Ajouter » par ligne, un bouton « Tout ajouter à la file » quand la liste
* le permet (playlist), et un chargement de la suite au fil du défilement
* quand la liste est paginée (recherche globale sans limite).
* *
* @param root - Racine du formulaire monté. * @param root - Racine du formulaire monté.
* @param callbacks - Rappels du formulaire. * @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<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[] = [];
let pager: Pager | null = null;
// Incrémenté à chaque remplacement de la liste : une opération encore en
// cours sur l'ancienne liste (ajout groupé, chargement d'une page) ne doit
// plus toucher à l'affichage (bouton, message, lignes) de la nouvelle.
let generation = 0;
const logUnexpected = (error: unknown): void => {
console.error('Erreur inattendue dans la liste de résultats', error);
};
const closePager = (): void => {
if (!pager) {
return;
}
const closing = pager;
pager = null;
closing.observer.disconnect();
closing.options.onClose();
};
const clear = (): void => {
generation += 1;
closePager();
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);
});
});
/**
* Ajoute des lignes en fin de liste (avant l'élément de fin s'il y en a un).
*/
const appendRows = (videos: readonly VideoDto[]): void => {
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);
}
if (pager) {
pager.sentinel.before(fragment);
} else {
list.append(fragment);
}
};
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;
appendRows(videos);
};
const refreshPagedStatus = (): void => {
if (pager) {
setMessage(statusEl, pager.options.formatStatus(rows.length, pager.nextOffset !== null));
}
};
const loadNextPage = async (): Promise<void> => {
const current = pager;
if (!current || current.loading || current.nextOffset === null) {
return;
}
const startedIn = generation;
current.loading = true;
renderSentinel(current.sentinel, { kind: 'loading' });
let page: VideoPage;
try {
page = await current.options.loadMore(current.nextOffset);
} catch (error) {
if (startedIn === generation) {
current.loading = false;
renderSentinel(current.sentinel, {
kind: 'error',
message: describeError(error, 'Échec du chargement.'),
onRetry: () => {
loadNextPage().catch(logUnexpected);
},
});
}
return;
}
if (startedIn !== generation) {
return;
}
appendRows(page.videos);
current.nextOffset = page.nextOffset;
current.loading = false;
refreshPagedStatus();
if (current.nextOffset === null) {
renderSentinel(current.sentinel, { kind: 'end' });
current.observer.disconnect();
return;
}
renderSentinel(current.sentinel, { kind: 'idle' });
// Si la fin de liste est encore visible (la page n'a pas suffi à remplir
// la zone), l'observateur ne signalerait plus rien : on le réarme.
current.observer.unobserve(current.sentinel);
current.observer.observe(current.sentinel);
};
const showPaged = (options: ShowPagedOptions): void => {
if (options.firstPage.nextOffset === null) {
// Tout tient dans la première page : rien à paginer ni à libérer.
showVideos(options.firstPage.videos, {
status: options.formatStatus(options.firstPage.videos.length, false),
allowAddAll: false,
});
return;
}
clear();
header.hidden = false;
addAllButton.hidden = true;
const sentinel = document.createElement('li');
sentinel.className = 'download-form__results-sentinel';
list.append(sentinel);
const observer = new IntersectionObserver(
(observed) => {
if (observed.some((entry) => entry.isIntersecting)) {
loadNextPage().catch(logUnexpected);
}
},
// Les résultats se chargent un peu avant d'atteindre la fin de liste.
{ root: list, rootMargin: '0px 0px 300px 0px' },
);
pager = {
options,
sentinel,
observer,
nextOffset: options.firstPage.nextOffset,
loading: false,
};
appendRows(options.firstPage.videos);
refreshPagedStatus();
observer.observe(sentinel);
};
return { showLoading, clear, showVideos, showPaged };
}
/**
* 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).`;
}
/**
* Décrit l'avancement d'une recherche pour la ligne de statut de la liste.
*
* @param count - Nombre de vidéos affichées.
* @param hasMore - `true` s'il reste des résultats à charger en défilant.
* @returns Un texte du type « 40 résultat(s) - d'autres se chargent en défilant. ».
*/
function describeSearchProgress(count: number, hasMore: boolean): string {
if (count === 0) {
return 'Aucun résultat.';
}
return hasMore
? `${count} résultat(s) — d'autres se chargent en défilant.`
: `${count} résultat(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 form = root.querySelector<HTMLFormElement>('form[data-form="url"]');
const errorEl = root.querySelector<HTMLElement>('[data-role="url-error"]'); const errorEl = root.querySelector<HTMLElement>('[data-role="url-error"]');
if (!form) { if (!form) {
@ -190,11 +617,15 @@ function wireUrlForm(root: HTMLElement, callbacks: DownloadFormCallbacks): void
} }
try { 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(); form.reset();
callbacks.onItemQueued();
} catch (error) { } 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 { } finally {
if (submitButton) { if (submitButton) {
submitButton.disabled = false; submitButton.disabled = false;
@ -204,7 +635,94 @@ function wireUrlForm(root: HTMLElement, callbacks: DownloadFormCallbacks): void
form.addEventListener('submit', (event) => { form.addEventListener('submit', (event) => {
handleSubmit(event).catch((error: unknown) => { 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);
});
});
}
/**
* Câble le formulaire de recherche YouTube globale. Avec une limite, les
* résultats arrivent d'un coup ; sans limite, la liste se prolonge au fil du
* défilement jusqu'à la fin des résultats de YouTube.
*
* @param root - Racine du formulaire monté.
* @param results - Zone de résultats partagée.
*/
function wireSearchForm(root: HTMLElement, results: ResultsView): void {
const form = root.querySelector<HTMLFormElement>('form[data-form="search"]');
const errorEl = root.querySelector<HTMLElement>('[data-role="search-error"]');
if (!form) {
return;
}
// Recherche gardée ouverte côté serveur pour ses pages suivantes : libérée
// quand la liste est remplacée (voir onClose), ou ici quand la page se
// ferme ou se recharge - sans quoi son yt-dlp resterait suspendu jusqu'à
// l'expiration côté serveur.
let openSearchId: string | null = null;
window.addEventListener('pagehide', () => {
if (openSearchId) {
closeSearch(openSearchId).catch(() => {
// Meilleur effort : la page se ferme, personne pour lire une erreur.
});
}
});
const handleSubmit = async (event: SubmitEvent): Promise<void> => {
event.preventDefault();
const formData = new FormData(form);
const query = String(formData.get('query') ?? '').trim();
const limit = parseSearchLimit(String(formData.get('limit') ?? ''));
if (!query) {
return;
}
setMessage(errorEl, null);
results.showLoading('Recherche en cours...');
const submitButton = form.querySelector<HTMLButtonElement>('button[type="submit"]');
if (submitButton) {
submitButton.disabled = true;
}
try {
if (limit !== null) {
// Limite fixée : tous les résultats en une fois, pas de défilement infini.
const search = await startSearch(query, limit, limit);
results.showVideos(search.videos, {
status: describeSearchProgress(search.videos.length, false),
allowAddAll: false,
});
} else {
const search = await startSearch(query, null, SEARCH_PAGE_SIZE);
if (search.nextOffset !== null) {
// Sinon le serveur a déjà refermé la recherche : rien à libérer.
openSearchId = search.id;
}
results.showPaged({
firstPage: search,
loadMore: (offset) => fetchSearchPage(search.id, offset, SEARCH_PAGE_SIZE),
formatStatus: describeSearchProgress,
onClose: () => {
openSearchId = null;
closeSearch(search.id).catch((error: unknown) => {
console.warn('Échec de la fermeture de la recherche', error);
});
},
});
}
} 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 YouTube', error);
}); });
}); });
} }
@ -213,14 +731,12 @@ function wireUrlForm(root: HTMLElement, callbacks: DownloadFormCallbacks): void
* Câble le formulaire de recherche de chaîne. * Câble le formulaire de recherche de chaîne.
* *
* @param root - Racine du formulaire monté. * @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<HTMLFormElement>('form[data-form="channel"]'); const form = root.querySelector<HTMLFormElement>('form[data-form="channel"]');
const errorEl = root.querySelector<HTMLElement>('[data-role="channel-error"]'); const errorEl = root.querySelector<HTMLElement>('[data-role="channel-error"]');
const statusEl = root.querySelector<HTMLElement>('[data-role="channel-status"]'); if (!form) {
const resultsEl = root.querySelector<HTMLElement>('[data-role="results"]');
if (!form || !resultsEl) {
return; return;
} }
@ -235,8 +751,7 @@ function wireChannelForm(root: HTMLElement, callbacks: DownloadFormCallbacks): v
} }
setMessage(errorEl, null); setMessage(errorEl, null);
setMessage(statusEl, 'Recherche en cours...'); results.showLoading('Recherche en cours...');
resultsEl.replaceChildren();
const submitButton = form.querySelector<HTMLButtonElement>('button[type="submit"]'); const submitButton = form.querySelector<HTMLButtonElement>('button[type="submit"]');
if (submitButton) { if (submitButton) {
submitButton.disabled = true; submitButton.disabled = true;
@ -244,14 +759,13 @@ function wireChannelForm(root: HTMLElement, callbacks: DownloadFormCallbacks): v
try { try {
const videos = await searchChannel(channel, section, keywords); const videos = await searchChannel(channel, section, keywords);
setMessage( results.showVideos(videos, {
statusEl, status: videos.length === 0 ? 'Aucun résultat.' : `${videos.length} résultat(s).`,
videos.length === 0 ? 'Aucun résultat.' : `${videos.length} résultat(s).`, allowAddAll: false,
); });
renderResults(resultsEl, videos, root, errorEl, callbacks);
} catch (error) { } catch (error) {
setMessage(statusEl, null); results.clear();
setMessage(errorEl, error instanceof Error ? error.message : 'Échec de la recherche.'); setMessage(errorEl, describeError(error, 'Échec de la recherche.'));
} finally { } finally {
if (submitButton) { if (submitButton) {
submitButton.disabled = false; submitButton.disabled = false;
@ -267,15 +781,17 @@ function wireChannelForm(root: HTMLElement, callbacks: DownloadFormCallbacks): v
} }
/** /**
* Monte le formulaire d'ajout (URL directe + recherche de chaîne) dans * Monte le formulaire d'ajout (URL directe ou de playlist, recherche YouTube
* l'élément racine fourni. * globale, et recherche de chaîne) dans l'élément racine fourni.
* *
* @param root - Élément DOM dans lequel injecter le formulaire. * @param root - Élément DOM dans lequel injecter le formulaire.
* @param callbacks - Rappels invoqués sur les actions du formulaire. * @param callbacks - Rappels invoqués sur les actions du formulaire.
*/ */
export function mountDownloadForm(root: HTMLElement, callbacks: DownloadFormCallbacks): void { export function mountDownloadForm(root: HTMLElement, callbacks: DownloadFormCallbacks): void {
root.innerHTML = downloadFormTemplate; root.innerHTML = downloadFormTemplate;
const results = createResultsView(root, callbacks);
wireModeToggle(root); wireModeToggle(root);
wireUrlForm(root, callbacks); wireUrlForm(root, results, callbacks);
wireChannelForm(root, callbacks); wireSearchForm(root, results);
wireChannelForm(root, results);
} }

View file

@ -48,6 +48,9 @@
} }
&__item { &__item {
display: flex;
align-items: flex-start;
gap: 0.65rem;
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-left-width: 4px; border-left-width: 4px;
border-radius: var(--radius); border-radius: var(--radius);
@ -70,6 +73,20 @@
} }
} }
&__item-thumb {
width: 96px;
height: 54px;
object-fit: cover;
border-radius: 6px;
flex-shrink: 0;
background: var(--color-bg);
}
&__item-content {
flex: 1;
min-width: 0;
}
&__item-title { &__item-title {
margin: 0; margin: 0;
font-size: 0.9rem; font-size: 0.9rem;

View file

@ -99,36 +99,47 @@ function renderItem(item: QueueItemDto, onChanged: () => void): HTMLLIElement {
const li = document.createElement('li'); const li = document.createElement('li');
li.className = `queue-panel__item queue-panel__item--${item.status}`; li.className = `queue-panel__item queue-panel__item--${item.status}`;
if (item.thumbnailUrl) {
const img = document.createElement('img');
img.src = item.thumbnailUrl;
img.alt = '';
img.className = 'queue-panel__item-thumb';
li.append(img);
}
const content = document.createElement('div');
content.className = 'queue-panel__item-content';
const title = document.createElement('p'); const title = document.createElement('p');
title.className = 'queue-panel__item-title'; title.className = 'queue-panel__item-title';
title.textContent = item.title ?? item.url; title.textContent = item.title ?? item.url;
li.append(title); content.append(title);
const meta = document.createElement('p'); const meta = document.createElement('p');
meta.className = 'queue-panel__item-meta'; meta.className = 'queue-panel__item-meta';
meta.textContent = `${MODE_LABELS[item.mode.kind]} · ${STATUS_LABELS[item.status]}`; meta.textContent = `${MODE_LABELS[item.mode.kind]} · ${STATUS_LABELS[item.status]}`;
li.append(meta); content.append(meta);
if (isActive(item)) { if (isActive(item)) {
const progress = document.createElement('progress'); const progress = document.createElement('progress');
progress.className = 'queue-panel__item-progress'; progress.className = 'queue-panel__item-progress';
progress.max = 100; progress.max = 100;
progress.value = item.progress; progress.value = item.progress;
li.append(progress); content.append(progress);
} }
if (item.status === 'failed' && item.error) { if (item.status === 'failed' && item.error) {
const error = document.createElement('p'); const error = document.createElement('p');
error.className = 'queue-panel__item-error'; error.className = 'queue-panel__item-error';
error.textContent = item.error; error.textContent = item.error;
li.append(error); content.append(error);
} }
if (item.status === 'completed' && !item.fileName) { if (item.status === 'completed' && !item.fileName) {
const note = document.createElement('p'); const note = document.createElement('p');
note.className = 'queue-panel__item-meta'; note.className = 'queue-panel__item-meta';
note.textContent = 'Déjà téléchargé'; note.textContent = 'Déjà téléchargé';
li.append(note); content.append(note);
} }
const actions = document.createElement('div'); const actions = document.createElement('div');
@ -155,7 +166,8 @@ function renderItem(item: QueueItemDto, onChanged: () => void): HTMLLIElement {
} }
actions.append(removeButton); actions.append(removeButton);
li.append(actions); content.append(actions);
li.append(content);
return li; return li;
} }

View file

@ -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);
});
});

View file

@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest';
import { parseSearchLimit } from '../src/searchLimit';
describe('parseSearchLimit', () => {
it.each([
['25', 25],
['1', 1],
[' 40 ', 40],
['1000', 1000],
['007', 7],
])('lit %j comme une limite de %i résultats', (raw, expected) => {
expect(parseSearchLimit(raw)).toBe(expected);
});
it.each([
['un champ vide', ''],
['des espaces', ' '],
['zéro', '0'],
['un nombre négatif', '-5'],
['un nombre décimal', '2.5'],
['du texte', 'beaucoup'],
['un nombre suivi de texte', '25 vidéos'],
['une notation exponentielle', '1e3'],
])('ne voit aucune limite dans %s', (_label, raw) => {
expect(parseSearchLimit(raw)).toBeNull();
});
});

View file

@ -3,7 +3,9 @@ import Fastify, { type FastifyInstance } from 'fastify';
import { type ServerConfig, loadConfig } from './config.js'; import { type ServerConfig, loadConfig } from './config.js';
import { channelRoutes } from './routes/channel.js'; import { channelRoutes } from './routes/channel.js';
import { healthRoutes } from './routes/health.js'; import { healthRoutes } from './routes/health.js';
import { playlistRoutes } from './routes/playlist.js';
import { createQueueRoutes } from './routes/queue.js'; import { createQueueRoutes } from './routes/queue.js';
import { searchRoutes } from './routes/search.js';
/** /**
* Construit et configure l'instance Fastify de l'application : fichiers * Construit et configure l'instance Fastify de l'application : fichiers
@ -22,6 +24,8 @@ async function buildServer(config: ServerConfig): Promise<FastifyInstance> {
}); });
await app.register(healthRoutes); await app.register(healthRoutes);
await app.register(channelRoutes); await app.register(channelRoutes);
await app.register(playlistRoutes);
await app.register(searchRoutes);
await app.register(createQueueRoutes(config)); await app.register(createQueueRoutes(config));
} catch (error) { } catch (error) {
app.log.error({ err: error }, "Échec de l'enregistrement des plugins Fastify"); app.log.error({ err: error }, "Échec de l'enregistrement des plugins Fastify");

View file

@ -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<void> => {
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.',
});
}
},
);
};

View file

@ -12,6 +12,8 @@ interface CreateQueueItemBody {
url: string; url: string;
/** Titre de la vidéo, si déjà connu (ex. depuis une recherche de chaîne), pour l'affichage uniquement. */ /** Titre de la vidéo, si déjà connu (ex. depuis une recherche de chaîne), pour l'affichage uniquement. */
title?: string; title?: string;
/** URL de la miniature, si déjà connue (ex. depuis une recherche de chaîne), pour l'affichage uniquement. */
thumbnailUrl?: string;
/** Mode de téléchargement demandé. */ /** Mode de téléchargement demandé. */
mode: 'video' | 'audio' | 'thumbnail'; mode: 'video' | 'audio' | 'thumbnail';
/** Mode vidéo uniquement : récupérer et embarquer les sous-titres fr/en. */ /** Mode vidéo uniquement : récupérer et embarquer les sous-titres fr/en. */
@ -28,6 +30,7 @@ const createQueueItemSchema = {
properties: { properties: {
url: { type: 'string', minLength: 1, maxLength: 500 }, url: { type: 'string', minLength: 1, maxLength: 500 },
title: { type: 'string', maxLength: 300 }, title: { type: 'string', maxLength: 300 },
thumbnailUrl: { type: 'string', maxLength: 500 },
mode: { type: 'string', enum: ['video', 'audio', 'thumbnail'] }, mode: { type: 'string', enum: ['video', 'audio', 'thumbnail'] },
subtitles: { type: 'boolean' }, subtitles: { type: 'boolean' },
embedThumbnail: { type: 'boolean' }, embedThumbnail: { type: 'boolean' },
@ -135,6 +138,7 @@ export function createQueueRoutes(
url, url,
toDownloadMode(request.body), toDownloadMode(request.body),
request.body.title ?? null, request.body.title ?? null,
request.body.thumbnailUrl ?? null,
); );
return reply.status(201).send({ item }); return reply.status(201).send({ item });
}, },

128
server/src/routes/search.ts Normal file
View file

@ -0,0 +1,128 @@
import type { FastifyInstance, FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify';
import { MAX_SEARCH_RESULTS, SearchService } from '../services/SearchService.js';
import { InvalidSearchQueryError, MAX_QUERY_LENGTH } from '../services/searchQuery.js';
/** Taille de page par défaut. */
const DEFAULT_PAGE_SIZE = 20;
/** Corps de requête accepté par `POST /api/search`. */
interface StartSearchBody {
/** Requête de recherche YouTube. */
query: string;
/** Nombre maximal de résultats au total ; absent = sans limite (jusqu'à la fin des résultats de YouTube). */
max?: number;
/** Taille de la première page. */
limit?: number;
}
/** Paramètres d'URL des routes `/api/search/:id`. */
interface SearchParams {
/** Identifiant de la recherche, retourné par `POST /api/search`. */
id: string;
}
/** Paramètres de requête acceptés par `GET /api/search/:id`. */
interface SearchPageQuerystring {
/** Position du premier résultat de la page ; 0 par défaut. */
offset?: number;
/** Nombre de résultats demandés. */
limit?: number;
}
const startSearchBodySchema = {
type: 'object',
required: ['query'],
properties: {
query: { type: 'string', minLength: 1, maxLength: MAX_QUERY_LENGTH },
max: { type: 'integer', minimum: 1, maximum: MAX_SEARCH_RESULTS },
limit: { type: 'integer', minimum: 1, maximum: MAX_SEARCH_RESULTS },
},
} as const;
const searchParamsSchema = {
type: 'object',
required: ['id'],
properties: {
id: { type: 'string', minLength: 1, maxLength: 64 },
},
} as const;
const searchPageQuerystringSchema = {
type: 'object',
properties: {
offset: { type: 'integer', minimum: 0 },
limit: { type: 'integer', minimum: 1, maximum: MAX_SEARCH_RESULTS },
},
} as const;
const NOT_FOUND_MESSAGE = 'Recherche introuvable ou expirée : relancez-la.';
const FAILURE_MESSAGE = 'Échec de la recherche YouTube (yt-dlp).';
/**
* Plugin Fastify exposant la recherche YouTube globale, paginée :
* `POST /api/search` ouvre une recherche et retourne sa première page,
* `GET /api/search/:id` retourne les pages suivantes, et `DELETE
* /api/search/:id` libère la recherche.
*
* @param app - Instance Fastify sur laquelle enregistrer les routes.
*/
export const searchRoutes: FastifyPluginAsync = async (app: FastifyInstance): Promise<void> => {
const searchService = new SearchService(app.log);
app.addHook('onClose', async () => {
searchService.closeAll();
});
app.post<{ Body: StartSearchBody }>(
'/api/search',
{ schema: { body: startSearchBodySchema } },
async (request: FastifyRequest<{ Body: StartSearchBody }>, reply: FastifyReply) => {
const { query, max = MAX_SEARCH_RESULTS, limit = DEFAULT_PAGE_SIZE } = request.body;
try {
const { id, page } = await searchService.start(query, max, limit);
return reply.status(201).send({ id, ...page });
} catch (error) {
if (error instanceof InvalidSearchQueryError) {
return reply.status(400).send({ error: error.message });
}
// La requête est validée avant tout appel à yt-dlp : toute autre
// erreur vient forcément du parcours (binaire absent, code de
// sortie non nul...), déjà journalisée par la session.
return reply.status(502).send({ error: FAILURE_MESSAGE });
}
},
);
app.get<{ Params: SearchParams; Querystring: SearchPageQuerystring }>(
'/api/search/:id',
{ schema: { params: searchParamsSchema, querystring: searchPageQuerystringSchema } },
async (
request: FastifyRequest<{ Params: SearchParams; Querystring: SearchPageQuerystring }>,
reply: FastifyReply,
) => {
const { offset = 0, limit = DEFAULT_PAGE_SIZE } = request.query;
try {
const page = await searchService.page(request.params.id, offset, limit);
if (!page) {
return reply.status(404).send({ error: NOT_FOUND_MESSAGE });
}
return page;
} catch {
return reply.status(502).send({ error: FAILURE_MESSAGE });
}
},
);
app.delete<{ Params: SearchParams }>(
'/api/search/:id',
{ schema: { params: searchParamsSchema } },
async (request: FastifyRequest<{ Params: SearchParams }>, reply: FastifyReply) => {
if (!searchService.dispose(request.params.id)) {
return reply.status(404).send({ error: NOT_FOUND_MESSAGE });
}
return reply.status(204).send();
},
);
};

View file

@ -1,25 +1,10 @@
import type { FastifyBaseLogger } from 'fastify'; import type { FastifyBaseLogger } from 'fastify';
import { type VideoSummary, summarizeEntry } from './videoSummary.js';
import { runYtDlp } from './ytDlpRunner.js'; import { runYtDlp } from './ytDlpRunner.js';
/** Section d'une chaîne YouTube à parcourir. */ /** Section d'une chaîne YouTube à parcourir. */
export type ChannelSection = 'videos' | 'streams'; 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 * Erreur levée quand l'identifiant de chaîne fourni ne correspond à aucun
* format accepté (ni handle, ni URL de chaîne YouTube). * format accepté (ni handle, ni URL de chaîne YouTube).
@ -92,29 +77,13 @@ function matchesKeywords(title: string, keywords: readonly string[]): boolean {
* @param line - Une ligne de la sortie yt-dlp, censée contenir un objet JSON. * @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. * @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) { if (line.trim().length === 0) {
return null; return null;
} }
try { try {
const entry = JSON.parse(line) as Record<string, unknown>; return summarizeEntry(JSON.parse(line));
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: typeof entry.thumbnail === 'string' ? entry.thumbnail : null,
durationSeconds: typeof entry.duration === 'number' ? entry.duration : null,
uploadDate: typeof entry.upload_date === 'string' ? entry.upload_date : null,
};
} catch { } catch {
return null; return null;
} }
@ -150,7 +119,7 @@ export class ChannelDiscoveryService {
channel: string, channel: string,
section: ChannelSection, section: ChannelSection,
keywords: readonly string[] = [], keywords: readonly string[] = [],
): Promise<ChannelVideoSummary[]> { ): Promise<VideoSummary[]> {
const url = buildChannelUrl(channel, section); const url = buildChannelUrl(channel, section);
this.logger.info({ channel, section, keywords }, 'Découverte de chaîne : requête yt-dlp'); this.logger.info({ channel, section, keywords }, 'Découverte de chaîne : requête yt-dlp');
@ -172,7 +141,7 @@ export class ChannelDiscoveryService {
const results = stdout const results = stdout
.split('\n') .split('\n')
.map(parseEntryLine) .map(parseEntryLine)
.filter((entry): entry is ChannelVideoSummary => entry !== null) .filter((entry): entry is VideoSummary => entry !== null)
.filter((entry) => matchesKeywords(entry.title, keywords)); .filter((entry) => matchesKeywords(entry.title, keywords));
this.logger.info( this.logger.info(

View file

@ -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<string, unknown>) : {};
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<PlaylistListing> {
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;
}
}

View file

@ -21,6 +21,8 @@ export interface QueueItem {
readonly url: string; readonly url: string;
/** Titre de la vidéo, si connu au moment de l'ajout (purement informatif, pour l'affichage). */ /** Titre de la vidéo, si connu au moment de l'ajout (purement informatif, pour l'affichage). */
readonly title: string | null; readonly title: string | null;
/** URL de la miniature, si connue au moment de l'ajout (purement informatif, pour l'affichage). */
readonly thumbnailUrl: string | null;
/** Mode de téléchargement demandé. */ /** Mode de téléchargement demandé. */
readonly mode: DownloadMode; readonly mode: DownloadMode;
/** État d'avancement courant. */ /** État d'avancement courant. */
@ -42,6 +44,7 @@ interface InternalQueueItem {
id: string; id: string;
url: string; url: string;
title: string | null; title: string | null;
thumbnailUrl: string | null;
mode: DownloadMode; mode: DownloadMode;
status: QueueItemStatus; status: QueueItemStatus;
progress: number; progress: number;
@ -100,14 +103,21 @@ export class QueueService {
* @param url - URL de la vidéo à télécharger (déjà validée par l'appelant). * @param url - URL de la vidéo à télécharger (déjà validée par l'appelant).
* @param mode - Mode de téléchargement demandé. * @param mode - Mode de téléchargement demandé.
* @param title - Titre de la vidéo, si déjà connu (ex. depuis une recherche de chaîne), pour l'affichage uniquement. * @param title - Titre de la vidéo, si déjà connu (ex. depuis une recherche de chaîne), pour l'affichage uniquement.
* @param thumbnailUrl - URL de la miniature, si déjà connue (ex. depuis une recherche de chaîne), pour l'affichage uniquement.
* @returns L'élément créé, à l'état `"pending"`. * @returns L'élément créé, à l'état `"pending"`.
*/ */
public enqueue(url: string, mode: DownloadMode, title: string | null = null): QueueItem { public enqueue(
url: string,
mode: DownloadMode,
title: string | null = null,
thumbnailUrl: string | null = null,
): QueueItem {
const id = randomUUID(); const id = randomUUID();
const item: InternalQueueItem = { const item: InternalQueueItem = {
id, id,
url, url,
title, title,
thumbnailUrl,
mode, mode,
status: 'pending', status: 'pending',
progress: 0, progress: 0,
@ -362,6 +372,7 @@ export class QueueService {
id: item.id, id: item.id,
url: item.url, url: item.url,
title: item.title, title: item.title,
thumbnailUrl: item.thumbnailUrl,
mode: item.mode, mode: item.mode,
status: item.status, status: item.status,
progress: item.progress, progress: item.progress,

View file

@ -0,0 +1,210 @@
import type { FastifyBaseLogger } from 'fastify';
import { SEARCH_DEPTH, type SearchPage, SearchSession } from './SearchSession.js';
import { assertValidSearchQuery } from './searchQuery.js';
/** Nombre maximal de résultats qu'une recherche peut renvoyer (la profondeur demandée à yt-dlp). */
export const MAX_SEARCH_RESULTS = SEARCH_DEPTH;
/** Réglages du service de recherche (surchargeables en test). */
export interface SearchServiceOptions {
/** Nombre maximal de recherches simultanées ; la moins récemment utilisée est fermée au-delà. */
readonly maxSessions: number;
/** Durée d'inactivité (ms) au-delà de laquelle une recherche est fermée. */
readonly idleTtlMs: number;
/** Durée de vie maximale (ms) d'une recherche, active ou non. */
readonly maxLifetimeMs: number;
/** Intervalle (ms) du balayage des recherches expirées. */
readonly sweepIntervalMs: number;
/** Nombre de résultats d'avance sur la demande au-delà duquel yt-dlp est suspendu. */
readonly readAhead: number;
}
const DEFAULT_OPTIONS: SearchServiceOptions = {
maxSessions: 4,
idleTtlMs: 10 * 60_000,
maxLifetimeMs: 30 * 60_000,
sweepIntervalMs: 30_000,
readAhead: 20,
};
/**
* Service de recherche YouTube globale : ouvre des recherches (un yt-dlp
* chacune, voir {@link SearchSession}) et les sert page par page.
*
* Chaque recherche ouverte garde un processus yt-dlp vivant (suspendu tant
* que l'utilisateur ne défile pas), d'où un nombre de sessions borné, une
* expiration par inactivité, et une fermeture dès que la dernière page a été
* servie.
*/
export class SearchService {
private readonly logger: FastifyBaseLogger;
private readonly options: SearchServiceOptions;
private readonly sessions = new Map<string, SearchSession>();
private sweeper: NodeJS.Timeout | null = null;
/**
* @param logger - Logger utilisé pour tracer les recherches.
* @param options - Réglages ; les valeurs par défaut conviennent hors tests.
*/
public constructor(logger: FastifyBaseLogger, options: Partial<SearchServiceOptions> = {}) {
this.logger = logger;
this.options = { ...DEFAULT_OPTIONS, ...options };
}
/**
* Ouvre une recherche et retourne sa première page.
*
* @param query - Requête de recherche (validée ici).
* @param max - Nombre maximal de résultats ; le parcours s'arrête dès qu'il est atteint.
* @param limit - Taille de la première page.
* @returns L'identifiant de la recherche et sa première page.
* @throws {InvalidSearchQueryError} Si la requête est vide, trop longue ou invalide.
* @throws {Error} Si yt-dlp échoue avant de produire le moindre résultat.
*/
public async start(
query: string,
max: number,
limit: number,
): Promise<{ id: string; page: SearchPage }> {
const validQuery = assertValidSearchQuery(query);
this.evictIfFull();
const session = new SearchSession(this.logger, {
query: validQuery,
max,
readAhead: this.options.readAhead,
lifetimeMs: this.options.maxLifetimeMs,
});
this.sessions.set(session.id, session);
this.ensureSweeper();
try {
const page = await session.page(0, limit);
this.closeIfComplete(session, page);
return { id: session.id, page };
} catch (error) {
this.close(session.id, 'échec de la première page');
throw error;
}
}
/**
* Retourne une page d'une recherche ouverte.
*
* @param id - Identifiant retourné par {@link start}.
* @param offset - Position du premier résultat de la page.
* @param limit - Nombre de résultats demandés.
* @returns La page, ou `null` si la recherche est inconnue ou expirée.
* @throws {Error} Si yt-dlp a échoué sans produire de résultat.
*/
public async page(id: string, offset: number, limit: number): Promise<SearchPage | null> {
const session = this.sessions.get(id);
if (!session) {
return null;
}
try {
const page = await session.page(offset, limit);
this.closeIfComplete(session, page);
return page;
} catch (error) {
this.close(id, 'échec de la page');
throw error;
}
}
/**
* Ferme une recherche (tue son yt-dlp).
*
* @param id - Identifiant de la recherche.
* @returns `true` si la recherche existait, `false` sinon.
*/
public dispose(id: string): boolean {
return this.close(id, 'fermée par le client');
}
/** Ferme toutes les recherches et arrête le balayage (arrêt du serveur). */
public closeAll(): void {
for (const id of [...this.sessions.keys()]) {
this.close(id, 'arrêt du serveur');
}
this.stopSweeperIfIdle();
}
/**
* Ferme une recherche dont la dernière page vient d'être servie : plus
* rien ne pourra lui être demandé, inutile de garder son processus.
*
* @param session - Session concernée.
* @param page - Page qui vient d'être servie.
*/
private closeIfComplete(session: SearchSession, page: SearchPage): void {
if (page.nextOffset === null) {
this.close(session.id, 'dernière page servie');
}
}
/**
* @param id - Identifiant de la recherche à fermer.
* @param reason - Raison de la fermeture, pour les logs.
* @returns `true` si la recherche existait.
*/
private close(id: string, reason: string): boolean {
const session = this.sessions.get(id);
if (!session) {
return false;
}
this.sessions.delete(id);
session.dispose(reason);
this.stopSweeperIfIdle();
return true;
}
/** Ferme la recherche la moins récemment utilisée si la limite de sessions est atteinte. */
private evictIfFull(): void {
while (this.sessions.size >= this.options.maxSessions) {
let oldest: SearchSession | null = null;
for (const session of this.sessions.values()) {
if (!oldest || session.lastAccess < oldest.lastAccess) {
oldest = session;
}
}
if (!oldest) {
return;
}
this.close(oldest.id, 'limite de recherches simultanées atteinte');
}
}
/** Ferme les recherches inactives ou trop anciennes. */
private sweep(): void {
const now = Date.now();
for (const session of [...this.sessions.values()]) {
if (now - session.lastAccess > this.options.idleTtlMs) {
this.close(session.id, 'inactivité');
} else if (now - session.createdAt > this.options.maxLifetimeMs) {
this.close(session.id, 'durée de vie maximale atteinte');
}
}
}
/** Démarre le balayage périodique s'il ne tourne pas déjà. */
private ensureSweeper(): void {
if (this.sweeper) {
return;
}
this.sweeper = setInterval(() => {
this.sweep();
}, this.options.sweepIntervalMs);
// Le balayage ne doit jamais empêcher le processus de se terminer.
this.sweeper.unref();
}
/** Arrête le balayage quand plus aucune recherche n'est ouverte. */
private stopSweeperIfIdle(): void {
if (this.sweeper && this.sessions.size === 0) {
clearInterval(this.sweeper);
this.sweeper = null;
}
}
}

View file

@ -0,0 +1,272 @@
import type { ChildProcessWithoutNullStreams } from 'node:child_process';
import { randomUUID } from 'node:crypto';
import type { FastifyBaseLogger } from 'fastify';
import { type VideoSummary, summarizeEntry } from './videoSummary.js';
import { runYtDlp } from './ytDlpRunner.js';
/**
* Profondeur demandée à yt-dlp (`ytsearchN:`). YouTube s'arrête de lui-même
* vers ~590 résultats même quand on en demande 1000 (mesuré) : ce n'est donc
* qu'un plafond de sécurité, jamais atteint en pratique.
*/
export const SEARCH_DEPTH = 1000;
/** Une page de résultats d'une recherche. */
export interface SearchPage {
/** Vidéos de la page, dans l'ordre du parcours (sans doublon). */
readonly videos: VideoSummary[];
/** Position à demander pour la page suivante, ou `null` s'il n'y a plus de résultats. */
readonly nextOffset: number | null;
}
/** Réglages d'une session de recherche. */
export interface SearchSessionOptions {
/** Requête déjà validée (voir `assertValidSearchQuery`). */
readonly query: string;
/** Nombre maximal de résultats (sans doublon) : le parcours s'arrête dès qu'il est atteint. */
readonly max: number;
/** Nombre de résultats d'avance sur la demande au-delà duquel on suspend yt-dlp. */
readonly readAhead: number;
/** Durée de vie maximale du processus yt-dlp, en millisecondes. */
readonly lifetimeMs: number;
}
/**
* Un parcours de recherche YouTube, servi page par page.
*
* Pourquoi une session plutôt qu'un appel yt-dlp par page : l'ordre des
* résultats de YouTube n'est pas stable d'un appel à l'autre, même pour une
* requête identique (mesuré : 5 résultats sur 20 à la même position entre
* deux appels identiques). Paginer en relançant la recherche donnerait des
* doublons et des trous. Ici un seul yt-dlp parcourt la recherche
* (`--lazy-playlist` : il émet ses résultats au fil des pages YouTube au lieu
* d'attendre la fin) et les pages sont servies depuis son tampon.
*
* Le parcours n'avance qu'à la demande : dès que le tampon a
* {@link SearchSessionOptions.readAhead} résultats d'avance sur ce qui a été
* demandé, la lecture de stdout est suspendue. Le tube se remplit alors et
* yt-dlp bloque sur son écriture (mesuré : plus aucune ligne pendant 10 s de
* pause), ce qui évite d'aspirer ~590 résultats - et ~30 requêtes vers
* YouTube - quand l'utilisateur ne défile pas.
*/
export class SearchSession {
/** Identifiant opaque de la session. */
public readonly id = randomUUID();
/** Date de création de la session (ms depuis l'epoch). */
public readonly createdAt = Date.now();
private readonly logger: FastifyBaseLogger;
private readonly options: SearchSessionOptions;
private readonly entries: VideoSummary[] = [];
private readonly seenIds = new Set<string>();
private readonly waiters = new Set<() => void>();
private readonly abortController = new AbortController();
private child: ChildProcessWithoutNullStreams | null = null;
private suspended = false;
private finished = false;
private disposed = false;
private failure: unknown = null;
private wantedUpTo = 0;
private lastAccessAt = Date.now();
/**
* Lance immédiatement le parcours yt-dlp.
*
* @param logger - Logger utilisé pour tracer le cycle de vie de la session.
* @param options - Réglages de la session.
*/
public constructor(logger: FastifyBaseLogger, options: SearchSessionOptions) {
this.logger = logger;
this.options = options;
this.logger.info(
{ sessionId: this.id, query: options.query, max: options.max },
'Recherche : session ouverte',
);
runYtDlp(
[
'--flat-playlist',
'--lazy-playlist',
'-j',
'--',
`ytsearch${SEARCH_DEPTH}:${options.query}`,
],
{
timeoutMs: options.lifetimeMs,
signal: this.abortController.signal,
onSpawn: (child) => {
this.child = child;
},
onStdoutLine: (line) => {
this.ingest(line);
},
},
)
.catch((error: unknown) => {
// Une annulation de notre part (plafond atteint, session fermée)
// n'est pas un échec.
if (this.abortController.signal.aborted) {
return;
}
this.failure = error;
this.logger.error(
{ err: error, sessionId: this.id },
'Recherche : échec du parcours yt-dlp',
);
})
.finally(() => {
this.finished = true;
this.notify();
});
}
/** Date du dernier accès à la session (ms depuis l'epoch), pour l'expiration par inactivité. */
public get lastAccess(): number {
return this.lastAccessAt;
}
/**
* Retourne une page de résultats, en attendant au besoin que le parcours
* ait produit assez de résultats (ou soit terminé).
*
* @param offset - Position du premier résultat de la page.
* @param limit - Nombre de résultats demandés.
* @returns La page, et la position de la suivante (`null` en fin de résultats).
* @throws {Error} Si le parcours a échoué avant de produire le moindre résultat.
*/
public async page(offset: number, limit: number): Promise<SearchPage> {
this.lastAccessAt = Date.now();
this.wantedUpTo = Math.max(this.wantedUpTo, offset + limit);
this.resumeReading();
try {
await this.waitUntil(() => this.entries.length >= offset + limit || this.finished);
} catch (error) {
this.logger.error(
{ err: error, sessionId: this.id, offset, limit },
'Recherche : attente de résultats interrompue',
);
throw error;
}
this.lastAccessAt = Date.now();
if (this.entries.length === 0 && this.failure !== null) {
throw this.failure;
}
const videos = this.entries.slice(offset, offset + limit);
const end = offset + videos.length;
const hasMore = end < this.entries.length || !this.finished;
this.logger.info(
{ sessionId: this.id, offset, limit, served: videos.length, buffered: this.entries.length },
'Recherche : page servie',
);
return { videos, nextOffset: hasMore ? end : null };
}
/**
* Arrête la session : tue yt-dlp et libère les appels en attente.
* Idempotent.
*
* @param reason - Raison de la fermeture, pour les logs.
*/
public dispose(reason: string): void {
if (this.disposed) {
return;
}
this.disposed = true;
this.finished = true;
this.abortController.abort();
// Un flux suspendu qui a encore des données n'émet jamais `close` : on
// le relance pour qu'il se vide et se ferme, sans quoi le descripteur
// resterait ouvert.
this.resumeReading();
this.notify();
this.logger.info(
{ sessionId: this.id, reason, buffered: this.entries.length },
'Recherche : session fermée',
);
}
/**
* Intègre une ligne de sortie de yt-dlp (une entrée JSON) au tampon, puis
* suspend la lecture si le tampon a assez d'avance sur la demande.
*
* @param line - Ligne complète reçue sur stdout.
*/
private ingest(line: string): void {
if (this.finished) {
return;
}
let summary: VideoSummary | null = null;
try {
summary = summarizeEntry(JSON.parse(line));
} catch {
// Ligne non JSON : ignorée, comme une entrée inexploitable.
}
// YouTube répète parfois une même vidéo dans ses résultats (mesuré : ~7 %
// de doublons sur 300 résultats).
if (summary && !this.seenIds.has(summary.id)) {
this.seenIds.add(summary.id);
this.entries.push(summary);
}
if (this.entries.length >= this.options.max) {
this.finished = true;
this.abortController.abort();
this.resumeReading();
} else if (this.entries.length >= this.wantedUpTo + this.options.readAhead) {
this.suspendReading();
}
this.notify();
}
/** Suspend la lecture de stdout : yt-dlp bloque quand le tube est plein. */
private suspendReading(): void {
if (this.suspended || !this.child) {
return;
}
this.suspended = true;
this.child.stdout.pause();
}
/** Reprend la lecture de stdout, si elle était suspendue. */
private resumeReading(): void {
if (!this.suspended || !this.child) {
return;
}
this.suspended = false;
this.child.stdout.resume();
}
/**
* @param predicate - Condition à attendre, réévaluée à chaque nouveau résultat ou fin de parcours.
* @returns Une promesse résolue dès que `predicate` est vraie.
*/
private waitUntil(predicate: () => boolean): Promise<void> {
return new Promise((resolve) => {
if (predicate()) {
resolve();
return;
}
const check = (): void => {
if (predicate()) {
this.waiters.delete(check);
resolve();
}
};
this.waiters.add(check);
});
}
/** Réveille les appels en attente pour qu'ils réévaluent leur condition. */
private notify(): void {
for (const check of [...this.waiters]) {
check();
}
}
}

View file

@ -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<string> = 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=<id>`) 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=<id>`.
* @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}`;
}

View file

@ -0,0 +1,60 @@
/** Longueur maximale (en caractères) d'une requête de recherche. */
export const MAX_QUERY_LENGTH = 200;
/**
* Erreur levée quand la requête de recherche fournie est vide, trop longue
* ou contient des caractères de contrôle.
*/
export class InvalidSearchQueryError extends Error {
/**
* @param reason - Raison du refus, lisible par l'utilisateur.
*/
public constructor(reason: string) {
super(`Requête de recherche invalide : ${reason}`);
this.name = 'InvalidSearchQueryError';
}
}
/**
* @param text - Texte à inspecter.
* @returns `true` si `text` contient un caractère de contrôle (code < 0x20 ou DEL).
*/
function hasControlCharacter(text: string): boolean {
for (const char of text) {
const code = char.codePointAt(0) ?? 0;
if (code < 0x20 || code === 0x7f) {
return true;
}
}
return false;
}
/**
* Valide et nettoie une requête de recherche YouTube.
*
* La requête est transmise à yt-dlp sous la forme `ytsearchN:<requête>`, un
* seul argument qui commence toujours par `ytsearch` : elle ne peut donc
* jamais être prise pour une option, ni faire récupérer une autre URL (le
* préfixe restreint yt-dlp à la recherche YouTube). Les caractères de
* contrôle (retours à la ligne, NUL...) n'ont aucun sens dans une requête et
* sont refusés par prudence.
*
* @param query - Requête fournie par l'appelant.
* @returns La requête, débarrassée des espaces superflus.
* @throws {InvalidSearchQueryError} Si la requête est vide, trop longue ou contient un caractère de contrôle.
*/
export function assertValidSearchQuery(query: string): string {
const trimmed = query.trim();
if (trimmed.length === 0) {
throw new InvalidSearchQueryError('elle est vide.');
}
if (trimmed.length > MAX_QUERY_LENGTH) {
throw new InvalidSearchQueryError(`elle dépasse ${MAX_QUERY_LENGTH} caractères.`);
}
if (hasControlCharacter(trimmed)) {
throw new InvalidSearchQueryError('elle contient un caractère de contrôle.');
}
return trimmed;
}

View file

@ -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, unknown>): 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<string, unknown> | 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<string, unknown>;
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,
};
}

View file

@ -1,4 +1,4 @@
import { spawn } from 'node:child_process'; import { type ChildProcessWithoutNullStreams, spawn } from 'node:child_process';
/** /**
* Erreur levée quand le processus yt-dlp se termine en échec (code de * Erreur levée quand le processus yt-dlp se termine en échec (code de
@ -30,6 +30,13 @@ export interface RunYtDlpOptions {
readonly onStdoutLine?: (line: string) => void; readonly onStdoutLine?: (line: string) => void;
/** Signal d'annulation : le processus est tué si ce signal se déclenche. */ /** Signal d'annulation : le processus est tué si ce signal se déclenche. */
readonly signal?: AbortSignal; readonly signal?: AbortSignal;
/**
* Rappelé une fois, juste après le lancement du processus, avec le
* processus lui-même : permet à l'appelant d'en piloter les flux (ex.
* suspendre la lecture de stdout pour freiner un yt-dlp qui produit plus
* vite qu'on ne consomme).
*/
readonly onSpawn?: (child: ChildProcessWithoutNullStreams) => void;
} }
/** /**
@ -84,10 +91,11 @@ class LineSplitter {
*/ */
export function runYtDlp(args: readonly string[], options: RunYtDlpOptions = {}): Promise<string> { export function runYtDlp(args: readonly string[], options: RunYtDlpOptions = {}): Promise<string> {
const ytDlpPath = process.env.YTDLP_PATH ?? 'yt-dlp'; const ytDlpPath = process.env.YTDLP_PATH ?? 'yt-dlp';
const { timeoutMs = 30_000, onStdoutLine, signal } = options; const { timeoutMs = 30_000, onStdoutLine, signal, onSpawn } = options;
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const child = spawn(ytDlpPath, [...args], { shell: false, timeout: timeoutMs, signal }); const child = spawn(ytDlpPath, [...args], { shell: false, timeout: timeoutMs, signal });
onSpawn?.(child);
let stdout = ''; let stdout = '';
let stderr = ''; let stderr = '';

View file

@ -52,6 +52,16 @@ describe('QueueService', () => {
expect(final?.error).toBeNull(); expect(final?.error).toBeNull();
}); });
it('conserve la miniature fournie à enqueue() dans la vue publique', async () => {
vi.mocked(runYtDlp).mockImplementation(() => new Promise(() => {}));
const service = new QueueService(noopLogger, baseDir, 1);
const item = service.enqueue(url, videoMode, 'Titre', 'https://x/thumb.jpg');
expect(item.thumbnailUrl).toBe('https://x/thumb.jpg');
expect(service.get(item.id)?.thumbnailUrl).toBe('https://x/thumb.jpg');
});
it('respecte la concurrence maximale configurée', async () => { it('respecte la concurrence maximale configurée', async () => {
let concurrentCount = 0; let concurrentCount = 0;
let maxObservedConcurrent = 0; let maxObservedConcurrent = 0;

View file

@ -49,6 +49,26 @@ describe('ChannelDiscoveryService', () => {
); );
}); });
it('retombe sur le tableau "thumbnails" (forme réelle de yt-dlp en --flat-playlist)', async () => {
vi.mocked(runYtDlp).mockResolvedValue(
JSON.stringify({
id: 'd1',
title: 'Sans champ thumbnail singulier',
url: 'https://youtu.be/d1',
thumbnails: [
{ url: 'https://x/d1-small.jpg', height: 90 },
{ url: 'https://x/d1-large.jpg', height: 360 },
],
}),
);
const service = new ChannelDiscoveryService(noopLogger);
const results = await service.listChannel('@worldclimbing', 'videos');
expect(results).toHaveLength(1);
expect(results[0]?.thumbnailUrl).toBe('https://x/d1-large.jpg');
});
it('accepte une URL de chaîne complète et ignore la casse des mots-clés', async () => { it('accepte une URL de chaîne complète et ignore la casse des mots-clés', async () => {
vi.mocked(runYtDlp).mockResolvedValue( vi.mocked(runYtDlp).mockResolvedValue(
JSON.stringify({ id: 'c1', title: 'BIG Speed Final', url: 'https://youtu.be/c1' }), JSON.stringify({ id: 'c1', title: 'BIG Speed Final', url: 'https://youtu.be/c1' }),

94
server/test/fakeYtDlp.ts Normal file
View file

@ -0,0 +1,94 @@
import type { ChildProcessWithoutNullStreams } from 'node:child_process';
import { type Mock, type MockedFunction, vi } from 'vitest';
import type { RunYtDlpOptions, runYtDlp } from '../src/services/ytDlpRunner.js';
/** Un appel simulé à yt-dlp, pilotable depuis le test. */
export interface FakeYtDlpRun {
/** Arguments reçus par `runYtDlp`. */
readonly args: readonly string[];
/** Options reçues par `runYtDlp`. */
readonly options: RunYtDlpOptions;
/** Espions sur les méthodes de stdout du faux processus. */
readonly stdout: { pause: Mock; resume: Mock };
/** `true` dès que le signal d'annulation fourni à `runYtDlp` a été déclenché. */
readonly aborted: boolean;
/** Émet une ligne de stdout, comme le ferait le processus. */
emit: (line: string) => void;
/** Émet `count` entrées valides supplémentaires (numérotées à la suite des précédentes). */
emitEntries: (count: number) => void;
/** Termine le processus avec succès. */
finish: () => void;
/** Termine le processus en échec. */
fail: (error: Error) => void;
}
/**
* @param index - Numéro de l'entrée (à partir de 1).
* @returns Une ligne `yt-dlp --flat-playlist -j` valide pour cette entrée.
*/
export function entryLine(index: number): string {
return JSON.stringify({
id: `id${index}`,
title: `Vidéo ${index}`,
url: `https://www.youtube.com/watch?v=id${index}`,
duration: 60,
thumbnails: [{ url: `https://i.ytimg.com/vi/id${index}/hq.jpg` }],
});
}
/**
* Remplace `runYtDlp` par un faux qui reste « en cours » tant que le test ne
* le termine pas, et dont le test pilote la sortie ligne par ligne.
*
* @param mockedRunYtDlp - Le `runYtDlp` mocké du module testé.
* @returns La liste (alimentée au fil des appels) des exécutions simulées.
*/
export function installFakeYtDlp(mockedRunYtDlp: MockedFunction<typeof runYtDlp>): FakeYtDlpRun[] {
const runs: FakeYtDlpRun[] = [];
mockedRunYtDlp.mockImplementation((args, options = {}) => {
const stdout = { pause: vi.fn(), resume: vi.fn() };
let settle: { resolve: (value: string) => void; reject: (reason: unknown) => void } | null =
null;
const promise = new Promise<string>((resolve, reject) => {
settle = { resolve, reject };
});
let emitted = 0;
const run: FakeYtDlpRun = {
args,
options,
stdout,
get aborted() {
return options.signal?.aborted ?? false;
},
emit: (line) => {
options.onStdoutLine?.(line);
},
emitEntries: (count) => {
for (let i = 0; i < count; i += 1) {
emitted += 1;
options.onStdoutLine?.(entryLine(emitted));
}
},
finish: () => {
settle?.resolve('');
},
fail: (error) => {
settle?.reject(error);
},
};
options.signal?.addEventListener('abort', () => {
const error = new Error('yt-dlp annulé');
error.name = 'AbortError';
settle?.reject(error);
});
options.onSpawn?.({ stdout } as unknown as ChildProcessWithoutNullStreams);
runs.push(run);
return promise;
});
return runs;
}

View file

@ -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),
});
});
});

View file

@ -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<typeof import('../src/services/ytDlpRunner.js')>();
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);
});
});

View file

@ -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);
});
});

View file

@ -33,7 +33,12 @@ describe('routes de file d’attente', () => {
const response = await app.inject({ const response = await app.inject({
method: 'POST', method: 'POST',
url: '/api/queue', url: '/api/queue',
payload: { url: validUrl, mode: 'audio', audioFormat: 'opus' }, payload: {
url: validUrl,
mode: 'audio',
audioFormat: 'opus',
thumbnailUrl: 'https://x/thumb.jpg',
},
}); });
expect(response.statusCode).toBe(201); expect(response.statusCode).toBe(201);
@ -42,6 +47,7 @@ describe('routes de file d’attente', () => {
url: validUrl, url: validUrl,
mode: { kind: 'audio', format: 'opus' }, mode: { kind: 'audio', format: 'opus' },
status: expect.stringMatching(/pending|running/), status: expect.stringMatching(/pending|running/),
thumbnailUrl: 'https://x/thumb.jpg',
}); });
}); });

Binary file not shown.

View file

@ -0,0 +1,181 @@
import Fastify, { type FastifyInstance } from 'fastify';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('../src/services/ytDlpRunner.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../src/services/ytDlpRunner.js')>();
return { ...actual, runYtDlp: vi.fn() };
});
import { searchRoutes } from '../src/routes/search.js';
import { runYtDlp } from '../src/services/ytDlpRunner.js';
import { type FakeYtDlpRun, installFakeYtDlp } from './fakeYtDlp.js';
import { waitFor } from './helpers.js';
let runs: FakeYtDlpRun[];
let app: FastifyInstance;
/**
* Ouvre une recherche via l'API, en faisant produire `entries` résultats au yt-dlp simulé.
*
* @param entries - Nombre de résultats à produire.
* @param body - Corps de la requête `POST /api/search`.
* @returns La réponse de l'API et le yt-dlp simulé de la recherche.
*/
async function openSearch(entries: number, body: Record<string, unknown> = { query: 'test' }) {
const responsePromise = app.inject({ method: 'POST', url: '/api/search', payload: body });
await waitFor(() => runs.length > 0);
const run = runs.at(-1) as FakeYtDlpRun;
run.emitEntries(entries);
return { response: await responsePromise, run };
}
beforeEach(async () => {
vi.mocked(runYtDlp).mockReset();
runs = installFakeYtDlp(vi.mocked(runYtDlp));
app = Fastify();
await app.register(searchRoutes);
});
afterEach(async () => {
await app.close();
});
describe('POST /api/search', () => {
it('ouvre une recherche et retourne son identifiant et sa première page', async () => {
const { response } = await openSearch(30, { query: 'speed climbing', limit: 5 });
expect(response.statusCode).toBe(201);
const body = response.json();
expect(body.id).toEqual(expect.any(String));
expect(body.videos).toHaveLength(5);
expect(body.videos[0]).toEqual({
id: 'id1',
title: 'Vidéo 1',
url: 'https://www.youtube.com/watch?v=id1',
thumbnailUrl: 'https://i.ytimg.com/vi/id1/hq.jpg',
durationSeconds: 60,
uploadDate: null,
});
expect(body.nextOffset).toBe(5);
});
it('utilise une page de 20 par défaut', async () => {
const { response } = await openSearch(50);
expect(response.json().videos).toHaveLength(20);
});
it('avec un plafond (max), retourne tout d’un coup et signale la fin', async () => {
const { response, run } = await openSearch(50, { query: 'test', max: 12, limit: 12 });
const body = response.json();
expect(body.videos).toHaveLength(12);
expect(body.nextOffset).toBeNull();
expect(run.aborted).toBe(true);
});
it.each([
['une requête d’espaces', { query: ' ' }],
['une requête vide', { query: '' }],
['une requête trop longue', { query: 'a'.repeat(201) }],
['une requête absente', {}],
['une limite hors bornes', { query: 'x', limit: 5000 }],
['un plafond nul', { query: 'x', max: 0 }],
['un plafond non entier', { query: 'x', max: 1.5 }],
])('renvoie 400 pour %s, sans lancer yt-dlp', async (_label, body) => {
const response = await app.inject({ method: 'POST', url: '/api/search', payload: body });
expect(response.statusCode).toBe(400);
expect(runs).toHaveLength(0);
});
it('renvoie 502 (pas 500) quand yt-dlp échoue avant le moindre résultat', async () => {
const responsePromise = app.inject({
method: 'POST',
url: '/api/search',
payload: { query: 'test' },
});
await waitFor(() => runs.length > 0);
runs[0]?.fail(new Error('spawn yt-dlp ENOENT'));
const response = await responsePromise;
expect(response.statusCode).toBe(502);
});
});
describe('GET /api/search/:id', () => {
it('retourne les pages suivantes', async () => {
const { response } = await openSearch(60, { query: 'test', limit: 10 });
const { id } = response.json();
const second = await app.inject({ method: 'GET', url: `/api/search/${id}?offset=10&limit=10` });
expect(second.statusCode).toBe(200);
expect(second.json().videos.map((video: { id: string }) => video.id)).toEqual(
Array.from({ length: 10 }, (_, i) => `id${i + 11}`),
);
expect(second.json().nextOffset).toBe(20);
});
it('renvoie 404 pour une recherche inconnue', async () => {
const response = await app.inject({ method: 'GET', url: '/api/search/inconnue?offset=0' });
expect(response.statusCode).toBe(404);
});
it.each([
['un offset négatif', 'offset=-1'],
['un offset non numérique', 'offset=abc'],
['une limite nulle', 'limit=0'],
['une limite hors bornes', 'limit=5000'],
])('renvoie 400 pour %s', async (_label, query) => {
const response = await app.inject({ method: 'GET', url: `/api/search/quelconque?${query}` });
expect(response.statusCode).toBe(400);
});
it('renvoie 404 une fois la dernière page servie (la recherche est fermée)', async () => {
const responsePromise = app.inject({
method: 'POST',
url: '/api/search',
payload: { query: 'test', limit: 10 },
});
await waitFor(() => runs.length > 0);
runs[0]?.emitEntries(3);
runs[0]?.finish();
const { id, nextOffset } = (await responsePromise).json();
expect(nextOffset).toBeNull();
const response = await app.inject({ method: 'GET', url: `/api/search/${id}?offset=0` });
expect(response.statusCode).toBe(404);
});
});
describe('DELETE /api/search/:id', () => {
it('ferme la recherche (yt-dlp annulé), puis renvoie 404', async () => {
const { response, run } = await openSearch(30, { query: 'test', limit: 5 });
const { id } = response.json();
const deleted = await app.inject({ method: 'DELETE', url: `/api/search/${id}` });
expect(deleted.statusCode).toBe(204);
expect(run.aborted).toBe(true);
const again = await app.inject({ method: 'DELETE', url: `/api/search/${id}` });
expect(again.statusCode).toBe(404);
const page = await app.inject({ method: 'GET', url: `/api/search/${id}?offset=5` });
expect(page.statusCode).toBe(404);
});
});
describe('arrêt du serveur', () => {
it('ferme les recherches ouvertes (aucun yt-dlp ne survit à l’arrêt)', async () => {
const { run } = await openSearch(30, { query: 'test', limit: 5 });
expect(run.aborted).toBe(false);
await app.close();
expect(run.aborted).toBe(true);
// afterEach rappelle app.close() : il doit rester sans effet.
});
});

View file

@ -0,0 +1,189 @@
import type { FastifyBaseLogger } from 'fastify';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('../src/services/ytDlpRunner.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../src/services/ytDlpRunner.js')>();
return { ...actual, runYtDlp: vi.fn() };
});
import { SearchService, type SearchServiceOptions } from '../src/services/SearchService.js';
import { InvalidSearchQueryError } from '../src/services/searchQuery.js';
import { runYtDlp } from '../src/services/ytDlpRunner.js';
import { type FakeYtDlpRun, installFakeYtDlp } from './fakeYtDlp.js';
const noopLogger = { info: () => {}, error: () => {} } as unknown as FastifyBaseLogger;
let runs: FakeYtDlpRun[];
/**
* @param overrides - Réglages à surcharger.
* @returns Un service de test.
*/
function createService(overrides: Partial<SearchServiceOptions> = {}): SearchService {
return new SearchService(noopLogger, overrides);
}
/**
* Ouvre une recherche dont le yt-dlp simulé produit `entries` résultats.
*
* @param service - Service concerné.
* @param entries - Nombre de résultats à faire produire au yt-dlp simulé.
* @param limit - Taille de la première page.
* @returns L'identifiant de la recherche, sa première page, et son yt-dlp simulé.
*/
async function openSearch(
service: SearchService,
entries: number,
limit = 5,
): Promise<{ id: string; run: FakeYtDlpRun; nextOffset: number | null }> {
const started = service.start('speed climbing', 1000, limit);
const run = runs.at(-1);
if (!run) {
throw new Error('yt-dlp simulé non lancé');
}
run.emitEntries(entries);
const { id, page } = await started;
return { id, run, nextOffset: page.nextOffset };
}
beforeEach(() => {
vi.mocked(runYtDlp).mockReset();
runs = installFakeYtDlp(vi.mocked(runYtDlp));
});
afterEach(() => {
vi.useRealTimers();
});
describe('SearchService', () => {
it('ouvre une recherche et retourne son identifiant et sa première page', async () => {
const service = createService();
const { id, run, nextOffset } = await openSearch(service, 20, 5);
expect(id).toEqual(expect.any(String));
expect(nextOffset).toBe(5);
expect(run.args.at(-1)).toBe('ytsearch1000:speed climbing');
service.closeAll();
});
it('sert les pages suivantes d’une recherche ouverte', async () => {
const service = createService();
const { id } = await openSearch(service, 20, 5);
const page = await service.page(id, 5, 5);
expect(page?.videos.map((video) => video.id)).toEqual(['id6', 'id7', 'id8', 'id9', 'id10']);
expect(page?.nextOffset).toBe(10);
service.closeAll();
});
it('retourne null pour une recherche inconnue', async () => {
const service = createService();
await expect(service.page('inconnue', 0, 5)).resolves.toBeNull();
});
it('rejette une requête invalide sans jamais lancer yt-dlp', async () => {
const service = createService();
await expect(service.start(' ', 100, 5)).rejects.toBeInstanceOf(InvalidSearchQueryError);
expect(runs).toHaveLength(0);
});
it('ferme la recherche dès que sa dernière page a été servie', async () => {
const service = createService();
const started = service.start('speed climbing', 1000, 5);
runs[0]?.emitEntries(3);
runs[0]?.finish(); // fin des résultats : la première page est aussi la dernière
const { id, page } = await started;
expect(page.nextOffset).toBeNull();
await expect(service.page(id, 0, 5)).resolves.toBeNull();
});
it('dispose ferme la recherche (yt-dlp annulé) et n’existe plus ensuite', async () => {
const service = createService();
const { id, run } = await openSearch(service, 20, 5);
expect(service.dispose(id)).toBe(true);
expect(run.aborted).toBe(true);
expect(service.dispose(id)).toBe(false);
await expect(service.page(id, 5, 5)).resolves.toBeNull();
});
it('ferme la recherche et relance l’erreur quand la première page échoue', async () => {
const service = createService();
const started = service.start('speed climbing', 1000, 5);
runs[0]?.fail(new Error('boom'));
await expect(started).rejects.toThrow('boom');
expect(runs[0]?.aborted).toBe(true);
});
it('ferme la moins récemment utilisée quand la limite de recherches simultanées est atteinte', async () => {
const service = createService({ maxSessions: 2 });
const first = await openSearch(service, 20);
const second = await openSearch(service, 20);
// La première est la moins récemment utilisée : consulter la seconde le confirme.
await service.page(second.id, 5, 5);
const third = await openSearch(service, 20);
expect(first.run.aborted).toBe(true);
expect(second.run.aborted).toBe(false);
expect(third.run.aborted).toBe(false);
await expect(service.page(first.id, 5, 5)).resolves.toBeNull();
service.closeAll();
});
it('ferme les recherches inactives, et arrête son balayage quand il n’en reste plus', async () => {
vi.useFakeTimers();
const service = createService({ idleTtlMs: 1000, sweepIntervalMs: 500 });
const { run } = await openSearch(service, 20);
expect(vi.getTimerCount()).toBe(1); // le balayage tourne
await vi.advanceTimersByTimeAsync(2000);
expect(run.aborted).toBe(true);
expect(vi.getTimerCount()).toBe(0); // plus rien à balayer
});
it('ne ferme pas une recherche encore active', async () => {
vi.useFakeTimers();
const service = createService({ idleTtlMs: 1000, sweepIntervalMs: 500 });
const { id, run } = await openSearch(service, 40, 5);
for (let offset = 5; offset <= 25; offset += 5) {
await vi.advanceTimersByTimeAsync(800); // toujours sous le délai d'inactivité
await service.page(id, offset, 5);
}
expect(run.aborted).toBe(false);
service.closeAll();
});
it('ferme une recherche qui dépasse sa durée de vie maximale, même active', async () => {
vi.useFakeTimers();
const service = createService({ idleTtlMs: 10_000, maxLifetimeMs: 2000, sweepIntervalMs: 500 });
const { id, run } = await openSearch(service, 40, 5);
await vi.advanceTimersByTimeAsync(1000);
await service.page(id, 5, 5); // activité récente...
await vi.advanceTimersByTimeAsync(1500); // ...mais durée de vie dépassée
expect(run.aborted).toBe(true);
});
it('closeAll ferme toutes les recherches', async () => {
const service = createService();
const first = await openSearch(service, 20);
const second = await openSearch(service, 20);
service.closeAll();
expect(first.run.aborted).toBe(true);
expect(second.run.aborted).toBe(true);
});
});

View file

@ -0,0 +1,251 @@
import type { FastifyBaseLogger } from 'fastify';
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('../src/services/ytDlpRunner.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../src/services/ytDlpRunner.js')>();
return { ...actual, runYtDlp: vi.fn() };
});
import {
SEARCH_DEPTH,
SearchSession,
type SearchSessionOptions,
} from '../src/services/SearchSession.js';
import { runYtDlp } from '../src/services/ytDlpRunner.js';
import { type FakeYtDlpRun, entryLine, installFakeYtDlp } from './fakeYtDlp.js';
let runs: FakeYtDlpRun[];
let logger: { info: ReturnType<typeof vi.fn>; error: ReturnType<typeof vi.fn> };
/**
* @param overrides - Réglages à surcharger.
* @returns Des réglages de session de test (plafond large, longue avance).
*/
function options(overrides: Partial<SearchSessionOptions> = {}): SearchSessionOptions {
return { query: 'speed climbing', max: 1000, readAhead: 20, lifetimeMs: 60_000, ...overrides };
}
/**
* @param overrides - Réglages à surcharger.
* @returns Une session ouverte avec le logger de test (son yt-dlp simulé est `runs.at(-1)`).
*/
function open(overrides: Partial<SearchSessionOptions> = {}): SearchSession {
return new SearchSession(logger as unknown as FastifyBaseLogger, options(overrides));
}
/** Laisse s'exécuter les micro-tâches et les callbacks en attente. */
const flush = (): Promise<void> => new Promise((resolve) => setTimeout(resolve, 0));
beforeEach(() => {
vi.mocked(runYtDlp).mockReset();
runs = installFakeYtDlp(vi.mocked(runYtDlp));
logger = { info: vi.fn(), error: vi.fn() };
});
describe('SearchSession', () => {
it('lance yt-dlp en mode paresseux, avec la requête (préfixe ytsearch) et la durée de vie demandée', () => {
open({ query: 'escalade été', lifetimeMs: 12_345 });
expect(runs).toHaveLength(1);
expect(runs[0]?.args).toEqual([
'--flat-playlist',
'--lazy-playlist',
'-j',
'--',
`ytsearch${SEARCH_DEPTH}:escalade été`,
]);
expect(runs[0]?.options.timeoutMs).toBe(12_345);
});
it('attend d’avoir assez de résultats avant de répondre à une page', async () => {
const session = open();
let settled = false;
const pagePromise = session.page(0, 3).then((page) => {
settled = true;
return page;
});
runs[0]?.emitEntries(2);
await flush();
expect(settled).toBe(false);
runs[0]?.emitEntries(1);
const page = await pagePromise;
expect(page.videos.map((video) => video.id)).toEqual(['id1', 'id2', 'id3']);
expect(page.nextOffset).toBe(3);
});
it('sert les pages suivantes depuis le tampon, sans relancer yt-dlp', async () => {
const session = open();
const first = session.page(0, 2);
runs[0]?.emitEntries(6);
await first;
const second = await session.page(2, 2);
const third = await session.page(4, 2);
expect(second.videos.map((video) => video.id)).toEqual(['id3', 'id4']);
expect(third.videos.map((video) => video.id)).toEqual(['id5', 'id6']);
expect(runs).toHaveLength(1);
});
it('ignore les doublons, les lignes illisibles et les entrées incomplètes', async () => {
const session = open();
const pagePromise = session.page(0, 10);
runs[0]?.emit(entryLine(1));
runs[0]?.emit(entryLine(1)); // doublon : YouTube répète parfois une vidéo
runs[0]?.emit('ceci n’est pas du JSON');
runs[0]?.emit(JSON.stringify({ id: 'sans-url', title: 'Incomplète' }));
runs[0]?.emit(entryLine(2));
runs[0]?.finish();
const page = await pagePromise;
expect(page.videos.map((video) => video.id)).toEqual(['id1', 'id2']);
});
it('signale la fin des résultats : page partielle et nextOffset null', async () => {
const session = open();
const pagePromise = session.page(0, 5);
runs[0]?.emitEntries(2);
runs[0]?.finish();
const page = await pagePromise;
expect(page.videos).toHaveLength(2);
expect(page.nextOffset).toBeNull();
});
it('signale la fin par une page vide quand la dernière page tombe pile sur le dernier résultat', async () => {
const session = open();
const first = session.page(0, 4);
runs[0]?.emitEntries(4);
const firstPage = await first;
// yt-dlp n'a pas encore signalé sa fin au moment où la page est pleine :
// une suite reste possible, la page suivante tranche.
expect(firstPage.videos).toHaveLength(4);
expect(firstPage.nextOffset).toBe(4);
runs[0]?.finish();
const next = await session.page(4, 4);
expect(next).toEqual({ videos: [], nextOffset: null });
});
it('renvoie une page vide et la fin quand on demande au-delà des résultats', async () => {
const session = open();
const first = session.page(0, 2);
runs[0]?.emitEntries(2);
runs[0]?.finish();
await first;
const beyond = await session.page(50, 10);
expect(beyond).toEqual({ videos: [], nextOffset: null });
});
it('s’arrête au plafond : annule yt-dlp, ignore la suite et signale la fin', async () => {
const session = open({ max: 3 });
const pagePromise = session.page(0, 10);
runs[0]?.emitEntries(5);
const page = await pagePromise;
expect(page.videos.map((video) => video.id)).toEqual(['id1', 'id2', 'id3']);
expect(page.nextOffset).toBeNull();
expect(runs[0]?.aborted).toBe(true);
expect(logger.error).not.toHaveBeenCalled();
});
it('suspend la lecture quand le tampon a assez d’avance, et la reprend à la demande', async () => {
const session = open({ readAhead: 2 });
const first = session.page(0, 2); // demande : 2 résultats
runs[0]?.emitEntries(3);
await first;
expect(runs[0]?.stdout.pause).not.toHaveBeenCalled(); // 3 < 2 + 2
runs[0]?.emitEntries(1); // 4 >= 2 + 2 : assez d'avance
expect(runs[0]?.stdout.pause).toHaveBeenCalledTimes(1);
expect(runs[0]?.stdout.resume).not.toHaveBeenCalled();
const next = await session.page(2, 2); // servi depuis le tampon...
expect(next.videos.map((video) => video.id)).toEqual(['id3', 'id4']);
expect(runs[0]?.stdout.resume).toHaveBeenCalledTimes(1); // ...et le parcours repart
});
it('ne suspend qu’une fois tant qu’il n’a pas été relancé', async () => {
const session = open({ readAhead: 1 });
const first = session.page(0, 1);
runs[0]?.emitEntries(2); // suspend
await first;
runs[0]?.emitEntries(3); // encore des lignes déjà dans le tube au moment de la pause
expect(runs[0]?.stdout.pause).toHaveBeenCalledTimes(1);
});
it('lève l’erreur quand yt-dlp échoue avant le moindre résultat', async () => {
const session = open();
const pagePromise = session.page(0, 5);
runs[0]?.fail(new Error('boom'));
await expect(pagePromise).rejects.toThrow('boom');
expect(logger.error).toHaveBeenCalled();
});
it('sert ce qu’il a et signale la fin quand yt-dlp échoue après des résultats', async () => {
const session = open();
const pagePromise = session.page(0, 10);
runs[0]?.emitEntries(2);
runs[0]?.fail(new Error('réseau coupé'));
const page = await pagePromise;
expect(page.videos).toHaveLength(2);
expect(page.nextOffset).toBeNull();
expect(logger.error).toHaveBeenCalled();
});
it('dispose : annule yt-dlp sans le compter comme un échec, et libère les appels en attente', async () => {
const session = open();
const pending = session.page(0, 5);
session.dispose('test');
const page = await pending;
await flush();
expect(runs[0]?.aborted).toBe(true);
expect(page).toEqual({ videos: [], nextOffset: null });
expect(logger.error).not.toHaveBeenCalled();
});
it('dispose relance un flux suspendu pour qu’il puisse se vider et se fermer', async () => {
const session = open({ readAhead: 1 });
const first = session.page(0, 1);
runs[0]?.emitEntries(2); // suspend
await first;
expect(runs[0]?.stdout.pause).toHaveBeenCalledTimes(1);
session.dispose('test');
expect(runs[0]?.stdout.resume).toHaveBeenCalledTimes(1);
});
it('dispose est idempotent', () => {
const session = open();
session.dispose('premier');
session.dispose('second');
expect(logger.info).toHaveBeenCalledWith(
expect.objectContaining({ reason: 'premier' }),
expect.any(String),
);
expect(logger.info).not.toHaveBeenCalledWith(
expect.objectContaining({ reason: 'second' }),
expect.any(String),
);
});
});

View file

@ -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();
},
);
});

View file

@ -45,6 +45,36 @@ describe('runYtDlp', () => {
expect(lines).toEqual(['sans-newline']); expect(lines).toEqual(['sans-newline']);
}); });
it('donne le processus lancé à onSpawn, avant toute sortie', async () => {
process.env.YTDLP_PATH = process.execPath;
const spawns: Array<{
pid: number | undefined;
canPause: boolean;
canResume: boolean;
linesSoFar: number;
}> = [];
let lines = 0;
await runYtDlp(['-e', "console.log('a')"], {
onSpawn: (child) => {
spawns.push({
pid: child.pid,
canPause: typeof child.stdout.pause === 'function',
canResume: typeof child.stdout.resume === 'function',
linesSoFar: lines,
});
},
onStdoutLine: () => {
lines += 1;
},
});
expect(spawns).toEqual([
{ pid: expect.any(Number), canPause: true, canResume: true, linesSoFar: 0 },
]);
expect(lines).toBe(1);
});
it("rejette avec une AbortError quand le signal d'annulation se déclenche", async () => { it("rejette avec une AbortError quand le signal d'annulation se déclenche", async () => {
process.env.YTDLP_PATH = process.execPath; process.env.YTDLP_PATH = process.execPath;
const controller = new AbortController(); const controller = new AbortController();

View file

@ -19,34 +19,46 @@ 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`). - 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/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/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`, `search.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/` : - `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`. - `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), l'annulation via `AbortSignal`, et `onSpawn` (reçoit le processus lancé, pour en piloter les flux - utilisé par la recherche pour suspendre stdout).
- `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. - `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 -- <url canonique>`, 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=<id>`, 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).
- `SearchService.ts` / `SearchSession.ts` / `searchQuery.ts` : recherche YouTube globale (`ytsearchN:`), paginée. Une **session** (`SearchSession`) = un seul processus yt-dlp (`--flat-playlist --lazy-playlist -j -- ytsearch1000:<requête>`) dont les pages sont servies depuis un tampon ; `SearchService` gère le parc de sessions (4 simultanées max - la moins récemment utilisée est fermée au-delà ; expiration après 10 min d'inactivité ou 30 min de vie, balayage toutes les 30 s ; fermeture dès que la dernière page est servie, sur `DELETE`, et à l'arrêt du serveur via le hook `onClose`). Pourquoi une session plutôt qu'un appel yt-dlp par page - **mesuré avec le vrai yt-dlp** : (1) l'ordre des résultats de YouTube n'est **pas déterministe, même pour deux appels identiques** (5 résultats sur 20 à la même position, 69 % de recouvrement sur 100) : paginer en relançant la recherche et en tranchant (`offset`/`limit`) donnait 15 à 30 % de doublons dès la 3e-4e page et des résultats jamais montrés ; (2) YouTube s'arrête de lui-même vers **~590 résultats** même si on en demande 1000 ; (3) environ 7 % des résultats sont des doublons d'une même vidéo (dédoublonnés à l'ingestion, donc le décalage `offset` reste cohérent) ; (4) sans `--lazy-playlist`, yt-dlp matérialise toute la liste avant d'imprimer la première ligne (~23 s pour 1000) : avec, les résultats arrivent au fil des pages (20 en ~2,3 s). **Le parcours n'avance qu'à la demande** : dès que le tampon a 20 résultats d'avance sur ce qui a été demandé, la lecture de stdout est suspendue (`child.stdout.pause()` via `onSpawn`) ; le tube se remplit et yt-dlp bloque sur son écriture (mesuré : plus aucune ligne pendant 10 s de pause, reprise au rythme normal ensuite) - on n'aspire donc pas ~590 résultats (et ~30 requêtes vers YouTube) quand l'utilisateur ne défile pas. Vérifié en boîte noire : une page déjà tamponnée répond en ~4 ms, une page 200 demandée après 12 s d'inactivité en ~4 s. `max` plafonne le nombre de résultats (au plafond, yt-dlp est annulé) : c'est ainsi que fonctionne le mode « limite ». Un échec de yt-dlp avant tout résultat lève une erreur ; après des résultats, on sert le tampon et on signale la fin. Sous Windows, tuer le parent d'un exécutable PyInstaller n'arrête pas son enfant, qui finit son parcours puis s'arrête seul (~30 s, transitoire, dev seulement) ; sous Linux (production) le lanceur transmet SIGTERM à l'enfant, comme pour l'annulation de téléchargements. `searchQuery.ts` valide la requête (1 à 200 caractères, sans caractère de contrôle) : elle est transmise sous la forme d'un unique argument `ytsearchN:<requête>`, qui ne peut jamais être pris pour une option ni faire récupérer une autre URL.
- `videoSummary.ts` : type `VideoSummary` et `summarizeEntry()`, conversion partagée d'une entrée `--flat-playlist` (chaîne, playlist ou recherche) 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. - `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). - `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. - `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`. - 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. `test/fakeYtDlp.ts` simule un yt-dlp en flux (le test émet les lignes une à une et espionne `pause`/`resume` d'un faux stdout) pour tester `SearchSession`/`SearchService`/la route sans réseau ; le comportement du tube réel (une pause arrête bel et bien yt-dlp) n'est **pas** testé automatiquement - un enfant `node` écrit de façon synchrone dans un tube sous POSIX mais asynchrone sous Windows, donc un tel test ne serait pas portable - il a été mesuré à la main avec le vrai yt-dlp. 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/`) ## 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é). - 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/`). - 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. - 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/api/` : clients fetch vers le backend (`channelApi.ts`, `playlistApi.ts`, `searchApi.ts`, `queueApi.ts`), avec leurs propres types DTO (`videoDto.ts` pour la vidéo listée et la page de vidéos, communes aux chaînes, playlists et recherches) - 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/searchLimit.ts` : `parseSearchLimit()`, fonction pure qui lit le champ « Limite » du formulaire de recherche (entier >= 1, sinon « pas de limite »).
- `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 (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. - `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, trois 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 YouTube globale (mots-clés + « Limite » optionnelle) via `/api/search` ; (3) la recherche de chaîne via `GET /api/channel`. Toutes 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 » (volontairement absent des recherches : demandé ainsi, et une recherche peut compter des centaines de résultats).
- Recherche globale : avec une **limite** renseignée, tous les résultats arrivent d'un coup (`max` = `limit` = N, une seule page) ; **sans limite**, la liste défile à l'infini (`showPaged`) : un élément « sentinelle » en fin de liste, observé par un `IntersectionObserver` dont la racine est la liste elle-même (marge de 300 px pour charger un peu avant la fin), déclenche le chargement de la page suivante (`nextOffset` fourni par le serveur, pages de 20). Après chaque page l'observateur est **réarmé** (`unobserve` puis `observe`) : sans cela, une page trop courte pour remplir la zone laisserait la sentinelle visible sans qu'aucun nouveau signal ne soit émis. La sentinelle affiche l'état : chargement, erreur avec « Réessayer » (une page en échec ne fait pas perdre celles déjà affichées, et rien ne boucle tout seul), ou « Fin des résultats ». Un indicateur de chargement en cours empêche deux requêtes de page simultanées, et le compteur de « génération » écarte une réponse arrivée après le remplacement de la liste. La recherche reste ouverte côté serveur tant qu'il reste des pages : elle est fermée (`DELETE /api/search/:id`) quand la liste est remplacée ou vidée (rappel `onClose`) et au `pagehide` (fermeture ou rechargement de l'onglet, `fetch` avec `keepalive`) ; à défaut, l'expiration serveur (10 min) s'en charge. 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. - 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. - 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.
- Filet pour les écrans courts (ajouté avec le troisième formulaire, dont les ~65 px de plus laissaient à la liste ~104 px, soit une seule ligne, à 1366x650) : une liste non vide (`&:not(:empty)`) garde `min-height: 13rem` (~2 lignes), et `.download-form` passe en `overflow-y: auto`, de sorte que sur un écran trop court c'est **la colonne du formulaire qui défile** au lieu de rogner la liste ou des champs (la page, elle, ne défile toujours jamais). Sans effet tant que tout tient. Mesuré (DOM) : 1920x950 -> liste de 406 px, colonne fixe ; 1366x650 -> liste de 208 px, colonne défilante ; 800x450 -> idem, tout reste atteignable. Si trois entrées empilées deviennent trop gourmandes, des onglets (URL / recherche / chaîne) libéreraient ~130 px.
- Tests : Vitest (`client/test/`), uniquement sur les fonctions pures (`format.ts`) pour l'instant - pas de configuration jsdom, le câblage DOM (formulaires, rendu de liste) est vérifié manuellement dans le navigateur plutôt que testé automatiquement. - Tests : Vitest (`client/test/`), uniquement sur les fonctions pures (`format.ts`) pour l'instant - pas de configuration jsdom, le câblage DOM (formulaires, rendu de liste) est vérifié manuellement dans le navigateur plutôt que testé automatiquement.
- `src/styles/base.scss` : palette et styles de base partagés (custom properties CSS sur `:root`, pas de `$variables` Sass - une variable Sass ne traverserait pas les fichiers, chaque vue étant compilée/importée séparément ; une custom property CSS si, puisqu'elle se résout à l'exécution dans le navigateur une fois tout le CSS regroupé par esbuild). Importé une fois depuis `app.ts`. - `src/styles/base.scss` : palette et styles de base partagés (custom properties CSS sur `:root`, pas de `$variables` Sass - une variable Sass ne traverserait pas les fichiers, chaque vue étant compilée/importée séparément ; une custom property CSS si, puisqu'elle se résout à l'exécution dans le navigateur une fois tout le CSS regroupé par esbuild). Importé une fois depuis `app.ts`. Thème sombre/clair automatique selon `prefers-color-scheme` (pas de bascule manuelle) : un unique bloc `@media (prefers-color-scheme: dark)` redéfinit les mêmes custom properties dans `:root` - fonctionne partout car aucune autre feuille ne code de couleur en dur (vérifié : toutes passent par `var(--color-*)`).
## API ## API
- `GET /health` : statut du service (utilisé par le `HEALTHCHECK` Docker). - `GET /health` : statut du service (utilisé par le `HEALTHCHECK` Docker).
- `GET /api/channel?channel=<@handle|URL>&section=<videos|streams>&keywords=<a,b>` : 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/channel?channel=<@handle|URL>&section=<videos|streams>&keywords=<a,b>` : 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.
- `POST /api/queue` : ajoute un élément (`{ url, title?, mode: "video"|"audio"|"thumbnail", subtitles?, embedThumbnail?, audioFormat? }`). `title` est purement informatif (affichage), jamais utilisé pour construire la commande yt-dlp. `201 { item }`, `400` si l'URL n'est pas une URL de vidéo YouTube reconnue. - `POST /api/search` (`{ query, max?, limit? }`), `GET /api/search/:id?offset=&limit=`, `DELETE /api/search/:id` : recherche YouTube globale paginée (voir `SearchSession`). `POST` ouvre une recherche et retourne `201 { id, videos, nextOffset }` (première page, `limit` par défaut 20 ; `max` plafonne le nombre total de résultats, absent = jusqu'à la fin de ceux de YouTube, ~590) ; `GET` retourne `200 { videos, nextOffset }` pour la page qui commence à `offset` ; `nextOffset` vaut `null` en fin de résultats (la dernière page peut être vide : la page pleine qui tombe pile sur le dernier résultat ne sait pas encore que yt-dlp a fini). La recherche est **refermée par le serveur** dès que la dernière page a été servie. Réponses d'erreur : `400` (requête vide, de plus de 200 caractères ou avec caractère de contrôle ; `limit`/`max`/`offset` hors bornes), `404` (recherche inconnue, expirée ou déjà refermée - message « relancez-la »), `502` (yt-dlp échoue avant le moindre résultat). `DELETE` répond `204` (ou `404` si déjà refermée).
- `GET /api/playlist?url=<lien de playlist>` : 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`. - `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. - `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.
- `GET /api/queue/:id/file` : télécharge le fichier produit par un élément `completed` (`Content-Disposition: attachment`). Le dossier de sortie de l'élément est supprimé juste après l'envoi, et `fileName` repassé à `null` (un second appel renvoie `404`, pas une tentative de relire un fichier déjà supprimé). `404` tant que l'élément n'est pas `completed` avec un fichier disponible. - `GET /api/queue/:id/file` : télécharge le fichier produit par un élément `completed` (`Content-Disposition: attachment`). Le dossier de sortie de l'élément est supprimé juste après l'envoi, et `fileName` repassé à `null` (un second appel renvoie `404`, pas une tentative de relire un fichier déjà supprimé). `404` tant que l'élément n'est pas `completed` avec un fichier disponible.
@ -65,5 +77,5 @@ Biome.js à la racine (`biome.json`), applique aux deux paquets (`server/src`, `
## Statut ## 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), recherche YouTube globale paginée en défilement infini (`/api/search*`), 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). - À 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).