const axios = require("axios"); const fs = require('fs-extra'); const path = require('path'); const yts = require('yt-search'); const { Transform } = require("stream"); const { pipeline } = require("stream/promises"); const REACT = { loading: "🐤", success: "🪶", error: "❌", }; const AUDIO_API = "https://yt-song-api.vercel.app/api/song"; const VIDEO_API = "https://video-dl-api-tan.vercel.app"; const MAX_FILE_SIZE = 25 * 1024 * 1024; const VIDEO_QUALITY = "720"; function isTrustedAudioHost(hostname) { return hostname === "vidssave.com" || hostname.endsWith(".vidssave.com") || hostname === "ymcdn.org" || hostname.endsWith(".ymcdn.org") || hostname === "googlevideo.com" || hostname.endsWith(".googlevideo.com"); } function getAudioUrl(data) { const value = data && (data.download || data.audio_url); if (!value) return null; try { const url = new URL(value); return url.protocol === "https:" && isTrustedAudioHost(url.hostname) ? url.toString() : null; } catch { return null; } } function fileSizeGuard(maxBytes) { let received = 0; return new Transform({ transform(chunk, _encoding, callback) { received += chunk.length; if (received > maxBytes) { const error = new Error("File is too large to send on Messenger"); error.code = "SONG_TOO_LARGE"; return callback(error); } callback(null, chunk); } }); } async function removeFile(filePath) { if (!filePath) return; await fs.promises.unlink(filePath).catch(() => {}); } function sendMessageAsync(api, msgObj, threadID, messageID) { return new Promise((resolve, reject) => { api.sendMessage(msgObj, threadID, (err, info) => { if (err) return reject(err instanceof Error ? err : new Error(String(err))); resolve(info); }, messageID); }); } module.exports = { config: { name: "sing", aliases: ["music", "play"], version: "2.1.1", author: "Arafat", countDown: 10, role: 0, description: { vi: "Tự động tìm và gửi bài hát gốc từ YouTube", en: "Automatically find and send the original song from YouTube" }, category: "media", guide: { vi: " {pn} : gửi audio bài hát gốc" + "\n {pn} -v : gửi video bài hát gốc" + "\n Ví dụ:" + "\n {pn} pal pal" + "\n {pn} -v pal pal", en: " {pn} → Audio" + "\n {pn} -v → Video" + "\n Example:" + "\n {pn} pal pal" + "\n {pn} -v pal pal" } }, langs: { vi: { error: "❌ | Không thể tải xuống. Vui lòng thử lại.", noResult: "⭕ Không có kết quả tìm kiếm nào phù hợp với từ khóa %1" }, en: { error: "❌ | Could not download. Please try again.", noResult: "⭕ No search results match the keyword %1" } }, onStart: async function ({ api, args, event, message, getLang }) { const { threadID, messageID, senderID } = event; let type = "audio"; if (args[0] === "-v" || args[0] === "video") { type = "video"; args.shift(); } else if (args[0] === "-a" || args[0] === "audio") { args.shift(); } const input = args.join(" "); if (!input) { return message.reply("📌 Usage:\nsing → Audio\nsing -v → Video"); } if (type === "audio") { return handleAudioDownload(api, threadID, messageID, senderID, input, getLang); } const checkurl = /^(?:https?:\/\/)?(?:m\.|www\.)?(?:youtu\.be\/|youtube\.com\/(?:embed\/|v\/|watch\?v=|watch\?.+&v=|shorts\/))((\w|-){11})(?:\S+)?$/; api.setMessageReaction(REACT.loading, messageID, () => {}, true); if (checkurl.test(input)) { const videoID = input.match(checkurl)[1]; return handleDownload(api, threadID, messageID, videoID, type, getLang); } try { const { videos } = await yts(`${input} official`); if (!videos || videos.length === 0) { api.setMessageReaction(REACT.error, messageID, () => {}, true); return api.sendMessage(getLang("noResult", input), threadID, messageID); } const results = videos.map(v => ({ id: v.videoId, title: v.title })); const videoID = pickBestResult(results, input).id; return handleDownload(api, threadID, messageID, videoID, type, getLang); } catch (e) { api.setMessageReaction(REACT.error, messageID, () => {}, true); return api.sendMessage(getLang("error"), threadID, messageID); } } }; function pickBestResult(results, query) { const q = query.toLowerCase(); const officialClean = results.find(r => { const title = (r.title || "").toLowerCase(); return title.includes(q) && title.includes("official") && !title.includes("audio") && !title.includes("lyric"); }); if (officialClean) return officialClean; const cleanMatch = results.find(r => { const title = (r.title || "").toLowerCase(); return title.includes(q) && !title.includes("audio") && !title.includes("lyric"); }); if (cleanMatch) return cleanMatch; const exactMatch = results.find(r => (r.title || "").toLowerCase().includes(q)); if (exactMatch) return exactMatch; return results[0]; } async function handleAudioDownload(api, threadID, messageID, senderID, songName, getLang) { api.setMessageReaction(REACT.loading, messageID, () => {}, true); const CACHE_DIR = path.join(__dirname, 'cache'); let filePath; try { const { data } = await axios.get( AUDIO_API, { params: { q: `${songName} official` }, timeout: 45000, headers: { Accept: "application/json" } } ); const audioUrl = getAudioUrl(data); if (!data || !data.success || !audioUrl) { api.setMessageReaction(REACT.error, messageID, () => {}, true); return api.sendMessage(getLang("noResult", songName), threadID, messageID); } await fs.promises.mkdir(CACHE_DIR, { recursive: true }); const extension = ["mp3", "m4a", "mp4"].includes(data.format) ? data.format : "mp3"; filePath = path.join( CACHE_DIR, `sing_${senderID || "user"}_${Date.now()}.${extension}` ); const response = await axios.get(audioUrl, { responseType: "stream", timeout: 90000, maxRedirects: 3, beforeRedirect: (options) => { if (options.protocol !== "https:" || !isTrustedAudioHost(options.hostname)) { throw new Error("Blocked an untrusted audio redirect"); } }, headers: { Accept: "audio/mpeg,audio/mp4,audio/*;q=0.9,*/*;q=0.1", "User-Agent": "Mozilla/5.0" } }); const declaredSize = Number(response.headers["content-length"] || 0); if (declaredSize > MAX_FILE_SIZE) { response.data.destroy(); const error = new Error("Song is too large to send on Messenger"); error.code = "SONG_TOO_LARGE"; throw error; } const contentType = String(response.headers["content-type"] || "").toLowerCase(); if (contentType && !contentType.startsWith("audio/") && contentType !== "application/octet-stream") { response.data.destroy(); throw new Error("Song provider returned an invalid audio file"); } await pipeline( response.data, fileSizeGuard(MAX_FILE_SIZE), fs.createWriteStream(filePath, { flags: "wx" }) ); const body = `✅ | 𝐇𝐞𝐫𝐞'𝐬 𝐲𝐨𝐮𝐫 𝐫𝐞𝐪𝐮𝐞𝐬𝐭𝐞𝐝 𝐬𝐨𝐧𝐠\n➡️ ${data.title || "Unknown"}`; await sendMessageAsync(api, { body, attachment: fs.createReadStream(filePath) }, threadID, messageID); api.setMessageReaction(REACT.success, messageID, () => {}, true); } catch (err) { api.setMessageReaction(REACT.error, messageID, () => {}, true); if (err.code === "SONG_TOO_LARGE") { return api.sendMessage("❌ | This song is over 25 MB, so Messenger cannot send it.", threadID, messageID); } if (err.code === "ECONNABORTED") { return api.sendMessage("❌ | Song request timed out. Please try again.", threadID, messageID); } return api.sendMessage(getLang("error"), threadID, messageID); } finally { await removeFile(filePath); } } async function handleDownload(api, threadID, messageID, videoID, type, getLang) { const format = 'mp4'; const cacheDir = path.join(__dirname, 'cache'); if (!fs.existsSync(cacheDir)) fs.mkdirSync(cacheDir, { recursive: true }); const filePath = path.join(cacheDir, `sing_${Date.now()}.${format}`); const youtubeUrl = `https://www.youtube.com/watch?v=${videoID}`; let title = "Unknown"; try { const { data } = await axios.get(`${VIDEO_API}/download`, { params: { url: youtubeUrl }, timeout: 45000 }); title = data.title || data.filename || data.name || "Unknown"; } catch (_) {} try { const streamUrl = `${VIDEO_API}/stream?url=${encodeURIComponent(youtubeUrl)}&type=video&quality=${VIDEO_QUALITY}`; const response = await axios.get(streamUrl, { responseType: "stream", timeout: 90000, headers: { Accept: "video/mp4,video/*;q=0.9,*/*;q=0.1" } }); const declaredSize = Number(response.headers["content-length"] || 0); if (declaredSize > MAX_FILE_SIZE) { response.data.destroy(); const error = new Error("Video is too large to send on Messenger"); error.code = "SONG_TOO_LARGE"; throw error; } const contentType = String(response.headers["content-type"] || "").toLowerCase(); if (contentType && !contentType.startsWith("video/") && contentType !== "application/octet-stream") { response.data.destroy(); throw new Error("Video provider returned an invalid file"); } await pipeline( response.data, fileSizeGuard(MAX_FILE_SIZE), fs.createWriteStream(filePath, { flags: "w" }) ); const body = `• ✨𝐓𝐢𝐭𝐥𝐞: ${title}`; await sendMessageAsync(api, { body, attachment: fs.createReadStream(filePath) }, threadID, messageID); api.setMessageReaction(REACT.success, messageID, () => {}, true); } catch (err) { api.setMessageReaction(REACT.error, messageID, () => {}, true); if (err.code === "SONG_TOO_LARGE") { return api.sendMessage("❌ | This video is over 25 MB, so Messenger cannot send it.", threadID, messageID); } return api.sendMessage(getLang("error"), threadID, messageID); } finally { await removeFile(filePath); } }