fix(auth,download): playlists/likes 401, suppression downloads, 429 formats
CI / build-and-test (push) Failing after 8m20s
CI / build-and-test (push) Failing after 8m20s
- likes + playlists: authMiddlewareCookieAware (cookies + Bearer) au lieu du Bearer seul -> corrige 401 Unauthorized meme connecte (creation playlist, ajout video, bouton J'aime) - add-to-playlist/like-button/search-box: retire HttpClientModule local qui court-circuitait les intercepteurs fonctionnels - intercepteur: refresh+retry sur 401 meme sans token en memoire (F5) - UserService: apiBase() au lieu de /proxy/api en dur (prod :4000) - J'aime: envoie titre/miniature, erreur visible au lieu de bouton mort - download: seaux separes (lectures 120/min, ecritures 30/min, formats 30/min) + reponses limiter en JSON rate_limited + Retry-After - formats: cache memoire 10 min + ytdlpNetOpts + retry front avec backoff - polling allege (downloads 5s, suivi job 4s, 429 transitoire non fatal) - erreurs FR precises (http-error.util): 401/403/404/410/429/quota/500 avec compte a rebours + bouton Reessayer
This commit is contained in:
+94
-28
@@ -492,13 +492,67 @@ const loginLimiter = rateLimit({
|
|||||||
legacyHeaders: false,
|
legacyHeaders: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const downloadLimiter = rateLimit({
|
/**
|
||||||
windowMs: 60 * 1000, // 1 min
|
* Réponse JSON (et non texte brut) quand un rate-limiter se déclenche, pour
|
||||||
max: 15,
|
* que le front puisse afficher un message FR précis avec compte à rebours.
|
||||||
|
*/
|
||||||
|
function jsonLimitHandler(req, res, _next, options) {
|
||||||
|
let retryAfterSec = 60;
|
||||||
|
try {
|
||||||
|
const resetMs = req?.rateLimit?.resetTime ? new Date(req.rateLimit.resetTime).getTime() : 0;
|
||||||
|
if (resetMs > Date.now()) retryAfterSec = Math.max(1, Math.ceil((resetMs - Date.now()) / 1000));
|
||||||
|
else if (options?.windowMs) retryAfterSec = Math.max(1, Math.ceil(options.windowMs / 1000));
|
||||||
|
} catch {}
|
||||||
|
try { res.set('Retry-After', String(retryAfterSec)); } catch {}
|
||||||
|
return res.status(options?.statusCode || 429).json({ error: 'rate_limited', retryAfterSec });
|
||||||
|
}
|
||||||
|
|
||||||
|
const downloadReadLimiter = rateLimit({
|
||||||
|
windowMs: 60 * 1000, // polling légitime des jobs (2-5 s) : seau large
|
||||||
|
max: 120,
|
||||||
standardHeaders: true,
|
standardHeaders: true,
|
||||||
legacyHeaders: false,
|
legacyHeaders: false,
|
||||||
|
handler: jsonLimitHandler,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const downloadWriteLimiter = rateLimit({
|
||||||
|
windowMs: 60 * 1000,
|
||||||
|
max: 30,
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
handler: jsonLimitHandler,
|
||||||
|
});
|
||||||
|
|
||||||
|
const downloadFormatsLimiter = rateLimit({
|
||||||
|
windowMs: 60 * 1000,
|
||||||
|
max: 30,
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
handler: jsonLimitHandler,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Cache mémoire des listes de formats (un dump yt-dlp = 5-15 s + quota YouTube). */
|
||||||
|
const formatsCache = new Map(); // key -> { ts, data }
|
||||||
|
const FORMATS_CACHE_TTL_MS = 10 * 60 * 1000;
|
||||||
|
const FORMATS_CACHE_MAX = 200;
|
||||||
|
function formatsCacheGet(key) {
|
||||||
|
const hit = formatsCache.get(key);
|
||||||
|
if (!hit) return null;
|
||||||
|
if (Date.now() - hit.ts > FORMATS_CACHE_TTL_MS) { formatsCache.delete(key); return null; }
|
||||||
|
// LRU : rejoue l'entrée en fin de Map
|
||||||
|
formatsCache.delete(key);
|
||||||
|
formatsCache.set(key, hit);
|
||||||
|
return hit.data;
|
||||||
|
}
|
||||||
|
function formatsCacheSet(key, data) {
|
||||||
|
if (formatsCache.has(key)) formatsCache.delete(key);
|
||||||
|
formatsCache.set(key, { ts: Date.now(), data });
|
||||||
|
while (formatsCache.size > FORMATS_CACHE_MAX) {
|
||||||
|
const oldest = formatsCache.keys().next().value;
|
||||||
|
formatsCache.delete(oldest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Rate limiter for Rumble scraping to prevent being blocked
|
// Rate limiter for Rumble scraping to prevent being blocked
|
||||||
const rumbleLimiter = rateLimit({
|
const rumbleLimiter = rateLimit({
|
||||||
windowMs: 60 * 1000, // 1 min
|
windowMs: 60 * 1000, // 1 min
|
||||||
@@ -1173,11 +1227,13 @@ r.get('/details/:provider/:videoId', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Download routes middleware (auth supports both Authorization header and cookies)
|
// Download routes middleware (auth supports both Authorization header and cookies).
|
||||||
r.use('/download', authMiddlewareCookieAware, downloadLimiter);
|
// Les lectures (polling jobs) et écritures ont des seaux séparés : le polling
|
||||||
|
// ne doit jamais affamer les suppressions ni les listes de formats.
|
||||||
|
r.use('/download', authMiddlewareCookieAware);
|
||||||
|
|
||||||
// List available formats for a given video
|
// List available formats for a given video (cache 10 min : un dump = 5-15 s)
|
||||||
r.get('/download/:provider/:videoId/formats', async (req, res) => {
|
r.get('/download/:provider/:videoId/formats', downloadFormatsLimiter, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { provider, videoId } = req.params;
|
const { provider, videoId } = req.params;
|
||||||
if (!DOWNLOAD_ALLOWED_PROVIDERS.includes(String(provider))) {
|
if (!DOWNLOAD_ALLOWED_PROVIDERS.includes(String(provider))) {
|
||||||
@@ -1186,19 +1242,24 @@ r.get('/download/:provider/:videoId/formats', async (req, res) => {
|
|||||||
const instance = req.query.instance || undefined;
|
const instance = req.query.instance || undefined;
|
||||||
const slug = req.query.slug || undefined;
|
const slug = req.query.slug || undefined;
|
||||||
const sourceUrl = req.query.sourceUrl || undefined;
|
const sourceUrl = req.query.sourceUrl || undefined;
|
||||||
|
const cacheKey = `formats:${provider}:${videoId}:${instance || ''}:${slug || ''}:${sourceUrl || ''}`;
|
||||||
|
const cached = formatsCacheGet(cacheKey);
|
||||||
|
if (cached) return res.json(cached);
|
||||||
const url = providerUrlFrom(provider, videoId, { instance, slug, sourceUrl });
|
const url = providerUrlFrom(provider, videoId, { instance, slug, sourceUrl });
|
||||||
const raw = await youtubedl(url, { dumpSingleJson: true, noWarnings: true, noCheckCertificates: true, skipDownload: true });
|
const raw = await youtubedl(url, { dumpSingleJson: true, noWarnings: true, noCheckCertificates: true, skipDownload: true, ...ytdlpNetOpts() });
|
||||||
const meta = (typeof raw === 'string') ? JSON.parse(raw || '{}') : (raw || {});
|
const meta = (typeof raw === 'string') ? JSON.parse(raw || '{}') : (raw || {});
|
||||||
const formats = formatListFromMeta(meta);
|
const formats = formatListFromMeta(meta);
|
||||||
return res.json({ url, formats, title: meta?.title || '', duration: meta?.duration || 0 });
|
const out = { url, formats, title: meta?.title || '', duration: meta?.duration || 0 };
|
||||||
|
formatsCacheSet(cacheKey, out);
|
||||||
|
return res.json(out);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const code = (e && e.message === 'peertube_instance_required') ? 400 : 500;
|
const code = (e && e.message === 'peertube_instance_required') ? 400 : 500;
|
||||||
return res.status(code).json({ error: 'formats_failed', details: String(e?.message || e) });
|
return res.status(code).json({ error: 'formats_failed', details: String(e?.message || e).slice(0, 300) });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Start a download job
|
// Start a download job
|
||||||
r.post('/download/:provider/:videoId', async (req, res) => {
|
r.post('/download/:provider/:videoId', downloadWriteLimiter, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { provider, videoId } = req.params;
|
const { provider, videoId } = req.params;
|
||||||
if (!DOWNLOAD_ALLOWED_PROVIDERS.includes(String(provider))) {
|
if (!DOWNLOAD_ALLOWED_PROVIDERS.includes(String(provider))) {
|
||||||
@@ -1394,7 +1455,7 @@ r.post('/download/:provider/:videoId', async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// List current user's download jobs (persistent queue history)
|
// List current user's download jobs (persistent queue history)
|
||||||
r.get('/download/jobs', (req, res) => {
|
r.get('/download/jobs', downloadReadLimiter, (req, res) => {
|
||||||
try {
|
try {
|
||||||
const userId = req.user?.id || 'anonymous';
|
const userId = req.user?.id || 'anonymous';
|
||||||
const limit = Math.min(200, Math.max(1, Number(req.query.limit || 50)));
|
const limit = Math.min(200, Math.max(1, Number(req.query.limit || 50)));
|
||||||
@@ -1424,7 +1485,7 @@ function loadOwnedJob(req) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Retry a failed/interrupted job (reprise)
|
// Retry a failed/interrupted job (reprise)
|
||||||
r.post('/download/jobs/:id/retry', async (req, res) => {
|
r.post('/download/jobs/:id/retry', downloadWriteLimiter, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { row, error } = loadOwnedJob(req);
|
const { row, error } = loadOwnedJob(req);
|
||||||
if (error === 'not_found' || error === 'forbidden') return res.status(error === 'forbidden' ? 403 : 404).json({ error });
|
if (error === 'not_found' || error === 'forbidden') return res.status(error === 'forbidden' ? 403 : 404).json({ error });
|
||||||
@@ -1568,7 +1629,7 @@ r.post('/download/jobs/:id/retry', async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Job status — DB first (persistent, survives restarts), memory fallback
|
// Job status — DB first (persistent, survives restarts), memory fallback
|
||||||
r.get('/download/jobs/:id', (req, res) => {
|
r.get('/download/jobs/:id', downloadReadLimiter, (req, res) => {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const { row, error } = loadOwnedJob(req);
|
const { row, error } = loadOwnedJob(req);
|
||||||
if (error === 'forbidden') return res.status(403).json({ error });
|
if (error === 'forbidden') return res.status(403).json({ error });
|
||||||
@@ -1587,7 +1648,7 @@ r.get('/download/jobs/:id', (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Stream the file (supports Range) — path resolved from the DB row (owner only)
|
// Stream the file (supports Range) — path resolved from the DB row (owner only)
|
||||||
r.get('/download/jobs/:id/file', (req, res) => {
|
r.get('/download/jobs/:id/file', downloadReadLimiter, (req, res) => {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const { row, error } = loadOwnedJob(req);
|
const { row, error } = loadOwnedJob(req);
|
||||||
if (error === 'forbidden') return res.status(403).json({ error });
|
if (error === 'forbidden') return res.status(403).json({ error });
|
||||||
@@ -1626,7 +1687,7 @@ r.get('/download/jobs/:id/file', (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Cancel a job (owner only) — also removes the DB row
|
// Cancel a job (owner only) — also removes the DB row
|
||||||
r.delete('/download/jobs/:id', (req, res) => {
|
r.delete('/download/jobs/:id', downloadWriteLimiter, (req, res) => {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const { row, error } = loadOwnedJob(req);
|
const { row, error } = loadOwnedJob(req);
|
||||||
if (error === 'forbidden') return res.status(403).json({ error });
|
if (error === 'forbidden') return res.status(403).json({ error });
|
||||||
@@ -1875,14 +1936,17 @@ r.delete('/user/history/watch', authMiddleware, (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// --- Likes ---
|
// --- Likes ---
|
||||||
r.get('/user/likes', authMiddleware, (req, res) => {
|
// NOTE : cookie-aware (et non Bearer seul) : le front s'authentifie via
|
||||||
|
// cookies httpOnly + Bearer en mémoire (perdu au F5) ; exiger le Bearer
|
||||||
|
// seul renvoyait 401 « Unauthorized » même connecté.
|
||||||
|
r.get('/user/likes', authMiddlewareCookieAware, (req, res) => {
|
||||||
const limit = Math.min(500, Number(req.query.limit || 100));
|
const limit = Math.min(500, Number(req.query.limit || 100));
|
||||||
const q = typeof req.query.q === 'string' ? req.query.q : undefined;
|
const q = typeof req.query.q === 'string' ? req.query.q : undefined;
|
||||||
const rows = listLikedVideos({ userId: req.user.id, limit, q });
|
const rows = listLikedVideos({ userId: req.user.id, limit, q });
|
||||||
return res.json(rows);
|
return res.json(rows);
|
||||||
});
|
});
|
||||||
|
|
||||||
r.post('/user/likes', authMiddleware, async (req, res) => {
|
r.post('/user/likes', authMiddlewareCookieAware, async (req, res) => {
|
||||||
let { provider, videoId, title, thumbnail } = req.body || {};
|
let { provider, videoId, title, thumbnail } = req.body || {};
|
||||||
try {
|
try {
|
||||||
console.log('[POST /user/likes] payload:', {
|
console.log('[POST /user/likes] payload:', {
|
||||||
@@ -1913,7 +1977,7 @@ r.post('/user/likes', authMiddleware, async (req, res) => {
|
|||||||
return res.status(201).json(row);
|
return res.status(201).json(row);
|
||||||
});
|
});
|
||||||
|
|
||||||
r.delete('/user/likes', authMiddleware, (req, res) => {
|
r.delete('/user/likes', authMiddlewareCookieAware, (req, res) => {
|
||||||
const provider = req.query.provider ? String(req.query.provider) : '';
|
const provider = req.query.provider ? String(req.query.provider) : '';
|
||||||
const videoId = req.query.videoId ? String(req.query.videoId) : '';
|
const videoId = req.query.videoId ? String(req.query.videoId) : '';
|
||||||
if (!provider || !videoId) return res.status(400).json({ error: 'provider and videoId are required' });
|
if (!provider || !videoId) return res.status(400).json({ error: 'provider and videoId are required' });
|
||||||
@@ -1922,7 +1986,7 @@ r.delete('/user/likes', authMiddleware, (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Like status for a specific video
|
// Like status for a specific video
|
||||||
r.get('/user/likes/status', authMiddleware, (req, res) => {
|
r.get('/user/likes/status', authMiddlewareCookieAware, (req, res) => {
|
||||||
const provider = req.query.provider ? String(req.query.provider) : '';
|
const provider = req.query.provider ? String(req.query.provider) : '';
|
||||||
const videoId = req.query.videoId ? String(req.query.videoId) : '';
|
const videoId = req.query.videoId ? String(req.query.videoId) : '';
|
||||||
if (!provider || !videoId) return res.status(400).json({ error: 'provider and videoId are required' });
|
if (!provider || !videoId) return res.status(400).json({ error: 'provider and videoId are required' });
|
||||||
@@ -2716,8 +2780,10 @@ app.listen(PORT, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// --- Playlists ---
|
// --- Playlists ---
|
||||||
|
// NOTE : cookie-aware (et non Bearer seul) : voir commentaire section Likes.
|
||||||
|
// Sans cela, création/ajout renvoyait 401 « Unauthorized » même connecté.
|
||||||
// Create a new playlist
|
// Create a new playlist
|
||||||
r.post('/playlists', authMiddleware, (req, res) => {
|
r.post('/playlists', authMiddlewareCookieAware, (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { title, description, thumbnail, isPrivate } = req.body || {};
|
const { title, description, thumbnail, isPrivate } = req.body || {};
|
||||||
if (!title || String(title).trim().length === 0) {
|
if (!title || String(title).trim().length === 0) {
|
||||||
@@ -2733,7 +2799,7 @@ r.post('/playlists', authMiddleware, (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// List current user's playlists (pagination + search)
|
// List current user's playlists (pagination + search)
|
||||||
r.get('/playlists', authMiddleware, (req, res) => {
|
r.get('/playlists', authMiddlewareCookieAware, (req, res) => {
|
||||||
try {
|
try {
|
||||||
const limit = Math.min(200, Math.max(1, Number(req.query.limit || 50)));
|
const limit = Math.min(200, Math.max(1, Number(req.query.limit || 50)));
|
||||||
const offset = Math.max(0, Number(req.query.offset || 0));
|
const offset = Math.max(0, Number(req.query.offset || 0));
|
||||||
@@ -2746,7 +2812,7 @@ r.get('/playlists', authMiddleware, (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Get playlist details (owner only for now)
|
// Get playlist details (owner only for now)
|
||||||
r.get('/playlists/:id', authMiddleware, (req, res) => {
|
r.get('/playlists/:id', authMiddlewareCookieAware, (req, res) => {
|
||||||
try {
|
try {
|
||||||
const id = String(req.params.id || '');
|
const id = String(req.params.id || '');
|
||||||
const pl = getPlaylistRaw(id);
|
const pl = getPlaylistRaw(id);
|
||||||
@@ -2762,7 +2828,7 @@ r.get('/playlists/:id', authMiddleware, (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Update a playlist (title/description/thumbnail/isPrivate)
|
// Update a playlist (title/description/thumbnail/isPrivate)
|
||||||
r.put('/playlists/:id', authMiddleware, (req, res) => {
|
r.put('/playlists/:id', authMiddlewareCookieAware, (req, res) => {
|
||||||
try {
|
try {
|
||||||
const id = String(req.params.id || '');
|
const id = String(req.params.id || '');
|
||||||
const patch = req.body || {};
|
const patch = req.body || {};
|
||||||
@@ -2776,7 +2842,7 @@ r.put('/playlists/:id', authMiddleware, (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Delete a playlist
|
// Delete a playlist
|
||||||
r.delete('/playlists/:id', authMiddleware, (req, res) => {
|
r.delete('/playlists/:id', authMiddlewareCookieAware, (req, res) => {
|
||||||
try {
|
try {
|
||||||
const id = String(req.params.id || '');
|
const id = String(req.params.id || '');
|
||||||
const result = deletePlaylist({ userId: req.user.id, id });
|
const result = deletePlaylist({ userId: req.user.id, id });
|
||||||
@@ -2789,7 +2855,7 @@ r.delete('/playlists/:id', authMiddleware, (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Add a video to a playlist (enrich title/thumbnail if missing)
|
// Add a video to a playlist (enrich title/thumbnail if missing)
|
||||||
r.post('/playlists/:id/videos', authMiddleware, async (req, res) => {
|
r.post('/playlists/:id/videos', authMiddlewareCookieAware, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const playlistId = String(req.params.id || '');
|
const playlistId = String(req.params.id || '');
|
||||||
let { provider, videoId, title, thumbnail, sourceUrl, slug, instance } = req.body || {};
|
let { provider, videoId, title, thumbnail, sourceUrl, slug, instance } = req.body || {};
|
||||||
@@ -2822,7 +2888,7 @@ r.post('/playlists/:id/videos', authMiddleware, async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Remove a video from a playlist (provider required via query)
|
// Remove a video from a playlist (provider required via query)
|
||||||
r.delete('/playlists/:id/videos/:videoId', authMiddleware, (req, res) => {
|
r.delete('/playlists/:id/videos/:videoId', authMiddlewareCookieAware, (req, res) => {
|
||||||
try {
|
try {
|
||||||
const playlistId = String(req.params.id || '');
|
const playlistId = String(req.params.id || '');
|
||||||
const videoId = String(req.params.videoId || '');
|
const videoId = String(req.params.videoId || '');
|
||||||
@@ -2838,7 +2904,7 @@ r.delete('/playlists/:id/videos/:videoId', authMiddleware, (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Reorder playlist items
|
// Reorder playlist items
|
||||||
r.put('/playlists/:id/reorder', authMiddleware, (req, res) => {
|
r.put('/playlists/:id/reorder', authMiddlewareCookieAware, (req, res) => {
|
||||||
try {
|
try {
|
||||||
const playlistId = String(req.params.id || '');
|
const playlistId = String(req.params.id || '');
|
||||||
const order = Array.isArray(req.body?.order) ? req.body.order : [];
|
const order = Array.isArray(req.body?.order) ? req.body.order : [];
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { CommonModule } from '@angular/common';
|
|||||||
import { RouterLink } from '@angular/router';
|
import { RouterLink } from '@angular/router';
|
||||||
import { DownloadService, type DownloadJob, type DownloadQuota } from '../../../services/download.service';
|
import { DownloadService, type DownloadJob, type DownloadQuota } from '../../../services/download.service';
|
||||||
import { AuthService } from '../../../services/auth.service';
|
import { AuthService } from '../../../services/auth.service';
|
||||||
|
import { frenchDownloadError } from '../../../utils/http-error.util';
|
||||||
import { TranslatePipe } from '../../../pipes/translate.pipe';
|
import { TranslatePipe } from '../../../pipes/translate.pipe';
|
||||||
import { ProviderBadgeComponent } from '../../../app/shared/components/provider-badge/provider-badge.component';
|
import { ProviderBadgeComponent } from '../../../app/shared/components/provider-badge/provider-badge.component';
|
||||||
|
|
||||||
@@ -88,14 +89,15 @@ export class DownloadsComponent {
|
|||||||
retry(job: DownloadJob): void {
|
retry(job: DownloadJob): void {
|
||||||
if (this.busyIds().has(job.id)) return;
|
if (this.busyIds().has(job.id)) return;
|
||||||
this.markBusy(job.id, true);
|
this.markBusy(job.id, true);
|
||||||
|
this.error.set(null);
|
||||||
this.downloads.retryJob(job.id).subscribe({
|
this.downloads.retryJob(job.id).subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
this.markBusy(job.id, false);
|
this.markBusy(job.id, false);
|
||||||
this.reload();
|
this.reload();
|
||||||
},
|
},
|
||||||
error: () => {
|
error: (err) => {
|
||||||
this.markBusy(job.id, false);
|
this.markBusy(job.id, false);
|
||||||
this.error.set('Nouvel essai impossible pour le moment.');
|
this.error.set(frenchDownloadError(err));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -103,14 +105,16 @@ export class DownloadsComponent {
|
|||||||
cancel(job: DownloadJob): void {
|
cancel(job: DownloadJob): void {
|
||||||
if (this.busyIds().has(job.id)) return;
|
if (this.busyIds().has(job.id)) return;
|
||||||
this.markBusy(job.id, true);
|
this.markBusy(job.id, true);
|
||||||
|
this.error.set(null);
|
||||||
this.downloads.cancelJob(job.id).subscribe({
|
this.downloads.cancelJob(job.id).subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
this.markBusy(job.id, false);
|
this.markBusy(job.id, false);
|
||||||
this.items.update((list) => list.filter((j) => j.id !== job.id));
|
this.items.update((list) => list.filter((j) => j.id !== job.id));
|
||||||
|
this.syncPoller();
|
||||||
},
|
},
|
||||||
error: () => {
|
error: (err) => {
|
||||||
this.markBusy(job.id, false);
|
this.markBusy(job.id, false);
|
||||||
this.error.set('Suppression impossible pour le moment.');
|
this.error.set(frenchDownloadError(err));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -188,7 +192,9 @@ export class DownloadsComponent {
|
|||||||
private syncPoller(): void {
|
private syncPoller(): void {
|
||||||
const hasActive = this.items().some((j) => ACTIVE_STATES.has(j.state));
|
const hasActive = this.items().some((j) => ACTIVE_STATES.has(j.state));
|
||||||
if (hasActive && !this.poller) {
|
if (hasActive && !this.poller) {
|
||||||
this.poller = setInterval(() => this.refreshActive(), 3000);
|
// 5 s : le polling agressif (3 s) saturait à lui seul le seau
|
||||||
|
// anti-abus et affamait suppressions + formats (429).
|
||||||
|
this.poller = setInterval(() => this.refreshActive(), 5000);
|
||||||
} else if (!hasActive && this.poller) {
|
} else if (!hasActive && this.poller) {
|
||||||
clearInterval(this.poller);
|
clearInterval(this.poller);
|
||||||
this.poller = null;
|
this.poller = null;
|
||||||
|
|||||||
@@ -1,28 +1,36 @@
|
|||||||
import { Component, EventEmitter, Output, Input, signal, computed, effect, inject, HostListener, ViewChild, ElementRef } from '@angular/core';
|
import { Component, EventEmitter, Output, Input, signal, computed, effect, inject, HostListener, ViewChild, ElementRef } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { HttpClientModule } from '@angular/common/http';
|
|
||||||
import { ProviderId, PROVIDERS } from '../../app/core/providers/provider-registry';
|
import { ProviderId, PROVIDERS } from '../../app/core/providers/provider-registry';
|
||||||
import { ProviderPickerComponent } from './provider-picker.component';
|
import { ProviderPickerComponent } from './provider-picker.component';
|
||||||
import { SearchService } from '../../app/search/search.service';
|
import { SearchService } from '../../app/search/search.service';
|
||||||
import { UserService, type UserPreferences } from '../../services/user.service';
|
import { UserService, type UserPreferences } from '../../services/user.service';
|
||||||
import { HistoryService, type SearchHistoryItem } from '../../services/history.service';
|
import { HistoryService, type SearchHistoryItem } from '../../services/history.service';
|
||||||
import { TelemetryService } from '../../services/telemetry.service';
|
import { TelemetryService } from '../../services/telemetry.service';
|
||||||
import { Subject } from 'rxjs';
|
import { Subject, of } from 'rxjs';
|
||||||
import { debounceTime, distinctUntilChanged } from 'rxjs/operators';
|
import { debounceTime, distinctUntilChanged, switchMap, catchError, finalize } from 'rxjs/operators';
|
||||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||||
|
import { SuggestService } from '../../app/search/suggest.service';
|
||||||
|
import { mergeGroups, highlightParts } from '../../app/search/suggest.util';
|
||||||
// No overlays; we render fixed-position modals with high z-index
|
// No overlays; we render fixed-position modals with high z-index
|
||||||
|
|
||||||
const AT_QUERY_RE = /(^|\s)@([a-z]{1,24})$/i;
|
const AT_QUERY_RE = /(^|\s)@([a-z]{1,24})$/i;
|
||||||
|
|
||||||
|
/** One row of the Step 15 typeahead panel (provider null = recent search). */
|
||||||
|
export interface SuggestFlatItem {
|
||||||
|
text: string;
|
||||||
|
provider: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-search-box',
|
selector: 'app-search-box',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
|
// NOTE : HttpClient est fourni à la racine (intercepteurs inclus) : ne pas
|
||||||
|
// importer HttpClientModule ici (écraserait les intercepteurs -> 401).
|
||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
FormsModule,
|
FormsModule,
|
||||||
ProviderPickerComponent,
|
ProviderPickerComponent
|
||||||
HttpClientModule // Nécessaire pour SearchService
|
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
SearchService, // Fournir le service au niveau du composant
|
SearchService, // Fournir le service au niveau du composant
|
||||||
@@ -36,6 +44,8 @@ export class SearchBoxComponent {
|
|||||||
private users = inject(UserService);
|
private users = inject(UserService);
|
||||||
private history = inject(HistoryService);
|
private history = inject(HistoryService);
|
||||||
private telemetry = inject(TelemetryService);
|
private telemetry = inject(TelemetryService);
|
||||||
|
// Optional: keeps unit tests (which don't provide it) working with a null guard
|
||||||
|
private suggestSvc = inject(SuggestService, { optional: true });
|
||||||
@ViewChild('qInput', { static: false }) qInputRef?: ElementRef<HTMLInputElement>;
|
@ViewChild('qInput', { static: false }) qInputRef?: ElementRef<HTMLInputElement>;
|
||||||
@ViewChild('quickDialog', { static: false }) quickDialogRef?: ElementRef<HTMLDivElement>;
|
@ViewChild('quickDialog', { static: false }) quickDialogRef?: ElementRef<HTMLDivElement>;
|
||||||
|
|
||||||
@@ -66,6 +76,29 @@ export class SearchBoxComponent {
|
|||||||
quickOpen = signal(false);
|
quickOpen = signal(false);
|
||||||
recentSearches = signal<SearchHistoryItem[]>([]);
|
recentSearches = signal<SearchHistoryItem[]>([]);
|
||||||
|
|
||||||
|
// Step 15 — query typeahead (suggestions de requêtes, avant validation).
|
||||||
|
// `suggestionsOpen` doubles as the typeahead panel flag (closed when @ popover opens).
|
||||||
|
suggestLoading = signal(false);
|
||||||
|
suggestGroups = signal<Record<string, string[]>>({});
|
||||||
|
recentSuggest = signal<string[]>([]);
|
||||||
|
suggestIndex = signal(-1);
|
||||||
|
private allRecentCache: string[] = [];
|
||||||
|
private lastRecentFetch = 0;
|
||||||
|
private suggestShownFor: string | null = null;
|
||||||
|
readonly suggestFlat = computed((): SuggestFlatItem[] => {
|
||||||
|
const q = this.query();
|
||||||
|
const recents = (this.recentSuggest() || []).slice(0, 5).map((t) => ({ text: t, provider: null as string | null }));
|
||||||
|
const seen = new Set(recents.map((r) => r.text.toLowerCase()));
|
||||||
|
const merged = mergeGroups(this.suggestGroups() || {}).filter((m) => {
|
||||||
|
const k = m.text.toLowerCase();
|
||||||
|
if (seen.has(k)) return false;
|
||||||
|
seen.add(k);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
void q;
|
||||||
|
return [...recents, ...merged].slice(0, 15);
|
||||||
|
});
|
||||||
|
|
||||||
// Element to restore focus to when a modal closes (a11y)
|
// Element to restore focus to when a modal closes (a11y)
|
||||||
private lastFocused: HTMLElement | null = null;
|
private lastFocused: HTMLElement | null = null;
|
||||||
|
|
||||||
@@ -88,6 +121,15 @@ export class SearchBoxComponent {
|
|||||||
this.searchChange.emit((value || '').trim());
|
this.searchChange.emit((value || '').trim());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Step 15 — debounced typeahead fetch; switchMap cancels the in-flight
|
||||||
|
// request on each keystroke so a slow response never overwrites the latest.
|
||||||
|
this._suggestReq.pipe(
|
||||||
|
debounceTime(250),
|
||||||
|
distinctUntilChanged(),
|
||||||
|
switchMap((value) => this.fetchSuggest(value)),
|
||||||
|
takeUntilDestroyed()
|
||||||
|
).subscribe((res) => this.applySuggestResponse(res));
|
||||||
|
|
||||||
// Reflect SearchService into local state to restore from URL/preference
|
// Reflect SearchService into local state to restore from URL/preference
|
||||||
effect(() => {
|
effect(() => {
|
||||||
if (hydratedFromService) return; // only hydrate once
|
if (hydratedFromService) return; // only hydrate once
|
||||||
@@ -152,6 +194,115 @@ export class SearchBoxComponent {
|
|||||||
const hasOptions = this.atOptions().length > 0;
|
const hasOptions = this.atOptions().length > 0;
|
||||||
this.atOpen.set(AT_QUERY_RE.test(value) && hasOptions);
|
this.atOpen.set(AT_QUERY_RE.test(value) && hasOptions);
|
||||||
if (this.atIndex() >= this.atOptions().length) this.atIndex.set(0);
|
if (this.atIndex() >= this.atOptions().length) this.atIndex.set(0);
|
||||||
|
// Step 15 — typeahead has lower priority than the @ popover
|
||||||
|
if (this.atOpen()) {
|
||||||
|
this.suggestionsOpen.set(false);
|
||||||
|
this.suggestIndex.set(-1);
|
||||||
|
} else {
|
||||||
|
this.refreshRecentSuggest(value);
|
||||||
|
this._suggestReq.next(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Step 15 helpers ---
|
||||||
|
|
||||||
|
private fetchSuggest(value: string) {
|
||||||
|
const q = String(value || '').trim();
|
||||||
|
if (!this.suggestSvc || q.length < 2 || this.atOpen()) {
|
||||||
|
return of(null);
|
||||||
|
}
|
||||||
|
const prov = this.providersForRequest();
|
||||||
|
const providers = Array.isArray(prov) ? prov : undefined;
|
||||||
|
this.suggestLoading.set(true);
|
||||||
|
return this.suggestSvc.suggestOnce(q, providers, 10).pipe(
|
||||||
|
catchError(() => of({ q, groups: {} as Record<string, string[]> })),
|
||||||
|
finalize(() => this.suggestLoading.set(false)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private applySuggestResponse(res: { q: string; groups: Record<string, string[]> } | null) {
|
||||||
|
if (!res) {
|
||||||
|
this.suggestGroups.set({});
|
||||||
|
this.suggestionsOpen.set(false);
|
||||||
|
this.suggestIndex.set(-1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.suggestGroups.set(res.groups || {});
|
||||||
|
this.refreshRecentSuggest(this.query());
|
||||||
|
const flat = this.suggestFlat();
|
||||||
|
if (flat.length > 0) {
|
||||||
|
this.suggestionsOpen.set(true);
|
||||||
|
this.suggestIndex.set(-1);
|
||||||
|
const key = String(res.q || '').toLowerCase();
|
||||||
|
if (this.suggestShownFor !== key) {
|
||||||
|
this.suggestShownFor = key;
|
||||||
|
this.telemetry.track('suggest_shown', { queryLength: key.length, resultCount: flat.length });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.suggestionsOpen.set(false);
|
||||||
|
this.suggestIndex.set(-1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Filter cached history synchronously; refresh the cache at most every 10s. */
|
||||||
|
private refreshRecentSuggest(value: string) {
|
||||||
|
const q = String(value || '').trim().toLowerCase();
|
||||||
|
if (q.length >= 2 && this.allRecentCache.length > 0) {
|
||||||
|
this.recentSuggest.set(this.allRecentCache.filter((t) => t.toLowerCase().includes(q)).slice(0, 5));
|
||||||
|
} else if (q.length < 2) {
|
||||||
|
this.recentSuggest.set([]);
|
||||||
|
}
|
||||||
|
const now = Date.now();
|
||||||
|
if (q.length < 2 || now - this.lastRecentFetch < 10_000) return;
|
||||||
|
this.lastRecentFetch = now;
|
||||||
|
try {
|
||||||
|
this.history.getSearchHistory(15).subscribe({
|
||||||
|
next: (items) => {
|
||||||
|
this.allRecentCache = (items || []).map((i) => String(i?.query || '').trim()).filter(Boolean);
|
||||||
|
const cur = String(this.query() || '').trim().toLowerCase();
|
||||||
|
if (cur.length >= 2) {
|
||||||
|
this.recentSuggest.set(this.allRecentCache.filter((t) => t.toLowerCase().includes(cur)).slice(0, 5));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: () => {},
|
||||||
|
});
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fill the input with a suggestion; submit=true also runs the search. */
|
||||||
|
applySuggestion(text: string, submit: boolean) {
|
||||||
|
const t = String(text || '').trim();
|
||||||
|
if (!t) return;
|
||||||
|
const prov = this.providersForRequest();
|
||||||
|
this.telemetry.track('suggest_used', { providers: Array.isArray(prov) ? prov : ['all'], queryLength: t.length });
|
||||||
|
this.suggestionsOpen.set(false);
|
||||||
|
this.suggestIndex.set(-1);
|
||||||
|
this.query.set(t);
|
||||||
|
try { this.search.q$.next(t); } catch {}
|
||||||
|
try { this.qInputRef?.nativeElement && (this.qInputRef.nativeElement.value = t); } catch {}
|
||||||
|
if (submit) {
|
||||||
|
this.submitted.emit({ q: t, providers: this.providersForRequest() });
|
||||||
|
this.telemetry.track('search_submit', { providers: Array.isArray(prov) ? prov : ['all'], queryLength: t.length });
|
||||||
|
} else {
|
||||||
|
this._suggestReq.next(t);
|
||||||
|
}
|
||||||
|
try { this.qInputRef?.nativeElement?.focus(); } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
suggestHighlight(text: string): { pre: string; match: string; post: string } {
|
||||||
|
return highlightParts(text, this.query());
|
||||||
|
}
|
||||||
|
|
||||||
|
providerBadge(pid: string | null): string {
|
||||||
|
if (!pid) return '';
|
||||||
|
const found = PROVIDERS.find((p) => p.id === (pid as ProviderId));
|
||||||
|
return found ? found.shortLabel : String(pid).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
onSuggestInputFocus() {
|
||||||
|
if (!this.atOpen() && String(this.query() || '').trim().length >= 2 && this.suggestFlat().length > 0) {
|
||||||
|
this.suggestionsOpen.set(true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pick an option from the inline '@' popover: strip the '@term' and toggle the chip
|
// Pick an option from the inline '@' popover: strip the '@term' and toggle the chip
|
||||||
@@ -205,6 +356,47 @@ export class SearchBoxComponent {
|
|||||||
}
|
}
|
||||||
if (ev.key === 'Escape') { this.atOpen.set(false); return; }
|
if (ev.key === 'Escape') { this.atOpen.set(false); return; }
|
||||||
}
|
}
|
||||||
|
// Step 15 — typeahead navigation (second priority after the @ popover)
|
||||||
|
if (this.suggestionsOpen() && !this.atOpen()) {
|
||||||
|
const flat = this.suggestFlat();
|
||||||
|
if (flat.length > 0) {
|
||||||
|
const last = flat.length - 1;
|
||||||
|
if (ev.key === 'ArrowDown') {
|
||||||
|
ev.preventDefault();
|
||||||
|
this.suggestIndex.set(this.suggestIndex() >= last ? 0 : this.suggestIndex() + 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (ev.key === 'ArrowUp') {
|
||||||
|
ev.preventDefault();
|
||||||
|
this.suggestIndex.set(this.suggestIndex() <= 0 ? last : this.suggestIndex() - 1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (ev.key === 'Enter') {
|
||||||
|
const idx = this.suggestIndex();
|
||||||
|
if (idx >= 0 && idx <= last) {
|
||||||
|
ev.stopImmediatePropagation();
|
||||||
|
ev.preventDefault();
|
||||||
|
this.applySuggestion(flat[idx].text, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Tab completes the active (or first) suggestion without searching
|
||||||
|
if (ev.key === 'Tab') {
|
||||||
|
const idx = this.suggestIndex() >= 0 ? this.suggestIndex() : 0;
|
||||||
|
if (flat[idx]) {
|
||||||
|
ev.preventDefault();
|
||||||
|
this.applySuggestion(flat[idx].text, false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Esc closes the panel but keeps the typed text
|
||||||
|
if (ev.key === 'Escape') {
|
||||||
|
this.suggestionsOpen.set(false);
|
||||||
|
this.suggestIndex.set(-1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
// Alt+1..6 map to chips by registry order
|
// Alt+1..6 map to chips by registry order
|
||||||
if (ev.altKey) {
|
if (ev.altKey) {
|
||||||
const map: Record<string, ProviderId | undefined> = { '1': 'yt', '2': 'dm', '3': 'tw', '4': 'pt', '5': 'od', '6': 'ru' };
|
const map: Record<string, ProviderId | undefined> = { '1': 'yt', '2': 'dm', '3': 'tw', '4': 'pt', '5': 'od', '6': 'ru' };
|
||||||
@@ -250,6 +442,8 @@ export class SearchBoxComponent {
|
|||||||
|
|
||||||
onSubmit(ev: Event) {
|
onSubmit(ev: Event) {
|
||||||
try { ev.preventDefault(); } catch {}
|
try { ev.preventDefault(); } catch {}
|
||||||
|
this.suggestionsOpen.set(false);
|
||||||
|
this.suggestIndex.set(-1);
|
||||||
let q = (this.query() || '').trim();
|
let q = (this.query() || '').trim();
|
||||||
if ((!q || q.length === 0) && this.qInputRef?.nativeElement) {
|
if ((!q || q.length === 0) && this.qInputRef?.nativeElement) {
|
||||||
try {
|
try {
|
||||||
@@ -355,4 +549,6 @@ export class SearchBoxComponent {
|
|||||||
|
|
||||||
// Internal subject for debouncing query changes
|
// Internal subject for debouncing query changes
|
||||||
private _qChanges = new Subject<string>();
|
private _qChanges = new Subject<string>();
|
||||||
|
// Internal subject for debounced typeahead fetches (Step 15)
|
||||||
|
private _suggestReq = new Subject<string>();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { ChangeDetectionStrategy, Component, Input, OnDestroy, OnInit, inject, signal, HostListener } from '@angular/core';
|
import { ChangeDetectionStrategy, Component, Input, OnDestroy, OnInit, inject, signal, HostListener } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { HttpClientModule } from '@angular/common/http';
|
|
||||||
import { PlaylistsService, Playlist } from '../../../../services/playlists.service';
|
import { PlaylistsService, Playlist } from '../../../../services/playlists.service';
|
||||||
import { AuthService } from '../../../../services/auth.service';
|
import { AuthService } from '../../../../services/auth.service';
|
||||||
import { forkJoin } from 'rxjs';
|
import { forkJoin } from 'rxjs';
|
||||||
@@ -8,7 +7,10 @@ import { forkJoin } from 'rxjs';
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'app-add-to-playlist',
|
selector: 'app-add-to-playlist',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, HttpClientModule],
|
// NOTE : ne pas importer HttpClientModule ici : fourni à la racine via
|
||||||
|
// provideHttpClient(withInterceptors(...)), un import local créerait un
|
||||||
|
// HttpClient sans intercepteurs (plus de Bearer/refresh -> 401).
|
||||||
|
imports: [CommonModule],
|
||||||
template: `
|
template: `
|
||||||
<div class="relative inline-block text-left" (click)="$event.stopPropagation();">
|
<div class="relative inline-block text-left" (click)="$event.stopPropagation();">
|
||||||
<button (click)="toggleOpen($event)"
|
<button (click)="toggleOpen($event)"
|
||||||
|
|||||||
@@ -2,13 +2,13 @@ import { ChangeDetectionStrategy, Component, Input, OnChanges, OnDestroy, OnInit
|
|||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { LikesService } from '../../../../services/likes.service';
|
import { LikesService } from '../../../../services/likes.service';
|
||||||
import { finalize } from 'rxjs';
|
import { finalize } from 'rxjs';
|
||||||
import { HttpClientModule } from '@angular/common/http';
|
|
||||||
import { AuthService } from '../../../../services/auth.service';
|
import { AuthService } from '../../../../services/auth.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-like-button',
|
selector: 'app-like-button',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, HttpClientModule],
|
// NOTE : ne pas importer HttpClientModule ici (voir add-to-playlist).
|
||||||
|
imports: [CommonModule],
|
||||||
// Le service est déjà fourni via providedIn: 'root' dans le service
|
// Le service est déjà fourni via providedIn: 'root' dans le service
|
||||||
template: `
|
template: `
|
||||||
@if (isAuthenticated()) {
|
@if (isAuthenticated()) {
|
||||||
|
|||||||
@@ -149,6 +149,9 @@
|
|||||||
</svg>
|
</svg>
|
||||||
<span>{{ liked() ? 'Aimée' : 'J\'aime' }}</span>
|
<span>{{ liked() ? 'Aimée' : 'J\'aime' }}</span>
|
||||||
</button>
|
</button>
|
||||||
|
@if (likeError(); as lerr) {
|
||||||
|
<span class="text-xs text-red-300" role="alert">{{ lerr }}</span>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@if (isLoggedIn()) {
|
@if (isLoggedIn()) {
|
||||||
<app-add-to-playlist [provider]="provider()" [videoId]="v.videoId" [title]="v.title" [thumbnail]="v.thumbnail"></app-add-to-playlist>
|
<app-add-to-playlist [provider]="provider()" [videoId]="v.videoId" [title]="v.title" [thumbnail]="v.thumbnail"></app-add-to-playlist>
|
||||||
@@ -277,7 +280,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
@if (downloadError() && !formatsLoading()) {
|
@if (downloadError() && !formatsLoading()) {
|
||||||
<div class="mt-3 text-sm text-red-300" role="alert">{{ downloadError() }}</div>
|
<div class="mt-3 flex flex-wrap items-center gap-3" role="alert">
|
||||||
|
<span class="text-sm text-red-300">{{ downloadError() }}</span>
|
||||||
|
<button (click)="retryFormats()" class="rounded-full bg-slate-700 px-3 py-1 text-xs font-semibold text-slate-200 transition hover:bg-slate-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400">
|
||||||
|
Réessayer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
</section>
|
</section>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { SubscribeButtonComponent } from '../../app/shared/components/subscribe-
|
|||||||
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
|
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
|
||||||
import { IframeProgressService } from '../../services/iframe-progress.service';
|
import { IframeProgressService } from '../../services/iframe-progress.service';
|
||||||
import { formatAbsoluteFr, formatNumberFr } from '../../utils/date.util';
|
import { formatAbsoluteFr, formatNumberFr } from '../../utils/date.util';
|
||||||
|
import { frenchDownloadError } from '../../utils/http-error.util';
|
||||||
import { ProviderBadgeComponent } from '../../app/shared/components/provider-badge/provider-badge.component';
|
import { ProviderBadgeComponent } from '../../app/shared/components/provider-badge/provider-badge.component';
|
||||||
import { ChannelIdentityComponent } from '../../app/shared/components/channel-identity/channel-identity.component';
|
import { ChannelIdentityComponent } from '../../app/shared/components/channel-identity/channel-identity.component';
|
||||||
import { DurationPipe } from '../../app/shared/pipes/duration.pipe';
|
import { DurationPipe } from '../../app/shared/pipes/duration.pipe';
|
||||||
@@ -289,6 +290,7 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
|
|||||||
// --- Like state ---
|
// --- Like state ---
|
||||||
liked = signal<boolean>(false);
|
liked = signal<boolean>(false);
|
||||||
likeBusy = signal<boolean>(false);
|
likeBusy = signal<boolean>(false);
|
||||||
|
likeError = signal<string | null>(null);
|
||||||
|
|
||||||
// --- Transcript state (Phase 2: display + search + click-to-seek + auto-scroll) ---
|
// --- Transcript state (Phase 2: display + search + click-to-seek + auto-scroll) ---
|
||||||
transcriptOpen = signal(false);
|
transcriptOpen = signal(false);
|
||||||
@@ -553,24 +555,37 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
|
|||||||
if (!provider || !videoId || this.likeBusy()) return;
|
if (!provider || !videoId || this.likeBusy()) return;
|
||||||
|
|
||||||
this.likeBusy.set(true);
|
this.likeBusy.set(true);
|
||||||
|
this.likeError.set(null);
|
||||||
|
|
||||||
const handleError = () => {
|
const handleError = (err: unknown) => {
|
||||||
this.likeBusy.set(false);
|
this.likeBusy.set(false);
|
||||||
|
const status = (err as { status?: number })?.status;
|
||||||
|
this.likeError.set(
|
||||||
|
status === 401
|
||||||
|
? "Session expirée. Reconnectez-vous puis réessayez."
|
||||||
|
: "Impossible d’enregistrer votre avis pour le moment. Réessayez."
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Titre + miniature connus : évite l'enrichissement serveur lent (yt-dlp).
|
||||||
|
const v = this.video();
|
||||||
|
const title = v?.title || undefined;
|
||||||
|
const thumbnail = v?.thumbnail || undefined;
|
||||||
if (this.liked()) {
|
if (this.liked()) {
|
||||||
this.likes.unlike(provider, videoId).subscribe({
|
this.likes.unlike(provider, videoId).subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
this.liked.set(false);
|
this.liked.set(false);
|
||||||
this.likeBusy.set(false);
|
this.likeBusy.set(false);
|
||||||
|
this.likeError.set(null);
|
||||||
},
|
},
|
||||||
error: handleError
|
error: handleError
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
this.likes.like(provider, videoId).subscribe({
|
this.likes.like(provider, videoId, title, thumbnail).subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
this.liked.set(true);
|
this.liked.set(true);
|
||||||
this.likeBusy.set(false);
|
this.likeBusy.set(false);
|
||||||
|
this.likeError.set(null);
|
||||||
},
|
},
|
||||||
error: handleError
|
error: handleError
|
||||||
});
|
});
|
||||||
@@ -634,6 +649,7 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
|
|||||||
// Reset like state while loading
|
// Reset like state while loading
|
||||||
this.liked.set(false);
|
this.liked.set(false);
|
||||||
this.likeBusy.set(false);
|
this.likeBusy.set(false);
|
||||||
|
this.likeError.set(null);
|
||||||
const provider = this.provider();
|
const provider = this.provider();
|
||||||
if (provider === 'rumble') {
|
if (provider === 'rumble') {
|
||||||
// Validate and enrich Rumble video via backend scraping (may also normalize id)
|
// Validate and enrich Rumble video via backend scraping (may also normalize id)
|
||||||
@@ -1261,11 +1277,20 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
|
|||||||
|
|
||||||
// --- Download logic ---
|
// --- Download logic ---
|
||||||
openDownloadPanel() {
|
openDownloadPanel() {
|
||||||
|
// Anti double-clic : ne recharge pas si le panneau est déjà ouvert.
|
||||||
|
if (this.downloadOpen() && (this.formatsLoading() || this.downloadFormats().length > 0)) return;
|
||||||
this.downloadOpen.set(true);
|
this.downloadOpen.set(true);
|
||||||
this.downloadError.set(null);
|
this.downloadError.set(null);
|
||||||
this.loadFormats();
|
this.loadFormats();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Nouveau essai manuel depuis le panneau (bouton « Réessayer »). */
|
||||||
|
retryFormats(): void {
|
||||||
|
if (this.formatsLoading()) return;
|
||||||
|
this.downloadError.set(null);
|
||||||
|
this.loadFormats();
|
||||||
|
}
|
||||||
|
|
||||||
closeDownloadPanel() {
|
closeDownloadPanel() {
|
||||||
this.downloadOpen.set(false);
|
this.downloadOpen.set(false);
|
||||||
}
|
}
|
||||||
@@ -1303,8 +1328,9 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
|
|||||||
loadFormats() {
|
loadFormats() {
|
||||||
const p = this.provider();
|
const p = this.provider();
|
||||||
const id = this.videoId();
|
const id = this.videoId();
|
||||||
if (!p || !id) return;
|
if (!p || !id || this.formatsLoading()) return;
|
||||||
this.formatsLoading.set(true);
|
this.formatsLoading.set(true);
|
||||||
|
this.downloadError.set(null);
|
||||||
this.downloads.listFormats(p, id, this.buildProviderOpts()).subscribe({
|
this.downloads.listFormats(p, id, this.buildProviderOpts()).subscribe({
|
||||||
next: (res) => {
|
next: (res) => {
|
||||||
const list = (res?.formats || []).slice();
|
const list = (res?.formats || []).slice();
|
||||||
@@ -1312,14 +1338,12 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
|
|||||||
list.sort((a, b) => (parseInt((b.resolution || '').replace(/[^0-9]/g, '')) || 0) - (parseInt((a.resolution || '').replace(/[^0-9]/g, '')) || 0));
|
list.sort((a, b) => (parseInt((b.resolution || '').replace(/[^0-9]/g, '')) || 0) - (parseInt((a.resolution || '').replace(/[^0-9]/g, '')) || 0));
|
||||||
this.downloadFormats.set(list);
|
this.downloadFormats.set(list);
|
||||||
this.formatsLoading.set(false);
|
this.formatsLoading.set(false);
|
||||||
|
if (!list.length) this.downloadError.set('Aucun format disponible pour cette vidéo.');
|
||||||
},
|
},
|
||||||
error: (err) => {
|
error: (err) => {
|
||||||
const status = err?.status || 0;
|
// Message FR propre (jamais le brut « Http failure response... »),
|
||||||
if (status === 403) this.downloadError.set('Le téléchargement est désactivé pour ce provider.');
|
// avec compte à rebours sur les 429 + bouton Réessayer du panneau.
|
||||||
else {
|
this.downloadError.set(frenchDownloadError(err));
|
||||||
const details = (err && err.error && (err.error.details || err.error.error)) || err?.message || '';
|
|
||||||
this.downloadError.set(details ? `Impossible de récupérer les formats. (${details})` : 'Impossible de récupérer les formats.');
|
|
||||||
}
|
|
||||||
this.formatsLoading.set(false);
|
this.formatsLoading.set(false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -1360,6 +1384,7 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
|
|||||||
|
|
||||||
private pollJob(jobId: string) {
|
private pollJob(jobId: string) {
|
||||||
if (this.jobPoller) clearInterval(this.jobPoller);
|
if (this.jobPoller) clearInterval(this.jobPoller);
|
||||||
|
// 4 s au lieu de 2 s : le polling agressif saturait le seau anti-abus (429).
|
||||||
this.jobPoller = setInterval(() => {
|
this.jobPoller = setInterval(() => {
|
||||||
this.downloads.getJob(jobId).subscribe({
|
this.downloads.getJob(jobId).subscribe({
|
||||||
next: (job) => {
|
next: (job) => {
|
||||||
@@ -1373,14 +1398,18 @@ export class WatchComponent implements OnDestroy, AfterViewInit {
|
|||||||
if (job.state === 'failed') this.downloadError.set(job.error || 'Téléchargement échoué.');
|
if (job.state === 'failed') this.downloadError.set(job.error || 'Téléchargement échoué.');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
error: () => {
|
error: (err) => {
|
||||||
// Stop polling on error
|
// Un 429 transitoire ne doit pas tuer le suivi : on continue le
|
||||||
|
// polling, seule une erreur durable l'arrête (401/403/404).
|
||||||
|
const status = (err as { status?: number })?.status;
|
||||||
|
if (status === 401 || status === 403 || status === 404) {
|
||||||
clearInterval(this.jobPoller);
|
clearInterval(this.jobPoller);
|
||||||
this.jobPoller = null;
|
this.jobPoller = null;
|
||||||
this.downloadError.set('Erreur de suivi du téléchargement.');
|
this.downloadError.set(frenchDownloadError(err));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, 2000);
|
}, 4000);
|
||||||
}
|
}
|
||||||
|
|
||||||
cancelDownload() {
|
cancelDownload() {
|
||||||
|
|||||||
@@ -33,11 +33,9 @@ export const authInterceptor: HttpInterceptorFn = (req, next) => {
|
|||||||
if (isRefreshCall) {
|
if (isRefreshCall) {
|
||||||
return throwError(() => err);
|
return throwError(() => err);
|
||||||
}
|
}
|
||||||
// If there is no access token at all, we are not logged in: do not try refresh
|
// Même sans token en mémoire (ex. juste après F5), une session par
|
||||||
const hasToken = !!auth.getAccessToken();
|
// cookies httpOnly peut exister : on tente un refresh via cookies
|
||||||
if (!hasToken) {
|
// avant d'abandonner. refresh() n'échoue jamais bruyamment (false).
|
||||||
return throwError(() => err);
|
|
||||||
}
|
|
||||||
// Try a single refresh then retry the request once
|
// Try a single refresh then retry the request once
|
||||||
return auth.refresh().pipe(
|
return auth.refresh().pipe(
|
||||||
switchMap((ok) => {
|
switchMap((ok) => {
|
||||||
@@ -47,7 +45,7 @@ export const authInterceptor: HttpInterceptorFn = (req, next) => {
|
|||||||
if (newToken) {
|
if (newToken) {
|
||||||
retry = retry.clone({ setHeaders: { Authorization: `Bearer ${newToken}` } });
|
retry = retry.clone({ setHeaders: { Authorization: `Bearer ${newToken}` } });
|
||||||
}
|
}
|
||||||
if (retry.url.startsWith('/proxy/api')) {
|
if (retry.url.startsWith('/proxy/api') || retry.url.startsWith('/api')) {
|
||||||
retry = retry.clone({ withCredentials: true });
|
retry = retry.clone({ withCredentials: true });
|
||||||
}
|
}
|
||||||
return next(retry);
|
return next(retry);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
import { HttpClient, HttpParams, HttpResponse } from '@angular/common/http';
|
import { HttpClient, HttpErrorResponse, HttpParams, HttpResponse } from '@angular/common/http';
|
||||||
import { Observable, map } from 'rxjs';
|
import { Observable, map, retry, timer, throwError } from 'rxjs';
|
||||||
|
|
||||||
export interface DownloadFormat {
|
export interface DownloadFormat {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -61,6 +61,19 @@ export class DownloadService {
|
|||||||
return this.http.get<{ url: string; formats: DownloadFormat[]; title: string; duration: number }>(
|
return this.http.get<{ url: string; formats: DownloadFormat[]; title: string; duration: number }>(
|
||||||
`/proxy/api/download/${encodeURIComponent(provider)}/${encodeURIComponent(videoId)}/formats`,
|
`/proxy/api/download/${encodeURIComponent(provider)}/${encodeURIComponent(videoId)}/formats`,
|
||||||
{ params, withCredentials: true }
|
{ params, withCredentials: true }
|
||||||
|
).pipe(
|
||||||
|
// La liste des formats dépend de yt-dlp/YouTube (parfois 429/5xx
|
||||||
|
// transitoires) : 2 nouveaux essais espacés, jamais sur les 4xx
|
||||||
|
// définitives (401/403/404) pour ne pas marteler le seau anti-abus.
|
||||||
|
retry({
|
||||||
|
count: 2,
|
||||||
|
delay: (err: unknown, retryCount: number) => {
|
||||||
|
const status = (err as HttpErrorResponse)?.status;
|
||||||
|
const retryable = status === 0 || status === 429 || (typeof status === 'number' && status >= 500);
|
||||||
|
if (!retryable) return throwError(() => err);
|
||||||
|
return timer(1500 * retryCount);
|
||||||
|
},
|
||||||
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,19 +24,31 @@ export class UserService {
|
|||||||
return this._preferences();
|
return this._preferences();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Même règle que les autres services : /proxy/api en dev (proxy Angular),
|
||||||
|
* /api quand on est servi par le backend :4000 (prod/docker). L'URL codée
|
||||||
|
* en dur cassait loadMe/loadPreferences en prod (-> currentUser null). */
|
||||||
|
private apiBase(): string {
|
||||||
|
try {
|
||||||
|
const port = window?.location?.port || '';
|
||||||
|
return port && port !== '4000' ? '/proxy/api' : '/api';
|
||||||
|
} catch {
|
||||||
|
return '/api';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
loadPreferences(): Observable<UserPreferences> {
|
loadPreferences(): Observable<UserPreferences> {
|
||||||
return this.http.get<UserPreferences>('/proxy/api/user/preferences', { withCredentials: true }).pipe(
|
return this.http.get<UserPreferences>(`${this.apiBase()}/user/preferences`, { withCredentials: true }).pipe(
|
||||||
tap(p => this._preferences.set(p || {}))
|
tap(p => this._preferences.set(p || {}))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
updatePreferences(patch: Partial<UserPreferences>): Observable<UserPreferences> {
|
updatePreferences(patch: Partial<UserPreferences>): Observable<UserPreferences> {
|
||||||
return this.http.patch<UserPreferences>('/proxy/api/user/preferences', patch, { withCredentials: true }).pipe(
|
return this.http.patch<UserPreferences>(`${this.apiBase()}/user/preferences`, patch, { withCredentials: true }).pipe(
|
||||||
tap(p => this._preferences.set(p || {}))
|
tap(p => this._preferences.set(p || {}))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
loadMe(): Observable<AuthUser> {
|
loadMe(): Observable<AuthUser> {
|
||||||
return this.http.get<AuthUser>('/proxy/api/user/me', { withCredentials: true });
|
return this.http.get<AuthUser>(`${this.apiBase()}/user/me`, { withCredentials: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { HttpErrorResponse } from '@angular/common/http';
|
||||||
|
|
||||||
|
/** Code d'erreur métier renvoyé par le backend : { error: 'rate_limited', ... }. */
|
||||||
|
export function backendErrorCode(err: unknown): string | null {
|
||||||
|
try {
|
||||||
|
const body = (err as HttpErrorResponse)?.error;
|
||||||
|
if (body && typeof body === 'object' && typeof body.error === 'string') return body.error;
|
||||||
|
if (typeof body === 'string') {
|
||||||
|
const m = /"error"\s*:\s*"([a-z_]+)"/i.exec(body);
|
||||||
|
if (m) return m[1];
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Secondes avant de réessayer après un 429 : backend { retryAfterSec } >
|
||||||
|
* header Retry-After > header RateLimit-Reset (epoch secondes) > défaut.
|
||||||
|
*/
|
||||||
|
export function retryAfterSeconds(err: unknown, fallback = 30): number {
|
||||||
|
try {
|
||||||
|
const e = err as HttpErrorResponse;
|
||||||
|
const body = e?.error;
|
||||||
|
if (body && typeof body === 'object' && Number.isFinite(Number(body.retryAfterSec))) {
|
||||||
|
return Math.max(1, Math.ceil(Number(body.retryAfterSec)));
|
||||||
|
}
|
||||||
|
const headers = e?.headers;
|
||||||
|
const retryAfter = headers?.get?.('Retry-After');
|
||||||
|
if (retryAfter && Number.isFinite(Number(retryAfter))) return Math.max(1, Math.ceil(Number(retryAfter)));
|
||||||
|
const reset = headers?.get?.('RateLimit-Reset');
|
||||||
|
if (reset && Number.isFinite(Number(reset))) {
|
||||||
|
const nowSec = Date.now() / 1000;
|
||||||
|
const wait = Math.ceil(Number(reset) - nowSec);
|
||||||
|
if (wait >= 1 && wait <= 3600) return wait;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vrai quand l'échec est transitoire et mérite un retry (429 / réseau / 5xx). */
|
||||||
|
export function isRetryableStatus(status: number | undefined): boolean {
|
||||||
|
return status === 0 || status === 429 || (typeof status === 'number' && status >= 500 && status < 600);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Message FR prêt à afficher pour les erreurs du module téléchargement.
|
||||||
|
* Ne fuit jamais le corps brut (HTML / « Http failure response... »).
|
||||||
|
*/
|
||||||
|
export function frenchDownloadError(err: unknown): string {
|
||||||
|
const e = err as HttpErrorResponse;
|
||||||
|
const status = typeof e?.status === 'number' ? e.status : undefined;
|
||||||
|
const code = backendErrorCode(err);
|
||||||
|
if (status === 0) return 'Serveur de téléchargement injoignable. Vérifiez que le backend tourne puis réessayez.';
|
||||||
|
if (status === 401) return 'Session expirée. Reconnectez-vous puis réessayez.';
|
||||||
|
if (status === 403) {
|
||||||
|
if (code === 'download_disabled_for_provider' || code === 'forbidden') {
|
||||||
|
return code === 'forbidden'
|
||||||
|
? 'Action interdite (ressource d’un autre compte).'
|
||||||
|
: 'Le téléchargement est désactivé pour ce provider.';
|
||||||
|
}
|
||||||
|
return 'Action interdite.';
|
||||||
|
}
|
||||||
|
if (status === 404) return 'Ce téléchargement n’existe plus (déjà supprimé ou autre compte). Actualisez la liste.';
|
||||||
|
if (status === 410) return 'Le fichier n’est plus présent sur le serveur (nettoyé). Relancez un téléchargement.';
|
||||||
|
if (status === 429) {
|
||||||
|
if (code === 'too_many_downloads') return 'Trop de téléchargements en parallèle. Attendez la fin d’un job puis réessayez.';
|
||||||
|
if (code === 'storage_quota_exceeded') return 'Quota de stockage atteint. Supprimez d’anciens fichiers puis réessayez.';
|
||||||
|
const wait = retryAfterSeconds(err);
|
||||||
|
return `Trop de requêtes pour le moment (limite anti-abus). Réessayez dans ~${wait} s.`;
|
||||||
|
}
|
||||||
|
if (status === 400) {
|
||||||
|
if (code === 'job_not_retryable') return 'Ce job ne peut pas être relancé dans son état actuel.';
|
||||||
|
return 'Requête invalide.';
|
||||||
|
}
|
||||||
|
if (code === 'formats_failed' || status === 500) {
|
||||||
|
return 'Impossible de récupérer les formats (service vidéo indisponible). Réessayez dans un instant.';
|
||||||
|
}
|
||||||
|
return 'Opération impossible pour le moment. Réessayez plus tard.';
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user