diff --git a/db/migrations/20260927_add_oauth_yt_channel.sql b/db/migrations/20260927_add_oauth_yt_channel.sql new file mode 100644 index 0000000..2b6a03b --- /dev/null +++ b/db/migrations/20260927_add_oauth_yt_channel.sql @@ -0,0 +1,4 @@ +-- Chaîne YouTube sélectionnée pour la sync (comptes multi-chaînes / marque). +-- Colonnes ajoutées si absentes (compatibles avec ensureOAuthTables()). +ALTER TABLE oauth_connections ADD COLUMN yt_channel_id TEXT; +ALTER TABLE oauth_connections ADD COLUMN yt_page_id TEXT; diff --git a/server/db.mjs b/server/db.mjs index 55d5854..ab40bc0 100644 --- a/server/db.mjs +++ b/server/db.mjs @@ -1397,6 +1397,12 @@ function ensureOAuthTables() { PRIMARY KEY (user_id, provider) );`); db.exec(`CREATE INDEX IF NOT EXISTS idx_oauth_connections_user ON oauth_connections(user_id);`); + // Chaîne YouTube sélectionnée (multi-chaînes / marque) : ajout idempotent. + try { + const cols = db.prepare(`PRAGMA table_info(oauth_connections)`).all().map((c) => c.name); + if (!cols.includes('yt_channel_id')) db.exec(`ALTER TABLE oauth_connections ADD COLUMN yt_channel_id TEXT`); + if (!cols.includes('yt_page_id')) db.exec(`ALTER TABLE oauth_connections ADD COLUMN yt_page_id TEXT`); + } catch {} } catch {} } ensureOAuthTables(); @@ -1417,9 +1423,19 @@ function oauthRowToDto(row, withTokens = false) { dto.accessToken = row.access_token || null; dto.refreshToken = row.refresh_token || null; } + dto.ytChannelId = row.yt_channel_id || null; + dto.ytPageId = row.yt_page_id || null; return dto; } +export function setOAuthChannel(userId, provider, { channelId, pageId }) { + ensureOAuthTables(); + db.prepare(`UPDATE oauth_connections SET yt_channel_id = ?, yt_page_id = ?, updated_at = ? + WHERE user_id = ? AND provider = ?`) + .run(channelId || null, pageId || null, Date.now(), userId, provider); + return getOAuthConnection(userId, provider); +} + export function upsertOAuthConnection({ userId, provider, externalUserId, displayName, avatarUrl, accessToken, refreshToken, expiresAt, scopes }) { ensureOAuthTables(); const now = Date.now(); diff --git a/server/index.mjs b/server/index.mjs index 42cf0d7..75b5ae8 100644 --- a/server/index.mjs +++ b/server/index.mjs @@ -82,6 +82,7 @@ import { getOAuthConnection, updateOAuthTokens, deleteOAuthConnection, + setOAuthChannel, upsertChannelRow, insertDownloadJob, getDownloadJob, @@ -107,6 +108,7 @@ import { fetchGoogleLiked, fetchTwitchProfile, fetchTwitchFollows, hasGoogleWriteScope, fetchGoogleWatchLater, pushGoogleWatchLater, fetchInnerTubeHistory, fetchInnerTubeWatchLaterViaLib, pushInnerTubeWatchLater, fetchGoogleWatchLaterId, + fetchAccountChannels, } from './oauth.mjs'; import { getSearchMode, getYtDlpBin, hasCookiesFile, metricsSnapshot } from './providers/youtube-common.mjs'; import { ytScrapeCacheStats } from './providers/youtube.mjs'; @@ -559,6 +561,39 @@ r.delete('/oauth/:provider', authMiddlewareCookieAware, oauthLimiter, (req, res) } }); +// -------------------- Google : choix de la chaîne (multi-chaînes / marque) -------------------- +// La chaîne par défaut peut être une coquille vide (pas d'historique ni WL) +// alors que l'activité est sur une chaîne secondaire : l'utilisateur la +// choisit ici, et les appels InnerTube la ciblent via X-Goog-PageId. + +r.get('/oauth/google/yt-channels', authMiddlewareCookieAware, oauthLimiter, async (req, res) => { + try { + const conn = await freshOAuthToken(req.user.id, 'google'); + const { channels } = await fetchAccountChannels(conn); + try { + console.log(`[oauth] yt-channels user=${req.user?.id} count=${channels.length}`); + } catch {} + return res.json({ channels, selected: { channelId: conn.ytChannelId || null, pageId: conn.ytPageId || null } }); + } catch (error) { + const out = { error: error?.message || 'yt_channels_failed' }; + if (error?.detail) out.detail = error.detail; + if (error?.hint) out.hint = error.hint; + return res.status(error?.status || 502).json(out); + } +}); + +r.put('/oauth/google/yt-channel', authMiddlewareCookieAware, oauthLimiter, (req, res) => { + try { + const channelId = String(req.body?.channelId || '').trim().slice(0, 64) || null; + const pageId = String(req.body?.pageId || '').trim().slice(0, 64) || null; + const conn = setOAuthChannel(req.user.id, 'google', { channelId, pageId }); + if (!conn) return res.status(404).json({ error: 'oauth_not_connected' }); + return res.json({ selected: { channelId: conn.ytChannelId || null, pageId: conn.ytPageId || null } }); + } catch { + return res.status(500).json({ error: 'yt_channel_save_failed' }); + } +}); + // Aperçu distant (sans rien importer) : abonnements + favoris. r.get('/oauth/:provider/preview', authMiddlewareCookieAware, oauthLimiter, async (req, res) => { try { diff --git a/server/oauth.mjs b/server/oauth.mjs index 353a683..bef093d 100644 --- a/server/oauth.mjs +++ b/server/oauth.mjs @@ -606,9 +606,10 @@ async function silenceYoutubeiNoise() { * Session youtubei.js authentifiée avec les tokens OAuth stockés * (même Bearer que l'app, mais contexte client officiel complet). */ -async function getAuthedInnertube(conn) { +async function getAuthedInnertube(conn, { pageId } = {}) { const accessToken = String(conn?.accessToken || ''); const refreshToken = String(conn?.refreshToken || ''); + const targetPageId = String(pageId || conn?.ytPageId || '').trim() || null; if (!accessToken) throw Object.assign(new Error('oauth_not_connected'), { status: 404 }); if (!refreshToken) { throw Object.assign(new Error('google_reconnect_required'), { @@ -618,7 +619,13 @@ async function getAuthedInnertube(conn) { } await silenceYoutubeiNoise(); const { Innertube } = await import('youtubei.js'); - const innertube = await Innertube.create({ lang: 'fr', location: 'FR' }); + // targetPageId = chaîne/brand secondaire : on_behalf_of_user fait envoyer + // X-Goog-PageId par youtubei.js (sélecteur de chaîne façon SmartTube). + const innertube = await Innertube.create({ + lang: 'fr', + location: 'FR', + ...(targetPageId ? { on_behalf_of_user: targetPageId } : {}), + }); const expiry = Number(conn?.expiresAt || 0); await innertube.session.oauth.init({ access_token: accessToken, @@ -687,9 +694,60 @@ function mapHistoryNode(node, mapVideoNode) { }; } +function textOf(t) { + if (!t) return ''; + if (typeof t === 'string') return t; + if (Array.isArray(t?.runs)) return t.runs.map((r) => r?.text || '').join(''); + return String(t?.simpleText || ''); +} + +/** + * Chaînes YouTube du compte (sélecteur façon SmartTube) via account_menu. + * La chaîne par défaut peut être une coquille vide (relatedPlaylists sans + * watchLater/history) alors que l'activité est sur une chaîne secondaire. + */ +export async function fetchAccountChannels(conn) { + try { + const innertube = await getAuthedInnertube(conn); + const data = await innertube.session.actions.execute('/account/account_menu', {}); + const found = new Map(); + const visit = (node, depth = 0) => { + if (!node || typeof node !== 'object' || depth > 12) return; + if (Array.isArray(node)) { + for (const e of node) visit(e, depth + 1); + return; + } + // accountItem : nom + avatar (+ handle/canal à proximité). + const name = textOf(node.accountName).trim(); + if (name) { + const blob = JSON.stringify(node).slice(0, 4000); + const ch = /UC[A-Za-z0-9_-]{20,}/.exec(blob)?.[0] || ''; + const handle = /@[A-Za-z0-9._-]{3,}/.exec(textOf(node.accountByline) + ' ' + blob)?.[0] || ''; + const key = ch || name.toLowerCase(); + if (!found.has(key)) { + found.set(key, { + channelId: ch || null, + title: name, + handle: handle || null, + selected: Boolean(node.isSelected || node.isDefault), + }); + } + } + for (const v of Object.values(node)) { + if (v && typeof v === 'object') visit(v, depth + 1); + } + }; + visit(data?.data || data); + return { channels: [...found.values()].slice(0, 10) }; + } catch (e) { + throwLibHistoryError(e, 'yt_channels'); + throw e; + } +} + /** * Historique YouTube via youtubei.js authentifié (getHistory + continuations). - * `conn` = { accessToken, refreshToken, expiresAt } (jeton déjà rafraîchi). + * `conn` = { accessToken, refreshToken, expiresAt, ytPageId? } (jeton déjà rafraîchi). */ export async function fetchInnerTubeHistory(conn, max = 200) { try { diff --git a/src/components/library/import/import.component.html b/src/components/library/import/import.component.html index 64517de..7e22c5b 100644 --- a/src/components/library/import/import.component.html +++ b/src/components/library/import/import.component.html @@ -89,6 +89,39 @@ + +
+ Comptes multi-chaînes / marque : la chaîne par défaut est parfois vide (pas d’historique ni de Regarder plus tard). + Choisissez la chaîne où se trouve votre activité réelle. +
+