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
+18
View File
@@ -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
+1 -1
View File
@@ -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
@@ -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);
+9
View File
@@ -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
+6
View File
@@ -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 ---
+99
View File
@@ -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 }; }
}
+196
View File
@@ -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 {
+359
View File
@@ -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;
}
+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 });
}
}