Les trois PR (découverte de chaîne, file d'attente/téléchargement, frontend) avaient été mergées côté Forgejo dans le désordre par rapport à la pile (traitement asynchrone côté serveur qui n'a pas respecté l'ordre des appels), laissant main avec seulement le contenu de la PR #1. Ce merge apporte le contenu complet de la pile (déjà validé : lint/typecheck/tests/build clean, testé en navigateur réel), via feature/frontend-ui qui contenait tout linéairement. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
commit
96460965bd
36 changed files with 3750 additions and 52 deletions
|
|
@ -84,6 +84,10 @@ jobs:
|
||||||
working-directory: client
|
working-directory: client
|
||||||
run: npm run typecheck
|
run: npm run typecheck
|
||||||
|
|
||||||
|
- name: Client - lancer les tests
|
||||||
|
working-directory: client
|
||||||
|
run: npm test
|
||||||
|
|
||||||
- name: Client - build
|
- name: Client - build
|
||||||
working-directory: client
|
working-directory: client
|
||||||
run: npm run build
|
run: npm run build
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ const buildOptions = {
|
||||||
outfile: path.join(outdir, 'app.js'),
|
outfile: path.join(outdir, 'app.js'),
|
||||||
bundle: true,
|
bundle: true,
|
||||||
format: 'esm',
|
format: 'esm',
|
||||||
target: 'es2020',
|
target: 'es2022',
|
||||||
sourcemap: true,
|
sourcemap: true,
|
||||||
// Chaque vue importe son propre gabarit comme chaîne brute (`import html
|
// Chaque vue importe son propre gabarit comme chaîne brute (`import html
|
||||||
// from './Vue.html'` - voir global.d.ts) et son propre style Sass ; les
|
// from './Vue.html'` - voir global.d.ts) et son propre style Sass ; les
|
||||||
|
|
|
||||||
1443
client/package-lock.json
generated
1443
client/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -5,11 +5,13 @@
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "node build.mjs",
|
"build": "node build.mjs",
|
||||||
"watch": "node build.mjs --watch",
|
"watch": "node build.mjs --watch",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit",
|
||||||
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"esbuild": "^0.28.2",
|
"esbuild": "^0.28.2",
|
||||||
"sass": "^1.83.4",
|
"sass": "^1.83.4",
|
||||||
"typescript": "^5.7.3"
|
"typescript": "^5.7.3",
|
||||||
|
"vitest": "^5.0.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
63
client/src/api/channelApi.ts
Normal file
63
client/src/api/channelApi.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
/** Vidéo/stream listé depuis une chaîne (miroir du DTO serveur). */
|
||||||
|
export interface ChannelVideoDto {
|
||||||
|
/** Identifiant YouTube de la vidéo. */
|
||||||
|
id: string;
|
||||||
|
/** Titre de la vidéo. */
|
||||||
|
title: string;
|
||||||
|
/** URL complète de la vidéo. */
|
||||||
|
url: string;
|
||||||
|
/** URL de la miniature, si connue. */
|
||||||
|
thumbnailUrl: string | null;
|
||||||
|
/** Durée en secondes, si connue. */
|
||||||
|
durationSeconds: number | null;
|
||||||
|
/** Date de mise en ligne au format `YYYYMMDD`, si connue. */
|
||||||
|
uploadDate: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Section d'une chaîne YouTube à parcourir. */
|
||||||
|
export type ChannelSection = 'videos' | 'streams';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interroge `GET /api/channel` pour lister les vidéos/streams d'une chaîne.
|
||||||
|
*
|
||||||
|
* @param channel - Handle (`@nom`) ou URL de la chaîne à parcourir.
|
||||||
|
* @param section - Section à parcourir.
|
||||||
|
* @param keywords - Mots-clés séparés par des virgules (optionnel).
|
||||||
|
* @returns La liste des vidéos correspondantes.
|
||||||
|
* @throws {Error} Si la requête échoue ou si le serveur répond une erreur.
|
||||||
|
*/
|
||||||
|
export async function searchChannel(
|
||||||
|
channel: string,
|
||||||
|
section: ChannelSection,
|
||||||
|
keywords: string,
|
||||||
|
): Promise<ChannelVideoDto[]> {
|
||||||
|
const params = new URLSearchParams({ channel, section });
|
||||||
|
if (keywords.trim().length > 0) {
|
||||||
|
params.set('keywords', keywords.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetch(`/api/channel?${params.toString()}`);
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error('Impossible de contacter le serveur.', { cause: error });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
let message = `Échec de la recherche (${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 {
|
||||||
|
const body = (await response.json()) as { videos: ChannelVideoDto[] };
|
||||||
|
return body.videos;
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error('Réponse du serveur invalide.', { cause: error });
|
||||||
|
}
|
||||||
|
}
|
||||||
137
client/src/api/queueApi.ts
Normal file
137
client/src/api/queueApi.ts
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
/** État d'avancement d'un élément de la file d'attente (miroir du DTO serveur). */
|
||||||
|
export type QueueItemStatus = 'pending' | 'running' | 'completed' | 'failed' | 'cancelled';
|
||||||
|
|
||||||
|
/** Mode de téléchargement d'un élément (miroir du DTO serveur). */
|
||||||
|
export type QueueItemMode =
|
||||||
|
| { kind: 'video'; options: { subtitles: boolean; embedThumbnail: boolean } }
|
||||||
|
| { kind: 'audio'; format: 'mp3' | 'm4a' | 'opus' }
|
||||||
|
| { kind: 'thumbnail' };
|
||||||
|
|
||||||
|
/** Élément de la file d'attente, tel que renvoyé par le serveur. */
|
||||||
|
export interface QueueItemDto {
|
||||||
|
id: string;
|
||||||
|
url: string;
|
||||||
|
title: string | null;
|
||||||
|
mode: QueueItemMode;
|
||||||
|
status: QueueItemStatus;
|
||||||
|
progress: number;
|
||||||
|
error: string | null;
|
||||||
|
fileName: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Entrée acceptée par {@link enqueueItem} pour choisir le mode et ses options. */
|
||||||
|
export type EnqueueMode =
|
||||||
|
| { mode: 'video'; subtitles: boolean; embedThumbnail: boolean }
|
||||||
|
| { mode: 'audio'; audioFormat: 'mp3' | 'm4a' | 'opus' }
|
||||||
|
| { mode: 'thumbnail' };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
* @returns Le message d'erreur à afficher.
|
||||||
|
*/
|
||||||
|
async function readErrorMessage(response: Response): Promise<string> {
|
||||||
|
try {
|
||||||
|
const body = (await response.json()) as { error?: string };
|
||||||
|
return body.error ?? `Échec de la requête (${response.status}).`;
|
||||||
|
} catch {
|
||||||
|
return `Échec de la requête (${response.status}).`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ajoute un élément à la file d'attente via `POST /api/queue`.
|
||||||
|
*
|
||||||
|
* @param url - URL de la vidéo YouTube à télécharger.
|
||||||
|
* @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).
|
||||||
|
* @returns L'élément créé.
|
||||||
|
* @throws {Error} Si la requête échoue ou si le serveur refuse l'ajout.
|
||||||
|
*/
|
||||||
|
export async function enqueueItem(
|
||||||
|
url: string,
|
||||||
|
mode: EnqueueMode,
|
||||||
|
title: string | null,
|
||||||
|
): Promise<QueueItemDto> {
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetch('/api/queue', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ url, title: title ?? undefined, ...mode }),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error('Impossible de contacter le serveur.', { cause: error });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await readErrorMessage(response));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = (await response.json()) as { item: QueueItemDto };
|
||||||
|
return body.item;
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error('Réponse du serveur invalide.', { cause: error });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liste tous les éléments de la file d'attente via `GET /api/queue`.
|
||||||
|
*
|
||||||
|
* @returns Les éléments de la file, dans leur ordre d'ajout.
|
||||||
|
* @throws {Error} Si la requête échoue.
|
||||||
|
*/
|
||||||
|
export async function listQueueItems(): Promise<QueueItemDto[]> {
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetch('/api/queue');
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error('Impossible de contacter le serveur.', { cause: error });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await readErrorMessage(response));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = (await response.json()) as { items: QueueItemDto[] };
|
||||||
|
return body.items;
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error('Réponse du serveur invalide.', { cause: error });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Annule un élément de la file d'attente via `DELETE /api/queue/:id`.
|
||||||
|
*
|
||||||
|
* @param id - Identifiant de l'élément à annuler.
|
||||||
|
* @throws {Error} Si la requête échoue ou si l'élément est introuvable.
|
||||||
|
*/
|
||||||
|
export async function cancelQueueItem(id: string): Promise<void> {
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetch(`/api/queue/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error('Impossible de contacter le serveur.', { cause: error });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok && response.status !== 204) {
|
||||||
|
throw new Error(await readErrorMessage(response));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construit l'URL de téléchargement du fichier produit par un élément
|
||||||
|
* terminé (`GET /api/queue/:id/file`) — destinée à un lien `<a>`, pas à un
|
||||||
|
* appel `fetch` : c'est le navigateur qui doit gérer le téléchargement.
|
||||||
|
*
|
||||||
|
* @param id - Identifiant de l'élément terminé.
|
||||||
|
* @returns L'URL à utiliser comme `href`.
|
||||||
|
*/
|
||||||
|
export function queueItemFileUrl(id: string): string {
|
||||||
|
return `/api/queue/${encodeURIComponent(id)}/file`;
|
||||||
|
}
|
||||||
|
|
@ -1,15 +1,21 @@
|
||||||
import { mountShell } from './shell/Shell';
|
import { mountShell } from './shell/Shell';
|
||||||
|
import { mountDownloadForm } from './views/downloadForm/DownloadForm';
|
||||||
|
import { mountQueuePanel } from './views/queuePanel/QueuePanel';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Point d'entrée du client : récupère l'élément racine et y monte le shell
|
* Point d'entrée du client : monte le shell puis les deux vues qu'il
|
||||||
* initial de l'application.
|
* héberge (formulaire d'ajout et panneau de file d'attente), et les relie
|
||||||
|
* entre elles (un ajout réussi rafraîchit immédiatement la file).
|
||||||
*/
|
*/
|
||||||
function main(): void {
|
function main(): void {
|
||||||
const root = document.getElementById('app');
|
const root = document.getElementById('app');
|
||||||
if (!root) {
|
if (!root) {
|
||||||
throw new Error("Élément racine '#app' introuvable dans le document.");
|
throw new Error("Élément racine '#app' introuvable dans le document.");
|
||||||
}
|
}
|
||||||
mountShell(root);
|
|
||||||
|
const { downloadFormRoot, queuePanelRoot } = mountShell(root);
|
||||||
|
const queuePanel = mountQueuePanel(queuePanelRoot);
|
||||||
|
mountDownloadForm(downloadFormRoot, { onItemQueued: () => queuePanel.refresh() });
|
||||||
}
|
}
|
||||||
|
|
||||||
main();
|
main();
|
||||||
|
|
|
||||||
37
client/src/format.ts
Normal file
37
client/src/format.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
/**
|
||||||
|
* Formate une durée en secondes au format `m:ss` (ou `h:mm:ss` au-delà d'une heure).
|
||||||
|
*
|
||||||
|
* @param seconds - Durée en secondes, ou `null` si inconnue.
|
||||||
|
* @returns La durée formatée, ou une chaîne vide si inconnue.
|
||||||
|
*/
|
||||||
|
export function formatDuration(seconds: number | null): string {
|
||||||
|
if (seconds === null || !Number.isFinite(seconds)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
const totalSeconds = Math.max(0, Math.round(seconds));
|
||||||
|
const hours = Math.floor(totalSeconds / 3600);
|
||||||
|
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||||
|
const secs = totalSeconds % 60;
|
||||||
|
|
||||||
|
if (hours > 0) {
|
||||||
|
return `${hours}:${String(minutes).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
return `${minutes}:${String(secs).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formate une date de mise en ligne au format `YYYYMMDD` (tel que renvoyé
|
||||||
|
* par yt-dlp) en `JJ/MM/AAAA`.
|
||||||
|
*
|
||||||
|
* @param uploadDate - Date au format `YYYYMMDD`, ou `null` si inconnue.
|
||||||
|
* @returns La date formatée, ou une chaîne vide si inconnue ou mal formée.
|
||||||
|
*/
|
||||||
|
export function formatUploadDate(uploadDate: string | null): string {
|
||||||
|
if (!uploadDate || !/^\d{8}$/.test(uploadDate)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
const year = uploadDate.slice(0, 4);
|
||||||
|
const month = uploadDate.slice(4, 6);
|
||||||
|
const day = uploadDate.slice(6, 8);
|
||||||
|
return `${day}/${month}/${year}`;
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,9 @@
|
||||||
<div class="shell">
|
<div class="shell">
|
||||||
|
<header class="shell__header">
|
||||||
<h1>youtubeDownloader</h1>
|
<h1>youtubeDownloader</h1>
|
||||||
<p>L'interface arrive bientôt — pour l'instant, seul le serveur est en place.</p>
|
</header>
|
||||||
|
<main class="shell__main">
|
||||||
|
<section class="shell__panel" id="download-form"></section>
|
||||||
|
<section class="shell__panel" id="queue-panel"></section>
|
||||||
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,41 @@
|
||||||
.shell {
|
.shell {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
min-height: 100vh;
|
||||||
justify-content: center;
|
|
||||||
height: 100vh;
|
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: system-ui, sans-serif;
|
font-family: system-ui, sans-serif;
|
||||||
text-align: center;
|
color: #222;
|
||||||
|
|
||||||
|
&__header {
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
font-size: 1.5rem;
|
font-size: 1.25rem;
|
||||||
margin-bottom: 0.5rem;
|
margin: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
p {
|
&__main {
|
||||||
color: #666;
|
flex: 1;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 1.5rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
max-width: 960px;
|
||||||
|
margin: 0 auto;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 800px) {
|
||||||
|
&__main {
|
||||||
|
grid-template-columns: 3fr 2fr;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__panel {
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,29 @@
|
||||||
import shellTemplate from './Shell.html';
|
import shellTemplate from './Shell.html';
|
||||||
import './Shell.scss';
|
import './Shell.scss';
|
||||||
|
|
||||||
|
/** Points de montage exposés par le shell pour les vues qu'il héberge. */
|
||||||
|
export interface ShellMounts {
|
||||||
|
/** Racine du formulaire d'ajout (URL directe + recherche de chaîne). */
|
||||||
|
downloadFormRoot: HTMLElement;
|
||||||
|
/** Racine du panneau de file d'attente. */
|
||||||
|
queuePanelRoot: HTMLElement;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Monte la coquille (shell) de l'application dans l'élément racine fourni.
|
* Monte la coquille (shell) de l'application dans l'élément racine fourni.
|
||||||
*
|
*
|
||||||
* @param root - Élément DOM dans lequel injecter le gabarit du shell.
|
* @param root - Élément DOM dans lequel injecter le gabarit du shell.
|
||||||
|
* @returns Les points de montage des vues hébergées par le shell.
|
||||||
|
* @throws {Error} Si le gabarit ne contient pas les points de montage attendus.
|
||||||
*/
|
*/
|
||||||
export function mountShell(root: HTMLElement): void {
|
export function mountShell(root: HTMLElement): ShellMounts {
|
||||||
root.innerHTML = shellTemplate;
|
root.innerHTML = shellTemplate;
|
||||||
|
|
||||||
|
const downloadFormRoot = root.querySelector<HTMLElement>('#download-form');
|
||||||
|
const queuePanelRoot = root.querySelector<HTMLElement>('#queue-panel');
|
||||||
|
if (!downloadFormRoot || !queuePanelRoot) {
|
||||||
|
throw new Error('Gabarit du shell invalide : points de montage introuvables.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { downloadFormRoot, queuePanelRoot };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
59
client/src/views/downloadForm/DownloadForm.html
Normal file
59
client/src/views/downloadForm/DownloadForm.html
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
<div class="download-form">
|
||||||
|
<h2>Ajouter des vidéos</h2>
|
||||||
|
|
||||||
|
<fieldset class="download-form__mode">
|
||||||
|
<legend>Mode</legend>
|
||||||
|
<label><input type="radio" name="mode" value="video" checked /> Vidéo</label>
|
||||||
|
<label><input type="radio" name="mode" value="audio" /> Audio</label>
|
||||||
|
<label><input type="radio" name="mode" value="thumbnail" /> Miniature seule</label>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<div class="download-form__options" data-mode-options="video">
|
||||||
|
<label><input type="checkbox" data-field="subtitles" /> Sous-titres (fr/en)</label>
|
||||||
|
<label><input type="checkbox" data-field="embedThumbnail" /> Miniature embarquée</label>
|
||||||
|
</div>
|
||||||
|
<div class="download-form__options" data-mode-options="audio" hidden>
|
||||||
|
<label>
|
||||||
|
Format
|
||||||
|
<select data-field="audioFormat">
|
||||||
|
<option value="mp3">MP3</option>
|
||||||
|
<option value="m4a">M4A</option>
|
||||||
|
<option value="opus">Opus</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class="download-form__form" data-form="url">
|
||||||
|
<label class="download-form__field">
|
||||||
|
URL directe
|
||||||
|
<input type="url" name="url" placeholder="https://www.youtube.com/watch?v=..." required />
|
||||||
|
</label>
|
||||||
|
<button type="submit">Ajouter à la file</button>
|
||||||
|
</form>
|
||||||
|
<p class="download-form__error" data-role="url-error" hidden></p>
|
||||||
|
|
||||||
|
<hr />
|
||||||
|
|
||||||
|
<form class="download-form__form" data-form="channel">
|
||||||
|
<label class="download-form__field">
|
||||||
|
Chaîne (@handle ou URL)
|
||||||
|
<input type="text" name="channel" placeholder="@worldclimbing" required />
|
||||||
|
</label>
|
||||||
|
<label class="download-form__field">
|
||||||
|
Section
|
||||||
|
<select name="section">
|
||||||
|
<option value="videos">Vidéos</option>
|
||||||
|
<option value="streams">Streams</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="download-form__field">
|
||||||
|
Mots-clés (optionnel, séparés par des virgules)
|
||||||
|
<input type="text" name="keywords" placeholder="speed, 2026" />
|
||||||
|
</label>
|
||||||
|
<button type="submit">Rechercher</button>
|
||||||
|
</form>
|
||||||
|
<p class="download-form__error" data-role="channel-error" hidden></p>
|
||||||
|
<p class="download-form__status" data-role="channel-status" hidden></p>
|
||||||
|
|
||||||
|
<ul class="download-form__results" data-role="results"></ul>
|
||||||
|
</div>
|
||||||
120
client/src/views/downloadForm/DownloadForm.scss
Normal file
120
client/src/views/downloadForm/DownloadForm.scss
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
.download-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__mode {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__options {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
&[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__form {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
flex: 1 1 160px;
|
||||||
|
|
||||||
|
input,
|
||||||
|
select {
|
||||||
|
padding: 0.4rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__error {
|
||||||
|
color: #b00020;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__status {
|
||||||
|
color: #555;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__results {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
max-height: 320px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__result {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
border: 1px solid #eee;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__result-thumb {
|
||||||
|
width: 96px;
|
||||||
|
height: 54px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 2px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__result-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__result-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__result-meta {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
}
|
||||||
281
client/src/views/downloadForm/DownloadForm.ts
Normal file
281
client/src/views/downloadForm/DownloadForm.ts
Normal file
|
|
@ -0,0 +1,281 @@
|
||||||
|
import { type ChannelSection, type ChannelVideoDto, searchChannel } from '../../api/channelApi';
|
||||||
|
import { type EnqueueMode, enqueueItem } from '../../api/queueApi';
|
||||||
|
import { formatDuration, formatUploadDate } from '../../format';
|
||||||
|
import downloadFormTemplate from './DownloadForm.html';
|
||||||
|
import './DownloadForm.scss';
|
||||||
|
|
||||||
|
/** Rappels fournis par l'appelant pour réagir aux actions du formulaire. */
|
||||||
|
export interface DownloadFormCallbacks {
|
||||||
|
/** Appelé juste après l'ajout réussi d'un élément à la file d'attente. */
|
||||||
|
onItemQueued: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Affiche ou efface un message dans un élément de statut/erreur.
|
||||||
|
*
|
||||||
|
* @param el - Élément cible, ou `null` s'il n'a pas été trouvé dans le gabarit.
|
||||||
|
* @param message - Message à afficher, ou `null` pour masquer l'élément.
|
||||||
|
*/
|
||||||
|
function setMessage(el: HTMLElement | null, message: string | null): void {
|
||||||
|
if (!el) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
el.hidden = message === null;
|
||||||
|
el.textContent = message ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lit le mode de téléchargement et ses options depuis les contrôles
|
||||||
|
* actuellement sélectionnés dans le formulaire.
|
||||||
|
*
|
||||||
|
* @param root - Racine du formulaire monté.
|
||||||
|
* @returns Le mode à utiliser pour le prochain élément ajouté.
|
||||||
|
*/
|
||||||
|
function readCurrentMode(root: HTMLElement): EnqueueMode {
|
||||||
|
const selected =
|
||||||
|
root.querySelector<HTMLInputElement>('input[name="mode"]:checked')?.value ?? 'video';
|
||||||
|
|
||||||
|
if (selected === 'audio') {
|
||||||
|
const audioFormat =
|
||||||
|
(root.querySelector<HTMLSelectElement>('[data-field="audioFormat"]')?.value as
|
||||||
|
| 'mp3'
|
||||||
|
| 'm4a'
|
||||||
|
| 'opus'
|
||||||
|
| undefined) ?? 'mp3';
|
||||||
|
return { mode: 'audio', audioFormat };
|
||||||
|
}
|
||||||
|
if (selected === 'thumbnail') {
|
||||||
|
return { mode: 'thumbnail' };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
mode: 'video',
|
||||||
|
subtitles: root.querySelector<HTMLInputElement>('[data-field="subtitles"]')?.checked ?? false,
|
||||||
|
embedThumbnail:
|
||||||
|
root.querySelector<HTMLInputElement>('[data-field="embedThumbnail"]')?.checked ?? false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Affiche/masque les panneaux d'options quand le mode sélectionné change.
|
||||||
|
*
|
||||||
|
* @param root - Racine du formulaire monté.
|
||||||
|
*/
|
||||||
|
function wireModeToggle(root: HTMLElement): void {
|
||||||
|
const fieldset = root.querySelector<HTMLFieldSetElement>('.download-form__mode');
|
||||||
|
const optionPanels = root.querySelectorAll<HTMLElement>('[data-mode-options]');
|
||||||
|
|
||||||
|
fieldset?.addEventListener('change', () => {
|
||||||
|
const selected = root.querySelector<HTMLInputElement>('input[name="mode"]:checked')?.value;
|
||||||
|
for (const panel of optionPanels) {
|
||||||
|
panel.hidden = panel.dataset.modeOptions !== selected;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ajoute un résultat de recherche de chaîne à la file d'attente, avec
|
||||||
|
* retour visuel sur le bouton concerné.
|
||||||
|
*
|
||||||
|
* @param video - Vidéo à ajouter.
|
||||||
|
* @param formRoot - Racine du formulaire (pour lire le mode courant).
|
||||||
|
* @param button - Bouton "Ajouter" cliqué, pour le désactiver pendant la requête.
|
||||||
|
* @param errorEl - Élément où afficher une éventuelle erreur.
|
||||||
|
* @param callbacks - Rappels du formulaire.
|
||||||
|
*/
|
||||||
|
async function handleAddResult(
|
||||||
|
video: ChannelVideoDto,
|
||||||
|
formRoot: HTMLElement,
|
||||||
|
button: HTMLButtonElement,
|
||||||
|
errorEl: HTMLElement | null,
|
||||||
|
callbacks: DownloadFormCallbacks,
|
||||||
|
): Promise<void> {
|
||||||
|
button.disabled = true;
|
||||||
|
setMessage(errorEl, null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await enqueueItem(video.url, readCurrentMode(formRoot), video.title);
|
||||||
|
button.textContent = 'Ajouté ✓';
|
||||||
|
callbacks.onItemQueued();
|
||||||
|
} catch (error) {
|
||||||
|
button.disabled = false;
|
||||||
|
setMessage(errorEl, error instanceof Error ? error.message : "Échec de l'ajout.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Affiche la liste des résultats d'une recherche de chaîne.
|
||||||
|
*
|
||||||
|
* @param container - Élément `<ul>` dans lequel rendre les résultats.
|
||||||
|
* @param videos - Vidéos à afficher.
|
||||||
|
* @param formRoot - Racine du formulaire (pour lire le mode courant lors d'un ajout).
|
||||||
|
* @param errorEl - Élément où afficher une éventuelle erreur d'ajout.
|
||||||
|
* @param callbacks - Rappels du formulaire.
|
||||||
|
*/
|
||||||
|
function renderResults(
|
||||||
|
container: HTMLElement,
|
||||||
|
videos: readonly ChannelVideoDto[],
|
||||||
|
formRoot: HTMLElement,
|
||||||
|
errorEl: HTMLElement | null,
|
||||||
|
callbacks: DownloadFormCallbacks,
|
||||||
|
): void {
|
||||||
|
container.replaceChildren();
|
||||||
|
|
||||||
|
for (const video of videos) {
|
||||||
|
const item = document.createElement('li');
|
||||||
|
item.className = 'download-form__result';
|
||||||
|
|
||||||
|
if (video.thumbnailUrl) {
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.src = video.thumbnailUrl;
|
||||||
|
img.alt = '';
|
||||||
|
img.className = 'download-form__result-thumb';
|
||||||
|
item.append(img);
|
||||||
|
}
|
||||||
|
|
||||||
|
const info = document.createElement('div');
|
||||||
|
info.className = 'download-form__result-info';
|
||||||
|
|
||||||
|
const title = document.createElement('p');
|
||||||
|
title.className = 'download-form__result-title';
|
||||||
|
title.textContent = video.title;
|
||||||
|
info.append(title);
|
||||||
|
|
||||||
|
const meta = document.createElement('p');
|
||||||
|
meta.className = 'download-form__result-meta';
|
||||||
|
meta.textContent = [formatDuration(video.durationSeconds), formatUploadDate(video.uploadDate)]
|
||||||
|
.filter((part) => part.length > 0)
|
||||||
|
.join(' · ');
|
||||||
|
info.append(meta);
|
||||||
|
|
||||||
|
item.append(info);
|
||||||
|
|
||||||
|
const addButton = document.createElement('button');
|
||||||
|
addButton.type = 'button';
|
||||||
|
addButton.textContent = 'Ajouter';
|
||||||
|
addButton.addEventListener('click', () => {
|
||||||
|
handleAddResult(video, formRoot, addButton, errorEl, callbacks).catch((error: unknown) => {
|
||||||
|
console.error('Erreur inattendue lors de l’ajout depuis la recherche', error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
item.append(addButton);
|
||||||
|
|
||||||
|
container.append(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Câble le formulaire d'ajout direct par URL.
|
||||||
|
*
|
||||||
|
* @param root - Racine du formulaire monté.
|
||||||
|
* @param callbacks - Rappels du formulaire.
|
||||||
|
*/
|
||||||
|
function wireUrlForm(root: HTMLElement, callbacks: DownloadFormCallbacks): void {
|
||||||
|
const form = root.querySelector<HTMLFormElement>('form[data-form="url"]');
|
||||||
|
const errorEl = root.querySelector<HTMLElement>('[data-role="url-error"]');
|
||||||
|
if (!form) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async (event: SubmitEvent): Promise<void> => {
|
||||||
|
event.preventDefault();
|
||||||
|
const url = form.querySelector<HTMLInputElement>('input[name="url"]')?.value.trim() ?? '';
|
||||||
|
if (!url) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setMessage(errorEl, null);
|
||||||
|
const submitButton = form.querySelector<HTMLButtonElement>('button[type="submit"]');
|
||||||
|
if (submitButton) {
|
||||||
|
submitButton.disabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await enqueueItem(url, readCurrentMode(root), null);
|
||||||
|
form.reset();
|
||||||
|
callbacks.onItemQueued();
|
||||||
|
} catch (error) {
|
||||||
|
setMessage(errorEl, error instanceof Error ? error.message : "Échec de l'ajout à la file.");
|
||||||
|
} finally {
|
||||||
|
if (submitButton) {
|
||||||
|
submitButton.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
form.addEventListener('submit', (event) => {
|
||||||
|
handleSubmit(event).catch((error: unknown) => {
|
||||||
|
console.error('Erreur inattendue lors de l’ajout par URL', error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Câble le formulaire de recherche de chaîne.
|
||||||
|
*
|
||||||
|
* @param root - Racine du formulaire monté.
|
||||||
|
* @param callbacks - Rappels du formulaire.
|
||||||
|
*/
|
||||||
|
function wireChannelForm(root: HTMLElement, callbacks: DownloadFormCallbacks): void {
|
||||||
|
const form = root.querySelector<HTMLFormElement>('form[data-form="channel"]');
|
||||||
|
const errorEl = root.querySelector<HTMLElement>('[data-role="channel-error"]');
|
||||||
|
const statusEl = root.querySelector<HTMLElement>('[data-role="channel-status"]');
|
||||||
|
const resultsEl = root.querySelector<HTMLElement>('[data-role="results"]');
|
||||||
|
if (!form || !resultsEl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async (event: SubmitEvent): Promise<void> => {
|
||||||
|
event.preventDefault();
|
||||||
|
const formData = new FormData(form);
|
||||||
|
const channel = String(formData.get('channel') ?? '').trim();
|
||||||
|
const section = String(formData.get('section') ?? 'videos') as ChannelSection;
|
||||||
|
const keywords = String(formData.get('keywords') ?? '');
|
||||||
|
if (!channel) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setMessage(errorEl, null);
|
||||||
|
setMessage(statusEl, 'Recherche en cours...');
|
||||||
|
resultsEl.replaceChildren();
|
||||||
|
const submitButton = form.querySelector<HTMLButtonElement>('button[type="submit"]');
|
||||||
|
if (submitButton) {
|
||||||
|
submitButton.disabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const videos = await searchChannel(channel, section, keywords);
|
||||||
|
setMessage(
|
||||||
|
statusEl,
|
||||||
|
videos.length === 0 ? 'Aucun résultat.' : `${videos.length} résultat(s).`,
|
||||||
|
);
|
||||||
|
renderResults(resultsEl, videos, root, errorEl, callbacks);
|
||||||
|
} catch (error) {
|
||||||
|
setMessage(statusEl, null);
|
||||||
|
setMessage(errorEl, error instanceof Error ? error.message : 'Échec de la recherche.');
|
||||||
|
} finally {
|
||||||
|
if (submitButton) {
|
||||||
|
submitButton.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
form.addEventListener('submit', (event) => {
|
||||||
|
handleSubmit(event).catch((error: unknown) => {
|
||||||
|
console.error('Erreur inattendue lors de la recherche de chaîne', error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Monte le formulaire d'ajout (URL directe + recherche de chaîne) dans
|
||||||
|
* l'élément racine fourni.
|
||||||
|
*
|
||||||
|
* @param root - Élément DOM dans lequel injecter le formulaire.
|
||||||
|
* @param callbacks - Rappels invoqués sur les actions du formulaire.
|
||||||
|
*/
|
||||||
|
export function mountDownloadForm(root: HTMLElement, callbacks: DownloadFormCallbacks): void {
|
||||||
|
root.innerHTML = downloadFormTemplate;
|
||||||
|
wireModeToggle(root);
|
||||||
|
wireUrlForm(root, callbacks);
|
||||||
|
wireChannelForm(root, callbacks);
|
||||||
|
}
|
||||||
5
client/src/views/queuePanel/QueuePanel.html
Normal file
5
client/src/views/queuePanel/QueuePanel.html
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
<div class="queue-panel">
|
||||||
|
<h2>File d'attente</h2>
|
||||||
|
<p class="queue-panel__empty" data-role="empty">Aucun élément pour l'instant.</p>
|
||||||
|
<ul class="queue-panel__list" data-role="list"></ul>
|
||||||
|
</div>
|
||||||
82
client/src/views/queuePanel/QueuePanel.scss
Normal file
82
client/src/views/queuePanel/QueuePanel.scss
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
.queue-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__empty {
|
||||||
|
color: #666;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__item {
|
||||||
|
border: 1px solid #eee;
|
||||||
|
border-left-width: 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
|
||||||
|
&--completed {
|
||||||
|
border-left-color: #2e7d32;
|
||||||
|
}
|
||||||
|
&--failed {
|
||||||
|
border-left-color: #b00020;
|
||||||
|
}
|
||||||
|
&--running {
|
||||||
|
border-left-color: #1565c0;
|
||||||
|
}
|
||||||
|
&--cancelled {
|
||||||
|
border-left-color: #999;
|
||||||
|
}
|
||||||
|
&--pending {
|
||||||
|
border-left-color: #999;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__item-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__item-meta {
|
||||||
|
margin: 0.15rem 0 0;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__item-progress {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__item-error {
|
||||||
|
margin: 0.35rem 0 0;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #b00020;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__item-actions {
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__item-download {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
168
client/src/views/queuePanel/QueuePanel.ts
Normal file
168
client/src/views/queuePanel/QueuePanel.ts
Normal file
|
|
@ -0,0 +1,168 @@
|
||||||
|
import {
|
||||||
|
type QueueItemDto,
|
||||||
|
cancelQueueItem,
|
||||||
|
listQueueItems,
|
||||||
|
queueItemFileUrl,
|
||||||
|
} from '../../api/queueApi';
|
||||||
|
import queuePanelTemplate from './QueuePanel.html';
|
||||||
|
import './QueuePanel.scss';
|
||||||
|
|
||||||
|
/** Intervalle de sondage de `GET /api/queue`, en l'absence de push temps réel (à venir). */
|
||||||
|
const POLL_INTERVAL_MS = 1500;
|
||||||
|
|
||||||
|
const STATUS_LABELS: Record<QueueItemDto['status'], string> = {
|
||||||
|
pending: 'En attente',
|
||||||
|
running: 'En cours',
|
||||||
|
completed: 'Terminé',
|
||||||
|
failed: 'Échec',
|
||||||
|
cancelled: 'Annulé',
|
||||||
|
};
|
||||||
|
|
||||||
|
const MODE_LABELS: Record<QueueItemDto['mode']['kind'], string> = {
|
||||||
|
video: 'Vidéo',
|
||||||
|
audio: 'Audio',
|
||||||
|
thumbnail: 'Miniature',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Contrôleur exposé par {@link mountQueuePanel}. */
|
||||||
|
export interface QueuePanelController {
|
||||||
|
/** Recharge immédiatement la liste, sans attendre le prochain sondage. */
|
||||||
|
refresh: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Annule un élément de la file au clic sur son bouton "Annuler".
|
||||||
|
*
|
||||||
|
* @param id - Identifiant de l'élément à annuler.
|
||||||
|
* @param button - Bouton cliqué, désactivé pendant la requête.
|
||||||
|
* @param onDone - Rappelé après la requête (succès ou échec) pour rafraîchir la liste.
|
||||||
|
*/
|
||||||
|
async function handleCancel(
|
||||||
|
id: string,
|
||||||
|
button: HTMLButtonElement,
|
||||||
|
onDone: () => void,
|
||||||
|
): Promise<void> {
|
||||||
|
button.disabled = true;
|
||||||
|
try {
|
||||||
|
await cancelQueueItem(id);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Échec de l'annulation", error);
|
||||||
|
} finally {
|
||||||
|
onDone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construit la ligne d'affichage d'un élément de la file.
|
||||||
|
*
|
||||||
|
* @param item - Élément à afficher.
|
||||||
|
* @param onChanged - Rappelé après une action (annulation) pour rafraîchir la liste.
|
||||||
|
* @returns L'élément `<li>` prêt à être inséré dans la liste.
|
||||||
|
*/
|
||||||
|
function renderItem(item: QueueItemDto, onChanged: () => void): HTMLLIElement {
|
||||||
|
const li = document.createElement('li');
|
||||||
|
li.className = `queue-panel__item queue-panel__item--${item.status}`;
|
||||||
|
|
||||||
|
const title = document.createElement('p');
|
||||||
|
title.className = 'queue-panel__item-title';
|
||||||
|
title.textContent = item.title ?? item.url;
|
||||||
|
li.append(title);
|
||||||
|
|
||||||
|
const meta = document.createElement('p');
|
||||||
|
meta.className = 'queue-panel__item-meta';
|
||||||
|
meta.textContent = `${MODE_LABELS[item.mode.kind]} · ${STATUS_LABELS[item.status]}`;
|
||||||
|
li.append(meta);
|
||||||
|
|
||||||
|
if (item.status === 'running' || item.status === 'pending') {
|
||||||
|
const progress = document.createElement('progress');
|
||||||
|
progress.className = 'queue-panel__item-progress';
|
||||||
|
progress.max = 100;
|
||||||
|
progress.value = item.progress;
|
||||||
|
li.append(progress);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.status === 'failed' && item.error) {
|
||||||
|
const error = document.createElement('p');
|
||||||
|
error.className = 'queue-panel__item-error';
|
||||||
|
error.textContent = item.error;
|
||||||
|
li.append(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const actions = document.createElement('div');
|
||||||
|
actions.className = 'queue-panel__item-actions';
|
||||||
|
|
||||||
|
if (item.status === 'pending' || item.status === 'running') {
|
||||||
|
const cancelButton = document.createElement('button');
|
||||||
|
cancelButton.type = 'button';
|
||||||
|
cancelButton.textContent = 'Annuler';
|
||||||
|
cancelButton.addEventListener('click', () => {
|
||||||
|
handleCancel(item.id, cancelButton, onChanged).catch((error: unknown) => {
|
||||||
|
console.error("Erreur inattendue lors de l'annulation", error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
actions.append(cancelButton);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.status === 'completed') {
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = queueItemFileUrl(item.id);
|
||||||
|
link.textContent = 'Télécharger';
|
||||||
|
link.className = 'queue-panel__item-download';
|
||||||
|
actions.append(link);
|
||||||
|
}
|
||||||
|
|
||||||
|
li.append(actions);
|
||||||
|
return li;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rend la liste des éléments dans le panneau.
|
||||||
|
*
|
||||||
|
* @param root - Racine du panneau monté.
|
||||||
|
* @param items - Éléments à afficher.
|
||||||
|
* @param onChanged - Rappelé après une action sur un élément (pour rafraîchir la liste).
|
||||||
|
*/
|
||||||
|
function render(root: HTMLElement, items: readonly QueueItemDto[], onChanged: () => void): void {
|
||||||
|
const list = root.querySelector<HTMLElement>('[data-role="list"]');
|
||||||
|
const empty = root.querySelector<HTMLElement>('[data-role="empty"]');
|
||||||
|
if (!list || !empty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
empty.hidden = items.length > 0;
|
||||||
|
list.replaceChildren(...items.map((item) => renderItem(item, onChanged)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Monte le panneau de file d'attente dans l'élément racine fourni, et
|
||||||
|
* démarre le sondage périodique de `GET /api/queue`.
|
||||||
|
*
|
||||||
|
* @param root - Élément DOM dans lequel injecter le panneau.
|
||||||
|
* @returns Un contrôleur permettant de déclencher un rafraîchissement immédiat.
|
||||||
|
*/
|
||||||
|
export function mountQueuePanel(root: HTMLElement): QueuePanelController {
|
||||||
|
root.innerHTML = queuePanelTemplate;
|
||||||
|
let isRefreshing = false;
|
||||||
|
|
||||||
|
const refresh = (): void => {
|
||||||
|
if (isRefreshing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
isRefreshing = true;
|
||||||
|
listQueueItems()
|
||||||
|
.then((items) => {
|
||||||
|
render(root, items, refresh);
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
console.error('Échec du chargement de la file d’attente', error);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
isRefreshing = false;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
refresh();
|
||||||
|
setInterval(refresh, POLL_INTERVAL_MS);
|
||||||
|
|
||||||
|
return { refresh };
|
||||||
|
}
|
||||||
35
client/test/format.test.ts
Normal file
35
client/test/format.test.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { formatDuration, formatUploadDate } from '../src/format';
|
||||||
|
|
||||||
|
describe('formatDuration', () => {
|
||||||
|
it('formate en m:ss sous une heure', () => {
|
||||||
|
expect(formatDuration(65)).toBe('1:05');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('complète les secondes avec un zéro', () => {
|
||||||
|
expect(formatDuration(60)).toBe('1:00');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formate en h:mm:ss au-delà d’une heure', () => {
|
||||||
|
expect(formatDuration(3725)).toBe('1:02:05');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retourne une chaîne vide si inconnue', () => {
|
||||||
|
expect(formatDuration(null)).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatUploadDate', () => {
|
||||||
|
it('formate YYYYMMDD en JJ/MM/AAAA', () => {
|
||||||
|
expect(formatUploadDate('20260315')).toBe('15/03/2026');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retourne une chaîne vide si inconnue', () => {
|
||||||
|
expect(formatUploadDate(null)).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retourne une chaîne vide si mal formée', () => {
|
||||||
|
expect(formatUploadDate('abc')).toBe('');
|
||||||
|
expect(formatUploadDate('2026-03-15')).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2020",
|
"target": "ES2022",
|
||||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"strict": true,
|
"strict": true,
|
||||||
|
|
@ -12,5 +12,5 @@
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"isolatedModules": true
|
"isolatedModules": true
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts"]
|
"include": ["src/**/*.ts", "test/**/*.ts"]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { resolve } from 'node:path';
|
import { resolve } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Configuration runtime du serveur, dérivée des variables d'environnement.
|
* Configuration runtime du serveur, dérivée des variables d'environnement.
|
||||||
|
|
@ -10,22 +11,37 @@ export interface ServerConfig {
|
||||||
readonly host: string;
|
readonly host: string;
|
||||||
/** Chemin absolu vers les fichiers statiques du client compilé. */
|
/** Chemin absolu vers les fichiers statiques du client compilé. */
|
||||||
readonly clientDistPath: string;
|
readonly clientDistPath: string;
|
||||||
|
/** Dossier racine des sorties de téléchargement (un sous-dossier par élément de file). */
|
||||||
|
readonly tmpDir: string;
|
||||||
|
/** Nombre maximal de téléchargements exécutés simultanément. */
|
||||||
|
readonly maxConcurrentDownloads: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Dossier contenant ce module compilé (server/dist/ en prod, server/src/ sous
|
||||||
|
// tsx en dev - même profondeur sous server/ dans les deux cas). Sert de base
|
||||||
|
// aux chemins par défaut ci-dessous : contrairement à process.cwd(), invariant
|
||||||
|
// quel que soit le répertoire depuis lequel le process a été lancé (ex. `npm
|
||||||
|
// --prefix server run dev` depuis la racine du dépôt ne change pas le cwd).
|
||||||
|
const here = fileURLToPath(new URL('.', import.meta.url));
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Construit la configuration du serveur à partir des variables d'environnement,
|
* Construit la configuration du serveur à partir des variables d'environnement,
|
||||||
* en appliquant des valeurs par défaut adaptées au développement local (le
|
* en appliquant des valeurs par défaut adaptées au développement local (le
|
||||||
* client compilé est alors supposé être un dossier `client/dist` voisin de
|
* client compilé et le dossier temporaire sont alors supposés voisins de
|
||||||
* `server/`).
|
* `server/`, indépendamment du répertoire de travail courant).
|
||||||
*
|
*
|
||||||
* @returns La configuration runtime résolue.
|
* @returns La configuration runtime résolue.
|
||||||
*/
|
*/
|
||||||
export function loadConfig(): ServerConfig {
|
export function loadConfig(): ServerConfig {
|
||||||
const parsedPort = Number.parseInt(process.env.PORT ?? '3000', 10);
|
const parsedPort = Number.parseInt(process.env.PORT ?? '3000', 10);
|
||||||
|
const parsedConcurrency = Number.parseInt(process.env.MAX_CONCURRENT_DOWNLOADS ?? '2', 10);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
port: Number.isNaN(parsedPort) ? 3000 : parsedPort,
|
port: Number.isNaN(parsedPort) ? 3000 : parsedPort,
|
||||||
host: process.env.HOST ?? '0.0.0.0',
|
host: process.env.HOST ?? '0.0.0.0',
|
||||||
clientDistPath: process.env.CLIENT_DIST_PATH ?? resolve(process.cwd(), '../client/dist'),
|
clientDistPath: process.env.CLIENT_DIST_PATH ?? resolve(here, '../../client/dist'),
|
||||||
|
tmpDir: process.env.TMP_DIR ?? resolve(here, '../tmp'),
|
||||||
|
maxConcurrentDownloads:
|
||||||
|
Number.isNaN(parsedConcurrency) || parsedConcurrency < 1 ? 2 : parsedConcurrency,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ 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 { createQueueRoutes } from './routes/queue.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Construit et configure l'instance Fastify de l'application : fichiers
|
* Construit et configure l'instance Fastify de l'application : fichiers
|
||||||
|
|
@ -21,6 +22,7 @@ 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(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");
|
||||||
throw error;
|
throw error;
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import {
|
||||||
type ChannelSection,
|
type ChannelSection,
|
||||||
InvalidChannelError,
|
InvalidChannelError,
|
||||||
} from '../services/ChannelDiscoveryService.js';
|
} from '../services/ChannelDiscoveryService.js';
|
||||||
import { YtDlpError } from '../services/ytDlpRunner.js';
|
|
||||||
|
|
||||||
/** Paramètres de requête acceptés par `GET /api/channel`. */
|
/** Paramètres de requête acceptés par `GET /api/channel`. */
|
||||||
interface ListChannelQuerystring {
|
interface ListChannelQuerystring {
|
||||||
|
|
@ -72,13 +71,15 @@ export const channelRoutes: FastifyPluginAsync = async (app: FastifyInstance): P
|
||||||
if (error instanceof InvalidChannelError) {
|
if (error instanceof InvalidChannelError) {
|
||||||
return reply.status(400).send({ error: error.message });
|
return reply.status(400).send({ error: error.message });
|
||||||
}
|
}
|
||||||
if (error instanceof YtDlpError) {
|
// buildChannelUrl() lève InvalidChannelError de façon synchrone,
|
||||||
|
// avant tout appel à yt-dlp : toute autre erreur ici vient donc
|
||||||
|
// forcément de runYtDlp (binaire introuvable, code de sortie non
|
||||||
|
// nul, etc.), qu'elle soit une YtDlpError ou une erreur brute de
|
||||||
|
// spawn (ex. ENOENT) - les deux sont un échec côté yt-dlp/YouTube.
|
||||||
return reply
|
return reply
|
||||||
.status(502)
|
.status(502)
|
||||||
.send({ error: 'Échec de la récupération depuis YouTube (yt-dlp).' });
|
.send({ error: 'Échec de la récupération depuis YouTube (yt-dlp).' });
|
||||||
}
|
}
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
148
server/src/routes/queue.ts
Normal file
148
server/src/routes/queue.ts
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
import { createReadStream } from 'node:fs';
|
||||||
|
import type { FastifyInstance, FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify';
|
||||||
|
import type { ServerConfig } from '../config.js';
|
||||||
|
import { QueueService } from '../services/QueueService.js';
|
||||||
|
import type { AudioFormat, DownloadMode } from '../services/downloadPlan.js';
|
||||||
|
import { InvalidVideoUrlError, assertValidVideoUrl } from '../services/videoUrl.js';
|
||||||
|
|
||||||
|
/** Corps de requête accepté par `POST /api/queue`. */
|
||||||
|
interface CreateQueueItemBody {
|
||||||
|
/** URL de la vidéo YouTube à télécharger. */
|
||||||
|
url: string;
|
||||||
|
/** Titre de la vidéo, si déjà connu (ex. depuis une recherche de chaîne), pour l'affichage uniquement. */
|
||||||
|
title?: string;
|
||||||
|
/** Mode de téléchargement demandé. */
|
||||||
|
mode: 'video' | 'audio' | 'thumbnail';
|
||||||
|
/** Mode vidéo uniquement : récupérer et embarquer les sous-titres fr/en. */
|
||||||
|
subtitles?: boolean;
|
||||||
|
/** Mode vidéo uniquement : embarquer la miniature. */
|
||||||
|
embedThumbnail?: boolean;
|
||||||
|
/** Mode audio uniquement : format de sortie (mp3 par défaut). */
|
||||||
|
audioFormat?: AudioFormat;
|
||||||
|
}
|
||||||
|
|
||||||
|
const createQueueItemSchema = {
|
||||||
|
type: 'object',
|
||||||
|
required: ['url', 'mode'],
|
||||||
|
properties: {
|
||||||
|
url: { type: 'string', minLength: 1, maxLength: 500 },
|
||||||
|
title: { type: 'string', maxLength: 300 },
|
||||||
|
mode: { type: 'string', enum: ['video', 'audio', 'thumbnail'] },
|
||||||
|
subtitles: { type: 'boolean' },
|
||||||
|
embedThumbnail: { type: 'boolean' },
|
||||||
|
audioFormat: { type: 'string', enum: ['mp3', 'm4a', 'opus'] },
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
interface QueueItemParams {
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const queueItemParamsSchema = {
|
||||||
|
type: 'object',
|
||||||
|
required: ['id'],
|
||||||
|
properties: {
|
||||||
|
id: { type: 'string', minLength: 1 },
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convertit le corps de requête `POST /api/queue` en mode de téléchargement
|
||||||
|
* typé, en appliquant les valeurs par défaut des options spécifiques à
|
||||||
|
* chaque mode.
|
||||||
|
*
|
||||||
|
* @param body - Corps de requête déjà validé par le schéma Fastify.
|
||||||
|
* @returns Le mode de téléchargement correspondant.
|
||||||
|
*/
|
||||||
|
function toDownloadMode(body: CreateQueueItemBody): DownloadMode {
|
||||||
|
switch (body.mode) {
|
||||||
|
case 'video':
|
||||||
|
return {
|
||||||
|
kind: 'video',
|
||||||
|
options: {
|
||||||
|
subtitles: body.subtitles ?? false,
|
||||||
|
embedThumbnail: body.embedThumbnail ?? false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
case 'audio':
|
||||||
|
return { kind: 'audio', format: body.audioFormat ?? 'mp3' };
|
||||||
|
case 'thumbnail':
|
||||||
|
return { kind: 'thumbnail' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construit le plugin Fastify exposant les routes de file d'attente :
|
||||||
|
* `POST /api/queue` (ajout), `GET /api/queue` (liste), `DELETE
|
||||||
|
* /api/queue/:id` (annulation) et `GET /api/queue/:id/file` (livraison du
|
||||||
|
* fichier terminé).
|
||||||
|
*
|
||||||
|
* @param config - Configuration runtime (dossier temporaire, concurrence).
|
||||||
|
* @returns Le plugin Fastify à enregistrer.
|
||||||
|
*/
|
||||||
|
export function createQueueRoutes(
|
||||||
|
config: Pick<ServerConfig, 'tmpDir' | 'maxConcurrentDownloads'>,
|
||||||
|
): FastifyPluginAsync {
|
||||||
|
return async (app: FastifyInstance): Promise<void> => {
|
||||||
|
const queueService = new QueueService(app.log, config.tmpDir, config.maxConcurrentDownloads);
|
||||||
|
|
||||||
|
app.post<{ Body: CreateQueueItemBody }>(
|
||||||
|
'/api/queue',
|
||||||
|
{ schema: { body: createQueueItemSchema } },
|
||||||
|
async (request: FastifyRequest<{ Body: CreateQueueItemBody }>, reply: FastifyReply) => {
|
||||||
|
let url: string;
|
||||||
|
try {
|
||||||
|
url = assertValidVideoUrl(request.body.url);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof InvalidVideoUrlError) {
|
||||||
|
return reply.status(400).send({ error: error.message });
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const item = queueService.enqueue(
|
||||||
|
url,
|
||||||
|
toDownloadMode(request.body),
|
||||||
|
request.body.title ?? null,
|
||||||
|
);
|
||||||
|
return reply.status(201).send({ item });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
app.get('/api/queue', async () => {
|
||||||
|
return { items: queueService.list() };
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete<{ Params: QueueItemParams }>(
|
||||||
|
'/api/queue/:id',
|
||||||
|
{ schema: { params: queueItemParamsSchema } },
|
||||||
|
async (request: FastifyRequest<{ Params: QueueItemParams }>, reply: FastifyReply) => {
|
||||||
|
const cancelled = queueService.cancel(request.params.id);
|
||||||
|
if (!cancelled) {
|
||||||
|
return reply.status(404).send({ error: 'Élément introuvable ou déjà terminé.' });
|
||||||
|
}
|
||||||
|
return reply.status(204).send();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
app.get<{ Params: QueueItemParams }>(
|
||||||
|
'/api/queue/:id/file',
|
||||||
|
{ schema: { params: queueItemParamsSchema } },
|
||||||
|
async (request: FastifyRequest<{ Params: QueueItemParams }>, reply: FastifyReply) => {
|
||||||
|
const result = queueService.consumeCompletedFile(request.params.id);
|
||||||
|
if (!result) {
|
||||||
|
return reply
|
||||||
|
.status(404)
|
||||||
|
.send({ error: 'Fichier introuvable ou téléchargement non terminé.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
reply.header(
|
||||||
|
'Content-Disposition',
|
||||||
|
`attachment; filename="${encodeURIComponent(result.fileName)}"`,
|
||||||
|
);
|
||||||
|
reply.raw.once('finish', result.onSent);
|
||||||
|
return reply.send(createReadStream(result.path));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -160,7 +160,7 @@ export class ChannelDiscoveryService {
|
||||||
// `--` marque la fin des options : même si `url` contenait un jour
|
// `--` marque la fin des options : même si `url` contenait un jour
|
||||||
// un "-" en tête (déjà exclu par buildChannelUrl), yt-dlp ne
|
// un "-" en tête (déjà exclu par buildChannelUrl), yt-dlp ne
|
||||||
// pourrait pas l'interpréter comme une option.
|
// pourrait pas l'interpréter comme une option.
|
||||||
stdout = await runYtDlp(['--flat-playlist', '-j', '--', url], 60_000);
|
stdout = await runYtDlp(['--flat-playlist', '-j', '--', url], { timeoutMs: 60_000 });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(
|
this.logger.error(
|
||||||
{ err: error, channel, section },
|
{ err: error, channel, section },
|
||||||
|
|
|
||||||
327
server/src/services/QueueService.ts
Normal file
327
server/src/services/QueueService.ts
Normal file
|
|
@ -0,0 +1,327 @@
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { mkdir, readdir, rm } from 'node:fs/promises';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import type { FastifyBaseLogger } from 'fastify';
|
||||||
|
import {
|
||||||
|
type DownloadMode,
|
||||||
|
buildDownloadArgs,
|
||||||
|
expectedExtension,
|
||||||
|
parseProgress,
|
||||||
|
} from './downloadPlan.js';
|
||||||
|
import { runYtDlp } from './ytDlpRunner.js';
|
||||||
|
|
||||||
|
/** État d'avancement d'un élément de la file d'attente. */
|
||||||
|
export type QueueItemStatus = 'pending' | 'running' | 'completed' | 'failed' | 'cancelled';
|
||||||
|
|
||||||
|
/** Vue publique (immuable) d'un élément de la file d'attente. */
|
||||||
|
export interface QueueItem {
|
||||||
|
/** Identifiant unique de l'élément. */
|
||||||
|
readonly id: string;
|
||||||
|
/** URL de la vidéo source. */
|
||||||
|
readonly url: string;
|
||||||
|
/** Titre de la vidéo, si connu au moment de l'ajout (purement informatif, pour l'affichage). */
|
||||||
|
readonly title: string | null;
|
||||||
|
/** Mode de téléchargement demandé. */
|
||||||
|
readonly mode: DownloadMode;
|
||||||
|
/** État d'avancement courant. */
|
||||||
|
readonly status: QueueItemStatus;
|
||||||
|
/** Progression estimée (0-100), au mieux selon la sortie yt-dlp. */
|
||||||
|
readonly progress: number;
|
||||||
|
/** Message d'erreur si `status` vaut `"failed"`, sinon `null`. */
|
||||||
|
readonly error: string | null;
|
||||||
|
/** Nom du fichier produit une fois `status` à `"completed"`, sinon `null`. */
|
||||||
|
readonly fileName: string | null;
|
||||||
|
/** Date de création de l'élément, au format ISO 8601. */
|
||||||
|
readonly createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Délai maximal accordé à un téléchargement avant arrêt forcé (30 minutes). */
|
||||||
|
const DOWNLOAD_TIMEOUT_MS = 30 * 60_000;
|
||||||
|
|
||||||
|
interface InternalQueueItem {
|
||||||
|
id: string;
|
||||||
|
url: string;
|
||||||
|
title: string | null;
|
||||||
|
mode: DownloadMode;
|
||||||
|
status: QueueItemStatus;
|
||||||
|
progress: number;
|
||||||
|
error: string | null;
|
||||||
|
fileName: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
outputDir: string;
|
||||||
|
abortController: AbortController | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recherche, dans un dossier, le premier fichier dont l'extension
|
||||||
|
* correspond à celle attendue pour un mode de téléchargement donné.
|
||||||
|
*
|
||||||
|
* @param dir - Dossier à inspecter.
|
||||||
|
* @param expectedExt - Extension attendue (sans le point).
|
||||||
|
* @returns Le nom du fichier trouvé, ou `null` si aucun ne correspond.
|
||||||
|
*/
|
||||||
|
async function findOutputFile(dir: string, expectedExt: string): Promise<string | null> {
|
||||||
|
const entries = await readdir(dir);
|
||||||
|
const suffix = `.${expectedExt.toLowerCase()}`;
|
||||||
|
return entries.find((name) => name.toLowerCase().endsWith(suffix)) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* File d'attente de téléchargements : accepte des éléments, les exécute
|
||||||
|
* avec une concurrence bornée via yt-dlp, et expose leur progression.
|
||||||
|
*
|
||||||
|
* Chaque élément obtient son propre sous-dossier sous `baseDir`, supprimé
|
||||||
|
* après échec/annulation ou après livraison du fichier au client (voir
|
||||||
|
* {@link QueueService.consumeCompletedFile}).
|
||||||
|
*/
|
||||||
|
export class QueueService {
|
||||||
|
private readonly logger: FastifyBaseLogger;
|
||||||
|
private readonly baseDir: string;
|
||||||
|
private readonly maxConcurrent: number;
|
||||||
|
private readonly items = new Map<string, InternalQueueItem>();
|
||||||
|
private readonly pendingIds: string[] = [];
|
||||||
|
private runningCount = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param logger - Logger utilisé pour tracer le cycle de vie des éléments.
|
||||||
|
* @param baseDir - Dossier racine des sorties de téléchargement (un sous-dossier par élément).
|
||||||
|
* @param maxConcurrent - Nombre maximal de téléchargements exécutés simultanément.
|
||||||
|
*/
|
||||||
|
public constructor(logger: FastifyBaseLogger, baseDir: string, maxConcurrent: number) {
|
||||||
|
this.logger = logger;
|
||||||
|
this.baseDir = baseDir;
|
||||||
|
this.maxConcurrent = maxConcurrent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ajoute un élément à la file d'attente et déclenche son exécution dès
|
||||||
|
* qu'un emplacement de concurrence se libère.
|
||||||
|
*
|
||||||
|
* @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 title - Titre de la vidéo, si déjà connu (ex. depuis une recherche de chaîne), pour l'affichage uniquement.
|
||||||
|
* @returns L'élément créé, à l'état `"pending"`.
|
||||||
|
*/
|
||||||
|
public enqueue(url: string, mode: DownloadMode, title: string | null = null): QueueItem {
|
||||||
|
const id = randomUUID();
|
||||||
|
const item: InternalQueueItem = {
|
||||||
|
id,
|
||||||
|
url,
|
||||||
|
title,
|
||||||
|
mode,
|
||||||
|
status: 'pending',
|
||||||
|
progress: 0,
|
||||||
|
error: null,
|
||||||
|
fileName: null,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
outputDir: join(this.baseDir, id),
|
||||||
|
abortController: null,
|
||||||
|
};
|
||||||
|
this.items.set(id, item);
|
||||||
|
this.pendingIds.push(id);
|
||||||
|
this.logger.info({ id, url, mode: mode.kind }, "File d'attente : élément ajouté");
|
||||||
|
|
||||||
|
this.pump();
|
||||||
|
return this.toPublic(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns Tous les éléments de la file, dans leur ordre d'ajout.
|
||||||
|
*/
|
||||||
|
public list(): QueueItem[] {
|
||||||
|
return [...this.items.values()].map((item) => this.toPublic(item));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param id - Identifiant de l'élément recherché.
|
||||||
|
* @returns L'élément correspondant, ou `null` s'il n'existe pas.
|
||||||
|
*/
|
||||||
|
public get(id: string): QueueItem | null {
|
||||||
|
const item = this.items.get(id);
|
||||||
|
return item ? this.toPublic(item) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Annule un élément : retiré de la file s'il était en attente, ou son
|
||||||
|
* processus yt-dlp est arrêté s'il était en cours.
|
||||||
|
*
|
||||||
|
* @param id - Identifiant de l'élément à annuler.
|
||||||
|
* @returns `true` si l'élément existait et a pu être annulé, `false` sinon.
|
||||||
|
*/
|
||||||
|
public cancel(id: string): boolean {
|
||||||
|
const item = this.items.get(id);
|
||||||
|
if (!item) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.status === 'pending') {
|
||||||
|
const index = this.pendingIds.indexOf(id);
|
||||||
|
if (index !== -1) {
|
||||||
|
this.pendingIds.splice(index, 1);
|
||||||
|
}
|
||||||
|
item.status = 'cancelled';
|
||||||
|
this.logger.info({ id }, "File d'attente : élément en attente annulé");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.status === 'running') {
|
||||||
|
item.abortController?.abort();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retourne le chemin absolu du fichier produit par un élément terminé,
|
||||||
|
* et programme la suppression de son dossier de sortie une fois `onSent`
|
||||||
|
* résolu (le fichier a été livré, ou l'envoi a échoué). Le serveur ne
|
||||||
|
* conserve donc jamais durablement les fichiers : ils sont livrés au
|
||||||
|
* navigateur puis nettoyés.
|
||||||
|
*
|
||||||
|
* @param id - Identifiant de l'élément terminé.
|
||||||
|
* @returns Le chemin absolu du fichier et son nom, ou `null` si l'élément n'est pas terminé.
|
||||||
|
*/
|
||||||
|
public consumeCompletedFile(
|
||||||
|
id: string,
|
||||||
|
): { path: string; fileName: string; onSent: () => Promise<void> } | null {
|
||||||
|
const item = this.items.get(id);
|
||||||
|
if (!item || item.status !== 'completed' || !item.fileName) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const path = join(item.outputDir, item.fileName);
|
||||||
|
const fileName = item.fileName;
|
||||||
|
|
||||||
|
return {
|
||||||
|
path,
|
||||||
|
fileName,
|
||||||
|
onSent: () =>
|
||||||
|
this.cleanupDir(item).catch((error: unknown) => {
|
||||||
|
this.logger.error(
|
||||||
|
{ err: error, id: item.id },
|
||||||
|
"File d'attente : échec du nettoyage après livraison",
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lance autant d'éléments en attente que la concurrence disponible le
|
||||||
|
* permet.
|
||||||
|
*/
|
||||||
|
private pump(): void {
|
||||||
|
while (this.runningCount < this.maxConcurrent && this.pendingIds.length > 0) {
|
||||||
|
const id = this.pendingIds.shift();
|
||||||
|
if (!id) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const item = this.items.get(id);
|
||||||
|
if (!item) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.runningCount += 1;
|
||||||
|
this.runItem(item)
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
this.logger.error(
|
||||||
|
{ err: error, id },
|
||||||
|
"File d'attente : erreur inattendue durant l'exécution",
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.runningCount -= 1;
|
||||||
|
this.pump();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exécute le téléchargement d'un élément et met à jour son état au fil
|
||||||
|
* de l'avancement.
|
||||||
|
*
|
||||||
|
* @param item - Élément interne à exécuter.
|
||||||
|
*/
|
||||||
|
private async runItem(item: InternalQueueItem): Promise<void> {
|
||||||
|
item.status = 'running';
|
||||||
|
item.abortController = new AbortController();
|
||||||
|
this.logger.info(
|
||||||
|
{ id: item.id, url: item.url },
|
||||||
|
'File d’attente : démarrage du téléchargement',
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await mkdir(item.outputDir, { recursive: true });
|
||||||
|
const args = buildDownloadArgs(item.url, item.mode, item.outputDir);
|
||||||
|
|
||||||
|
await runYtDlp(args, {
|
||||||
|
timeoutMs: DOWNLOAD_TIMEOUT_MS,
|
||||||
|
signal: item.abortController.signal,
|
||||||
|
onStdoutLine: (line) => {
|
||||||
|
const progress = parseProgress(line);
|
||||||
|
if (progress !== null) {
|
||||||
|
item.progress = progress;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const fileName = await findOutputFile(item.outputDir, expectedExtension(item.mode));
|
||||||
|
if (!fileName) {
|
||||||
|
throw new Error('Fichier de sortie introuvable après le téléchargement');
|
||||||
|
}
|
||||||
|
|
||||||
|
item.fileName = fileName;
|
||||||
|
item.progress = 100;
|
||||||
|
item.status = 'completed';
|
||||||
|
this.logger.info({ id: item.id, fileName }, 'File d’attente : téléchargement terminé');
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.name === 'AbortError') {
|
||||||
|
item.status = 'cancelled';
|
||||||
|
this.logger.info({ id: item.id }, 'File d’attente : téléchargement annulé');
|
||||||
|
} else {
|
||||||
|
item.status = 'failed';
|
||||||
|
item.error = error instanceof Error ? error.message : String(error);
|
||||||
|
this.logger.error({ err: error, id: item.id }, 'File d’attente : échec du téléchargement');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await this.cleanupDir(item);
|
||||||
|
} catch (cleanupError) {
|
||||||
|
this.logger.error(
|
||||||
|
{ err: cleanupError, id: item.id },
|
||||||
|
"File d'attente : échec du nettoyage après erreur",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
item.abortController = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supprime le dossier de sortie d'un élément.
|
||||||
|
*
|
||||||
|
* @param item - Élément dont le dossier de sortie doit être supprimé.
|
||||||
|
*/
|
||||||
|
private cleanupDir(item: InternalQueueItem): Promise<void> {
|
||||||
|
return rm(item.outputDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convertit un élément interne en vue publique immuable.
|
||||||
|
*
|
||||||
|
* @param item - Élément interne à exposer.
|
||||||
|
* @returns Une copie publique de l'élément.
|
||||||
|
*/
|
||||||
|
private toPublic(item: InternalQueueItem): QueueItem {
|
||||||
|
return {
|
||||||
|
id: item.id,
|
||||||
|
url: item.url,
|
||||||
|
title: item.title,
|
||||||
|
mode: item.mode,
|
||||||
|
status: item.status,
|
||||||
|
progress: item.progress,
|
||||||
|
error: item.error,
|
||||||
|
fileName: item.fileName,
|
||||||
|
createdAt: item.createdAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
102
server/src/services/downloadPlan.ts
Normal file
102
server/src/services/downloadPlan.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
/** Options du mode vidéo. */
|
||||||
|
export interface VideoModeOptions {
|
||||||
|
/** Récupère et embarque les sous-titres fr/en, quand disponibles. */
|
||||||
|
readonly subtitles: boolean;
|
||||||
|
/** Embarque la miniature dans le fichier vidéo. */
|
||||||
|
readonly embedThumbnail: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Formats audio courants proposés en sortie du mode audio. */
|
||||||
|
export type AudioFormat = 'mp3' | 'm4a' | 'opus';
|
||||||
|
|
||||||
|
/** Mode de téléchargement demandé pour un élément de la file d'attente. */
|
||||||
|
export type DownloadMode =
|
||||||
|
| { readonly kind: 'video'; readonly options: VideoModeOptions }
|
||||||
|
| { readonly kind: 'audio'; readonly format: AudioFormat }
|
||||||
|
| { readonly kind: 'thumbnail' };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extension de fichier attendue pour le résultat d'un mode de téléchargement,
|
||||||
|
* utilisée pour retrouver le fichier produit par yt-dlp dans son dossier de
|
||||||
|
* sortie.
|
||||||
|
*
|
||||||
|
* @param mode - Mode de téléchargement concerné.
|
||||||
|
* @returns L'extension (sans le point) du fichier attendu.
|
||||||
|
*/
|
||||||
|
export function expectedExtension(mode: DownloadMode): string {
|
||||||
|
switch (mode.kind) {
|
||||||
|
case 'video':
|
||||||
|
return 'mp4';
|
||||||
|
case 'audio':
|
||||||
|
return mode.format;
|
||||||
|
case 'thumbnail':
|
||||||
|
return 'jpg';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construit les arguments yt-dlp permettant de télécharger `url` selon le
|
||||||
|
* mode demandé, avec la sortie écrite dans `outputDir`. `--newline` force
|
||||||
|
* une ligne de progression par mise à jour (au lieu d'un retour chariot),
|
||||||
|
* nécessaire pour un suivi ligne par ligne fiable.
|
||||||
|
*
|
||||||
|
* @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 outputDir - Dossier de sortie, propre à cet élément de file.
|
||||||
|
* @returns Le tableau d'arguments à passer à yt-dlp (jamais un shell).
|
||||||
|
*/
|
||||||
|
export function buildDownloadArgs(url: string, mode: DownloadMode, outputDir: string): string[] {
|
||||||
|
const args: string[] = ['--newline', '--no-playlist', '--add-metadata'];
|
||||||
|
|
||||||
|
switch (mode.kind) {
|
||||||
|
case 'video':
|
||||||
|
args.push(
|
||||||
|
'-f',
|
||||||
|
'bv*[ext=mp4]+ba[ext=m4a]/b[ext=mp4]/bv*+ba/b',
|
||||||
|
'--merge-output-format',
|
||||||
|
'mp4',
|
||||||
|
);
|
||||||
|
if (mode.options.subtitles) {
|
||||||
|
args.push('--write-subs', '--write-auto-subs', '--sub-langs', 'fr,en', '--embed-subs');
|
||||||
|
}
|
||||||
|
if (mode.options.embedThumbnail) {
|
||||||
|
args.push('--embed-thumbnail');
|
||||||
|
}
|
||||||
|
args.push('-o', `${outputDir}/%(title)s.%(ext)s`);
|
||||||
|
break;
|
||||||
|
case 'audio':
|
||||||
|
args.push('-x', '--audio-format', mode.format);
|
||||||
|
args.push('-o', `${outputDir}/%(title)s.%(ext)s`);
|
||||||
|
break;
|
||||||
|
case 'thumbnail':
|
||||||
|
args.push('--skip-download', '--write-thumbnail', '--convert-thumbnails', 'jpg');
|
||||||
|
args.push('-o', `thumbnail:${outputDir}/%(title)s`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// `--` marque la fin des options : l'URL, déjà validée par l'appelant, ne
|
||||||
|
// pourra jamais être réinterprétée comme une option yt-dlp.
|
||||||
|
args.push('--', url);
|
||||||
|
return args;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PROGRESS_PATTERN = /\[download]\s+(\d{1,3}(?:\.\d+)?)%/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extrait un pourcentage de progression d'une ligne de sortie yt-dlp (ex.
|
||||||
|
* `[download] 42.0% of ...`), si elle en contient un.
|
||||||
|
*
|
||||||
|
* @param line - Une ligne de la sortie standard de yt-dlp.
|
||||||
|
* @returns Le pourcentage (0-100), ou `null` si la ligne n'en contient pas.
|
||||||
|
*/
|
||||||
|
export function parseProgress(line: string): number | null {
|
||||||
|
const match = PROGRESS_PATTERN.exec(line);
|
||||||
|
if (!match?.[1]) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const value = Number.parseFloat(match[1]);
|
||||||
|
if (Number.isNaN(value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return Math.min(100, Math.max(0, value));
|
||||||
|
}
|
||||||
39
server/src/services/videoUrl.ts
Normal file
39
server/src/services/videoUrl.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
/**
|
||||||
|
* Erreur levée quand l'URL de vidéo fournie n'est pas une URL YouTube
|
||||||
|
* reconnue.
|
||||||
|
*/
|
||||||
|
export class InvalidVideoUrlError extends Error {
|
||||||
|
/**
|
||||||
|
* @param url - Valeur fournie par l'appelant, refusée telle quelle.
|
||||||
|
*/
|
||||||
|
public constructor(url: string) {
|
||||||
|
super(`URL de vidéo YouTube invalide : "${url}"`);
|
||||||
|
this.name = 'InvalidVideoUrlError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Identifiant de vidéo YouTube : toujours 11 caractères, alphabet
|
||||||
|
// [A-Za-z0-9_-]. N'accepte que youtube.com/watch|shorts|live et youtu.be -
|
||||||
|
// 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 si on le laissait accepter n'importe quelle URL ici.
|
||||||
|
const VIDEO_ID = '[\\w-]{11}';
|
||||||
|
const VIDEO_URL_PATTERN = new RegExp(
|
||||||
|
`^https://(www\\.)?(youtube\\.com/watch\\?v=${VIDEO_ID}(&\\S*)?|youtube\\.com/shorts/${VIDEO_ID}(\\?\\S*)?|youtube\\.com/live/${VIDEO_ID}(\\?\\S*)?|youtu\\.be/${VIDEO_ID}(\\?\\S*)?)$`,
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valide qu'une chaîne est bien une URL de vidéo YouTube reconnue
|
||||||
|
* (`youtube.com/watch`, `/shorts/`, `/live/` ou `youtu.be/...`).
|
||||||
|
*
|
||||||
|
* @param url - URL fournie par l'appelant.
|
||||||
|
* @returns L'URL, débarrassée des espaces superflus.
|
||||||
|
* @throws {InvalidVideoUrlError} Si `url` ne correspond à aucun format accepté.
|
||||||
|
*/
|
||||||
|
export function assertValidVideoUrl(url: string): string {
|
||||||
|
const trimmed = url.trim();
|
||||||
|
if (!VIDEO_URL_PATTERN.test(trimmed)) {
|
||||||
|
throw new InvalidVideoUrlError(url);
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
@ -22,6 +22,51 @@ export class YtDlpError extends Error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Options d'exécution de {@link runYtDlp}. */
|
||||||
|
export interface RunYtDlpOptions {
|
||||||
|
/** Délai maximal avant l'arrêt forcé du processus (30s par défaut). */
|
||||||
|
readonly timeoutMs?: number;
|
||||||
|
/** Rappelé pour chaque ligne complète reçue sur stdout, au fil de l'exécution. */
|
||||||
|
readonly onStdoutLine?: (line: string) => void;
|
||||||
|
/** Signal d'annulation : le processus est tué si ce signal se déclenche. */
|
||||||
|
readonly signal?: AbortSignal;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Découpe un flux de sortie en lignes complètes au fil des chunks reçus,
|
||||||
|
* en conservant le fragment incomplet final entre deux appels.
|
||||||
|
*/
|
||||||
|
class LineSplitter {
|
||||||
|
private buffer = '';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param onLine - Rappelé pour chaque ligne complète extraite du chunk.
|
||||||
|
*/
|
||||||
|
public constructor(private readonly onLine: (line: string) => void) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ajoute un nouveau chunk de texte et émet toute ligne désormais complète.
|
||||||
|
*
|
||||||
|
* @param chunk - Fragment de texte reçu (peut contenir 0, 1 ou plusieurs sauts de ligne).
|
||||||
|
*/
|
||||||
|
public push(chunk: string): void {
|
||||||
|
this.buffer += chunk;
|
||||||
|
const lines = this.buffer.split('\n');
|
||||||
|
this.buffer = lines.pop() ?? '';
|
||||||
|
for (const line of lines) {
|
||||||
|
this.onLine(line.replace(/\r$/, ''));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Émet le fragment restant, s'il y en a un, en fin de flux. */
|
||||||
|
public flush(): void {
|
||||||
|
if (this.buffer.length > 0) {
|
||||||
|
this.onLine(this.buffer.replace(/\r$/, ''));
|
||||||
|
this.buffer = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Exécute yt-dlp avec les arguments fournis et retourne sa sortie standard
|
* Exécute yt-dlp avec les arguments fournis et retourne sa sortie standard
|
||||||
* complète. N'utilise jamais de shell : les arguments sont passés tels
|
* complète. N'utilise jamais de shell : les arguments sont passés tels
|
||||||
|
|
@ -33,21 +78,25 @@ export class YtDlpError extends Error {
|
||||||
* en test ou sur un environnement où le binaire n'est pas sur le PATH.
|
* en test ou sur un environnement où le binaire n'est pas sur le PATH.
|
||||||
*
|
*
|
||||||
* @param args - Arguments passés à yt-dlp (jamais interpolés dans un shell).
|
* @param args - Arguments passés à yt-dlp (jamais interpolés dans un shell).
|
||||||
* @param timeoutMs - Délai maximal avant l'arrêt forcé du processus.
|
* @param options - Options d'exécution (délai, suivi ligne par ligne, annulation).
|
||||||
* @returns La sortie standard (stdout) du processus.
|
* @returns La sortie standard (stdout) complète du processus.
|
||||||
* @throws {YtDlpError} Si le processus se termine avec un code non nul.
|
* @throws {YtDlpError} Si le processus se termine avec un code non nul.
|
||||||
*/
|
*/
|
||||||
export function runYtDlp(args: readonly string[], timeoutMs = 30_000): 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;
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const child = spawn(ytDlpPath, [...args], { shell: false, timeout: timeoutMs });
|
const child = spawn(ytDlpPath, [...args], { shell: false, timeout: timeoutMs, signal });
|
||||||
|
|
||||||
let stdout = '';
|
let stdout = '';
|
||||||
let stderr = '';
|
let stderr = '';
|
||||||
|
const splitter = onStdoutLine ? new LineSplitter(onStdoutLine) : null;
|
||||||
|
|
||||||
child.stdout.on('data', (chunk: Buffer) => {
|
child.stdout.on('data', (chunk: Buffer) => {
|
||||||
stdout += chunk.toString('utf8');
|
const text = chunk.toString('utf8');
|
||||||
|
stdout += text;
|
||||||
|
splitter?.push(text);
|
||||||
});
|
});
|
||||||
child.stderr.on('data', (chunk: Buffer) => {
|
child.stderr.on('data', (chunk: Buffer) => {
|
||||||
stderr += chunk.toString('utf8');
|
stderr += chunk.toString('utf8');
|
||||||
|
|
@ -58,6 +107,12 @@ export function runYtDlp(args: readonly string[], timeoutMs = 30_000): Promise<s
|
||||||
});
|
});
|
||||||
|
|
||||||
child.on('close', (exitCode: number | null) => {
|
child.on('close', (exitCode: number | null) => {
|
||||||
|
splitter?.flush();
|
||||||
|
|
||||||
|
if (signal?.aborted) {
|
||||||
|
reject(new DOMException('yt-dlp annulé', 'AbortError'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (exitCode !== 0) {
|
if (exitCode !== 0) {
|
||||||
reject(new YtDlpError(exitCode, stderr));
|
reject(new YtDlpError(exitCode, stderr));
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
192
server/test/QueueService.test.ts
Normal file
192
server/test/QueueService.test.ts
Normal file
|
|
@ -0,0 +1,192 @@
|
||||||
|
import { mkdtemp, rm, stat, writeFile } from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
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 { QueueService } from '../src/services/QueueService.js';
|
||||||
|
import type { DownloadMode } from '../src/services/downloadPlan.js';
|
||||||
|
import { YtDlpError, runYtDlp } from '../src/services/ytDlpRunner.js';
|
||||||
|
|
||||||
|
const noopLogger = { info: () => {}, error: () => {} } as unknown as FastifyBaseLogger;
|
||||||
|
const url = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ';
|
||||||
|
const videoMode: DownloadMode = {
|
||||||
|
kind: 'video',
|
||||||
|
options: { subtitles: false, embedThumbnail: false },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Extrait le dossier de sortie passé à yt-dlp via `-o` (voir downloadPlan.ts). */
|
||||||
|
function extractOutputDir(args: readonly string[]): string {
|
||||||
|
const oIndex = args.indexOf('-o');
|
||||||
|
const template = args[oIndex + 1] ?? '';
|
||||||
|
const withoutPrefix = template.replace(/^thumbnail:/, '');
|
||||||
|
return withoutPrefix.slice(0, withoutPrefix.lastIndexOf('/'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Attend qu'une condition devienne vraie, en pollant (pas de timers factices ici : vraie I/O fichier en jeu). */
|
||||||
|
async function waitFor(predicate: () => boolean, timeoutMs = 2000): Promise<void> {
|
||||||
|
const start = Date.now();
|
||||||
|
while (!predicate()) {
|
||||||
|
if (Date.now() - start > timeoutMs) {
|
||||||
|
throw new Error('waitFor: timeout');
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let baseDir: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
baseDir = await mkdtemp(join(tmpdir(), 'ytdl-queue-test-'));
|
||||||
|
vi.mocked(runYtDlp).mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await rm(baseDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('QueueService', () => {
|
||||||
|
it('exécute un élément jusqu’à complétion et retrouve le fichier produit', async () => {
|
||||||
|
vi.mocked(runYtDlp).mockImplementation(async (args, options) => {
|
||||||
|
options?.onStdoutLine?.('[download] 50.0% of 10.00MiB');
|
||||||
|
await writeFile(join(extractOutputDir(args), 'My Video.mp4'), 'fake');
|
||||||
|
return '';
|
||||||
|
});
|
||||||
|
|
||||||
|
const service = new QueueService(noopLogger, baseDir, 2);
|
||||||
|
const item = service.enqueue(url, videoMode);
|
||||||
|
|
||||||
|
await waitFor(() => service.get(item.id)?.status === 'completed');
|
||||||
|
|
||||||
|
const final = service.get(item.id);
|
||||||
|
expect(final?.fileName).toBe('My Video.mp4');
|
||||||
|
expect(final?.progress).toBe(100);
|
||||||
|
expect(final?.error).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('respecte la concurrence maximale configurée', async () => {
|
||||||
|
let concurrentCount = 0;
|
||||||
|
let maxObservedConcurrent = 0;
|
||||||
|
const releaseFns: Array<() => void> = [];
|
||||||
|
|
||||||
|
vi.mocked(runYtDlp).mockImplementation(async (args) => {
|
||||||
|
concurrentCount += 1;
|
||||||
|
maxObservedConcurrent = Math.max(maxObservedConcurrent, concurrentCount);
|
||||||
|
await new Promise<void>((resolve) => releaseFns.push(resolve));
|
||||||
|
concurrentCount -= 1;
|
||||||
|
await writeFile(join(extractOutputDir(args), 'v.mp4'), 'x');
|
||||||
|
return '';
|
||||||
|
});
|
||||||
|
|
||||||
|
const service = new QueueService(noopLogger, baseDir, 2);
|
||||||
|
const first = service.enqueue(url, videoMode);
|
||||||
|
const second = service.enqueue(url, videoMode);
|
||||||
|
const third = service.enqueue(url, videoMode);
|
||||||
|
const items = [first, second, third];
|
||||||
|
|
||||||
|
await waitFor(() => releaseFns.length === 2);
|
||||||
|
expect(maxObservedConcurrent).toBe(2);
|
||||||
|
expect(service.get(third.id)?.status).toBe('pending');
|
||||||
|
|
||||||
|
releaseFns[0]?.();
|
||||||
|
await waitFor(() => releaseFns.length === 3);
|
||||||
|
releaseFns[1]?.();
|
||||||
|
releaseFns[2]?.();
|
||||||
|
|
||||||
|
await waitFor(() => items.every((item) => service.get(item.id)?.status === 'completed'));
|
||||||
|
expect(maxObservedConcurrent).toBeLessThanOrEqual(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marque un élément en échec et nettoie son dossier de sortie quand yt-dlp échoue', async () => {
|
||||||
|
vi.mocked(runYtDlp).mockRejectedValue(new YtDlpError(1, 'network unreachable'));
|
||||||
|
|
||||||
|
const service = new QueueService(noopLogger, baseDir, 2);
|
||||||
|
const item = service.enqueue(url, videoMode);
|
||||||
|
|
||||||
|
await waitFor(() => service.get(item.id)?.status === 'failed');
|
||||||
|
|
||||||
|
expect(service.get(item.id)?.error).toContain('network unreachable');
|
||||||
|
await expect(stat(join(baseDir, item.id))).rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('annule un élément encore en attente sans jamais appeler yt-dlp pour lui', async () => {
|
||||||
|
vi.mocked(runYtDlp).mockImplementation(() => new Promise(() => {})); // occupe le seul slot indéfiniment
|
||||||
|
|
||||||
|
const service = new QueueService(noopLogger, baseDir, 1);
|
||||||
|
service.enqueue(url, videoMode);
|
||||||
|
const second = service.enqueue(url, videoMode);
|
||||||
|
|
||||||
|
expect(service.get(second.id)?.status).toBe('pending');
|
||||||
|
expect(service.cancel(second.id)).toBe(true);
|
||||||
|
expect(service.get(second.id)?.status).toBe('cancelled');
|
||||||
|
|
||||||
|
// Le premier élément appelle yt-dlp de façon asynchrone (après son
|
||||||
|
// `mkdir`) : on attend cet appel plutôt que de supposer qu'il a déjà
|
||||||
|
// eu lieu au moment où enqueue() rend la main.
|
||||||
|
await waitFor(() => vi.mocked(runYtDlp).mock.calls.length === 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("annule un élément en cours en déclenchant son signal d'annulation", async () => {
|
||||||
|
vi.mocked(runYtDlp).mockImplementation(
|
||||||
|
(_args, options) =>
|
||||||
|
new Promise((_resolve, reject) => {
|
||||||
|
const rejectAborted = () => {
|
||||||
|
const error = new Error('aborted');
|
||||||
|
error.name = 'AbortError';
|
||||||
|
reject(error);
|
||||||
|
};
|
||||||
|
// Le signal peut déjà être annulé si cancel() a été appelé avant
|
||||||
|
// que ce mock ne soit invoqué (après le `mkdir` de runItem) : un
|
||||||
|
// listener "abort" posé après coup ne se déclencherait jamais.
|
||||||
|
if (options?.signal?.aborted) {
|
||||||
|
rejectAborted();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
options?.signal?.addEventListener('abort', rejectAborted);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const service = new QueueService(noopLogger, baseDir, 1);
|
||||||
|
const item = service.enqueue(url, videoMode);
|
||||||
|
|
||||||
|
await waitFor(() => vi.mocked(runYtDlp).mock.calls.length === 1);
|
||||||
|
expect(service.cancel(item.id)).toBe(true);
|
||||||
|
await waitFor(() => service.get(item.id)?.status === 'cancelled');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('consumeCompletedFile retourne null pour un élément inconnu ou non terminé', () => {
|
||||||
|
vi.mocked(runYtDlp).mockImplementation(() => new Promise(() => {}));
|
||||||
|
|
||||||
|
const service = new QueueService(noopLogger, baseDir, 1);
|
||||||
|
const item = service.enqueue(url, videoMode);
|
||||||
|
|
||||||
|
expect(service.consumeCompletedFile('inconnu')).toBeNull();
|
||||||
|
expect(service.consumeCompletedFile(item.id)).toBeNull(); // encore en cours
|
||||||
|
});
|
||||||
|
|
||||||
|
it('consumeCompletedFile expose le fichier puis onSent nettoie le dossier', async () => {
|
||||||
|
vi.mocked(runYtDlp).mockImplementation(async (args) => {
|
||||||
|
await writeFile(join(extractOutputDir(args), 'My Video.mp4'), 'fake');
|
||||||
|
return '';
|
||||||
|
});
|
||||||
|
|
||||||
|
const service = new QueueService(noopLogger, baseDir, 1);
|
||||||
|
const item = service.enqueue(url, videoMode);
|
||||||
|
await waitFor(() => service.get(item.id)?.status === 'completed');
|
||||||
|
|
||||||
|
const result = service.consumeCompletedFile(item.id);
|
||||||
|
if (!result) {
|
||||||
|
throw new Error('expected consumeCompletedFile to return a result');
|
||||||
|
}
|
||||||
|
expect(result.fileName).toBe('My Video.mp4');
|
||||||
|
expect(result.path).toBe(join(baseDir, item.id, 'My Video.mp4'));
|
||||||
|
|
||||||
|
await result.onSent();
|
||||||
|
await expect(stat(dirname(result.path))).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -45,7 +45,7 @@ describe('ChannelDiscoveryService', () => {
|
||||||
]);
|
]);
|
||||||
expect(runYtDlp).toHaveBeenCalledWith(
|
expect(runYtDlp).toHaveBeenCalledWith(
|
||||||
['--flat-playlist', '-j', '--', 'https://www.youtube.com/@worldclimbing/streams'],
|
['--flat-playlist', '-j', '--', 'https://www.youtube.com/@worldclimbing/streams'],
|
||||||
60_000,
|
{ timeoutMs: 60_000 },
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -62,7 +62,7 @@ describe('ChannelDiscoveryService', () => {
|
||||||
expect(results).toHaveLength(1);
|
expect(results).toHaveLength(1);
|
||||||
expect(runYtDlp).toHaveBeenCalledWith(
|
expect(runYtDlp).toHaveBeenCalledWith(
|
||||||
['--flat-playlist', '-j', '--', 'https://www.youtube.com/@worldclimbing/videos'],
|
['--flat-playlist', '-j', '--', 'https://www.youtube.com/@worldclimbing/videos'],
|
||||||
60_000,
|
{ timeoutMs: 60_000 },
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,7 @@ describe('GET /api/channel', () => {
|
||||||
expect(response.statusCode).toBe(400);
|
expect(response.statusCode).toBe(400);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renvoie 502 quand yt-dlp échoue', async () => {
|
it('renvoie 502 quand yt-dlp échoue avec un code de sortie non nul', async () => {
|
||||||
vi.mocked(runYtDlp).mockRejectedValue(new YtDlpError(1, 'network error'));
|
vi.mocked(runYtDlp).mockRejectedValue(new YtDlpError(1, 'network error'));
|
||||||
|
|
||||||
const app = Fastify();
|
const app = Fastify();
|
||||||
|
|
@ -66,4 +66,18 @@ describe('GET /api/channel', () => {
|
||||||
|
|
||||||
expect(response.statusCode).toBe(502);
|
expect(response.statusCode).toBe(502);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renvoie 502 (pas 500) quand le binaire yt-dlp est introuvable (ENOENT)', async () => {
|
||||||
|
// Node rejette avec une Error brute (pas une YtDlpError) quand le spawn
|
||||||
|
// lui-même échoue - voir ytDlpRunner.ts, l'événement 'error' du child.
|
||||||
|
const spawnError = Object.assign(new Error('spawn yt-dlp ENOENT'), { code: 'ENOENT' });
|
||||||
|
vi.mocked(runYtDlp).mockRejectedValue(spawnError);
|
||||||
|
|
||||||
|
const app = Fastify();
|
||||||
|
await app.register(channelRoutes);
|
||||||
|
|
||||||
|
const response = await app.inject({ method: 'GET', url: '/api/channel?channel=@test' });
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(502);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
133
server/test/downloadPlan.test.ts
Normal file
133
server/test/downloadPlan.test.ts
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
type DownloadMode,
|
||||||
|
buildDownloadArgs,
|
||||||
|
expectedExtension,
|
||||||
|
parseProgress,
|
||||||
|
} from '../src/services/downloadPlan.js';
|
||||||
|
|
||||||
|
describe('expectedExtension', () => {
|
||||||
|
it('retourne mp4 pour le mode vidéo', () => {
|
||||||
|
const mode: DownloadMode = {
|
||||||
|
kind: 'video',
|
||||||
|
options: { subtitles: false, embedThumbnail: false },
|
||||||
|
};
|
||||||
|
expect(expectedExtension(mode)).toBe('mp4');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retourne le format demandé pour le mode audio', () => {
|
||||||
|
expect(expectedExtension({ kind: 'audio', format: 'opus' })).toBe('opus');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retourne jpg pour le mode miniature', () => {
|
||||||
|
expect(expectedExtension({ kind: 'thumbnail' })).toBe('jpg');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildDownloadArgs', () => {
|
||||||
|
const url = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ';
|
||||||
|
const outputDir = '/tmp/abc123';
|
||||||
|
|
||||||
|
it('mode vidéo sans options : pas de sous-titres ni de miniature embarquée', () => {
|
||||||
|
const args = buildDownloadArgs(
|
||||||
|
url,
|
||||||
|
{ kind: 'video', options: { subtitles: false, embedThumbnail: false } },
|
||||||
|
outputDir,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(args).toEqual([
|
||||||
|
'--newline',
|
||||||
|
'--no-playlist',
|
||||||
|
'--add-metadata',
|
||||||
|
'-f',
|
||||||
|
'bv*[ext=mp4]+ba[ext=m4a]/b[ext=mp4]/bv*+ba/b',
|
||||||
|
'--merge-output-format',
|
||||||
|
'mp4',
|
||||||
|
'-o',
|
||||||
|
`${outputDir}/%(title)s.%(ext)s`,
|
||||||
|
'--',
|
||||||
|
url,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mode vidéo avec sous-titres et miniature embarquée', () => {
|
||||||
|
const args = buildDownloadArgs(
|
||||||
|
url,
|
||||||
|
{ kind: 'video', options: { subtitles: true, embedThumbnail: true } },
|
||||||
|
outputDir,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(args).toContain('--embed-subs');
|
||||||
|
expect(args).toContain('--embed-thumbnail');
|
||||||
|
expect(args).toEqual(
|
||||||
|
expect.arrayContaining(['--write-subs', '--write-auto-subs', '--sub-langs', 'fr,en']),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mode audio : extraction au format demandé', () => {
|
||||||
|
const args = buildDownloadArgs(url, { kind: 'audio', format: 'opus' }, outputDir);
|
||||||
|
|
||||||
|
expect(args).toEqual([
|
||||||
|
'--newline',
|
||||||
|
'--no-playlist',
|
||||||
|
'--add-metadata',
|
||||||
|
'-x',
|
||||||
|
'--audio-format',
|
||||||
|
'opus',
|
||||||
|
'-o',
|
||||||
|
`${outputDir}/%(title)s.%(ext)s`,
|
||||||
|
'--',
|
||||||
|
url,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mode miniature : téléchargement de la vidéo ignoré', () => {
|
||||||
|
const args = buildDownloadArgs(url, { kind: 'thumbnail' }, outputDir);
|
||||||
|
|
||||||
|
expect(args).toEqual([
|
||||||
|
'--newline',
|
||||||
|
'--no-playlist',
|
||||||
|
'--add-metadata',
|
||||||
|
'--skip-download',
|
||||||
|
'--write-thumbnail',
|
||||||
|
'--convert-thumbnails',
|
||||||
|
'jpg',
|
||||||
|
'-o',
|
||||||
|
`thumbnail:${outputDir}/%(title)s`,
|
||||||
|
'--',
|
||||||
|
url,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("place toujours l'URL après un séparateur `--`, quel que soit le mode", () => {
|
||||||
|
for (const mode of [
|
||||||
|
{ kind: 'video', options: { subtitles: false, embedThumbnail: false } },
|
||||||
|
{ kind: 'audio', format: 'mp3' },
|
||||||
|
{ kind: 'thumbnail' },
|
||||||
|
] satisfies DownloadMode[]) {
|
||||||
|
const args = buildDownloadArgs(url, mode, outputDir);
|
||||||
|
const separatorIndex = args.indexOf('--');
|
||||||
|
expect(separatorIndex).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(args[separatorIndex + 1]).toBe(url);
|
||||||
|
expect(args).toHaveLength(separatorIndex + 2);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseProgress', () => {
|
||||||
|
it('extrait un pourcentage entier', () => {
|
||||||
|
expect(parseProgress('[download] 42% of 10.00MiB at 1.00MiB/s ETA 00:05')).toBe(42);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extrait un pourcentage décimal', () => {
|
||||||
|
expect(parseProgress('[download] 99.5% of 10.00MiB')).toBe(99.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retourne null pour une ligne sans progression', () => {
|
||||||
|
expect(parseProgress('[info] Writing video metadata as JSON')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('borne le résultat entre 0 et 100', () => {
|
||||||
|
expect(parseProgress('[download] 100.0% of 10.00MiB')).toBe(100);
|
||||||
|
});
|
||||||
|
});
|
||||||
111
server/test/queueRoute.test.ts
Normal file
111
server/test/queueRoute.test.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
import { mkdtemp, rm } from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import Fastify 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(() => new Promise(() => {})) };
|
||||||
|
});
|
||||||
|
|
||||||
|
import { createQueueRoutes } from '../src/routes/queue.js';
|
||||||
|
|
||||||
|
const validUrl = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ';
|
||||||
|
|
||||||
|
let tmpDir: string;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
tmpDir = await mkdtemp(join(tmpdir(), 'ytdl-queue-route-test-'));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await rm(tmpDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('routes de file d’attente', () => {
|
||||||
|
it('POST /api/queue ajoute un élément valide et le retourne', async () => {
|
||||||
|
const app = Fastify();
|
||||||
|
await app.register(createQueueRoutes({ tmpDir, maxConcurrentDownloads: 2 }));
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/queue',
|
||||||
|
payload: { url: validUrl, mode: 'audio', audioFormat: 'opus' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(201);
|
||||||
|
const body = response.json();
|
||||||
|
expect(body.item).toMatchObject({
|
||||||
|
url: validUrl,
|
||||||
|
mode: { kind: 'audio', format: 'opus' },
|
||||||
|
status: expect.stringMatching(/pending|running/),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /api/queue renvoie 400 pour une URL non-YouTube', async () => {
|
||||||
|
const app = Fastify();
|
||||||
|
await app.register(createQueueRoutes({ tmpDir, maxConcurrentDownloads: 2 }));
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/queue',
|
||||||
|
payload: { url: 'https://evil.com/x', mode: 'video' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /api/queue renvoie 400 pour un mode invalide (validation de schéma)', async () => {
|
||||||
|
const app = Fastify();
|
||||||
|
await app.register(createQueueRoutes({ tmpDir, maxConcurrentDownloads: 2 }));
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/queue',
|
||||||
|
payload: { url: validUrl, mode: 'video-4k-ultra' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET /api/queue liste les éléments ajoutés', async () => {
|
||||||
|
const app = Fastify();
|
||||||
|
await app.register(createQueueRoutes({ tmpDir, maxConcurrentDownloads: 2 }));
|
||||||
|
|
||||||
|
await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/queue',
|
||||||
|
payload: { url: validUrl, mode: 'thumbnail' },
|
||||||
|
});
|
||||||
|
const response = await app.inject({ method: 'GET', url: '/api/queue' });
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.json().items).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DELETE /api/queue/:id renvoie 404 pour un identifiant inconnu', async () => {
|
||||||
|
const app = Fastify();
|
||||||
|
await app.register(createQueueRoutes({ tmpDir, maxConcurrentDownloads: 2 }));
|
||||||
|
|
||||||
|
const response = await app.inject({ method: 'DELETE', url: '/api/queue/inconnu' });
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET /api/queue/:id/file renvoie 404 tant que le téléchargement n’est pas terminé', async () => {
|
||||||
|
const app = Fastify();
|
||||||
|
await app.register(createQueueRoutes({ tmpDir, maxConcurrentDownloads: 2 }));
|
||||||
|
|
||||||
|
const created = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/queue',
|
||||||
|
payload: { url: validUrl, mode: 'video' },
|
||||||
|
});
|
||||||
|
const { id } = created.json().item;
|
||||||
|
|
||||||
|
const response = await app.inject({ method: 'GET', url: `/api/queue/${id}/file` });
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
31
server/test/videoUrl.test.ts
Normal file
31
server/test/videoUrl.test.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { InvalidVideoUrlError, assertValidVideoUrl } from '../src/services/videoUrl.js';
|
||||||
|
|
||||||
|
describe('assertValidVideoUrl', () => {
|
||||||
|
it.each([
|
||||||
|
'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
|
||||||
|
'https://youtube.com/watch?v=dQw4w9WgXcQ',
|
||||||
|
'https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=30s',
|
||||||
|
'https://www.youtube.com/shorts/dQw4w9WgXcQ',
|
||||||
|
'https://www.youtube.com/live/dQw4w9WgXcQ',
|
||||||
|
'https://youtu.be/dQw4w9WgXcQ',
|
||||||
|
'https://youtu.be/dQw4w9WgXcQ?t=30',
|
||||||
|
])('accepte "%s"', (url) => {
|
||||||
|
expect(assertValidVideoUrl(url)).toBe(url);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
'',
|
||||||
|
'not a url',
|
||||||
|
'ftp://youtube.com/watch?v=dQw4w9WgXcQ',
|
||||||
|
'https://evil.com/watch?v=dQw4w9WgXcQ',
|
||||||
|
'https://youtube.com.evil.com/watch?v=dQw4w9WgXcQ',
|
||||||
|
'https://www.youtube.com/watch?v=short',
|
||||||
|
'https://www.youtube.com/@somechannel',
|
||||||
|
'https://www.youtube.com/watch?v=dQw4w9WgXcQ; rm -rf /',
|
||||||
|
'--exec=touch /tmp/pwned https://www.youtube.com/watch?v=dQw4w9WgXcQ',
|
||||||
|
'file:///etc/passwd',
|
||||||
|
])('rejette "%s"', (url) => {
|
||||||
|
expect(() => assertValidVideoUrl(url)).toThrow(InvalidVideoUrlError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -22,4 +22,36 @@ describe('runYtDlp', () => {
|
||||||
YtDlpError,
|
YtDlpError,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('appelle onStdoutLine pour chaque ligne, sans attendre la fin du processus', async () => {
|
||||||
|
process.env.YTDLP_PATH = process.execPath;
|
||||||
|
const lines: string[] = [];
|
||||||
|
|
||||||
|
await runYtDlp(['-e', "console.log('a'); console.log('b'); console.log('c')"], {
|
||||||
|
onStdoutLine: (line) => lines.push(line),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(lines).toEqual(['a', 'b', 'c']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('émet le fragment final même sans saut de ligne terminal', async () => {
|
||||||
|
process.env.YTDLP_PATH = process.execPath;
|
||||||
|
const lines: string[] = [];
|
||||||
|
|
||||||
|
await runYtDlp(['-e', "process.stdout.write('sans-newline')"], {
|
||||||
|
onStdoutLine: (line) => lines.push(line),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(lines).toEqual(['sans-newline']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejette avec une AbortError quand le signal d'annulation se déclenche", async () => {
|
||||||
|
process.env.YTDLP_PATH = process.execPath;
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
const promise = runYtDlp(['-e', 'setTimeout(() => {}, 5000)'], { signal: controller.signal });
|
||||||
|
controller.abort();
|
||||||
|
|
||||||
|
await expect(promise).rejects.toMatchObject({ name: 'AbortError' });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -18,24 +18,36 @@ 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`).
|
- `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`). Chaque domaine métier (découverte de chaîne, file d'attente, téléchargement, notifications temps réel à venir) 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`, `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).
|
- `ytDlpRunner.ts` : exécution bas niveau de yt-dlp (`node:child_process.spawn`, jamais de shell - arguments toujours passés en tableau, jamais concaténés dans une chaîne, pour exclure toute injection de commande). Supporte le suivi ligne par ligne de stdout (`onStdoutLine`, pour la progression) et l'annulation via `AbortSignal`.
|
||||||
- `ChannelDiscoveryService.ts` : liste les vidéos/streams d'une chaîne (`yt-dlp --flat-playlist -j`), filtre par mots-clés sur le titre. Valide strictement le handle/URL de chaîne en entrée (premier caractère forcément alphanumérique) et ajoute un séparateur `--` avant l'URL passée à yt-dlp, en défense en profondeur contre l'injection d'options.
|
- `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.
|
||||||
|
- `videoUrl.ts` : valide qu'une URL de vidéo est bien sur `youtube.com`/`youtu.be` (formats `watch`/`shorts`/`live`/court) avant de la transmettre à yt-dlp. N'accepte aucun autre domaine : yt-dlp sait suivre des centaines de sites via un extracteur générique capable de récupérer une URL quelconque, ce qui en ferait un relais SSRF si cette validation n'existait pas.
|
||||||
|
- `downloadPlan.ts` : fonctions pures construisant les arguments yt-dlp selon le mode (vidéo/audio/miniature) et parsant la progression (`[download] 42.0% ...`) depuis stdout. Toujours un séparateur `--` avant l'URL (même défense qu'au-dessus).
|
||||||
|
- `QueueService.ts` : file d'attente en mémoire, exécution avec concurrence bornée (`MAX_CONCURRENT_DOWNLOADS`, défaut 2). Chaque élément a son propre sous-dossier sous `TMP_DIR`, supprimé après échec/annulation ou après livraison du fichier au client (`consumeCompletedFile` -> `onSent`) : le serveur ne conserve jamais durablement les fichiers.
|
||||||
- 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/`). Deux niveaux : services testés directement (avec `ytDlpRunner` mocké pour `ChannelDiscoveryService` - seul `ytDlpRunner` lui-même spawn un vrai process, `node` en test), et routes testées via `app.inject()` (schéma de validation Fastify, mapping des erreurs en codes HTTP). Les deux 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.
|
- 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`).
|
||||||
|
|
||||||
## 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é). Voir `src/shell/` pour l'exemple actuel (placeholder).
|
- 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`).
|
- 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 encore de router : une seule vue (shell) pour l'instant. Un routeur sera introduit avec la première vraie vue (parcours de chaîne).
|
- Pas de router : `src/shell/Shell.ts` monte deux vues fixes côte à côte (`views/downloadForm/`, `views/queuePanel/`), reliées par un callback simple (`onItemQueued`) plutôt qu'un bus d'événements généraliste - au-delà de deux vues qui coexistent sur un seul écran, un vrai routeur deviendrait nécessaire.
|
||||||
|
- `src/api/` : clients fetch vers le backend (`channelApi.ts`, `queueApi.ts`), avec leurs propres types DTO - pas de paquet de types partagé avec `server/` (monorepo simple, pas de workspace npm).
|
||||||
|
- `src/views/downloadForm/` : formulaire d'ajout, deux entrées possibles (URL directe, ou recherche de chaîne via `GET /api/channel` puis ajout par résultat) partageant un même sélecteur de mode (vidéo/audio/miniature + options). Le mode courant est lu depuis le DOM à l'ajout, pas stocké en état séparé.
|
||||||
|
- `src/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, bouton annuler/lien de téléchargement selon le statut). 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.
|
||||||
|
- 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.
|
||||||
|
|
||||||
## 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>§ion=<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>§ion=<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.
|
||||||
|
- `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` : annule un élément (`204`) - retiré de la file s'il était en attente, processus yt-dlp arrêté s'il était en cours. `404` si inconnu ou déjà terminé.
|
||||||
|
- `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. `404` tant que l'élément n'est pas `completed`.
|
||||||
|
|
||||||
## Lint
|
## Lint
|
||||||
|
|
||||||
|
|
@ -45,9 +57,9 @@ Biome.js à la racine (`biome.json`), applique aux deux paquets (`server/src`, `
|
||||||
|
|
||||||
- `server/Dockerfile` : image multi-étapes construite avec pour contexte la racine du dépôt (`docker build -f server/Dockerfile .`), car elle a besoin à la fois de `server/` et `client/`. Étapes : build client (esbuild) → build serveur (tsc) → image runtime (Node 22 Alpine + `ffmpeg` + binaire autonome `yt-dlp`, utilisateur non-root `node`).
|
- `server/Dockerfile` : image multi-étapes construite avec pour contexte la racine du dépôt (`docker build -f server/Dockerfile .`), car elle a besoin à la fois de `server/` et `client/`. Étapes : build client (esbuild) → build serveur (tsc) → image runtime (Node 22 Alpine + `ffmpeg` + binaire autonome `yt-dlp`, utilisateur non-root `node`).
|
||||||
- `yt-dlp` et `ffmpeg` sont installés dans l'image (pas vendorisés dans le dépôt, contrairement aux scripts `.cmd` d'origine qui embarquaient `yt-dlp.exe` et un build ffmpeg complet).
|
- `yt-dlp` et `ffmpeg` sont installés dans l'image (pas vendorisés dans le dépôt, contrairement aux scripts `.cmd` d'origine qui embarquaient `yt-dlp.exe` et un build ffmpeg complet).
|
||||||
- `.forgejo/workflows/publish-docker.yml` : job `test` (lint racine, typecheck/tests/build serveur, typecheck/build client) puis job `publish` (build + push de l'image vers `git.dev.kyuno.fr/kyuno/youtubedownloader`, seulement si `test` passe et hors PR). Tags : `latest` + sha court sur push `main`, `X.Y.Z`/`X.Y`/`latest` sur tag `vX.Y.Z`. Nécessite le secret de dépôt `REGISTRY_TOKEN` (à créer manuellement, voir l'en-tête du workflow - un jeton d'API n'a pas le droit d'écrire dans le registre de paquets).
|
- `.forgejo/workflows/publish-docker.yml` : job `test` (lint racine, typecheck/tests/build serveur, typecheck/tests/build client) puis job `publish` (build + push de l'image vers `git.dev.kyuno.fr/kyuno/youtubedownloader`, seulement si `test` passe et hors PR). Tags : `latest` + sha court sur push `main`, `X.Y.Z`/`X.Y`/`latest` sur tag `vX.Y.Z`. Nécessite le secret de dépôt `REGISTRY_TOKEN` (à créer manuellement, voir l'en-tête du workflow - un jeton d'API n'a pas le droit d'écrire dans le registre de paquets).
|
||||||
|
|
||||||
## Statut
|
## Statut
|
||||||
|
|
||||||
- Fait : infrastructure (serveur qui répond, client placeholder, Docker, CI), découverte de chaîne (`GET /api/channel`).
|
- 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).
|
||||||
- À venir (PR successives) : file d'attente, téléchargement yt-dlp (vidéo/audio/miniature), notifications temps réel, vues frontend (parcours de chaîne, file d'attente).
|
- À 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).
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue