Merge pull request 'feat(client): frontend (formulaire d'ajout + file d'attente)' (#3) from feature/frontend-ui into feature/download-queue
Reviewed-on: #3
This commit is contained in:
commit
6c5e29246c
24 changed files with 2556 additions and 40 deletions
|
|
@ -84,6 +84,10 @@ jobs:
|
|||
working-directory: client
|
||||
run: npm run typecheck
|
||||
|
||||
- name: Client - lancer les tests
|
||||
working-directory: client
|
||||
run: npm test
|
||||
|
||||
- name: Client - build
|
||||
working-directory: client
|
||||
run: npm run build
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ const buildOptions = {
|
|||
outfile: path.join(outdir, 'app.js'),
|
||||
bundle: true,
|
||||
format: 'esm',
|
||||
target: 'es2020',
|
||||
target: 'es2022',
|
||||
sourcemap: true,
|
||||
// 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
|
||||
|
|
|
|||
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": {
|
||||
"build": "node build.mjs",
|
||||
"watch": "node build.mjs --watch",
|
||||
"typecheck": "tsc --noEmit"
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"devDependencies": {
|
||||
"esbuild": "^0.28.2",
|
||||
"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 { 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
|
||||
* initial de l'application.
|
||||
* Point d'entrée du client : monte le shell puis les deux vues qu'il
|
||||
* 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 {
|
||||
const root = document.getElementById('app');
|
||||
if (!root) {
|
||||
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();
|
||||
|
|
|
|||
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">
|
||||
<h1>youtubeDownloader</h1>
|
||||
<p>L'interface arrive bientôt — pour l'instant, seul le serveur est en place.</p>
|
||||
<header class="shell__header">
|
||||
<h1>youtubeDownloader</h1>
|
||||
</header>
|
||||
<main class="shell__main">
|
||||
<section class="shell__panel" id="download-form"></section>
|
||||
<section class="shell__panel" id="queue-panel"></section>
|
||||
</main>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,19 +1,41 @@
|
|||
.shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100vh;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
font-family: system-ui, sans-serif;
|
||||
text-align: center;
|
||||
color: #222;
|
||||
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
&__header {
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid #eee;
|
||||
|
||||
h1 {
|
||||
font-size: 1.25rem;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
color: #666;
|
||||
&__main {
|
||||
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 './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.
|
||||
*
|
||||
* @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;
|
||||
|
||||
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": {
|
||||
"target": "ES2020",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
|
|
@ -12,5 +12,5 @@
|
|||
"noEmit": true,
|
||||
"isolatedModules": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
"include": ["src/**/*.ts", "test/**/*.ts"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import {
|
|||
type ChannelSection,
|
||||
InvalidChannelError,
|
||||
} from '../services/ChannelDiscoveryService.js';
|
||||
import { YtDlpError } from '../services/ytDlpRunner.js';
|
||||
|
||||
/** Paramètres de requête acceptés par `GET /api/channel`. */
|
||||
interface ListChannelQuerystring {
|
||||
|
|
@ -72,12 +71,14 @@ export const channelRoutes: FastifyPluginAsync = async (app: FastifyInstance): P
|
|||
if (error instanceof InvalidChannelError) {
|
||||
return reply.status(400).send({ error: error.message });
|
||||
}
|
||||
if (error instanceof YtDlpError) {
|
||||
return reply
|
||||
.status(502)
|
||||
.send({ error: 'Échec de la récupération depuis YouTube (yt-dlp).' });
|
||||
}
|
||||
throw error;
|
||||
// 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
|
||||
.status(502)
|
||||
.send({ error: 'Échec de la récupération depuis YouTube (yt-dlp).' });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ import { InvalidVideoUrlError, assertValidVideoUrl } from '../services/videoUrl.
|
|||
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. */
|
||||
|
|
@ -24,6 +26,7 @@ const createQueueItemSchema = {
|
|||
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' },
|
||||
|
|
@ -97,7 +100,11 @@ export function createQueueRoutes(
|
|||
throw error;
|
||||
}
|
||||
|
||||
const item = queueService.enqueue(url, toDownloadMode(request.body));
|
||||
const item = queueService.enqueue(
|
||||
url,
|
||||
toDownloadMode(request.body),
|
||||
request.body.title ?? null,
|
||||
);
|
||||
return reply.status(201).send({ item });
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ export interface QueueItem {
|
|||
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. */
|
||||
|
|
@ -39,6 +41,7 @@ const DOWNLOAD_TIMEOUT_MS = 30 * 60_000;
|
|||
interface InternalQueueItem {
|
||||
id: string;
|
||||
url: string;
|
||||
title: string | null;
|
||||
mode: DownloadMode;
|
||||
status: QueueItemStatus;
|
||||
progress: number;
|
||||
|
|
@ -96,13 +99,15 @@ export class QueueService {
|
|||
*
|
||||
* @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): QueueItem {
|
||||
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,
|
||||
|
|
@ -310,6 +315,7 @@ export class QueueService {
|
|||
return {
|
||||
id: item.id,
|
||||
url: item.url,
|
||||
title: item.title,
|
||||
mode: item.mode,
|
||||
status: item.status,
|
||||
progress: item.progress,
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ describe('GET /api/channel', () => {
|
|||
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'));
|
||||
|
||||
const app = Fastify();
|
||||
|
|
@ -66,4 +66,18 @@ describe('GET /api/channel', () => {
|
|||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -31,15 +31,20 @@ Pas de `docker-compose.yml` pour l'instant (une seule image, pas encore de dépe
|
|||
|
||||
## 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).
|
||||
- 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`).
|
||||
- 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).
|
||||
- TypeScript vanilla, pas de framework. Chaque vue = un dossier avec `Vue.ts` / `Vue.html` / `Vue.scss` (gabarit importé comme chaîne brute, style Sass importé et compilé).
|
||||
- Build via `build.mjs` (esbuild), pas d'outil de build plus lourd (Vite, webpack) : le script compile `src/app.ts` en bundle ESM, copie `src/index.html`, et compile/regroupe les `.scss` importés en un `app.css` voisin (plugin esbuild maison qui délègue au paquet `sass`). Cible ES2022 (nécessaire pour `Error.cause`, utilisé dans `api/`).
|
||||
- Pas de router : `src/shell/Shell.ts` monte deux vues fixes côte à côte (`views/downloadForm/`, `views/queuePanel/`), reliées par un callback simple (`onItemQueued`) plutôt qu'un bus d'événements généraliste - au-delà de deux vues qui coexistent sur un seul écran, un vrai routeur deviendrait nécessaire.
|
||||
- `src/api/` : clients fetch vers le backend (`channelApi.ts`, `queueApi.ts`), avec leurs propres types DTO - pas de paquet de types partagé avec `server/` (monorepo simple, pas de workspace npm).
|
||||
- `src/views/downloadForm/` : formulaire d'ajout, deux entrées possibles (URL directe, ou recherche de chaîne via `GET /api/channel` puis ajout par résultat) partageant un même sélecteur de mode (vidéo/audio/miniature + options). Le mode courant est lu depuis le DOM à l'ajout, pas stocké en état séparé.
|
||||
- `src/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
|
||||
|
||||
- `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.
|
||||
- `POST /api/queue` : ajoute un élément (`{ url, mode: "video"|"audio"|"thumbnail", subtitles?, embedThumbnail?, audioFormat? }`). `201 { item }`, `400` si l'URL n'est pas une URL de vidéo YouTube reconnue.
|
||||
- `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`.
|
||||
|
|
@ -52,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`).
|
||||
- `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
|
||||
|
||||
- Fait : infrastructure (serveur qui répond, client placeholder, 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*`).
|
||||
- À venir (PR successives) : notifications temps réel (WebSocket, progression poussée plutôt que sondage de `GET /api/queue`), vues frontend (parcours de chaîne, file d'attente).
|
||||
- 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) : 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