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
+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 {