1

Synchronisation du contenu avec lemmy

This commit is contained in:
2025-12-11 00:02:53 +01:00
parent a0e9a87e8d
commit 181223d3d9
10 changed files with 1268 additions and 50 deletions

View File

@@ -0,0 +1,741 @@
#!/usr/bin/env node
const crypto = require("node:crypto");
const fs = require("node:fs");
const path = require("node:path");
const sharp = require("sharp");
const { LemmyHttp } = require("lemmy-js-client");
const { collectBundles } = require("./lib/content");
const { loadToolsConfig } = require("./lib/config");
const { parseFrontmatterDate } = require("./lib/datetime");
const { readFrontmatterFile, writeFrontmatterFile } = require("./lib/frontmatter");
const CONTENT_ROOT = path.join(__dirname, "..", "content");
const FRONTMATTER_COMMENT_FIELD = "comments_url";
const FRONTMATTER_COVER_FIELD = "cover";
const MAX_COMMUNITY_NAME_LENGTH = 20;
const MIN_COMMUNITY_NAME_LENGTH = 3;
const MAX_THUMBNAIL_WIDTH = 320;
const MAX_THUMBNAIL_HEIGHT = 240;
const THUMBNAIL_QUALITY = 82;
const THUMBNAIL_CACHE_DIR = path.join(__dirname, "cache", "lemmy_thumbnails");
const THUMBNAIL_FORMAT = "png";
main().then(
() => {
process.exit(0);
},
(error) => {
console.error(`❌ Synchronisation Lemmy interrompue : ${error.message}`);
process.exit(1);
}
);
/**
* Point d'entrée principal : charge la configuration, collecte les articles et orchestre la synchronisation.
*/
async function main() {
const toolsConfig = await loadToolsConfig(path.join(__dirname, "config", "config.json"));
const lemmyConfig = normalizeLemmyConfig(toolsConfig.lemmy);
const client = await createLemmyClient(lemmyConfig);
const bundles = await collectBundles(CONTENT_ROOT);
const articles = selectArticles(bundles);
if (articles.length === 0) {
console.log("Aucun article à synchroniser.");
return;
}
for (const article of articles) {
await synchroniseArticle(article, lemmyConfig, client);
}
}
/**
* Valide et normalise la configuration Lemmy issue des fichiers et variables d'environnement.
* @param {object} rawConfig Configuration telle que chargée par loadToolsConfig.
* @returns {object} Configuration prête à l'emploi pour Lemmy.
*/
function normalizeLemmyConfig(rawConfig) {
if (!rawConfig || typeof rawConfig !== "object") {
throw new Error("La configuration Lemmy est manquante (tools/config/config.json).");
}
const instanceUrl = normalizeUrl(rawConfig.instanceUrl);
if (!instanceUrl) {
throw new Error("lemmy.instanceUrl doit être renseigné dans tools/config/config.json ou via l'environnement.");
}
const siteUrl = normalizeUrl(rawConfig.siteUrl);
if (!siteUrl) {
throw new Error("lemmy.siteUrl doit être défini pour construire les URLs des articles.");
}
const auth = rawConfig.auth || {};
const hasJwt = typeof auth.jwt === "string" && auth.jwt.trim().length > 0;
const hasCredentials =
typeof auth.username === "string" &&
auth.username.trim().length > 0 &&
typeof auth.password === "string" &&
auth.password.length > 0;
if (!hasJwt && !hasCredentials) {
throw new Error("lemmy.auth.jwt ou lemmy.auth.username + lemmy.auth.password doivent être fournis.");
}
const prefixOverrides = buildOverrides(rawConfig.community?.prefixOverrides || {});
const descriptionTemplate =
typeof rawConfig.community?.descriptionTemplate === "string" &&
rawConfig.community.descriptionTemplate.trim().length > 0
? rawConfig.community.descriptionTemplate.trim()
: "Espace dédié aux échanges autour de {{path}}.";
return {
instanceUrl,
siteUrl,
auth: {
jwt: hasJwt ? auth.jwt.trim() : null,
username: hasCredentials ? auth.username.trim() : null,
password: hasCredentials ? auth.password : null,
},
community: {
visibility: rawConfig.community?.visibility || "Public",
nsfw: rawConfig.community?.nsfw === true,
descriptionTemplate,
prefixOverrides,
},
};
}
/**
* Crée un client Lemmy authentifié via JWT ou couple utilisateur/mot de passe.
* @param {object} lemmyConfig Configuration normalisée.
* @returns {Promise<LemmyHttp>} Client prêt pour les appels API.
*/
async function createLemmyClient(lemmyConfig) {
const client = new LemmyHttp(lemmyConfig.instanceUrl);
if (lemmyConfig.auth.jwt) {
client.setHeaders({ Authorization: `Bearer ${lemmyConfig.auth.jwt}` });
return client;
}
const loginResponse = await client.login({
username_or_email: lemmyConfig.auth.username,
password: lemmyConfig.auth.password,
});
client.setHeaders({ Authorization: `Bearer ${loginResponse.jwt}` });
return client;
}
/**
* Prépare la liste des articles à synchroniser : frontmatter présent, date valide, comments_url absent.
* Le tri est effectué par date croissante, puis par chemin en cas d'égalité.
* @param {Array<object>} bundles Bundles collectés sous content/.
* @returns {Array<object>} Articles prêts pour la synchronisation.
*/
function selectArticles(bundles) {
const articles = [];
for (const bundle of bundles) {
const frontmatter = readFrontmatterFile(bundle.indexPath);
if (!frontmatter) {
console.warn(`⚠️ ${bundle.relativePath} : frontmatter introuvable, article ignoré.`);
continue;
}
const existingComments =
typeof frontmatter.data?.[FRONTMATTER_COMMENT_FIELD] === "string"
? frontmatter.data[FRONTMATTER_COMMENT_FIELD].trim()
: "";
if (existingComments) {
continue;
}
const publication = parseFrontmatterDate(frontmatter.data?.date);
if (!publication) {
console.warn(`⚠️ ${bundle.relativePath} : date absente ou invalide, article ignoré.`);
continue;
}
const title = typeof frontmatter.data?.title === "string" ? frontmatter.data.title.trim() : "";
if (!title) {
console.warn(`⚠️ ${bundle.relativePath} : titre manquant, article ignoré.`);
continue;
}
articles.push({
bundle,
frontmatter,
publication,
title,
});
}
articles.sort((a, b) => {
const diff = a.publication.toMillis() - b.publication.toMillis();
if (diff !== 0) {
return diff;
}
return a.bundle.relativePath.localeCompare(b.bundle.relativePath);
});
return articles;
}
/**
* Synchronise un article unique : communauté, miniature, post Lemmy et mise à jour du frontmatter.
* @param {object} article Article préparé par selectArticles.
* @param {object} lemmyConfig Configuration Lemmy.
* @param {LemmyHttp} client Client Lemmy.
*/
async function synchroniseArticle(article, lemmyConfig, client) {
const communityDescriptor = buildCommunityDescriptor(article.bundle.parts, lemmyConfig.community);
const community = await ensureCommunity(client, communityDescriptor, lemmyConfig.community);
const coverPath = resolveCoverPath(article.bundle, article.frontmatter.data);
let thumbnailUrl = null;
let thumbnailCachePath = null;
if (coverPath) {
const coverRelative = path.relative(process.cwd(), coverPath);
console.log(`Préparation de ${article.bundle.relativePath} (couverture : ${coverRelative})`);
const thumbnail = await buildThumbnailAsset(coverPath, article.bundle);
thumbnailCachePath = thumbnail.cachePath;
console.log(`Miniature générée : ${thumbnail.cachePath}`);
thumbnailUrl = await uploadThumbnail(client, thumbnail.buffer, {
cachePath: thumbnail.cachePath,
bundlePath: article.bundle.relativePath,
});
} else {
console.warn(
`⚠️ ${article.bundle.relativePath} : aucune couverture exploitable, création du post sans miniature.`
);
}
const articleUrl = buildArticleUrl(lemmyConfig.siteUrl, article.bundle.parts);
const post = await ensurePost(client, community, {
title: article.title,
articleUrl,
thumbnailUrl,
});
const commentsUrl = buildCommentsUrl(lemmyConfig.instanceUrl, post.id);
article.frontmatter.data[FRONTMATTER_COMMENT_FIELD] = commentsUrl;
writeFrontmatterFile(article.bundle.indexPath, article.frontmatter.data, article.frontmatter.body);
cleanupThumbnail(thumbnailCachePath);
console.log(`${article.bundle.relativePath}${community.name}`);
}
/**
* Construit l'URL publique d'un article à partir de son chemin Hugo.
* @param {string} siteUrl Domaine du site.
* @param {string[]} parts Segments du chemin du bundle.
* @returns {string} URL finale.
*/
function buildArticleUrl(siteUrl, parts) {
const relative = parts.join("/");
return `${siteUrl}/${relative}`;
}
/**
* Transforme les segments de chemin en nom et titre de communauté Lemmy.
* @param {string[]} parts Segments du chemin du bundle.
* @param {object} communityConfig Configuration de la section community.
* @returns {{ name: string, title: string, description: string }}
*/
function buildCommunityDescriptor(parts, communityConfig) {
const intermediate = stripDateSegments(parts.slice(0, -1));
if (intermediate.length === 0) {
throw new Error(`Impossible de déduire une communauté depuis ${parts.join("/")}.`);
}
const normalized = intermediate.map((segment) => applyOverride(segment, communityConfig.prefixOverrides));
const sanitized = normalized.map((segment) => sanitizeSegment(segment)).filter(Boolean);
if (sanitized.length === 0) {
throw new Error(`Les segments ${intermediate.join("/")} sont invalides pour une communauté.`);
}
const name = enforceCommunityLength(sanitized);
if (name.length < MIN_COMMUNITY_NAME_LENGTH) {
throw new Error(`Nom de communauté trop court pour ${parts.join("/")}.`);
}
const title = normalized.map((segment) => capitalizeLabel(segment)).join(" / ");
const labelPath = normalized.join("/");
const description = buildCommunityDescription(communityConfig.descriptionTemplate, labelPath);
return { name, title, description };
}
/**
* Supprime les segments correspondant à un pattern année/mois/jour consécutif.
* @param {string[]} segments Segments intermédiaires du chemin.
* @returns {string[]} Segments épurés des dates.
*/
function stripDateSegments(segments) {
const filtered = [];
let index = 0;
while (index < segments.length) {
if (
isYearSegment(segments[index]) &&
isMonthSegment(segments[index + 1]) &&
isDaySegment(segments[index + 2])
) {
index += 3;
continue;
}
filtered.push(segments[index]);
index += 1;
}
return filtered;
}
/**
* Indique si un segment représente une année sur 4 chiffres.
* @param {string|undefined} value Segment à tester.
* @returns {boolean} true si le segment est une année.
*/
function isYearSegment(value) {
if (typeof value !== "string") {
return false;
}
return /^\d{4}$/.test(value);
}
/**
* Indique si un segment représente un mois numérique valide.
* @param {string|undefined} value Segment à tester.
* @returns {boolean} true si le segment est un mois.
*/
function isMonthSegment(value) {
if (typeof value !== "string") {
return false;
}
if (!/^\d{1,2}$/.test(value)) {
return false;
}
const numeric = Number.parseInt(value, 10);
return numeric >= 1 && numeric <= 12;
}
/**
* Indique si un segment représente un jour numérique valide.
* @param {string|undefined} value Segment à tester.
* @returns {boolean} true si le segment est un jour.
*/
function isDaySegment(value) {
if (typeof value !== "string") {
return false;
}
if (!/^\d{1,2}$/.test(value)) {
return false;
}
const numeric = Number.parseInt(value, 10);
return numeric >= 1 && numeric <= 31;
}
/**
* Applique une table de remplacements sur un segment en respectant la casse initiale.
* @param {string} segment Segment brut.
* @param {Record<string, string>} overrides Table de substitutions.
* @returns {string} Segment éventuellement remplacé.
*/
function applyOverride(segment, overrides) {
const lookup = segment.toLowerCase();
if (overrides[lookup]) {
return overrides[lookup];
}
return segment;
}
/**
* Nettoie un segment pour l'utiliser dans un nom de communauté Lemmy.
* @param {string} segment Valeur brute.
* @returns {string} Segment assaini.
*/
function sanitizeSegment(segment) {
return segment
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "_")
.replace(/_{2,}/g, "_")
.replace(/^_|_$/g, "");
}
/**
* Garantit que le nom de communauté respecte la longueur imposée par Lemmy.
* @param {string[]} segments Segments déjà assainis.
* @returns {string} Nom final.
*/
function enforceCommunityLength(segments) {
const reduced = segments.map((segment) => segment.slice(0, MAX_COMMUNITY_NAME_LENGTH));
let current = reduced.join("_");
if (current.length <= MAX_COMMUNITY_NAME_LENGTH) {
return current;
}
const working = [...reduced];
let cursor = working.length - 1;
while (current.length > MAX_COMMUNITY_NAME_LENGTH && cursor >= 0) {
if (working[cursor].length > 1) {
working[cursor] = working[cursor].slice(0, -1);
current = working.join("_");
continue;
}
cursor -= 1;
}
if (current.length <= MAX_COMMUNITY_NAME_LENGTH) {
return current;
}
const compactSource = segments.join("_");
const compact = compactSource.replace(/_/g, "").slice(0, Math.max(1, MAX_COMMUNITY_NAME_LENGTH - 5));
const hash = crypto.createHash("sha1").update(compactSource).digest("hex");
const suffixLength = Math.max(2, MAX_COMMUNITY_NAME_LENGTH - compact.length - 1);
const suffix = hash.slice(0, suffixLength);
return `${compact}_${suffix}`;
}
/**
* Construit un titre lisible pour la communauté.
* @param {string} value Segment brut.
* @returns {string} Version capitalisée.
*/
function capitalizeLabel(value) {
const spaced = value.replace(/[-_]+/g, " ").replace(/\s+/g, " ").trim();
if (!spaced) {
return value;
}
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
}
/**
* Crée une description de communauté à partir du template configuré.
* @param {string} template Modèle issu de la configuration.
* @param {string} labelPath Chemin textuel des segments.
* @returns {string} Description finale.
*/
function buildCommunityDescription(template, labelPath) {
if (template.includes("{{path}}")) {
return template.replace("{{path}}", labelPath);
}
return `${template} (${labelPath})`;
}
/**
* Génère une miniature et la sauvegarde sur disque pour inspection si besoin.
* @param {string} coverPath Chemin absolu de l'image source.
* @param {object} bundle Bundle concerné.
* @returns {Promise<{ buffer: Buffer, cachePath: string }>} Miniature prête à l'emploi.
*/
async function buildThumbnailAsset(coverPath, bundle) {
const buffer = await createThumbnail(coverPath, THUMBNAIL_FORMAT);
const cachePath = writeThumbnailToCache(buffer, bundle, coverPath, THUMBNAIL_FORMAT);
return { buffer, cachePath };
}
/**
* Recherche une communauté par nom, et la crée si nécessaire.
* @param {LemmyHttp} client Client Lemmy.
* @param {object} descriptor Nom, titre et description attendus.
* @param {object} communityConfig Paramètres nsfw/visibilité.
* @returns {Promise<{ id: number, name: string }>} Communauté finalisée.
*/
async function ensureCommunity(client, descriptor, communityConfig) {
const existing = await searchCommunity(client, descriptor.name);
if (existing) {
return { id: existing.community.id, name: existing.community.name };
}
const response = await client.createCommunity({
name: descriptor.name,
title: descriptor.title,
description: descriptor.description,
nsfw: communityConfig.nsfw,
visibility: communityConfig.visibility,
});
return {
id: response.community_view.community.id,
name: response.community_view.community.name,
};
}
/**
* Recherche une communauté précise via l'API de recherche.
* @param {LemmyHttp} client Client Lemmy.
* @param {string} name Nom recherché.
* @returns {Promise<object|null>} Vue de communauté ou null.
*/
async function searchCommunity(client, name) {
const response = await client.search({ q: name, type_: "Communities", limit: 50 });
if (!response.communities || response.communities.length === 0) {
return null;
}
return response.communities.find((communityView) => communityView.community.name === name) || null;
}
/**
* Construit la miniature à partir de l'image de couverture.
* @param {string} absolutePath Chemin absolu vers l'image source.
* @param {string} format Format de sortie (jpeg|png).
* @returns {Promise<Buffer>} Données JPEG redimensionnées.
*/
async function createThumbnail(absolutePath, format = "jpeg") {
const base = sharp(absolutePath).resize({
width: MAX_THUMBNAIL_WIDTH,
height: MAX_THUMBNAIL_HEIGHT,
fit: "inside",
withoutEnlargement: true,
});
if (format === "png") {
return base
.png({
compressionLevel: 9,
palette: true,
effort: 5,
})
.toBuffer();
}
return base.jpeg({ quality: THUMBNAIL_QUALITY, mozjpeg: true }).toBuffer();
}
/**
* Téléverse une miniature vers Lemmy et retourne l'URL publique.
* @param {LemmyHttp} client Client Lemmy.
* @param {Buffer} thumbnailBuffer Données de l'image.
* @param {object} info Informations de contexte pour les erreurs.
* @returns {Promise<string>} URL de la miniature hébergée par Lemmy.
*/
async function uploadThumbnail(client, thumbnailBuffer, info) {
const label = info?.bundlePath ? ` pour ${info.bundlePath}` : "";
const location = info?.cachePath ? ` (miniature : ${info.cachePath})` : "";
const upload = await client.uploadImage({ image: thumbnailBuffer }).catch((error) => {
const reason = typeof error?.message === "string" && error.message.trim() ? error.message : "erreur inconnue";
throw new Error(`Téléversement Lemmy échoué${label}${location} : ${reason}`);
});
if (!upload) {
throw new Error(`Miniature rejetée${label}${location} : réponse vide`);
}
if (upload.error) {
throw new Error(`Miniature rejetée${label}${location} : ${upload.error}`);
}
if (upload.msg !== "ok" || !upload.url) {
const details = JSON.stringify(upload);
throw new Error(`Miniature rejetée${label}${location} : réponse inattendue ${details}`);
}
return upload.url;
}
/**
* Crée ou met à jour un post Lemmy correspondant à l'article.
* @param {LemmyHttp} client Client Lemmy.
* @param {{ id: number, name: string }} community Communauté cible.
* @param {{ title: string, articleUrl: string, thumbnailUrl: string|null }} payload Données du post.
* @returns {Promise<{ id: number }>} Post final.
*/
async function ensurePost(client, community, payload) {
const existing = await searchPostByUrl(client, community, payload.articleUrl);
if (existing) {
if (shouldUpdatePost(existing, payload.title, payload.thumbnailUrl, payload.articleUrl)) {
const edited = await client.editPost({
post_id: existing.post.id,
name: payload.title,
url: payload.articleUrl,
custom_thumbnail: payload.thumbnailUrl || undefined,
});
return { id: edited.post_view.post.id };
}
return { id: existing.post.id };
}
const response = await client.createPost({
name: payload.title,
community_id: community.id,
url: payload.articleUrl,
custom_thumbnail: payload.thumbnailUrl || undefined,
});
return { id: response.post_view.post.id };
}
/**
* Vérifie la nécessité d'une mise à jour du post existant.
* @param {object} postView Post obtenu par recherche.
* @param {string} expectedTitle Titre attendu.
* @param {string|null} thumbnailUrl URL attendue pour la miniature.
* @param {string} articleUrl URL de l'article.
* @returns {boolean} true si une édition est requise.
*/
function shouldUpdatePost(postView, expectedTitle, thumbnailUrl, articleUrl) {
const currentTitle = typeof postView.post.name === "string" ? postView.post.name : "";
const currentUrl = typeof postView.post.url === "string" ? postView.post.url : "";
const currentThumbnail =
typeof postView.post.thumbnail_url === "string" ? postView.post.thumbnail_url : null;
if (currentTitle !== expectedTitle) {
return true;
}
if (currentUrl !== articleUrl) {
return true;
}
if (thumbnailUrl && currentThumbnail !== thumbnailUrl) {
return true;
}
return false;
}
/**
* Cherche un post existant pointant vers l'URL de l'article dans la communauté donnée.
* @param {LemmyHttp} client Client Lemmy.
* @param {{ id: number, name: string }} community Communauté cible.
* @param {string} articleUrl URL Hugo recherchée.
* @returns {Promise<object|null>} PostView correspondant ou null.
*/
async function searchPostByUrl(client, community, articleUrl) {
const response = await client.search({
q: articleUrl,
type_: "Url",
community_name: community.name,
limit: 50,
});
if (!response.posts || response.posts.length === 0) {
return null;
}
return response.posts.find(
(postView) =>
typeof postView.post.url === "string" &&
postView.post.url.trim() === articleUrl &&
postView.post.community_id === community.id
) || null;
}
/**
* Construit l'URL publique des commentaires Lemmy.
* @param {string} instanceUrl Domaine de l'instance Lemmy.
* @param {number} postId Identifiant du post.
* @returns {string} URL des commentaires.
*/
function buildCommentsUrl(instanceUrl, postId) {
return `${instanceUrl}/post/${postId}`;
}
/**
* Détermine le chemin absolu vers l'image de couverture déclarée.
* @param {object} bundle Bundle en cours de traitement.
* @param {object} frontmatterData Données du frontmatter.
* @returns {string|null} Chemin absolu ou null si inexistant.
*/
function resolveCoverPath(bundle, frontmatterData) {
const cover =
typeof frontmatterData?.[FRONTMATTER_COVER_FIELD] === "string"
? frontmatterData[FRONTMATTER_COVER_FIELD].trim()
: "";
if (!cover) {
return null;
}
const normalized = cover.replace(/^\.?\//, "").replace(/\/{2,}/g, "/");
if (!normalized) {
return null;
}
const absolute = path.join(bundle.dir, normalized);
if (!fs.existsSync(absolute)) {
console.warn(`⚠️ ${bundle.relativePath} : couverture ${normalized} introuvable.`);
return null;
}
const stats = fs.statSync(absolute);
if (!stats.isFile()) {
console.warn(`⚠️ ${bundle.relativePath} : ${normalized} n'est pas un fichier image.`);
return null;
}
return absolute;
}
/**
* Nettoie les URLs en supprimant les slashs terminaux et espaces superflus.
* @param {string|null} url URL brute.
* @returns {string|null} URL normalisée ou null.
*/
function normalizeUrl(url) {
if (typeof url !== "string") {
return null;
}
const trimmed = url.trim();
if (!trimmed) {
return null;
}
return trimmed.replace(/\/+$/, "");
}
/**
* Transforme l'objet de remplacements brut en table normalisée.
* @param {Record<string, string>} overrides Remplacements issus de la configuration.
* @returns {Record<string, string>} Table prête à l'emploi.
*/
function buildOverrides(overrides) {
const table = {};
for (const [key, value] of Object.entries(overrides)) {
if (typeof key !== "string" || typeof value !== "string") {
continue;
}
const normalizedKey = key.trim().toLowerCase();
const normalizedValue = value.trim();
if (normalizedKey && normalizedValue) {
table[normalizedKey] = normalizedValue;
}
}
return table;
}
/**
* Écrit la miniature générée sur disque dans tools/cache pour inspection.
* @param {Buffer} buffer Miniature prête.
* @param {object} bundle Bundle concerné.
* @param {string} coverPath Chemin de la couverture d'origine.
* @param {string} format Format de la miniature (jpeg|png).
* @returns {string} Chemin du fichier écrit.
*/
function writeThumbnailToCache(buffer, bundle, coverPath, format) {
const targetPath = computeThumbnailCachePath(bundle, coverPath, format);
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
fs.writeFileSync(targetPath, buffer);
return targetPath;
}
/**
* Supprime la miniature mise en cache une fois le post Lemmy créé.
* @param {string|null} cachePath Chemin éventuel de la miniature.
*/
function cleanupThumbnail(cachePath) {
if (!cachePath) {
return;
}
if (fs.existsSync(cachePath)) {
fs.unlinkSync(cachePath);
}
}
/**
* Construit un nom de fichier stable et assaini pour la miniature en cache.
* @param {object} bundle Bundle concerné.
* @param {string} coverPath Chemin de la couverture.
* @param {string} format Format de la miniature.
* @returns {string} Chemin cible dans tools/cache/lemmy_thumbnails.
*/
function computeThumbnailCachePath(bundle, coverPath, format = "jpeg") {
const base = `${bundle.relativePath}__${path.basename(coverPath)}`;
const safeBase = base
.replace(/[^a-zA-Z0-9._-]+/g, "_")
.replace(/_{2,}/g, "_")
.replace(/^_|_$/g, "");
const hash = crypto.createHash("sha1").update(base).digest("hex").slice(0, 10);
const extension = format === "png" ? "png" : "jpg";
const name = `${safeBase || "thumbnail"}_${hash}.${extension}`;
return path.join(THUMBNAIL_CACHE_DIR, name);
}