feat(sync): sélecteur de chaîne YouTube (multi-chaînes/marque) via PageId

This commit is contained in:
2026-09-27 08:05:01 -04:00
parent d3836989ea
commit f038f19b71
7 changed files with 216 additions and 4 deletions
+16
View File
@@ -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();
+35
View File
@@ -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 {
+61 -3
View File
@@ -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 {