feat(channel): page details chaine multi-providers avec onglets et infinite scroll
CI / build-and-test (push) Successful in 13m34s

- Strategy Pattern front: ChannelProviderInterface + 6 providers (yt/dm/tw/pt/od/ru) + factory (ids longs/courts)
- ChannelContentService: cache par onglet/tri/recherche (TTL 5min), pagination nextPage
- ChannelPage: header (banniere/avatar/subs/description ...plus), tabs dynamiques par capabilities, grilles videos/shorts 9:16/playlists/live, recherche debounced, tri recent/populaire, infinite scroll IntersectionObserver, skeletons/empty/error
- Backend: GET /api/channels/:provider/:id/content (videos/shorts/playlists/live) natif par provider
This commit is contained in:
2026-09-25 10:15:12 -04:00
parent fe35340948
commit 97e5de74f5
147 changed files with 7954 additions and 77497 deletions
+414 -9
View File
@@ -39,6 +39,55 @@ if (schemaFile && fs.existsSync(schemaFile)) {
console.warn('[db] No schema.sql found in expected locations:', schemaCandidates.join(', '));
}
// Lightweight idempotent migration runner.
// Applies SQL files from db/migrations/ in filename order, once each, tracked
// in the migrations table. Safe to run on every boot.
// When /app/db is shadowed by a Docker volume (which hides the image's migrations
// folder), fall back to the immutable /app/db-migrations copy bundled in the image.
(function runMigrations() {
try {
db.exec(`CREATE TABLE IF NOT EXISTS migrations (
name TEXT PRIMARY KEY,
applied_at TEXT NOT NULL
);`);
const migrationsDirs = [
path.join(root, 'db', 'migrations'),
path.join(root, 'db-migrations'),
];
const migrationsDir = migrationsDirs.find(p => fs.existsSync(p));
if (!migrationsDir) return;
const files = fs.readdirSync(migrationsDir)
.filter(f => f.endsWith('.sql'))
.sort();
const applied = new Set(
db.prepare('SELECT name FROM migrations').all().map(r => r.name)
);
for (const file of files) {
if (applied.has(file)) continue;
const sql = fs.readFileSync(path.join(migrationsDir, file), 'utf8');
if (!sql || !sql.trim()) continue;
// Migration files manage their own transactions (some contain BEGIN/COMMIT),
// so execute them as-is instead of wrapping in another transaction.
let failed = false;
try {
db.exec(sql);
} catch (e) {
failed = true;
// Idempotent migrations (IF NOT EXISTS / duplicate columns) can fail on
// databases already up to date — tolerate and continue.
console.warn(`[db] Migration ${file} reported an error:`, e?.message || e);
}
// Record as applied even on tolerated errors so we don't re-run/re-warn each boot.
try {
db.prepare('INSERT INTO migrations (name, applied_at) VALUES (?, ?)').run(file, new Date().toISOString());
} catch {}
if (!failed) console.log(`[db] Migration applied: ${file}`);
}
} catch (e) {
console.warn('[db] Migration runner failed:', e?.message || e);
}
})();
// Lightweight schema upgrades for existing databases (SQLite is permissive)
(function ensurePlaylistSchemaUpgrades() {
try {
@@ -69,6 +118,14 @@ if (schemaFile && fs.existsSync(schemaFile)) {
// (duplicate schema exec removed)
(function ensurePreferencesSchemaUpgrades() {
try {
const colsPrefs = db.prepare(`PRAGMA table_info(user_preferences)`).all();
const have = new Set(colsPrefs.map(c => c.name));
if (!have.has('default_providers')) db.exec(`ALTER TABLE user_preferences ADD COLUMN default_providers TEXT`);
} catch {}
})();
// Helpers
export function nowIso() {
return new Date().toISOString();
@@ -86,29 +143,58 @@ export function insertUser({ id, username, email, passwordHash }) {
const ts = nowIso();
db.prepare(`INSERT INTO users (id, username, email, password_hash, is_active, created_at, updated_at)
VALUES (@id, @username, @email, @passwordHash, 1, @ts, @ts)`).run({ id, username, email, passwordHash, ts });
// Default preferences row
db.prepare(`INSERT INTO user_preferences (user_id, language, default_provider, theme, video_quality, region, version, updated_at)
VALUES (@id, 'en', 'youtube', 'system', 'auto', 'US', 1, @ts)`).run({ id, ts });
// Default preferences row (default_providers NULL = no multi-provider preference yet)
db.prepare(`INSERT INTO user_preferences (user_id, language, default_provider, default_providers, theme, video_quality, region, version, updated_at)
VALUES (@id, 'en', 'youtube', NULL, 'system', 'auto', 'US', 1, @ts)`).run({ id, ts });
}// Sanitize a defaultProviders list: keep only known provider ids.
const KNOWN_PROVIDER_IDS = ['yt', 'dm', 'tw', 'pt', 'od', 'ru'];
function sanitizeDefaultProviders(value) {
let arr = value;
// Accept JSON string (from DB) or array (from API patch)
if (typeof arr === 'string') {
try { arr = JSON.parse(arr); } catch { arr = []; }
}
if (!Array.isArray(arr)) arr = [];
const cleaned = arr
.map(v => String(v || '').trim().toLowerCase())
.filter(v => KNOWN_PROVIDER_IDS.includes(v));
// Deduplicate, preserve order
return Array.from(new Set(cleaned));
}
export function upsertPreferences(userId, patch) {
// Normalize the multi-provider selection if provided
let defaultProviders = undefined;
if (patch.defaultProviders !== undefined) {
defaultProviders = sanitizeDefaultProviders(patch.defaultProviders);
}
const current = db.prepare('SELECT * FROM user_preferences WHERE user_id = ?').get(userId);
if (!current) {
const merged = {
language: patch.language ?? 'en',
default_provider: patch.defaultProvider ?? 'youtube',
default_providers: defaultProviders !== undefined
? (defaultProviders.length ? JSON.stringify(defaultProviders) : null)
: null,
theme: patch.theme ?? 'system',
video_quality: patch.videoQuality ?? 'auto',
region: patch.region ?? 'US',
version: 1,
};
db.prepare(`INSERT INTO user_preferences (user_id, language, default_provider, theme, video_quality, region, version, updated_at)
VALUES (@userId, @language, @default_provider, @theme, @video_quality, @region, @version, @updated_at)`)
db.prepare(`INSERT INTO user_preferences (user_id, language, default_provider, default_providers, theme, video_quality, region, version, updated_at)
VALUES (@userId, @language, @default_provider, @default_providers, @theme, @video_quality, @region, @version, @updated_at)`)
.run({ userId, ...merged, updated_at: nowIso() });
} else {
const storedList = current.default_providers;
const nextList = defaultProviders !== undefined
? (defaultProviders.length ? JSON.stringify(defaultProviders) : null)
: storedList;
const merged = {
language: patch.language ?? current.language,
default_provider: patch.defaultProvider ?? current.default_provider,
default_providers: nextList,
theme: patch.theme ?? current.theme,
video_quality: patch.videoQuality ?? current.video_quality,
region: patch.region ?? current.region,
@@ -116,15 +202,27 @@ export function upsertPreferences(userId, patch) {
updated_at: nowIso(),
};
db.prepare(`UPDATE user_preferences
SET language=@language, default_provider=@default_provider, theme=@theme,
video_quality=@video_quality, region=@region, version=@version, updated_at=@updated_at
WHERE user_id=@userId`)
SET language=@language, default_provider=@default_provider, default_providers=@default_providers,
theme=@theme, video_quality=@video_quality, region=@region, version=@version, updated_at=@updated_at
WHERE user_id=@userId`)
.run({ userId, ...merged });
}
}
export function getPreferences(userId) {
return db.prepare('SELECT language, default_provider AS defaultProvider, theme, video_quality AS videoQuality, region, version, updated_at FROM user_preferences WHERE user_id = ?').get(userId);
return db.prepare(`SELECT language, default_provider AS defaultProvider, default_providers AS defaultProviders,
theme, video_quality AS videoQuality, region, version, updated_at
FROM user_preferences WHERE user_id = ?`).get(userId);
}
// Return preferences with defaultProviders parsed as an array (API shape)
export function getPreferencesForApi(userId) {
const prefs = getPreferences(userId) || {};
let list = null;
if (prefs.defaultProviders) {
try { list = JSON.parse(prefs.defaultProviders); } catch { list = null; }
}
return { ...prefs, defaultProviders: Array.isArray(list) ? list : null };
}
export function insertSession({ id, userId, refreshTokenHash, isRemember, userAgent, deviceInfo, ip, expiresAt }) {
@@ -176,6 +274,51 @@ export function cryptoRandomId() {
return randomBytes(16).toString('base64url');
}
// -------------------- Telemetry (minimal product events) --------------------
export function insertTelemetryEvent({ userId, event, meta }) {
const id = cryptoRandomId();
const ts = nowIso();
try {
db.prepare(`INSERT INTO telemetry_events (id, user_id, event, meta_json, created_at)
VALUES (@id, @userId, @event, @metaJson, @ts)`)
.run({
id,
userId: userId || null,
event: String(event || '').slice(0, 120),
metaJson: meta ? JSON.stringify(meta) : null,
ts,
});
return { id, created_at: ts };
} catch {
// Telemetry must never break the request path
return null;
}
}
export function listTelemetryEvents({ event, limit = 100, since } = {}) {
const capped = Math.min(1000, Math.max(1, Number(limit || 100)));
const clauses = [];
const params = {};
if (event) { clauses.push('event = @event'); params.event = String(event); }
if (since) { clauses.push('created_at >= @since'); params.since = String(since); }
const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : '';
return db.prepare(`SELECT id, user_id AS userId, event, meta_json AS metaJson, created_at AS createdAt
FROM telemetry_events ${where}
ORDER BY created_at DESC LIMIT @limit`)
.all({ ...params, limit: capped });
}
export function countTelemetryEvents({ event, since } = {}) {
const clauses = [];
const params = {};
if (event) { clauses.push('event = @event'); params.event = String(event); }
if (since) { clauses.push('created_at >= @since'); params.since = String(since); }
const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : '';
const row = db.prepare(`SELECT COUNT(*) AS n FROM telemetry_events ${where}`).get(params);
return row ? row.n : 0;
}
export function cryptoRandomUUID() {
return randomUUID();
}
@@ -593,3 +736,265 @@ export function reorderPlaylistVideos({ userId, playlistId, order }) {
recordPlaylistMetric({ userId, playlistId, action: 'reorder', meta: { count: order.length } });
return { changed };
}
// -------------------- Download jobs --------------------
export function insertDownloadJob({ id, userId, provider, videoId, title, formatId, audioOnly, url }) {
const now = Date.now();
db.prepare(`INSERT INTO download_jobs (id, user_id, provider, video_id, title, state, progress, format_id, audio_only, url, created_at, updated_at)
VALUES (@id, @userId, @provider, @videoId, @title, 'queued', 0, @formatId, @audioOnly, @url, @now, @now)`)
.run({ id, userId, provider, videoId, title: title || null, formatId: formatId || null, audioOnly: audioOnly ? 1 : 0, url: url || null, now });
return getDownloadJob(id);
}
export function getDownloadJob(id) {
return db.prepare(`SELECT id, user_id AS userId, provider, video_id AS videoId, title, state, progress, format_id AS formatId,
audio_only AS audioOnly, url, file_name AS fileName, file_ext AS fileExt, file_size AS fileSize,
file_path AS filePath, error, created_at AS createdAt, updated_at AS updatedAt, completed_at AS completedAt
FROM download_jobs WHERE id = ?`).get(id);
}
export function listDownloadJobs({ userId, limit = 50, offset = 0, state }) {
const hasState = typeof state === 'string' && state.trim().length > 0;
// When userId is falsy, list jobs across all users (server-side maintenance).
const hasUser = userId != null && String(userId).length > 0;
const cols = `id, user_id AS userId, provider, video_id AS videoId, title, state, progress, format_id AS formatId,
audio_only AS audioOnly, url, file_name AS fileName, file_ext AS fileExt, file_size AS fileSize,
file_path AS filePath, error, created_at AS createdAt, updated_at AS updatedAt, completed_at AS completedAt`;
const where = [];
const params = [];
if (hasUser) { where.push('user_id = ?'); params.push(userId); }
if (hasState) { where.push('state = ?'); params.push(state.trim()); }
const whereSql = where.length ? ` WHERE ${where.join(' AND ')}` : '';
params.push(limit, offset);
return db.prepare(`SELECT ${cols} FROM download_jobs${whereSql} ORDER BY created_at DESC LIMIT ? OFFSET ?`).all(...params);
}
export function updateDownloadJob(id, patch = {}) {
const cur = getDownloadJob(id);
if (!cur) return null;
const next = {
state: patch.state != null ? String(patch.state) : cur.state,
progress: (typeof patch.progress === 'number' && patch.progress > (cur.progress || 0)) ? patch.progress : (cur.progress || 0),
title: patch.title != null ? patch.title : cur.title,
fileName: patch.fileName != null ? patch.fileName : cur.fileName,
fileExt: patch.fileExt != null ? patch.fileExt : cur.fileExt,
fileSize: patch.fileSize != null ? patch.fileSize : cur.fileSize,
filePath: patch.filePath != null ? patch.filePath : cur.filePath,
error: patch.error != null ? patch.error : cur.error,
completedAt: patch.completedAt != null ? patch.completedAt : cur.completedAt,
updatedAt: Date.now(),
};
db.prepare(`UPDATE download_jobs SET state=@state, progress=@progress, title=@title, file_name=@fileName, file_ext=@fileExt,
file_size=@fileSize, file_path=@filePath, error=@error, completed_at=@completedAt, updated_at=@updatedAt
WHERE id = @id`).run({ ...next, id });
return getDownloadJob(id);
}
export function deleteDownloadJob({ userId, id }) {
const info = db.prepare(`DELETE FROM download_jobs WHERE id = ? AND user_id = ?`).run(id, userId);
return (info.changes || 0) > 0;
}
// Mark jobs that were queued/running/merging when the API stopped as 'interrupted'
// so the user can retry them from the UI instead of polling forever.
export function resetActiveDownloadJobs() {
try {
const info = db.prepare(`UPDATE download_jobs
SET state = 'interrupted', error = 'api_restarted', updated_at = ?
WHERE state IN ('queued', 'running', 'merging')`).run(Date.now());
if ((info.changes || 0) > 0) console.log(`[db] Marked ${info.changes} active download job(s) as interrupted`);
} catch (e) {
console.warn('[db] resetActiveDownloadJobs failed:', e?.message || e);
}
}
// Count active (queued/running/merging) jobs for a user — used for concurrency quota
export function countActiveDownloadJobs(userId) {
const row = db.prepare(`SELECT COUNT(1) AS n FROM download_jobs WHERE user_id = ? AND state IN ('queued', 'running', 'merging')`).get(userId);
return row?.n || 0;
}
// Sum of completed job sizes (bytes) within the retention window — used for storage quota
export function sumCompletedDownloadBytes(userId, sinceMs) {
const row = sinceMs
? db.prepare(`SELECT COALESCE(SUM(file_size), 0) AS total FROM download_jobs WHERE user_id = ? AND state = 'completed' AND completed_at >= ?`).get(userId, sinceMs)
: db.prepare(`SELECT COALESCE(SUM(file_size), 0) AS total FROM download_jobs WHERE user_id = ? AND state = 'completed'`).get(userId);
return row?.total || 0;
}
// -------------------- Channels & Subscriptions --------------------
const CHANNEL_TTL_MS = Number(process.env.CHANNEL_TTL_MS || (6 * 60 * 60 * 1000));
export function getChannelByProviderExternalId(provider, externalId) {
return db.prepare(`SELECT * FROM channels WHERE provider = ? AND external_id = ?`).get(provider, externalId);
}
export function getChannelById(id) {
return db.prepare(`SELECT * FROM channels WHERE id = ?`).get(id);
}
export function channelRowToMeta(row) {
if (!row) return null;
return {
id: row.id,
provider: row.provider,
externalId: row.external_id,
title: row.title || null,
handle: row.handle || null,
avatarUrl: row.avatar_url || null,
url: row.url || null,
subsCount: typeof row.subs_count === 'number' ? row.subs_count : undefined,
verified: row.verified == null ? undefined : Boolean(row.verified),
lastRefreshedAt: row.last_refreshed_at || null,
};
}
export function upsertChannelRow(meta) {
if (!meta || !meta.provider || !meta.externalId) {
throw new Error('invalid_channel_meta');
}
const now = Date.now();
const payload = {
provider: meta.provider,
external_id: meta.externalId,
title: meta.title || null,
handle: meta.handle || null,
avatar_url: meta.avatarUrl || null,
url: meta.url || null,
subs_count: typeof meta.subsCount === 'number' ? meta.subsCount : null,
verified: meta.verified === undefined ? null : (meta.verified ? 1 : 0),
last_refreshed_at: meta.lastRefreshedAt ? Number(meta.lastRefreshedAt) : now,
};
db.prepare(`INSERT INTO channels (provider, external_id, title, handle, avatar_url, url, subs_count, verified, last_refreshed_at)
VALUES (@provider, @external_id, @title, @handle, @avatar_url, @url, @subs_count, @verified, @last_refreshed_at)
ON CONFLICT(provider, external_id) DO UPDATE SET
title=excluded.title,
handle=excluded.handle,
avatar_url=excluded.avatar_url,
url=excluded.url,
subs_count=excluded.subs_count,
verified=COALESCE(excluded.verified, channels.verified),
last_refreshed_at=excluded.last_refreshed_at`).run(payload);
const row = getChannelByProviderExternalId(meta.provider, meta.externalId);
return channelRowToMeta(row);
}
export async function ensureChannelFresh(provider, externalId, fetcher, opts = {}) {
if (typeof fetcher !== 'function') throw new Error('channel_fetcher_required');
const forceRefresh = Boolean(opts?.force);
const row = getChannelByProviderExternalId(provider, externalId);
if (!forceRefresh && row && row.last_refreshed_at && (Date.now() - row.last_refreshed_at) < CHANNEL_TTL_MS) {
return channelRowToMeta(row);
}
try {
const maybePromise = fetcher();
const data = maybePromise instanceof Promise ? await maybePromise : maybePromise;
const consolidated = {
provider,
externalId,
title: data?.title,
handle: data?.handle,
avatarUrl: data?.avatarUrl,
url: data?.url,
subsCount: data?.subsCount,
verified: data?.verified,
lastRefreshedAt: Date.now(),
};
return upsertChannelRow(consolidated);
} catch {
const fallback = {
provider,
externalId,
lastRefreshedAt: Date.now(),
};
return upsertChannelRow(fallback);
}
}
export function listSubscriptionsByUser(userId) {
const rows = db.prepare(`
SELECT s.id AS subscriptionId,
s.created_at AS createdAt,
c.id AS channelId,
c.provider,
c.external_id AS externalId,
c.title,
c.handle,
c.avatar_url AS avatarUrl,
c.url,
c.subs_count AS subsCount,
c.verified,
c.last_refreshed_at AS lastRefreshedAt
FROM subscriptions s
JOIN channels c ON c.id = s.channel_id
WHERE s.user_id = ?
ORDER BY COALESCE(c.title, c.external_id) COLLATE NOCASE ASC
`).all(userId);
return rows.map(subscriptionRowToDto);
}
export function subscribeChannel({ userId, provider, externalId, channelId }) {
if (!channelId) {
const channelRow = getChannelByProviderExternalId(provider, externalId);
if (!channelRow) throw new Error('channel_missing');
channelId = channelRow.id;
}
const ts = Date.now();
db.prepare(`INSERT OR IGNORE INTO subscriptions (user_id, channel_id, created_at)
VALUES (?, ?, ?)`).run(userId, channelId, ts);
const row = db.prepare(`
SELECT s.id AS subscriptionId,
s.created_at AS createdAt,
c.id AS channelId,
c.provider,
c.external_id AS externalId,
c.title,
c.handle,
c.avatar_url AS avatarUrl,
c.url,
c.subs_count AS subsCount,
c.verified,
c.last_refreshed_at AS lastRefreshedAt
FROM subscriptions s
JOIN channels c ON c.id = s.channel_id
WHERE s.user_id = ? AND s.channel_id = ?
`).get(userId, channelId);
return subscriptionRowToDto(row);
}
export function unsubscribeChannel({ userId, subscriptionId }) {
return db.prepare(`DELETE FROM subscriptions WHERE id = ? AND user_id = ?`).run(subscriptionId, userId);
}
export function isSubscribed({ userId, provider, externalId }) {
return db.prepare(`
SELECT 1
FROM subscriptions s
JOIN channels c ON c.id = s.channel_id
WHERE s.user_id = ? AND c.provider = ? AND c.external_id = ?
LIMIT 1
`).get(userId, provider, externalId) != null;
}
function subscriptionRowToDto(row) {
if (!row) return null;
const channel = channelRowToMeta({
id: row.channelId,
provider: row.provider,
external_id: row.externalId,
title: row.title,
handle: row.handle,
avatar_url: row.avatarUrl,
url: row.url,
subs_count: row.subsCount,
verified: row.verified,
last_refreshed_at: row.lastRefreshedAt,
});
return {
subscriptionId: row.subscriptionId,
createdAt: row.createdAt,
channel,
};
}