Version 2.1.0 - Update UI

This commit is contained in:
raphix committed 2026-10-08 10:06:46 +02:00
1 parent 6017e03eac
commit dcc350241e
18 files changed
+3508 -330

No files matched your search

+23
View File
@@ -1,4 +1,27 @@
<div class="changelog-version changelog-actual"> <div class="changelog-version changelog-actual">
<h2>Chopin - Version /*2.0.0*/</h2>
<p class="changelog-date">*_Date de sortie_*: *-07/10/2026-*</p>
<ul>
<li>/#[AJOUT][FRONTEND]#/ Paroles synchronisées du titre en cours : ligne active mise en avant, défilement automatique, clic sur une ligne pour s'y rendre, décalage réglable et recherche manuelle</li>
<li>/#[AJOUT][BACKEND]#/ Nouveau service de paroles : LRCLIB en priorité (paroles horodatées), repli sur lyrics.ovh</li>
<li>/#[AJOUT][FRONTEND]#/ Playlists partagées par serveur, activables par le propriétaire depuis « Gestion » (modification par tous les membres ou par les modérateurs)</li>
<li>/#[AJOUT][FRONTEND]#/ Tri des playlists (nom, dates, nombre de titres, durée) et ordre personnalisé par glisser-déposer</li>
<li>/#[AJOUT][FRONTEND]#/ Nouvelle fenêtre de création de playlist avec détection automatique du lien (YouTube, YouTube Music, Spotify, SoundCloud)</li>
<li>/#[AJOUT][FRONTEND]#/ Nouvel accueil : lecture en cours, accès rapides, écoutes récentes, statistiques du serveur et conseil du moment ; les nouveautés s'ouvrent dans une fenêtre dédiée</li>
<li>/#[AJOUT][FRONTEND]#/ Refonte du panneau d'administration : widgets regroupés par catégorie et navigation repensée</li>
<li>/#[AJOUT][FRONTEND]#/ Accessibilité : navigation complète au clavier, focus visible, fenêtres accessibles, meilleurs contrastes du thème clair et respect des animations réduites</li>
<li>/#[AJOUT][FRONTEND]#/ Panneau d'administration : comptes, serveurs, players, journaux, métriques, configuration et maintenance</li>
<li>/#[FIX][BACKEND]#/ Refonte de la lecture YouTube (yt-dlp tenu à jour, jetons PO, reprises automatiques) : fin des erreurs 403 et des vérifications « robot »</li>
<li>/#[AJOUT][BACKEND]#/ Connexion d'un compte YouTube par cookies depuis l'administration, avec vérification et test de lecture</li>
<li>/#[AJOUT][BACKEND]#/ Liaison du compte Google pour importer et synchroniser ses playlists YouTube, titres « J'aime » compris</li>
<li>/#[AJOUT][BACKEND]#/ Passage par le proxy Cloudflare WARP avec bascule automatique sur la connexion directe</li>
<li>/#[FIX][BACKEND]#/ Spotify : identifiants rechargés à chaud et conversion plus rapide des albums et playlists</li>
<li>/#[FIX][PLAYER]#/ Déplacement dans un titre plus fiable et reprise automatique en cas de coupure du flux</li>
<li>/#[FIX][BACKEND]#/ Stabilité du serveur : bannissements appliqués immédiatement, plantages corrigés, titres envoyés par le site vérifiés</li>
</ul>
</div>
<div class="changelog-version">
<h2>Chopin - Version /*1.4.0*/</h2> <h2>Chopin - Version /*1.4.0*/</h2>
<p class="changelog-date">*_Date de sortie_*: *-06/12/2025-*</p> <p class="changelog-date">*_Date de sortie_*: *-06/12/2025-*</p>
<ul> <ul>
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "chopin-backend", "name": "chopin-backend",
"version": "2.0.0", "version": "2.1.0",
"description": "Discord Bot for music - Fetching everywhere !", "description": "Discord Bot for music - Fetching everywhere !",
"main": "src/main.js", "main": "src/main.js",
"nodemonConfig": { "nodemonConfig": {
+55 -1
View File
@@ -39,6 +39,57 @@ function setSecureRole(guildId, role) {
return true; return true;
} }
// Playlists partagées : désactivées par défaut ; "members" = tout membre peut les modifier, "mods" = modérateurs
const SHARED_PLAYLISTS_EDITORS = ["members", "mods"];
const SHARED_PLAYLISTS_DEFAULTS = Object.freeze({ enabled: false, editors: "members" });
/**
* Réglage des playlists partagées d'un serveur. Lecture seule : valeurs par défaut pour un serveur inconnu
* ou une ancienne entrée (qui ne contient que secureRole), jamais d'écriture.
* @param {string} guildId
* @returns {{enabled: boolean, editors: "members"|"mods"}}
*/
function getSharedPlaylists(guildId) {
const result = { ...SHARED_PLAYLISTS_DEFAULTS };
if (typeof guildId !== "string" || guildId === "") return result;
const data = ServerDB.getData() || {};
if (!Object.prototype.hasOwnProperty.call(data, guildId)) return result;
const stored = data[guildId]?.sharedPlaylists;
if (!stored || typeof stored !== "object") return result;
if (typeof stored.enabled === "boolean") result.enabled = stored.enabled;
if (SHARED_PLAYLISTS_EDITORS.includes(stored.editors)) result.editors = stored.editors;
return result;
}
/**
* Modifie le réglage des playlists partagées (champs absents conservés).
* Désactiver ne supprime aucune playlist : elles sont seulement masquées jusqu'à la réactivation.
* @param {string} guildId
* @param {{enabled?: boolean, editors?: "members"|"mods"}} patch
* @returns {{enabled: boolean, editors: string}|null} nouveau réglage, null si les paramètres sont invalides
*/
function setSharedPlaylists(guildId, patch) {
if (typeof guildId !== "string" || !/^\d{1,25}$/.test(guildId)) {
clog.warn("Impossible de modifier les playlists partagées sans identifiant de serveur valide");
return null;
}
if (!patch || typeof patch !== "object" || Array.isArray(patch)) return null;
const hasEnabled = patch.enabled !== undefined;
const hasEditors = patch.editors !== undefined;
if (!hasEnabled && !hasEditors) return null;
if (hasEnabled && typeof patch.enabled !== "boolean") return null;
if (hasEditors && !SHARED_PLAYLISTS_EDITORS.includes(patch.editors)) return null;
const next = getSharedPlaylists(guildId);
if (hasEnabled) next.enabled = patch.enabled;
if (hasEditors) next.editors = patch.editors;
checkKey(guildId);
ServerDB.getData()[guildId].sharedPlaylists = { ...next };
ServerDB.save();
clog.log(`Playlists partagées du serveur ${guildId} : ${next.enabled ? "activées" : "désactivées"} (modification : ${next.editors === "mods" ? "modérateurs" : "tous les membres"})`);
return next;
}
function checkKey(guildId) { function checkKey(guildId) {
const data = ServerDB.getData(); const data = ServerDB.getData();
if (!data[guildId]) { if (!data[guildId]) {
@@ -48,5 +99,8 @@ function checkKey(guildId) {
module.exports = { module.exports = {
getSecureRole, getSecureRole,
setSecureRole setSecureRole,
getSharedPlaylists,
setSharedPlaylists,
SHARED_PLAYLISTS_EDITORS
}; };
+916 -55
View File
@@ -1,62 +1,923 @@
/**
* Paroles des morceaux : LRCLIB (base libre, paroles synchronisées au format LRC) en priorité,
* puis l'ancienne chaîne Deezer (recherche) + lyrics.ovh (texte seul) en repli.
*
* Toutes les réponses publiques ont la forme « Result » :
* {ok:true, songKey, source:"lrclib"|"lyrics.ovh", synced, lines:[{t,text}]|null, plain, instrumental, match}
* {ok:false, error:"NO_MUSIC"|"NOT_FOUND"|"UNAVAILABLE"|"INVALID_PARAMS", songKey?}
*/
const { LogType } = require("loguix"); const { LogType } = require("loguix");
const plog = new LogType('Lyrics'); const { __glob } = require("../utils/GlobalVars");
const urls = require('./urls.json'); const { createLimiter, createCache } = require("../utils/Limiter");
const urls = require("./urls.json");
// Délai maximal de chaque requête : sans réponse du fournisseur, /PLAYER/LYRICS restait en attente indéfiniment const plog = new LogType("Lyrics");
const REQUEST_TIMEOUT = 8000
// Make sure Url exists and get lyrics for the first item only const LRCLIB = String(urls.lrclib || "https://lrclib.net/api").replace(/\/+$/, "");
async function getLyrics(name) { // LRCLIB demande un User-Agent identifiant l'application
let result = null; const USER_AGENT = `Subsonics-Chopin/${__glob.VERSION || "dev"} (${urls.project || "https://git.raphix.fr/subsonics"})`;
try {
const searchResponse = await fetch(`${urls.urlSearch}${encodeURIComponent(name)}`, {
method: 'GET',
headers: {
'content-type': 'application/json'
},
signal: AbortSignal.timeout(REQUEST_TIMEOUT)
});
const searchData = await searchResponse.json();
// Check if data exists and has at least one item // Délai maximal de chaque requête : sans réponse du fournisseur, la demande restait en attente indéfiniment
if (searchData && searchData.data && searchData.data.length > 0) { const REQUEST_TIMEOUT = 8000;
const firstItem = searchData.data[0]; // Budget total d'une recherche (toutes sources confondues) : le client n'attend pas éternellement
const artist = firstItem.artist && firstItem.artist.name ? firstItem.artist.name : null; const LOOKUP_BUDGET = 20000;
const title = firstItem.title || null; // En dessous, inutile de lancer une requête de plus
const MIN_REQUEST_MS = 1000;
// Écart de durée toléré pour garder la synchronisation (intro/outro des clips vidéo)
const DURATION_WINDOW = 10;
const MAX_QUERY = 200;
const MAX_LINES = 3000;
const MAX_PLAIN = 50000;
if (artist && title) { const MINUTE = 60 * 1000;
try { const HOUR = 60 * MINUTE;
const lyricsResponse = await fetch(`${urls.urlGet}${encodeURIComponent(artist)}/${encodeURIComponent(title)}`, {
method: 'GET', // Même politesse que pour YouTube : 2 requêtes simultanées, 250 ms entre deux démarrages
headers: { const limiter = createLimiter(2, { minIntervalMs: 250 });
'content-type': 'application/json' // Clé = requête normalisée (artiste|titre|durée) : deux vidéos du même morceau partagent le résultat
}, const lookupCache = createCache({ ttlMs: 6 * HOUR, max: 1000 });
signal: AbortSignal.timeout(REQUEST_TIMEOUT) const idCache = createCache({ ttlMs: 6 * HOUR, max: 300 });
}); const searchCache = createCache({ ttlMs: 10 * MINUTE, max: 200 });
const lyricsData = await lyricsResponse.json();
if (lyricsData && lyricsData.lyrics) { // Trouvé : 6 h ; introuvable : 20 min (une saisie LRCLIB peut arriver) ; service injoignable : 1 min
result = lyricsData.lyrics; function ttlForResult(result) {
} else { if (!result) return 0;
plog.error('Invalid response structure: ' + JSON.stringify(lyricsData).slice(0, 200)); if (result.ok) return 6 * HOUR;
return null; if (result.error === "NOT_FOUND") return 20 * MINUTE;
} if (result.error === "UNAVAILABLE") return MINUTE;
} catch (error) { return 0;
plog.error('Error fetching lyrics data: ' + (error?.message || error));
return null;
}
} else {
plog.error('Artist or title missing in search result');
return null;
}
} else {
plog.error('No search results found');
return null;
}
} catch (error) {
plog.error('Error fetching search data: ' + (error?.message || error));
return null;
}
return result;
} }
module.exports = { getLyrics }; // ---------------------------------------------------------------------------------------------
// Analyse LRC
// ---------------------------------------------------------------------------------------------
// [mm:ss], [mm:ss.x], [mm:ss.xx], [mm:ss.xxx] (séparateur « . », « , » ou « : » pour la fraction)
const TIME_TAG = /^\[(\d{1,3}):(\d{1,2})(?:[.,:](\d{1,3}))?\]/;
const META_TAG = /^\[([a-z#]+):([^\]]*)\]\s*$/i;
// Horodatage par mot du format LRC « enrichi » : <mm:ss.xx>
const WORD_TAG = /<\d{1,3}:\d{1,2}(?:[.,:]\d{1,3})?>/g;
/**
* Convertit un texte LRC en lignes horodatées triées.
* Gère plusieurs horodatages par ligne, [offset:±ms], les balises de métadonnées (ignorées)
* et conserve les lignes vides horodatées (pauses) avec text:"".
* @param {string} text
* @returns {{t:number, text:string}[]}
*/
function parseLrc(text) {
if (typeof text !== "string" || !text) return [];
let offset = 0;
const entries = [];
let index = 0;
for (const rawLine of text.split(/\r\n|\r|\n/)) {
let line = rawLine.replace(/^/, "").trim();
if (!line) continue;
const times = [];
let match;
while ((match = TIME_TAG.exec(line))) {
const minutes = Number(match[1]);
const seconds = Number(match[2]);
if (seconds < 60) {
const fraction = match[3] ? Number(match[3].padEnd(3, "0")) : 0;
times.push(minutes * 60000 + seconds * 1000 + fraction);
}
line = line.slice(match[0].length).trimStart();
}
if (times.length === 0) {
// Balise de métadonnées : seul l'offset nous intéresse
const meta = META_TAG.exec(line);
if (meta && meta[1].toLowerCase() === "offset") {
const value = Number(meta[2].trim());
if (Number.isFinite(value)) offset = value;
}
continue;
}
const content = line.replace(WORD_TAG, "").replace(/\s+/g, " ").trim();
for (const t of times) entries.push({ t, text: content, index: index++ });
if (entries.length >= MAX_LINES) break;
}
// Offset positif = paroles affichées plus tôt (convention LRC)
return entries
.map(entry => ({ t: Math.max(0, Math.round(entry.t - offset)), text: entry.text, index: entry.index }))
.sort((a, b) => a.t - b.t || a.index - b.index)
.map(({ t, text: content }) => ({ t, text: content }));
}
// Texte brut propre : fins de ligne unifiées, au plus une ligne vide consécutive
function normalizePlain(text) {
if (typeof text !== "string") return null;
const clean = text
.replace(/\r\n|\r/g, "\n")
.split("\n")
.map(line => line.trim())
.join("\n")
.replace(/\n{3,}/g, "\n\n")
.trim();
if (!clean) return null;
return clean.length > MAX_PLAIN ? clean.slice(0, MAX_PLAIN) : clean;
}
function linesToPlain(lines) {
return normalizePlain(lines.map(line => line.text).join("\n"));
}
// ---------------------------------------------------------------------------------------------
// Nettoyage des titres / artistes (titres YouTube, chaînes VEVO / « - Topic »…)
// ---------------------------------------------------------------------------------------------
// Contenu d'une parenthèse ou d'un suffixe « - … » qui ne fait pas partie du titre
const NOISE = /(?:^|[^\p{L}\p{N}])(?:official|officiel(?:le)?|video|vid[ée]o|clip|audio|lyrics?|paroles?|visuali[sz]er|hd|hq|uhd|4k|8k|\d{3,4}p|remaster(?:ed)?|remasteris[ée]e?|mv|m\/v|explicit|clean|radio edit|single version|album version|version (?:originale|officielle)|feat\.?|ft\.?|featuring|prod\.?(?: by)?|out now|free download|color coded|full album)(?=$|[^\p{L}\p{N}])/iu;
// Suffixe après tiret qui désigne une version du même morceau (on le retire pour la recherche)
const VERSION_SUFFIX = /^(?:\d{4}\s+)?(?:live|remaster(?:ed)?|version|edit|radio|acoustic|acoustique|from|bonus|demo|mono|stereo|st[ée]r[ée]o|instrumental|extended|original|single|album|mix|remix|re-?recorded|sped up|slowed|taylor'?s version)\b/i;
const NOISE_ALL = new RegExp(NOISE.source, "giu");
// Mots de liaison tolérés dans un suffixe parasite (« Official Music Video », « Remastered 2011 »)
const FILLER = new Set(["music", "musical", "new", "the", "with", "and", "et", "in", "by", "version", "full", "video", "audio"]);
const FEAT = /\s+(?:feat\.?|ft\.?|featuring)\s+[^-–—()[\]{}]*/gi;
const EXTENSION = /\.(?:mp3|flac|wav|ogg|oga|m4a|opus|webm|aac|wma|alac|aiff?|mp4|mkv|mov)$/i;
const SEPARATOR = /\s+[-–—]\s+/;
const PLACEHOLDERS = new Set(["aucun titre", "auteur inconnu", "aucun fichier"]);
function capText(value) {
return value.length > MAX_QUERY ? value.slice(0, MAX_QUERY).trim() : value;
}
function tidy(value) {
return value
.replace(/\s+/g, " ")
.replace(/^[\s\-–—|:,;~/·•*]+|[\s\-–—|:,;~/·•*]+$/g, "")
.trim();
}
// Un suffixe « - … » n'est retiré que s'il ne contient QUE du bruit :
// « - Official Video » oui, « The Buggles - Video Killed the Radio Star » non
function isNoiseSegment(segment) {
if (!NOISE.test(segment)) return false;
const rest = normalizeText(` ${segment} `.replace(NOISE_ALL, " "));
return rest.split(" ").every(word => !word || FILLER.has(word) || /^\d{1,4}$/.test(word));
}
// Retire le bruit « structurel » (crochets, parenthèses parasites, suffixes, émojis) sans toucher aux « feat. »
// ni au séparateur « Artiste - Titre » : sert avant le découpage
function stripNoise(input) {
let value = String(input)
.replace(/\p{Extended_Pictographic}|️/gu, " ")
// Crochets : toujours du bruit ([HD], [Official Video], 【MV】…)
.replace(/\[[^\]]*\]|【[^】]*】|〖[^〗]*〗/g, " ")
.replace(/(^|\s)#[\p{L}\p{N}_]+/gu, " ");
// Parenthèses / accolades parasites (on garde « (Remix) », « (Live) », « (Don't Fear) The Reaper »…)
value = value.replace(/[(({]([^()(){}]*)[))}]/g, (all, inner) => (NOISE.test(inner) ? " " : all));
// Tout ce qui suit « | » ou « // » est de la description (« Titre | Clip officiel »)
value = value.replace(/\s+(?:\||||\/\/)\s+.*$/, "");
// Suffixes « - Official Video », « - Remastered 2011 »…
let previous;
do {
previous = value;
value = value.replace(/\s+[-–—]\s+([^-–—]+)$/, (all, segment) => (isNoiseSegment(segment) ? "" : all));
value = value.replace(/\s+(?:official\s+(?:music\s+)?video|official\s+audio|official\s+lyric\s+video|lyric\s+video|lyrics|clip\s+officiel|audio\s+officiel|vid[ée]o\s+officielle|hd|hq|4k)\s*$/i, "");
} while (value !== previous);
return tidy(value.replace(/["“”«»„]/g, " "));
}
/**
* Nettoie un titre pour la recherche : (Official Video), [HD], « feat. X », « - Remastered 2011 »…
* @param {string} str
* @returns {string}
*/
function cleanTitle(str) {
if (str === undefined || str === null) return "";
const original = tidy(String(str));
if (!original) return "";
const cleaned = tidy(stripNoise(original).replace(FEAT, " "));
return capText(cleaned || original);
}
/**
* Nettoie un nom d'artiste / de chaîne : « Artist - Topic », « ArtistVEVO », « Artist Official »…
* @param {string} str
* @returns {string}
*/
function cleanArtist(str) {
if (str === undefined || str === null) return "";
let value = tidy(String(str).replace(/\p{Extended_Pictographic}|️/gu, " "));
if (!value || PLACEHOLDERS.has(value.toLowerCase())) return "";
const original = value;
value = value.replace(/\s*[-–—]\s*topic$/i, "");
const vevo = /vevo$/i.test(value);
value = value.replace(/\s*vevo$/i, "");
// « DaftPunkVEVO » → « Daft Punk »
if (vevo && !/\s/.test(value)) value = value.replace(/(\p{Ll})(\p{Lu})/gu, "$1 $2");
value = value.replace(/\s*[[(][^\])]*\b(?:official|officiel(?:le)?)\b[^\])]*[\])]/gi, " ");
value = value.replace(/\s+(?:official(?:\s+(?:artist\s+)?channel)?|officiel(?:le)?|channel|cha[iî]ne\s+officielle)$/i, "");
value = value.replace(/\s+(?:feat\.?|ft\.?|featuring)\s+.*$/i, "");
value = tidy(value);
return capText(value || original);
}
// ---------------------------------------------------------------------------------------------
// Similarité
// ---------------------------------------------------------------------------------------------
function normalizeText(value) {
return String(value || "")
.normalize("NFKD")
.replace(/[̀-ͯ]/g, "")
.toLowerCase()
.replace(/&/g, " and ")
.replace(/['’`´]/g, "")
.replace(/[^\p{L}\p{N}]+/gu, " ")
.trim();
}
function tokenSet(value) {
const normalized = normalizeText(value);
return new Set(normalized ? normalized.split(" ") : []);
}
// 1 = identique ; inclusion complète d'un côté dans l'autre = 0.6–0.9 selon la différence de longueur
function similarity(a, b) {
const na = normalizeText(a);
const nb = normalizeText(b);
if (!na || !nb) return 0;
if (na === nb) return 1;
if (na.replace(/ /g, "") === nb.replace(/ /g, "")) return 0.95;
const A = tokenSet(na);
const B = tokenSet(nb);
let common = 0;
for (const token of A) if (B.has(token)) common++;
if (!common) return 0;
const dice = (2 * common) / (A.size + B.size);
const small = Math.min(A.size, B.size);
const large = Math.max(A.size, B.size);
const contained = common === small ? 0.6 + 0.3 * (small / large) : 0;
return Math.max(dice, contained);
}
// Part des mots de `part` présents dans `whole` (recherche en texte libre)
function coverage(part, whole) {
const P = tokenSet(part);
const W = whole instanceof Set ? whole : tokenSet(whole);
if (!P.size) return 0;
let found = 0;
for (const token of P) if (W.has(token)) found++;
return found / P.size;
}
// ---------------------------------------------------------------------------------------------
// Requête à partir d'un morceau
// ---------------------------------------------------------------------------------------------
function toDuration(value) {
const number = Number(value);
return Number.isFinite(number) && number > 0 && number < 86400 ? Math.round(number) : 0;
}
function textField(value) {
if (typeof value !== "string" && typeof value !== "number") return "";
const text = String(value).trim();
return PLACEHOLDERS.has(text.toLowerCase()) ? "" : text;
}
function looksLike(part, artist) {
if (!part || !artist) return false;
if (similarity(part, artist) >= 0.6) return true;
const np = normalizeText(part).replace(/ /g, "");
const na = normalizeText(artist).replace(/ /g, "");
return !!np && !!na && (np.includes(na) || na.includes(np));
}
/**
* Devine {track, artist, duration} pour la recherche de paroles.
* Les titres YouTube « Artiste - Titre (Official Video) » sont découpés ; les morceaux convertis
* depuis Spotify (titre et artiste déjà propres) passent quasiment inchangés.
* @param {{title?:string, author?:string, duration?:number, type?:string}} song
* @returns {{track:string, artist:string, duration:number}}
*/
function guessQuery(song) {
const source = song && typeof song === "object" ? song : {};
let title = textField(source.title);
const author = textField(source.author);
const duration = toDuration(source.duration);
// Fichiers envoyés : « 01 - Artiste - Titre.mp3 », « artiste_titre.flac »
if (source.type === "attachment" || EXTENSION.test(title)) {
title = title.replace(EXTENSION, "").replace(/_/g, " ").replace(/^(?:\d{1,3}\s*[-.)]\s*|0\d\s+)(?=\S)/, "");
}
let artist = cleanArtist(author);
let rough = stripNoise(title);
let track = "";
// Artiste "Titre" / Artiste « Titre » (seulement si la partie gauche est bien l'artiste,
// sinon « Theme from "Shaft" » perdrait son titre)
const quoted = /^(.+?)\s+["“«]\s*(.+?)\s*["”»]/.exec(title);
if (!SEPARATOR.test(rough) && quoted && (!artist || looksLike(quoted[1], artist))) {
artist = cleanArtist(quoted[1]) || artist;
rough = stripNoise(quoted[2]);
}
const parts = rough.split(SEPARATOR);
if (parts.length >= 2) {
const left = parts[0];
const right = parts.slice(1).join(" - ");
if (artist && looksLike(left, artist)) {
// « Artiste - Titre » avec l'artiste déjà connu (le titre est plus fiable que la chaîne)
artist = cleanArtist(left) || artist;
track = right;
} else if (artist && looksLike(right, artist)) {
// « Titre - Artiste »
track = left;
} else if (VERSION_SUFFIX.test(right.trim())) {
// « Titre - Live at Wembley » (style Spotify) : on garde le titre
track = left;
} else {
// Chaîne de label / inconnue : on fait confiance au titre de la vidéo
artist = cleanArtist(left) || artist;
track = right;
}
} else {
track = rough;
}
track = cleanTitle(track) || cleanTitle(title);
return { track: capText(track), artist: capText(artist), duration };
}
/**
* Clé identifiant un morceau côté client : `${type}:${id}`, sinon l'URL, sinon null.
* @param {object} song
* @returns {string|null}
*/
function songKeyOf(song) {
if (!song || typeof song !== "object") return null;
const id = typeof song.id === "number" ? String(song.id) : song.id;
if (typeof id === "string" && id) return `${song.type}:${id}`;
if (typeof song.url === "string" && song.url) return song.url;
return null;
}
// ---------------------------------------------------------------------------------------------
// HTTP
// ---------------------------------------------------------------------------------------------
class SourceError extends Error {
constructor(code, message) {
super(message || code);
this.code = code;
}
}
function describe(error) {
if (!error) return "erreur inconnue";
if (error.code && error instanceof SourceError) return error.message;
if (error.name === "TimeoutError" || error.name === "AbortError") return "délai dépassé";
return error.cause?.code || error.message || String(error);
}
const RETRY_DELAY = 600;
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
function isRetryable(error) {
if (error instanceof SourceError) return error.retryable === true;
// Coupure réseau ponctuelle oui, délai dépassé non (on doublerait l'attente)
return !!error && error.name !== "TimeoutError" && error.name !== "AbortError";
}
/**
* GET JSON limité (concurrence + espacement), avec délai. 404 → {status:404, data:null}.
* Une seule nouvelle tentative après un 429/5xx ou une coupure réseau (LRCLIB renvoie parfois un 503 passager).
* @returns {Promise<{status:number, data:any}>}
*/
async function getJson(url, ctx, attempt = 0) {
try {
return await getJsonOnce(url, ctx);
} catch (error) {
const remaining = ctx ? ctx.deadline - Date.now() : REQUEST_TIMEOUT;
if (attempt > 0 || !isRetryable(error) || remaining < MIN_REQUEST_MS + RETRY_DELAY) throw error;
await sleep(RETRY_DELAY);
return getJson(url, ctx, attempt + 1);
}
}
async function getJsonOnce(url, ctx) {
const remaining = ctx ? ctx.deadline - Date.now() : REQUEST_TIMEOUT;
if (remaining < MIN_REQUEST_MS) throw new SourceError("BUDGET", "budget de recherche épuisé");
return limiter.run(async () => {
const left = ctx ? ctx.deadline - Date.now() : REQUEST_TIMEOUT;
if (left < MIN_REQUEST_MS) throw new SourceError("BUDGET", "budget de recherche épuisé");
const response = await fetch(url, {
method: "GET",
headers: { "User-Agent": USER_AGENT, "Accept": "application/json" },
signal: AbortSignal.timeout(Math.min(REQUEST_TIMEOUT, left)),
});
if (response.status === 404) {
// Libère la connexion
await response.text().catch(() => {});
return { status: 404, data: null };
}
if (!response.ok) {
await response.text().catch(() => {});
const failure = new SourceError("HTTP", `HTTP ${response.status}`);
failure.retryable = response.status === 429 || response.status >= 500;
throw failure;
}
let data;
try {
data = await response.json();
} catch {
throw new SourceError("BAD_JSON", "réponse illisible");
}
return { status: response.status, data };
});
}
function newContext() {
return { deadline: Date.now() + LOOKUP_BUDGET, lrclibDown: false };
}
// ---------------------------------------------------------------------------------------------
// LRCLIB
// ---------------------------------------------------------------------------------------------
function isRecord(value) {
return !!value && typeof value === "object" && !Array.isArray(value) && value.id !== undefined;
}
// En dessous, l'entrée est un essai / du vandalisme (vu en vrai : « [00:00.00]probe »)
const MIN_LYRIC_LINES = 3;
const ANY_TIME_TAG = /\[\d{1,3}:\d{1,2}(?:[.,:]\d{1,3})?\]|<\d{1,3}:\d{1,2}(?:[.,:]\d{1,3})?>/g;
function lyricLineCount(text) {
if (typeof text !== "string") return 0;
let count = 0;
for (const line of text.split(/\r\n|\r|\n/)) {
const content = line.replace(ANY_TIME_TAG, "").trim();
// Les balises de métadonnées [ar:…] ne comptent pas
if (content && !META_TAG.test(content)) count++;
}
return count;
}
function hasSynced(record) {
return lyricLineCount(record.syncedLyrics) >= MIN_LYRIC_LINES;
}
function hasPlain(record) {
return lyricLineCount(record.plainLyrics) >= MIN_LYRIC_LINES;
}
function hasContent(record) {
return record.instrumental === true || hasSynced(record) || hasPlain(record);
}
function matchOf(record) {
return {
id: record.id,
track: String(record.trackName || record.name || ""),
artist: String(record.artistName || ""),
album: String(record.albumName || ""),
duration: toDuration(record.duration),
};
}
/**
* Construit un Result depuis un enregistrement LRCLIB.
* Si la durée du morceau s'écarte de plus de DURATION_WINDOW secondes, la synchronisation serait
* fausse (intro d'un clip…) : on ne renvoie alors que le texte.
*/
function resultFromRecord(record, query) {
if (!isRecord(record) || !hasContent(record)) return null;
const instrumental = record.instrumental === true;
const parsed = hasSynced(record) ? parseLrc(record.syncedLyrics) : [];
const timed = parsed.filter(line => line.text).length >= MIN_LYRIC_LINES;
const expected = query ? toDuration(query.duration) : 0;
const actual = toDuration(record.duration);
const durationOk = !expected || !actual || Math.abs(expected - actual) <= DURATION_WINDOW;
const synced = !instrumental && timed && durationOk;
const plain = instrumental ? null : ((hasPlain(record) ? normalizePlain(record.plainLyrics) : null) || (timed ? linesToPlain(parsed) : null));
if (!instrumental && !synced && !plain) return null;
return {
ok: true,
songKey: null,
source: "lrclib",
synced,
lines: synced ? parsed : null,
plain,
instrumental,
match: matchOf(record),
};
}
/**
* Note un candidat (LRCLIB ou Deezer normalisé {trackName, artistName, duration, …}).
* @returns {{score:number, inWindow:boolean}|null} null = candidat rejeté
*/
function scoreCandidate(candidate, query) {
if (!candidate || typeof candidate !== "object") return null;
const rawTrack = String(candidate.trackName || candidate.name || "");
const candTrack = cleanTitle(rawTrack);
const candArtist = String(candidate.artistName || "");
if (!candTrack) return null;
const expected = toDuration(query.duration);
const actual = toDuration(candidate.duration);
let durScore = 0.5;
let inWindow = true;
let delta = null;
if (expected && actual) {
delta = Math.abs(expected - actual);
inWindow = delta <= DURATION_WINDOW;
durScore = delta <= 2 ? 1 : inWindow ? 1 - (delta - 2) / 16 : 0.1;
} else if (expected) {
inWindow = false;
}
let score;
if (query.artist) {
const tSim = Math.max(
similarity(query.track, candTrack),
// Saisies « Artiste - Titre » dans le champ titre
similarity(`${query.artist} ${query.track}`, candTrack)
);
const aSim = Math.max(similarity(query.artist, candArtist), similarity(query.artist, rawTrack) >= 0.6 ? 0.6 : 0);
if (tSim < 0.5) return null;
if (aSim < 0.3 && !(tSim >= 0.9 && delta !== null && delta <= 3)) return null;
score = 2 * tSim + 1.5 * aSim + 2 * durScore;
} else {
// Texte libre : les mots du candidat doivent se retrouver dans la requête
const words = tokenSet(query.track);
const tCov = coverage(candTrack, words);
const aCov = coverage(candArtist, words);
const qCov = coverage(query.track, tokenSet(`${candArtist} ${candTrack}`));
if (tCov < 0.75 || qCov < 0.5) return null;
score = 2 * tCov + aCov + 1.5 * qCov + 2 * durScore;
}
if (hasSynced(candidate)) score += 0.6;
else if (hasPlain(candidate)) score += 0.2;
// Saisies d'internautes « Artiste - Titre (Official Video) » : souvent moins bien calées
if (candTrack !== tidy(rawTrack)) score -= 0.2;
// « Artiste - Titre » dans le champ titre (en recherche libre, comparé à l'artiste du candidat)
else if (SEPARATOR.test(rawTrack) && looksLike(rawTrack.split(SEPARATOR)[0], query.artist || candArtist)) score -= 0.2;
return { score, inWindow };
}
/**
* Choisit le meilleur candidat : d'abord ceux dans la fenêtre de durée, puis le meilleur score
* (paroles synchronisées favorisées, durée la plus proche, similarité titre/artiste).
* @param {object[]} candidates
* @param {{track:string, artist:string, duration:number}} query
* @param {{requireContent?: boolean}} [options]
* @returns {object|null}
*/
function pickBestCandidate(candidates, query, options = {}) {
const requireContent = options.requireContent !== false;
let best = null;
for (const candidate of Array.isArray(candidates) ? candidates : []) {
if (requireContent && (!isRecord(candidate) || !hasContent(candidate))) continue;
const rated = scoreCandidate(candidate, query);
if (!rated) continue;
if (!best
|| (rated.inWindow && !best.inWindow)
|| (rated.inWindow === best.inWindow && rated.score > best.score)) {
best = { candidate, ...rated };
}
}
return best ? best.candidate : null;
}
async function lrclibCall(path, params, ctx) {
const search = new URLSearchParams();
for (const [key, value] of Object.entries(params || {})) {
if (value !== undefined && value !== null && value !== "") search.set(key, String(value));
}
const query = search.toString();
try {
return await getJson(`${LRCLIB}${path}${query ? "?" + query : ""}`, ctx);
} catch (error) {
if (ctx) ctx.lrclibDown = true;
plog.warn(`LRCLIB injoignable (${path}) : ${describe(error)}`);
throw error;
}
}
async function lrclibSearch(params, ctx) {
const { status, data } = await lrclibCall("/search", params, ctx);
if (status === 404 || !Array.isArray(data)) return [];
return data.filter(isRecord).slice(0, 20);
}
async function lookupLrclib(query, ctx) {
const candidates = [];
const seen = new Set();
const add = (list) => {
for (const record of list) {
if (seen.has(record.id)) continue;
seen.add(record.id);
candidates.push(record);
}
};
if (query.artist) {
// 1. Correspondance exacte (durée ignorée si inconnue / direct)
const { status, data } = await lrclibCall("/get", {
artist_name: query.artist,
track_name: query.track,
duration: query.duration || undefined,
}, ctx);
if (status !== 404 && isRecord(data) && hasContent(data)) {
const rated = scoreCandidate(data, query);
if (!query.duration || (rated && rated.inWindow)) return data;
add([data]);
}
// 2. Recherche par champs
add(await lrclibSearch({ track_name: query.track, artist_name: query.artist }, ctx));
const best = pickBestCandidate(candidates, query);
if (best && scoreCandidate(best, query).inWindow) return best;
}
// 3. Recherche libre « artiste titre »
add(await lrclibSearch({ q: query.artist ? `${query.artist} ${query.track}` : query.track }, ctx));
return pickBestCandidate(candidates, query);
}
// ---------------------------------------------------------------------------------------------
// Repli : Deezer (recherche) + lyrics.ovh (texte seul)
// ---------------------------------------------------------------------------------------------
async function fetchOvh(artist, track, ctx) {
const { status, data } = await getJson(`${urls.urlGet}${encodeURIComponent(artist)}/${encodeURIComponent(track)}`, ctx);
if (status === 404 || !data || typeof data.lyrics !== "string") return null;
// lyrics.ovh préfixe parfois « Paroles de la chanson X par Y »
return normalizePlain(data.lyrics.replace(/^\s*Paroles de la chanson .+ par .+(?:\r\n|\r|\n)/i, ""));
}
async function lookupLegacy(query, ctx) {
let pick = null;
try {
const text = query.artist ? `${query.artist} ${query.track}` : query.track;
const { data } = await getJson(`${urls.urlSearch}${encodeURIComponent(text)}`, ctx);
if (data && data.error) throw new SourceError("DEEZER", "Deezer : " + String(data.error.message || data.error.type || "erreur"));
const hits = (data && Array.isArray(data.data) ? data.data : []).map(hit => ({
id: hit?.id,
trackName: hit?.title_short || hit?.title || "",
artistName: hit?.artist?.name || "",
albumName: hit?.album?.title || "",
duration: hit?.duration,
}));
pick = pickBestCandidate(hits, query, { requireContent: false });
} catch (error) {
plog.warn(`Deezer injoignable : ${describe(error)}`);
// Sans Deezer, on tente lyrics.ovh avec la requête devinée
if (query.artist) pick = { trackName: query.track, artistName: query.artist, albumName: "", duration: 0 };
}
if (!pick || !pick.artistName || !pick.trackName) return null;
let plain;
try {
plain = await fetchOvh(pick.artistName, pick.trackName, ctx);
} catch (error) {
plog.warn(`lyrics.ovh injoignable : ${describe(error)}`);
return null;
}
if (!plain) return null;
return {
ok: true,
songKey: null,
source: "lyrics.ovh",
synced: false,
lines: null,
plain,
instrumental: false,
match: {
track: String(pick.trackName),
artist: String(pick.artistName),
album: String(pick.albumName || ""),
duration: toDuration(pick.duration),
},
};
}
// ---------------------------------------------------------------------------------------------
// Recherche complète
// ---------------------------------------------------------------------------------------------
function label(query) {
return query.artist ? `${query.artist} - ${query.track}` : query.track;
}
async function lookup(query) {
const ctx = newContext();
try {
const record = await lookupLrclib(query, ctx);
const result = record && resultFromRecord(record, query);
if (result) {
plog.log(`Paroles trouvées sur LRCLIB (${result.instrumental ? "instrumental" : result.synced ? "synchronisées" : "texte seul"}) : ${label(query)}`);
return result;
}
} catch {
// LRCLIB injoignable : déjà journalisé, on passe au repli
}
const legacy = await lookupLegacy(query, ctx);
if (legacy) {
plog.log(`Paroles trouvées sur lyrics.ovh : ${label(query)}`);
return legacy;
}
if (ctx.lrclibDown) return { ok: false, error: "UNAVAILABLE" };
// Un morceau sans paroles connues est un cas normal : simple log
plog.log(`Aucune parole trouvée : ${label(query)}`);
return { ok: false, error: "NOT_FOUND" };
}
function queryKey(query) {
return `${normalizeText(query.artist)}|${normalizeText(query.track)}|${query.duration || 0}`;
}
async function cachedLookup(query) {
return lookupCache.wrap(queryKey(query), () => lookup(query), { ttlFor: ttlForResult });
}
function withKey(result, songKey) {
return { ...result, songKey };
}
/**
* Paroles du morceau (objet Song ou équivalent sérialisé).
* @param {object} song
* @returns {Promise<object>} Result
*/
async function getLyricsForSong(song) {
if (!song || typeof song !== "object") return { ok: false, error: "NO_MUSIC" };
const songKey = songKeyOf(song);
try {
const query = guessQuery(song);
if (!query.track) return { ok: false, error: "INVALID_PARAMS", songKey };
return withKey(await cachedLookup(query), songKey);
} catch (error) {
plog.warn(`Recherche de paroles interrompue : ${describe(error)}`);
return { ok: false, error: "UNAVAILABLE", songKey };
}
}
function validText(value, required) {
if (value === undefined || value === null || value === "") return required ? null : "";
if (typeof value !== "string") return null;
const text = value.trim();
if (required && !text) return null;
return text.length > MAX_QUERY ? null : text;
}
/**
* Meilleures paroles pour une recherche manuelle : texte libre (« artiste titre ») ou {track, artist?, duration?}.
* @param {string|{track:string, artist?:string, duration?:number}} input
* @returns {Promise<object>} Result (songKey null)
*/
async function findLyrics(input) {
let query;
if (typeof input === "string") {
const text = validText(input, true);
if (text === null) return { ok: false, error: "INVALID_PARAMS", songKey: null };
query = guessQuery({ title: text, author: "" });
} else if (input && typeof input === "object" && !Array.isArray(input)) {
const track = validText(input.track, true);
const artist = validText(input.artist, false);
const rawDuration = input.duration;
const durationValid = rawDuration === undefined || rawDuration === null
|| (typeof rawDuration === "number" && Number.isFinite(rawDuration) && rawDuration >= 0 && rawDuration < 86400);
if (track === null || artist === null || !durationValid) return { ok: false, error: "INVALID_PARAMS", songKey: null };
query = {
track: cleanTitle(track),
artist: cleanArtist(artist),
duration: toDuration(rawDuration),
};
} else {
return { ok: false, error: "INVALID_PARAMS", songKey: null };
}
if (!query.track) return { ok: false, error: "INVALID_PARAMS", songKey: null };
try {
return withKey(await cachedLookup(query), null);
} catch (error) {
plog.warn(`Recherche de paroles interrompue : ${describe(error)}`);
return { ok: false, error: "UNAVAILABLE", songKey: null };
}
}
/**
* Classe les enregistrements d'une recherche libre : les plus pertinents d'abord (mots de la requête,
* paroles synchronisées, saisies propres), les candidats jugés hors sujet ensuite, dans l'ordre de LRCLIB.
* @param {object[]} records
* @param {string} text
* @returns {object[]}
*/
function rankCandidates(records, text) {
const query = { track: text, artist: "", duration: 0 };
return records
.map((record, index) => ({ record, index, rated: scoreCandidate(record, query) }))
.sort((a, b) => {
if (!!a.rated !== !!b.rated) return a.rated ? -1 : 1;
if (a.rated && b.rated && b.rated.score !== a.rated.score) return b.rated.score - a.rated.score;
return a.index - b.index;
})
.map(entry => entry.record);
}
/**
* Candidats LRCLIB pour une recherche manuelle (≤ 20), les plus pertinents en premier.
* @param {string} query
* @returns {Promise<{ok:true, results:object[]}|{ok:false, error:string}>}
*/
async function searchCandidates(query) {
const text = validText(query, true);
if (text === null) return { ok: false, error: "INVALID_PARAMS" };
const key = normalizeText(text);
if (!key) return { ok: false, error: "INVALID_PARAMS" };
return searchCache.wrap(key, async () => {
try {
const records = await lrclibSearch({ q: text }, newContext());
return {
ok: true,
// Les entrées vides ou d'essai ne servent à rien dans la liste
results: rankCandidates(records.filter(hasContent), text).map(record => ({
id: record.id,
track: String(record.trackName || record.name || ""),
artist: String(record.artistName || ""),
album: String(record.albumName || ""),
duration: toDuration(record.duration),
synced: record.instrumental !== true && hasSynced(record),
instrumental: record.instrumental === true,
})),
};
} catch {
return { ok: false, error: "UNAVAILABLE" };
}
}, { ttlFor: (value) => (value && value.ok ? 10 * MINUTE : 0) });
}
/**
* Paroles d'un enregistrement LRCLIB précis (choix manuel) : la synchronisation est toujours conservée.
* @param {number|string} id
* @returns {Promise<object>} Result (songKey null)
*/
async function getById(id) {
const value = typeof id === "string" && /^\d{1,15}$/.test(id.trim()) ? Number(id.trim()) : id;
if (!Number.isSafeInteger(value) || value <= 0) return { ok: false, error: "INVALID_PARAMS", songKey: null };
const result = await idCache.wrap(String(value), async () => {
try {
const { status, data } = await lrclibCall(`/get/${value}`, null, newContext());
if (status === 404 || !isRecord(data)) return { ok: false, error: "NOT_FOUND" };
return resultFromRecord(data, null) || { ok: false, error: "NOT_FOUND" };
} catch {
return { ok: false, error: "UNAVAILABLE" };
}
}, { ttlFor: ttlForResult });
return withKey(result, null);
}
/**
* Compatibilité : paroles en texte brut pour une recherche libre, ou null.
* @param {string} name
* @returns {Promise<string|null>}
*/
async function getLyrics(name) {
const result = await findLyrics(typeof name === "string" ? name.slice(0, MAX_QUERY) : name);
if (!result.ok) return null;
return result.plain || (result.lines ? linesToPlain(result.lines) : null);
}
// Vide les caches (tests, ou après un changement de source)
function clearCache() {
lookupCache.clear();
idCache.clear();
searchCache.clear();
}
module.exports = {
getLyricsForSong,
searchCandidates,
getById,
findLyrics,
getLyrics,
parseLrc,
cleanTitle,
cleanArtist,
guessQuery,
songKeyOf,
pickBestCandidate,
scoreCandidate,
similarity,
rankCandidates,
ttlForResult,
clearCache,
USER_AGENT,
};
+5 -3
View File
@@ -1,4 +1,6 @@
{ {
"urlSearch": "http://api.deezer.com/search?limit=5&q=", "lrclib": "https://lrclib.net/api",
"urlGet": "https://api.lyrics.ovh/v1/" "urlSearch": "https://api.deezer.com/search?limit=5&q=",
} "urlGet": "https://api.lyrics.ovh/v1/",
"project": "https://git.raphix.fr/subsonics"
}
+310
View File
@@ -0,0 +1,310 @@
/**
* [Subsonics Chopin - Backend] - Playlists partagées d'un serveur Discord
* Base "GuildPlaylists" (data/guild_playlists.json) : { "<guildId>": [Playlist] }.
* Chaque playlist porte en plus scope:"guild", guildId, createdBy {id, username, global_name, avatar}
* et updatedBy {id, username}. Les droits (option du serveur, éditeurs) sont vérifiés par l'appelant (Server.js).
*
* Règles : aucune clé créée par une lecture, aucune suppression automatique (un serveur quitté garde ses playlists).
* Les opérations renvoient {ok:true, playlist} ou {ok:false, error, message}
* (error : NOT_FOUND, INVALID_PARAMS, LIMIT, FAILED).
*/
const { Database } = require('../utils/Database/Database');
const { __glob } = require('../utils/GlobalVars');
const { LogType } = require('loguix');
const utils = require('./PlaylistUtils');
const PlaylistManager = require('./PlaylistManager');
const glog = new LogType("GuildPlaylists");
// Au-delà, la liste partagée devient illisible et chaque sauvegarde s'alourdit
const MAX_PLAYLISTS = 200;
const MAX_SONGS = utils.MAX_SONGS;
const guildDB = new Database("GuildPlaylists", __glob.GUILD_PLAYLISTS_DB, {});
function isGuildId(guildId) {
return utils.isSnowflake(guildId);
}
function hasGuild(guildId) {
return isGuildId(guildId) && Object.prototype.hasOwnProperty.call(guildDB.data, guildId) && Array.isArray(guildDB.data[guildId]);
}
// Au démarrage : mêmes réparations que les playlists personnelles (durées, identifiants, dates, rattachement)
try {
let repaired = 0;
for (const guildId of Object.keys(guildDB.data || {})) {
const list = guildDB.data[guildId];
if (!Array.isArray(list)) continue;
for (const playlist of list) {
if (!playlist || typeof playlist !== "object") continue;
let changed = utils.refreshPlaylistDuration(playlist);
// Dates d'abord : un identifiant attribué maintenant ne dit rien de la date de création
if (utils.ensureTimestamps(playlist)) changed = true;
if (playlist.playlistId === undefined || playlist.playlistId === null || playlist.playlistId === "") {
playlist.playlistId = utils.newPlaylistId(list);
changed = true;
} else if (typeof playlist.playlistId !== "string") {
playlist.playlistId = String(playlist.playlistId);
changed = true;
}
if (playlist.scope !== "guild" || playlist.guildId !== guildId) {
playlist.scope = "guild";
playlist.guildId = guildId;
changed = true;
}
if (changed) repaired++;
}
}
if (repaired > 0) {
glog.warn(`Playlists partagées réparées : ${repaired}`);
guildDB.save();
}
} catch (e) {
glog.error("Erreur lors de la vérification des playlists partagées : " + e);
}
function ok(playlist) {
return { ok: true, playlist };
}
function fail(error, message) {
return { ok: false, error, message };
}
function notFound() {
return fail("NOT_FOUND", "Playlist introuvable");
}
/**
* Identité publique (jamais de jeton) de l'auteur d'une action
*/
function creatorRef(user) {
const identity = user?.identity || user || {};
return {
id: typeof identity.id === "string" ? identity.id : null,
username: identity.username ?? null,
global_name: identity.global_name ?? null,
avatar: identity.avatar ?? null
};
}
function editorRef(user) {
const identity = user?.identity || user || {};
return {
id: typeof identity.id === "string" ? identity.id : null,
username: identity.username ?? null
};
}
/**
* Liste modifiable d'un serveur, créée seulement pour une écriture
*/
function ensureGuildList(guildId) {
if (!isGuildId(guildId)) throw new Error("Identifiant de serveur invalide");
if (!hasGuild(guildId)) guildDB.data[guildId] = [];
return guildDB.data[guildId];
}
/**
* Playlists partagées d'un serveur (tableau vide détaché si aucune : jamais d'écriture)
* @param {string} guildId
* @returns {Array<Object>}
*/
function getPlaylists(guildId) {
return hasGuild(guildId) ? guildDB.data[guildId] : [];
}
function getPlaylist(guildId, playlistId) {
return getPlaylists(guildId).find(p => utils.sameId(p?.playlistId, playlistId)) || null;
}
function countPlaylists(guildId) {
return getPlaylists(guildId).length;
}
/**
* Serveurs possédant au moins une entrée (pour l'administration)
* @returns {string[]}
*/
function listGuildIds() {
return Object.keys(guildDB.data || {}).filter(id => Array.isArray(guildDB.data[id]));
}
/**
* Nouvelle playlist du serveur : rattachement, auteur, identifiant unique dans la liste du serveur
*/
function attach(list, playlist, guildId, user) {
playlist.playlistId = utils.newPlaylistId(list);
playlist.scope = "guild";
playlist.guildId = guildId;
playlist.createdBy = creatorRef(user);
playlist.updatedBy = editorRef(user);
list.push(playlist);
guildDB.save();
return playlist;
}
function markEdited(playlist, user) {
utils.touch(playlist);
playlist.updatedBy = editorRef(user);
}
function limitReached(guildId) {
return countPlaylists(guildId) >= MAX_PLAYLISTS;
}
function limitError() {
return fail("LIMIT", `Ce serveur a atteint la limite de ${MAX_PLAYLISTS} playlists partagées`);
}
/**
* Crée une playlist partagée vide (name) ou importée depuis un lien (url, name facultatif)
* @param {string} guildId
* @param {{name?: string|null, url?: string|null}} options
* @param {Object} user utilisateur (ou identité) à l'origine de la création
*/
async function createPlaylist(guildId, { name = null, url = null } = {}, user) {
if (!isGuildId(guildId)) return fail("INVALID_PARAMS", "Serveur invalide");
if (!url && !name) return fail("INVALID_PARAMS", "Nom de playlist requis (1 à 100 caractères)");
if (limitReached(guildId)) return limitError();
const identity = user?.identity || user || {};
const playlist = await PlaylistManager.buildNewPlaylist({ name, url, user: identity });
if (!playlist) {
return fail("FAILED", url ? "Import impossible : vérifiez le lien (playlist publique YouTube, Spotify ou SoundCloud)" : "La playlist n'a pas pu être créée");
}
// Revérifié après l'import (d'autres créations ont pu aboutir pendant l'attente)
if (limitReached(guildId)) return limitError();
attach(ensureGuildList(guildId), playlist, guildId, user);
glog.log(`Playlist partagée « ${playlist.title} » créée sur le serveur ${guildId} par ${identity.username ?? identity.id}`);
return ok(playlist);
}
/**
* Copie dans le serveur une playlist existante (par exemple une playlist personnelle)
*/
function importPlaylist(guildId, source, user) {
if (!isGuildId(guildId)) return fail("INVALID_PARAMS", "Serveur invalide");
if (!source || typeof source !== "object") return notFound();
if (limitReached(guildId)) return limitError();
const copy = utils.detachedCopy(source);
const now = utils.nowIso();
copy.createdAt = now;
copy.updatedAt = now;
utils.capSongs(copy);
utils.refreshPlaylistDuration(copy);
attach(ensureGuildList(guildId), copy, guildId, user);
glog.log(`Playlist « ${copy.title} » partagée sur le serveur ${guildId}`);
return ok(copy);
}
function deletePlaylist(guildId, playlistId) {
const list = getPlaylists(guildId);
const index = list.findIndex(p => utils.sameId(p?.playlistId, playlistId));
if (index === -1) return false;
const [removed] = list.splice(index, 1);
guildDB.save();
glog.log(`Playlist partagée « ${removed?.title} » (${playlistId}) supprimée du serveur ${guildId}`);
return true;
}
function renamePlaylist(guildId, playlistId, newName, user) {
const playlist = getPlaylist(guildId, playlistId);
if (!playlist) return notFound();
if (typeof newName !== "string" || newName === "") return fail("INVALID_PARAMS", "Nom invalide");
playlist.title = newName;
if (playlist.url) playlist.customTitle = true;
markEdited(playlist, user);
guildDB.save();
return ok(playlist);
}
function addSong(guildId, playlistId, song, user) {
const playlist = getPlaylist(guildId, playlistId);
if (!playlist) return notFound();
const clean = utils.prepareSong(song);
if (!clean) return fail("INVALID_PARAMS", "Musique invalide");
if (!Array.isArray(playlist.songs)) playlist.songs = [];
if (playlist.songs.length >= MAX_SONGS) return fail("LIMIT", `Une playlist ne peut pas dépasser ${MAX_SONGS} titres`);
playlist.songs.push(clean);
utils.refreshPlaylistDuration(playlist);
markEdited(playlist, user);
guildDB.save();
return ok(playlist);
}
function removeSong(guildId, playlistId, songId, index, user) {
const playlist = getPlaylist(guildId, playlistId);
if (!playlist) return notFound();
if (!utils.removeSongFrom(playlist, songId, index)) return fail("NOT_FOUND", "Musique introuvable dans la playlist");
utils.refreshPlaylistDuration(playlist);
markEdited(playlist, user);
guildDB.save();
return ok(playlist);
}
function moveSong(guildId, playlistId, from, to, user) {
const playlist = getPlaylist(guildId, playlistId);
if (!playlist) return notFound();
if (!Array.isArray(playlist.songs)) playlist.songs = [];
if (!utils.moveItem(playlist.songs, from, to)) return fail("INVALID_PARAMS", "Positions invalides");
if (from !== to) {
markEdited(playlist, user);
guildDB.save();
}
return ok(playlist);
}
/**
* Ordre d'affichage commun du serveur
* @returns {boolean} false si l'ordre est invalide
*/
function reorder(guildId, order) {
if (!Array.isArray(order)) return false;
if (!hasGuild(guildId)) return true;
if (!utils.applyOrder(guildDB.data[guildId], order)) return false;
guildDB.save();
return true;
}
/**
* Actualise une playlist importée depuis son lien public (même position, même identifiant, même auteur)
*/
async function refreshPlaylist(guildId, playlistId, user) {
const current = getPlaylist(guildId, playlistId);
if (!current) return notFound();
if (!current.url) return fail("INVALID_PARAMS", "Seules les playlists importées depuis un lien peuvent être actualisées");
const fresh = await PlaylistManager.fetchRemotePlaylist(current.url);
if (!fresh) return fail("FAILED", "Actualisation impossible : la playlist source est inaccessible");
utils.inheritIdentity(fresh, current);
fresh.scope = "guild";
fresh.guildId = guildId;
fresh.createdBy = current.createdBy ?? null;
fresh.updatedBy = editorRef(user);
// Supprimée pendant l'import : on ne remplace rien
const list = getPlaylists(guildId);
const index = list.findIndex(p => utils.sameId(p?.playlistId, current.playlistId));
if (index === -1) return notFound();
list.splice(index, 1, fresh);
guildDB.save();
return ok(fresh);
}
module.exports = {
MAX_PLAYLISTS,
MAX_SONGS,
isGuildId,
getPlaylists,
getPlaylist,
countPlaylists,
listGuildIds,
createPlaylist,
importPlaylist,
deletePlaylist,
renamePlaylist,
addSong,
removeSong,
moveSong,
reorder,
refreshPlaylist
};
+243 -161
View File
@@ -1,90 +1,47 @@
const {Database} = require('../utils/Database/Database'); const {Database} = require('../utils/Database/Database');
const {__glob} = require('../utils/GlobalVars'); const {__glob} = require('../utils/GlobalVars');
const {Playlist, computeDuration} = require('./Playlist'); const {Playlist} = require('./Playlist');
const {LogType} = require('loguix'); const {LogType} = require('loguix');
const clog = new LogType("PlaylistManager"); const clog = new LogType("PlaylistManager");
const Finder = require('../player/Finder'); const Finder = require('../player/Finder');
const spotify = require('../media/SpotifyInformation'); const spotify = require('../media/SpotifyInformation');
const YoutubeList = require('./Google/YoutubeList'); const YoutubeList = require('./Google/YoutubeList');
const GoogleOAuth2 = require('./Google/OAuth2'); const GoogleOAuth2 = require('./Google/OAuth2');
const { getReadableDuration } = require('../utils/TimeConverter'); const utils = require('./PlaylistUtils');
const { toSafeDuration } = require('../utils/SafeMetric'); const {
const songCheck = require('../player/SongCheck'); sameId,
newPlaylistId,
sanitizeSongDuration,
refreshPlaylistDuration,
ensureTimestamps,
touch,
nowIso,
MAX_SONGS
} = utils;
// Délai minimal entre deux synchronisations YouTube réussies d'un même utilisateur (quota de l'API) // Délai minimal entre deux synchronisations YouTube réussies d'un même utilisateur (quota de l'API)
const SYNC_COOLDOWN_MS = 60 * 1000; const SYNC_COOLDOWN_MS = 60 * 1000;
// Playlists personnelles créées / copiées par un utilisateur (chaque sauvegarde réécrit tout le fichier ;
// la synchronisation YouTube n'est pas concernée : elle remplace des playlists existantes)
const MAX_PLAYLISTS = 500;
const playlistDB = new Database("Playlists", __glob.PLAYLISTFILE, {}); const playlistDB = new Database("Playlists", __glob.PLAYLISTFILE, {});
/** // Au démarrage : répare les durées corrompues déjà enregistrées (null, chaîne, NaN), donne un identifiant (chaîne)
* Comparaison d'identifiants de playlist tolérante (ancien String objet, nombre envoyé par un client...) // aux playlists qui n'en ont pas (anciens imports YouTube) et des dates de création / modification aux anciennes
*/
function sameId(a, b) {
if (a === undefined || a === null || b === undefined || b === null) return false;
return String(a) === String(b);
}
/**
* Nouvel identifiant de playlist : chaîne primitive unique parmi les playlists de l'utilisateur
* (plusieurs playlists créées dans la même milliseconde lors d'une synchronisation)
* @param {Array} userPlaylists
* @returns {string}
*/
function newPlaylistId(userPlaylists) {
let candidate = Date.now();
const used = new Set((userPlaylists || []).map(p => String(p?.playlistId)));
while (used.has(String(candidate))) candidate++;
return String(candidate);
}
/**
* Normalise la durée d'une musique (nombre de secondes, 0 si inconnue) et sa durée lisible.
* Ne jamais faire confiance à la durée envoyée par le client ni à celle déjà enregistrée.
* @returns {boolean} true si la musique a été modifiée
*/
function sanitizeSongDuration(song) {
if (!song || typeof song !== "object") return false;
const duration = toSafeDuration(song.duration);
const readduration = getReadableDuration(duration);
if (song.duration === duration && song.readduration === readduration) return false;
song.duration = duration;
song.readduration = readduration;
return true;
}
/**
* Recalcule la durée totale d'une playlist à partir de ses musiques (jamais NaN ni chaîne)
* @returns {boolean} true si la playlist a été modifiée
*/
function refreshPlaylistDuration(playlist) {
if (!playlist || typeof playlist !== "object") return false;
if (!Array.isArray(playlist.songs)) playlist.songs = [];
let changed = false;
for (const song of playlist.songs) {
if (sanitizeSongDuration(song)) changed = true;
}
const duration = computeDuration(playlist.songs);
const readduration = getReadableDuration(duration);
if (playlist.duration !== duration || playlist.readduration !== readduration) {
playlist.duration = duration;
playlist.readduration = readduration;
changed = true;
}
return changed;
}
// Au démarrage : répare les durées corrompues déjà enregistrées (null, chaîne, NaN)
// et donne un identifiant (chaîne) aux playlists qui n'en ont pas (anciens imports YouTube)
try { try {
let repaired = 0; let repaired = 0;
let identified = 0; let identified = 0;
let dated = 0;
for (const userId of Object.keys(playlistDB.data || {})) { for (const userId of Object.keys(playlistDB.data || {})) {
const userPlaylists = playlistDB.data[userId]; const userPlaylists = playlistDB.data[userId];
if (!Array.isArray(userPlaylists)) continue; if (!Array.isArray(userPlaylists)) continue;
for (const playlist of userPlaylists) { for (const playlist of userPlaylists) {
if (refreshPlaylistDuration(playlist)) repaired++; if (refreshPlaylistDuration(playlist)) repaired++;
if (playlist && typeof playlist === "object") { if (playlist && typeof playlist === "object") {
// Dates d'abord : un identifiant attribué maintenant ne dit rien de la date de création
if (ensureTimestamps(playlist)) dated++;
if (playlist.playlistId === undefined || playlist.playlistId === null || playlist.playlistId === "") { if (playlist.playlistId === undefined || playlist.playlistId === null || playlist.playlistId === "") {
playlist.playlistId = newPlaylistId(userPlaylists); playlist.playlistId = newPlaylistId(userPlaylists);
identified++; identified++;
@@ -97,28 +54,38 @@ try {
} }
if (repaired > 0) clog.warn(`Durées recalculées pour ${repaired} playlist(s)`); if (repaired > 0) clog.warn(`Durées recalculées pour ${repaired} playlist(s)`);
if (identified > 0) clog.warn(`Identifiant attribué ou corrigé pour ${identified} playlist(s)`); if (identified > 0) clog.warn(`Identifiant attribué ou corrigé pour ${identified} playlist(s)`);
if (repaired > 0 || identified > 0) playlistDB.save(); if (dated > 0) clog.log(`Dates de création / modification ajoutées à ${dated} playlist(s)`);
if (repaired > 0 || identified > 0 || dated > 0) playlistDB.save();
} catch (e) { } catch (e) {
clog.error("Erreur lors de la vérification des playlists : " + e); clog.error("Erreur lors de la vérification des playlists : " + e);
} }
function hasUser(id) {
return typeof id === "string" && id !== "" && Object.prototype.hasOwnProperty.call(playlistDB.data, id) && Array.isArray(playlistDB.data[id]);
}
/**
* Liste modifiable des playlists d'un utilisateur, créée seulement pour une écriture
*/
function ensureUserList(id) {
if (typeof id !== "string" || id === "" || id === "__proto__" || id === "constructor" || id === "prototype") {
throw new Error("Identifiant d'utilisateur invalide pour les playlists");
}
if (!hasUser(id)) {
playlistDB.data[id] = [];
clog.log(`Création d'une clé pour l'utilisateur : ${id}`);
}
return playlistDB.data[id];
}
/** /**
* @param {string} id * @param {string} id
* @returns {Array<Playlist>} * @returns {Array<Playlist>}
* @description Renvoie la liste des playlists de l'utilisateur * @description Renvoie la liste des playlists de l'utilisateur (lecture seule : un utilisateur sans playlist
* n'entraîne plus d'écriture disque ; le tableau renvoyé est alors un tableau vide détaché)
*/ */
function getPlaylistsOfUser(id) { function getPlaylistsOfUser(id) {
if (playlistDB.data[id]) { return hasUser(id) ? playlistDB.data[id] : [];
return playlistDB.data[id];
} else {
// Creaete a key with the user id and an empty array
playlistDB.data[id] = new Array();
clog.log(`Création d'une clé pour l'utilisateur : ${id}`);
playlistDB.save();
return playlistDB.data[id];
}
} }
/** /**
@@ -128,7 +95,7 @@ function getPlaylistsOfUser(id) {
*/ */
function getPlaylistOfUser(id, playlistId) { function getPlaylistOfUser(id, playlistId) {
const playlists = getPlaylistsOfUser(id); const playlists = getPlaylistsOfUser(id);
const playlist = playlists.find(p => sameId(p.playlistId, playlistId)); const playlist = playlists.find(p => sameId(p?.playlistId, playlistId));
if (!playlist) { if (!playlist) {
clog.warn(`La playlist ${playlistId} n'existe pas pour l'utilisateur ${id}`); clog.warn(`La playlist ${playlistId} n'existe pas pour l'utilisateur ${id}`);
return null; return null;
@@ -136,47 +103,106 @@ function getPlaylistOfUser(id, playlistId) {
return playlist; return playlist;
} }
async function addPlaylist(id, name, url, authorName, authorId, authorAvatar) { /**
const playlists = getPlaylistsOfUser(id); * Playlist publique importée depuis un lien (YouTube, Spotify, SoundCloud), titres bornés à MAX_SONGS
var playlist = new Playlist(name, url); * @param {string} url
let failed = false; * @returns {Promise<Playlist|null>}
playlist.thumbnail = null */
playlist.author = authorName; async function fetchRemotePlaylist(url) {
playlist.authorAvatar = `https://cdn.discordapp.com/avatars/${authorId}/${authorAvatar}`; if (typeof url !== "string" || url === "") return null;
playlist.views = null; try {
const found = await Finder.search(url, false, "PLAYLIST");
if(url) { if (!found || typeof found !== "object" || !Array.isArray(found.songs)) return null;
await Finder.search(url, false, "PLAYLIST").then(async (playlistFounded) => { // Avant la conversion Spotify → YouTube : inutile de convertir des titres qui seraient retirés
if(!playlistFounded) { utils.capSongs(found);
failed = true; if (found.type === "spotify") {
} // getTracks remplace playlist.songs et renvoie la playlist elle-même
if(playlistFounded instanceof Playlist) { // (ne pas l'assigner à playlist.songs : référence circulaire impossible à sauvegarder)
playlist = playlistFounded; await spotify.getTracks(found);
} }
if(!failed && playlist.type === "spotify") { utils.capSongs(found);
// getTracks remplace playlist.songs et renvoie la playlist elle-même refreshPlaylistDuration(found);
// (ne pas l'assigner à playlist.songs : référence circulaire impossible à sauvegarder) return found;
await spotify.getTracks(playlist); } catch (e) {
} clog.warn(`Import de la playlist impossible depuis le lien : ${e?.message || e}`);
})
}
if(failed) {
clog.error(`Impossible de trouver la playlist ${name} pour l'utilisateur ${id}`);
return null; return null;
} }
}
/**
* Construit une nouvelle playlist (vide ou importée), sans l'enregistrer.
* @param {{name?: string|null, url?: string|null, user: {id: string, username?: string, global_name?: string, avatar?: string|null}}} options
* name : obligatoire sans lien ; avec un lien, remplace le titre importé s'il est donné
* @returns {Promise<Playlist|null>}
*/
async function buildNewPlaylist({ name, url, user }) {
let playlist;
if (url) {
playlist = await fetchRemotePlaylist(url);
if (!playlist) return null;
// Un nom identique au lien (ancien client) n'est pas un vrai titre
if (name && name !== url) {
playlist.title = name;
playlist.customTitle = true;
}
} else {
if (!name) return null;
playlist = new Playlist(name, null);
playlist.thumbnail = null;
playlist.views = null;
playlist.author = user?.username || "Auteur inconnu";
playlist.authorId = utils.discordProfileUrl(user?.id);
playlist.authorAvatar = utils.discordAvatarUrl(user?.id, user?.avatar);
}
refreshPlaylistDuration(playlist); refreshPlaylistDuration(playlist);
const now = nowIso();
playlist.createdAt = now;
playlist.updatedAt = now;
return playlist;
}
/**
* Crée une playlist personnelle (vide ou importée depuis url)
* @returns {Promise<Playlist|null>}
*/
/**
* Vrai si l'utilisateur peut encore créer ou recevoir une playlist
*/
function hasRoomForPlaylist(id) {
return getPlaylistsOfUser(id).length < MAX_PLAYLISTS;
}
async function addPlaylist(id, name, url, authorName, authorId, authorAvatar) {
// Vérifiée avant l'import (inutile d'interroger YouTube / Spotify pour rien) puis après
if (!hasRoomForPlaylist(id)) {
clog.warn(`Limite de ${MAX_PLAYLISTS} playlists atteinte pour l'utilisateur ${id}`);
return null;
}
const playlist = await buildNewPlaylist({
name,
url,
user: { id: authorId, username: authorName, avatar: authorAvatar }
});
if (!playlist) {
clog.error(`Impossible de créer la playlist ${name || url} pour l'utilisateur ${id}`);
return null;
}
if (!hasRoomForPlaylist(id)) {
clog.warn(`Limite de ${MAX_PLAYLISTS} playlists atteinte pour l'utilisateur ${id}`);
return null;
}
const playlists = ensureUserList(id);
// Chaîne primitive : un objet String ne serait jamais égal (===) à l'identifiant envoyé par le client // Chaîne primitive : un objet String ne serait jamais égal (===) à l'identifiant envoyé par le client
playlist.playlistId = newPlaylistId(playlists); playlist.playlistId = newPlaylistId(playlists);
playlists.push(playlist); playlists.push(playlist);
playlistDB.save(); playlistDB.save();
clog.log(`Ajout de la playlist ${name} pour l'utilisateur ${id}`); clog.log(`Ajout de la playlist ${playlist.title} pour l'utilisateur ${id}`);
return playlist; return playlist;
} }
function removePlaylist(id, playlistId) { function removePlaylist(id, playlistId) {
const playlists = getPlaylistsOfUser(id); const playlists = getPlaylistsOfUser(id);
const index = playlists.findIndex(p => sameId(p.playlistId, playlistId)); const index = playlists.findIndex(p => sameId(p?.playlistId, playlistId));
if (index === -1) { if (index === -1) {
clog.warn(`La playlist ${playlistId} n'existe pas pour l'utilisateur ${id}`); clog.warn(`La playlist ${playlistId} n'existe pas pour l'utilisateur ${id}`);
return false; return false;
@@ -190,6 +216,32 @@ function getPlaylist(id, playlistId) {
return getPlaylistOfUser(id, playlistId); return getPlaylistOfUser(id, playlistId);
} }
/**
* Ajoute à un utilisateur une copie indépendante d'une playlist (playlist partagée d'un serveur, autre utilisateur)
* @param {string} toId
* @param {Object} playlist
* @returns {Playlist|null} la copie
*/
function addPlaylistCopy(toId, playlist) {
if (!playlist || typeof playlist !== "object") return null;
if (!hasRoomForPlaylist(toId)) {
clog.warn(`Limite de ${MAX_PLAYLISTS} playlists atteinte pour l'utilisateur ${toId} : copie refusée`);
return null;
}
const toPlaylists = ensureUserList(toId);
const copy = utils.detachedCopy(playlist);
copy.playlistId = newPlaylistId(toPlaylists);
const now = nowIso();
copy.createdAt = now;
copy.updatedAt = now;
utils.capSongs(copy);
refreshPlaylistDuration(copy);
toPlaylists.push(copy);
playlistDB.save();
clog.log(`Copie de la playlist ${copy.title} ajoutée à l'utilisateur ${toId}`);
return copy;
}
/** /**
* Copie une playlist vers un autre utilisateur (copie indépendante, nouvel identifiant). * Copie une playlist vers un autre utilisateur (copie indépendante, nouvel identifiant).
* Refusée si le destinataire possède déjà une playlist du même titre. * Refusée si le destinataire possède déjà une playlist du même titre.
@@ -198,20 +250,13 @@ function getPlaylist(id, playlistId) {
function copyPlaylist(fromId, toId, playlistId) { function copyPlaylist(fromId, toId, playlistId) {
const playlist = getPlaylistOfUser(fromId, playlistId); const playlist = getPlaylistOfUser(fromId, playlistId);
if (!playlist) return null; if (!playlist) return null;
const toPlaylists = getPlaylistsOfUser(toId);
// Doublon : même titre chez le destinataire (Playlist n'a pas de champ "name") // Doublon : même titre chez le destinataire (Playlist n'a pas de champ "name")
if (toPlaylists.find(p => p.title === playlist.title)) { if (getPlaylistsOfUser(toId).find(p => p?.title === playlist.title)) {
clog.warn(`La playlist ${playlist.title} existe déjà pour l'utilisateur ${toId}`); clog.warn(`La playlist ${playlist.title} existe déjà pour l'utilisateur ${toId}`);
return null; return null;
} }
// Copie profonde : un même objet partagé entre deux utilisateurs serait modifié des deux côtés const copy = addPlaylistCopy(toId, playlist);
const copy = JSON.parse(JSON.stringify(playlist)); if (copy) clog.log(`Copie de la playlist ${playlist.title} de l'utilisateur ${fromId} vers l'utilisateur ${toId}`);
copy.playlistId = newPlaylistId(toPlaylists);
// Une playlist synchronisée appartient au compte Google de l'expéditeur : la copie devient une playlist classique
delete copy.source;
toPlaylists.push(copy);
playlistDB.save();
clog.log(`Copie de la playlist ${playlist.title} de l'utilisateur ${fromId} vers l'utilisateur ${toId}`);
return copy; return copy;
} }
@@ -219,60 +264,85 @@ function renamePlaylist(id, playlistId, newName) {
const playlist = getPlaylistOfUser(id, playlistId); const playlist = getPlaylistOfUser(id, playlistId);
if (!playlist) return null; if (!playlist) return null;
playlist.title = newName; playlist.title = newName;
// Playlist importée : le titre choisi survit aux actualisations
if (playlist.url) playlist.customTitle = true;
touch(playlist);
playlistDB.save(); playlistDB.save();
clog.log(`Renommage de la playlist ${playlistId} en ${newName} pour l'utilisateur ${id}`); clog.log(`Renommage de la playlist ${playlistId} en ${newName} pour l'utilisateur ${id}`);
return playlist; return playlist;
} }
function addSong(id, playlistId, song) { function addSong(id, playlistId, song) {
if(typeof song === "string") {
try {
song = JSON.parse(song)
} catch (e) {
clog.error(`La chanson ${song} n'est pas valide`);
return null;
}
}
// Check if the song is a valid object
if (typeof song !== 'object' || !song) {
clog.error(`La chanson ${song} n'est pas valide`);
return null;
}
const playlist = getPlaylistOfUser(id, playlistId); const playlist = getPlaylistOfUser(id, playlistId);
if (!playlist) return null; if (!playlist) return null;
// Données client : normalisées (textes bornés, URL autorisée, durée) avant enregistrement // Données client : normalisées (textes bornés, URL autorisée, durée) avant enregistrement
song = songCheck.sanitizeSong(song); const clean = utils.prepareSong(song);
if (!song || !song.id || !song.title) { if (!clean) {
clog.error(`La chanson reçue n'est pas valide`); clog.error(`La chanson reçue n'est pas valide`);
return null; return null;
} }
if (!Array.isArray(playlist.songs)) playlist.songs = []; if (!Array.isArray(playlist.songs)) playlist.songs = [];
playlist.songs.push(song); if (playlist.songs.length >= MAX_SONGS) {
clog.warn(`La playlist ${playlistId} de l'utilisateur ${id} a atteint la limite de ${MAX_SONGS} musiques`);
return null;
}
playlist.songs.push(clean);
// Recalcul complet de la durée (jamais de += sur une valeur potentiellement corrompue) // Recalcul complet de la durée (jamais de += sur une valeur potentiellement corrompue)
refreshPlaylistDuration(playlist); refreshPlaylistDuration(playlist);
touch(playlist);
playlistDB.save(); playlistDB.save();
clog.log(`Ajout de la chanson ${song.title} à la playlist ${playlistId} pour l'utilisateur ${id}`); clog.log(`Ajout de la chanson ${clean.title} à la playlist ${playlistId} pour l'utilisateur ${id}`);
return playlist; return playlist;
} }
function removeSong(id, playlistId, songId) { /**
* @param {number} [index] position exacte de la musique (doublons) ; ignorée si elle ne correspond pas à songId
*/
function removeSong(id, playlistId, songId, index) {
const playlist = getPlaylistOfUser(id, playlistId); const playlist = getPlaylistOfUser(id, playlistId);
if (!playlist) return null; if (!playlist) return null;
if (!Array.isArray(playlist.songs)) playlist.songs = []; if (!Array.isArray(playlist.songs)) playlist.songs = [];
const index = playlist.songs.findIndex(s => s.id === songId); if (!utils.removeSongFrom(playlist, songId, index)) {
if (index === -1) {
clog.warn(`La chanson ${songId} n'existe pas dans la playlist ${playlistId} pour l'utilisateur ${id}`); clog.warn(`La chanson ${songId} n'existe pas dans la playlist ${playlistId} pour l'utilisateur ${id}`);
return null; return null;
} }
playlist.songs.splice(index, 1);
// Recalcul complet plutôt qu'une soustraction (durée manquante → NaN, résultat négatif...) // Recalcul complet plutôt qu'une soustraction (durée manquante → NaN, résultat négatif...)
refreshPlaylistDuration(playlist); refreshPlaylistDuration(playlist);
touch(playlist);
playlistDB.save(); playlistDB.save();
clog.log(`Suppression de la chanson ${songId} de la playlist ${playlistId} pour l'utilisateur ${id}`); clog.log(`Suppression de la chanson ${songId} de la playlist ${playlistId} pour l'utilisateur ${id}`);
return playlist; return playlist;
} }
/**
* Déplace une musique dans une playlist
* @returns {Playlist|null} null si la playlist n'existe pas ou si les positions sont invalides
*/
function moveSong(id, playlistId, from, to) {
const playlist = getPlaylistOfUser(id, playlistId);
if (!playlist) return null;
if (!Array.isArray(playlist.songs)) playlist.songs = [];
if (!utils.moveItem(playlist.songs, from, to)) return null;
if (from !== to) {
touch(playlist);
playlistDB.save();
}
return playlist;
}
/**
* Ordre d'affichage choisi par l'utilisateur (ordre du tableau enregistré)
* @param {Array} order identifiants de playlist
* @returns {boolean}
*/
function reorderPlaylists(id, order) {
if (!hasUser(id)) return Array.isArray(order);
if (!utils.applyOrder(playlistDB.data[id], order)) return false;
playlistDB.save();
clog.log(`Nouvel ordre des playlists de l'utilisateur ${id}`);
return true;
}
// --- YOUTUBE (compte Google lié) --- // --- YOUTUBE (compte Google lié) ---
/** /**
@@ -290,6 +360,8 @@ function playlistFromYoutube(data, playlistId) {
playlist.type = "youtube"; playlist.type = "youtube";
playlist.playlistId = playlistId; playlist.playlistId = playlistId;
playlist.syncedAt = new Date().toISOString(); playlist.syncedAt = new Date().toISOString();
if (data.truncated) playlist.truncated = true;
utils.capSongs(playlist);
refreshPlaylistDuration(playlist); refreshPlaylistDuration(playlist);
return playlist; return playlist;
} }
@@ -306,7 +378,7 @@ function findImportedIndex(userPlaylists, youtubeId) {
/** /**
* Importe / met à jour les playlists YouTube données (format neutre) dans la collection de l'utilisateur. * Importe / met à jour les playlists YouTube données (format neutre) dans la collection de l'utilisateur.
* Les playlists déjà importées gardent leur identifiant local et leur position. * Les playlists déjà importées gardent leur identifiant local, leur position et leur date de création.
* @param {string} userId * @param {string} userId
* @param {Array} data * @param {Array} data
* @returns {Array<Playlist>} * @returns {Array<Playlist>}
@@ -316,17 +388,22 @@ function processYoutubeData(userId, data) {
clog.warn(`Aucune donnée YouTube trouvée pour l'utilisateur ${userId}`); clog.warn(`Aucune donnée YouTube trouvée pour l'utilisateur ${userId}`);
return []; return [];
} }
const userPlaylists = getPlaylistsOfUser(userId); const userPlaylists = ensureUserList(userId);
const result = []; const result = [];
for (const item of data) { for (const item of data) {
if (!item?.youtubeId) continue; if (!item?.youtubeId) continue;
const index = findImportedIndex(userPlaylists, item.youtubeId); const index = findImportedIndex(userPlaylists, item.youtubeId);
if (index !== -1) { if (index !== -1) {
const playlist = playlistFromYoutube(item, userPlaylists[index].playlistId || newPlaylistId(userPlaylists)); const current = userPlaylists[index];
const playlist = playlistFromYoutube(item, current.playlistId || newPlaylistId(userPlaylists));
utils.inheritIdentity(playlist, { ...current, playlistId: playlist.playlistId });
userPlaylists.splice(index, 1, playlist); userPlaylists.splice(index, 1, playlist);
result.push(playlist); result.push(playlist);
} else { } else {
const playlist = playlistFromYoutube(item, newPlaylistId(userPlaylists)); const playlist = playlistFromYoutube(item, newPlaylistId(userPlaylists));
const now = nowIso();
playlist.createdAt = now;
playlist.updatedAt = now;
userPlaylists.push(playlist); userPlaylists.push(playlist);
result.push(playlist); result.push(playlist);
} }
@@ -393,7 +470,7 @@ async function syncYoutube(userId, { force = false } = {}) {
async function deleteUserPlaylists(userId) { async function deleteUserPlaylists(userId) {
// Delete all playlists of the user and the keys // Delete all playlists of the user and the keys
if (!playlistDB.data[userId]) { if (!hasUser(userId)) {
clog.warn(`Aucune playlist trouvée pour l'utilisateur ${userId}`); clog.warn(`Aucune playlist trouvée pour l'utilisateur ${userId}`);
return; return;
} }
@@ -408,8 +485,7 @@ async function refreshPlaylist(userId, playlistId) {
return null; return null;
} }
const localId = current.playlistId; const localId = current.playlistId;
var playlist = current; let playlist = null;
let failed = false;
if (current.source === "google" && current.youtubeId && GoogleOAuth2.isLinked(userId)) { if (current.source === "google" && current.youtubeId && GoogleOAuth2.isLinked(userId)) {
// Playlist importée : l'API (compte du propriétaire) voit aussi les playlists privées et "J'aime" // Playlist importée : l'API (compte du propriétaire) voit aussi les playlists privées et "J'aime"
@@ -418,39 +494,32 @@ async function refreshPlaylist(userId, playlistId) {
playlist = playlistFromYoutube(data, localId); playlist = playlistFromYoutube(data, localId);
} catch (error) { } catch (error) {
clog.warn(`Échec de la mise à jour via l'API YouTube de la playlist ${playlistId} : ${error?.message || error}`); clog.warn(`Échec de la mise à jour via l'API YouTube de la playlist ${playlistId} : ${error?.message || error}`);
failed = true;
} }
} else if (current.url) { } else if (current.url) {
// Sinon : recherche publique par l'URL (les playlists privées échouent) // Sinon : recherche publique par l'URL (les playlists privées échouent)
await Finder.search(current.url, false, "PLAYLIST").then(async (playlistFounded) => { playlist = await fetchRemotePlaylist(current.url);
if(!playlistFounded) {
failed = true;
}
if(playlistFounded instanceof Playlist) {
playlist = playlistFounded;
}
if(!failed && playlist.type === "spotify") {
// getTracks remplace playlist.songs et renvoie la playlist elle-même
await spotify.getTracks(playlist);
}
})
// Une playlist importée rafraîchie publiquement reste rattachée au compte Google // Une playlist importée rafraîchie publiquement reste rattachée au compte Google
if (!failed && current.source === "google" && playlist !== current) { if (playlist && current.source === "google") {
playlist.source = "google"; playlist.source = "google";
playlist.youtubeId = current.youtubeId; playlist.youtubeId = current.youtubeId;
} }
} else {
// Playlist manuelle : rien à actualiser, simple recalcul des durées
refreshPlaylistDuration(current);
playlistDB.save();
return current;
} }
if(failed) { if (!playlist) {
clog.warn(`Échec de la mise à jour de la playlist ${playlistId} pour l'utilisateur ${userId}`); clog.warn(`Échec de la mise à jour de la playlist ${playlistId} pour l'utilisateur ${userId}`);
return null; return null;
} }
refreshPlaylistDuration(playlist); utils.inheritIdentity(playlist, current);
playlist.playlistId = localId; playlist.playlistId = localId;
const playlists = getPlaylistsOfUser(userId); const playlists = getPlaylistsOfUser(userId);
// Remplacement à la même position // Remplacement à la même position
const existingIndex = playlists.findIndex(p => sameId(p.playlistId, localId)); const existingIndex = playlists.findIndex(p => sameId(p?.playlistId, localId));
if (existingIndex === -1) { if (existingIndex === -1) {
// Supprimée entre-temps : splice(-1) aurait remplacé la dernière playlist de l'utilisateur // Supprimée entre-temps : splice(-1) aurait remplacé la dernière playlist de l'utilisateur
clog.warn(`La playlist ${playlistId} a été supprimée pendant sa mise à jour pour l'utilisateur ${userId}`); clog.warn(`La playlist ${playlistId} a été supprimée pendant sa mise à jour pour l'utilisateur ${userId}`);
@@ -464,15 +533,28 @@ async function refreshPlaylist(userId, playlistId) {
module.exports = { module.exports = {
getPlaylistsOfUser, getPlaylistsOfUser,
getPlaylistOfUser, getPlaylistOfUser,
hasRoomForPlaylist,
MAX_PLAYLISTS,
addPlaylist, addPlaylist,
removePlaylist, removePlaylist,
getPlaylist, getPlaylist,
copyPlaylist, copyPlaylist,
addPlaylistCopy,
renamePlaylist, renamePlaylist,
addSong, addSong,
removeSong, removeSong,
moveSong,
reorderPlaylists,
processYoutubeData, processYoutubeData,
syncYoutube, syncYoutube,
deleteUserPlaylists, deleteUserPlaylists,
refreshPlaylist refreshPlaylist,
// Outils partagés avec les playlists de serveur (GuildPlaylists)
buildNewPlaylist,
fetchRemotePlaylist,
sameId,
newPlaylistId,
sanitizeSongDuration,
refreshPlaylistDuration,
MAX_SONGS
} }
+341
View File
@@ -0,0 +1,341 @@
/**
* [Subsonics Chopin - Backend] - Outils communs aux playlists personnelles (PlaylistManager)
* et aux playlists partagées des serveurs (GuildPlaylists).
* Fonctions pures : aucune base de données, aucun accès réseau (tests unitaires possibles sans data/).
*/
const { computeDuration } = require('./Playlist');
const { getReadableDuration } = require('../utils/TimeConverter');
const { toSafeDuration } = require('../utils/SafeMetric');
const songCheck = require('../player/SongCheck');
// Limites : une playlist énorme ralentit chaque sauvegarde (écriture synchrone de tout le fichier)
const MAX_SONGS = 5000;
const MAX_NAME = 100;
const MAX_URL = 2000;
const MAX_ID = 64;
// Ordre envoyé par un client : au-delà, la demande est forcément anormale
const MAX_ORDER = 10000;
/**
* Comparaison d'identifiants de playlist tolérante (ancien String objet, nombre envoyé par un client...)
*/
function sameId(a, b) {
if (a === undefined || a === null || b === undefined || b === null) return false;
return String(a) === String(b);
}
/**
* Nouvel identifiant de playlist : chaîne primitive unique parmi les playlists de la liste
* (plusieurs playlists créées dans la même milliseconde lors d'une synchronisation)
* @param {Array} list
* @returns {string}
*/
function newPlaylistId(list) {
let candidate = Date.now();
const used = new Set((list || []).map(p => String(p?.playlistId)));
while (used.has(String(candidate))) candidate++;
return String(candidate);
}
/**
* Normalise la durée d'une musique (nombre de secondes, 0 si inconnue) et sa durée lisible.
* Ne jamais faire confiance à la durée envoyée par le client ni à celle déjà enregistrée.
* @returns {boolean} true si la musique a été modifiée
*/
function sanitizeSongDuration(song) {
if (!song || typeof song !== "object") return false;
const duration = toSafeDuration(song.duration);
const readduration = getReadableDuration(duration);
if (song.duration === duration && song.readduration === readduration) return false;
song.duration = duration;
song.readduration = readduration;
return true;
}
/**
* Recalcule la durée totale d'une playlist à partir de ses musiques (jamais NaN ni chaîne)
* @returns {boolean} true si la playlist a été modifiée
*/
function refreshPlaylistDuration(playlist) {
if (!playlist || typeof playlist !== "object") return false;
if (!Array.isArray(playlist.songs)) playlist.songs = [];
let changed = false;
for (const song of playlist.songs) {
if (sanitizeSongDuration(song)) changed = true;
}
const duration = computeDuration(playlist.songs);
const readduration = getReadableDuration(duration);
if (playlist.duration !== duration || playlist.readduration !== readduration) {
playlist.duration = duration;
playlist.readduration = readduration;
changed = true;
}
return changed;
}
function nowIso() {
return new Date().toISOString();
}
function isValidIso(value) {
return typeof value === "string" && value !== "" && !Number.isNaN(Date.parse(value));
}
/**
* Horodatages manquants (anciennes playlists) : la création est déduite de l'identifiant (Date.now() en chaîne),
* à défaut de la dernière synchronisation, sinon maintenant.
* @returns {boolean} true si la playlist a été modifiée
*/
function ensureTimestamps(playlist) {
if (!playlist || typeof playlist !== "object") return false;
let changed = false;
if (!isValidIso(playlist.createdAt)) {
const raw = String(playlist.playlistId ?? "");
let created = null;
if (/^\d{1,16}$/.test(raw)) {
const date = new Date(Number(raw));
if (!Number.isNaN(date.getTime())) created = date.toISOString();
}
if (!created && isValidIso(playlist.syncedAt)) created = new Date(playlist.syncedAt).toISOString();
playlist.createdAt = created || nowIso();
changed = true;
}
if (!isValidIso(playlist.updatedAt)) {
playlist.updatedAt = playlist.createdAt;
changed = true;
}
return changed;
}
/**
* Marque une playlist comme modifiée
*/
function touch(playlist) {
if (playlist && typeof playlist === "object") playlist.updatedAt = nowIso();
return playlist;
}
function isSnowflake(value) {
return typeof value === "string" && /^\d{1,25}$/.test(value);
}
/**
* URL de l'avatar Discord d'un utilisateur ; avatar par défaut de Discord quand il n'en a pas
* (avant : ".../avatars/<id>/null", image cassée)
* @param {string} userId
* @param {string|null} avatar hash Discord
* @returns {string}
*/
function discordAvatarUrl(userId, avatar) {
if (isSnowflake(userId) && typeof avatar === "string" && /^(a_)?[0-9a-f]{6,64}$/i.test(avatar)) {
const ext = avatar.startsWith("a_") ? "gif" : "png";
return `https://cdn.discordapp.com/avatars/${userId}/${avatar}.${ext}?size=128`;
}
// Nouveau système de pseudos : (id >> 22) % 6
let index = 0;
if (isSnowflake(userId)) {
try {
index = Number((BigInt(userId) >> 22n) % 6n);
} catch (e) {
index = 0;
}
}
return `https://cdn.discordapp.com/embed/avatars/${index}.png`;
}
/**
* Lien du profil Discord (authorId d'une playlist est un lien, comme celui des musiques)
*/
function discordProfileUrl(userId) {
return isSnowflake(userId) ? `https://discord.com/users/${userId}` : null;
}
/**
* Nom de playlist envoyé par un client : texte nettoyé de 1 à max caractères, sinon null
*/
function parseName(value, max = MAX_NAME) {
if (typeof value !== "string") return null;
const name = value.replace(/[\u0000-\u001f\u007f]/g, " ").trim();
if (name.length < 1 || name.length > max) return null;
return name;
}
/**
* Lien d'import envoyé par un client : texte sans espace de 1 à 2000 caractères, sinon null
*/
function parseUrl(value) {
if (typeof value !== "string") return null;
const url = value.trim();
if (url.length < 1 || url.length > MAX_URL || /\s/.test(url)) return null;
return url;
}
/**
* Identifiant de playlist envoyé par un client (chaîne ou nombre), sinon null
*/
function parsePlaylistId(value) {
if (typeof value === "number" && Number.isSafeInteger(value) && value >= 0) return String(value);
if (typeof value !== "string") return null;
const id = value.trim();
if (id.length < 1 || id.length > MAX_ID) return null;
return id;
}
/**
* Position envoyée par un client : entier positif ou nul (nombre ou chaîne de chiffres), sinon null
*/
function parseIndex(value) {
if (typeof value === "number") return Number.isSafeInteger(value) && value >= 0 ? value : null;
if (typeof value === "string" && /^\d{1,9}$/.test(value)) return Number(value);
return null;
}
/**
* Musique envoyée par un client (objet ou JSON) : normalisée par SongCheck (type, hôte autorisé, textes bornés)
* @returns {import('../player/Song').Song|null}
*/
function prepareSong(raw) {
if (typeof raw === "string" && raw.length > 100000) return null;
const song = songCheck.sanitizeSong(raw);
if (!song || !song.id || !song.title) return null;
return song;
}
function sameSongId(a, b) {
if (a === undefined || a === null || b === undefined || b === null) return false;
return String(a) === String(b);
}
/**
* Retire une musique : l'index exact s'il correspond bien à songId (doublons), sinon la première occurrence
* @returns {boolean} true si une musique a été retirée
*/
function removeSongFrom(playlist, songId, index) {
if (!playlist || !Array.isArray(playlist.songs)) return false;
const songs = playlist.songs;
let target = -1;
if (Number.isInteger(index) && index >= 0 && index < songs.length && sameSongId(songs[index]?.id, songId)) {
target = index;
} else {
target = songs.findIndex(s => sameSongId(s?.id, songId));
}
if (target === -1) return false;
songs.splice(target, 1);
return true;
}
/**
* Déplace un élément d'un tableau (from et to : entiers dans les bornes)
* @returns {boolean} false si les positions sont invalides
*/
function moveItem(list, from, to) {
if (!Array.isArray(list)) return false;
if (!Number.isInteger(from) || !Number.isInteger(to)) return false;
if (from < 0 || to < 0 || from >= list.length || to >= list.length) return false;
if (from === to) return true;
const [item] = list.splice(from, 1);
list.splice(to, 0, item);
return true;
}
/**
* Réordonne une liste de playlists sur place : les identifiants connus dans l'ordre donné (sans doublon),
* puis ceux non cités dans leur ordre relatif actuel. Identifiants inconnus ignorés.
* @param {Array} list
* @param {Array} order identifiants de playlist
* @returns {boolean} false si l'ordre est invalide (pas un tableau, trop long)
*/
function applyOrder(list, order) {
if (!Array.isArray(list) || !Array.isArray(order) || order.length > MAX_ORDER) return false;
const byId = new Map();
for (const playlist of list) {
const key = String(playlist?.playlistId);
if (!byId.has(key)) byId.set(key, playlist);
}
const placed = new Set();
const result = [];
for (const raw of order) {
const id = parsePlaylistId(raw);
if (id === null || placed.has(id) || !byId.has(id)) continue;
placed.add(id);
result.push(byId.get(id));
}
const kept = new Set(result);
for (const playlist of list) {
if (kept.has(playlist)) continue;
result.push(playlist);
}
list.splice(0, list.length, ...result);
return true;
}
/**
* Borne le nombre de musiques d'une playlist (imports géants)
* @returns {boolean} true si la playlist a été tronquée
*/
function capSongs(playlist) {
if (!playlist || !Array.isArray(playlist.songs) || playlist.songs.length <= MAX_SONGS) return false;
playlist.songs.length = MAX_SONGS;
playlist.truncated = true;
return true;
}
/**
* Copie indépendante d'une playlist, sans ce qui la rattache à son propriétaire ou à son serveur
* (une playlist synchronisée appartient au compte Google de son propriétaire : la copie devient classique)
*/
function detachedCopy(playlist) {
const copy = JSON.parse(JSON.stringify(playlist));
delete copy.scope;
delete copy.guildId;
delete copy.createdBy;
delete copy.updatedBy;
delete copy.source;
delete copy.syncedAt;
return copy;
}
/**
* Une playlist rafraîchie (nouvel objet) garde l'identité de l'ancienne : identifiant local, date de création
* et titre choisi par l'utilisateur
*/
function inheritIdentity(fresh, current) {
fresh.playlistId = current.playlistId;
fresh.createdAt = isValidIso(current.createdAt) ? current.createdAt : nowIso();
if (current.customTitle && typeof current.title === "string") {
fresh.title = current.title;
fresh.customTitle = true;
}
capSongs(fresh);
refreshPlaylistDuration(fresh);
touch(fresh);
return fresh;
}
module.exports = {
MAX_SONGS,
MAX_NAME,
MAX_URL,
sameId,
newPlaylistId,
sanitizeSongDuration,
refreshPlaylistDuration,
nowIso,
isValidIso,
ensureTimestamps,
touch,
isSnowflake,
discordAvatarUrl,
discordProfileUrl,
parseName,
parseUrl,
parsePlaylistId,
parseIndex,
prepareSong,
removeSongFrom,
moveItem,
applyOrder,
capSongs,
detachedCopy,
inheritIdentity
};
+121 -15
View File
@@ -19,6 +19,7 @@ const players = require('../player/Player')
const discordBot = require('../discord/Bot') const discordBot = require('../discord/Bot')
const serverSettings = require('../discord/ServerSettings') const serverSettings = require('../discord/ServerSettings')
const playlists = require('../playlists/PlaylistManager') const playlists = require('../playlists/PlaylistManager')
const guildPlaylists = require('../playlists/GuildPlaylists')
const warp = require('../utils/WarpProxy') const warp = require('../utils/WarpProxy')
const SafeMetric = require('../utils/SafeMetric') const SafeMetric = require('../utils/SafeMetric')
const LogReader = require('../utils/LogReader') const LogReader = require('../utils/LogReader')
@@ -53,7 +54,9 @@ const SPOTIFY_TEST_TIMEOUT = 15 * 1000
// Changement des processus yt-dlp actifs (Youtube.js ne prévient pas le player) : sondage léger // Changement des processus yt-dlp actifs (Youtube.js ne prévient pas le player) : sondage léger
const YTDLP_PROCESS_POLL = 2000 const YTDLP_PROCESS_POLL = 2000
// Jamais "config" : contient le token du bot et les secrets // Jamais "config" : contient le token du bot et les secrets
const DATABASES = ["Users", "Playlists", "history", "media", "previous", "server_settings"] const DATABASES = ["Users", "Playlists", "GuildPlaylists", "history", "media", "previous", "server_settings"]
// Playlists partagées : qui peut les modifier (tous les membres / modérateurs)
const SHARED_PLAYLISTS_EDITORS = ["members", "mods"]
const PLAYER_ACTIONS = ["pause", "resume", "skip", "previous", "stop", "leave", "loop", "shuffle", "clear"] const PLAYER_ACTIONS = ["pause", "resume", "skip", "previous", "stop", "leave", "loop", "shuffle", "clear"]
const LOG_LEVELS = ["INFO", "WARN", "ERROR", "FATAL"] const LOG_LEVELS = ["INFO", "WARN", "ERROR", "FATAL"]
const USER_FILTERS = ["all", "online", "admin", "banned", "deleted"] const USER_FILTERS = ["all", "online", "admin", "banned", "deleted"]
@@ -447,7 +450,42 @@ function guildSummary(guildData, ctx = buildContext()) {
missingPermissions, missingPermissions,
isMediaGuild: !!id && id === configuration.getMediaGuildId(), isMediaGuild: !!id && id === configuration.getMediaGuildId(),
player: isConnectedPlayer(player) ? playerSummary(player) : null, player: isConnectedPlayer(player) ? playerSummary(player) : null,
stats: guildStats(id) stats: guildStats(id),
sharedPlaylists: sharedPlaylistsSummary(id)
}
}
/**
* Option des playlists partagées d'un serveur + nombre de playlists enregistrées (même masquées)
*/
function sharedPlaylistsSummary(guildId) {
const settings = typeof serverSettings.getSharedPlaylists === "function"
? serverSettings.getSharedPlaylists(guildId)
: { enabled: false, editors: "members" }
return {
enabled: !!settings?.enabled,
editors: SHARED_PLAYLISTS_EDITORS.includes(settings?.editors) ? settings.editors : "members",
count: guildPlaylists.countPlaylists(guildId)
}
}
/**
* Résumé d'une playlist pour les listes d'administration (jamais la liste des titres)
*/
function playlistLite(p) {
return {
playlistId: p?.playlistId != null ? String(p.playlistId) : null,
title: p?.title ?? null,
type: p?.type ?? null,
// "google" : synchronisée depuis le compte Google du propriétaire
source: p?.source ?? null,
url: typeof p?.url === "string" ? p.url : null,
thumbnail: typeof p?.thumbnail === "string" ? p.thumbnail : null,
duration: safeNum(p?.duration),
syncedAt: toIso(p?.syncedAt),
createdAt: toIso(p?.createdAt),
updatedAt: toIso(p?.updatedAt),
songCount: Array.isArray(p?.songs) ? p.songs.length : 0
} }
} }
@@ -1601,6 +1639,27 @@ function attach({ io: ioInstance, socket, socketUser }) {
return { secureRole: role ? { id: role.id, name: role.name } : null } return { secureRole: role ? { id: role.id, name: role.name } : null }
}) })
// Playlists partagées d'un serveur : {guildId, enabled?, editors?} (désactiver masque sans rien supprimer)
AdminRequest("/ADMIN/GUILDS/SET_SHARED_PLAYLISTS", async (payload, me) => {
const data = asObject(payload)
const guildId = requireId(data.guildId, "guildId")
if (!discordBot.getGuilds().has(guildId)) throw new AdminError("NOT_FOUND", "Serveur introuvable")
const patch = {}
if (data.enabled !== undefined) patch.enabled = requireBool(data.enabled, "enabled")
if (data.editors !== undefined) {
if (!SHARED_PLAYLISTS_EDITORS.includes(data.editors)) throw new AdminError("INVALID_PARAMS", "Paramètre \"editors\" invalide (members ou mods)")
patch.editors = data.editors
}
if (Object.keys(patch).length === 0) throw new AdminError("INVALID_PARAMS", "Aucune modification demandée (enabled ou editors)")
const settings = serverSettings.setSharedPlaylists(guildId, patch)
if (!settings) throw new AdminError("INVALID_PARAMS", "Réglage des playlists partagées invalide")
audit(me, "playlists partagées", `${guildId} -> ${settings.enabled ? "activées" : "désactivées"}, modification : ${settings.editors}`)
process.emit("GUILDS_UPDATE")
// Les membres présents sur ce serveur rechargent leurs playlists partagées
process.emit("GUILD_PLAYLISTS_UPDATE", guildId)
return { sharedPlaylists: sharedPlaylistsSummary(guildId) }
})
AdminRequest("/ADMIN/GUILDS/REFRESH", async (payload, me) => { AdminRequest("/ADMIN/GUILDS/REFRESH", async (payload, me) => {
if (typeof discordBot.refreshGuilds !== "function") throw new AdminError("UNAVAILABLE", "Rafraîchissement indisponible") if (typeof discordBot.refreshGuilds !== "function") throw new AdminError("UNAVAILABLE", "Rafraîchissement indisponible")
await discordBot.refreshGuilds() await discordBot.refreshGuilds()
@@ -1763,27 +1822,63 @@ function attach({ io: ioInstance, socket, socketUser }) {
username: owner?.identity?.username ?? null, username: owner?.identity?.username ?? null,
// Identité légère pour l'affichage (jamais auth ni tokens) // Identité légère pour l'affichage (jamais auth ni tokens)
owner: owner ? { ...userLite(owner.identity), isAdmin: owner.labels?.includes("ADMIN") ?? false, isDeleted: owner.labels?.includes("DELETED") ?? false } : null, owner: owner ? { ...userLite(owner.identity), isAdmin: owner.labels?.includes("ADMIN") ?? false, isDeleted: owner.labels?.includes("DELETED") ?? false } : null,
playlists: list.map(p => ({ playlists: list.map(playlistLite)
playlistId: p?.playlistId != null ? String(p.playlistId) : null,
title: p?.title ?? null,
type: p?.type ?? null,
// "google" : synchronisée depuis le compte Google du propriétaire
source: p?.source ?? null,
url: typeof p?.url === "string" ? p.url : null,
thumbnail: typeof p?.thumbnail === "string" ? p.thumbnail : null,
duration: safeNum(p?.duration),
syncedAt: toIso(p?.syncedAt),
songCount: Array.isArray(p?.songs) ? p.songs.length : 0
}))
}) })
} }
return result return result
}) })
// Playlists partagées des serveurs : {guildId?}. Serveurs ayant des playlists (même désactivées ou quittés
// par le bot) ou l'option activée
AdminRequest("/ADMIN/PLAYLISTS/GUILDS", async (payload) => {
const data = asObject(payload)
const filter = data.guildId === undefined || data.guildId === null ? null : requireId(data.guildId, "guildId")
const botGuilds = discordBot.getGuilds()
const ids = new Set(guildPlaylists.listGuildIds())
if (typeof serverSettings.getSharedPlaylists === "function") {
for (const guildId of botGuilds.keys()) {
if (serverSettings.getSharedPlaylists(guildId)?.enabled) ids.add(guildId)
}
}
if (filter) {
ids.clear()
ids.add(filter)
}
const result = []
for (const guildId of ids) {
const list = guildPlaylists.getPlaylists(guildId)
const shared = sharedPlaylistsSummary(guildId)
if (!filter && list.length === 0 && !shared.enabled) continue
const guild = botGuilds.get(guildId)
result.push({
guildId,
guildName: guild?.name ?? null,
guildIcon: guild?.icon ?? null,
settings: { enabled: shared.enabled, editors: shared.editors },
playlists: list.map(p => ({
...playlistLite(p),
createdBy: p?.createdBy ? userLite(p.createdBy) : null
}))
})
}
return result.sort((a, b) => (a.guildName || a.guildId).localeCompare(b.guildName || b.guildId))
})
// {userId, playlistId} : playlist personnelle ; {guildId, playlistId} : playlist partagée d'un serveur
AdminRequest("/ADMIN/PLAYLISTS/DELETE", async (payload, me) => { AdminRequest("/ADMIN/PLAYLISTS/DELETE", async (payload, me) => {
const data = asObject(payload) const data = asObject(payload)
const userId = requireId(data.userId)
const playlistId = requireString(typeof data.playlistId === "number" ? String(data.playlistId) : data.playlistId, "playlistId", 1, 64) const playlistId = requireString(typeof data.playlistId === "number" ? String(data.playlistId) : data.playlistId, "playlistId", 1, 64)
if (data.guildId !== undefined && data.guildId !== null) {
const guildId = requireId(data.guildId, "guildId")
const playlist = guildPlaylists.getPlaylist(guildId, playlistId)
if (!playlist) throw new AdminError("NOT_FOUND", "Playlist introuvable")
if (!guildPlaylists.deletePlaylist(guildId, playlist.playlistId)) throw new AdminError("NOT_FOUND", "Playlist introuvable")
audit(me, "suppression de la playlist partagée", `"${playlist.title}" (${playlistId}) du serveur ${guildId}`)
process.emit("GUILDS_UPDATE")
process.emit("GUILD_PLAYLISTS_UPDATE", guildId)
return { deleted: true }
}
const userId = requireId(data.userId)
const list = dbData("Playlists", {})[userId] const list = dbData("Playlists", {})[userId]
const playlist = Array.isArray(list) ? list.find(p => p?.playlistId != null && String(p.playlistId) === playlistId) : null const playlist = Array.isArray(list) ? list.find(p => p?.playlistId != null && String(p.playlistId) === playlistId) : null
if (!playlist) throw new AdminError("NOT_FOUND", "Playlist introuvable") if (!playlist) throw new AdminError("NOT_FOUND", "Playlist introuvable")
@@ -2016,6 +2111,17 @@ function init(ioInstance, deps = {}) {
}, 500) }, 500)
process.on("GUILDS_UPDATE", pushGuilds) process.on("GUILDS_UPDATE", pushGuilds)
// Playlists modifiées (personnelles ou partagées) : la section Playlists ouverte se relit d'elle-même
const changedScopes = new Set()
const pushPlaylists = debounce(() => {
const scopes = [...changedScopes]
changedScopes.clear()
if (roomSize(ADMIN_ROOM) === 0) return
io.to(ADMIN_ROOM).emit("/ADMIN/PLAYLISTS/UPDATE", { scopes })
}, 800)
process.on("PLAYLISTS_UPDATE", () => { changedScopes.add("personal"); pushPlaylists() })
process.on("GUILD_PLAYLISTS_UPDATE", () => { changedScopes.add("guild"); pushPlaylists() })
// WARP : changement d'état du proxy // WARP : changement d'état du proxy
process.on("WARP_UPDATE", () => { process.on("WARP_UPDATE", () => {
if (roomSize(ADMIN_ROOM) === 0) return if (roomSize(ADMIN_ROOM) === 0) return
+498 -86
View File
@@ -17,6 +17,8 @@ const Finder = require("../player/Finder")
const {__glob} = require("../utils/GlobalVars") const {__glob} = require("../utils/GlobalVars")
const playlists = require("../playlists/PlaylistManager") const playlists = require("../playlists/PlaylistManager")
const guildPlaylists = require("../playlists/GuildPlaylists")
const playlistUtils = require("../playlists/PlaylistUtils")
const history = require("../playlists/History") const history = require("../playlists/History")
const lyrics = require("../lyrics/Lyrics") const lyrics = require("../lyrics/Lyrics")
const serverSettings = require("../discord/ServerSettings") const serverSettings = require("../discord/ServerSettings")
@@ -92,6 +94,104 @@ function notifyGuildUpdate(guildId) {
io.to(rooms).emit("/GUILD/UPDATE") io.to(rooms).emit("/GUILD/UPDATE")
} }
// Playlists partagées : messages par défaut des refus (enveloppe {ok:false, error, message})
const GUILD_PLAYLIST_ERRORS = {
NO_GUILD: "Aucun serveur actif ou accès refusé à ce serveur",
DISABLED: "Les playlists partagées sont désactivées sur ce serveur",
FORBIDDEN: "Vous n'avez pas les droits nécessaires sur les playlists de ce serveur",
NOT_FOUND: "Playlist introuvable",
INVALID_PARAMS: "Paramètres invalides",
LIMIT: "Limite atteinte",
FAILED: "L'opération a échoué",
}
function refusal(error, message) {
return { ok: false, error, message: message || GUILD_PLAYLIST_ERRORS[error] || GUILD_PLAYLIST_ERRORS.FAILED }
}
function payloadObject(data) {
return data !== null && typeof data === "object" && !Array.isArray(data) ? data : {}
}
/**
* Identifiant de playlist : valeur brute (ancien client) ou {id} / {playlistId}
* @returns {string|null}
*/
function playlistIdFrom(data) {
if(typeof data === "string" || typeof data === "number") return playlistUtils.parsePlaylistId(data)
const payload = payloadObject(data)
return playlistUtils.parsePlaylistId(payload.id ?? payload.playlistId)
}
/**
* Création de playlist {name, url?} : nom de 1 à 100 caractères sans lien ; avec un lien (2000 caractères max),
* un nom non vide remplace le titre importé (un nom identique au lien, envoyé par l'ancien client, est ignoré)
* @returns {{name: string|null, url: string|null}|null}
*/
function parseCreateInput(data) {
const payload = payloadObject(data)
const hasUrl = payload.url !== undefined && payload.url !== null && payload.url !== ""
if(hasUrl) {
const url = playlistUtils.parseUrl(payload.url)
if(!url) return null
let name = null
if(typeof payload.name === "string" && payload.name.trim() !== "" && payload.name.trim() !== url) {
name = playlistUtils.parseName(payload.name)
if(!name) return null
} else if(payload.name !== undefined && payload.name !== null && typeof payload.name !== "string") {
return null
}
return { name, url }
}
const name = playlistUtils.parseName(payload.name)
return name ? { name, url: null } : null
}
/**
* Identifiant de musique (chaîne ou nombre non vide)
*/
function songIdFrom(value) {
if(typeof value === "number" && Number.isFinite(value)) return String(value)
if(typeof value === "string" && value !== "" && value.length <= 200) return value
return null
}
/**
* Statistiques publiques d'un serveur (accueil) : nombres finis, positifs, arrondis
*/
function guildStatsOf(guildId) {
const values = new Map()
for(const metric of SafeMetric.getGuildMetrics(guildId) || []) {
if(metric && typeof metric.name === "string") values.set(metric.name, metric.value)
}
const read = (name) => {
const n = Number(SafeMetric.toSafeNumber(values.get(name + "_" + guildId), 0))
return Number.isFinite(n) && n > 0 ? Math.round(n) : 0
}
return {
musicPlayed: read("numberOfMusicPlayed"),
secondsPlayed: read("numberOfSecondsPlayed"),
commands: read("numberOfCommands"),
}
}
/**
* Appel protégé du module de paroles : toute absence, exception ou réponse mal formée devient UNAVAILABLE
*/
async function callLyrics(functionName, ...args) {
const fn = lyrics && typeof lyrics[functionName] === "function" ? lyrics[functionName] : null
if(!fn) return { ok: false, error: "UNAVAILABLE" }
try {
const result = await fn(...args)
if(result && typeof result === "object" && typeof result.ok === "boolean") return result
wlog.warn("Réponse inattendue du module de paroles (" + functionName + ")")
return { ok: false, error: "UNAVAILABLE" }
} catch(e) {
wlog.error("Erreur du module de paroles (" + functionName + ") : " + (e?.stack || e))
return { ok: false, error: "UNAVAILABLE" }
}
}
function init() { function init() {
wlog.step.init("server_init", "Initialisation du serveur Socket.IO") wlog.step.init("server_init", "Initialisation du serveur Socket.IO")
@@ -177,6 +277,16 @@ function init() {
io.to("user:" + userId).emit("/PLAYLISTS/UPDATE") io.to("user:" + userId).emit("/PLAYLISTS/UPDATE")
}) })
// Playlists partagées d'un serveur (contenu ou réglage modifié) : sockets présents sur ce serveur
process.on("GUILD_PLAYLISTS_UPDATE", (guildId) => {
if(!io || typeof guildId !== "string" || guildId === "") return
try {
io.to(guildId).emit("/GUILD/PLAYLISTS/UPDATE", { guildId })
} catch(e) {
wlog.error("Erreur lors de l'envoi de la mise à jour des playlists partagées : " + (e?.stack || e))
}
})
io.on("connection", async (socket) => { io.on("connection", async (socket) => {
var socketUser; var socketUser;
var disconnectHandled = false var disconnectHandled = false
@@ -384,23 +494,34 @@ function init() {
// PLAYERS // PLAYERS
IORequest("/PLAYER/LYRICS", async () => { // PAROLES (LRCLIB, repli lyrics.ovh) : réponses {ok:true, ...} ou {ok:false, error}
if(!checkUserGuild(socketUser, actualGuildId)) return IOAnswer("/PLAYER/LYRICS", false) // NO_MUSIC | NOT_FOUND | UNAVAILABLE | INVALID_PARAMS, par acquittement ou par l'événement du même nom
const player = await verifyPlayerAction(actualGuildId)
if(!player) return IOAnswer("/PLAYER/LYRICS", false) // Paroles de la musique en cours sur le serveur actif (jamais de création de player)
if(!player.queue?.current) { IORequest("/PLAYER/LYRICS", async (_, ack) => {
wlog.warn("Le player de la guilde : " + actualGuildId + " n'a pas de musique en cours") const reply = (value) => answer("/PLAYER/LYRICS", value, ack)
IOAnswer("/PLAYER/LYRICS", false) if(!checkUserGuild(socketUser, actualGuildId)) return reply({ ok: false, error: "NO_MUSIC" })
return const song = players.AllPlayers.get(actualGuildId)?.queue?.current
} if(!song) return reply({ ok: false, error: "NO_MUSIC" })
const song = player.queue.current reply(await callLyrics("getLyricsForSong", song))
const lyricsData = await lyrics.getLyrics(song.title + " " + song.author) })
if(!lyricsData) {
wlog.warn("Aucune lyrics trouvée pour la musique : " + song.title + " de l'artiste : " + song.author) // Recherche manuelle : {q} ou texte (200 caractères max)
IOAnswer("/PLAYER/LYRICS", false) IORequest("/LYRICS/SEARCH", async (data, ack) => {
return const reply = (value) => answer("/LYRICS/SEARCH", value, ack)
} const raw = typeof data === "string" ? data : payloadObject(data).q
IOAnswer("/PLAYER/LYRICS", lyricsData) const query = typeof raw === "string" ? raw.trim() : ""
if(query === "" || query.length > 200) return reply({ ok: false, error: "INVALID_PARAMS" })
reply(await callLyrics("searchCandidates", query))
})
// Paroles d'un résultat de recherche : {id} (identifiant LRCLIB, entier positif)
IORequest("/LYRICS/GET", async (data, ack) => {
const reply = (value) => answer("/LYRICS/GET", value, ack)
const raw = typeof data === "number" || typeof data === "string" ? data : payloadObject(data).id
const id = typeof raw === "string" && /^\d{1,15}$/.test(raw.trim()) ? Number(raw.trim()) : raw
if(!Number.isSafeInteger(id) || id <= 0) return reply({ ok: false, error: "INVALID_PARAMS" })
reply(await callLyrics("getById", id))
}) })
@@ -460,6 +581,9 @@ function init() {
isAdmin: socketUser.isAdmin(), isAdmin: socketUser.isAdmin(),
} }
guildData.members = connectedMembers(actualGuildId, guild.owner) guildData.members = connectedMembers(actualGuildId, guild.owner)
// Option et droits des playlists partagées (le client adapte l'interface sans requête de plus)
const access = guildPlaylistsAccess()
guildData.sharedPlaylists = access ? sharedPlaylistsInfo(access) : { enabled: false, editors: "members", canEdit: false, canManage: false }
IOAnswer("/GUILD/INFO", guildData, true) IOAnswer("/GUILD/INFO", guildData, true)
}, true) }, true)
@@ -469,6 +593,12 @@ function init() {
IOAnswer("/GUILD/LIST", guildData, true) IOAnswer("/GUILD/LIST", guildData, true)
}, true) }, true)
// Statistiques publiques du serveur actif (accueil) : tout membre ayant accès
IORequest("/GUILD/STATS", (_, ack) => {
if(!checkUserGuild(socketUser, actualGuildId)) return answer("/GUILD/STATS", { ok: false, error: "NO_GUILD" }, ack, true)
answer("/GUILD/STATS", { ok: true, ...guildStatsOf(actualGuildId) }, ack, true)
}, true)
// CHECKED : 03/05/2025 // CHECKED : 03/05/2025
IORequest("/PLAYER/STATE", async () => { IORequest("/PLAYER/STATE", async () => {
const player = await verifyPlayerAction(actualGuildId) const player = await verifyPlayerAction(actualGuildId)
@@ -632,11 +762,33 @@ function init() {
IOAnswer("/SEARCH/PLAYLIST", true) IOAnswer("/SEARCH/PLAYLIST", true)
}) })
IORequest("/SEARCH/LYRICS", async (name) => { // Ancienne route : texte libre ou {track, artist?, duration?} → paroles du meilleur résultat
if(!name) return IOAnswer("/SEARCH/LYRICS", false) IORequest("/SEARCH/LYRICS", async (data, ack) => {
const lyricsData = await lyrics.getLyrics(name) const reply = (value) => answer("/SEARCH/LYRICS", value, ack)
if(!lyricsData) return IOAnswer("/SEARCH/LYRICS", false) const invalid = { ok: false, error: "INVALID_PARAMS" }
IOAnswer("/SEARCH/LYRICS", lyricsData) let query
if(typeof data === "string") {
const text = data.trim()
if(text === "" || text.length > 200) return reply(invalid)
query = text
} else {
const payload = payloadObject(data)
const title = typeof payload.track === "string" ? payload.track.trim() : ""
const author = payload.artist === undefined || payload.artist === null ? "" : payload.artist
if(title === "" || title.length > 200 || typeof author !== "string" || author.length > 200) return reply(invalid)
let duration = 0
if(payload.duration !== undefined && payload.duration !== null) {
duration = Number(payload.duration)
if(!Number.isFinite(duration) || duration < 0 || duration > 86400) return reply(invalid)
}
query = { track: title, artist: author.trim(), duration }
}
// findLyrics : texte libre (« artiste titre ») ou {track, artist, duration}
if(typeof lyrics?.findLyrics === "function") return reply(await callLyrics("findLyrics", query))
const song = typeof query === "string"
? { title: query, author: "", duration: 0 }
: { title: query.track, author: query.artist, duration: query.duration }
reply(await callLyrics("getLyricsForSong", song))
}) })
// UPLOAD // UPLOAD
@@ -734,104 +886,291 @@ function init() {
IOAnswer("/CHANNEL", channel, true) IOAnswer("/CHANNEL", channel, true)
}, true) }, true)
// PLAYLISTS PERSONNELLES
// Réponses inchangées (playlist | false | true) mais par acquittement si le client en fournit un
// (IORequestAck), sinon par l'événement du même nom. Chaque modification est poussée à toutes les
// sessions de l'utilisateur (/PLAYLISTS/UPDATE) pour que ses autres appareils se mettent à jour.
// CHECKED : 30/04/2025 // CHECKED : 30/04/2025
IORequest("/PLAYLISTS/CREATE", async (data) => { IORequest("/PLAYLISTS/CREATE", async (data, ack) => {
if(!data) return IOAnswer("/PLAYLISTS/CREATE", false) const reply = (value) => answer("/PLAYLISTS/CREATE", value, ack)
const {name, url} = data const input = parseCreateInput(data)
if(!name || typeof name !== "string") return IOAnswer("/PLAYLISTS/CREATE", false) if(!input) return reply(false)
const playlist = await playlists.addPlaylist(socketUser.identity.id, name, url, socketUser.identity.username, socketUser.identity.id, socketUser.identity.avatar) // Identité figée avant l'import (socketUser est relu par chaque nouvelle requête)
if(!playlist) return IOAnswer("/PLAYLISTS/CREATE", false) const identity = socketUser.identity
IOAnswer("/PLAYLISTS/CREATE", playlist) const playlist = await playlists.addPlaylist(identity.id, input.name, input.url, identity.username, identity.id, identity.avatar)
if(!playlist) return reply(false)
personalPlaylistsChanged(identity.id)
reply(playlist)
}) })
// CHECKED : 30/04/2025 // CHECKED : 30/04/2025
IORequest("/PLAYLISTS/DELETE", (data) => { IORequest("/PLAYLISTS/DELETE", (data, ack) => {
if(!data) return IOAnswer("/PLAYLISTS/DELETE", false) const id = playlistIdFrom(data)
IOAnswer("/PLAYLISTS/DELETE", playlists.removePlaylist(socketUser.identity.id, data)) if(!id) return answer("/PLAYLISTS/DELETE", false, ack)
const userId = socketUser.identity.id
const removed = playlists.removePlaylist(userId, id)
if(removed) personalPlaylistsChanged(userId)
answer("/PLAYLISTS/DELETE", removed, ack)
}) })
// CHECKED : 24/04/2025 // CHECKED : 24/04/2025
IORequest("/PLAYLISTS/LIST", () => { IORequest("/PLAYLISTS/LIST", (_, ack) => {
const playlist = playlists.getPlaylistsOfUser(socketUser.identity.id) answer("/PLAYLISTS/LIST", playlists.getPlaylistsOfUser(socketUser.identity.id), ack)
IOAnswer("/PLAYLISTS/LIST", playlist)
}) })
// Envoie une copie d'une playlist à un utilisateur partageant au moins un serveur // Envoie une copie d'une playlist à un utilisateur partageant au moins un serveur
// {playlistId (ou name, ancien nom du champ), toId} // {playlistId (ou name, ancien nom du champ), toId}
IORequest("/PLAYLISTS/SEND", (data) => { IORequest("/PLAYLISTS/SEND", (data, ack) => {
if(!data) return IOAnswer("/PLAYLISTS/SEND", false) const reply = (value) => answer("/PLAYLISTS/SEND", value, ack)
const toId = data.toId const payload = payloadObject(data)
const playlistId = data.playlistId ?? data.id ?? data.name const toId = payload.toId
if(!playlistId || !toId || typeof toId !== "string") return IOAnswer("/PLAYLISTS/SEND", false) const playlistId = playlistUtils.parsePlaylistId(payload.playlistId ?? payload.id ?? payload.name)
if(!playlistId || !toId || typeof toId !== "string") return reply(false)
const fromId = socketUser.identity.id const fromId = socketUser.identity.id
if(toId === fromId) return IOAnswer("/PLAYLISTS/SEND", false) if(toId === fromId) return reply(false)
const toUser = users.getUserById(toId) const toUser = users.getUserById(toId)
if(!toUser || toUser.isDeleted()) return IOAnswer("/PLAYLISTS/SEND", false) if(!toUser || toUser.isDeleted()) return reply(false)
const shareGuild = [...discordBot.getGuilds().values()].some(g => g.allMembers?.includes(fromId) && g.allMembers?.includes(toId)) const shareGuild = [...discordBot.getGuilds().values()].some(g => g.allMembers?.includes(fromId) && g.allMembers?.includes(toId))
if(!shareGuild) return IOAnswer("/PLAYLISTS/SEND", false) if(!shareGuild) return reply(false)
// copyPlaylist refuse un doublon de titre chez le destinataire // copyPlaylist refuse un doublon de titre chez le destinataire
const copy = playlists.copyPlaylist(fromId, toId, playlistId) const copy = playlists.copyPlaylist(fromId, toId, playlistId)
if(copy) process.emit("PLAYLISTS_UPDATE", toId) if(copy) process.emit("PLAYLISTS_UPDATE", toId)
IOAnswer("/PLAYLISTS/SEND", !!copy) reply(!!copy)
}) })
// CHECKED : 30/04/2025 // CHECKED : 30/04/2025
IORequest("/PLAYLISTS/RENAME", (data) => { IORequest("/PLAYLISTS/RENAME", (data, ack) => {
if(!data) return IOAnswer("/PLAYLISTS/RENAME", false) const reply = (value) => answer("/PLAYLISTS/RENAME", value, ack)
const {id, newName} = data const payload = payloadObject(data)
if(!id || !newName || typeof newName !== "string" || newName.trim() === "" || newName.length > 200) return IOAnswer("/PLAYLISTS/RENAME", false) const id = playlistUtils.parsePlaylistId(payload.id)
var playlist = playlists.getPlaylist(socketUser.identity.id, id) const newName = playlistUtils.parseName(payload.newName, 200)
if(!playlist) return IOAnswer("/PLAYLISTS/RENAME", false) if(!id || !newName) return reply(false)
playlists.renamePlaylist(socketUser.identity.id, id, newName.trim()) const userId = socketUser.identity.id
playlist = playlists.getPlaylist(socketUser.identity.id, id) const playlist = playlists.renamePlaylist(userId, id, newName)
IOAnswer("/PLAYLISTS/RENAME", playlist) if(!playlist) return reply(false)
personalPlaylistsChanged(userId)
reply(playlist)
}) })
// CHECKED : 30/04/2025 // CHECKED : 30/04/2025
IORequest("/PLAYLISTS/ADD_SONG", (data) => { IORequest("/PLAYLISTS/ADD_SONG", (data, ack) => {
if(!data) return IOAnswer("/PLAYLISTS/ADD_SONG", false) const reply = (value) => answer("/PLAYLISTS/ADD_SONG", value, ack)
const {id, song} = data const payload = payloadObject(data)
if(!id || !song) return IOAnswer("/PLAYLISTS/ADD_SONG", false) const id = playlistUtils.parsePlaylistId(payload.id)
var playlist = playlists.getPlaylistOfUser(socketUser.identity.id, id) if(!id || !payload.song) return reply(false)
if(!playlist) return IOAnswer("/PLAYLISTS/ADD_SONG", false) const userId = socketUser.identity.id
playlists.addSong(socketUser.identity.id, id, song) // null : playlist inconnue, musique invalide ou limite de titres atteinte
playlist = playlists.getPlaylistOfUser(socketUser.identity.id, id) const playlist = playlists.addSong(userId, id, payload.song)
IOAnswer("/PLAYLISTS/ADD_SONG", playlist) if(!playlist) return reply(false)
personalPlaylistsChanged(userId)
reply(playlist)
}) })
// CHECKED : 30/04/2025 // CHECKED : 30/04/2025
IORequest("/PLAYLISTS/REMOVE_SONG", (data) => { // {id, songId, index?} : index désigne l'occurrence exacte quand la musique est en double
if(!data) return IOAnswer("/PLAYLISTS/REMOVE_SONG", false) IORequest("/PLAYLISTS/REMOVE_SONG", (data, ack) => {
const {id, songId} = data const reply = (value) => answer("/PLAYLISTS/REMOVE_SONG", value, ack)
if(!id || !songId) return IOAnswer("/PLAYLISTS/REMOVE_SONG", false) const payload = payloadObject(data)
var playlist = playlists.getPlaylistOfUser(socketUser.identity.id, id) const id = playlistUtils.parsePlaylistId(payload.id)
if(!playlist) return IOAnswer("/PLAYLISTS/REMOVE_SONG", false) const songId = songIdFrom(payload.songId)
if(!id || !songId) return reply(false)
const index = playlistUtils.parseIndex(payload.index)
const userId = socketUser.identity.id
const playlist = playlists.removeSong(userId, id, songId, index === null ? undefined : index)
if(!playlist) return reply(false)
personalPlaylistsChanged(userId)
reply(playlist)
})
playlists.removeSong(socketUser.identity.id, id, songId) // Déplace une musique dans une playlist : {id, from, to}
playlist = playlists.getPlaylistOfUser(socketUser.identity.id, id) IORequest("/PLAYLISTS/MOVE_SONG", (data, ack) => {
const reply = (value) => answer("/PLAYLISTS/MOVE_SONG", value, ack)
const payload = payloadObject(data)
const id = playlistUtils.parsePlaylistId(payload.id)
const from = playlistUtils.parseIndex(payload.from)
const to = playlistUtils.parseIndex(payload.to)
if(!id || from === null || to === null) return reply(false)
const userId = socketUser.identity.id
const playlist = playlists.moveSong(userId, id, from, to)
if(!playlist) return reply(false)
if(from !== to) personalPlaylistsChanged(userId)
reply(playlist)
})
IOAnswer("/PLAYLISTS/REMOVE_SONG", playlist) // Ordre d'affichage des playlists : {order: [playlistId...]} (identifiants absents conservés à la suite)
IORequest("/PLAYLISTS/REORDER", (data, ack) => {
const order = payloadObject(data).order
if(!Array.isArray(order)) return answer("/PLAYLISTS/REORDER", false, ack)
const userId = socketUser.identity.id
const done = playlists.reorderPlaylists(userId, order)
if(done) personalPlaylistsChanged(userId)
answer("/PLAYLISTS/REORDER", done, ack)
}) })
// CHECKED : 05/05/2025 // CHECKED : 05/05/2025
IORequest("/PLAYLISTS/PLAY", async (data) => { // {id, now} (ou {name, now} : ancien nom du champ, qui contenait déjà l'identifiant)
if(!data) return IOAnswer("/PLAYLISTS/PLAY", false) IORequest("/PLAYLISTS/PLAY", async (data, ack) => {
const {name, now} = data const reply = (value) => answer("/PLAYLISTS/PLAY", value, ack)
if(!name) return IOAnswer("/PLAYLISTS/PLAY", false) const payload = payloadObject(data)
if(!checkUserGuild(socketUser, actualGuildId)) return IOAnswer("/PLAYLISTS/PLAY", false) const id = playlistUtils.parsePlaylistId(payload.id ?? payload.name)
const playlist = playlists.getPlaylistOfUser(socketUser.identity.id, name) if(!id) return reply(false)
if(!playlist) return IOAnswer("/PLAYLISTS/PLAY", false) if(!checkUserGuild(socketUser, actualGuildId)) return reply(false)
const playlist = playlists.getPlaylistOfUser(socketUser.identity.id, id)
if(!playlist) return reply(false)
const player = new Player(actualGuildId) const player = new Player(actualGuildId)
if(!connectToPlayer(actualGuildId, player)) return IOAnswer("/PLAYLISTS/PLAY", false) if(!connectToPlayer(actualGuildId, player)) return reply(false)
player.readPlaylist(playlist, now) player.readPlaylist(playlist, !!payload.now)
IOAnswer("/PLAYLISTS/PLAY", true) reply(true)
}) })
IORequest("/PLAYLISTS/REFRESH", async (data) => { // Identifiant brut ou {id}
if(!data) return IOAnswer("/PLAYLISTS/REFRESH", false) IORequest("/PLAYLISTS/REFRESH", async (data, ack) => {
const playlist = await playlists.refreshPlaylist(socketUser.identity.id, data) const reply = (value) => answer("/PLAYLISTS/REFRESH", value, ack)
if(!playlist) return IOAnswer("/PLAYLISTS/REFRESH", false) const id = playlistIdFrom(data)
IOAnswer("/PLAYLISTS/REFRESH", playlist) if(!id) return reply(false)
const userId = socketUser.identity.id
const playlist = await playlists.refreshPlaylist(userId, id)
if(!playlist) return reply(false)
personalPlaylistsChanged(userId)
reply(playlist)
})
// PLAYLISTS PARTAGÉES DU SERVEUR ACTIF
// Toujours le serveur actif (actualGuildId, jamais un identifiant du client) et checkUserGuild d'abord.
// Réponses {ok:true, ...} ou {ok:false, error, message} ; error : NO_GUILD, DISABLED, FORBIDDEN,
// NOT_FOUND, INVALID_PARAMS, LIMIT, FAILED. Chaque modification pousse /GUILD/PLAYLISTS/UPDATE {guildId}.
IORequest("/GUILD/PLAYLISTS/LIST", (_, ack) => {
const access = guildPlaylistsAccess()
if(!access) return answer("/GUILD/PLAYLISTS/LIST", refusal("NO_GUILD"), ack, true)
answer("/GUILD/PLAYLISTS/LIST", {
ok: true,
guildId: access.guildId,
settings: { ...access.settings },
canEdit: access.canEdit,
canManage: access.canManage,
// Désactivées : masquées (jamais supprimées), réapparaissent à la réactivation
playlists: access.settings.enabled ? guildPlaylists.getPlaylists(access.guildId) : [],
}, ack, true)
}, true)
GuildPlaylistRequest("/GUILD/PLAYLISTS/CREATE", async (payload, access) => {
const input = parseCreateInput(payload)
if(!input) return refusal("INVALID_PARAMS", "Nom (1 à 100 caractères) ou lien de playlist invalide")
return guildResult(await guildPlaylists.createPlaylist(access.guildId, input, access.user), access.guildId)
}, { edit: true })
// Suppression : créateur de la playlist, modérateur, propriétaire du serveur ou administrateur
GuildPlaylistRequest("/GUILD/PLAYLISTS/DELETE", async (payload, access) => {
const id = playlistUtils.parsePlaylistId(payload.id)
if(!id) return refusal("INVALID_PARAMS")
const playlist = guildPlaylists.getPlaylist(access.guildId, id)
if(!playlist) return refusal("NOT_FOUND")
const isCreator = !!playlist.createdBy?.id && playlist.createdBy.id === access.userId
if(!isCreator && !access.isModerator) return refusal("FORBIDDEN", "Seuls le créateur de la playlist et les modérateurs peuvent la supprimer")
if(!guildPlaylists.deletePlaylist(access.guildId, id)) return refusal("NOT_FOUND")
guildPlaylistsChanged(access.guildId)
return { ok: true }
})
GuildPlaylistRequest("/GUILD/PLAYLISTS/RENAME", async (payload, access) => {
const id = playlistUtils.parsePlaylistId(payload.id)
const newName = playlistUtils.parseName(payload.newName)
if(!id || !newName) return refusal("INVALID_PARAMS", "Nom invalide (1 à 100 caractères)")
return guildResult(guildPlaylists.renamePlaylist(access.guildId, id, newName, access.user), access.guildId)
}, { edit: true })
GuildPlaylistRequest("/GUILD/PLAYLISTS/ADD_SONG", async (payload, access) => {
const id = playlistUtils.parsePlaylistId(payload.id)
if(!id || !payload.song) return refusal("INVALID_PARAMS")
return guildResult(guildPlaylists.addSong(access.guildId, id, payload.song, access.user), access.guildId)
}, { edit: true })
GuildPlaylistRequest("/GUILD/PLAYLISTS/REMOVE_SONG", async (payload, access) => {
const id = playlistUtils.parsePlaylistId(payload.id)
const songId = songIdFrom(payload.songId)
if(!id || !songId) return refusal("INVALID_PARAMS")
const index = playlistUtils.parseIndex(payload.index)
return guildResult(guildPlaylists.removeSong(access.guildId, id, songId, index === null ? undefined : index, access.user), access.guildId)
}, { edit: true })
GuildPlaylistRequest("/GUILD/PLAYLISTS/MOVE_SONG", async (payload, access) => {
const id = playlistUtils.parsePlaylistId(payload.id)
const from = playlistUtils.parseIndex(payload.from)
const to = playlistUtils.parseIndex(payload.to)
if(!id || from === null || to === null) return refusal("INVALID_PARAMS")
const result = guildPlaylists.moveSong(access.guildId, id, from, to, access.user)
// Déplacement sur place : aucune poussée inutile
if(result?.ok && from === to) return { ok: true, playlist: result.playlist }
return guildResult(result, access.guildId)
}, { edit: true })
GuildPlaylistRequest("/GUILD/PLAYLISTS/REORDER", async (payload, access) => {
if(!Array.isArray(payload.order)) return refusal("INVALID_PARAMS")
if(!guildPlaylists.reorder(access.guildId, payload.order)) return refusal("INVALID_PARAMS")
guildPlaylistsChanged(access.guildId)
return { ok: true }
}, { edit: true })
// Playlists importées depuis un lien uniquement
GuildPlaylistRequest("/GUILD/PLAYLISTS/REFRESH", async (payload, access) => {
const id = playlistUtils.parsePlaylistId(payload.id)
if(!id) return refusal("INVALID_PARAMS")
return guildResult(await guildPlaylists.refreshPlaylist(access.guildId, id, access.user), access.guildId)
}, { edit: true })
// Lecture : tout membre ayant accès (même logique de connexion que /PLAYLISTS/PLAY)
GuildPlaylistRequest("/GUILD/PLAYLISTS/PLAY", async (payload, access) => {
const id = playlistUtils.parsePlaylistId(payload.id)
if(!id) return refusal("INVALID_PARAMS")
const playlist = guildPlaylists.getPlaylist(access.guildId, id)
if(!playlist) return refusal("NOT_FOUND")
const player = new Player(access.guildId)
if(!connectToPlayer(access.guildId, player)) return refusal("FAILED", "Rejoignez d'abord un salon vocal de ce serveur")
player.readPlaylist(playlist, !!payload.now)
return { ok: true }
})
// Partage sur le serveur d'une de MES playlists (copie indépendante) : {playlistId}
GuildPlaylistRequest("/GUILD/PLAYLISTS/IMPORT", async (payload, access) => {
const id = playlistUtils.parsePlaylistId(payload.playlistId ?? payload.id)
if(!id) return refusal("INVALID_PARAMS")
const personal = playlists.getPlaylistOfUser(access.userId, id)
if(!personal) return refusal("NOT_FOUND")
return guildResult(guildPlaylists.importPlaylist(access.guildId, personal, access.user), access.guildId)
}, { edit: true })
// Copie d'une playlist du serveur dans MES playlists : {id}
GuildPlaylistRequest("/GUILD/PLAYLISTS/COPY", async (payload, access) => {
const id = playlistUtils.parsePlaylistId(payload.id)
if(!id) return refusal("INVALID_PARAMS")
const playlist = guildPlaylists.getPlaylist(access.guildId, id)
if(!playlist) return refusal("NOT_FOUND")
if(!playlists.hasRoomForPlaylist(access.userId)) return refusal("LIMIT", "Vous avez atteint la limite de " + playlists.MAX_PLAYLISTS + " playlists : supprimez-en une avant d'en copier une autre")
const copy = playlists.addPlaylistCopy(access.userId, playlist)
if(!copy) return refusal("FAILED", "La copie de la playlist a échoué")
personalPlaylistsChanged(access.userId)
return { ok: true, playlist: copy }
})
// Réglage du serveur actif : {enabled?, editors?} (propriétaire ou administrateur du bot)
IORequest("/OWNER/PLAYLISTS/SETTINGS", (data, ack) => {
const reply = (value) => answer("/OWNER/PLAYLISTS/SETTINGS", value, ack)
if(!checkUserGuild(socketUser, actualGuildId)) return reply(refusal("NO_GUILD"))
const guild = discordBot.getGuilds().get(actualGuildId)
if(!isGuildOwnerOrAdmin(guild)) return reply(refusal("FORBIDDEN", "Réservé au propriétaire du serveur"))
const payload = payloadObject(data)
const patch = {}
if(payload.enabled !== undefined) patch.enabled = payload.enabled
if(payload.editors !== undefined) patch.editors = payload.editors
const settings = serverSettings.setSharedPlaylists(actualGuildId, patch)
if(!settings) return reply(refusal("INVALID_PARAMS", "Réglage invalide (enabled : booléen, editors : \"members\" ou \"mods\")"))
wlog.log("Playlists partagées de la guilde " + actualGuildId + " modifiées par '" + socketUser.identity.username + "'")
guildPlaylistsChanged(actualGuildId)
// Liste des serveurs de l'administration (option affichée)
process.emit("GUILDS_UPDATE")
reply({ ok: true, settings })
}) })
@@ -900,6 +1239,79 @@ function init() {
return socketUser.isAdmin() return socketUser.isAdmin()
} }
/**
* Droits de l'utilisateur sur les playlists partagées du serveur actif (null : pas d'accès au serveur)
* - voir / lire : option activée ; modifier : activée et (tous les membres ou modérateur / propriétaire / admin)
* - gérer l'option : propriétaire ou administrateur du bot
*/
function guildPlaylistsAccess() {
if(!checkUserGuild(socketUser, actualGuildId)) return null
const guild = discordBot.getGuilds().get(actualGuildId)
if(!guild) return null
const settings = serverSettings.getSharedPlaylists(actualGuildId)
const userId = socketUser.identity.id
const isOwner = userId === guild.owner
const isAdmin = socketUser.isAdmin()
const isModerator = isOwner || isAdmin || socketUser.isMod(actualGuildId)
return {
guildId: actualGuildId,
guild,
settings,
userId,
// Identité figée au moment de la requête (createdBy / updatedBy)
user: { ...socketUser.identity },
isModerator,
canEdit: settings.enabled && (settings.editors === "members" || isModerator),
canManage: isOwner || isAdmin,
}
}
function sharedPlaylistsInfo(access) {
return {
enabled: access.settings.enabled,
editors: access.settings.editors,
canEdit: access.canEdit,
canManage: access.canManage,
}
}
/**
* Route des playlists partagées : accès au serveur, option activée et, si edit, droit de modification
* vérifiés avant le handler ; réponse unique (acquittement ou événement), exception → FAILED
*/
function GuildPlaylistRequest(name, handler, { edit = false } = {}) {
IORequest(name, async (data, ack) => {
const reply = (value) => answer(name, value, ack)
try {
const access = guildPlaylistsAccess()
if(!access) return reply(refusal("NO_GUILD"))
if(!access.settings.enabled) return reply(refusal("DISABLED"))
if(edit && !access.canEdit) return reply(refusal("FORBIDDEN", "Seuls les modérateurs peuvent modifier les playlists de ce serveur"))
reply(await handler(payloadObject(data), access))
} catch(e) {
wlog.error("Erreur lors du traitement de " + name + " pour '" + socketUser?.identity?.username + "' : " + (e?.stack || e))
reply(refusal("FAILED"))
}
})
}
function guildPlaylistsChanged(guildId) {
process.emit("GUILD_PLAYLISTS_UPDATE", guildId)
}
function personalPlaylistsChanged(userId) {
process.emit("PLAYLISTS_UPDATE", userId)
}
// Résultat de GuildPlaylists → enveloppe de réponse (poussée aux membres présents en cas de succès)
function guildResult(result, guildId) {
if(result?.ok) {
guildPlaylistsChanged(guildId)
return { ok: true, playlist: result.playlist }
}
return refusal(result?.error || "FAILED", result?.message)
}
// CHECKED : 24/04/2025 // CHECKED : 24/04/2025
IORequest("/MOD/USERS/BAN", async (userId) => { IORequest("/MOD/USERS/BAN", async (userId) => {
if(!userId || typeof userId !== "string" || !actualGuildId) return IOAnswer("/MOD/USERS/BAN", false) if(!userId || typeof userId !== "string" || !actualGuildId) return IOAnswer("/MOD/USERS/BAN", false)
+2
View File
@@ -13,6 +13,8 @@ const __glob = {
PREVIOUSFILE: root + path.sep + "data" + path.sep + "previous.json", PREVIOUSFILE: root + path.sep + "data" + path.sep + "previous.json",
USERFILE: root + path.sep + "data" + path.sep + "users.json", USERFILE: root + path.sep + "data" + path.sep + "users.json",
PLAYLISTFILE: root + path.sep + "data" + path.sep + "playlists.json", PLAYLISTFILE: root + path.sep + "data" + path.sep + "playlists.json",
// Playlists partagées par serveur Discord : { "<guildId>": [Playlist] }
GUILD_PLAYLISTS_DB: root + path.sep + "data" + path.sep + "guild_playlists.json",
HISTORY_DB: root + path.sep + "data" + path.sep + "history.json", HISTORY_DB: root + path.sep + "data" + path.sep + "history.json",
MEDIA_DB: root + path.sep + "data" + path.sep + "media.json", MEDIA_DB: root + path.sep + "data" + path.sep + "media.json",
SERVER_DB: root + path.sep + "data" + path.sep + "servers.json", SERVER_DB: root + path.sep + "data" + path.sep + "servers.json",
+2 -2
View File
@@ -3,8 +3,8 @@
Scripts autonomes écrits pendant la refonte YouTube / admin / OAuth (octobre 2026). Ils ne démarrent ni le bot Discord ni le serveur HTTP. Scripts autonomes écrits pendant la refonte YouTube / admin / OAuth (octobre 2026). Ils ne démarrent ni le bot Discord ni le serveur HTTP.
Lancer depuis n'importe où : `node backend/tests/dev/<fichier>.js` (code de sortie 1 en cas d'échec). Lancer depuis n'importe où : `node backend/tests/dev/<fichier>.js` (code de sortie 1 en cas d'échec).
- Hors réseau : `cookies.test.js`, `errors.test.js`, `plan.test.js`, `limiter.test.js`, `google-utils.test.js`, `google-flow.test.js`, `admin-youtube.test.js`, `admin-config.test.js`, `server-socket.test.js`, `stream.edge.js` - Hors réseau : `cookies.test.js`, `errors.test.js`, `plan.test.js`, `limiter.test.js`, `google-utils.test.js`, `google-flow.test.js`, `admin-youtube.test.js`, `admin-config.test.js`, `server-socket.test.js`, `guild-playlists.test.js` (playlists personnelles / partagées, dossier data temporaire), `stream.edge.js`, `lyrics.test.js` (fetch simulé)
- Réseau (YouTube / GitHub réels) : `stream.e2e.js`, `check.e2e.js`, `metadata.live.test.js`, `avatar.live.test.js`, `update.check.js` - Réseau (YouTube / GitHub / LRCLIB réels) : `stream.e2e.js`, `check.e2e.js`, `metadata.live.test.js`, `avatar.live.test.js`, `update.check.js`, `lyrics.live.js`
- `undef-check.js` : détection d'identifiants non déclarés (nécessite `frontend/node_modules` pour @babel). - `undef-check.js` : détection d'identifiants non déclarés (nécessite `frontend/node_modules` pour @babel).
Les données YouTube des tests vont dans `os.tmpdir()/chopin-tests` (jamais `backend/data/`), les logs dans `tests/dev/logs/` (ignoré par git). Les données YouTube des tests vont dans `os.tmpdir()/chopin-tests` (jamais `backend/data/`), les logs dans `tests/dev/logs/` (ignoré par git).
+2 -1
View File
@@ -175,7 +175,7 @@ const playlistEvents = [];
process.on('PLAYLISTS_UPDATE', (userId) => playlistEvents.push(userId)); process.on('PLAYLISTS_UPDATE', (userId) => playlistEvents.push(userId));
(async () => { (async () => {
const expected = ['/ADMIN/CONFIG/GET', '/ADMIN/CONFIG/SET', '/ADMIN/CONFIG/SPOTIFY/TEST', '/ADMIN/WARP/CONFIG', '/ADMIN/PLAYLISTS/LIST', '/ADMIN/PLAYLISTS/DELETE']; const expected = ['/ADMIN/CONFIG/GET', '/ADMIN/CONFIG/SET', '/ADMIN/CONFIG/SPOTIFY/TEST', '/ADMIN/WARP/CONFIG', '/ADMIN/PLAYLISTS/LIST', '/ADMIN/PLAYLISTS/DELETE', '/ADMIN/PLAYLISTS/GUILDS', '/ADMIN/GUILDS/SET_SHARED_PLAYLISTS'];
check('routes enregistrées', expected.every(r => handlers.has(r)), expected.filter(r => !handlers.has(r))); check('routes enregistrées', expected.every(r => handlers.has(r)), expected.filter(r => !handlers.has(r)));
console.log('# Configuration : validateur pur'); console.log('# Configuration : validateur pur');
@@ -316,6 +316,7 @@ process.on('PLAYLISTS_UPDATE', (userId) => playlistEvents.push(userId));
check('compte des titres', lists[0].songCount === 2 && lists[1].songCount === 1 && lists[2].songCount === 0); check('compte des titres', lists[0].songCount === 2 && lists[1].songCount === 1 && lists[2].songCount === 0);
check('synchro Google repérée', lists[1].source === 'google' && lists[1].syncedAt === '2026-10-01T10:00:00.000Z'); check('synchro Google repérée', lists[1].source === 'google' && lists[1].syncedAt === '2026-10-01T10:00:00.000Z');
check('aucune liste de titres renvoyée', lists.every(p => !('songs' in p))); check('aucune liste de titres renvoyée', lists.every(p => !('songs' in p)));
check('dates de création / modification exposées', lists.every(p => 'createdAt' in p && 'updatedAt' in p));
r = await call('/ADMIN/PLAYLISTS/LIST', { userId: '444444' }); r = await call('/ADMIN/PLAYLISTS/LIST', { userId: '444444' });
check('filtre par utilisateur (même vide)', r.ok && r.data.length === 1 && r.data[0].playlists.length === 0); check('filtre par utilisateur (même vide)', r.ok && r.data.length === 1 && r.data[0].playlists.length === 0);
r = await call('/ADMIN/PLAYLISTS/LIST', { userId: 'abc' }); r = await call('/ADMIN/PLAYLISTS/LIST', { userId: 'abc' });
+1
View File
@@ -25,6 +25,7 @@ const stubs = {
[norm(SRC + '/discord/Bot')]: { getGuilds: () => guilds, getClient: () => null, isReady: () => false }, [norm(SRC + '/discord/Bot')]: { getGuilds: () => guilds, getClient: () => null, isReady: () => false },
[norm(SRC + '/discord/ServerSettings')]: {}, [norm(SRC + '/discord/ServerSettings')]: {},
[norm(SRC + '/playlists/PlaylistManager')]: {}, [norm(SRC + '/playlists/PlaylistManager')]: {},
[norm(SRC + '/playlists/GuildPlaylists')]: { countPlaylists: () => 0, getPlaylists: () => [], getPlaylist: () => null, listGuildIds: () => [] },
[norm(SRC + '/utils/SafeMetric')]: { getAll: () => [], get: () => 0 }, [norm(SRC + '/utils/SafeMetric')]: { getAll: () => [], get: () => 0 },
[norm(SRC + '/utils/LogReader')]: { init() {}, getCurrentName: () => 'x.log', isInterrupted: async () => false }, [norm(SRC + '/utils/LogReader')]: { init() {}, getCurrentName: () => 'x.log', isInterrupted: async () => false },
[norm(SRC + '/utils/Maintenance')]: {}, [norm(SRC + '/utils/Maintenance')]: {},
+336
View File
@@ -0,0 +1,336 @@
// Playlists : horodatages / réparation au démarrage, ordre, déplacements, limites (PlaylistManager),
// playlists partagées par serveur (GuildPlaylists), option du serveur (ServerSettings) et routes d'administration
// associées (Admin.js). Vrais modules et vraie base JSON, mais TOUS les chemins de __glob qui pointent vers
// backend/data sont redirigés vers un dossier temporaire. Aucun appel réseau (Finder / Spotify / Google simulés).
const Module = require('module');
const path = require('path');
const fs = require('fs');
const { BACKEND, SCRATCH, __glob, isolateData, check, done } = require('./_setup');
// --- Données isolées : chaque chemin de data/ est redirigé AVANT de charger le moindre module ---
const DATA_DIR = path.join(BACKEND, 'data');
const realGuildFile = path.join(DATA_DIR, 'guild_playlists.json');
const realGuildFileExisted = fs.existsSync(realGuildFile);
const tmp = fs.mkdtempSync(path.join(SCRATCH, 'guild-playlists-'));
for (const [key, value] of Object.entries(__glob)) {
if (typeof value === 'string' && path.resolve(value).toLowerCase().startsWith(DATA_DIR.toLowerCase())) {
__glob[key] = path.join(tmp, path.relative(DATA_DIR, value));
}
}
__glob.DATA = tmp;
isolateData('guild-playlists-yt');
const safePaths = Object.entries(__glob).every(([k, v]) => typeof v !== 'string' || !path.resolve(v).toLowerCase().startsWith(DATA_DIR.toLowerCase()));
if (!safePaths || !__glob.GUILD_PLAYLISTS_DB || path.dirname(__glob.GUILD_PLAYLISTS_DB) !== tmp) {
console.error('Chemins non isolés : arrêt');
process.exit(1);
}
const USER = '111111111111111111';
const OTHER = '444444444444444444';
const GUILD = '222222222222222222';
const GUILD_UNKNOWN = '999999999999999999';
const LEGACY_ID = '1754933300538';
// --- Fichiers synthétiques (anciennes entrées sans dates, identifiant numérique, durée en texte) ---
const ytSong = (id, extra = {}) => ({ type: 'youtube', url: `https://www.youtube.com/watch?v=${id}`, id, title: 'Titre ' + id, author: 'Artiste', duration: 100, ...extra });
fs.writeFileSync(__glob.PLAYLISTFILE, JSON.stringify({
[USER]: [
{ title: 'Ancienne', playlistId: LEGACY_ID, url: null, songs: [ytSong('a', { duration: '120' })], type: 'playlist' },
{ title: 'Numérique', playlistId: 1754933300999, url: null, songs: [], type: 'playlist' },
{ title: 'Sans id', url: 'https://www.youtube.com/playlist?list=PLx', songs: [], type: 'youtube', syncedAt: '2026-01-02T03:04:05.000Z' }
]
}));
fs.writeFileSync(__glob.GUILD_PLAYLISTS_DB, JSON.stringify({
[GUILD]: [{ title: 'Partagée ancienne', playlistId: 1700000000000, url: null, songs: [ytSong('g', { duration: '30' })], type: 'playlist' }]
}));
fs.writeFileSync(__glob.SERVER_DB, JSON.stringify({ [GUILD]: { secureRole: null } }));
const SRC = path.join(BACKEND, 'src');
const norm = (p) => p.replace(/\\/g, '/').toLowerCase().replace(/\.js$/, '');
// --- Stubs (réseau, Discord, administration) ---
let finderCalls = 0;
const finderStub = {
search: async (url, multiple, forceType) => {
finderCalls++;
if (forceType !== 'PLAYLIST' || url.includes('echec')) return null;
const { Playlist } = require(path.join(SRC, 'playlists', 'Playlist.js'));
const songs = [ytSong('r1'), ytSong('r2', { duration: 200 })];
const playlist = new Playlist('Distante', url, 'Chaîne', 'https://www.youtube.com/channel/UCx', songs, 'https://i.ytimg.com/vi/r1/hq.jpg');
playlist.type = 'youtube';
return playlist;
}
};
const admin = { identity: { id: USER, username: 'testadmin', global_name: 'Admin', avatar: null }, labels: ['ADMIN'], isAdmin: () => true, isFullBanned: () => false };
const guilds = new Map([[GUILD, { id: GUILD, name: 'Serveur Test', icon: null, owner: OTHER, allMembers: [USER, OTHER] }]]);
const stubs = {
[norm(SRC + '/player/Finder')]: finderStub,
[norm(SRC + '/media/SpotifyInformation')]: { getTracks: async (p) => p },
[norm(SRC + '/playlists/Google/YoutubeList')]: {},
[norm(SRC + '/playlists/Google/OAuth2')]: { isLinked: () => false },
[norm(SRC + '/media/MediaInformation')]: {},
[norm(SRC + '/server/auth/User')]: { getUserById: (id) => id === USER ? admin : null, getUsers: () => [admin] },
[norm(SRC + '/server/auth/Session')]: { getSessionCount: () => 0 },
[norm(SRC + '/player/Player')]: { getAllPlayers: () => [], AllPlayers: new Map() },
[norm(SRC + '/discord/Bot')]: { getGuilds: () => guilds, getClient: () => ({ guilds: { cache: new Map() } }), getChannel: () => null, getGuildRoles: () => [] },
[norm(SRC + '/discord/MediaBase')]: {},
[norm(SRC + '/utils/WarpProxy')]: {},
[norm(SRC + '/utils/LogReader')]: { init() {} },
[norm(SRC + '/utils/Maintenance')]: {},
[norm(SRC + '/utils/Database/Configuration')]: { getMediaGuildId: () => '', getMediaChannelId: () => '' },
[norm(SRC + '/youtube')]: {},
[norm(SRC + '/player/Method/Youtube')]: { activeProcesses: new Map() },
};
const origLoad = Module._load;
Module._load = function (request, parent) {
if (request.startsWith('.') && parent) {
const resolved = norm(path.resolve(path.dirname(parent.filename), request));
if (stubs[resolved]) return stubs[resolved];
}
return origLoad.apply(this, arguments);
};
const readJson = (file) => JSON.parse(fs.readFileSync(file, 'utf-8'));
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
const PlaylistManager = require(path.join(SRC, 'playlists', 'PlaylistManager.js'));
const GuildPlaylists = require(path.join(SRC, 'playlists', 'GuildPlaylists.js'));
const ServerSettings = require(path.join(SRC, 'discord', 'ServerSettings.js'));
const utils = require(path.join(SRC, 'playlists', 'PlaylistUtils.js'));
const Admin = require(path.join(SRC, 'server', 'Admin.js'));
const guildEvents = [];
process.on('GUILD_PLAYLISTS_UPDATE', (guildId) => guildEvents.push(guildId));
(async () => {
console.log('# PlaylistManager : réparation au démarrage');
let disk = readJson(__glob.PLAYLISTFILE)[USER];
check('date déduite de l\'identifiant', disk[0].createdAt === new Date(Number(LEGACY_ID)).toISOString() && disk[0].updatedAt === disk[0].createdAt, disk[0]);
check('identifiant numérique converti en chaîne + daté', disk[1].playlistId === '1754933300999' && disk[1].createdAt === new Date(1754933300999).toISOString());
check('identifiant attribué + date de synchro', typeof disk[2].playlistId === 'string' && disk[2].createdAt === '2026-01-02T03:04:05.000Z');
check('durée en texte réparée', disk[0].songs[0].duration === 120 && disk[0].duration === 120);
console.log('# PlaylistManager : lecture sans écriture');
const before = fs.readFileSync(__glob.PLAYLISTFILE, 'utf-8');
const empty = PlaylistManager.getPlaylistsOfUser(OTHER);
check('utilisateur inconnu : tableau vide', Array.isArray(empty) && empty.length === 0);
check('aucune clé créée sur lecture', fs.readFileSync(__glob.PLAYLISTFILE, 'utf-8') === before && !(OTHER in readJson(__glob.PLAYLISTFILE)));
check('playlist inconnue : null', PlaylistManager.getPlaylistOfUser(OTHER, '1') === null);
console.log('# PlaylistManager : création');
const manual = await PlaylistManager.addPlaylist(USER, 'Ma liste', null, 'testadmin', USER, null);
check('playlist vide créée', manual && manual.title === 'Ma liste' && manual.type === 'playlist' && Array.isArray(manual.songs));
check('avatar par défaut si aucun avatar', /^https:\/\/cdn\.discordapp\.com\/embed\/avatars\/[0-5]\.png$/.test(manual.authorAvatar), manual.authorAvatar);
check('authorId = profil Discord', manual.authorId === `https://discord.com/users/${USER}`);
check('createdAt = updatedAt (ISO)', utils.isValidIso(manual.createdAt) && manual.createdAt === manual.updatedAt);
const withAvatar = utils.discordAvatarUrl(USER, 'a_0123456789abcdef');
check('avatar animé en gif', withAvatar === `https://cdn.discordapp.com/avatars/${USER}/a_0123456789abcdef.gif?size=128`);
const imported = await PlaylistManager.addPlaylist(USER, 'Nom perso', 'https://www.youtube.com/playlist?list=PLabc', 'testadmin', USER, null);
check('import : nom choisi prioritaire', imported && imported.title === 'Nom perso' && imported.customTitle === true && imported.songs.length === 2 && imported.duration === 300, imported && { title: imported.title, d: imported.duration });
const importedPlain = await PlaylistManager.addPlaylist(USER, 'https://www.youtube.com/playlist?list=PLdef', 'https://www.youtube.com/playlist?list=PLdef', 'testadmin', USER, null);
check('import : nom identique au lien ignoré', importedPlain && importedPlain.title === 'Distante' && !importedPlain.customTitle);
check('import en échec : null', await PlaylistManager.addPlaylist(USER, null, 'https://www.youtube.com/playlist?list=echec', 'testadmin', USER, null) === null);
check('création sans nom ni lien : null', await PlaylistManager.addPlaylist(USER, null, null, 'testadmin', USER, null) === null);
console.log('# PlaylistManager : musiques');
const pid = manual.playlistId;
manual.updatedAt = '2020-01-01T00:00:00.000Z';
let res = PlaylistManager.addSong(USER, pid, ytSong('a'));
check('ajout + updatedAt', res && res.songs.length === 1 && res.updatedAt !== '2020-01-01T00:00:00.000Z');
check('ajout en JSON accepté', PlaylistManager.addSong(USER, pid, JSON.stringify(ytSong('b'))) !== null);
PlaylistManager.addSong(USER, pid, ytSong('a'));
check('musique invalide refusée', PlaylistManager.addSong(USER, pid, { type: 'evil', url: 'http://127.0.0.1/x', id: 'x', title: 'x' }) === null);
check('hôte non autorisé refusé', PlaylistManager.addSong(USER, pid, { type: 'youtube', url: 'https://evil.example/watch?v=x', id: 'x', title: 'x' }) === null);
check('playlist inconnue refusée', PlaylistManager.addSong(USER, 'nope', ytSong('c')) === null);
// [a, b, a] : l'index exact retire la bonne occurrence
res = PlaylistManager.removeSong(USER, pid, 'a', 2);
check('retrait par index exact', res && res.songs.map(s => s.id).join(',') === 'a,b', res?.songs?.map(s => s.id));
PlaylistManager.addSong(USER, pid, ytSong('c'));
res = PlaylistManager.removeSong(USER, pid, 'c', 0);
check('index incohérent : première occurrence', res && res.songs.map(s => s.id).join(',') === 'a,b');
check('musique absente : null', PlaylistManager.removeSong(USER, pid, 'zzz') === null);
PlaylistManager.addSong(USER, pid, ytSong('c'));
res = PlaylistManager.moveSong(USER, pid, 0, 2);
check('déplacement', res && res.songs.map(s => s.id).join(',') === 'b,c,a');
check('déplacement hors limites : null', PlaylistManager.moveSong(USER, pid, 0, 3) === null && PlaylistManager.moveSong(USER, pid, -1, 0) === null);
check('durée recalculée', manual.duration === 300);
// Limite de 5000 titres
const big = PlaylistManager.getPlaylistOfUser(USER, pid);
const saved = big.songs;
big.songs = Array.from({ length: utils.MAX_SONGS }, (_, i) => ({ ...saved[0], id: 'x' + i }));
check('limite de 5000 titres', PlaylistManager.addSong(USER, pid, ytSong('d')) === null);
big.songs = saved;
console.log('# PlaylistManager : ordre, renommage, actualisation, copie');
const ids = PlaylistManager.getPlaylistsOfUser(USER).map(p => p.playlistId);
check('ordre : refus si pas un tableau', PlaylistManager.reorderPlaylists(USER, 'x') === false);
check('ordre appliqué', PlaylistManager.reorderPlaylists(USER, [ids[3], 'inconnu', ids[0], ids[3], 42]) === true);
const order = PlaylistManager.getPlaylistsOfUser(USER).map(p => p.playlistId);
check('connus d\'abord, sans doublon, oubliés à la suite', order.join() === [ids[3], ids[0], ids[1], ids[2], ...ids.slice(4)].join(), order);
check('ordre enregistré', readJson(__glob.PLAYLISTFILE)[USER].map(p => p.playlistId).join() === order.join());
check('ordre pour un utilisateur sans playlist : aucune écriture', PlaylistManager.reorderPlaylists(OTHER, []) === true && !(OTHER in readJson(__glob.PLAYLISTFILE)));
const renamed = PlaylistManager.renamePlaylist(USER, importedPlain.playlistId, 'Renommée');
check('renommage d\'un import : titre conservé à l\'actualisation', renamed.customTitle === true);
const createdAt = importedPlain.createdAt;
renamed.updatedAt = '2020-01-01T00:00:00.000Z';
const refreshed = await PlaylistManager.refreshPlaylist(USER, importedPlain.playlistId);
check('actualisation : titre, id, création conservés', refreshed && refreshed.title === 'Renommée' && refreshed.playlistId === importedPlain.playlistId && refreshed.createdAt === createdAt && refreshed.updatedAt !== '2020-01-01T00:00:00.000Z', refreshed && { t: refreshed.title, c: refreshed.createdAt });
check('actualisation : même position', PlaylistManager.getPlaylistsOfUser(USER).findIndex(p => p.playlistId === importedPlain.playlistId) === order.indexOf(importedPlain.playlistId));
const syncedLike = { ...PlaylistManager.getPlaylistOfUser(USER, LEGACY_ID), source: 'google', scope: 'guild', guildId: GUILD, createdBy: { id: OTHER } };
const copy = PlaylistManager.addPlaylistCopy(OTHER, syncedLike);
check('copie détachée', copy && !copy.source && !copy.scope && !copy.guildId && !copy.createdBy && copy.createdAt && copy.playlistId);
check('copie : clé créée à l\'écriture', readJson(__glob.PLAYLISTFILE)[OTHER].length === 1);
console.log('# PlaylistManager : limite de playlists personnelles');
const otherList = PlaylistManager.getPlaylistsOfUser(OTHER);
const otherLength = otherList.length;
for (let i = otherLength; i < PlaylistManager.MAX_PLAYLISTS; i++) otherList.push({ playlistId: 'p' + i, title: 'x' + i, songs: [] });
const callsBefore = finderCalls;
check('limite : hasRoomForPlaylist faux', PlaylistManager.hasRoomForPlaylist(OTHER) === false && PlaylistManager.hasRoomForPlaylist(USER) === true);
check('limite : création refusée', await PlaylistManager.addPlaylist(OTHER, 'Trop', null, 'membre', OTHER, null) === null);
check('limite : import refusé sans appel réseau', await PlaylistManager.addPlaylist(OTHER, null, 'https://www.youtube.com/playlist?list=PLzz', 'membre', OTHER, null) === null && finderCalls === callsBefore);
check('limite : copie refusée', PlaylistManager.addPlaylistCopy(OTHER, syncedLike) === null && PlaylistManager.copyPlaylist(USER, OTHER, LEGACY_ID) === null);
otherList.splice(otherLength);
check('limite levée après suppression', PlaylistManager.hasRoomForPlaylist(OTHER) === true);
console.log('# ServerSettings : option des playlists partagées');
const serversBefore = fs.readFileSync(__glob.SERVER_DB, 'utf-8');
check('valeurs par défaut (ancienne entrée)', JSON.stringify(ServerSettings.getSharedPlaylists(GUILD)) === JSON.stringify({ enabled: false, editors: 'members' }));
check('serveur inconnu : défauts', ServerSettings.getSharedPlaylists(GUILD_UNKNOWN).enabled === false && ServerSettings.getSharedPlaylists(undefined).editors === 'members');
check('lecture sans écriture', fs.readFileSync(__glob.SERVER_DB, 'utf-8') === serversBefore);
check('identifiant invalide refusé', ServerSettings.setSharedPlaylists('__proto__', { enabled: true }) === null && ServerSettings.setSharedPlaylists('abc', { enabled: true }) === null);
check('patch vide refusé', ServerSettings.setSharedPlaylists(GUILD, {}) === null);
check('enabled non booléen refusé', ServerSettings.setSharedPlaylists(GUILD, { enabled: 'oui' }) === null);
check('editors inconnu refusé', ServerSettings.setSharedPlaylists(GUILD, { editors: 'all' }) === null);
check('aucune écriture sur refus', fs.readFileSync(__glob.SERVER_DB, 'utf-8') === serversBefore);
let settings = ServerSettings.setSharedPlaylists(GUILD, { enabled: true });
check('activation', settings && settings.enabled === true && settings.editors === 'members');
settings = ServerSettings.setSharedPlaylists(GUILD, { editors: 'mods' });
check('champ absent conservé', settings.enabled === true && settings.editors === 'mods');
let serversDisk = readJson(__glob.SERVER_DB)[GUILD];
check('enregistré, rôle sécurisé conservé', serversDisk.secureRole === null && serversDisk.sharedPlaylists.enabled === true && serversDisk.sharedPlaylists.editors === 'mods');
check('getSecureRole intact', ServerSettings.getSecureRole(GUILD) === null);
console.log('# GuildPlaylists : réparation et lecture');
const legacyGuild = GuildPlaylists.getPlaylists(GUILD)[0];
check('réparation : id chaîne, dates, rattachement', legacyGuild.playlistId === '1700000000000' && legacyGuild.createdAt === new Date(1700000000000).toISOString() && legacyGuild.scope === 'guild' && legacyGuild.guildId === GUILD);
check('réparation : durée', legacyGuild.duration === 30);
const guildBefore = fs.readFileSync(__glob.GUILD_PLAYLISTS_DB, 'utf-8');
check('serveur inconnu : vide sans écriture', GuildPlaylists.getPlaylists(GUILD_UNKNOWN).length === 0 && GuildPlaylists.getPlaylist(GUILD_UNKNOWN, '1') === null && GuildPlaylists.countPlaylists(GUILD_UNKNOWN) === 0);
check('reorder sur serveur inconnu : aucune écriture', GuildPlaylists.reorder(GUILD_UNKNOWN, []) === true);
check('fichier inchangé après lectures', fs.readFileSync(__glob.GUILD_PLAYLISTS_DB, 'utf-8') === guildBefore);
console.log('# GuildPlaylists : création et modifications');
const actor = { id: OTHER, username: 'membre', global_name: 'Membre', avatar: null, tokens: ['secret'] };
let g = await GuildPlaylists.createPlaylist(GUILD, { name: 'Soirée' }, actor);
check('création', g.ok && g.playlist.scope === 'guild' && g.playlist.guildId === GUILD && g.playlist.title === 'Soirée');
check('createdBy sans jeton', JSON.stringify(g.playlist.createdBy) === JSON.stringify({ id: OTHER, username: 'membre', global_name: 'Membre', avatar: null }) && g.playlist.updatedBy.id === OTHER);
const gid = g.playlist.playlistId;
check('identifiant unique dans le serveur', GuildPlaylists.getPlaylists(GUILD).filter(p => p.playlistId === gid).length === 1);
check('création sans nom ni lien refusée', (await GuildPlaylists.createPlaylist(GUILD, {}, actor)).error === 'INVALID_PARAMS');
check('serveur invalide refusé', (await GuildPlaylists.createPlaylist('abc', { name: 'x' }, actor)).error === 'INVALID_PARAMS');
check('import en échec : FAILED', (await GuildPlaylists.createPlaylist(GUILD, { url: 'https://www.youtube.com/playlist?list=echec' }, actor)).error === 'FAILED');
const gImported = await GuildPlaylists.createPlaylist(GUILD, { url: 'https://www.youtube.com/playlist?list=PLg' }, admin);
check('import partagé', gImported.ok && gImported.playlist.title === 'Distante' && gImported.playlist.createdBy.id === USER && gImported.playlist.songs.length === 2);
g = GuildPlaylists.addSong(GUILD, gid, ytSong('s1'), admin);
check('ajout + updatedBy', g.ok && g.playlist.songs.length === 1 && g.playlist.updatedBy.id === USER);
check('musique invalide : INVALID_PARAMS', GuildPlaylists.addSong(GUILD, gid, { type: 'x' }, admin).error === 'INVALID_PARAMS');
check('playlist inconnue : NOT_FOUND', GuildPlaylists.addSong(GUILD, 'nope', ytSong('s1'), admin).error === 'NOT_FOUND');
GuildPlaylists.addSong(GUILD, gid, ytSong('s2'), admin);
GuildPlaylists.addSong(GUILD, gid, ytSong('s1'), admin);
g = GuildPlaylists.removeSong(GUILD, gid, 's1', 2, actor);
check('retrait par index exact', g.ok && g.playlist.songs.map(s => s.id).join() === 's1,s2' && g.playlist.updatedBy.id === OTHER);
check('retrait absent : NOT_FOUND', GuildPlaylists.removeSong(GUILD, gid, 'zz', undefined, actor).error === 'NOT_FOUND');
g = GuildPlaylists.moveSong(GUILD, gid, 1, 0, actor);
check('déplacement', g.ok && g.playlist.songs.map(s => s.id).join() === 's2,s1');
check('déplacement invalide', GuildPlaylists.moveSong(GUILD, gid, 0, 9, actor).error === 'INVALID_PARAMS');
g = GuildPlaylists.renamePlaylist(GUILD, gid, 'Soirée 2', actor);
check('renommage', g.ok && g.playlist.title === 'Soirée 2');
const list = GuildPlaylists.getPlaylists(GUILD).map(p => p.playlistId);
check('ordre commun', GuildPlaylists.reorder(GUILD, [list[2], list[0]]) && GuildPlaylists.getPlaylists(GUILD).map(p => p.playlistId).join() === [list[2], list[0], list[1]].join());
check('ordre invalide', GuildPlaylists.reorder(GUILD, 'x') === false);
check('actualisation d\'une playlist manuelle refusée', (await GuildPlaylists.refreshPlaylist(GUILD, gid, actor)).error === 'INVALID_PARAMS');
const importedCreated = gImported.playlist.createdAt;
g = await GuildPlaylists.refreshPlaylist(GUILD, gImported.playlist.playlistId, actor);
check('actualisation : auteur et création conservés', g.ok && g.playlist.createdBy.id === USER && g.playlist.updatedBy.id === OTHER && g.playlist.createdAt === importedCreated && g.playlist.scope === 'guild' && g.playlist.guildId === GUILD);
const personalSynced = { ...PlaylistManager.getPlaylistOfUser(USER, LEGACY_ID), source: 'google', syncedAt: '2026-01-01T00:00:00.000Z' };
g = GuildPlaylists.importPlaylist(GUILD, personalSynced, admin);
check('partage d\'une playlist personnelle', g.ok && !g.playlist.source && !g.playlist.syncedAt && g.playlist.scope === 'guild' && g.playlist.title === 'Ancienne' && g.playlist.playlistId);
const sharedId = g.playlist.playlistId;
check('copie indépendante', g.playlist.songs !== personalSynced.songs);
// Limite de 200 playlists par serveur
const live = GuildPlaylists.getPlaylists(GUILD);
const realLength = live.length;
for (let i = realLength; i < GuildPlaylists.MAX_PLAYLISTS; i++) live.push({ playlistId: 'fill' + i, title: 'x', songs: [] });
check('limite de 200 : création', (await GuildPlaylists.createPlaylist(GUILD, { name: 'Trop' }, actor)).error === 'LIMIT');
check('limite de 200 : partage', GuildPlaylists.importPlaylist(GUILD, personalSynced, actor).error === 'LIMIT');
live.splice(realLength);
const diskGuild = readJson(__glob.GUILD_PLAYLISTS_DB)[GUILD];
check('enregistré sur disque', diskGuild.length === realLength && diskGuild.every(p => p.scope === 'guild' && p.createdAt && p.updatedAt));
console.log('# Désactivation : données conservées');
ServerSettings.setSharedPlaylists(GUILD, { enabled: false });
check('désactivé : playlists conservées', GuildPlaylists.countPlaylists(GUILD) === realLength && readJson(__glob.GUILD_PLAYLISTS_DB)[GUILD].length === realLength);
ServerSettings.setSharedPlaylists(GUILD, { enabled: true });
console.log('# Administration');
const handlers = new Map();
const socket = { id: 'sock1', data: { userId: USER }, rooms: new Set(), on: (name, fn) => handlers.set(name, fn), emit() {}, leave() {}, join() {} };
const fakeIo = {
sockets: { adapter: { rooms: new Map() }, sockets: new Map() },
to: () => ({ emit() {} }), in: () => ({ socketsLeave() {} }), emit() {}, of: () => ({ adapter: { on() {} } })
};
Admin.attach({ io: fakeIo, socket, socketUser: () => admin });
const call = (name, payload) => new Promise((resolve) => handlers.get(name)(payload, resolve));
let r = await call('/ADMIN/DATABASES', {});
check('base GuildPlaylists listée', r.ok && r.data.some(db => db.name === 'GuildPlaylists' && db.entries === 1), r.data?.map(db => db.name));
r = await call('/ADMIN/PLAYLISTS/LIST', { userId: USER });
check('LIST : createdAt / updatedAt', r.ok && r.data[0].playlists.every(p => utils.isValidIso(p.createdAt) && utils.isValidIso(p.updatedAt)));
r = await call('/ADMIN/PLAYLISTS/GUILDS', {});
const entry = r.data?.find(e => e.guildId === GUILD);
check('GUILDS : serveur et réglage', r.ok && entry && entry.guildName === 'Serveur Test' && entry.settings.enabled === true && entry.settings.editors === 'mods', r);
check('GUILDS : playlists résumées', entry && entry.playlists.length === realLength && entry.playlists.every(p => typeof p.songCount === 'number' && !('songs' in p) && p.createdAt));
check('GUILDS : createdBy léger', entry && entry.playlists.find(p => p.playlistId === gid)?.createdBy?.id === OTHER && !JSON.stringify(entry).includes('secret'));
r = await call('/ADMIN/PLAYLISTS/GUILDS', { guildId: 'abc' });
check('GUILDS : guildId invalide', !r.ok && r.error === 'INVALID_PARAMS');
r = await call('/ADMIN/PLAYLISTS/GUILDS', { guildId: GUILD_UNKNOWN });
check('GUILDS : filtre sur serveur sans playlist', r.ok && r.data.length === 1 && r.data[0].playlists.length === 0 && !GuildPlaylists.listGuildIds().includes(GUILD_UNKNOWN));
guildEvents.length = 0;
r = await call('/ADMIN/GUILDS/SET_SHARED_PLAYLISTS', { guildId: GUILD, editors: 'members' });
check('SET_SHARED_PLAYLISTS', r.ok && r.data.sharedPlaylists.enabled === true && r.data.sharedPlaylists.editors === 'members' && r.data.sharedPlaylists.count === realLength, r);
check('poussée vers le serveur', guildEvents.includes(GUILD));
r = await call('/ADMIN/GUILDS/SET_SHARED_PLAYLISTS', { guildId: GUILD, editors: 'tous' });
check('editors invalide', !r.ok && r.error === 'INVALID_PARAMS');
r = await call('/ADMIN/GUILDS/SET_SHARED_PLAYLISTS', { guildId: GUILD, enabled: 'oui' });
check('enabled invalide', !r.ok && r.error === 'INVALID_PARAMS');
r = await call('/ADMIN/GUILDS/SET_SHARED_PLAYLISTS', { guildId: GUILD });
check('aucune modification', !r.ok && r.error === 'INVALID_PARAMS');
r = await call('/ADMIN/GUILDS/SET_SHARED_PLAYLISTS', { guildId: GUILD_UNKNOWN, enabled: true });
check('serveur inconnu', !r.ok && r.error === 'NOT_FOUND');
r = await call('/ADMIN/GUILDS/LIST', {});
check('résumé du serveur : sharedPlaylists', r.ok && JSON.stringify(r.data[0].sharedPlaylists) === JSON.stringify({ enabled: true, editors: 'members', count: realLength }), r.data?.[0]?.sharedPlaylists);
guildEvents.length = 0;
r = await call('/ADMIN/PLAYLISTS/DELETE', { guildId: GUILD, playlistId: 'inconnue' });
check('DELETE partagée inconnue', !r.ok && r.error === 'NOT_FOUND');
r = await call('/ADMIN/PLAYLISTS/DELETE', { guildId: GUILD, playlistId: sharedId });
check('DELETE partagée', r.ok && r.data.deleted === true && !GuildPlaylists.getPlaylist(GUILD, sharedId) && guildEvents.includes(GUILD));
r = await call('/ADMIN/PLAYLISTS/DELETE', { userId: USER, playlistId: pid });
check('DELETE personnelle toujours possible', r.ok && !PlaylistManager.getPlaylistOfUser(USER, pid));
check('suppression d\'une playlist partagée (module)', GuildPlaylists.deletePlaylist(GUILD, gid) === true && GuildPlaylists.deletePlaylist(GUILD, gid) === false);
await sleep(50);
check('backend/data jamais utilisé', fs.existsSync(realGuildFile) === realGuildFileExisted && !fs.existsSync(realGuildFile + '.tmp'));
fs.rmSync(tmp, { recursive: true, force: true });
done();
setTimeout(() => process.exit(process.exitCode || 0), 50);
})().catch((e) => { console.error(e); process.exit(1); });
+90
View File
@@ -0,0 +1,90 @@
// Test réseau de backend/src/lyrics/Lyrics.js contre LRCLIB réel (node lyrics.live.js) : affiche les résultats
const { check, done, req } = require("./_setup");
const L = req("src/lyrics/Lyrics.js");
// Formes rencontrées en vrai : titres YouTube bruts, chaînes VEVO / Topic, morceaux Spotify propres
const SONGS = [
{ type: "youtube", id: "FGBhQbmPwH8", title: "Daft Punk - One More Time (Official Video)", author: "Daft Punk", duration: 320, expectSynced: true },
{ type: "youtube", id: "dQw4w9WgXcQ", title: "Rick Astley - Never Gonna Give You Up (Official Music Video)", author: "Rick Astley", duration: 213, expectSynced: true },
{ type: "youtube", id: "spotify-papaoutai", title: "Papaoutai", author: "Stromae", duration: 232, expectSynced: true },
{ type: "youtube", id: "fJ9rUzIMcZQ", title: "Queen – Bohemian Rhapsody (Official Video Remastered)", author: "Queen Official", duration: 360 },
{ type: "youtube", id: "topic-test", title: "Get Lucky (feat. Pharrell Williams and Nile Rodgers)", author: "Daft Punk - Topic", duration: 369 },
{ type: "youtube", id: "nope", title: "zzqx introuvable 8472 kq", author: "Personne", duration: 100, expectMiss: true },
];
function preview(result) {
if (!result.ok) return `échec ${result.error}`;
const first = result.synced ? result.lines.find(l => l.text) : null;
return `${result.source} · ${result.instrumental ? "instrumental" : result.synced ? `synchronisées (${result.lines.length} lignes, 1re à ${(first.t / 1000).toFixed(2)} s : « ${first.text} »)` : "texte seul"}`
+ ` · match ${result.match.artist} - ${result.match.track} (${result.match.album || "?"}, ${result.match.duration} s, id ${result.match.id ?? "-"})`;
}
(async () => {
for (const song of SONGS) {
const t = Date.now();
const result = await L.getLyricsForSong(song);
console.log(`\n${song.title} / ${song.author}\n requête : ${JSON.stringify(L.guessQuery(song))}\n ${preview(result)} [${Date.now() - t} ms]`);
if (song.expectMiss) check("introuvable → NOT_FOUND", !result.ok && result.error === "NOT_FOUND", result);
else check(`trouvé : ${song.title}`, result.ok && !!(result.plain || result.instrumental), result.error);
if (song.expectSynced) {
check(`synchronisées : ${song.title}`, result.ok && result.synced);
// Horodatages exploitables : croissants, en ms, dans la durée du morceau
const lines = result.ok && Array.isArray(result.lines) ? result.lines : [];
const texts = lines.filter(l => l.text);
const sorted = lines.every((l, i) => Number.isInteger(l.t) && l.t >= 0 && (i === 0 || l.t >= lines[i - 1].t));
const last = lines.length ? lines[lines.length - 1].t : 0;
check(`horodatages croissants (${texts.length} lignes)`, texts.length >= 10 && sorted && last > 30000 && last <= (song.duration + 15) * 1000, { lines: lines.length, last });
check("User-Agent identifiant envoyé", /^Subsonics-Chopin\/\S+ \(https?:\/\//.test(L.USER_AGENT));
}
check("songKey", result.songKey === `${song.type}:${song.id}`);
}
const t = Date.now();
await L.getLyricsForSong(SONGS[0]);
check("2e appel servi par le cache", Date.now() - t < 50);
const search = await L.searchCandidates("daft punk one more time");
console.log(`\nsearchCandidates : ${search.ok ? search.results.length + " résultats" : search.error}`);
if (search.ok) for (const c of search.results.slice(0, 5)) console.log(` #${c.id} ${c.artist} - ${c.track} (${c.album}, ${c.duration} s${c.synced ? ", synchro" : ""})`);
check("searchCandidates ok", search.ok && search.results.length > 0);
if (search.ok && search.results.length) {
const byId = await L.getById(search.results[0].id);
console.log(`getById(${search.results[0].id}) : ${preview(byId)}`);
check("getById ok", byId.ok);
}
const legacy = await L.getLyrics("Daft Punk - One More Time");
check("getLyrics (compat) renvoie du texte", typeof legacy === "string" && legacy.length > 20);
// Repli : LRCLIB rendu injoignable, Deezer + lyrics.ovh réels
console.log("\nRepli lyrics.ovh (LRCLIB simulé en panne)");
const realFetch = globalThis.fetch;
const hosts = [];
globalThis.fetch = async (input, options) => {
const host = new URL(String(input)).host;
hosts.push(host);
if (host === "lrclib.net") throw new TypeError("fetch failed");
return realFetch(input, options);
};
try {
L.clearCache();
const t0 = Date.now();
const fallback = await L.getLyricsForSong({ type: "youtube", id: "ovh-test", title: "Coldplay - Yellow (Official Video)", author: "Coldplay", duration: 269 });
console.log(` ${fallback.ok ? preview(fallback) : "échec " + fallback.error} [${Date.now() - t0} ms] · hôtes : ${[...new Set(hosts)].join(", ")}`);
check("repli tenté (Deezer puis lyrics.ovh)", hosts.includes("api.deezer.com") && hosts.includes("api.lyrics.ovh"), [...new Set(hosts)]);
if (fallback.ok) {
check("repli : texte seul depuis lyrics.ovh", fallback.source === "lyrics.ovh" && !fallback.synced && fallback.lines === null && fallback.plain.length > 100);
} else {
// lyrics.ovh est souvent en panne : signalé sans faire échouer le test
console.log(` AVERTISSEMENT : lyrics.ovh n'a rien renvoyé (${fallback.error}), le repli n'a pas pu être vérifié de bout en bout`);
check("repli en panne → UNAVAILABLE (LRCLIB injoignable)", fallback.error === "UNAVAILABLE");
}
} finally {
globalThis.fetch = realFetch;
}
done();
})().catch((error) => {
console.error(error);
process.exitCode = 1;
});
+390
View File
@@ -0,0 +1,390 @@
// Tests hors réseau de backend/src/lyrics/Lyrics.js (node lyrics.test.js) : fetch global remplacé par un faux
const { check, done, req } = require("./_setup");
const L = req("src/lyrics/Lyrics.js");
const realFetch = globalThis.fetch;
let calls = [];
let route = () => json(404, { message: "not found" });
function json(status, body) {
return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
}
function mock(handler) {
calls = [];
route = handler;
L.clearCache();
}
globalThis.fetch = async (input, options = {}) => {
const url = new URL(String(input));
calls.push({ url, options });
return route(url, options);
};
const eq = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const LRC = "[ar:Daft Punk]\n[ti:One More Time]\n[00:30.75] One more time\n[00:33.18] \n[00:46.35] Celebrate\n[00:50.00] Oh yeah\n";
const PLAIN = "One more time\n\nCelebrate\nOh yeah";
function record(over = {}) {
return {
id: 1, trackName: "One More Time", artistName: "Daft Punk", albumName: "Discovery", duration: 320,
instrumental: false, plainLyrics: PLAIN, syncedLyrics: LRC, ...over,
};
}
(async () => {
console.log("parseLrc");
{
check("texte non chaîne → []", eq(L.parseLrc(null), []) && eq(L.parseLrc(42), []) && eq(L.parseLrc(""), []));
const lines = L.parseLrc(LRC);
check("métadonnées ignorées, lignes vides gardées", eq(lines, [
{ t: 30750, text: "One more time" }, { t: 33180, text: "" }, { t: 46350, text: "Celebrate" }, { t: 50000, text: "Oh yeah" },
]), lines);
check("[mm:ss] sans fraction", eq(L.parseLrc("[01:02]Bye"), [{ t: 62000, text: "Bye" }]));
check("fractions .x / .xx / .xxx / :xx", eq(L.parseLrc("[00:01.5]a\n[00:02.25]b\n[00:03.125]c\n[00:04:50]d").map(l => l.t), [1500, 2250, 3125, 4500]));
const multi = L.parseLrc("[00:20.00][00:05.00]Refrain\n[00:10.00]Couplet");
check("plusieurs horodatages par ligne + tri", eq(multi, [
{ t: 5000, text: "Refrain" }, { t: 10000, text: "Couplet" }, { t: 20000, text: "Refrain" },
]), multi);
check("[offset:+500] avance les paroles", eq(L.parseLrc("[offset:+500]\n[00:10.00]a").map(l => l.t), [9500]));
check("[offset:-250] retarde les paroles", eq(L.parseLrc("[offset:-250]\n[00:10.00]a").map(l => l.t), [10250]));
check("offset ne rend pas de temps négatif", eq(L.parseLrc("[offset:2000]\n[00:01.00]a").map(l => l.t), [0]));
check("CRLF, BOM et horodatages par mot", eq(L.parseLrc("[00:01.00]Hello <00:01.20>world\r\n[00:02.00] x "), [
{ t: 1000, text: "Hello world" }, { t: 2000, text: "x" },
]));
check("lignes sans horodatage ignorées, secondes invalides ignorées", eq(L.parseLrc("texte libre\n[00:75.00]x\n[00:03.00]ok"), [{ t: 3000, text: "ok" }]));
check("égalité de temps : ordre d'origine conservé", eq(L.parseLrc("[00:01.00]a\n[00:01.00]b").map(l => l.text), ["a", "b"]));
}
console.log("cleanTitle / cleanArtist");
{
check("Official Video + [HD]", L.cleanTitle("One More Time (Official Video) [HD]") === "One More Time");
check("Clip officiel / Lyrics", L.cleanTitle("Alors on danse (Clip Officiel)") === "Alors on danse" && L.cleanTitle("Hello (Lyrics)") === "Hello");
check("feat. retiré", L.cleanTitle("Get Lucky (feat. Pharrell Williams)") === "Get Lucky" && L.cleanTitle("Get Lucky ft. Pharrell") === "Get Lucky");
check("suffixe « - Remastered 2011 » retiré", L.cleanTitle("Bohemian Rhapsody - Remastered 2011") === "Bohemian Rhapsody");
check("suffixe « | … » retiré", L.cleanTitle("Titre | Clip officiel") === "Titre");
check("parenthèse de titre conservée", L.cleanTitle("(Don't Fear) The Reaper") === "(Don't Fear) The Reaper");
check("« (Remix) » conservé", L.cleanTitle("Song (Remix)") === "Song (Remix)");
check("« Video » dans un vrai titre conservé", L.cleanTitle("The Buggles - Video Killed the Radio Star") === "The Buggles - Video Killed the Radio Star");
check("titre entièrement parasite → original", L.cleanTitle("(Official Video)") === "(Official Video)");
check("vide / null", L.cleanTitle("") === "" && L.cleanTitle(null) === "");
check("Artist - Topic", L.cleanArtist("Stromae - Topic") === "Stromae");
check("ArtistVEVO → espaces", L.cleanArtist("DaftPunkVEVO") === "Daft Punk" && L.cleanArtist("Adele VEVO") === "Adele");
check("Official / feat.", L.cleanArtist("Daft Punk Official") === "Daft Punk" && L.cleanArtist("Daft Punk feat. Pharrell") === "Daft Punk");
check("Auteur inconnu → vide", L.cleanArtist("Auteur inconnu") === "");
check("AC/DC intact", L.cleanArtist("AC/DC") === "AC/DC");
check("longueur bornée à 200", L.cleanTitle("a".repeat(500)).length === 200);
}
console.log("guessQuery");
{
const g = (title, author, extra = {}) => L.guessQuery({ title, author, type: "youtube", ...extra });
check("Artiste - Titre (Official Video)", eq(g("Daft Punk - One More Time (Official Video)", "Daft Punk", { duration: 320.4 }), { track: "One More Time", artist: "Daft Punk", duration: 320 }));
check("chaîne VEVO sans tiret", eq(g("Never Gonna Give You Up", "RickAstleyVEVO"), { track: "Never Gonna Give You Up", artist: "Rick Astley", duration: 0 }));
check("chaîne de label → artiste du titre", eq(g("The Buggles - Video Killed the Radio Star", "Island Records"), { track: "Video Killed the Radio Star", artist: "The Buggles", duration: 0 }));
check("Titre - Artiste", eq(g("Shape of You - Ed Sheeran", "Ed Sheeran"), { track: "Shape of You", artist: "Ed Sheeran", duration: 0 }));
check("suffixe de version Spotify", eq(g("Hello - Live at Wembley", "Adele"), { track: "Hello", artist: "Adele", duration: 0 }));
check("feat. dans la partie artiste", eq(g("Daft Punk ft. Pharrell Williams - Get Lucky [HD] | Clip officiel", "Some Label"), { track: "Get Lucky", artist: "Daft Punk", duration: 0 }));
check("Artiste « Titre »", eq(g('Stromae "Alors on danse" (Clip officiel)', "StromaeVEVO"), { track: "Alors on danse", artist: "Stromae", duration: 0 }));
check("guillemets dans un titre Spotify", eq(g('Theme from "Shaft"', "Isaac Hayes"), { track: "Theme from Shaft", artist: "Isaac Hayes", duration: 0 }));
check("fichier envoyé", eq(L.guessQuery({ title: "01 - Muse - Uprising.mp3", author: "Auteur inconnu", type: "attachment" }), { track: "Uprising", artist: "Muse", duration: 0 }));
check("valeurs par défaut du Song → vide", eq(L.guessQuery({ title: "Aucun titre", author: "Auteur inconnu" }), { track: "", artist: "", duration: 0 }));
check("durée invalide → 0", L.guessQuery({ title: "x", duration: "abc" }).duration === 0 && L.guessQuery({ title: "x", duration: -3 }).duration === 0);
check("pas d'objet → vide", eq(L.guessQuery(null), { track: "", artist: "", duration: 0 }));
}
console.log("songKeyOf");
{
check("type:id", L.songKeyOf({ type: "youtube", id: "abc", url: "u" }) === "youtube:abc");
check("repli sur l'URL", L.songKeyOf({ type: "youtube", url: "https://x" }) === "https://x");
check("rien → null", L.songKeyOf({}) === null && L.songKeyOf(null) === null);
}
console.log("pickBestCandidate");
{
const q = { track: "One More Time", artist: "Daft Punk", duration: 320 };
const list = [
record({ id: 10, duration: 234, albumName: "Charts" }),
record({ id: 11, duration: 321, syncedLyrics: null }),
record({ id: 12, duration: 319 }),
record({ id: 13, trackName: "Around the World", duration: 320 }),
record({ id: 14, artistName: "Someone Else", duration: 330 }),
];
check("synchronisé et durée proche", L.pickBestCandidate(list, q)?.id === 12);
check("titre différent rejeté", L.pickBestCandidate([list[3]], q) === null);
check("autre artiste rejeté", L.pickBestCandidate([list[4]], q) === null);
// Chaîne de label : titre identique et durée quasi exacte suffisent
check("autre artiste accepté si titre et durée exacts", L.pickBestCandidate([record({ id: 19, artistName: "Ed Banger Records", duration: 321 })], q)?.id === 19);
check("hors fenêtre seulement si rien de mieux", L.pickBestCandidate([list[0]], q)?.id === 10 && L.pickBestCandidate([list[0], list[1]], q)?.id === 11);
check("sans contenu ignoré", L.pickBestCandidate([record({ id: 15, syncedLyrics: "", plainLyrics: "" })], q) === null);
const upload = record({ id: 16, trackName: "Daft Punk - One More Time (Official Video)" });
check("saisie propre préférée à « Artiste - Titre (Official Video) »", L.pickBestCandidate([upload, record({ id: 17 })], q)?.id === 17);
const free = { track: "daft punk one more time", artist: "", duration: 0 };
check("texte libre : bon candidat", L.pickBestCandidate([list[3], record({ id: 18 })], free)?.id === 18);
check("texte libre : titre absent de la requête rejeté", L.pickBestCandidate([list[3]], free) === null);
}
console.log("getLyricsForSong (fetch simulé)");
{
const song = { type: "youtube", id: "FGBhQbmPwH8", title: "Daft Punk - One More Time (Official Video)", author: "Daft Punk", duration: 320, url: "https://www.youtube.com/watch?v=FGBhQbmPwH8" };
mock((url) => url.pathname === "/api/get" ? json(200, record()) : json(404, {}));
let r = await L.getLyricsForSong(song);
check("LRCLIB /get → synchronisées", r.ok && r.source === "lrclib" && r.synced && r.lines.length === 4 && r.lines[0].t === 30750, r);
check("songKey renvoyé", r.songKey === "youtube:FGBhQbmPwH8");
check("plain et match remplis", r.plain === PLAIN && eq(r.match, { id: 1, track: "One More Time", artist: "Daft Punk", album: "Discovery", duration: 320 }) && r.instrumental === false);
const p = calls[0].url.searchParams;
check("paramètres /get nettoyés", calls[0].url.host === "lrclib.net" && p.get("artist_name") === "Daft Punk" && p.get("track_name") === "One More Time" && p.get("duration") === "320", calls[0].url.href);
check("User-Agent avec la version", /^Subsonics-Chopin\/\S+ \(https:\/\/git\.raphix\.fr\/subsonics\)$/.test(calls[0].options.headers["User-Agent"]) && L.USER_AGENT.includes(require("../../package.json").version));
check("délai d'attente fourni", calls[0].options.signal instanceof AbortSignal);
const before = calls.length;
const again = await L.getLyricsForSong({ ...song, id: "autreVideo" });
check("cache par requête (aucun nouvel appel), songKey propre", calls.length === before && again.songKey === "youtube:autreVideo" && again.synced);
mock((url) => url.pathname === "/api/get" ? json(200, record()) : json(404, {}));
r = await L.getLyricsForSong({ ...song, duration: 0 });
check("durée 0 → pas de paramètre duration", r.ok && !calls[0].url.searchParams.has("duration"));
mock((url) => {
if (url.pathname === "/api/get") return json(404, { message: "Failed to find specified track" });
if (url.pathname === "/api/search" && url.searchParams.get("track_name")) {
return json(200, [record({ id: 30, duration: 210 }), record({ id: 31, duration: 321 }), record({ id: 32, trackName: "Digital Love", duration: 320 })]);
}
return json(200, []);
});
r = await L.getLyricsForSong(song);
check("/get 404 → recherche par champs, bon candidat", r.ok && r.match.id === 31 && r.synced, r.match);
check("recherche libre évitée si un candidat convient", !calls.some(c => c.url.searchParams.has("q")));
mock((url) => {
if (url.pathname === "/api/get") return json(404, {});
if (url.searchParams.has("q")) return json(200, [record({ id: 40, duration: 320 })]);
return json(200, []);
});
r = await L.getLyricsForSong(song);
check("puis recherche libre « artiste titre »", r.ok && r.match.id === 40 && calls.find(c => c.url.searchParams.has("q"))?.url.searchParams.get("q") === "Daft Punk One More Time");
mock((url) => url.pathname === "/api/get" ? json(200, record({ duration: 260 })) : json(200, []));
r = await L.getLyricsForSong(song);
check("durée trop différente → texte seul", r.ok && r.synced === false && r.lines === null && r.plain === PLAIN, r);
mock((url) => url.pathname === "/api/get" ? json(200, record({ duration: 260, plainLyrics: null })) : json(200, []));
r = await L.getLyricsForSong(song);
check("texte seul reconstruit depuis le LRC", r.ok && !r.synced && r.plain === PLAIN, r.plain);
mock((url) => url.pathname === "/api/get" ? json(200, record({ instrumental: true, plainLyrics: null, syncedLyrics: null })) : json(404, {}));
r = await L.getLyricsForSong(song);
check("instrumental", r.ok && r.instrumental && !r.synced && r.lines === null && r.plain === null);
// Entrée d'essai réellement présente sur LRCLIB (« [00:00.00]probe ») : ignorée au profit de la recherche
mock((url) => {
if (url.pathname === "/api/get") return json(200, record({ id: 50, plainLyrics: "probe", syncedLyrics: "[00:00.00]probe" }));
if (url.pathname === "/api/search") return json(200, [record({ id: 50, plainLyrics: "probe", syncedLyrics: "[00:00.00]probe" }), record({ id: 51 })]);
return json(404, {});
});
r = await L.getLyricsForSong(song);
check("entrée vandalisée ignorée", r.ok && r.match.id === 51 && r.synced, r.match);
// 503 passager : une seule nouvelle tentative
let flaky = 0;
mock((url) => {
if (url.pathname === "/api/get") return ++flaky === 1 ? json(503, {}) : json(200, record());
return json(404, {});
});
r = await L.getLyricsForSong(song);
check("503 passager → nouvelle tentative réussie", r.ok && r.synced && flaky === 2);
mock((url) => {
if (url.host === "lrclib.net") return url.pathname === "/api/get" ? json(404, {}) : json(200, []);
if (url.host === "api.deezer.com") return json(200, { data: [
{ id: 1, title: "Harder, Better, Faster, Stronger", artist: { name: "Daft Punk" }, album: { title: "Discovery" }, duration: 224 },
{ id: 2, title: "One More Time (Radio Edit)", title_short: "One More Time", artist: { name: "Daft Punk" }, album: { title: "Discovery" }, duration: 320 },
] });
if (url.host === "api.lyrics.ovh") return json(200, { lyrics: "Paroles de la chanson One More Time par Daft Punk\r\nOne more time\r\n\r\n\r\nCelebrate\r\nOh yeah" });
return json(404, {});
});
r = await L.getLyricsForSong(song);
check("repli Deezer + lyrics.ovh", r.ok && r.source === "lyrics.ovh" && !r.synced && r.lines === null && r.plain === PLAIN, r);
const ovh = calls.find(c => c.url.host === "api.lyrics.ovh");
check("lyrics.ovh interrogé avec le bon candidat Deezer", ovh && decodeURIComponent(ovh.url.pathname) === "/v1/Daft Punk/One More Time", ovh?.url.pathname);
check("Deezer en https", calls.find(c => c.url.host === "api.deezer.com")?.url.protocol === "https:");
mock(() => json(404, { error: "No lyrics found" }));
r = await L.getLyricsForSong(song);
check("rien nulle part → NOT_FOUND + songKey", !r.ok && r.error === "NOT_FOUND" && r.songKey === "youtube:FGBhQbmPwH8", r);
const n = calls.length;
await L.getLyricsForSong(song);
check("échec mis en cache", calls.length === n);
mock((url) => {
if (url.host === "lrclib.net") throw new TypeError("fetch failed");
return json(404, {});
});
r = await L.getLyricsForSong(song);
check("LRCLIB injoignable → repli puis UNAVAILABLE", !r.ok && r.error === "UNAVAILABLE" && calls.filter(c => c.url.host === "lrclib.net").length === 2 && calls.every(c => c.url.pathname !== "/api/search"), calls.map(c => c.url.href));
mock((url) => url.host === "lrclib.net" ? json(500, {}) : json(404, {}));
r = await L.getLyricsForSong(song);
check("HTTP 500 → UNAVAILABLE", !r.ok && r.error === "UNAVAILABLE");
mock(() => json(200, record()));
check("pas de morceau → NO_MUSIC", eq(await L.getLyricsForSong(null), { ok: false, error: "NO_MUSIC" }));
r = await L.getLyricsForSong({ type: "youtube", id: "x", title: "Aucun titre", author: "Auteur inconnu" });
check("titre vide → INVALID_PARAMS", !r.ok && r.error === "INVALID_PARAMS" && r.songKey === "youtube:x" && calls.length === 0);
}
console.log("searchCandidates / getById / findLyrics / getLyrics");
{
mock(() => json(200, record()));
for (const bad of [undefined, null, 42, "", " ", "x".repeat(201), { q: "x" }]) {
const r = await L.searchCandidates(bad);
check(`searchCandidates(${JSON.stringify(bad)?.slice(0, 20)}) → INVALID_PARAMS`, eq(r, { ok: false, error: "INVALID_PARAMS" }));
}
check("aucune requête pour une entrée invalide", calls.length === 0);
mock(() => json(200, [record({ id: 5 }), record({ id: 6, syncedLyrics: null, instrumental: true, albumName: null }), { bad: true }]));
let r = await L.searchCandidates(" daft punk one more time ");
check("candidats mis en forme", r.ok && eq(r.results, [
{ id: 5, track: "One More Time", artist: "Daft Punk", album: "Discovery", duration: 320, synced: true, instrumental: false },
{ id: 6, track: "One More Time", artist: "Daft Punk", album: "", duration: 320, synced: false, instrumental: true },
]), r);
check("requête q transmise", calls[0].url.pathname === "/api/search" && calls[0].url.searchParams.get("q") === "daft punk one more time");
mock(() => { throw new TypeError("fetch failed"); });
r = await L.searchCandidates("abc");
check("réseau en panne → UNAVAILABLE", eq(r, { ok: false, error: "UNAVAILABLE" }));
route = () => json(200, []);
r = await L.searchCandidates("abc");
check("UNAVAILABLE non mis en cache", r.ok && eq(r.results, []));
mock((url) => url.pathname === "/api/get/77" ? json(200, record({ id: 77, duration: 100 })) : json(404, {}));
for (const bad of [0, -1, 1.5, "abc", null, {}, Number.MAX_SAFE_INTEGER + 2]) {
check(`getById(${JSON.stringify(bad)}) → INVALID_PARAMS`, (await L.getById(bad)).error === "INVALID_PARAMS");
}
r = await L.getById(77);
check("getById garde la synchro (choix manuel)", r.ok && r.synced && r.match.id === 77 && r.songKey === null, r);
check("getById accepte une chaîne numérique", (await L.getById("77")).ok);
check("getById inconnu → NOT_FOUND", (await L.getById(78)).error === "NOT_FOUND");
mock((url) => url.pathname === "/api/get" ? json(200, record()) : json(404, {}));
r = await L.findLyrics({ track: "One More Time (Official Video)", artist: "DaftPunkVEVO", duration: 320 });
check("findLyrics objet", r.ok && r.synced && r.songKey === null && calls[0].url.searchParams.get("artist_name") === "Daft Punk");
check("findLyrics invalide", (await L.findLyrics({ track: "" })).error === "INVALID_PARAMS"
&& (await L.findLyrics({ track: "a", artist: 3 })).error === "INVALID_PARAMS"
&& (await L.findLyrics({ track: "a", duration: "x" })).error === "INVALID_PARAMS"
&& (await L.findLyrics(["a"])).error === "INVALID_PARAMS"
&& (await L.findLyrics("x".repeat(201))).error === "INVALID_PARAMS");
mock((url) => url.searchParams.has("q") ? json(200, [record({ id: 90, trackName: "Time" }), record({ id: 91 })]) : json(404, {}));
r = await L.findLyrics("daft punk one more time");
check("findLyrics texte libre", r.ok && r.match.track === "One More Time", r);
mock((url) => url.pathname === "/api/get" ? json(200, record()) : json(404, {}));
const text = await L.getLyrics("Daft Punk - One More Time");
check("getLyrics (compat) → texte brut", text === PLAIN, text);
mock(() => json(404, {}));
check("getLyrics (compat) → null si rien", (await L.getLyrics("zzz introuvable")) === null && (await L.getLyrics(null)) === null);
}
console.log("titres YouTube supplémentaires");
{
const g = (title, author, extra = {}) => L.guessQuery({ title, author, type: "youtube", ...extra });
check("Artiste - Titre (Official Video) [4K]", eq(g("Daft Punk - Around the World (Official Video) [4K]", "Daft Punk"), { track: "Around the World", artist: "Daft Punk", duration: 0 }));
check("Titre (Official Music Video) 4K sans tiret", L.cleanTitle("Blinding Lights (Official Music Video) 4K") === "Blinding Lights");
check("chaîne « - Topic » + feat. dans le titre", eq(g("Instant Crush (feat. Julian Casablancas)", "Daft Punk - Topic", { duration: 337 }), { track: "Instant Crush", artist: "Daft Punk", duration: 337 }));
check("ft. en fin de titre", L.cleanTitle("Stay ft. Justin Bieber") === "Stay");
check("Titre [Lyrics] / (Audio)", L.cleanTitle("Bad Guy [Lyrics]") === "Bad Guy" && L.cleanTitle("Lose Yourself (Audio)") === "Lose Yourself");
check("« (Official Video) » + émoji", L.cleanTitle("🔥 Alors on danse (Official Video) 🔥") === "Alors on danse");
check("suffixe « - Live » conservé comme version : titre seul", eq(g("Hey Jude - Live", "The Beatles"), { track: "Hey Jude", artist: "The Beatles", duration: 0 }));
check("chaîne Official + titre sans tiret", eq(g("Bohemian Rhapsody (Official Video Remastered)", "Queen Official"), { track: "Bohemian Rhapsody", artist: "Queen", duration: 0 }));
}
console.log("similarity / rankCandidates");
{
check("identique → 1", L.similarity("One More Time", "one more time") === 1);
check("accents et apostrophes ignorés", L.similarity("Je t'aime", "Je taime") === 1 && L.similarity("Éléphant", "elephant") === 1);
check("espaces ignorés → 0.95", L.similarity("AC DC", "ACDC") === 0.95);
check("inclusion → ≥ 0.6", L.similarity("One More Time", "One More Time Radio Edit") >= 0.6);
check("rien en commun → 0", L.similarity("Hello", "Goodbye") === 0 && L.similarity("", "x") === 0);
const ranked = L.rankCandidates([
record({ id: 1, trackName: "Something Else", artistName: "Nobody" }),
record({ id: 2, trackName: "Daft Punk - One More Time (Official Video)" }),
record({ id: 3, syncedLyrics: null }),
record({ id: 4 }),
], "daft punk one more time");
check("classement : synchro propre, synchro bruitée, texte seul, hors sujet", eq(ranked.map(r => r.id), [4, 2, 3, 1]), ranked.map(r => r.id));
check("rankCandidates ne modifie pas l'entrée", (() => { const list = [record({ id: 9 })]; L.rankCandidates(list, "x"); return list.length === 1; })());
}
console.log("durées de cache (horloge simulée)");
{
const MIN = 60 * 1000;
const HOUR = 60 * MIN;
check("ttl trouvé 6 h", L.ttlForResult({ ok: true }) === 6 * HOUR);
check("ttl introuvable 20 min", L.ttlForResult({ ok: false, error: "NOT_FOUND" }) === 20 * MIN);
check("ttl indisponible 1 min", L.ttlForResult({ ok: false, error: "UNAVAILABLE" }) === MIN);
check("ttl autre erreur / rien → 0", L.ttlForResult({ ok: false, error: "INVALID_PARAMS" }) === 0 && L.ttlForResult(null) === 0);
const realNow = Date.now;
// Horloge monotone : le limiteur retient l'heure du dernier démarrage, reculer le bloquerait
let offset = 0;
let base = 0;
const at = (ms) => { offset = base + ms; };
const nextScenario = () => { base = offset + HOUR; offset = base; };
Date.now = () => realNow() + offset;
try {
const song = { type: "youtube", id: "ttl", title: "Daft Punk - One More Time", author: "Daft Punk", duration: 320 };
const lrclibCalls = () => calls.filter(c => c.url.host === "lrclib.net").length;
mock((url) => url.pathname === "/api/get" ? json(200, record()) : json(404, {}));
await L.getLyricsForSong(song);
let n = lrclibCalls();
at(6 * HOUR - MIN);
await L.getLyricsForSong(song);
check("trouvé : encore en cache à 5 h 59", lrclibCalls() === n);
at(6 * HOUR + MIN);
await L.getLyricsForSong(song);
check("trouvé : redemandé après 6 h", lrclibCalls() > n);
nextScenario();
mock(() => json(404, {}));
await L.getLyricsForSong(song);
n = calls.length;
at(19 * MIN);
await L.getLyricsForSong(song);
check("introuvable : en cache à 19 min", calls.length === n);
at(21 * MIN);
await L.getLyricsForSong(song);
check("introuvable : redemandé après 20 min", calls.length > n);
nextScenario();
mock((url) => { if (url.host === "lrclib.net") throw new TypeError("fetch failed"); return json(404, {}); });
await L.getLyricsForSong(song);
n = calls.length;
at(30 * 1000);
await L.getLyricsForSong(song);
check("indisponible : en cache 30 s", calls.length === n);
at(61 * 1000);
await L.getLyricsForSong(song);
check("indisponible : redemandé après 1 min", calls.length > n);
nextScenario();
mock(() => json(200, [record({ id: 5 })]));
await L.searchCandidates("daft punk");
n = calls.length;
at(9 * MIN);
await L.searchCandidates("Daft Punk");
check("recherche manuelle : cache 10 min (requête normalisée)", calls.length === n);
at(11 * MIN);
await L.searchCandidates("daft punk");
check("recherche manuelle : redemandée après 10 min", calls.length > n);
} finally {
Date.now = realNow;
}
}
globalThis.fetch = realFetch;
done();
})().catch((error) => {
globalThis.fetch = realFetch;
console.error(error);
process.exitCode = 1;
});
+172 -5
View File
@@ -20,6 +20,7 @@ const raw = {
A: { id: "A", tokens: ["tA1", "tA2"], labels: ["ADMIN"] }, A: { id: "A", tokens: ["tA1", "tA2"], labels: ["ADMIN"] },
B: { id: "B", tokens: ["tB"], labels: [] }, B: { id: "B", tokens: ["tB"], labels: [] },
C: { id: "C", tokens: ["tC"], labels: [] }, C: { id: "C", tokens: ["tC"], labels: [] },
D: { id: "D", tokens: ["tD"], labels: [] },
} }
function makeUser(r) { function makeUser(r) {
if (!r) return null if (!r) return null
@@ -48,7 +49,7 @@ const usersStub = {
// --- Bot / players --- // --- Bot / players ---
const guilds = new Map([ const guilds = new Map([
["g1", { id: "g1", name: "Guilde 1", owner: "C", allMembers: ["A", "B", "C"] }], ["g1", { id: "g1", name: "Guilde 1", owner: "C", allMembers: ["A", "B", "C", "D"] }],
["g2", { id: "g2", name: "Guilde 2", owner: "A", allMembers: ["A", "B"] }], ["g2", { id: "g2", name: "Guilde 2", owner: "A", allMembers: ["A", "B"] }],
]) ])
const AllPlayers = new Map() const AllPlayers = new Map()
@@ -76,9 +77,68 @@ const adminStub = {
const fakeLog = { LogType: class { constructor() { this.step = { init() {}, end() {} } } log() {} warn() {} error(m) { if (process.env.VERBOSE) console.log("[error]", m) } } } const fakeLog = { LogType: class { constructor() { this.step = { init() {}, end() {} } } log() {} warn() {} error(m) { if (process.env.VERBOSE) console.log("[error]", m) } } }
let reportResult = false let reportResult = false
let finderThrows = true let finderThrows = true
// --- Playlists personnelles (mémoire) : l'ordre est appliqué par le vrai PlaylistUtils (chargé après les stubs) ---
const personal = {
C: [{ playlistId: "1", title: "Un", songs: [] }, { playlistId: "2", title: "Deux", songs: [] }, { playlistId: "3", title: "Trois", songs: [] }],
}
const playlistsStub = {
syncYoutube: async () => ({ ok: true }),
getPlaylistsOfUser: (id) => personal[id] || [],
getPlaylistOfUser: (id, pid) => (personal[id] || []).find(p => p.playlistId === String(pid)) || null,
reorderPlaylists: (id, order) => require(SRC + "/playlists/PlaylistUtils").applyOrder(personal[id] || [], order),
hasRoomForPlaylist: (id) => (personal[id] || []).length < 500,
MAX_PLAYLISTS: 500,
addPlaylistCopy: (id, playlist) => { const copy = { ...playlist, playlistId: "copy" + Date.now() }; (personal[id] ||= []).push(copy); return copy },
}
// --- Playlists partagées (mémoire) et réglage par serveur ---
const sharedSettings = new Map()
const getShared = (g) => ({ enabled: false, editors: "members", ...(sharedSettings.get(g) || {}) })
const serverSettingsStub = {
getSecureRole: () => null,
getSharedPlaylists: getShared,
setSharedPlaylists: (g, patch) => {
if (!patch || !Object.keys(patch).length) return null
if (patch.enabled !== undefined && typeof patch.enabled !== "boolean") return null
if (patch.editors !== undefined && !["members", "mods"].includes(patch.editors)) return null
const next = { ...getShared(g), ...patch }
sharedSettings.set(g, next)
return next
},
}
const guildLists = { g1: [{ playlistId: "10", title: "Commune", songs: [], createdBy: { id: "A", username: "userA" } }] }
const guildPlaylistsStub = {
getPlaylists: (g) => guildLists[g] || [],
getPlaylist: (g, id) => (guildLists[g] || []).find(p => p.playlistId === String(id)) || null,
countPlaylists: (g) => (guildLists[g] || []).length,
deletePlaylist: (g, id) => { const list = guildLists[g] || []; const i = list.findIndex(p => p.playlistId === String(id)); if (i === -1) return false; list.splice(i, 1); return true },
createPlaylist: async (g, input, user) => {
const playlist = { playlistId: String(Date.now()), title: input.name, songs: [], scope: "guild", guildId: g, createdBy: { id: user.id, username: user.username } }
;(guildLists[g] ||= []).push(playlist)
return { ok: true, playlist }
},
}
// --- Paroles : searchCandidates seulement (getById absent → UNAVAILABLE) ---
const lyricsCalls = []
const lyricsStub = {
searchCandidates: async (q) => { lyricsCalls.push(q); return { ok: true, results: [{ id: 1, track: q, artist: "x", album: null, duration: 100, synced: true, instrumental: false }] } },
getLyricsForSong: async (song) => { lyricsCalls.push(song); return { ok: false, error: "NOT_FOUND", songKey: null } },
findLyrics: async (query) => { lyricsCalls.push(query); return { ok: false, error: "NOT_FOUND", songKey: null } },
}
const stubs = { const stubs = {
"loguix": fakeLog, "loguix": fakeLog,
[norm(SRC + "/utils/SafeMetric")]: { getGuildMetrics: () => [], toSafeNumber: (v) => v }, [norm(SRC + "/utils/SafeMetric")]: {
getGuildMetrics: (g) => g === "g1" ? [
{ name: "numberOfMusicPlayed_g1", value: 12 },
{ name: "numberOfSecondsPlayed_g1", value: "3600.4" },
{ name: "numberOfCommands_g1", value: NaN },
] : [],
toSafeNumber: (v, fallback = 0) => typeof v === "number" ? (Number.isFinite(v) ? v : fallback) : (typeof v === "string" && /^\s*-?\d+(\.\d+)?\s*$/.test(v) ? Number(v) : fallback),
toSafeDuration: (v) => { const n = Number(v); return Number.isFinite(n) && n > 0 && n <= 86400 ? n : 0 },
},
[norm(SRC + "/server/auth/Session")]: { checkSession: () => false, addSession: () => "sess", removeSession() {} }, [norm(SRC + "/server/auth/Session")]: { checkSession: () => false, addSession: () => "sess", removeSession() {} },
[norm(SRC + "/server/auth/User")]: usersStub, [norm(SRC + "/server/auth/User")]: usersStub,
[norm(SRC + "/player/Player")]: { Player: FakePlayer, AllPlayers, offlineState: (g) => ({ v: 2, guildId: g, status: "offline" }), getPlayer: () => { throw new Error("getPlayer ne doit plus être appelé") }, getAllPlayers: () => [...AllPlayers.values()] }, [norm(SRC + "/player/Player")]: { Player: FakePlayer, AllPlayers, offlineState: (g) => ({ v: 2, guildId: g, status: "offline" }), getPlayer: () => { throw new Error("getPlayer ne doit plus être appelé") }, getAllPlayers: () => [...AllPlayers.values()] },
@@ -86,10 +146,11 @@ const stubs = {
[norm(SRC + "/server/auth/DiscordAuth")]: {}, [norm(SRC + "/server/auth/DiscordAuth")]: {},
[norm(SRC + "/discord/ReportSender")]: { Report: class { async send() { return reportResult } } }, [norm(SRC + "/discord/ReportSender")]: { Report: class { async send() { return reportResult } } },
[norm(SRC + "/player/Finder")]: { search: async () => { if (finderThrows) throw new Error("panne simulée"); return [] } }, [norm(SRC + "/player/Finder")]: { search: async () => { if (finderThrows) throw new Error("panne simulée"); return [] } },
[norm(SRC + "/playlists/PlaylistManager")]: { syncYoutube: async () => ({ ok: true }) }, [norm(SRC + "/playlists/PlaylistManager")]: playlistsStub,
[norm(SRC + "/playlists/GuildPlaylists")]: guildPlaylistsStub,
[norm(SRC + "/playlists/History")]: { getPersonalHistory: () => [], addToPersonalHistory() {}, clearPersonalHistory: async () => {} }, [norm(SRC + "/playlists/History")]: { getPersonalHistory: () => [], addToPersonalHistory() {}, clearPersonalHistory: async () => {} },
[norm(SRC + "/lyrics/Lyrics")]: {}, [norm(SRC + "/lyrics/Lyrics")]: lyricsStub,
[norm(SRC + "/discord/ServerSettings")]: { getSecureRole: () => null }, [norm(SRC + "/discord/ServerSettings")]: serverSettingsStub,
[norm(SRC + "/discord/MediaBase")]: {}, [norm(SRC + "/discord/MediaBase")]: {},
[norm(SRC + "/playlists/Google/OAuth2")]: { getStatus: (id) => ({ configured: false, linked: false, user: id }), isSyncing: () => false }, [norm(SRC + "/playlists/Google/OAuth2")]: { getStatus: (id) => ({ configured: false, linked: false, user: id }), isSyncing: () => false },
[norm(SRC + "/utils/Database/Configuration")]: { getPort: () => PORT }, [norm(SRC + "/utils/Database/Configuration")]: { getPort: () => PORT },
@@ -218,6 +279,112 @@ function serverSocket(client) { return ioRef.sockets.sockets.get(client.id) }
const status = await new Promise(r => c.emit("/GOOGLE/STATUS", null, r)) const status = await new Promise(r => c.emit("/GOOGLE/STATUS", null, r))
assert.strictEqual(status.user, "C") assert.strictEqual(status.user, "C")
// --- Playlists partagées : option, droits, poussées (réponses par acquittement) ---
const ackReq = (s, event, data) => new Promise((resolve, reject) => {
s.emit(event, data, resolve)
setTimeout(() => reject(new Error("pas d'acquittement " + event)), 3000)
})
const d = await connect("tD")
assert.strictEqual(await request(d, "/GUILD/JOIN", "g1"), true)
let list = await ackReq(d, "/GUILD/PLAYLISTS/LIST")
assert.ok(list.ok && list.guildId === "g1" && list.settings.enabled === false, "LIST désactivé : " + JSON.stringify(list))
assert.deepStrictEqual(list.playlists, [], "désactivé : playlists masquées")
assert.strictEqual(list.canEdit, false)
let r = await ackReq(d, "/GUILD/PLAYLISTS/CREATE", { name: "Test" })
assert.strictEqual(r.error, "DISABLED")
r = await ackReq(d, "/OWNER/PLAYLISTS/SETTINGS", { enabled: true })
assert.strictEqual(r.error, "FORBIDDEN", "un membre ne gère pas l'option")
assert.ok(!sharedSettings.has("g1"), "aucun réglage écrit par un refus")
const pushed = new Promise(res => d.once("/GUILD/PLAYLISTS/UPDATE", res))
r = await ackReq(c, "/OWNER/PLAYLISTS/SETTINGS", { enabled: true })
assert.ok(r.ok && r.settings.enabled === true && r.settings.editors === "members", JSON.stringify(r))
assert.deepStrictEqual(await pushed, { guildId: "g1" }, "poussée /GUILD/PLAYLISTS/UPDATE à la room du serveur")
r = await ackReq(c, "/OWNER/PLAYLISTS/SETTINGS", { editors: "tous" })
assert.strictEqual(r.error, "INVALID_PARAMS")
list = await ackReq(d, "/GUILD/PLAYLISTS/LIST")
assert.ok(list.ok && list.canEdit === true && list.canManage === false && list.playlists.length === 1, JSON.stringify(list))
r = await ackReq(d, "/GUILD/PLAYLISTS/CREATE", { name: " " })
assert.strictEqual(r.error, "INVALID_PARAMS", "nom vide refusé")
r = await ackReq(d, "/GUILD/PLAYLISTS/CREATE", { name: "x".repeat(101) })
assert.strictEqual(r.error, "INVALID_PARAMS", "nom de plus de 100 caractères refusé")
r = await ackReq(d, "/GUILD/PLAYLISTS/CREATE", { name: " De D " })
assert.ok(r.ok && r.playlist.title === "De D" && r.playlist.createdBy.id === "D", JSON.stringify(r))
const createdByD = r.playlist.playlistId
// Modérateurs seulement : D (simple membre) ne modifie plus, mais peut supprimer SA playlist
r = await ackReq(c, "/OWNER/PLAYLISTS/SETTINGS", { editors: "mods" })
assert.ok(r.ok && r.settings.editors === "mods" && r.settings.enabled === true)
list = await ackReq(d, "/GUILD/PLAYLISTS/LIST")
assert.strictEqual(list.canEdit, false)
r = await ackReq(d, "/GUILD/PLAYLISTS/CREATE", { name: "Refusée" })
assert.strictEqual(r.error, "FORBIDDEN")
// Toutes les routes d'écriture sont refusées à un simple membre (vérifié avant le module)
for (const [route, payload] of [
["/GUILD/PLAYLISTS/RENAME", { id: "10", newName: "Piratée" }],
["/GUILD/PLAYLISTS/ADD_SONG", { id: "10", song: { type: "youtube", id: "x", title: "x", url: "https://www.youtube.com/watch?v=x" } }],
["/GUILD/PLAYLISTS/REMOVE_SONG", { id: "10", songId: "x" }],
["/GUILD/PLAYLISTS/MOVE_SONG", { id: "10", from: 0, to: 1 }],
["/GUILD/PLAYLISTS/REORDER", { order: ["10"] }],
["/GUILD/PLAYLISTS/REFRESH", { id: "10" }],
["/GUILD/PLAYLISTS/IMPORT", { playlistId: "1" }],
]) {
r = await ackReq(d, route, payload)
assert.strictEqual(r.error, "FORBIDDEN", route + " refusé à un membre en mode modérateurs : " + JSON.stringify(r))
}
assert.strictEqual(guildLists.g1.find(p => p.playlistId === "10").title, "Commune", "aucune modification par un refus")
r = await ackReq(d, "/GUILD/PLAYLISTS/DELETE", { id: "10" })
assert.strictEqual(r.error, "FORBIDDEN", "playlist d'un autre membre")
r = await ackReq(d, "/GUILD/PLAYLISTS/DELETE", { id: createdByD })
assert.ok(r.ok, "le créateur supprime sa playlist : " + JSON.stringify(r))
r = await ackReq(c, "/GUILD/PLAYLISTS/DELETE", { id: "inconnue" })
assert.strictEqual(r.error, "NOT_FOUND")
const ginfo = await request(d, "/GUILD/INFO")
assert.deepStrictEqual(ginfo.sharedPlaylists, { enabled: true, editors: "mods", canEdit: false, canManage: false })
// Copie d'une playlist du serveur dans mes playlists : simple lecture (D n'est pas éditeur)
const personalPush = new Promise(res => d.once("/PLAYLISTS/UPDATE", res))
r = await ackReq(d, "/GUILD/PLAYLISTS/COPY", { id: "10" })
assert.ok(r.ok && r.playlist.title === "Commune", JSON.stringify(r))
await personalPush
// --- Playlists personnelles : réponses par acquittement + poussée à toutes les sessions ---
const cPush = new Promise(res => c.once("/PLAYLISTS/UPDATE", res))
assert.strictEqual(await ackReq(c, "/PLAYLISTS/REORDER", { order: ["3", "inconnu", "1", "3"] }), true)
await cPush
assert.deepStrictEqual(personal.C.map(p => p.playlistId), ["3", "1", "2"], "ordre appliqué, inconnus ignorés, oubliés à la suite")
assert.strictEqual(await ackReq(c, "/PLAYLISTS/REORDER", { order: "3,1" }), false)
const listed = await ackReq(c, "/PLAYLISTS/LIST")
assert.deepStrictEqual(listed.map(p => p.playlistId), ["3", "1", "2"])
assert.ok(Array.isArray(await request(c, "/PLAYLISTS/LIST")), "ancien client (événement du même nom)")
assert.strictEqual(await ackReq(c, "/PLAYLISTS/MOVE_SONG", { id: "1", from: 0, to: "x" }), false)
assert.strictEqual(await ackReq(c, "/PLAYLISTS/CREATE", { name: "" }), false)
// --- Paroles ---
r = await ackReq(c, "/PLAYER/LYRICS")
assert.deepStrictEqual(r, { ok: false, error: "NO_MUSIC" }, "rien en cours de lecture")
r = await ackReq(c, "/LYRICS/SEARCH", { q: " " })
assert.strictEqual(r.error, "INVALID_PARAMS")
r = await ackReq(c, "/LYRICS/SEARCH", { q: "x".repeat(201) })
assert.strictEqual(r.error, "INVALID_PARAMS")
r = await ackReq(c, "/LYRICS/SEARCH", { q: " daft punk one more time " })
assert.ok(r.ok && r.results[0].track === "daft punk one more time", JSON.stringify(r))
r = await ackReq(c, "/LYRICS/GET", { id: -4 })
assert.strictEqual(r.error, "INVALID_PARAMS")
r = await ackReq(c, "/LYRICS/GET", { id: 42 })
assert.strictEqual(r.error, "UNAVAILABLE", "fonction absente du module : UNAVAILABLE")
r = await request(c, "/SEARCH/LYRICS", { track: "Titre", artist: "Artiste", duration: 200 })
assert.strictEqual(r.error, "NOT_FOUND", "ancienne route : résultat du module")
assert.deepStrictEqual(lyricsCalls[lyricsCalls.length - 1], { track: "Titre", artist: "Artiste", duration: 200 })
r = await ackReq(c, "/SEARCH/LYRICS", " artiste titre ")
assert.strictEqual(lyricsCalls[lyricsCalls.length - 1], "artiste titre", "texte libre transmis tel quel (nettoyé)")
r = await ackReq(c, "/SEARCH/LYRICS", { track: "", artist: "x" })
assert.strictEqual(r.error, "INVALID_PARAMS")
// --- Statistiques publiques du serveur ---
r = await ackReq(d, "/GUILD/STATS")
assert.deepStrictEqual(r, { ok: true, musicPlayed: 12, secondsPlayed: 3600, commands: 0 })
await sleep(100) await sleep(100)
assert.deepStrictEqual(uncaught, [], "aucune exception non gérée") assert.deepStrictEqual(uncaught, [], "aucune exception non gérée")
console.log("server-socket.test.js : OK") console.log("server-socket.test.js : OK")