const pull = require("../server/node_modules/pull-stream"); const { buildValidatedTombstoneSet } = require('./tombstone_validator'); const { dedupeBy, norm, versionGroups } = require('../backend/dedupe'); const { readTyped, memoIndex } = require('./typed_log'); const { getConfig } = require("../configs/config-manager.js"); const categories = require("../backend/opinion_categories"); const logLimit = getConfig().ssbLogStream?.limit || 1000; const safeArr = (v) => (Array.isArray(v) ? v : []); const normalizeTags = (raw) => { if (raw === undefined || raw === null) return undefined; if (Array.isArray(raw)) return raw.map((t) => String(t || "").trim()).filter(Boolean); return String(raw).split(",").map((t) => t.trim()).filter(Boolean); }; const parseBlobId = (blobMarkdown) => { const s = String(blobMarkdown || ""); const match = s.match(/\(([^)]+)\)/); return match ? match[1] : s || null; }; const voteSum = (opinions = {}) => Object.values(opinions || {}).reduce((s, n) => s + (Number(n) || 0), 0); module.exports = ({ cooler }) => { let ssb; const openSsb = async () => { if (!ssb) ssb = await cooler.open(); return ssb; }; const AUDIO_TYPES = ["audio", "audioOpinion", "tombstone"]; const getAllMessages = async (ssbClient) => readTyped(ssbClient, AUDIO_TYPES, { limit: logLimit }); const getMsg = async (ssbClient, key) => new Promise((resolve, reject) => { ssbClient.get(key, (err, msg) => (err ? reject(err) : resolve(msg))); }); const buildIndex = (messages) => { const tomb = buildValidatedTombstoneSet(messages); const nodes = new Map(); const opinionMsgs = []; for (const m of messages) { const k = m.key; const v = m.value || {}; const c = v.content; if (!c) continue; if (c.type === "tombstone") continue; if (c.type === "audioOpinion") { opinionMsgs.push({ target: c.target, author: v.author, category: c.category }); continue; } if (c.type !== "audio") continue; const ts = v.timestamp || m.timestamp || 0; let sizeBytes = 0; try { sizeBytes = Buffer.byteLength(JSON.stringify(v), 'utf8'); } catch (_) { sizeBytes = 0; } nodes.set(k, { key: k, ts, seq: v.sequence || 0, c, sizeBytes, author: v.author }); } const vg = versionGroups([...nodes.values()].map(n => ({ key: n.key, author: n.author, seq: n.seq, ts: n.ts, replaces: n.c.replaces || null, claimedAuthor: typeof n.c.author === "string" ? n.c.author : null }))); const rootOf = vg.rootOf; const contentTipOf = vg.tipOf; const opinionsByRoot = new Map(); for (const op of opinionMsgs) { if (!nodes.has(op.target)) continue; const r = rootOf(op.target); if (!opinionsByRoot.has(r)) opinionsByRoot.set(r, []); opinionsByRoot.get(r).push(op); } const resolveGroup = (root) => { const contentTip = contentTipOf(root); const contentNode = nodes.get(contentTip) || nodes.get(root); const lc = contentNode ? contentNode.c : {}; const opinions = { ...(lc.opinions || {}) }; const voters = safeArr(lc.opinions_inhabitants).slice(); const voterSet = new Set(voters); for (const op of (opinionsByRoot.get(root) || [])) { if (voterSet.has(op.author)) continue; voterSet.add(op.author); voters.push(op.author); opinions[op.category] = (opinions[op.category] || 0) + 1; } return { contentTip, contentNode, opinions, voters }; }; const tipByRoot = new Map(); for (const r of vg.roots) tipByRoot.set(r, contentTipOf(r)); return { tomb, nodes, rootOf, tipByRoot, resolveGroup }; }; const buildAudio = (node, rootId, viewerId, agg) => { const c = node.c || {}; const voters = agg ? agg.voters : safeArr(c.opinions_inhabitants); const opinions = agg ? agg.opinions : (c.opinions || {}); const composition = Array.isArray(c.bcsComposition) ? c.bcsComposition : null; const tagsArr = safeArr(c.tags); const isBcs = (composition && composition.length > 0) || tagsArr.some(t => String(t).toLowerCase() === 'bcs'); return { key: node.key, rootId, url: c.url, createdAt: c.createdAt || new Date(node.ts).toISOString(), updatedAt: c.updatedAt || null, tags: tagsArr, author: node.author, title: c.title || "", description: c.description || "", license: c.license || "", mapUrl: c.mapUrl || "", opinions, opinions_inhabitants: voters, hasVoted: viewerId ? voters.includes(viewerId) : false, bcsComposition: composition, isBcs, sizeBytes: node.sizeBytes || 0 }; }; return { type: "audio", async resolveCurrentId(id) { const ssbClient = await openSsb(); const messages = await getAllMessages(ssbClient); const idx = buildIndex(messages); const tip = idx.tipByRoot.get(idx.rootOf(id)) || id; if (idx.tomb.has(tip)) throw new Error("Audio not found"); return tip; }, async resolveRootId(id) { const ssbClient = await openSsb(); const messages = await getAllMessages(ssbClient); const idx = buildIndex(messages); const root = idx.rootOf(id); if (idx.tomb.has(idx.tipByRoot.get(root) || id)) throw new Error("Audio not found"); return root; }, async createAudio(blobMarkdown, tagsRaw, title, description, mapUrl, license) { const ssbClient = await openSsb(); const blobId = parseBlobId(blobMarkdown); const tags = normalizeTags(tagsRaw) || []; const now = new Date().toISOString(); const content = { type: "audio", url: blobId, createdAt: now, updatedAt: null, author: ssbClient.id, tags, title: title || "", description: description || "", mapUrl: mapUrl || "", opinions: {}, opinions_inhabitants: [], ...(license ? { license } : {}) }; return new Promise((resolve, reject) => { ssbClient.publish(content, (err, res) => (err ? reject(err) : resolve(res))); }); }, async createBcsAudio(blobId, title, description, composition) { const ssbClient = await openSsb(); const now = new Date().toISOString(); const cleanComposition = safeArr(composition) .map(n => ({ t: String(n.type || n.t || ''), n: String(n.name || n.n || ''), d: Number(n.durMs || n.d || 0), id: typeof n.id === 'string' ? n.id : (typeof n.key === 'string' ? n.key : null) })) .filter(n => n.t && n.n); const baseContent = { type: "audio", url: blobId, createdAt: now, updatedAt: null, author: ssbClient.id, tags: ["bcs"], title: title || "", description: description || "", mapUrl: "", bcsComposition: [], opinions: {}, opinions_inhabitants: [] }; const SSB_MAX_BYTES = 8000; const wrapEnvelope = (content) => ({ previous: "%0000000000000000000000000000000000000000000=.sha256", sequence: 999999, author: ssbClient.id, timestamp: Date.now(), hash: "sha256", content, signature: "00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000==.sig.ed25519" }); const fitted = []; for (const item of cleanComposition) { fitted.push(item); const probe = wrapEnvelope({ ...baseContent, bcsComposition: fitted }); if (Buffer.byteLength(JSON.stringify(probe, null, 2), 'utf8') > SSB_MAX_BYTES) { fitted.pop(); break; } } const content = { ...baseContent, bcsComposition: fitted }; return new Promise((resolve, reject) => { ssbClient.publish(content, (err, res) => (err ? reject(err) : resolve(res))); }); }, async updateAudioById(id, blobMarkdown, tagsRaw, title, description, mapUrl, license) { const ssbClient = await openSsb(); const userId = ssbClient.id; const tipId = await this.resolveCurrentId(id); const oldMsg = await getMsg(ssbClient, tipId); if (!oldMsg || oldMsg.content?.type !== "audio") throw new Error("Audio not found"); if (oldMsg.content.author !== userId) throw new Error("Not the author"); const aggAudio = await this.getAudioById(id, userId); if (aggAudio && Object.keys(aggAudio.opinions || {}).some(k => (aggAudio.opinions[k] || 0) > 0)) throw new Error("Cannot edit audio after it has received opinions."); const tags = tagsRaw !== undefined ? normalizeTags(tagsRaw) || [] : safeArr(oldMsg.content.tags); const blobId = blobMarkdown ? parseBlobId(blobMarkdown) : null; const now = new Date().toISOString(); const updated = { ...oldMsg.content, replaces: tipId, url: blobId || oldMsg.content.url, tags, title: title !== undefined ? title || "" : oldMsg.content.title || "", description: description !== undefined ? description || "" : oldMsg.content.description || "", mapUrl: mapUrl !== undefined ? mapUrl || "" : oldMsg.content.mapUrl || "", createdAt: oldMsg.content.createdAt, updatedAt: now, ...(license !== undefined ? { license } : {}) }; const tombstone = { type: "tombstone", target: tipId, deletedAt: now, author: userId }; await new Promise((res, rej) => ssbClient.publish(tombstone, (e) => (e ? rej(e) : res()))); return new Promise((resolve, reject) => { ssbClient.publish(updated, (err, result) => (err ? reject(err) : resolve(result))); }); }, async deleteAudioById(id) { const ssbClient = await openSsb(); const userId = ssbClient.id; const tipId = await this.resolveCurrentId(id); const msg = await getMsg(ssbClient, tipId); if (!msg || msg.content?.type !== "audio") throw new Error("Audio not found"); if (msg.content.author !== userId) throw new Error("Not the author"); const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }; return new Promise((resolve, reject) => { ssbClient.publish(tombstone, (err, res) => (err ? reject(err) : resolve(res))); }); }, async listAll(filterOrOpts = "all", maybeOpts = {}) { const ssbClient = await openSsb(); const opts = typeof filterOrOpts === "object" ? filterOrOpts : maybeOpts || {}; const filter = (typeof filterOrOpts === "string" ? filterOrOpts : opts.filter || "all") || "all"; const q = String(opts.q || "").trim().toLowerCase(); const sort = String(opts.sort || "recent").trim(); const viewerId = opts.viewerId || ssbClient.id; const messages = await getAllMessages(ssbClient); const idx = buildIndex(messages); const items = []; for (const [rootId, tipId] of idx.tipByRoot.entries()) { if (idx.tomb.has(tipId)) continue; const node = idx.nodes.get(tipId); if (!node) continue; items.push(buildAudio(node, rootId, viewerId, idx.resolveGroup(rootId))); } let list = dedupeBy(items, x => x.url ? [norm(x.author), norm(x.url)].join('|') : null); const now = Date.now(); if (filter === "mine") list = list.filter((a) => String(a.author) === String(viewerId)); else if (filter === "recent") list = list.filter((a) => new Date(a.createdAt).getTime() >= now - 86400000); else if (filter === "top") { list = list.slice().sort((a, b) => voteSum(b.opinions) - voteSum(a.opinions) || new Date(b.createdAt) - new Date(a.createdAt)); } else if (filter === "bcs") { list = list.filter((a) => a.isBcs === true); } if (q) { list = list.filter((a) => { const title = String(a.title || "").toLowerCase(); const desc = String(a.description || "").toLowerCase(); const tags = safeArr(a.tags).join(" ").toLowerCase(); const author = String(a.author || "").toLowerCase(); return title.includes(q) || desc.includes(q) || tags.includes(q) || author.includes(q); }); } if (sort === "top") { list = list.slice().sort((a, b) => voteSum(b.opinions) - voteSum(a.opinions) || new Date(b.createdAt) - new Date(a.createdAt)); } else if (sort === "oldest") { list = list.slice().sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt)); } else { list = list.slice().sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)); } return list; }, async getAudioById(id, viewerId = null) { const ssbClient = await openSsb(); const viewer = viewerId || ssbClient.id; const messages = await getAllMessages(ssbClient); const idx = memoIndex('audios', messages, buildIndex); const rootId = idx.rootOf(id); const agg = idx.resolveGroup(rootId); if (!idx.tipByRoot.has(rootId) || !agg.contentNode || idx.tomb.has(agg.contentTip)) throw new Error("Audio not found"); return buildAudio(agg.contentNode, rootId, viewer, agg); }, async createOpinion(id, category) { if (!categories.includes(category)) throw new Error("Invalid voting category"); const ssbClient = await openSsb(); const userId = ssbClient.id; const audio = await this.getAudioById(id, userId); if (!audio) throw new Error("Audio not found"); if (safeArr(audio.opinions_inhabitants).includes(userId)) throw new Error("Already voted"); const content = { type: "audioOpinion", target: audio.rootId, category, createdAt: new Date().toISOString() }; return new Promise((resolve, reject) => { ssbClient.publish(content, (err, result) => (err ? reject(err) : resolve(result))); }); } }; };