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:
@@ -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<HTMLInputElement>;
|
||||
@ViewChild('quickDialog', { static: false }) quickDialogRef?: ElementRef<HTMLDivElement>;
|
||||
|
||||
@@ -66,6 +76,29 @@ export class SearchBoxComponent {
|
||||
quickOpen = signal(false);
|
||||
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)
|
||||
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<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
|
||||
@@ -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<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) {
|
||||
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<string>();
|
||||
// Internal subject for debounced typeahead fetches (Step 15)
|
||||
private _suggestReq = new Subject<string>();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user