feat(oauth): import favoris/abos Google (YouTube) et Twitch vers abonnements/likes

- Backend: server/oauth.mjs (auth URL, echange code, refresh, fetch
  subscriptions/likes Google, follows Twitch), table oauth_connections
  + migration, routes /api/oauth/:provider/{url,callback,preview,import},
  /api/oauth/{status,connections}
- Front: /library/import (connect, preview, import), service
  oauth-import, lien depuis Abonnements
- Config: GOOGLE_CLIENT_ID/SECRET/REDIRECT_URI, TWITCH_REDIRECT_URI,
  OAUTH_APP_BASE_URL documentes (.env.example, docker-compose),
  passthrough docker-compose.local.yml, roadmap README cochee
This commit is contained in:
2026-09-26 18:47:02 -04:00
parent 21f2296c41
commit df53445efe
13 changed files with 971 additions and 1 deletions
+5
View File
@@ -64,6 +64,11 @@ export const APP_ROUTES: Routes = [
loadComponent: () => import('./components/library/subscriptions/subscriptions.component').then(m => m.SubscriptionsComponent),
title: 'NewTube - Subscriptions'
},
{
path: 'library/import',
loadComponent: () => import('./components/library/import/import.component').then(m => m.ImportComponent),
title: 'NewTube - Import'
},
{
path: 'library/downloads/:id',
loadComponent: () => import('./components/library/downloads/download-play.component').then(m => m.DownloadPlayComponent),
@@ -0,0 +1,78 @@
<div class="container mx-auto p-4 sm:p-6">
<div class="mb-6 flex flex-col gap-2">
<div class="flex items-center gap-3">
<a [routerLink]="['/library/subscriptions']" class="rounded-lg border border-slate-700 bg-slate-800 px-3 py-1.5 text-slate-200 transition hover:bg-slate-700">‹</a>
<h2 class="text-3xl font-bold text-slate-100">Importer depuis Google / Twitch</h2>
</div>
<p class="text-sm text-slate-400">Connectez votre compte, prévisualisez, puis importez vos abonnements et favoris dans NewTube.</p>
<div class="flex gap-2 text-sm">
<a [routerLink]="['/library/subscriptions']" class="text-red-300 hover:text-red-200">Voir mes abonnements</a>
<span class="text-slate-600">•</span>
<a [routerLink]="['/library/liked']" class="text-red-300 hover:text-red-200">Voir mes favoris</a>
</div>
</div>
<div *ngIf="notice()" class="mb-4 rounded-lg border border-blue-700 bg-blue-900/30 p-3 text-sm text-blue-200">{{ notice() }}</div>
<div *ngIf="loading()" class="text-sm text-slate-400">Chargement…</div>
<div *ngIf="!loading()" class="grid gap-4 md:grid-cols-2">
<div *ngFor="let p of providers" class="rounded-xl border border-slate-800 bg-slate-900/60 p-4">
<div class="mb-2 flex items-center justify-between gap-2">
<h3 class="text-lg font-semibold text-slate-100"><span class="mr-2">{{ p.emoji }}</span>{{ p.label }}</h3>
<span *ngIf="isConnected(p.id)" class="rounded-full bg-green-900/40 border border-green-700 px-2 py-0.5 text-xs text-green-200">connecté</span>
<span *ngIf="!isConnected(p.id)" class="rounded-full bg-slate-800 border border-slate-700 px-2 py-0.5 text-xs text-slate-300">non connecté</span>
</div>
<p class="mb-3 text-sm text-slate-400">{{ p.hint }}</p>
<div *ngIf="!configured()[p.id]" class="mb-3 rounded-lg border border-amber-700 bg-amber-900/20 p-2 text-xs text-amber-200">
Non configuré côté serveur. Ajoutez {{ (missing()[p.id] || []).join(', ') || 'les variables manquantes' }} puis redémarrez l’API.
<span *ngIf="p.id === 'google'">Voir <code>GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET / GOOGLE_REDIRECT_URI</code> dans <code>.env.example</code>.</span>
</div>
<div *ngIf="isConnected(p.id) && connectionOf(p.id) as c" class="mb-3 flex items-center gap-2 text-sm text-slate-300">
<img *ngIf="c.avatarUrl" [src]="c.avatarUrl" alt="" class="h-8 w-8 rounded-full object-cover" />
<span class="font-medium">{{ c.displayName || c.externalUserId }}</span>
</div>
<div class="mb-3 flex flex-wrap gap-2">
<button *ngIf="!isConnected(p.id)" type="button" [disabled]="!configured()[p.id]"
(click)="connect(p.id)"
class="rounded-md bg-red-600 px-3 py-1.5 text-sm font-semibold text-white transition hover:bg-red-500 disabled:opacity-40">
Connecter {{ p.label }}
</button>
<button *ngIf="isConnected(p.id)" type="button" (click)="preview(p.id)" [disabled]="loadingPreview()[p.id]"
class="rounded-md border border-slate-700 bg-slate-800 px-3 py-1.5 text-sm text-slate-200 transition hover:border-slate-500">
{{ loadingPreview()[p.id] ? 'Aperçu…' : 'Prévisualiser' }}
</button>
<button *ngIf="isConnected(p.id)" type="button" (click)="importAll(p.id)" [disabled]="importing()[p.id]"
class="rounded-md bg-green-700 px-3 py-1.5 text-sm font-semibold text-white transition hover:bg-green-600 disabled:opacity-40">
{{ importing()[p.id] ? 'Import…' : 'Importer' }}
</button>
<button *ngIf="isConnected(p.id)" type="button" (click)="disconnect(p.id)"
class="rounded-md border border-slate-700 bg-transparent px-3 py-1.5 text-sm text-slate-400 transition hover:text-white">
Déconnecter
</button>
</div>
<div *ngIf="errors()[p.id]" class="mb-2 rounded-lg border border-red-700 bg-red-900/30 p-2 text-xs text-red-200">{{ errors()[p.id] }}</div>
<div *ngIf="previews()[p.id] as pv" class="mb-2 text-sm">
<p class="text-slate-300">Abonnements : <strong>{{ pv.subscriptionCount }}</strong><span *ngIf="p.id === 'google'"> • Favoris : <strong>{{ pv.likeCount }}</strong></span></p>
<ul class="mt-2 max-h-48 space-y-1 overflow-auto text-xs text-slate-400">
<li *ngFor="let s of pv.subscriptions.slice(0, 20)" class="flex items-center gap-2">
<img *ngIf="s.avatarUrl" [src]="s.avatarUrl" alt="" class="h-5 w-5 rounded-full object-cover" />
<span class="truncate">{{ s.title }}</span>
</li>
</ul>
<ul *ngIf="pv.likes?.length" class="mt-2 max-h-32 space-y-1 overflow-auto text-xs text-slate-400">
<li *ngFor="let l of pv.likes.slice(0, 10)" class="truncate">♥ {{ l.title }}</li>
</ul>
</div>
<div *ngIf="results()[p.id] as r" class="rounded-lg border border-green-700 bg-green-900/20 p-2 text-xs text-green-200">
Importé : {{ r.importedSubscriptions }} abonnement(s)<span *ngIf="p.id === 'google'">, {{ r.importedLikes }} favori(s)</span>.
<span *ngIf="r.skippedSubscriptions"> (ignorés : {{ r.skippedSubscriptions }})</span>
</div>
</div>
</div>
</div>
@@ -0,0 +1,116 @@
import { ChangeDetectionStrategy, Component, OnInit, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { OAuthImportService, type OAuthConnection, type OAuthPreview, type OAuthImportResult } from '../../../services/oauth-import.service';
interface ProviderCard {
id: 'google' | 'twitch';
label: string;
emoji: string;
hint: string;
}
@Component({
selector: 'app-library-import',
standalone: true,
imports: [CommonModule, RouterLink],
templateUrl: './import.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ImportComponent implements OnInit {
private api = inject(OAuthImportService);
private route = inject(ActivatedRoute);
readonly providers: ProviderCard[] = [
{ id: 'google', label: 'Google / YouTube', emoji: '🔴', hint: 'Abonnements + favoris (likes YouTube)' },
{ id: 'twitch', label: 'Twitch', emoji: '🟣', hint: 'Chaînes suivies (pas de likes côté Twitch)' },
];
configured = signal<Record<string, boolean>>({ google: false, twitch: false });
missing = signal<Record<string, string[]>>({ google: [], twitch: [] });
connections = signal<OAuthConnection[]>([]);
previews = signal<Record<string, OAuthPreview | null>>({});
loadingPreview = signal<Record<string, boolean>>({});
importing = signal<Record<string, boolean>>({});
results = signal<Record<string, OAuthImportResult | null>>({});
errors = signal<Record<string, string | null>>({});
notice = signal<string | null>(null);
loading = signal(true);
ngOnInit(): void {
this.route.queryParamMap.subscribe((pm) => {
const connected = pm.get('connected');
const provider = pm.get('provider');
const error = pm.get('error');
if (connected && provider) this.notice.set(`Connecté à ${provider} — prévisualisez puis importez.`);
if (error) this.notice.set(`Échec OAuth (${provider || ''}) : ${error}`);
});
this.refresh();
}
refresh(): void {
this.loading.set(true);
this.api.connections().subscribe({
next: (res) => {
this.connections.set(res?.connections || []);
const st = res?.status;
if (st) {
this.configured.set({ google: !!st.google?.configured, twitch: !!st.twitch?.configured });
this.missing.set({ google: st.google?.missing || [], twitch: st.twitch?.missing || [] });
}
this.loading.set(false);
},
error: () => this.loading.set(false),
});
}
isConnected(id: string): boolean {
return this.connections().some((c) => c.provider === id);
}
connectionOf(id: string): OAuthConnection | undefined {
return this.connections().find((c) => c.provider === id);
}
connect(id: 'google' | 'twitch'): void {
this.errors.set({ ...this.errors(), [id]: null });
this.api.connect(id).catch((e) => {
this.errors.set({ ...this.errors(), [id]: e?.error?.error || e?.message || 'oauth_url_failed' });
});
}
disconnect(id: string): void {
this.api.disconnect(id).subscribe({ next: () => this.refresh() });
}
preview(id: string): void {
this.loadingPreview.set({ ...this.loadingPreview(), [id]: true });
this.errors.set({ ...this.errors(), [id]: null });
this.api.preview(id).subscribe({
next: (p) => {
this.previews.set({ ...this.previews(), [id]: p });
this.loadingPreview.set({ ...this.loadingPreview(), [id]: false });
},
error: (e) => {
this.errors.set({ ...this.errors(), [id]: e?.error?.error || e?.message || 'oauth_preview_failed' });
this.loadingPreview.set({ ...this.loadingPreview(), [id]: false });
},
});
}
importAll(id: string): void {
const types = id === 'twitch' ? ['subscriptions'] : ['subscriptions', 'likes'];
this.importing.set({ ...this.importing(), [id]: true });
this.errors.set({ ...this.errors(), [id]: null });
this.api.importAll(id, types).subscribe({
next: (r) => {
this.results.set({ ...this.results(), [id]: r });
this.importing.set({ ...this.importing(), [id]: false });
},
error: (e) => {
this.errors.set({ ...this.errors(), [id]: e?.error?.error || e?.message || 'oauth_import_failed' });
this.importing.set({ ...this.importing(), [id]: false });
},
});
}
}
@@ -19,6 +19,12 @@
>
Rafraîchir
</button>
<a
[routerLink]="['/library/import']"
class="rounded-md border border-slate-700 bg-slate-800 px-3 py-1.5 text-sm text-slate-200 transition hover:border-slate-500 hover:text-white"
>
⬇ Importer (Google / Twitch)
</a>
</div>
</div>
+62
View File
@@ -0,0 +1,62 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
export interface OAuthConnection {
provider: string;
externalUserId: string | null;
displayName: string | null;
avatarUrl: string | null;
scopes: string | null;
expiresAt: number | null;
connectedAt: number | null;
updatedAt: number | null;
}
export interface OAuthPreview {
provider: string;
subscriptions: Array<{ provider: string; externalId: string; title: string; handle?: string | null; avatarUrl?: string | null; url?: string | null }>;
likes: Array<{ provider: string; videoId: string; title: string; thumbnail?: string }>;
subscriptionCount: number;
likeCount: number;
}
export interface OAuthImportResult {
provider: string;
importedSubscriptions: number;
importedLikes: number;
skippedSubscriptions: number;
skippedLikes: number;
}
@Injectable({ providedIn: 'root' })
export class OAuthImportService {
private http = inject(HttpClient);
status(): Observable<{ google: { configured: boolean; missing: string[] }; twitch: { configured: boolean; missing: string[] } }> {
return this.http.get<any>('/api/oauth/status');
}
connections(): Observable<{ connections: OAuthConnection[]; status: any }> {
return this.http.get<any>('/api/oauth/connections');
}
async connect(provider: 'google' | 'twitch'): Promise<void> {
const res: any = await this.http.get(`/api/oauth/${provider}/url`).toPromise().catch((e) => { throw e; });
const url = res?.url;
if (!url) throw new Error('oauth_url_failed');
window.location.href = url;
}
disconnect(provider: string): Observable<void> {
return this.http.delete<void>(`/api/oauth/${provider}`);
}
preview(provider: string): Observable<OAuthPreview> {
return this.http.get<OAuthPreview>(`/api/oauth/${provider}/preview`);
}
importAll(provider: string, types: string[]): Observable<OAuthImportResult> {
return this.http.post<OAuthImportResult>(`/api/oauth/${provider}/import`, { types });
}
}