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:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
@@ -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
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
@@ -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 }; }
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user