Files
chopin/src/youtube/settings.js
T

220 lines
7.4 KiB
JavaScript

/**
* [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 };