← back to browse
goat command

find

Detect song from video/audio and download the full track

0
views
0
likes
0
installs

Aliases: finds, shazam, detect

raw source
const axios = require("axios");
const fs = require("fs-extra");
const path = require("path");
const https = require("https");
const http = require("http");
const yts = require("yt-search");

const API_BASE_URL = "https://hinata-shazam-api-production.up.railway.app";

const TMP_DIR        = path.join(__dirname, "cache");
const MAX_BYTES      = 100 * 1024 * 1024;

const REACT_WORKING = "๐Ÿค";
const REACT_DONE    = "๐Ÿชถ";
const REACT_ERROR   = "โŒ";

const sentSongIds    = new Set();
const pendingSongIds = new Set();
const failedSongIds  = new Set();

const CLEANUP_DELAY_MS = 10 * 60 * 1000;

function isLocked(msgID) {
  return sentSongIds.has(msgID) || pendingSongIds.has(msgID) || failedSongIds.has(msgID);
}
function markSending(msgID)  { pendingSongIds.add(msgID); }
function markSent(msgID)     { pendingSongIds.delete(msgID); sentSongIds.add(msgID); }
function markFailed(msgID)   { pendingSongIds.delete(msgID); failedSongIds.add(msgID); }

function scheduleCleanup(msgID) {
  setTimeout(() => {
    sentSongIds.delete(msgID);
    pendingSongIds.delete(msgID);
    failedSongIds.delete(msgID);
  }, CLEANUP_DELAY_MS);
}

function react(api, event, emoji) {
  try { api.setMessageReaction(emoji, event.messageID, () => {}, true); } catch (_) {}
}

function registerCard(msgID, data) {
  const payload = { commandName: "find", messageID: msgID, ...data };
  global.GoatBot.onReply.set(msgID, payload);
  global.GoatBot.onReaction.set(msgID, payload);
}

async function checkVIP(senderID, usersData, message) {
  const ADMINS = global.GoatBot?.config?.adminBot || [];
  const isBotAdmin = ADMINS.includes(senderID);

  if (isBotAdmin) return true;

  const userData = await usersData.get(senderID);
  const vip = userData?.data?.vip;

  if (!vip || !vip.expires || vip.expires < Date.now()) {
    await message.reply(
      "โŒ ๐•๐ˆ๐ ๐Ž๐๐‹๐˜ ๐‚๐Ž๐Œ๐Œ๐€๐๐ƒ\n" +
      "โ€ข ๐Ž๐ง๐ฅ๐ฒ ๐•๐ˆ๐ ๐ฎ๐ฌ๐ž๐ซ๐ฌ ๐œ๐š๐ง ๐ฎ๐ฌ๐ž ๐Ÿ๐ข๐ง๐\n" +
      "โ€ข ๐“๐ฒ๐ฉ๐ž: vip buy"
    );
    return false;
  }

  return true;
}

async function baseApiUrl() {
  const base = await axios.get("https://raw.githubusercontent.com/mahmudx7/HINATA/main/baseApiUrl.json");
  return base.data.mahmud;
}

async function downloadViaNewApi({ videoID, title, type }) {
  const ext = type === "audio" ? "mp3" : "mp4";
  const filePath = path.join(TMP_DIR, `${Date.now()}_ytb.${ext}`);

  const apiUrl = await baseApiUrl();
  const { data } = await axios.get(`${apiUrl}/api/ytb/get?id=${videoID}&type=${type}`, { timeout: 60000 });

  const info = data?.data;
  if (!info || !info.downloadLink) throw new Error("API did not return a download URL.");

  const response = await axios({ url: info.downloadLink, method: "GET", responseType: "stream", timeout: 180000 });

  await new Promise((resolve, reject) => {
    const writer = fs.createWriteStream(filePath);
    response.data.pipe(writer);
    writer.on("finish", resolve);
    writer.on("error", reject);
    response.data.on("error", reject);
  });

  const { size } = await fs.stat(filePath);
  if (size < 1024) throw new Error(`Download too small (${size}B)`);

  return {
    filePath,
    caption: type === "audio"
      ? `โœ… | ๐‡๐ž๐ซ๐ž'๐ฌ ๐ฒ๐จ๐ฎ๐ซ ๐ซ๐ž๐ช๐ฎ๐ž๐ฌ๐ญ๐ž๐ ๐ฌ๐จ๐ง๐ \nโžก๏ธ ${info.title || title}`
      : `โ€ข โœจ๐“๐ข๐ญ๐ฅ๐ž: ${info.title || title}`
  };
}

function cleanFile(p) {
  try { if (p && fs.existsSync(p)) fs.unlinkSync(p); } catch (_) {}
}

module.exports = {
  config: {
    name: "find",
    aliases: ["finds", "shazam", "detect"],
    version: "3.8.0",
    author: "Arafat",
    countDown: 15,
    role: 0,
    shortDescription: "Detect song from video/audio and download the full track",
    longDescription:
      "Reply to any video or audio with 'find' โ†’ detects the background song + sends info card.\n" +
      "Reply to the card OR react to it โ†’ downloads and sends the full song (sent only once).\n" +
      "If download fails, the card is permanently locked โ€” no spam retries.",
    category: "music",
    guide: {
      en: "Step 1 โ€” Reply to a video or audio: {pn}\nStep 2 โ€” Reply to the card OR react with any emoji โ†’ full song sent (once only)"
    }
  },

  onStart: async function ({ message, event, api, usersData }) {
    const allowed = await checkVIP(event.senderID, usersData, message);
    if (!allowed) return;

    const afterCmd = (event.body || '').trim().replace(/^[^\s]+\s*/i, '').trim().toLowerCase();

    let mode = null;
    if (['-a', 'audio', '-audio', 'find audio', 'find -a'].includes(afterCmd)) {
      mode = 'audio';
    } else if (['-v', 'video', '-video', 'find video', 'find -v'].includes(afterCmd)) {
      mode = 'video';
    }

    return handleFindSong({ message, event, api, mode });
  },

  onReply: async function ({ message, event, api, Reply, usersData }) {
    const allowed = await checkVIP(event.senderID, usersData, message);
    if (!allowed) return;

    const body = (event.body || "").trim().toLowerCase();
    if (body === "send video") {
      return sendVideo({ message, event, api, songData: Reply });
    }
    return sendAudio({ message, event, api, songData: Reply });
  },

  onReaction: async function ({ message, event, api, Reaction, usersData }) {
    const allowed = await checkVIP(event.senderID, usersData, message);
    if (!allowed) return;

    return sendAudio({ message, event, api, songData: Reaction });
  }
};

async function handleFindSong({ message, event, api, mode }) {
  const reply = event.messageReply;
  if (!reply) {
    return message.reply("โŒ | Please reply to a video or audio message and type: !find");
  }

  const mediaUrl = extractMediaUrl(reply);
  if (!mediaUrl) {
    return message.reply("โŒ | No video or audio found in the replied message.");
  }

  react(api, event, REACT_WORKING);

  try {
    const { data } = await axios.get(`${API_BASE_URL}/api/find`, {
      params: { url: mediaUrl },
      timeout: 60000
    });

    if (!data.success) {
      react(api, event, REACT_ERROR);
      return message.reply("โŒ | No song detected in this video/audio.");
    }

    const song = data.data;

    const result = {
      title:        song.title       || "Unknown",
      artist:       song.artist      || "Unknown",
      release_date: song.detectedAt  ? new Date(song.detectedAt).toLocaleDateString() : null,
      song_link:    song.shazamLink  || null,
      thumbnail:    song.thumbnail   || null,
      audioUrl:     song.audioUrl    || null,
      _shazamMeta:  [],
    };

    const info = buildSongInfo(result);

    // Silent direct-download modes (no card)
    if (mode === 'audio') {
      react(api, event, REACT_DONE);
      return sendAudio({
        message, event, api,
        songData: {
          title:     info.title,
          artist:    info.artist,
          audioUrl:  result.audioUrl,
          messageID: event.messageID
        }
      });
    }

    if (mode === 'video') {
      react(api, event, REACT_DONE);
      return sendVideo({
        message, event, api,
        songData: {
          title:     info.title,
          artist:    info.artist,
          audioUrl:  result.audioUrl,
          messageID: event.messageID
        }
      });
    }

    // Default: card flow
    const coverUrl  = result.thumbnail;
    const coverPath = await downloadCover(coverUrl, result.song_link || Date.now());

    const ts = Date.now();
    const canvasPath = await buildSongCanvas(info, coverPath, ts);

    let sentMsg;
    if (canvasPath && fs.existsSync(canvasPath)) {
      sentMsg = await message.reply({ body: buildCaption(info), attachment: fs.createReadStream(canvasPath) });
      cleanFile(canvasPath);
    } else if (coverPath && fs.existsSync(coverPath)) {
      sentMsg = await message.reply({ body: buildCaption(info), attachment: fs.createReadStream(coverPath) });
    } else {
      sentMsg = await message.reply(`โ ${info.title} โ€” ${info.artist}`);
    }
    if (coverPath) cleanFile(coverPath);

    react(api, event, REACT_DONE);
    registerCard(sentMsg.messageID, {
      title:    info.title,
      artist:   info.artist,
      audioUrl: result.audioUrl,
      songLink: result.song_link
    });

  } catch (err) {
    react(api, event, REACT_ERROR);
    await message.reply(`โŒ | Error detecting song.`);
  }
}

async function sendVideo({ message, event, api, songData }) {
  const { title, artist, audioUrl, messageID: cardMsgID } = songData;

  if (!title || !artist) {
    return message.reply("โŒ | Song data missing. Please use !find again.");
  }

  if (isLocked(cardMsgID)) return;

  markSending(cardMsgID);
  scheduleCleanup(cardMsgID);

  react(api, event, REACT_WORKING);
  await fs.ensureDir(TMP_DIR);

  const searchQuery = `${title} ${artist}`;
  let filePath = null;
  let caption = null;

  // Step 1: search YouTube then fetch via new API
  let ytSelected = null;
  try {
    const ytResults = await yts(searchQuery);
    if (ytResults && ytResults.videos.length) ytSelected = ytResults.videos[0];
  } catch (_) {}

  if (ytSelected) {
    try {
      const result = await downloadViaNewApi({ videoID: ytSelected.videoId, title: ytSelected.title, type: "video" });
      filePath = result.filePath;
      caption  = result.caption;
    } catch (apiErr) {
      console.warn(`[find] sendVideo new API failed: ${apiErr.message}`);
      filePath = null;
    }
  }

  // All failed โ€” now send error
  if (!filePath) {
    markFailed(cardMsgID);
    react(api, event, REACT_ERROR);
    return message.reply(`โŒ | Could not download video. Please try again.`);
  }

  try {
    const { size: sendSize } = await fs.stat(filePath);
    if (sendSize < 1024) throw new Error(`File too small before send (${sendSize}B)`);

    const sentMsg = await message.reply({ body: caption, attachment: fs.createReadStream(filePath) });
    if (!sentMsg || !sentMsg.messageID) throw new Error("Send returned no messageID โ€” upload likely failed.");

    markSent(cardMsgID);
    react(api, event, REACT_DONE);
  } catch (sendErr) {
    console.error(`[find] sendVideo send failed: ${sendErr.message}`);
    markFailed(cardMsgID);
    react(api, event, REACT_ERROR);
    await message.reply(`โŒ | Could not send video. Please try again.`);
  } finally {
    cleanFile(filePath);
  }
}

async function sendAudio({ message, event, api, songData }) {
  const { title, artist, messageID: cardMsgID, audioUrl } = songData;

  if (!title || !artist) {
    return message.reply("โŒ | Song data missing. Please use !find again.");
  }

  if (isLocked(cardMsgID)) return;

  markSending(cardMsgID);
  scheduleCleanup(cardMsgID);

  react(api, event, REACT_WORKING);
  await fs.ensureDir(TMP_DIR);

  const searchQuery = `${title} ${artist}`;
  let filePath = null;
  let caption = null;

  // Step 1: Railway audioUrl
  if (audioUrl) {
    const railwayPath = path.join(TMP_DIR, `find_railway_${Date.now()}.mp3`);
    try {
      const response = await axios({
        url: audioUrl,
        method: "GET",
        responseType: "stream",
        timeout: 120000,
        headers: { "User-Agent": "Mozilla/5.0" }
      });
      const writer = fs.createWriteStream(railwayPath);
      response.data.pipe(writer);
      await new Promise((resolve, reject) => {
        writer.on("finish", resolve);
        writer.on("error", reject);
        response.data.on("error", reject);
      });
      const { size } = await fs.stat(railwayPath);
      if (size < 1024) throw new Error("File too small.");
      filePath = railwayPath;
      caption  = `โœ… | ๐‡๐ž๐ซ๐ž'๐ฌ ๐ฒ๐จ๐ฎ๐ซ ๐ซ๐ž๐ช๐ฎ๐ž๐ฌ๐ญ๐ž๐ ๐ฌ๐จ๐ง๐ \nโžก๏ธ ${title}`;
    } catch (railErr) {
      console.warn(`[find] Railway audioUrl failed: ${railErr.message}`);
      cleanFile(railwayPath);
      filePath = null;
    }
  }

  // Step 2: search YouTube then fetch via new API
  if (!filePath) {
    let ytSelected = null;
    try {
      const ytResults = await yts(searchQuery);
      if (ytResults && ytResults.videos.length) ytSelected = ytResults.videos[0];
    } catch (_) {}

    if (ytSelected) {
      try {
        const result = await downloadViaNewApi({ videoID: ytSelected.videoId, title: ytSelected.title, type: "audio" });
        filePath = result.filePath;
        caption  = result.caption;
      } catch (apiErr) {
        console.warn(`[find] sendAudio new API failed: ${apiErr.message}`);
        filePath = null;
      }
    }
  }

  // All failed โ€” now send error
  if (!filePath) {
    markFailed(cardMsgID);
    react(api, event, REACT_ERROR);
    return message.reply(`โŒ | Could not download song from any source.`);
  }

  try {
    const { size: sendSize } = await fs.stat(filePath);
    if (sendSize < 1024) throw new Error(`File too small before send (${sendSize}B)`);

    const sentMsg = await message.reply({ body: caption, attachment: fs.createReadStream(filePath) });
    if (!sentMsg || !sentMsg.messageID) throw new Error("Send returned no messageID โ€” upload likely failed.");

    markSent(cardMsgID);
    react(api, event, REACT_DONE);
  } catch (sendErr) {
    console.error(`[find] sendAudio send failed: ${sendErr.message}`);
    markFailed(cardMsgID);
    react(api, event, REACT_ERROR);
    await message.reply(`โŒ | Could not send song. Please try again.`);
  } finally {
    cleanFile(filePath);
  }
}

function buildSongInfo(result) {
  const meta = (title) =>
    (result._shazamMeta || []).find(m => m.title === title)?.text || "Unknown";

  return {
    title:         result.title                    || "Unknown",
    artist:        result.artist                   || "Unknown",
    album:         meta("Album"),
    released:      result.release_date || meta("Released"),
    genre:         meta("Genre"),
    lyrics:        result.lyrics?.snippet          || null,
    songLink:      result.song_link                || null,
    spotifyUrl:    null,
    appleMusicUrl: null,
  };
}

function buildCaption(info) {
  const lines = [
    "แƒฆ ๐–ฎ๐–ฌ๐–ญ๐–จ-๐–ท ๐– ๐–ญ๐– ๐–ซ๐–ธ๐–ณ๐–จ๐–ข๐–ฒ",
    "แƒฆ ๐–ฒ๐–ธ๐–ฒ๐–ณ๐–ค๐–ฌ ๐–ก๐–ธ ๐– ๐–ฑ๐– ๐–ฅ๐– ๐–ณ",
    "",
    `โ ๐–ณ๐—‚๐—๐—…๐–พ: ${info.title}`,
    `โ ๐– ๐—‹๐—๐—‚๐—Œ๐—: ${info.artist}`,
  ];
  if (info.lyrics) {
    lines.push("", "โ ๐–ซ๐—’๐—‹๐—‚๐–ผ๐—Œ:");
    info.lyrics.split("\n").forEach(l => lines.push(l));
  }
  lines.push("", "");
  return lines.join("\n");
}

function extractMediaUrl(reply) {
  if (!reply) return null;
  for (const att of (reply.attachments || [])) {
    const t = (att.type || "").toLowerCase();
    if (["video", "audio"].includes(t) && att.url) return att.url;
    if (att.payload?.url && ["video", "audio"].includes(t)) return att.payload.url;
    if (t === "share" && att.url) return att.url;
  }
  return null;
}

async function downloadCover(url, songKey) {
  if (!url) return null;
  try {
    await fs.ensureDir(TMP_DIR);
    const p = path.join(TMP_DIR, `cover_${songKey}.jpg`);
    await streamDownload(url, p, 5 * 1024 * 1024);
    return p;
  } catch (_) {
    return null;
  }
}

function streamDownload(url, destPath, maxBytes = 0) {
  return new Promise((resolve, reject) => {
    let received = 0;
    const doRequest = (currentUrl, hops = 0) => {
      if (hops >= 5) return reject(new Error("Too many redirects"));
      const mod = currentUrl.startsWith("https") ? https : http;
      mod.get(currentUrl, {
        headers: { "User-Agent": "Mozilla/5.0", "Accept": "*/*" }
      }, (res) => {
        if ([301, 302, 307, 308].includes(res.statusCode) && res.headers.location)
          return doRequest(res.headers.location, hops + 1);
        if (res.statusCode !== 200)
          return reject(new Error(`HTTP ${res.statusCode}`));
        const out = fs.createWriteStream(destPath);
        res.on("data", chunk => {
          received += chunk.length;
          if (maxBytes > 0 && received > maxBytes) {
            res.destroy();
            out.close();
            reject(new Error(`File exceeds ${maxBytes / 1024 / 1024} MB limit`));
          }
        });
        res.pipe(out);
        out.on("finish", resolve);
        out.on("error",  reject);
        res.on("error",  reject);
      }).on("error", reject)
        .setTimeout(120000, () => reject(new Error("Stream timeout")));
    };
    doRequest(url);
  });
}

function roundRect(ctx, x, y, w, h, r) {
  ctx.beginPath();
  ctx.moveTo(x + r, y);
  ctx.lineTo(x + w - r, y);
  ctx.quadraticCurveTo(x + w, y, x + w, y + r);
  ctx.lineTo(x + w, y + h - r);
  ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
  ctx.lineTo(x + r, y + h);
  ctx.quadraticCurveTo(x, y + h, x, y + h - r);
  ctx.lineTo(x, y + r);
  ctx.quadraticCurveTo(x, y, x + r, y);
  ctx.closePath();
}

function truncate(str, max) {
  const s = str || "Unknown";
  return s.length > max ? s.slice(0, max) + "โ€ฆ" : s;
}

async function buildSongCanvas(info, coverPath, ts) {
  try {
    const { createCanvas, loadImage } = require("canvas");

    const W = 900, H = 420;
    const canvas = createCanvas(W, H);
    const ctx    = canvas.getContext("2d");

    const bg = ctx.createLinearGradient(0, 0, W, H);
    bg.addColorStop(0,   "#0f0c29");
    bg.addColorStop(0.5, "#302b63");
    bg.addColorStop(1,   "#24243e");
    ctx.fillStyle = bg;
    ctx.fillRect(0, 0, W, H);

    ctx.strokeStyle = "rgba(255,255,255,0.03)";
    ctx.lineWidth   = 1;
    for (let i = 0; i < H; i += 6) {
      ctx.beginPath(); ctx.moveTo(0, i); ctx.lineTo(W, i); ctx.stroke();
    }

    const COVER_W = 280, COVER_H = H;
    if (coverPath && fs.existsSync(coverPath)) {
      try {
        const cover = await loadImage(coverPath);
        ctx.save();
        ctx.drawImage(cover, 0, 0, COVER_W, COVER_H);
        const fadeGrad = ctx.createLinearGradient(COVER_W - 80, 0, COVER_W, 0);
        fadeGrad.addColorStop(0, "rgba(15,12,41,0)");
        fadeGrad.addColorStop(1, "rgba(15,12,41,1)");
        ctx.fillStyle = fadeGrad;
        ctx.fillRect(COVER_W - 80, 0, 80, COVER_H);
        ctx.restore();
      } catch (_) {
        ctx.fillStyle = "rgba(255,255,255,0.05)";
        ctx.fillRect(0, 0, COVER_W, COVER_H);
        ctx.fillStyle = "rgba(255,255,255,0.15)";
        ctx.font      = "120px Sans";
        ctx.textAlign = "center";
        ctx.fillText("โ™ซ", COVER_W / 2, COVER_H / 2 + 45);
        ctx.textAlign = "left";
      }
    } else {
      ctx.fillStyle = "rgba(255,255,255,0.05)";
      ctx.fillRect(0, 0, COVER_W, COVER_H);
      ctx.fillStyle = "rgba(255,255,255,0.15)";
      ctx.font      = "120px Sans";
      ctx.textAlign = "center";
      ctx.fillText("โ™ซ", COVER_W / 2, COVER_H / 2 + 45);
      ctx.textAlign = "left";
    }

    const PAD  = 28;
    const TX   = COVER_W + PAD;
    const TW   = W - TX - PAD;

    ctx.fillStyle = "rgba(255,255,255,0.38)";
    ctx.font      = "bold 11px Sans";
    ctx.fillText("แƒฆ OMNI-X ANALYTICS  ยท  SYSTEM BY ARAFAT", TX, 30);

    const divGrad = ctx.createLinearGradient(TX, 0, TX + TW, 0);
    divGrad.addColorStop(0, "rgba(167,139,250,0.80)");
    divGrad.addColorStop(1, "rgba(167,139,250,0)");
    ctx.strokeStyle = divGrad;
    ctx.lineWidth   = 1.5;
    ctx.beginPath(); ctx.moveTo(TX, 40); ctx.lineTo(TX + TW, 40); ctx.stroke();

    ctx.fillStyle = "#ffffff";
    ctx.font      = "bold 30px Sans";
    ctx.fillText(truncate(info.title || "Unknown Title", 26), TX, 78);

    ctx.fillStyle = "#a78bfa";
    ctx.font      = "bold 16px Sans";
    ctx.fillText("โœฆ  " + truncate(info.artist || "Unknown Artist", 38), TX, 104);

    ctx.strokeStyle = "rgba(255,255,255,0.08)";
    ctx.lineWidth   = 1;
    ctx.beginPath(); ctx.moveTo(TX, 118); ctx.lineTo(TX + TW, 118); ctx.stroke();

    const LABEL_W = 74, LABEL_H = 20, VAL_OFF = LABEL_W + 10;
    const ROW_H   = 32, COL_GAP = 10;
    const COL2_X  = TX + (TW / 2) + COL_GAP;

    const allRows = [
      ["ALBUM",    info.album    || "โ€”"],
      ["ARTIST",   info.artist   || "โ€”"],
      ["GENRE",    info.genre    || "โ€”"],
      ["RELEASE",  info.released || "โ€”"],
      ["LABEL",    info.label    || "โ€”"],
      ["DURATION", info.duration || "โ€”"],
    ];

    let ry = 134;
    for (let i = 0; i < allRows.length; i += 2) {
      const leftItem  = allRows[i];
      const rightItem = allRows[i + 1];

      if (leftItem) {
        ctx.save();
        ctx.fillStyle = "rgba(167,139,250,0.20)";
        roundRect(ctx, TX, ry, LABEL_W, LABEL_H, 4);
        ctx.fill();
        ctx.restore();
        ctx.fillStyle = "rgba(167,139,250,0.95)";
        ctx.font      = "bold 10px Sans";
        ctx.fillText(leftItem[0], TX + 6, ry + 14);
        ctx.fillStyle = "#e2e8f0";
        ctx.font      = "13px Sans";
        ctx.fillText(truncate(leftItem[1], 18), TX + VAL_OFF, ry + 14);
      }

      if (rightItem) {
        ctx.save();
        ctx.fillStyle = "rgba(167,139,250,0.20)";
        roundRect(ctx, COL2_X, ry, LABEL_W, LABEL_H, 4);
        ctx.fill();
        ctx.restore();
        ctx.fillStyle = "rgba(167,139,250,0.95)";
        ctx.font      = "bold 10px Sans";
        ctx.fillText(rightItem[0], COL2_X + 6, ry + 14);
        ctx.fillStyle = "#e2e8f0";
        ctx.font      = "13px Sans";
        ctx.fillText(truncate(rightItem[1], 18), COL2_X + VAL_OFF, ry + 14);
      }

      ry += ROW_H;
    }

    const links = [];
    if (info.spotifyUrl)    links.push(["SPOTIFY",   info.spotifyUrl]);
    if (info.appleMusicUrl) links.push(["APPLE",     info.appleMusicUrl]);
    if (info.songLink)      links.push(["SONG LINK", info.songLink]);

    if (links.length > 0) {
      ry += 4;
      ctx.strokeStyle = "rgba(255,255,255,0.07)";
      ctx.lineWidth   = 1;
      ctx.beginPath(); ctx.moveTo(TX, ry); ctx.lineTo(TX + TW, ry); ctx.stroke();
      ry += 10;

      let lx = TX;
      for (const [label] of links) {
        const bw = 76;
        ctx.save();
        ctx.fillStyle = "rgba(255,255,255,0.10)";
        roundRect(ctx, lx, ry, bw, 18, 4);
        ctx.fill();
        ctx.restore();
        ctx.fillStyle = "rgba(167,139,250,0.85)";
        ctx.font      = "bold 10px Sans";
        ctx.textAlign = "center";
        ctx.fillText("โ–ถ " + label, lx + bw / 2, ry + 13);
        ctx.textAlign = "left";
        lx += bw + 8;
      }
      ry += 26;
    }

    if (info.lyrics) {
      const snip = (info.lyrics.split("\n").find(l => l.trim()) || "").trim();
      if (snip) {
        ctx.fillStyle = "rgba(255,255,255,0.22)";
        ctx.font      = "italic 12px Sans";
        const lyricY  = Math.min(Math.max(ry + 6, H - 48), H - 36);
        ctx.fillText(`โ ${truncate(snip, 58)} โž`, TX, lyricY);
      }
    }

    ctx.fillStyle = "rgba(255,255,255,0.16)";
    ctx.font      = "11px Sans";
    ctx.fillText("โ†ฉ Reply or react to this card to download the full song", TX, H - 14);

    const accentGrad = ctx.createLinearGradient(0, H - 4, W, H - 4);
    accentGrad.addColorStop(0,   "#a78bfa");
    accentGrad.addColorStop(0.5, "#7c3aed");
    accentGrad.addColorStop(1,   "#a78bfa");
    ctx.strokeStyle = accentGrad;
    ctx.lineWidth   = 3;
    ctx.beginPath(); ctx.moveTo(0, H - 3); ctx.lineTo(W, H - 3); ctx.stroke();

    const outPath = path.join(TMP_DIR, `card_${ts}.jpg`);
    const buf     = canvas.toBuffer("image/jpeg", { quality: 0.93 });
    fs.writeFileSync(outPath, buf);
    return outPath;

  } catch (_) {
    return null;
  }
}

View raw file