diff --git a/server/index.mjs b/server/index.mjs index 6adc555..2d57911 100644 --- a/server/index.mjs +++ b/server/index.mjs @@ -492,13 +492,67 @@ const loginLimiter = rateLimit({ legacyHeaders: false, }); -const downloadLimiter = rateLimit({ - windowMs: 60 * 1000, // 1 min - max: 15, +/** + * Réponse JSON (et non texte brut) quand un rate-limiter se déclenche, pour + * 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, 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 const rumbleLimiter = rateLimit({ 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) -r.use('/download', authMiddlewareCookieAware, downloadLimiter); +// Download routes middleware (auth supports both Authorization header and cookies). +// 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 -r.get('/download/:provider/:videoId/formats', async (req, res) => { +// List available formats for a given video (cache 10 min : un dump = 5-15 s) +r.get('/download/:provider/:videoId/formats', downloadFormatsLimiter, async (req, res) => { try { const { provider, videoId } = req.params; 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 slug = req.query.slug || 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 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 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) { 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 -r.post('/download/:provider/:videoId', async (req, res) => { +r.post('/download/:provider/:videoId', downloadWriteLimiter, async (req, res) => { try { const { provider, videoId } = req.params; 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) -r.get('/download/jobs', (req, res) => { +r.get('/download/jobs', downloadReadLimiter, (req, res) => { try { const userId = req.user?.id || 'anonymous'; 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) -r.post('/download/jobs/:id/retry', async (req, res) => { +r.post('/download/jobs/:id/retry', downloadWriteLimiter, async (req, res) => { try { const { row, error } = loadOwnedJob(req); 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 -r.get('/download/jobs/:id', (req, res) => { +r.get('/download/jobs/:id', downloadReadLimiter, (req, res) => { const { id } = req.params; const { row, error } = loadOwnedJob(req); 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) -r.get('/download/jobs/:id/file', (req, res) => { +r.get('/download/jobs/:id/file', downloadReadLimiter, (req, res) => { const { id } = req.params; const { row, error } = loadOwnedJob(req); 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 -r.delete('/download/jobs/:id', (req, res) => { +r.delete('/download/jobs/:id', downloadWriteLimiter, (req, res) => { const { id } = req.params; const { row, error } = loadOwnedJob(req); if (error === 'forbidden') return res.status(403).json({ error }); @@ -1875,14 +1936,17 @@ r.delete('/user/history/watch', authMiddleware, (req, res) => { }); // --- 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 q = typeof req.query.q === 'string' ? req.query.q : undefined; const rows = listLikedVideos({ userId: req.user.id, limit, q }); 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 || {}; try { console.log('[POST /user/likes] payload:', { @@ -1913,7 +1977,7 @@ r.post('/user/likes', authMiddleware, async (req, res) => { 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 videoId = req.query.videoId ? String(req.query.videoId) : ''; 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 -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 videoId = req.query.videoId ? String(req.query.videoId) : ''; if (!provider || !videoId) return res.status(400).json({ error: 'provider and videoId are required' }); @@ -2716,8 +2780,10 @@ app.listen(PORT, () => { }); // --- 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 -r.post('/playlists', authMiddleware, (req, res) => { +r.post('/playlists', authMiddlewareCookieAware, (req, res) => { try { const { title, description, thumbnail, isPrivate } = req.body || {}; if (!title || String(title).trim().length === 0) { @@ -2733,7 +2799,7 @@ r.post('/playlists', authMiddleware, (req, res) => { }); // List current user's playlists (pagination + search) -r.get('/playlists', authMiddleware, (req, res) => { +r.get('/playlists', authMiddlewareCookieAware, (req, res) => { try { const limit = Math.min(200, Math.max(1, Number(req.query.limit || 50))); 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) -r.get('/playlists/:id', authMiddleware, (req, res) => { +r.get('/playlists/:id', authMiddlewareCookieAware, (req, res) => { try { const id = String(req.params.id || ''); const pl = getPlaylistRaw(id); @@ -2762,7 +2828,7 @@ r.get('/playlists/:id', authMiddleware, (req, res) => { }); // Update a playlist (title/description/thumbnail/isPrivate) -r.put('/playlists/:id', authMiddleware, (req, res) => { +r.put('/playlists/:id', authMiddlewareCookieAware, (req, res) => { try { const id = String(req.params.id || ''); const patch = req.body || {}; @@ -2776,7 +2842,7 @@ r.put('/playlists/:id', authMiddleware, (req, res) => { }); // Delete a playlist -r.delete('/playlists/:id', authMiddleware, (req, res) => { +r.delete('/playlists/:id', authMiddlewareCookieAware, (req, res) => { try { const id = String(req.params.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) -r.post('/playlists/:id/videos', authMiddleware, async (req, res) => { +r.post('/playlists/:id/videos', authMiddlewareCookieAware, async (req, res) => { try { const playlistId = String(req.params.id || ''); 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) -r.delete('/playlists/:id/videos/:videoId', authMiddleware, (req, res) => { +r.delete('/playlists/:id/videos/:videoId', authMiddlewareCookieAware, (req, res) => { try { const playlistId = String(req.params.id || ''); const videoId = String(req.params.videoId || ''); @@ -2838,7 +2904,7 @@ r.delete('/playlists/:id/videos/:videoId', authMiddleware, (req, res) => { }); // Reorder playlist items -r.put('/playlists/:id/reorder', authMiddleware, (req, res) => { +r.put('/playlists/:id/reorder', authMiddlewareCookieAware, (req, res) => { try { const playlistId = String(req.params.id || ''); const order = Array.isArray(req.body?.order) ? req.body.order : []; diff --git a/src/components/library/downloads/downloads.component.ts b/src/components/library/downloads/downloads.component.ts index 987098f..754bde3 100644 --- a/src/components/library/downloads/downloads.component.ts +++ b/src/components/library/downloads/downloads.component.ts @@ -3,6 +3,7 @@ import { CommonModule } from '@angular/common'; import { RouterLink } from '@angular/router'; import { DownloadService, type DownloadJob, type DownloadQuota } from '../../../services/download.service'; import { AuthService } from '../../../services/auth.service'; +import { frenchDownloadError } from '../../../utils/http-error.util'; import { TranslatePipe } from '../../../pipes/translate.pipe'; import { ProviderBadgeComponent } from '../../../app/shared/components/provider-badge/provider-badge.component'; @@ -88,14 +89,15 @@ export class DownloadsComponent { retry(job: DownloadJob): void { if (this.busyIds().has(job.id)) return; this.markBusy(job.id, true); + this.error.set(null); this.downloads.retryJob(job.id).subscribe({ next: () => { this.markBusy(job.id, false); this.reload(); }, - error: () => { + error: (err) => { 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 { if (this.busyIds().has(job.id)) return; this.markBusy(job.id, true); + this.error.set(null); this.downloads.cancelJob(job.id).subscribe({ next: () => { this.markBusy(job.id, false); this.items.update((list) => list.filter((j) => j.id !== job.id)); + this.syncPoller(); }, - error: () => { + error: (err) => { 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 { const hasActive = this.items().some((j) => ACTIVE_STATES.has(j.state)); 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) { clearInterval(this.poller); this.poller = null; diff --git a/src/components/search/search-box.component.ts b/src/components/search/search-box.component.ts index 2ef28a6..3c5d9f0 100644 --- a/src/components/search/search-box.component.ts +++ b/src/components/search/search-box.component.ts @@ -1,28 +1,36 @@ import { Component, EventEmitter, Output, Input, signal, computed, effect, inject, HostListener, ViewChild, ElementRef } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; -import { HttpClientModule } from '@angular/common/http'; import { ProviderId, PROVIDERS } from '../../app/core/providers/provider-registry'; import { ProviderPickerComponent } from './provider-picker.component'; import { SearchService } from '../../app/search/search.service'; import { UserService, type UserPreferences } from '../../services/user.service'; import { HistoryService, type SearchHistoryItem } from '../../services/history.service'; import { TelemetryService } from '../../services/telemetry.service'; -import { Subject } from 'rxjs'; -import { debounceTime, distinctUntilChanged } from 'rxjs/operators'; +import { Subject, of } from 'rxjs'; +import { debounceTime, distinctUntilChanged, switchMap, catchError, finalize } from 'rxjs/operators'; 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 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({ selector: 'app-search-box', standalone: true, + // NOTE : HttpClient est fourni à la racine (intercepteurs inclus) : ne pas + // importer HttpClientModule ici (écraserait les intercepteurs -> 401). imports: [ CommonModule, FormsModule, - ProviderPickerComponent, - HttpClientModule // Nécessaire pour SearchService + ProviderPickerComponent ], providers: [ SearchService, // Fournir le service au niveau du composant @@ -36,6 +44,8 @@ export class SearchBoxComponent { private users = inject(UserService); private history = inject(HistoryService); 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; @ViewChild('quickDialog', { static: false }) quickDialogRef?: ElementRef; @@ -66,6 +76,29 @@ export class SearchBoxComponent { quickOpen = signal(false); recentSearches = signal([]); + // 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>({}); + recentSuggest = signal([]); + 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) private lastFocused: HTMLElement | null = null; @@ -88,6 +121,15 @@ export class SearchBoxComponent { 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 effect(() => { if (hydratedFromService) return; // only hydrate once @@ -152,6 +194,115 @@ export class SearchBoxComponent { const hasOptions = this.atOptions().length > 0; this.atOpen.set(AT_QUERY_RE.test(value) && hasOptions); 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 })), + finalize(() => this.suggestLoading.set(false)), + ); + } + + private applySuggestResponse(res: { q: string; groups: Record } | 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 @@ -205,6 +356,47 @@ export class SearchBoxComponent { } 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 if (ev.altKey) { const map: Record = { '1': 'yt', '2': 'dm', '3': 'tw', '4': 'pt', '5': 'od', '6': 'ru' }; @@ -250,6 +442,8 @@ export class SearchBoxComponent { onSubmit(ev: Event) { try { ev.preventDefault(); } catch {} + this.suggestionsOpen.set(false); + this.suggestIndex.set(-1); let q = (this.query() || '').trim(); if ((!q || q.length === 0) && this.qInputRef?.nativeElement) { try { @@ -355,4 +549,6 @@ export class SearchBoxComponent { // Internal subject for debouncing query changes private _qChanges = new Subject(); + // Internal subject for debounced typeahead fetches (Step 15) + private _suggestReq = new Subject(); } diff --git a/src/components/shared/components/add-to-playlist/add-to-playlist.component.ts b/src/components/shared/components/add-to-playlist/add-to-playlist.component.ts index b5b7209..b4d6765 100644 --- a/src/components/shared/components/add-to-playlist/add-to-playlist.component.ts +++ b/src/components/shared/components/add-to-playlist/add-to-playlist.component.ts @@ -1,6 +1,5 @@ import { ChangeDetectionStrategy, Component, Input, OnDestroy, OnInit, inject, signal, HostListener } from '@angular/core'; import { CommonModule } from '@angular/common'; -import { HttpClientModule } from '@angular/common/http'; import { PlaylistsService, Playlist } from '../../../../services/playlists.service'; import { AuthService } from '../../../../services/auth.service'; import { forkJoin } from 'rxjs'; @@ -8,7 +7,10 @@ import { forkJoin } from 'rxjs'; @Component({ selector: 'app-add-to-playlist', 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: `
+ @if (likeError(); as lerr) { + {{ lerr }} + } } @if (isLoggedIn()) { @@ -277,7 +280,12 @@ } } @if (downloadError() && !formatsLoading()) { - + } } diff --git a/src/components/watch/watch.component.ts b/src/components/watch/watch.component.ts index 0a32802..ead5a14 100644 --- a/src/components/watch/watch.component.ts +++ b/src/components/watch/watch.component.ts @@ -20,6 +20,7 @@ import { SubscribeButtonComponent } from '../../app/shared/components/subscribe- import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser'; import { IframeProgressService } from '../../services/iframe-progress.service'; 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 { ChannelIdentityComponent } from '../../app/shared/components/channel-identity/channel-identity.component'; import { DurationPipe } from '../../app/shared/pipes/duration.pipe'; @@ -289,6 +290,7 @@ export class WatchComponent implements OnDestroy, AfterViewInit { // --- Like state --- liked = signal(false); likeBusy = signal(false); + likeError = signal(null); // --- Transcript state (Phase 2: display + search + click-to-seek + auto-scroll) --- transcriptOpen = signal(false); @@ -547,30 +549,43 @@ export class WatchComponent implements OnDestroy, AfterViewInit { // Toggle like status for the current video toggleLike(): void { if (!this.isLoggedIn()) return; - + const provider = this.provider(); const videoId = this.videoId(); if (!provider || !videoId || this.likeBusy()) return; - + this.likeBusy.set(true); - - const handleError = () => { + this.likeError.set(null); + + const handleError = (err: unknown) => { 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()) { this.likes.unlike(provider, videoId).subscribe({ next: () => { this.liked.set(false); this.likeBusy.set(false); + this.likeError.set(null); }, error: handleError }); } else { - this.likes.like(provider, videoId).subscribe({ + this.likes.like(provider, videoId, title, thumbnail).subscribe({ next: () => { this.liked.set(true); this.likeBusy.set(false); + this.likeError.set(null); }, error: handleError }); @@ -634,6 +649,7 @@ export class WatchComponent implements OnDestroy, AfterViewInit { // Reset like state while loading this.liked.set(false); this.likeBusy.set(false); + this.likeError.set(null); const provider = this.provider(); if (provider === 'rumble') { // Validate and enrich Rumble video via backend scraping (may also normalize id) @@ -1261,11 +1277,20 @@ export class WatchComponent implements OnDestroy, AfterViewInit { // --- Download logic --- 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.downloadError.set(null); this.loadFormats(); } + /** Nouveau essai manuel depuis le panneau (bouton « Réessayer »). */ + retryFormats(): void { + if (this.formatsLoading()) return; + this.downloadError.set(null); + this.loadFormats(); + } + closeDownloadPanel() { this.downloadOpen.set(false); } @@ -1303,8 +1328,9 @@ export class WatchComponent implements OnDestroy, AfterViewInit { loadFormats() { const p = this.provider(); const id = this.videoId(); - if (!p || !id) return; + if (!p || !id || this.formatsLoading()) return; this.formatsLoading.set(true); + this.downloadError.set(null); this.downloads.listFormats(p, id, this.buildProviderOpts()).subscribe({ next: (res) => { 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)); this.downloadFormats.set(list); this.formatsLoading.set(false); + if (!list.length) this.downloadError.set('Aucun format disponible pour cette vidéo.'); }, error: (err) => { - const status = err?.status || 0; - if (status === 403) this.downloadError.set('Le téléchargement est désactivé pour ce provider.'); - else { - 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.'); - } + // Message FR propre (jamais le brut « Http failure response... »), + // avec compte à rebours sur les 429 + bouton Réessayer du panneau. + this.downloadError.set(frenchDownloadError(err)); this.formatsLoading.set(false); } }); @@ -1360,6 +1384,7 @@ export class WatchComponent implements OnDestroy, AfterViewInit { private pollJob(jobId: string) { 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.downloads.getJob(jobId).subscribe({ 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é.'); } }, - error: () => { - // Stop polling on error - clearInterval(this.jobPoller); - this.jobPoller = null; - this.downloadError.set('Erreur de suivi du téléchargement.'); + error: (err) => { + // 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); + this.jobPoller = null; + this.downloadError.set(frenchDownloadError(err)); + } } }); - }, 2000); + }, 4000); } cancelDownload() { diff --git a/src/interceptors/auth.interceptor.ts b/src/interceptors/auth.interceptor.ts index 6d3d0b1..a68adeb 100644 --- a/src/interceptors/auth.interceptor.ts +++ b/src/interceptors/auth.interceptor.ts @@ -33,11 +33,9 @@ export const authInterceptor: HttpInterceptorFn = (req, next) => { if (isRefreshCall) { return throwError(() => err); } - // If there is no access token at all, we are not logged in: do not try refresh - const hasToken = !!auth.getAccessToken(); - if (!hasToken) { - return throwError(() => err); - } + // Même sans token en mémoire (ex. juste après F5), une session par + // cookies httpOnly peut exister : on tente un refresh via cookies + // avant d'abandonner. refresh() n'échoue jamais bruyamment (false). // Try a single refresh then retry the request once return auth.refresh().pipe( switchMap((ok) => { @@ -47,7 +45,7 @@ export const authInterceptor: HttpInterceptorFn = (req, next) => { if (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 }); } return next(retry); diff --git a/src/services/download.service.ts b/src/services/download.service.ts index e64b7e1..826d015 100644 --- a/src/services/download.service.ts +++ b/src/services/download.service.ts @@ -1,6 +1,6 @@ import { Injectable } from '@angular/core'; -import { HttpClient, HttpParams, HttpResponse } from '@angular/common/http'; -import { Observable, map } from 'rxjs'; +import { HttpClient, HttpErrorResponse, HttpParams, HttpResponse } from '@angular/common/http'; +import { Observable, map, retry, timer, throwError } from 'rxjs'; export interface DownloadFormat { id: string; @@ -61,6 +61,19 @@ export class DownloadService { return this.http.get<{ url: string; formats: DownloadFormat[]; title: string; duration: number }>( `/proxy/api/download/${encodeURIComponent(provider)}/${encodeURIComponent(videoId)}/formats`, { 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); + }, + }) ); } diff --git a/src/services/user.service.ts b/src/services/user.service.ts index 277e67d..781654e 100644 --- a/src/services/user.service.ts +++ b/src/services/user.service.ts @@ -24,19 +24,31 @@ export class UserService { 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 { - return this.http.get('/proxy/api/user/preferences', { withCredentials: true }).pipe( + return this.http.get(`${this.apiBase()}/user/preferences`, { withCredentials: true }).pipe( tap(p => this._preferences.set(p || {})) ); } updatePreferences(patch: Partial): Observable { - return this.http.patch('/proxy/api/user/preferences', patch, { withCredentials: true }).pipe( + return this.http.patch(`${this.apiBase()}/user/preferences`, patch, { withCredentials: true }).pipe( tap(p => this._preferences.set(p || {})) ); } loadMe(): Observable { - return this.http.get('/proxy/api/user/me', { withCredentials: true }); + return this.http.get(`${this.apiBase()}/user/me`, { withCredentials: true }); } } diff --git a/src/utils/http-error.util.ts b/src/utils/http-error.util.ts new file mode 100644 index 0000000..f9281bc --- /dev/null +++ b/src/utils/http-error.util.ts @@ -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.'; +}