Merge pull request 'feat: suppression d'éléments de la file, téléchargement groupé' (#5) from feat/queue-remove-and-bulk-download into main
All checks were successful
Publier l'image Docker / test (push) Successful in 23s
Publier l'image Docker / publish (push) Successful in 27s

This commit is contained in:
kyuno 2026-09-16 22:51:55 +02:00
commit 915a2af0de
10 changed files with 201 additions and 60 deletions

View file

@ -106,12 +106,13 @@ export async function listQueueItems(): Promise<QueueItemDto[]> {
} }
/** /**
* Annule un élément de la file d'attente via `DELETE /api/queue/:id`. * Supprime un élément de la file d'attente via `DELETE /api/queue/:id`,
* quel que soit son état (l'annule d'abord s'il était en attente/en cours).
* *
* @param id - Identifiant de l'élément à annuler. * @param id - Identifiant de l'élément à supprimer.
* @throws {Error} Si la requête échoue ou si l'élément est introuvable. * @throws {Error} Si la requête échoue ou si l'élément est introuvable.
*/ */
export async function cancelQueueItem(id: string): Promise<void> { export async function removeQueueItem(id: string): Promise<void> {
let response: Response; let response: Response;
try { try {
response = await fetch(`/api/queue/${encodeURIComponent(id)}`, { method: 'DELETE' }); response = await fetch(`/api/queue/${encodeURIComponent(id)}`, { method: 'DELETE' });

View file

@ -84,7 +84,7 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.5rem; gap: 0.5rem;
max-height: 320px; max-height: 65vh;
overflow-y: auto; overflow-y: auto;
} }

View file

@ -1,5 +1,8 @@
<div class="queue-panel"> <div class="queue-panel">
<h2>File d'attente</h2> <div class="queue-panel__header">
<h2>File d'attente</h2>
<button type="button" class="secondary" data-role="download-all" hidden>Tout télécharger</button>
</div>
<p class="queue-panel__empty" data-role="empty">Aucun élément pour l'instant.</p> <p class="queue-panel__empty" data-role="empty">Aucun élément pour l'instant.</p>
<ul class="queue-panel__list" data-role="list"></ul> <ul class="queue-panel__list" data-role="list"></ul>
</div> </div>

View file

@ -9,6 +9,18 @@
font-weight: 600; font-weight: 600;
} }
&__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
button {
font-size: 0.8rem;
padding: 0.35rem 0.7rem;
}
}
&__empty { &__empty {
color: var(--color-text-muted); color: var(--color-text-muted);
font-size: 0.85rem; font-size: 0.85rem;

View file

@ -1,8 +1,8 @@
import { import {
type QueueItemDto, type QueueItemDto,
cancelQueueItem,
listQueueItems, listQueueItems,
queueItemFileUrl, queueItemFileUrl,
removeQueueItem,
} from '../../api/queueApi'; } from '../../api/queueApi';
import queuePanelTemplate from './QueuePanel.html'; import queuePanelTemplate from './QueuePanel.html';
import './QueuePanel.scss'; import './QueuePanel.scss';
@ -10,6 +10,9 @@ import './QueuePanel.scss';
/** Intervalle de sondage de `GET /api/queue`, en l'absence de push temps réel (à venir). */ /** Intervalle de sondage de `GET /api/queue`, en l'absence de push temps réel (à venir). */
const POLL_INTERVAL_MS = 1500; const POLL_INTERVAL_MS = 1500;
/** Délai entre deux déclenchements de téléchargement lors d'un "tout télécharger" (limite les blocages navigateur sur les téléchargements multiples). */
const BULK_DOWNLOAD_STAGGER_MS = 250;
const STATUS_LABELS: Record<QueueItemDto['status'], string> = { const STATUS_LABELS: Record<QueueItemDto['status'], string> = {
pending: 'En attente', pending: 'En attente',
running: 'En cours', running: 'En cours',
@ -31,32 +34,77 @@ export interface QueuePanelController {
} }
/** /**
* Annule un élément de la file au clic sur son bouton "Annuler". * @param item - Élément dont on veut savoir s'il est encore actionnable (en attente/en cours).
* @returns `true` si l'élément peut encore être annulé.
*/
function isActive(item: QueueItemDto): boolean {
return item.status === 'pending' || item.status === 'running';
}
/**
* @param item - Élément à tester.
* @returns `true` si un fichier est disponible au téléchargement pour cet élément.
*/
function isDownloadable(item: QueueItemDto): boolean {
return item.status === 'completed' && item.fileName !== null;
}
/**
* Supprime un élément de la file au clic sur son bouton "Annuler"/"Supprimer".
* *
* @param id - Identifiant de l'élément à annuler. * @param id - Identifiant de l'élément à supprimer.
* @param button - Bouton cliqué, désactivé pendant la requête. * @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. * @param onDone - Rappelé après la requête (succès ou échec) pour rafraîchir la liste.
*/ */
async function handleCancel( async function handleRemove(
id: string, id: string,
button: HTMLButtonElement, button: HTMLButtonElement,
onDone: () => void, onDone: () => void,
): Promise<void> { ): Promise<void> {
button.disabled = true; button.disabled = true;
try { try {
await cancelQueueItem(id); await removeQueueItem(id);
} catch (error) { } catch (error) {
console.error("Échec de l'annulation", error); console.error('Échec de la suppression', error);
} finally { } finally {
onDone(); onDone();
} }
} }
/**
* Déclenche, avec un léger décalage entre chacun, le téléchargement de tous
* les fichiers actuellement disponibles (évite le blocage navigateur des
* téléchargements multiples déclenchés au même instant).
*
* @param button - Bouton "Tout télécharger" cliqué, désactivé pendant l'opération.
*/
async function handleDownloadAll(button: HTMLButtonElement): Promise<void> {
button.disabled = true;
try {
const items = await listQueueItems();
const downloadable = items.filter(isDownloadable);
downloadable.forEach((item, index) => {
setTimeout(() => {
const link = document.createElement('a');
link.href = queueItemFileUrl(item.id);
document.body.append(link);
link.click();
link.remove();
}, index * BULK_DOWNLOAD_STAGGER_MS);
});
} catch (error) {
console.error('Échec du téléchargement groupé', error);
} finally {
button.disabled = false;
}
}
/** /**
* Construit la ligne d'affichage d'un élément de la file. * Construit la ligne d'affichage d'un élément de la file.
* *
* @param item - Élément à afficher. * @param item - Élément à afficher.
* @param onChanged - Rappelé après une action (annulation) pour rafraîchir la liste. * @param onChanged - Rappelé après une action (annulation/suppression) pour rafraîchir la liste.
* @returns L'élément `<li>` prêt à être inséré dans la liste. * @returns L'élément `<li>` prêt à être inséré dans la liste.
*/ */
function renderItem(item: QueueItemDto, onChanged: () => void): HTMLLIElement { function renderItem(item: QueueItemDto, onChanged: () => void): HTMLLIElement {
@ -73,7 +121,7 @@ function renderItem(item: QueueItemDto, onChanged: () => void): HTMLLIElement {
meta.textContent = `${MODE_LABELS[item.mode.kind]} · ${STATUS_LABELS[item.status]}`; meta.textContent = `${MODE_LABELS[item.mode.kind]} · ${STATUS_LABELS[item.status]}`;
li.append(meta); li.append(meta);
if (item.status === 'running' || item.status === 'pending') { if (isActive(item)) {
const progress = document.createElement('progress'); const progress = document.createElement('progress');
progress.className = 'queue-panel__item-progress'; progress.className = 'queue-panel__item-progress';
progress.max = 100; progress.max = 100;
@ -88,23 +136,27 @@ function renderItem(item: QueueItemDto, onChanged: () => void): HTMLLIElement {
li.append(error); li.append(error);
} }
if (item.status === 'completed' && !item.fileName) {
const note = document.createElement('p');
note.className = 'queue-panel__item-meta';
note.textContent = 'Déjà téléchargé';
li.append(note);
}
const actions = document.createElement('div'); const actions = document.createElement('div');
actions.className = 'queue-panel__item-actions'; actions.className = 'queue-panel__item-actions';
if (item.status === 'pending' || item.status === 'running') { const removeButton = document.createElement('button');
const cancelButton = document.createElement('button'); removeButton.type = 'button';
cancelButton.type = 'button'; removeButton.className = 'secondary';
cancelButton.className = 'secondary'; removeButton.textContent = isActive(item) ? 'Annuler' : 'Supprimer';
cancelButton.textContent = 'Annuler'; removeButton.addEventListener('click', () => {
cancelButton.addEventListener('click', () => { handleRemove(item.id, removeButton, onChanged).catch((error: unknown) => {
handleCancel(item.id, cancelButton, onChanged).catch((error: unknown) => { console.error('Erreur inattendue lors de la suppression', error);
console.error("Erreur inattendue lors de l'annulation", error);
});
}); });
actions.append(cancelButton); });
}
if (item.status === 'completed') { if (isDownloadable(item)) {
const link = document.createElement('a'); const link = document.createElement('a');
link.href = queueItemFileUrl(item.id); link.href = queueItemFileUrl(item.id);
link.textContent = 'Télécharger'; link.textContent = 'Télécharger';
@ -112,12 +164,14 @@ function renderItem(item: QueueItemDto, onChanged: () => void): HTMLLIElement {
actions.append(link); actions.append(link);
} }
actions.append(removeButton);
li.append(actions); li.append(actions);
return li; return li;
} }
/** /**
* Rend la liste des éléments dans le panneau. * Rend la liste des éléments dans le panneau et ajuste la visibilité du
* bouton "Tout télécharger" selon qu'il existe au moins un fichier prêt.
* *
* @param root - Racine du panneau monté. * @param root - Racine du panneau monté.
* @param items - Éléments à afficher. * @param items - Éléments à afficher.
@ -126,12 +180,17 @@ function renderItem(item: QueueItemDto, onChanged: () => void): HTMLLIElement {
function render(root: HTMLElement, items: readonly QueueItemDto[], onChanged: () => void): void { function render(root: HTMLElement, items: readonly QueueItemDto[], onChanged: () => void): void {
const list = root.querySelector<HTMLElement>('[data-role="list"]'); const list = root.querySelector<HTMLElement>('[data-role="list"]');
const empty = root.querySelector<HTMLElement>('[data-role="empty"]'); const empty = root.querySelector<HTMLElement>('[data-role="empty"]');
const downloadAllButton = root.querySelector<HTMLButtonElement>('[data-role="download-all"]');
if (!list || !empty) { if (!list || !empty) {
return; return;
} }
empty.hidden = items.length > 0; empty.hidden = items.length > 0;
list.replaceChildren(...items.map((item) => renderItem(item, onChanged))); list.replaceChildren(...items.map((item) => renderItem(item, onChanged)));
if (downloadAllButton) {
downloadAllButton.hidden = !items.some(isDownloadable);
}
} }
/** /**
@ -162,6 +221,13 @@ export function mountQueuePanel(root: HTMLElement): QueuePanelController {
}); });
}; };
const downloadAllButton = root.querySelector<HTMLButtonElement>('[data-role="download-all"]');
downloadAllButton?.addEventListener('click', () => {
handleDownloadAll(downloadAllButton).catch((error: unknown) => {
console.error('Erreur inattendue lors du téléchargement groupé', error);
});
});
refresh(); refresh();
setInterval(refresh, POLL_INTERVAL_MS); setInterval(refresh, POLL_INTERVAL_MS);

View file

@ -74,8 +74,8 @@ function toDownloadMode(body: CreateQueueItemBody): DownloadMode {
/** /**
* Construit le plugin Fastify exposant les routes de file d'attente : * Construit le plugin Fastify exposant les routes de file d'attente :
* `POST /api/queue` (ajout), `GET /api/queue` (liste), `DELETE * `POST /api/queue` (ajout), `GET /api/queue` (liste), `DELETE
* /api/queue/:id` (annulation) et `GET /api/queue/:id/file` (livraison du * /api/queue/:id` (suppression, quel que soit l'état) et `GET
* fichier terminé). * /api/queue/:id/file` (livraison du fichier terminé).
* *
* @param config - Configuration runtime (dossier temporaire, concurrence). * @param config - Configuration runtime (dossier temporaire, concurrence).
* @returns Le plugin Fastify à enregistrer. * @returns Le plugin Fastify à enregistrer.
@ -117,9 +117,9 @@ export function createQueueRoutes(
'/api/queue/:id', '/api/queue/:id',
{ schema: { params: queueItemParamsSchema } }, { schema: { params: queueItemParamsSchema } },
async (request: FastifyRequest<{ Params: QueueItemParams }>, reply: FastifyReply) => { async (request: FastifyRequest<{ Params: QueueItemParams }>, reply: FastifyReply) => {
const cancelled = queueService.cancel(request.params.id); const removed = queueService.remove(request.params.id);
if (!cancelled) { if (!removed) {
return reply.status(404).send({ error: 'Élément introuvable ou déjà terminé.' }); return reply.status(404).send({ error: 'Élément introuvable.' });
} }
return reply.status(204).send(); return reply.status(204).send();
}, },

View file

@ -142,13 +142,15 @@ export class QueueService {
} }
/** /**
* Annule un élément : retiré de la file s'il était en attente, ou son * Retire un élément de la file, quel que soit son état : s'il est en
* processus yt-dlp est arrêté s'il était en cours. * attente ou en cours, il est d'abord arrêté (le processus yt-dlp est
* annulé le cas échéant) ; dans tous les cas l'entrée est supprimée de
* la liste et son dossier de sortie éventuel nettoyé.
* *
* @param id - Identifiant de l'élément à annuler. * @param id - Identifiant de l'élément à retirer.
* @returns `true` si l'élément existait et a pu être annulé, `false` sinon. * @returns `true` si l'élément existait, `false` sinon.
*/ */
public cancel(id: string): boolean { public remove(id: string): boolean {
const item = this.items.get(id); const item = this.items.get(id);
if (!item) { if (!item) {
return false; return false;
@ -159,17 +161,19 @@ export class QueueService {
if (index !== -1) { if (index !== -1) {
this.pendingIds.splice(index, 1); this.pendingIds.splice(index, 1);
} }
item.status = 'cancelled'; } else if (item.status === 'running') {
this.logger.info({ id }, "File d'attente : élément en attente annulé");
return true;
}
if (item.status === 'running') {
item.abortController?.abort(); item.abortController?.abort();
return true;
} }
return false; this.items.delete(id);
this.cleanupDir(item).catch((error: unknown) => {
this.logger.error(
{ err: error, id },
"File d'attente : échec du nettoyage lors de la suppression",
);
});
this.logger.info({ id, status: item.status }, "File d'attente : élément supprimé");
return true;
} }
/** /**
@ -197,12 +201,20 @@ export class QueueService {
path, path,
fileName, fileName,
onSent: () => onSent: () =>
this.cleanupDir(item).catch((error: unknown) => { this.cleanupDir(item)
this.logger.error( .catch((error: unknown) => {
{ err: error, id: item.id }, this.logger.error(
"File d'attente : échec du nettoyage après livraison", { err: error, id: item.id },
); "File d'attente : échec du nettoyage après livraison",
}), );
})
.finally(() => {
// Le fichier vient d'être envoyé (et son dossier nettoyé, au
// mieux) : `fileName` ne doit plus désigner un fichier
// consommable, sous peine qu'un second appel tente de lire un
// fichier qui n'existe plus.
item.fileName = null;
}),
}; };
} }

View file

@ -1,3 +1,4 @@
import { existsSync } from 'node:fs';
import { mkdtemp, rm, stat, writeFile } from 'node:fs/promises'; import { mkdtemp, rm, stat, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path'; import { dirname, join } from 'node:path';
@ -114,7 +115,7 @@ describe('QueueService', () => {
await expect(stat(join(baseDir, item.id))).rejects.toThrow(); 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 () => { it('remove() supprime 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 vi.mocked(runYtDlp).mockImplementation(() => new Promise(() => {})); // occupe le seul slot indéfiniment
const service = new QueueService(noopLogger, baseDir, 1); const service = new QueueService(noopLogger, baseDir, 1);
@ -122,16 +123,17 @@ describe('QueueService', () => {
const second = service.enqueue(url, videoMode); const second = service.enqueue(url, videoMode);
expect(service.get(second.id)?.status).toBe('pending'); expect(service.get(second.id)?.status).toBe('pending');
expect(service.cancel(second.id)).toBe(true); expect(service.remove(second.id)).toBe(true);
expect(service.get(second.id)?.status).toBe('cancelled'); expect(service.get(second.id)).toBeNull();
// Le premier élément appelle yt-dlp de façon asynchrone (après son // 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à // `mkdir`) : on attend cet appel plutôt que de supposer qu'il a déjà
// eu lieu au moment où enqueue() rend la main. // eu lieu au moment où enqueue() rend la main.
await waitFor(() => vi.mocked(runYtDlp).mock.calls.length === 1); await waitFor(() => vi.mocked(runYtDlp).mock.calls.length === 1);
expect(runYtDlp).toHaveBeenCalledTimes(1); // jamais appelé pour le second, supprimé avant de démarrer
}); });
it("annule un élément en cours en déclenchant son signal d'annulation", async () => { it("remove() supprime un élément en cours en déclenchant son signal d'annulation", async () => {
vi.mocked(runYtDlp).mockImplementation( vi.mocked(runYtDlp).mockImplementation(
(_args, options) => (_args, options) =>
new Promise((_resolve, reject) => { new Promise((_resolve, reject) => {
@ -140,7 +142,7 @@ describe('QueueService', () => {
error.name = 'AbortError'; error.name = 'AbortError';
reject(error); reject(error);
}; };
// Le signal peut déjà être annulé si cancel() a été appelé avant // Le signal peut déjà être annulé si remove() a été appelé avant
// que ce mock ne soit invoqué (après le `mkdir` de runItem) : un // que ce mock ne soit invoqué (après le `mkdir` de runItem) : un
// listener "abort" posé après coup ne se déclencherait jamais. // listener "abort" posé après coup ne se déclencherait jamais.
if (options?.signal?.aborted) { if (options?.signal?.aborted) {
@ -155,8 +157,30 @@ describe('QueueService', () => {
const item = service.enqueue(url, videoMode); const item = service.enqueue(url, videoMode);
await waitFor(() => vi.mocked(runYtDlp).mock.calls.length === 1); await waitFor(() => vi.mocked(runYtDlp).mock.calls.length === 1);
expect(service.cancel(item.id)).toBe(true); expect(service.remove(item.id)).toBe(true);
await waitFor(() => service.get(item.id)?.status === 'cancelled'); // Retiré immédiatement de la liste, pas seulement une fois l'abort
// effectif côté yt-dlp.
expect(service.get(item.id)).toBeNull();
});
it('remove() retourne false pour un identifiant inconnu', () => {
const service = new QueueService(noopLogger, baseDir, 1);
expect(service.remove('inconnu')).toBe(false);
});
it('remove() supprime un élément terminé et nettoie le fichier jamais téléchargé', 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');
expect(service.remove(item.id)).toBe(true);
expect(service.get(item.id)).toBeNull();
await waitFor(() => !existsSync(join(baseDir, item.id)));
}); });
it('consumeCompletedFile retourne null pour un élément inconnu ou non terminé', () => { it('consumeCompletedFile retourne null pour un élément inconnu ou non terminé', () => {
@ -188,5 +212,10 @@ describe('QueueService', () => {
await result.onSent(); await result.onSent();
await expect(stat(dirname(result.path))).rejects.toThrow(); await expect(stat(dirname(result.path))).rejects.toThrow();
// Le fichier a été livré : un second appel ne doit plus rien exposer,
// sous peine de tenter de relire un fichier qui n'existe plus.
expect(service.get(item.id)?.fileName).toBeNull();
expect(service.consumeCompletedFile(item.id)).toBeNull();
}); });
}); });

View file

@ -93,6 +93,24 @@ describe('routes de file d’attente', () => {
expect(response.statusCode).toBe(404); expect(response.statusCode).toBe(404);
}); });
it('DELETE /api/queue/:id supprime un élément et le retire de la liste', 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 deleteResponse = await app.inject({ method: 'DELETE', url: `/api/queue/${id}` });
expect(deleteResponse.statusCode).toBe(204);
const listResponse = await app.inject({ method: 'GET', url: '/api/queue' });
expect(listResponse.json().items).toHaveLength(0);
});
it('GET /api/queue/:id/file renvoie 404 tant que le téléchargement n’est pas terminé', async () => { it('GET /api/queue/:id/file renvoie 404 tant que le téléchargement n’est pas terminé', async () => {
const app = Fastify(); const app = Fastify();
await app.register(createQueueRoutes({ tmpDir, maxConcurrentDownloads: 2 })); await app.register(createQueueRoutes({ tmpDir, maxConcurrentDownloads: 2 }));

View file

@ -36,7 +36,7 @@ Pas de `docker-compose.yml` pour l'instant (une seule image, pas encore de dépe
- Pas de router : `src/shell/Shell.ts` monte deux vues fixes côte à côte (`views/downloadForm/`, `views/queuePanel/`), reliées par un callback simple (`onItemQueued`) plutôt qu'un bus d'événements généraliste - au-delà de deux vues qui coexistent sur un seul écran, un vrai routeur deviendrait nécessaire. - Pas de router : `src/shell/Shell.ts` monte deux vues fixes côte à côte (`views/downloadForm/`, `views/queuePanel/`), reliées par un callback simple (`onItemQueued`) plutôt qu'un bus d'événements généraliste - au-delà de deux vues qui coexistent sur un seul écran, un vrai routeur deviendrait nécessaire.
- `src/api/` : clients fetch vers le backend (`channelApi.ts`, `queueApi.ts`), avec leurs propres types DTO - pas de paquet de types partagé avec `server/` (monorepo simple, pas de workspace npm). - `src/api/` : clients fetch vers le backend (`channelApi.ts`, `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/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. - `src/views/queuePanel/` : sonde `GET /api/queue` toutes les 1,5s (pas de push temps réel pour l'instant, voir Statut) et affiche chaque élément (titre ou URL à défaut, statut, barre de progression, erreur). Un même bouton retire l'élément quel que soit son état - libellé "Annuler" s'il est en attente/en cours, "Supprimer" une fois terminé (`DELETE /api/queue/:id` gère les deux cas côté serveur). Lien "Télécharger" natif (`<a download>`) uniquement si un fichier est encore disponible ; bouton "Tout télécharger" dans l'en-tête qui déclenche, avec un léger décalage entre chacun (évite le blocage navigateur des téléchargements multiples simultanés), tous les téléchargements prêts. Expose `refresh()` pour un rafraîchissement immédiat après un ajout.
- Piège CSS relevé en testant dans un vrai navigateur (pas seulement les tests) : un attribut `hidden` combiné à une règle `display: flex` sur le même sélecteur se fait écraser par cette règle (même spécificité, la feuille de l'auteur gagne sur celle du user-agent) - toujours ajouter `&[hidden] { display: none; }` quand un conteneur togglé porte aussi un `display` explicite. - Piège CSS relevé en testant dans un vrai navigateur (pas seulement les tests) : un attribut `hidden` combiné à une règle `display: flex` sur le même sélecteur se fait écraser par cette règle (même spécificité, la feuille de l'auteur gagne sur celle du user-agent) - toujours ajouter `&[hidden] { display: none; }` quand un conteneur togglé porte aussi un `display` explicite.
- Tests : Vitest (`client/test/`), uniquement sur les fonctions pures (`format.ts`) pour l'instant - pas de configuration jsdom, le câblage DOM (formulaires, rendu de liste) est vérifié manuellement dans le navigateur plutôt que testé automatiquement. - Tests : Vitest (`client/test/`), uniquement sur les fonctions pures (`format.ts`) pour l'instant - pas de configuration jsdom, le câblage DOM (formulaires, rendu de liste) est vérifié manuellement dans le navigateur plutôt que testé automatiquement.
- `src/styles/base.scss` : palette et styles de base partagés (custom properties CSS sur `:root`, pas de `$variables` Sass - une variable Sass ne traverserait pas les fichiers, chaque vue étant compilée/importée séparément ; une custom property CSS si, puisqu'elle se résout à l'exécution dans le navigateur une fois tout le CSS regroupé par esbuild). Importé une fois depuis `app.ts`. - `src/styles/base.scss` : palette et styles de base partagés (custom properties CSS sur `:root`, pas de `$variables` Sass - une variable Sass ne traverserait pas les fichiers, chaque vue étant compilée/importée séparément ; une custom property CSS si, puisqu'elle se résout à l'exécution dans le navigateur une fois tout le CSS regroupé par esbuild). Importé une fois depuis `app.ts`.
@ -47,8 +47,8 @@ Pas de `docker-compose.yml` pour l'instant (une seule image, pas encore de dépe
- `GET /api/channel?channel=<@handle|URL>&section=<videos|streams>&keywords=<a,b>` : liste les vidéos/streams d'une chaîne, filtrés par mots-clés (ET, insensible à la casse) sur le titre. `section` par défaut `videos`. Réponses : `200 { videos: [...] }`, `400` si `channel` est absent/invalide, `502` si yt-dlp échoue. - `GET /api/channel?channel=<@handle|URL>&section=<videos|streams>&keywords=<a,b>` : liste les vidéos/streams d'une chaîne, filtrés par mots-clés (ET, insensible à la casse) sur le titre. `section` par défaut `videos`. Réponses : `200 { videos: [...] }`, `400` si `channel` est absent/invalide, `502` si yt-dlp échoue.
- `POST /api/queue` : ajoute un élément (`{ url, title?, mode: "video"|"audio"|"thumbnail", subtitles?, embedThumbnail?, audioFormat? }`). `title` est purement informatif (affichage), jamais utilisé pour construire la commande yt-dlp. `201 { item }`, `400` si l'URL n'est pas une URL de vidéo YouTube reconnue. - `POST /api/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`. - `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é. - `DELETE /api/queue/:id` : supprime un élément (`204`), quel que soit son état - s'il était en attente/en cours, il est d'abord annulé, puis retiré de la liste dans tous les cas (dossier de sortie éventuel nettoyé). `404` seulement si l'identifiant est inconnu.
- `GET /api/queue/:id/file` : télécharge le fichier produit par un élément `completed` (`Content-Disposition: attachment`). Le dossier de sortie de l'élément est supprimé juste après l'envoi. `404` tant que l'élément n'est pas `completed`. - `GET /api/queue/:id/file` : télécharge le fichier produit par un élément `completed` (`Content-Disposition: attachment`). Le dossier de sortie de l'élément est supprimé juste après l'envoi, et `fileName` repassé à `null` (un second appel renvoie `404`, pas une tentative de relire un fichier déjà supprimé). `404` tant que l'élément n'est pas `completed` avec un fichier disponible.
## Lint ## Lint