128 lines
5.2 KiB
TypeScript
128 lines
5.2 KiB
TypeScript
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;
|
|
ytChannelId?: string | null;
|
|
ytPageId?: string | null;
|
|
}
|
|
|
|
export interface YtChannel {
|
|
channelId: string | null;
|
|
title: string;
|
|
handle: string | null;
|
|
selected: boolean;
|
|
}
|
|
|
|
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;
|
|
/** Totaux du compte (l'aperçu n'affiche que les premiers, l'import prend tout). */
|
|
subscriptionTotal?: number;
|
|
likeTotal?: 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 });
|
|
}
|
|
|
|
/** Contenu "Regarder plus tard" YouTube (lecture seule OK). */
|
|
watchLater(): Observable<{ items: Array<{ videoId: string; title: string; thumbnail?: string }>; count: number; writeGranted: boolean; via?: string; note?: string }> {
|
|
return this.http.get<any>(`/api/oauth/google/watchlater`);
|
|
}
|
|
|
|
/** Pousse des vidéos vers "Regarder plus tard" YouTube (scope écriture requis). */
|
|
pushWatchLater(videoIds: string[]): Observable<{ added: number; skipped: number; total: number }> {
|
|
return this.http.post<any>(`/api/oauth/google/watchlater`, { videoIds });
|
|
}
|
|
|
|
/** Lot d'entrées d'historique Takeout (max 1000/lot côté serveur). */
|
|
importTakeoutBatch(items: Array<{ videoId: string; title?: string; watchedAt?: string }>): Observable<{ imported: number; skipped: number; total: number }> {
|
|
return this.http.post<any>(`/api/user/history/takeout`, { items });
|
|
}
|
|
|
|
/** Historique YouTube via InnerTube authentifié (sync auto, sans fichier). */
|
|
ytHistory(max = 200): Observable<{ items: Array<{ videoId: string; title: string; thumbnail?: string; watchedAt?: string }>; count: number; hasMore: boolean }> {
|
|
return this.http.get<any>(`/api/oauth/google/yt-history?max=${max}`);
|
|
}
|
|
|
|
/** Chaînes YouTube du compte (sélecteur multi-chaînes / marque). */
|
|
ytChannels(): Observable<{ channels: YtChannel[]; selected: { channelId: string | null; pageId: string | null } }> {
|
|
return this.http.get<any>(`/api/oauth/google/yt-channels`);
|
|
}
|
|
|
|
/** Mémorise la chaîne à cibler pour l'historique et Regarder plus tard. */
|
|
saveYtChannel(channelId: string | null, pageId: string | null): Observable<{ selected: { channelId: string | null; pageId: string | null } }> {
|
|
return this.http.put<any>(`/api/oauth/google/yt-channel`, { channelId, pageId });
|
|
}
|
|
}
|
|
|
|
/** Parse local du `watch-history.json` de Google Takeout (confidentiel : rien n'est uploadé tel quel). */
|
|
export function parseTakeoutHistory(json: unknown): Array<{ videoId: string; title: string; watchedAt: string }> {
|
|
const arr = Array.isArray(json) ? json : [];
|
|
const byId = new Map<string, { videoId: string; title: string; watchedAt: string }>();
|
|
for (const e of arr) {
|
|
const url = String((e as any)?.titleUrl || '');
|
|
const m = /(?:[?&]v=|youtu\.be\/|\/(?:shorts|live|embed)\/)([A-Za-z0-9_-]{11})/.exec(url);
|
|
if (!m) continue;
|
|
const videoId = m[1];
|
|
const rawTitle = String((e as any)?.title || '').replace(/^Watched\s+/i, '').trim();
|
|
let watchedAt = new Date().toISOString();
|
|
try {
|
|
const d = new Date(String((e as any)?.time || ''));
|
|
if (!Number.isNaN(d.getTime())) watchedAt = d.toISOString();
|
|
} catch {}
|
|
const prev = byId.get(videoId);
|
|
if (!prev || watchedAt > prev.watchedAt) {
|
|
byId.set(videoId, { videoId, title: (rawTitle || videoId).slice(0, 300), watchedAt });
|
|
}
|
|
}
|
|
return [...byId.values()].sort((a, b) => (a.watchedAt < b.watchedAt ? 1 : -1));
|
|
}
|