From df53445efe5bcde0bbb6fed4299bbaeb9e8a17d7 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Sat, 26 Sep 2026 18:47:02 -0400 Subject: [PATCH] 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 --- .env.example | 18 + README.md | 2 +- .../20260927_add_oauth_connections.sql | 16 + docker-compose/.env.example | 9 + docker-compose/docker-compose.local.yml | 6 + server/db.mjs | 99 +++++ server/index.mjs | 196 ++++++++++ server/oauth.mjs | 359 ++++++++++++++++++ src/app.routes.ts | 5 + .../library/import/import.component.html | 78 ++++ .../library/import/import.component.ts | 116 ++++++ .../subscriptions.component.html | 6 + src/services/oauth-import.service.ts | 62 +++ 13 files changed, 971 insertions(+), 1 deletion(-) create mode 100644 db/migrations/20260927_add_oauth_connections.sql create mode 100644 server/oauth.mjs create mode 100644 src/components/library/import/import.component.html create mode 100644 src/components/library/import/import.component.ts create mode 100644 src/services/oauth-import.service.ts diff --git a/.env.example b/.env.example index 1992ca9..75d6b41 100644 --- a/.env.example +++ b/.env.example @@ -32,6 +32,24 @@ YOUTUBE_API_KEY=your_youtube_api_key_here # Twitch Client ID (optionnel, pour une recherche Twitch plus complète) # Obtenez un Client ID ici : https://dev.twitch.tv/console/apps TWITCH_CLIENT_ID=your_twitch_client_id_here +# Twitch OAuth (import des chaînes suivies) : créez une app sur https://dev.twitch.tv/console/apps, +# ajoutez en redirect URI : http://localhost:4200/api/oauth/twitch/callback (et l'URL prod). +# TWITCH_CLIENT_SECRET n'est jamais exposé au front (échange côté serveur). +TWITCH_CLIENT_SECRET=your_twitch_client_secret_here +# TWITCH_REDIRECT_URI=http://localhost:4200/api/oauth/twitch/callback + +# Google OAuth (import abonnements + favoris YouTube) : créez un client OAuth +# sur https://console.cloud.google.com/apis/credentials (type "Web application"), +# ajoutez en redirect URI : http://localhost:4200/api/oauth/google/callback +# puis renseignez les deux variables ci-dessous. Scopes demandés : openid, +# userinfo.profile, youtube.readonly (lecture seule, aucun post). +# GOOGLE_CLIENT_ID=your_google_client_id_here +# GOOGLE_CLIENT_SECRET=your_google_client_secret_here +# GOOGLE_REDIRECT_URI=http://localhost:4200/api/oauth/google/callback + +# Base publique de l'app pour les callbacks OAuth (utile en Docker : l'API voit +# :4000 mais l'UI est sur :4200 côté hôte). Ex. http://localhost:4200 +# OAUTH_APP_BASE_URL=http://localhost:4200 # Providers autorisés au téléchargement (liste CSV). # Défaut serveur : youtube,dailymotion,twitch,peertube,odysee,rumble diff --git a/README.md b/README.md index 564e59b..ea6d9aa 100644 --- a/README.md +++ b/README.md @@ -256,7 +256,7 @@ Ajoutez au besoin `log-driver`, `log-opts`, `default-address-pools`, etc. * ⏳ **Chromecast / AirPlay** * ⏳ Mode “TV” * ⏳ **Observabilité** : healthcheck `/healthz`, métriques, page **Admin** (clés OK/KO, logs, versions) -* ⏳ **OAuth** (Google/Twitch) pour import favoris/abonnements +* ✅ **OAuth** (Google/Twitch) pour import favoris/abonnements — `/library/import` : abonnements + likes YouTube (lecture seule), follows Twitch → abonnements, preview + import, `GET /api/oauth/status|connections|preview`, `POST /api/oauth/:provider/import` * ⏳ **Cache serveur** configurable (TTL par provider) * ⏳ **Qualité vidéo** : sélecteur et “auto” intelligent * ⏳ **Traduction UI** (i18n) élargie diff --git a/db/migrations/20260927_add_oauth_connections.sql b/db/migrations/20260927_add_oauth_connections.sql new file mode 100644 index 0000000..3f877ce --- /dev/null +++ b/db/migrations/20260927_add_oauth_connections.sql @@ -0,0 +1,16 @@ +-- Connexions OAuth (import favoris / abonnements Google + Twitch). +CREATE TABLE IF NOT EXISTS oauth_connections ( + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + provider TEXT NOT NULL, + external_user_id TEXT, + display_name TEXT, + avatar_url TEXT, + access_token TEXT, + refresh_token TEXT, + expires_at INTEGER, + scopes TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + PRIMARY KEY (user_id, provider) +); +CREATE INDEX IF NOT EXISTS idx_oauth_connections_user ON oauth_connections(user_id); diff --git a/docker-compose/.env.example b/docker-compose/.env.example index 758d5a2..1445705 100644 --- a/docker-compose/.env.example +++ b/docker-compose/.env.example @@ -11,6 +11,15 @@ YOUTUBE_API_KEYS=votre_cle_youtube_ici,deuxieme_cle_youtube_ici VIMEO_ACCESS_TOKEN=votre_token_vimeo_ici TWITCH_CLIENT_ID=votre_client_id_twitch_ici TWITCH_CLIENT_SECRET=votre_client_secret_twitch_ici +# TWITCH_REDIRECT_URI=http://localhost:4200/api/oauth/twitch/callback + +# Google OAuth (import abonnements + favoris YouTube, lecture seule) +# GOOGLE_CLIENT_ID=votre_client_id_google_ici +# GOOGLE_CLIENT_SECRET=votre_client_secret_google_ici +# GOOGLE_REDIRECT_URI=http://localhost:4200/api/oauth/google/callback + +# Base publique pour les callbacks OAuth en Docker (UI hôte :4200, API :4000) +# OAUTH_APP_BASE_URL=http://localhost:4200 # Configuration du cache (en millisecondes) YT_CACHE_TTL_MS=3600000 # 1 heure par défaut diff --git a/docker-compose/docker-compose.local.yml b/docker-compose/docker-compose.local.yml index 3186471..659d404 100644 --- a/docker-compose/docker-compose.local.yml +++ b/docker-compose/docker-compose.local.yml @@ -25,6 +25,12 @@ services: - VIMEO_ACCESS_TOKEN=${VIMEO_ACCESS_TOKEN} - TWITCH_CLIENT_ID=${TWITCH_CLIENT_ID} - TWITCH_CLIENT_SECRET=${TWITCH_CLIENT_SECRET} + - TWITCH_REDIRECT_URI=${TWITCH_REDIRECT_URI} + - GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID} + - GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET} + - GOOGLE_REDIRECT_URI=${GOOGLE_REDIRECT_URI} + - OAUTH_APP_BASE_URL=${OAUTH_APP_BASE_URL:-http://localhost:4200} + - HOST_PORT=${HOST_PORT:-4200} - DOWNLOAD_PROVIDERS=${DOWNLOAD_PROVIDERS:-youtube,dailymotion,twitch,peertube,odysee,rumble} - DOWNLOAD_MAX_CONCURRENT=${DOWNLOAD_MAX_CONCURRENT:-2} # --- YouTube hybride (Steps 17-18) : innertube > scrape yt-dlp > API --- diff --git a/server/db.mjs b/server/db.mjs index 9397702..af12b02 100644 --- a/server/db.mjs +++ b/server/db.mjs @@ -1361,3 +1361,102 @@ export function getYoutubeMetricsToday() { export function countYoutubeCacheRows() { try { return db.prepare(`SELECT COUNT(1) AS n FROM youtube_search_cache`).get()?.n || 0; } catch { return 0; } } + +// -------------------- OAuth : connexions Google / Twitch (import favoris/abos) -------------------- +function ensureOAuthTables() { + try { + db.exec(`CREATE TABLE IF NOT EXISTS oauth_connections ( + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + provider TEXT NOT NULL, + external_user_id TEXT, + display_name TEXT, + avatar_url TEXT, + access_token TEXT, + refresh_token TEXT, + expires_at INTEGER, + scopes TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + PRIMARY KEY (user_id, provider) + );`); + db.exec(`CREATE INDEX IF NOT EXISTS idx_oauth_connections_user ON oauth_connections(user_id);`); + } catch {} +} +ensureOAuthTables(); + +function oauthRowToDto(row, withTokens = false) { + if (!row) return null; + const dto = { + provider: row.provider, + externalUserId: row.external_user_id || null, + displayName: row.display_name || null, + avatarUrl: row.avatar_url || null, + scopes: row.scopes || null, + expiresAt: row.expires_at ?? null, + connectedAt: row.created_at ?? null, + updatedAt: row.updated_at ?? null, + }; + if (withTokens) { + dto.accessToken = row.access_token || null; + dto.refreshToken = row.refresh_token || null; + } + return dto; +} + +export function upsertOAuthConnection({ userId, provider, externalUserId, displayName, avatarUrl, accessToken, refreshToken, expiresAt, scopes }) { + ensureOAuthTables(); + const now = Date.now(); + const existing = db.prepare(`SELECT * FROM oauth_connections WHERE user_id = ? AND provider = ?`).get(userId, provider); + if (existing) { + db.prepare(`UPDATE oauth_connections SET external_user_id = ?, display_name = ?, avatar_url = ?, + access_token = COALESCE(?, access_token), refresh_token = COALESCE(?, refresh_token), + expires_at = COALESCE(?, expires_at), scopes = COALESCE(?, scopes), updated_at = ? + WHERE user_id = ? AND provider = ?`) + .run( + externalUserId ?? existing.external_user_id, displayName ?? existing.display_name, + avatarUrl ?? existing.avatar_url, accessToken ?? null, refreshToken ?? null, + expiresAt ?? null, scopes ?? null, now, userId, provider, + ); + } else { + db.prepare(`INSERT INTO oauth_connections (user_id, provider, external_user_id, display_name, avatar_url, + access_token, refresh_token, expires_at, scopes, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`) + .run(userId, provider, externalUserId || null, displayName || null, avatarUrl || null, + accessToken || null, refreshToken || null, expiresAt ?? null, scopes || null, now, now); + } + return oauthRowToDto(db.prepare(`SELECT * FROM oauth_connections WHERE user_id = ? AND provider = ?`).get(userId, provider)); +} + +export function listOAuthConnections(userId) { + try { + ensureOAuthTables(); + return db.prepare(`SELECT * FROM oauth_connections WHERE user_id = ?`).all(userId).map((r) => oauthRowToDto(r)); + } catch { return []; } +} + +export function getOAuthConnection(userId, provider, withTokens = false) { + try { + ensureOAuthTables(); + return oauthRowToDto( + db.prepare(`SELECT * FROM oauth_connections WHERE user_id = ? AND provider = ?`).get(userId, provider), + withTokens, + ); + } catch { return null; } +} + +export function updateOAuthTokens(userId, provider, { accessToken, refreshToken, expiresAt }) { + try { + ensureOAuthTables(); + db.prepare(`UPDATE oauth_connections SET access_token = COALESCE(?, access_token), + refresh_token = COALESCE(?, refresh_token), expires_at = COALESCE(?, expires_at), updated_at = ? + WHERE user_id = ? AND provider = ?`) + .run(accessToken ?? null, refreshToken ?? null, expiresAt ?? null, Date.now(), userId, provider); + } catch {} +} + +export function deleteOAuthConnection(userId, provider) { + try { + ensureOAuthTables(); + return db.prepare(`DELETE FROM oauth_connections WHERE user_id = ? AND provider = ?`).run(userId, provider); + } catch { return { changes: 0 }; } +} diff --git a/server/index.mjs b/server/index.mjs index ad59447..176e66d 100644 --- a/server/index.mjs +++ b/server/index.mjs @@ -75,6 +75,12 @@ import { setSubscriptionGroupMembers, setSubscriptionGroups, listSubscriptionGroupMembersByUser, + upsertOAuthConnection, + listOAuthConnections, + getOAuthConnection, + updateOAuthTokens, + deleteOAuthConnection, + upsertChannelRow, insertDownloadJob, getDownloadJob, listDownloadJobs, @@ -93,6 +99,11 @@ import { } from './db.mjs'; import { getChannelAdapter, setTwitchTokenProvider } from './providers/channel-registry.mjs'; import { fetchChannelContent } from './providers/channel-content.mjs'; +import { + oauthStatus, buildAuthUrl, createOAuthState, consumeOAuthState, exchangeCode, + refreshAccessToken, redirectUriFor, fetchGoogleProfile, fetchGoogleSubscriptions, + fetchGoogleLiked, fetchTwitchProfile, fetchTwitchFollows, +} from './oauth.mjs'; import { getSearchMode, getYtDlpBin, hasCookiesFile, metricsSnapshot } from './providers/youtube-common.mjs'; import { ytScrapeCacheStats } from './providers/youtube.mjs'; @@ -438,6 +449,191 @@ r.put('/subscriptions/:subscriptionId/groups', authMiddlewareCookieAware, subscr } }); +// -------------------- OAuth Google / Twitch (import favoris + abonnements) -------------------- + +const oauthLimiter = rateLimit({ windowMs: 60 * 1000, max: 30, standardHeaders: true, legacyHeaders: false }); + +function requireOAuthProvider(value) { + const p = String(value || '').toLowerCase(); + if (p !== 'google' && p !== 'twitch') throw Object.assign(new Error('invalid_oauth_provider'), { status: 400 }); + return p; +} + +/** Token frais (refresh si expiré dans < 60 s et refresh_token dispo). */ +async function freshOAuthToken(userId, provider) { + const conn = getOAuthConnection(userId, provider, true); + if (!conn?.accessToken) throw Object.assign(new Error('oauth_not_connected'), { status: 404 }); + const expired = typeof conn.expiresAt === 'number' && conn.expiresAt - Date.now() < 60_000; + if (!expired || !conn.refreshToken) return conn; + const refreshed = await refreshAccessToken(provider, conn.refreshToken); + updateOAuthTokens(userId, provider, refreshed); + return { ...conn, accessToken: refreshed.accessToken, refreshToken: refreshed.refreshToken, expiresAt: refreshed.expiresAt }; +} + +// Public : l'UI affiche "Connecter" seulement si configuré côté serveur. +r.get('/oauth/status', (req, res) => res.json(oauthStatus())); + +// URL d'autorisation (connecté requis : le state mémorise l'utilisateur). +r.get('/oauth/:provider/url', authMiddlewareCookieAware, oauthLimiter, (req, res) => { + try { + const provider = requireOAuthProvider(req.params.provider); + const status = oauthStatus()[provider]; + if (!status?.configured) return res.status(503).json({ error: `${provider}_oauth_not_configured`, missing: status?.missing || [] }); + const state = createOAuthState(req.user.id, provider); + return res.json({ url: buildAuthUrl(provider, state, req) }); + } catch (error) { + return res.status(error?.status || 500).json({ error: error?.message || 'oauth_url_failed' }); + } +}); + +// Callback OAuth (public : retrouvé via le state). Redirige vers le front. +r.get('/oauth/:provider/callback', oauthLimiter, async (req, res) => { + const provider = String(req.params.provider || '').toLowerCase(); + const frontBase = (() => { + try { + const explicit = String(process.env.OAUTH_APP_BASE_URL || '').trim().replace(/\/+$/, ''); + if (explicit) return explicit; + const proto = String(req.headers?.['x-forwarded-proto'] || req.protocol || 'http').split(',')[0].trim() || 'http'; + const host = String(req.headers?.['x-forwarded-host'] || req.headers?.host || '').trim(); + // En prod Docker le callback arrive sur :4000 mais l'UI est sur :4200 (hôte). + // Si l'hôte pointe le port API interne, on rebascule vers le port public. + const publicPort = String(process.env.OAUTH_PUBLIC_PORT || process.env.HOST_PORT || '').trim(); + if (host && publicPort) { + const bare = host.split(':')[0]; + return `${proto}://${bare}:${publicPort}`; + } + if (host) return `${proto}://${host}`; + } catch {} + return 'http://localhost:4200'; + })(); + const fail = (code) => res.redirect(302, `${frontBase}/library/import?provider=${provider}&error=${encodeURIComponent(code)}`); + try { + if (provider !== 'google' && provider !== 'twitch') return fail('invalid_oauth_provider'); + if (req.query?.error) return fail(String(req.query.error_description || req.query.error)); + const { code, state } = req.query || {}; + if (!code || !state) return fail('oauth_missing_code_or_state'); + const entry = consumeOAuthState(String(state)); + if (!entry || entry.provider !== provider) return fail('oauth_invalid_state'); + const tokens = await exchangeCode(provider, String(code), req); + if (!tokens?.accessToken) return fail('oauth_token_failed'); + let profile = { id: '', displayName: provider, avatarUrl: '' }; + try { + profile = provider === 'google' ? await fetchGoogleProfile(tokens.accessToken) : await fetchTwitchProfile(tokens.accessToken); + } catch {} + upsertOAuthConnection({ + userId: entry.userId, provider, externalUserId: profile.id || null, + displayName: profile.displayName || null, avatarUrl: profile.avatarUrl || null, + accessToken: tokens.accessToken, refreshToken: tokens.refreshToken, + expiresAt: tokens.expiresAt, scopes: tokens.scopes, + }); + return res.redirect(302, `${frontBase}/library/import?provider=${provider}&connected=1`); + } catch (error) { + return fail(error?.message || 'oauth_callback_failed'); + } +}); + +r.get('/oauth/connections', authMiddlewareCookieAware, oauthLimiter, (req, res) => { + try { + return res.json({ connections: listOAuthConnections(req.user.id), status: oauthStatus() }); + } catch { + return res.status(500).json({ error: 'oauth_connections_failed' }); + } +}); + +r.delete('/oauth/:provider', authMiddlewareCookieAware, oauthLimiter, (req, res) => { + try { + const provider = requireOAuthProvider(req.params.provider); + deleteOAuthConnection(req.user.id, provider); + return res.status(204).end(); + } catch (error) { + return res.status(error?.status || 500).json({ error: error?.message || 'oauth_disconnect_failed' }); + } +}); + +// Aperçu distant (sans rien importer) : abonnements + favoris. +r.get('/oauth/:provider/preview', authMiddlewareCookieAware, oauthLimiter, async (req, res) => { + try { + const provider = requireOAuthProvider(req.params.provider); + const conn = await freshOAuthToken(req.user.id, provider); + if (provider === 'google') { + const [subscriptions, likes] = await Promise.all([ + fetchGoogleSubscriptions(conn.accessToken, 50).catch(() => []), + fetchGoogleLiked(conn.accessToken, 25).catch(() => []), + ]); + return res.json({ provider, subscriptions, likes, subscriptionCount: subscriptions.length, likeCount: likes.length }); + } + const profile = await fetchTwitchProfile(conn.accessToken).catch(() => ({ id: conn.externalUserId || '' })); + const follows = await fetchTwitchFollows(conn.accessToken, profile?.id || conn.externalUserId || '', 100); + return res.json({ provider, subscriptions: follows, likes: [], subscriptionCount: follows.length, likeCount: 0 }); + } catch (error) { + const status = error?.status || 502; + return res.status(status).json({ error: error?.message || 'oauth_preview_failed' }); + } +}); + +// Import : { types: ['subscriptions','likes'] } (likes = Google uniquement). +r.post('/oauth/:provider/import', authMiddlewareCookieAware, oauthLimiter, async (req, res) => { + try { + const provider = requireOAuthProvider(req.params.provider); + const rawTypes = Array.isArray(req.body?.types) ? req.body.types : ['subscriptions', 'likes']; + const wantSubs = rawTypes.includes('subscriptions'); + const wantLikes = rawTypes.includes('likes'); + const conn = await freshOAuthToken(req.user.id, provider); + let importedSubscriptions = 0; + let importedLikes = 0; + let skippedSubscriptions = 0; + let skippedLikes = 0; + + if (provider === 'google') { + if (wantSubs) { + const subs = await fetchGoogleSubscriptions(conn.accessToken, 50); + for (const s of subs) { + try { + const row = upsertChannelRow({ + provider: 'youtube', externalId: s.externalId, title: s.title, + handle: s.handle || null, avatarUrl: s.avatarUrl || null, url: s.url || null, + lastRefreshedAt: Date.now(), + }); + subscribeChannel({ userId: req.user.id, provider: 'youtube', externalId: s.externalId, channelId: row?.id }); + importedSubscriptions++; + } catch { skippedSubscriptions++; } + } + } + if (wantLikes) { + const likes = await fetchGoogleLiked(conn.accessToken, 25); + for (const v of likes) { + try { + likeVideo({ userId: req.user.id, provider: 'youtube', videoId: v.videoId, title: v.title, thumbnail: v.thumbnail }); + importedLikes++; + } catch { skippedLikes++; } + } + } + } else { + if (wantSubs) { + const profile = await fetchTwitchProfile(conn.accessToken).catch(() => ({ id: conn.externalUserId || '' })); + const follows = await fetchTwitchFollows(conn.accessToken, profile?.id || conn.externalUserId || '', 100); + for (const f of follows) { + try { + const row = upsertChannelRow({ + provider: 'twitch', externalId: f.externalId, title: f.title, + handle: f.handle || null, avatarUrl: f.avatarUrl || null, url: f.url || null, + lastRefreshedAt: Date.now(), + }); + subscribeChannel({ userId: req.user.id, provider: 'twitch', externalId: f.externalId, channelId: row?.id }); + importedSubscriptions++; + } catch { skippedSubscriptions++; } + } + } + // Twitch : pas de likes vidéo → on l'indique au lieu d'échouer silencieusement. + if (wantLikes) skippedLikes = 0; + } + return res.json({ provider, importedSubscriptions, importedLikes, skippedSubscriptions, skippedLikes }); + } catch (error) { + const status = error?.status || 502; + return res.status(status).json({ error: error?.message || 'oauth_import_failed' }); + } +}); + // Public: view a playlist if allowed (owner or public). Authorization header is optional. r.get('/playlists/:id/view', (req, res) => { try { diff --git a/server/oauth.mjs b/server/oauth.mjs new file mode 100644 index 0000000..bb67727 --- /dev/null +++ b/server/oauth.mjs @@ -0,0 +1,359 @@ +/** + * OAuth Google / Twitch pour l'import des favoris et abonnements. + * + * - Google (YouTube) : scopes `youtube.readonly` (+ profil). Importe les + * abonnements (`subscriptions.list?mine=true`) et les favoris + * (`videos.list?myRating=like`). + * - Twitch : scopes `user:read:follows user:read:subscriptions`. Importe les + * chaînes suivies (`/helix/users/follows?from_id=`). Twitch n'a pas de + * notion de "like" vidéo : seul l'import abonnements est proposé. + * + * Les tokens sont stockés en SQLite (instance locale / auto-hébergée). + * Ne jamais logger access_token / refresh_token / client_secret. + */ + +const GOOGLE_AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth'; +const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token'; +const TWITCH_AUTH_URL = 'https://id.twitch.tv/oauth2/authorize'; +const TWITCH_TOKEN_URL = 'https://id.twitch.tv/oauth2/token'; + +const GOOGLE_SCOPES = [ + 'openid', + 'https://www.googleapis.com/auth/userinfo.profile', + 'https://www.googleapis.com/auth/youtube.readonly', +].join(' '); + +const TWITCH_SCOPES = ['user:read:follows', 'user:read:subscriptions'].join(' '); + +// state -> { userId, provider, createdAt } (mémoire, 10 min). +const pendingStates = new Map(); + +function randomState() { + try { + const { randomBytes } = require('node:crypto'); + return randomBytes(16).toString('hex'); + } catch {} + return `${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`; +} + +export function createOAuthState(userId, provider) { + const state = randomState(); + pendingStates.set(state, { userId: String(userId), provider: String(provider), createdAt: Date.now() }); + // Purge opportuniste. + try { + const now = Date.now(); + for (const [k, v] of pendingStates) { + if (now - v.createdAt > 10 * 60 * 1000) pendingStates.delete(k); + } + } catch {} + return state; +} + +export function consumeOAuthState(state) { + const entry = pendingStates.get(String(state || '')); + if (!entry) return null; + pendingStates.delete(String(state)); + if (Date.now() - entry.createdAt > 10 * 60 * 1000) return null; + return entry; +} + +export function oauthStatus() { + const googleId = String(process.env.GOOGLE_CLIENT_ID || '').trim(); + const googleSecret = String(process.env.GOOGLE_CLIENT_SECRET || '').trim(); + const twitchId = String(process.env.TWITCH_CLIENT_ID || '').trim(); + const twitchSecret = String(process.env.TWITCH_CLIENT_SECRET || '').trim(); + return { + google: { + configured: Boolean(googleId && googleSecret), + missing: [...(!googleId ? ['GOOGLE_CLIENT_ID'] : []), ...(!googleSecret ? ['GOOGLE_CLIENT_SECRET'] : [])], + }, + twitch: { + configured: Boolean(twitchId && twitchSecret), + missing: [...(!twitchId ? ['TWITCH_CLIENT_ID'] : []), ...(!twitchSecret ? ['TWITCH_CLIENT_SECRET'] : [])], + }, + }; +} + +/** Base publique de l'app (pour la redirect_uri). Priorité au .env explicite. */ +export function appBaseUrl(req) { + const explicit = String(process.env.OAUTH_APP_BASE_URL || '').trim().replace(/\/+$/, ''); + if (explicit) return explicit; + try { + const proto = String(req?.headers?.['x-forwarded-proto'] || req?.protocol || 'http').split(',')[0].trim() || 'http'; + const host = String(req?.headers?.['x-forwarded-host'] || req?.headers?.host || req?.get?.('host') || '').trim(); + if (host) return `${proto}://${host}`; + } catch {} + return 'http://localhost:4200'; +} + +export function redirectUriFor(provider, req) { + const p = String(provider); + if (p === 'google') { + const explicit = String(process.env.GOOGLE_REDIRECT_URI || '').trim(); + if (explicit) return explicit; + } + if (p === 'twitch') { + const explicit = String(process.env.TWITCH_REDIRECT_URI || '').trim(); + if (explicit) return explicit; + } + return `${appBaseUrl(req).replace(/\/+$/, '')}/api/oauth/${p}/callback`; +} + +export function buildAuthUrl(provider, state, req) { + const p = String(provider); + if (p === 'google') { + const clientId = String(process.env.GOOGLE_CLIENT_ID || '').trim(); + if (!clientId) throw Object.assign(new Error('google_oauth_not_configured'), { status: 503 }); + const qs = new URLSearchParams({ + client_id: clientId, + redirect_uri: redirectUriFor('google', req), + response_type: 'code', + scope: GOOGLE_SCOPES, + access_type: 'offline', + prompt: 'consent', + state, + }); + return `${GOOGLE_AUTH_URL}?${qs.toString()}`; + } + if (p === 'twitch') { + const clientId = String(process.env.TWITCH_CLIENT_ID || '').trim(); + if (!clientId) throw Object.assign(new Error('twitch_oauth_not_configured'), { status: 503 }); + const qs = new URLSearchParams({ + client_id: clientId, + redirect_uri: redirectUriFor('twitch', req), + response_type: 'code', + scope: TWITCH_SCOPES, + state, + }); + return `${TWITCH_AUTH_URL}?${qs.toString()}`; + } + throw Object.assign(new Error('invalid_oauth_provider'), { status: 400 }); +} + +async function postForm(url, params) { + const body = new URLSearchParams(params); + const resp = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: body.toString(), + }); + const data = await resp.json().catch(() => ({})); + if (!resp.ok) { + const err = new Error(`oauth_token_failed_${resp.status}`); + err.status = 502; + err.details = data; + throw err; + } + return data; +} + +export async function exchangeCode(provider, code, req) { + const p = String(provider); + if (p === 'google') { + const data = await postForm(GOOGLE_TOKEN_URL, { + code: String(code), + client_id: String(process.env.GOOGLE_CLIENT_ID || ''), + client_secret: String(process.env.GOOGLE_CLIENT_SECRET || ''), + redirect_uri: redirectUriFor('google', req), + grant_type: 'authorization_code', + }); + return { + accessToken: data.access_token, + refreshToken: data.refresh_token || null, + expiresAt: data.expires_in ? Date.now() + Number(data.expires_in) * 1000 : null, + scopes: data.scope || GOOGLE_SCOPES, + }; + } + if (p === 'twitch') { + const data = await postForm(TWITCH_TOKEN_URL, { + code: String(code), + client_id: String(process.env.TWITCH_CLIENT_ID || ''), + client_secret: String(process.env.TWITCH_CLIENT_SECRET || ''), + redirect_uri: redirectUriFor('twitch', req), + grant_type: 'authorization_code', + }); + return { + accessToken: data.access_token, + refreshToken: data.refresh_token || null, + expiresAt: data.expires_in ? Date.now() + Number(data.expires_in) * 1000 : null, + scopes: Array.isArray(data.scope) ? data.scope.join(' ') : TWITCH_SCOPES, + }; + } + throw Object.assign(new Error('invalid_oauth_provider'), { status: 400 }); +} + +export async function refreshAccessToken(provider, refreshToken) { + const p = String(provider); + if (p === 'google') { + const data = await postForm(GOOGLE_TOKEN_URL, { + refresh_token: String(refreshToken), + client_id: String(process.env.GOOGLE_CLIENT_ID || ''), + client_secret: String(process.env.GOOGLE_CLIENT_SECRET || ''), + grant_type: 'refresh_token', + }); + return { + accessToken: data.access_token, + refreshToken: data.refresh_token || refreshToken, + expiresAt: data.expires_in ? Date.now() + Number(data.expires_in) * 1000 : null, + }; + } + if (p === 'twitch') { + const data = await postForm(TWITCH_TOKEN_URL, { + refresh_token: String(refreshToken), + client_id: String(process.env.TWITCH_CLIENT_ID || ''), + client_secret: String(process.env.TWITCH_CLIENT_SECRET || ''), + grant_type: 'refresh_token', + }); + return { + accessToken: data.access_token, + refreshToken: data.refresh_token || refreshToken, + expiresAt: data.expires_in ? Date.now() + Number(data.expires_in) * 1000 : null, + }; + } + throw Object.assign(new Error('invalid_oauth_provider'), { status: 400 }); +} + +async function getJson(url, accessToken, extraHeaders = {}) { + const resp = await fetch(url, { + headers: { Authorization: `Bearer ${accessToken}`, ...extraHeaders }, + }); + const data = await resp.json().catch(() => ({})); + if (!resp.ok) { + const err = new Error(`oauth_api_failed_${resp.status}`); + err.status = resp.status === 401 ? 401 : 502; + err.details = data; + throw err; + } + return data; +} + +// -------------------- Google (YouTube) -------------------- + +export async function fetchGoogleProfile(accessToken) { + const data = await getJson('https://www.googleapis.com/oauth2/v2/userinfo', accessToken); + return { + id: String(data.id || ''), + displayName: String(data.name || data.email || 'Google'), + avatarUrl: String(data.picture || ''), + }; +} + +export async function fetchGoogleSubscriptions(accessToken, max = 50) { + const out = []; + let pageToken = ''; + while (out.length < max) { + const qs = new URLSearchParams({ + part: 'snippet', + mine: 'true', + maxResults: String(Math.min(50, max - out.length)), + order: 'alphabetical', + }); + if (pageToken) qs.set('pageToken', pageToken); + const data = await getJson(`https://www.googleapis.com/youtube/v3/subscriptions?${qs.toString()}`, accessToken); + for (const item of Array.isArray(data?.items) ? data.items : []) { + const sn = item?.snippet || {}; + const channelId = sn?.resourceId?.channelId || ''; + if (!channelId) continue; + out.push({ + provider: 'youtube', + externalId: channelId, + title: sn?.title || channelId, + handle: null, + avatarUrl: sn?.thumbnails?.default?.url || sn?.thumbnails?.medium?.url || null, + url: `https://www.youtube.com/channel/${channelId}`, + }); + } + pageToken = data?.nextPageToken || ''; + if (!pageToken) break; + } + return out; +} + +export async function fetchGoogleLiked(accessToken, max = 25) { + const qs = new URLSearchParams({ + part: 'snippet,contentDetails', + myRating: 'like', + maxResults: String(Math.min(50, max)), + }); + const data = await getJson(`https://www.googleapis.com/youtube/v3/videos?${qs.toString()}`, accessToken); + const out = []; + for (const item of Array.isArray(data?.items) ? data.items : []) { + const id = item?.id || ''; + const sn = item?.snippet || {}; + if (!id) continue; + out.push({ + provider: 'youtube', + videoId: String(id), + title: sn?.title || '', + thumbnail: sn?.thumbnails?.medium?.url || sn?.thumbnails?.default?.url || '', + }); + } + return out; +} + +// -------------------- Twitch -------------------- + +function twitchClientId() { + return String(process.env.TWITCH_CLIENT_ID || '').trim(); +} + +export async function fetchTwitchProfile(accessToken) { + const data = await getJson('https://api.twitch.tv/helix/users', accessToken, { 'Client-Id': twitchClientId() }); + const u = Array.isArray(data?.data) ? data.data[0] : null; + if (!u) throw Object.assign(new Error('twitch_profile_failed'), { status: 502 }); + return { + id: String(u.id || ''), + displayName: String(u.display_name || u.login || 'Twitch'), + avatarUrl: String(u.profile_image_url || ''), + login: String(u.login || ''), + }; +} + +export async function fetchTwitchFollows(accessToken, twitchUserId, max = 100) { + const out = []; + let cursor = ''; + while (out.length < max) { + const qs = new URLSearchParams({ + from_id: String(twitchUserId), + first: String(Math.min(100, max - out.length)), + }); + if (cursor) qs.set('after', cursor); + const data = await getJson(`https://api.twitch.tv/helix/users/follows?${qs.toString()}`, accessToken, { + 'Client-Id': twitchClientId(), + }); + const list = Array.isArray(data?.data) ? data.data : []; + // Enrichit les logins via /helix/users?id= (display_name + avatar). + const ids = list.map((f) => f?.to_id).filter(Boolean).slice(0, 100); + let usersById = new Map(); + if (ids.length) { + try { + const uqs = new URLSearchParams(); + ids.forEach((id) => uqs.append('id', String(id))); + const udata = await getJson(`https://api.twitch.tv/helix/users?${uqs.toString()}`, accessToken, { + 'Client-Id': twitchClientId(), + }); + for (const u of Array.isArray(udata?.data) ? udata.data : []) { + usersById.set(String(u.id), u); + } + } catch {} + } + for (const f of list) { + const toId = String(f?.to_id || ''); + if (!toId) continue; + const u = usersById.get(toId); + const login = String(u?.login || f?.to_name || ''); + out.push({ + provider: 'twitch', + externalId: login || toId, + twitchUserId: toId, + title: String(u?.display_name || f?.to_name || login || toId), + handle: login || null, + avatarUrl: String(u?.profile_image_url || ''), + url: login ? `https://www.twitch.tv/${login}` : '', + }); + } + cursor = data?.pagination?.cursor || ''; + if (!cursor || !list.length) break; + } + return out; +} diff --git a/src/app.routes.ts b/src/app.routes.ts index 76085be..b03b748 100644 --- a/src/app.routes.ts +++ b/src/app.routes.ts @@ -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), diff --git a/src/components/library/import/import.component.html b/src/components/library/import/import.component.html new file mode 100644 index 0000000..517557d --- /dev/null +++ b/src/components/library/import/import.component.html @@ -0,0 +1,78 @@ +
+
+
+ ‹ +

Importer depuis Google / Twitch

+
+

Connectez votre compte, prévisualisez, puis importez vos abonnements et favoris dans NewTube.

+ +
+ +
{{ notice() }}
+
Chargement…
+ +
+
+
+

{{ p.emoji }}{{ p.label }}

+ connecté + non connecté +
+

{{ p.hint }}

+ +
+ Non configuré côté serveur. Ajoutez {{ (missing()[p.id] || []).join(', ') || 'les variables manquantes' }} puis redémarrez l’API. + Voir GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET / GOOGLE_REDIRECT_URI dans .env.example. +
+ +
+ + {{ c.displayName || c.externalUserId }} +
+ +
+ + + + +
+ +
{{ errors()[p.id] }}
+ +
+

Abonnements : {{ pv.subscriptionCount }} • Favoris : {{ pv.likeCount }}

+
    +
  • + + {{ s.title }} +
  • +
+
    +
  • ♥ {{ l.title }}
  • +
+
+ +
+ Importé : {{ r.importedSubscriptions }} abonnement(s), {{ r.importedLikes }} favori(s). + (ignorés : {{ r.skippedSubscriptions }}) +
+
+
+
diff --git a/src/components/library/import/import.component.ts b/src/components/library/import/import.component.ts new file mode 100644 index 0000000..12a8cbd --- /dev/null +++ b/src/components/library/import/import.component.ts @@ -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>({ google: false, twitch: false }); + missing = signal>({ google: [], twitch: [] }); + connections = signal([]); + previews = signal>({}); + loadingPreview = signal>({}); + importing = signal>({}); + results = signal>({}); + errors = signal>({}); + notice = signal(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 }); + }, + }); + } +} diff --git a/src/components/library/subscriptions/subscriptions.component.html b/src/components/library/subscriptions/subscriptions.component.html index f91cd1f..9dba335 100644 --- a/src/components/library/subscriptions/subscriptions.component.html +++ b/src/components/library/subscriptions/subscriptions.component.html @@ -19,6 +19,12 @@ > Rafraîchir + + ⬇ Importer (Google / Twitch) + diff --git a/src/services/oauth-import.service.ts b/src/services/oauth-import.service.ts new file mode 100644 index 0000000..379dbd6 --- /dev/null +++ b/src/services/oauth-import.service.ts @@ -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('/api/oauth/status'); + } + + connections(): Observable<{ connections: OAuthConnection[]; status: any }> { + return this.http.get('/api/oauth/connections'); + } + + async connect(provider: 'google' | 'twitch'): Promise { + 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 { + return this.http.delete(`/api/oauth/${provider}`); + } + + preview(provider: string): Observable { + return this.http.get(`/api/oauth/${provider}/preview`); + } + + importAll(provider: string, types: string[]): Observable { + return this.http.post(`/api/oauth/${provider}/import`, { types }); + } +}