feat: suppression d'éléments de la file, téléchargement groupé #5
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.
|
* @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' });
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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 }));
|
||||||
|
|
|
||||||
|
|
@ -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>§ion=<videos|streams>&keywords=<a,b>` : liste les vidéos/streams d'une chaîne, filtrés par mots-clés (ET, insensible à la casse) sur le titre. `section` par défaut `videos`. Réponses : `200 { videos: [...] }`, `400` si `channel` est absent/invalide, `502` si yt-dlp échoue.
|
- `GET /api/channel?channel=<@handle|URL>§ion=<videos|streams>&keywords=<a,b>` : liste les vidéos/streams d'une chaîne, filtrés par mots-clés (ET, insensible à la casse) sur le titre. `section` par défaut `videos`. Réponses : `200 { videos: [...] }`, `400` si `channel` est absent/invalide, `502` si yt-dlp échoue.
|
||||||
- `POST /api/queue` : ajoute un élément (`{ url, title?, mode: "video"|"audio"|"thumbnail", subtitles?, embedThumbnail?, audioFormat? }`). `title` est purement informatif (affichage), jamais utilisé pour construire la commande yt-dlp. `201 { item }`, `400` si l'URL n'est pas une URL de vidéo YouTube reconnue.
|
- `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
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue