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:
@@ -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>
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user