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
This commit is contained in:
commit
915a2af0de
10 changed files with 201 additions and 60 deletions
|
|
@ -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.
|
||||
*/
|
||||
export async function cancelQueueItem(id: string): Promise<void> {
|
||||
export async function removeQueueItem(id: string): Promise<void> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(`/api/queue/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@
|
|||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
max-height: 320px;
|
||||
max-height: 65vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
<div class="queue-panel">
|
||||
<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>
|
||||
<ul class="queue-panel__list" data-role="list"></ul>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -9,6 +9,18 @@
|
|||
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 {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.85rem;
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import {
|
||||
type QueueItemDto,
|
||||
cancelQueueItem,
|
||||
listQueueItems,
|
||||
queueItemFileUrl,
|
||||
removeQueueItem,
|
||||
} from '../../api/queueApi';
|
||||
import queuePanelTemplate from './QueuePanel.html';
|
||||
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). */
|
||||
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> = {
|
||||
pending: 'En attente',
|
||||
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 onDone - Rappelé après la requête (succès ou échec) pour rafraîchir la liste.
|
||||
*/
|
||||
async function handleCancel(
|
||||
async function handleRemove(
|
||||
id: string,
|
||||
button: HTMLButtonElement,
|
||||
onDone: () => void,
|
||||
): Promise<void> {
|
||||
button.disabled = true;
|
||||
try {
|
||||
await cancelQueueItem(id);
|
||||
await removeQueueItem(id);
|
||||
} catch (error) {
|
||||
console.error("Échec de l'annulation", error);
|
||||
console.error('Échec de la suppression', error);
|
||||
} finally {
|
||||
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.
|
||||
*
|
||||
* @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.
|
||||
*/
|
||||
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]}`;
|
||||
li.append(meta);
|
||||
|
||||
if (item.status === 'running' || item.status === 'pending') {
|
||||
if (isActive(item)) {
|
||||
const progress = document.createElement('progress');
|
||||
progress.className = 'queue-panel__item-progress';
|
||||
progress.max = 100;
|
||||
|
|
@ -88,23 +136,27 @@ function renderItem(item: QueueItemDto, onChanged: () => void): HTMLLIElement {
|
|||
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');
|
||||
actions.className = 'queue-panel__item-actions';
|
||||
|
||||
if (item.status === 'pending' || item.status === 'running') {
|
||||
const cancelButton = document.createElement('button');
|
||||
cancelButton.type = 'button';
|
||||
cancelButton.className = 'secondary';
|
||||
cancelButton.textContent = 'Annuler';
|
||||
cancelButton.addEventListener('click', () => {
|
||||
handleCancel(item.id, cancelButton, onChanged).catch((error: unknown) => {
|
||||
console.error("Erreur inattendue lors de l'annulation", error);
|
||||
const removeButton = document.createElement('button');
|
||||
removeButton.type = 'button';
|
||||
removeButton.className = 'secondary';
|
||||
removeButton.textContent = isActive(item) ? 'Annuler' : 'Supprimer';
|
||||
removeButton.addEventListener('click', () => {
|
||||
handleRemove(item.id, removeButton, onChanged).catch((error: unknown) => {
|
||||
console.error('Erreur inattendue lors de la suppression', error);
|
||||
});
|
||||
});
|
||||
actions.append(cancelButton);
|
||||
}
|
||||
|
||||
if (item.status === 'completed') {
|
||||
if (isDownloadable(item)) {
|
||||
const link = document.createElement('a');
|
||||
link.href = queueItemFileUrl(item.id);
|
||||
link.textContent = 'Télécharger';
|
||||
|
|
@ -112,12 +164,14 @@ function renderItem(item: QueueItemDto, onChanged: () => void): HTMLLIElement {
|
|||
actions.append(link);
|
||||
}
|
||||
|
||||
actions.append(removeButton);
|
||||
li.append(actions);
|
||||
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 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 {
|
||||
const list = root.querySelector<HTMLElement>('[data-role="list"]');
|
||||
const empty = root.querySelector<HTMLElement>('[data-role="empty"]');
|
||||
const downloadAllButton = root.querySelector<HTMLButtonElement>('[data-role="download-all"]');
|
||||
if (!list || !empty) {
|
||||
return;
|
||||
}
|
||||
|
||||
empty.hidden = items.length > 0;
|
||||
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();
|
||||
setInterval(refresh, POLL_INTERVAL_MS);
|
||||
|
||||
|
|
|
|||
|
|
@ -74,8 +74,8 @@ function toDownloadMode(body: CreateQueueItemBody): DownloadMode {
|
|||
/**
|
||||
* 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é).
|
||||
* /api/queue/:id` (suppression, quel que soit l'état) et `GET
|
||||
* /api/queue/:id/file` (livraison du fichier terminé).
|
||||
*
|
||||
* @param config - Configuration runtime (dossier temporaire, concurrence).
|
||||
* @returns Le plugin Fastify à enregistrer.
|
||||
|
|
@ -117,9 +117,9 @@ export function createQueueRoutes(
|
|||
'/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é.' });
|
||||
const removed = queueService.remove(request.params.id);
|
||||
if (!removed) {
|
||||
return reply.status(404).send({ error: 'Élément introuvable.' });
|
||||
}
|
||||
return reply.status(204).send();
|
||||
},
|
||||
|
|
|
|||
|
|
@ -142,13 +142,15 @@ export class QueueService {
|
|||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Retire un élément de la file, quel que soit son état : s'il est en
|
||||
* 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.
|
||||
* @returns `true` si l'élément existait et a pu être annulé, `false` sinon.
|
||||
* @param id - Identifiant de l'élément à retirer.
|
||||
* @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);
|
||||
if (!item) {
|
||||
return false;
|
||||
|
|
@ -159,17 +161,19 @@ export class QueueService {
|
|||
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') {
|
||||
} else if (item.status === 'running') {
|
||||
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,11 +201,19 @@ export class QueueService {
|
|||
path,
|
||||
fileName,
|
||||
onSent: () =>
|
||||
this.cleanupDir(item).catch((error: unknown) => {
|
||||
this.cleanupDir(item)
|
||||
.catch((error: unknown) => {
|
||||
this.logger.error(
|
||||
{ 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;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { existsSync } from 'node:fs';
|
||||
import { mkdtemp, rm, stat, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
|
@ -114,7 +115,7 @@ describe('QueueService', () => {
|
|||
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
|
||||
|
||||
const service = new QueueService(noopLogger, baseDir, 1);
|
||||
|
|
@ -122,16 +123,17 @@ describe('QueueService', () => {
|
|||
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');
|
||||
expect(service.remove(second.id)).toBe(true);
|
||||
expect(service.get(second.id)).toBeNull();
|
||||
|
||||
// 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);
|
||||
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(
|
||||
(_args, options) =>
|
||||
new Promise((_resolve, reject) => {
|
||||
|
|
@ -140,7 +142,7 @@ describe('QueueService', () => {
|
|||
error.name = 'AbortError';
|
||||
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
|
||||
// listener "abort" posé après coup ne se déclencherait jamais.
|
||||
if (options?.signal?.aborted) {
|
||||
|
|
@ -155,8 +157,30 @@ describe('QueueService', () => {
|
|||
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');
|
||||
expect(service.remove(item.id)).toBe(true);
|
||||
// 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é', () => {
|
||||
|
|
@ -188,5 +212,10 @@ describe('QueueService', () => {
|
|||
|
||||
await result.onSent();
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -93,6 +93,24 @@ describe('routes de file d’attente', () => {
|
|||
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 () => {
|
||||
const app = Fastify();
|
||||
await app.register(createQueueRoutes({ tmpDir, maxConcurrentDownloads: 2 }));
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
- `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.
|
||||
- `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.
|
||||
- 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`.
|
||||
|
|
@ -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>§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`.
|
||||
- `DELETE /api/queue/:id` : supprime un élément (`204`), quel que soit son état - s'il était en attente/en cours, il est d'abord annulé, puis retiré de la liste dans tous les cas (dossier de sortie éventuel nettoyé). `404` seulement si l'identifiant est inconnu.
|
||||
- `GET /api/queue/:id/file` : télécharge le fichier produit par un élément `completed` (`Content-Disposition: attachment`). Le dossier de sortie de l'élément est supprimé juste après l'envoi, et `fileName` repassé à `null` (un second appel renvoie `404`, pas une tentative de relire un fichier déjà supprimé). `404` tant que l'élément n'est pas `completed` avec un fichier disponible.
|
||||
|
||||
## Lint
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue