import { createHash } from 'node:crypto'; import { mkdirSync, writeFileSync, statSync } from 'node:fs'; import { execFileSync } from 'node:child_process'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); const OUT_DIR = join(ROOT, 'static', 'images', 'remote'); const MAP_FILE = join(ROOT, 'src', 'lib', 'server', 'image-map.json'); const PEERTUBE = 'https://gade.o-k-i.net'; const MASTODON = 'https://bokante.o-k-i.net'; const MASTODON_ACCT = 'cedric'; const PRIORITY_CATEGORIES = [11, 15, 4, 9, 10]; mkdirSync(OUT_DIR, { recursive: true }); const urls = new Map(); function localPathFor(remoteUrl, width) { const hash = createHash('sha1').update(remoteUrl).digest('hex').slice(0, 16); return { local: `/images/remote/${hash}.webp`, width }; } async function collectVideos(params, width) { const query = new URLSearchParams({ isLocal: 'true', ...params }); try { const res = await fetch(`${PEERTUBE}/api/v1/videos?${query}`); if (!res.ok) return; const data = await res.json(); for (const video of data?.data ?? []) { if (video.previewPath) { const url = `${PEERTUBE}${video.previewPath}`; if (!urls.has(url)) urls.set(url, localPathFor(url, width)); } const avatar = video.channel?.avatars?.[0]?.path; if (avatar) { const url = `${PEERTUBE}${avatar}`; if (!urls.has(url)) urls.set(url, localPathFor(url, 96)); } } } catch (err) { console.warn('[images] collectVideos échoué:', err.message); } } // Pool récent (couvre récentes, shorts et l'essentiel des catégories) + tendances + chaque catégorie prioritaire await collectVideos({ sort: '-publishedAt', count: '100' }, 640); await collectVideos({ sort: '-trending', count: '6' }, 640); for (const id of PRIORITY_CATEGORIES) { await collectVideos({ categoryOneOf: String(id), sort: '-publishedAt', count: '6' }, 640); } // Médias de la timeline Mastodon try { const account = await ( await fetch(`${MASTODON}/api/v1/accounts/lookup?acct=${encodeURIComponent(MASTODON_ACCT)}`) ).json(); if (account?.id) { const statuses = await ( await fetch(`${MASTODON}/api/v1/accounts/${account.id}/statuses?limit=10&exclude_replies=true`) ).json(); for (const status of statuses ?? []) { const source = status.reblog ?? status; for (const media of source.media_attachments ?? []) { const url = media.preview_url ?? media.url; if (url && !urls.has(url)) urls.set(url, localPathFor(url, 800)); } } } } catch (err) { console.warn('[images] médias Mastodon échoués:', err.message); } console.log(`[images] ${urls.size} image(s) distante(s) à optimiser`); const map = {}; let total = 0; let done = 0; for (const [remote, { local, width }] of urls) { const out = join(OUT_DIR, local.split('/').pop()); try { const res = await fetch(remote); if (!res.ok) { console.warn(`[images] HTTP ${res.status} pour ${remote}`); continue; } const source = Buffer.from(await res.arrayBuffer()); const tmp = `/tmp/opencode/img-${done}`; writeFileSync(tmp, source); execFileSync('convert', [ tmp, '-auto-orient', '-resize', `${width}x${width}>`, '-quality', '80', '-define', 'webp:method=6', out ]); map[remote] = local; total += statSync(out).size; done++; } catch (err) { console.warn(`[images] échec pour ${remote}:`, err.message); } } writeFileSync(MAP_FILE, JSON.stringify(map, null, '\t') + '\n'); console.log(`[images] ${done} optimisée(s), ${Math.round(total / 1024)} Ko au total`);