From f038f19b71bbcb89192deeec4071b1b8eaa53c2c Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Sun, 27 Sep 2026 08:05:01 -0400 Subject: [PATCH] =?UTF-8?q?feat(sync):=20s=C3=A9lecteur=20de=20cha=C3=AEne?= =?UTF-8?q?=20YouTube=20(multi-cha=C3=AEnes/marque)=20via=20PageId?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../20260927_add_oauth_yt_channel.sql | 4 ++ server/db.mjs | 16 +++++ server/index.mjs | 35 ++++++++++ server/oauth.mjs | 64 ++++++++++++++++++- .../library/import/import.component.html | 33 ++++++++++ .../library/import/import.component.ts | 49 +++++++++++++- src/services/oauth-import.service.ts | 19 ++++++ 7 files changed, 216 insertions(+), 4 deletions(-) create mode 100644 db/migrations/20260927_add_oauth_yt_channel.sql 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 @@ + +
+

📺Chaîne YouTube utilisée

+

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

+
+ +
+
{{ ytChannelsError() }}
+
+ +
+ + {{ ytChannelSaved() }} +
+
+
+

🕘Historique YouTube (Takeout)

diff --git a/src/components/library/import/import.component.ts b/src/components/library/import/import.component.ts index e65510f..d966c3e 100644 --- a/src/components/library/import/import.component.ts +++ b/src/components/library/import/import.component.ts @@ -3,7 +3,7 @@ import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { ActivatedRoute, RouterLink } from '@angular/router'; import { firstValueFrom } from 'rxjs'; -import { OAuthImportService, parseTakeoutHistory, type OAuthConnection, type OAuthPreview, type OAuthImportResult } from '../../../services/oauth-import.service'; +import { OAuthImportService, parseTakeoutHistory, type OAuthConnection, type OAuthPreview, type OAuthImportResult, type YtChannel } from '../../../services/oauth-import.service'; import { PlaylistsService, type Playlist } from '../../../services/playlists.service'; interface ProviderCard { @@ -241,6 +241,53 @@ export class ImportComponent implements OnInit { userPlaylists = signal([]); wlExportPlaylistId = signal(''); + // -------------------- Chaîne YouTube (multi-chaînes / marque) -------------------- + + ytChannels = signal([]); + ytChannelsLoading = signal(false); + ytChannelsError = signal(null); + ytSelectedChannelId = signal(''); + ytSavingChannel = signal(false); + ytChannelSaved = signal(null); + + loadYtChannels(): void { + if (!this.isConnected('google') || this.ytChannelsLoading()) return; + this.ytChannelsLoading.set(true); + this.ytChannelsError.set(null); + this.api.ytChannels().subscribe({ + next: (res) => { + this.ytChannels.set(res?.channels || []); + const sel = res?.selected?.channelId || this.connectionOf('google')?.ytChannelId || ''; + this.ytSelectedChannelId.set(sel); + this.ytChannelsLoading.set(false); + }, + error: (e) => { + const code = e?.error?.error || e?.message || 'yt_channels_failed'; + const detail = e?.error?.detail; + this.ytChannelsError.set(detail ? `${code} — ${detail}` : code); + this.ytChannelsLoading.set(false); + }, + }); + } + + saveYtChannel(): void { + const id = this.ytSelectedChannelId(); + if (this.ytSavingChannel()) return; + this.ytSavingChannel.set(true); + this.ytChannelSaved.set(null); + this.api.saveYtChannel(id || null, id || null).subscribe({ + next: () => { + this.ytChannelSaved.set('Chaîne mémorisée : relancez la sync auto / Regarder plus tard.'); + this.ytSavingChannel.set(false); + this.refresh(); + }, + error: (e) => { + this.ytChannelsError.set(e?.error?.error || e?.message || 'yt_channel_save_failed'); + this.ytSavingChannel.set(false); + }, + }); + } + loadWatchLater(): void { this.wlLoading.set(true); this.wlError.set(null); diff --git a/src/services/oauth-import.service.ts b/src/services/oauth-import.service.ts index 7fd5a3a..4350d61 100644 --- a/src/services/oauth-import.service.ts +++ b/src/services/oauth-import.service.ts @@ -11,6 +11,15 @@ export interface OAuthConnection { expiresAt: number | null; connectedAt: number | null; updatedAt: number | null; + ytChannelId?: string | null; + ytPageId?: string | null; +} + +export interface YtChannel { + channelId: string | null; + title: string; + handle: string | null; + selected: boolean; } export interface OAuthPreview { @@ -82,6 +91,16 @@ export class OAuthImportService { ytHistory(max = 200): Observable<{ items: Array<{ videoId: string; title: string; thumbnail?: string; watchedAt?: string }>; count: number; hasMore: boolean }> { return this.http.get(`/api/oauth/google/yt-history?max=${max}`); } + + /** Chaînes YouTube du compte (sélecteur multi-chaînes / marque). */ + ytChannels(): Observable<{ channels: YtChannel[]; selected: { channelId: string | null; pageId: string | null } }> { + return this.http.get(`/api/oauth/google/yt-channels`); + } + + /** Mémorise la chaîne à cibler pour l'historique et Regarder plus tard. */ + saveYtChannel(channelId: string | null, pageId: string | null): Observable<{ selected: { channelId: string | null; pageId: string | null } }> { + return this.http.put(`/api/oauth/google/yt-channel`, { channelId, pageId }); + } } /** Parse local du `watch-history.json` de Google Takeout (confidentiel : rien n'est uploadé tel quel). */