/** * [Subsonics Chopin - Backend] - Module YouTube * File: youtube/settings.js * Description: Réglages YouTube / yt-dlp (data/youtube/settings.json), les variables d'environnement l'emportent */ const { LogType } = require('loguix'); const clog = new LogType("Youtube-Settings"); const { __glob } = require('../utils/GlobalVars'); const { readJson, writeJson, emitUpdate } = require('./util'); const DEFAULTS = Object.freeze({ // fallback : sans compte d'abord, cookies seulement après une vérification anti-robot / âge / connexion cookiesMode: "fallback", // Évite d'utiliser le compte depuis les IP tournantes de WARP (sauf si c'est la seule route) cookiesOnProxy: false, // "" = clients par défaut de yt-dlp (recommandé) playerClients: "", // Une tentative de plus avec ces clients après un 403 / format indisponible / SABR / PO token fallbackPlayerClients: "web_safari,tv_downgraded", potProviderUrl: "", forceIpv4: true, autoUpdate: true, seekStrategy: "auto", testUrl: "https://www.youtube.com/watch?v=jNQXAC9IVRw" }); const ENV = { cookiesMode: "YTDLP_COOKIES_MODE", cookiesOnProxy: "YTDLP_COOKIES_ON_PROXY", playerClients: "YTDLP_PLAYER_CLIENTS", fallbackPlayerClients: "YTDLP_FALLBACK_PLAYER_CLIENTS", potProviderUrl: "YTDLP_POT_PROVIDER", forceIpv4: "YTDLP_FORCE_IPV4", autoUpdate: "YTDLP_AUTO_UPDATE", seekStrategy: "YTDLP_SEEK_STRATEGY", testUrl: "YTDLP_TEST_URL" }; const YOUTUBE_HOSTS = /^(?:www\.|m\.|music\.)?youtube\.com$|^youtu\.be$/i; function invalid(message) { const err = new Error(message); err.code = "INVALID_PARAMS"; return err; } function toBoolean(key, value) { if (typeof value === 'boolean') return value; if (/^(true|1|yes|on)$/i.test(String(value).trim())) return true; if (/^(false|0|no|off)$/i.test(String(value).trim())) return false; throw invalid(`${key} : valeur booléenne attendue`); } function toClients(key, value) { const text = String(value ?? '').trim().toLowerCase().replace(/\s+/g, ''); if (text.length > 200 || !/^[a-z_,\-]*$/.test(text)) { throw invalid(`${key} : liste de clients invalide (lettres, "_", "-" et virgules uniquement)`); } return text.split(',').filter(Boolean).join(','); } const VALIDATORS = { cookiesMode: (value) => { if (!["fallback", "always", "never"].includes(value)) throw invalid("cookiesMode : fallback, always ou never attendu"); return value; }, cookiesOnProxy: (value) => toBoolean("cookiesOnProxy", value), playerClients: (value) => toClients("playerClients", value), fallbackPlayerClients: (value) => toClients("fallbackPlayerClients", value), potProviderUrl: (value) => { const text = String(value ?? '').trim(); if (!text) return ""; let parsed; try { parsed = new URL(text); } catch (e) { throw invalid("potProviderUrl : URL invalide"); } if (!/^https?:$/.test(parsed.protocol) || /[\s;"']/.test(text)) throw invalid("potProviderUrl : URL http(s) attendue"); return text.replace(/\/+$/, ''); }, forceIpv4: (value) => toBoolean("forceIpv4", value), autoUpdate: (value) => toBoolean("autoUpdate", value), seekStrategy: (value) => { if (!["auto", "ffmpeg", "native"].includes(value)) throw invalid("seekStrategy : auto, ffmpeg ou native attendu"); return value; }, testUrl: (value) => { const text = String(value ?? '').trim(); let parsed; try { parsed = new URL(text); } catch (e) { throw invalid("testUrl : URL invalide"); } if (!/^https?:$/.test(parsed.protocol) || !YOUTUBE_HOSTS.test(parsed.hostname) || /\s/.test(text)) { throw invalid("testUrl : lien YouTube http(s) attendu"); } return text; } }; let fileValues = null; let envValues = null; function loadFile() { if (fileValues) return fileValues; const raw = readJson(__glob.YOUTUBE_SETTINGS, {}); fileValues = {}; for (const key of Object.keys(DEFAULTS)) { if (raw[key] === undefined) continue; try { fileValues[key] = VALIDATORS[key](raw[key]); } catch (e) { clog.warn(`Réglage ignoré dans settings.json : ${e.message}`); } } return fileValues; } function loadEnv() { if (envValues) return envValues; envValues = {}; for (const [key, name] of Object.entries(ENV)) { if (process.env[name] === undefined || process.env[name] === '') continue; try { envValues[key] = VALIDATORS[key](process.env[name]); } catch (e) { clog.warn(`Variable d'environnement ${name} ignorée : ${e.message}`); } } return envValues; } /** * Valeurs effectives (défaut < settings.json < environnement) */ function get() { return { ...DEFAULTS, ...loadFile(), ...loadEnv() }; } function isLocked(key) { return Object.prototype.hasOwnProperty.call(loadEnv(), key); } /** * URL sans identifiants éventuels (pour l'affichage) */ function maskUrl(url) { if (!url) return ""; try { const parsed = new URL(url); if (parsed.username || parsed.password) { parsed.username = "***"; parsed.password = ""; } return parsed.toString().replace(/\/$/, ''); } catch (e) { return "***"; } } /** * Pour l'administration : { clé: { value, default, locked, env } } (URL du fournisseur PO token masquée) */ function getPublic() { const values = get(); const result = {}; for (const key of Object.keys(DEFAULTS)) { const value = key === "potProviderUrl" ? maskUrl(values[key]) : values[key]; result[key] = { value, default: DEFAULTS[key], locked: isLocked(key), env: ENV[key] }; } return result; } /** * Vérifie un correctif sans l'appliquer (clés inconnues ou verrouillées refusées) * @param {Object} patch * @returns {Object} valeurs normalisées */ function validate(patch) { if (!patch || typeof patch !== 'object' || Array.isArray(patch)) throw invalid("Réglages invalides"); const clean = {}; for (const [key, value] of Object.entries(patch)) { if (!Object.prototype.hasOwnProperty.call(DEFAULTS, key)) throw invalid(`Réglage inconnu : ${key}`); if (isLocked(key)) throw invalid(`${key} est défini par la variable d'environnement ${ENV[key]}`); clean[key] = VALIDATORS[key](value); } return clean; } /** * Applique et enregistre un correctif de réglages * @param {Object} patch * @param {{id?: string, username?: string}|null} by * @returns {Object} getPublic() */ function update(patch, by = null) { const clean = validate(patch); const changed = Object.keys(clean).filter(key => get()[key] !== clean[key]); if (changed.length === 0) return getPublic(); const next = { ...loadFile(), ...clean }; writeJson(__glob.YOUTUBE_SETTINGS, next); fileValues = next; clog.log(`Réglages YouTube modifiés${by?.username ? ` par ${by.username}` : ''} : ${changed.join(', ')}`); emitUpdate(); return getPublic(); } /** * Relit settings.json et l'environnement (tests) */ function reload() { fileValues = null; envValues = null; } /** * Liste de clients yt-dlp normalisée (test de lecture), lève INVALID_PARAMS si invalide */ function normalizeClients(value) { return toClients("clients", value); } module.exports = { get, getPublic, update, validate, normalizeClients, isLocked, reload, maskUrl, DEFAULTS, ENV };