UNPKG

discord-media-server

Version:

Self-hosted Discord bot for scanning and serving information on local media files.

780 lines (671 loc) 26.8 kB
// lib/bot.js (ES module version using database.js directly) import { Client, GatewayIntentBits, EmbedBuilder, ButtonBuilder, ActionRowBuilder, ActivityType } from 'discord.js'; import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; import { setBotRunning } from './botStatus.js'; import runScanner from './scanner.js'; import { initDatabase, query, close, isSQLite } from './database.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); import { CONFIG_DIR, MEDIA_DIR, DATA_DIR } from './setupConfig.js'; const client = new Client({ intents: [ GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages, GatewayIntentBits.MessageContent ] }); const token = process.env.DISCORD_TOKEN; if (!token) { console.error('\nMissing DISCORD_TOKEN in .env\nPlease run setup first\n'); process.exit(0); } client.on('ready', async () => { try { await initDatabase(); // Initialize DB once here //console.log("Database ready."); } catch (err) { console.error("Failed to initialize DB:", err); process.exit(1); } console.log(`Logged in as ${client.user.tag}`); setBotRunning(true); client.user.setPresence({ activities: [{ name: ' for .help', type: ActivityType.Watching }], status: 'online' }); }); client.on('messageCreate', async (message) => { if (message.author.bot || !message.content.startsWith('.')) return; console.log(`${new Date().toLocaleString()} - ${message.author.tag}: ${message.content}`); const args = message.content.slice(1).trim().split(/ +/); const command = args.shift().toLowerCase(); if (command === 'm' || command === 'movie') { const { queryText, year } = parseMovieArgs(args); let searchText = queryText; try { let movie; let rows = []; const terms = []; if (queryText || year) { if (isSQLite) { // Use FTS for SQLite /*let sql = `SELECT mi.* FROM Movie_Search ms JOIN Movie_Info mi ON mi.id = ms.rowid WHERE ms MATCH ?`; const terms = [queryText]; */ // SQLite FTS with multi-word prefix search let sql = `SELECT mi.* FROM Movie_Search ms JOIN Movie_Info mi ON mi.id = ms.rowid WHERE 1=1`; //WHERE Movie_Search MATCH ?`; if (queryText) { // turn "lord rin" into "lord* AND rin*" const queryTokens = queryText .trim() .split(/\s+/) .map(t => `${t}*`); const ftsQuery = queryTokens.join(' AND '); sql += ` AND Movie_Search MATCH ?`; terms.push(ftsQuery); } //const terms = [ftsQuery]; if (year && year !== '0000') { sql += ` AND mi.year = ?`; terms.push(year); } sql += ` ORDER BY rank LIMIT 1`; rows = await query(sql, terms); } else { // MySQL FULLTEXT with multi-word prefix search //let sql = `SELECT * FROM Movie_Info // WHERE MATCH(title, year, filename) AGAINST(? IN BOOLEAN MODE)`; let sql = `SELECT * FROM Movie_Info WHERE 1=1`; if (queryText) { // turn "lord rin" into "+lord* +rin*" const queryTokens = queryText .trim() .split(/\s+/) .map(t => { if (/^(and|or)$/i.test(t)) { return t.toUpperCase(); // keep AND/OR if typed } return `+${t}*`; // require prefix match }); const fulltextQuery = queryTokens.join(' '); sql += ` AND MATCH(title, filename) AGAINST(? IN BOOLEAN MODE)`; terms.push(fulltextQuery); } if (year && year !== '0000') { sql += ` AND year = ?`; terms.push(year); } sql += ` ORDER BY title ASC LIMIT 1`; rows = await query(sql, terms); // Use FULLTEXT for MySQL /*let sql = `SELECT * FROM Movie_Info WHERE MATCH(title, year) AGAINST(? IN BOOLEAN MODE)`; const terms = [queryText + (year && year !== '0000' ? ` ${year}` : '')]; sql += ` ORDER BY title ASC LIMIT 1`; rows = await query(sql, terms); */ } } movie = rows[0]; if (!movie) { // fallback to random if nothing found const sql = `SELECT * FROM Movie_Info ORDER BY ${isSQLite ? 'RANDOM()' : 'RAND()'} LIMIT 1`; rows = await query(sql); movie = rows[0]; searchText = `${searchText ? searchText + '\n' : ''}No Search, Random movie chosen`; } if (!movie) { const embed = new EmbedBuilder() .setTitle(`❌ Missing Movies`) .setDescription(`No movies available`) .setFooter({ text: `${message.author.username}\n`, iconURL: message.author.displayAvatarURL({ dynamic: true }) }) .setTimestamp(); const reply = await message.channel.send({ embeds: [embed] }); setTimeout(() => reply.delete().catch(() => {}), 5 * 60 * 1000); } else { if (!queryText && year) { searchText = `Movie from Year: ${year}`; } else if (queryText && year) { searchText = `${searchText ? searchText + '\n' : ''}"${queryText}" (${year})`; } else if (queryText) { searchText = `${searchText ? searchText + '\n' : ''}"${queryText}"`; } else { searchText = `${searchText ? searchText + '\n' : ''}Random Movie`; } const embed = createMovieEmbed(movie, message.author, searchText); const reply = await message.channel.send({ embeds: [embed] }); setTimeout(() => reply.delete().catch(() => {}), 5 * 60 * 1000); } } catch (err) { console.error('Error retrieving movie:', err); } } /* ------- if (command === 'm' || command === 'movie') { const { queryText, year, limit } = parseMovieArgs(args); let searchText = queryText; try { if (isSQLite) { let movie; let sql = `SELECT * FROM Movie_Info WHERE 1=1`; let sqlArgs = []; if (queryText) { sql += ` AND title LIKE ?`; sqlArgs.push(`%${queryText}%`); } if (year) { sql += ` AND year = ?`; sqlArgs.push(year); searchText = queryText+' ('+year+')'.trim(); } sql += ` ORDER BY title ASC LIMIT 1`; let rows = await query(sql, sqlArgs); movie = rows[0]; if (!movie || (!queryText && !year)) { // fallback to random if no filters sql = `SELECT * FROM Movie_Info ORDER BY ${isSQLite ? 'RANDOM()' : 'RAND()'} LIMIT 1`; sqlArgs = []; rows = await query(sql, sqlArgs); movie = rows[0]; searchText = `${searchText ? searchText+'\n' : ''}No Result, Random movie chosen`; } if (!movie) { const embed = new EmbedBuilder() .setTitle(`❌ Movie Missing`) .setDescription(`No movie available.`) .setFooter({ text: `${message.author.username}\n${queryText}`, iconURL: message.author.displayAvatarURL({ dynamic: true }) }) .setTimestamp(); const reply = await message.channel.send({ embeds: [embed] }); setTimeout(() => reply.delete().catch(() => {}), 5 * 60 * 1000); } else { const embed = createMovieEmbed(movie, message.author, searchText); const reply = await message.channel.send({ embeds: [embed] }); setTimeout(() => reply.delete().catch(() => {}), 5 * 60 * 1000); } } } catch (err) { console.error('Error retrieving movie:', err); //message.reply('❌ Error retrieving movie.'); } } */ if (command === 'ml' || command === 'movie-list') { const { queryText, year, limit } = parseMovieArgs(args); let finalLimit = limit && limit <= 25 && limit > 0 ? limit : 10; try { let rows = []; const terms = []; let searchText = queryText; if (!queryText && !year) { // random movies const sql = `SELECT * FROM Movie_Info ORDER BY ${isSQLite ? 'RANDOM()' : 'RAND()'} LIMIT ?`; rows = await query(sql, [finalLimit]); } else if (isSQLite) { // SQLite FTS with multi-word prefix search let sql = `SELECT mi.* FROM Movie_Search ms JOIN Movie_Info mi ON mi.id = ms.rowid WHERE 1=1`; //WHERE Movie_Search MATCH ?`; if (queryText) { // turn "lord rin" into "lord* AND rin*" const queryTokens = queryText .trim() .split(/\s+/) .map(t => `${t}*`); const ftsQuery = queryTokens.join(' AND '); sql += ` AND Movie_Search MATCH ?`; terms.push(ftsQuery); } if (year && year !== '0000') { sql += ` AND mi.year = ?`; terms.push(year); } sql += ` ORDER BY rank LIMIT ?`; terms.push(finalLimit); rows = await query(sql, terms); // SQLite FTS /* let sql = `SELECT mi.* FROM Movie_Search ms JOIN Movie_Info mi ON mi.id = ms.rowid WHERE ms MATCH ?`; const terms = [queryText]; if (year && year !== '0000') { sql += ` AND mi.year = ?`; terms.push(year); } sql += ` ORDER BY rank LIMIT ?`; terms.push(finalLimit); rows = await query(sql, terms); */ } else { // MySQL FULLTEXT with multi-word prefix search //let sql = `SELECT * FROM Movie_Info // WHERE MATCH(title, year, filename) AGAINST(? IN BOOLEAN MODE)`; let sql = `SELECT * FROM Movie_Info WHERE 1=1`; if (queryText) { // turn "lord rin" into "+lord* +rin*" const queryTokens = queryText .trim() .split(/\s+/) .map(t => (/^(and|or)$/i.test(t) ? t.toUpperCase() : `+${t}*`)); /* .map(t => { if (/^(and|or)$/i.test(t)) { return t.toUpperCase(); // pass through AND/OR if user explicitly typed it } return `+${t}*`; // default = required prefix match }); */ const fulltextQuery = queryTokens.join(" "); sql += ` AND MATCH(title, filename) AGAINST(? IN BOOLEAN MODE)`; terms.push(fulltextQuery); } //const terms = [ // fulltextQuery + (year && year !== "0000" ? ` ${year}` : "") //]; if (year && year !== '0000') { sql += ` AND year = ?`; terms.push(year); } sql += ` ORDER BY title ASC LIMIT ?`; terms.push(finalLimit); rows = await query(sql, terms); // MySQL FULLTEXT /*let sql = `SELECT * FROM Movie_Info WHERE MATCH(title, year) AGAINST(? IN BOOLEAN MODE)`; const terms = [queryText + (year && year !== '0000' ? ` ${year}` : '')]; sql += ` ORDER BY title ASC LIMIT ?`; terms.push(finalLimit); rows = await query(sql, terms); */ } if (!rows.length) { const embed = new EmbedBuilder() .setTitle(`❌ Missing Movies`) .setDescription(`No matches for: '${searchText}'`) .setFooter({ text: `${message.author.username}\nNo movies available`, iconURL: message.author.displayAvatarURL({ dynamic: true }) }) .setTimestamp(); const reply = await message.channel.send({ embeds: [embed] }); setTimeout(() => reply.delete().catch(() => {}), 5 * 60 * 1000); } else { const embed = new EmbedBuilder() /* .setTitle( !queryText && !year ? `🎬 ${finalLimit != 10 ? finalLimit + ' ' : ''}Random Movies` : `🎬 Movies matching: "${searchText}"` ) */ .setTitle( !queryText && year ? `🎬 Movies from Year: ${year}` : queryText && year ? `🎬 Movies matching: "${searchText}" (${year})` : queryText ? `🎬 Movies matching: "${searchText}"` : `🎬 ${finalLimit != 10 ? finalLimit + ' ' : ''}Random Movies` ) .setDescription( rows .map( (m, i) => `**${i + 1}.** ${m.title} ${m.year === '0000' ? '' : '(' + m.year + ')'}\n` + ` - ${m.format} - ${formatBytes(m.filesize)}` + ` - ${m.year === '0000' ? `[IMDB](https://www.imdb.com/search/title/?title=${encodeURI(m.title)})` : `[IMDB](https://www.imdb.com/title/${m.imdb})`}` ) .join('\n\n') ) .setColor(0x2ecc71) .setFooter({ text: `${message.author.username}\n${searchText}`, iconURL: message.author.displayAvatarURL({ dynamic: true }) }) .setTimestamp(); const reply = await message.channel.send({ embeds: [embed] }); setTimeout(() => reply.delete().catch(() => {}), 5 * 60 * 1000); } } catch (err) { console.error('Error retrieving movie list:', err); } } /* if (command === 'ml' || command === 'movie-list') { const { queryText, year, limit } = parseMovieArgs(args); let finalLimit = 10; if (limit && (limit <= 25 && limit > 0)) { finalLimit = limit; } let sql; let sqlArgs = []; if (!queryText && !year) { // No args == random movies sql = `SELECT * FROM Movie_Info ORDER BY ${isSQLite ? 'RANDOM()' : 'RAND()'} LIMIT ?`; sqlArgs.push(finalLimit); } else { // Build search query sql = `SELECT * FROM Movie_Info WHERE 1=1`; if (queryText) { sql += ` AND title LIKE ?`; sqlArgs.push(`%${queryText}%`); } if (year) { sql += ` AND year = ?`; sqlArgs.push(year); } if (!queryText && year) { sql += ` ORDER BY ${isSQLite ? 'RANDOM()' : 'RAND()'} LIMIT ?`; } else { sql += ` ORDER BY title ASC LIMIT ?`; } sqlArgs.push(finalLimit); } try { let searchText = queryText; if (year) { searchText = queryText+' ('+year+')'.trim(); } const rows = await query(sql, sqlArgs); if (!rows.length) { const embed = new EmbedBuilder() .setTitle(`❌ Movies Missing`) .setDescription(`No movies are available.`) .setFooter({ text: `${message.author.username}\n${searchText}`, iconURL: message.author.displayAvatarURL({ dynamic: true }) }) .setTimestamp(); const reply = await message.channel.send({ embeds: [embed] }); //await message.delete().catch(() => {}); setTimeout(() => reply.delete().catch(() => {}), 5 * 60 * 1000); } else { const embed = new EmbedBuilder() .setTitle( !queryText && !year ? `🎬 ${finalLimit!=10?finalLimit+' ':''}Random Movies` : `🎬 Movies matching: "${searchText}"`.trim() ) .setDescription( rows .map((m, i) => `**${i + 1}.** ${m.title} ${m.year === '0000' ? '' : '('+m.year+')'}\n - ${m.format} - ${formatBytes(m.filesize)} - ${m.year === '0000' ? '[IMDB](https://www.imdb.com/search/title/?title='+encodeURI(m.title)+')\n' : '[IMDB](https://www.imdb.com/title/'+m.imdb+')\n'}`) .join('\n') ) .setColor(0x2ecc71) .setFooter({ text: `${message.author.username}\n${searchText}`, iconURL: message.author.displayAvatarURL({ dynamic: true }) }) .setTimestamp(); const reply = await message.channel.send({ embeds: [embed] }); //await message.delete().catch(() => {}); setTimeout(() => reply.delete().catch(() => {}), 5 * 60 * 1000); } } catch (err) { console.error('Error retrieving movie list:', err); //message.reply('❌ Error retrieving movie list.'); } } */ // scan movie command if (command === 'scan' || command === 'rescan') { if (!message.member.permissions.has('ManageGuild')) { return message.reply('You do not have permission to trigger a scan.'); } let typingInterval; try { const initialEmbed = new EmbedBuilder() .setTitle('🔍 Starting scan for new movies...') .setColor(0x2ecc71) .setFooter({ text: `Scanning movies...` }) .setTimestamp(); const reply = await message.channel.send({ embeds: [initialEmbed] }); if (message.channel.permissionsFor(message.client.user).has('ManageMessages')) { message.delete().catch(() => {}); } // Start periodic typing indicator typingInterval = setInterval(() => { message.channel.sendTyping().catch(() => {}); }, 8000); // Re-send every 8s to keep it alive const result = await runScanner(MEDIA_DIR, process.env.DB_NAME); clearInterval(typingInterval); // Stop when done const finalEmbed = new EmbedBuilder() .setTitle('✅ Scan Complete!') .setColor(0x2ecc71) .setDescription( `**Total movies:** ${result.totalFiles}\n` + `**Movies added:** ${result.newMovies}\n` + `**Movies updated:** ${result.updatedMovies}` ) .setFooter({ text: `Movie scan completed` }) .setTimestamp(); await reply.edit({ embeds: [finalEmbed] }); // Send new movies to a channel if (result.addedList.length > 0) { const channel = message.channel; if (channel?.isTextBased?.()) { const movieList = result.addedList.map((m, i) => { const index = i + 1; let msg = ''; if (m.year !== '0000' && m.imdb !== "") { msg = '**['+index+'. '+m.title+' ('+m.year+')](https://www.imdb.com/title/'+m.imdb+')**'; } else { msg = '**['+index+'. '+m.title+'](https://www.imdb.com/search/title/?title='+encodeURIComponent(m.title)+')**'; } return msg; }); /* const movieList = result.addedList.map((m, i) => { // 1. Clean the title once per movie const cleanTitle = m.title.replace("(", "").trim(); const index = i + 1; // 2. Return the formatted string if (m.year !== '0000') { return `**[`+index+`. `+cleanTitle+` (`+m.year+`)](www.imdb.com/title/`+m.imdb+`)**`; } else { // Note: You can use cleanTitle here too if you want both to be cleaned return `**[`+index+`. `+cleanTitle+`](www.imdb.com/search/title/?title=`+encodeURIComponent(m.title)+`)**`; } }); */ const chunks = chunkLines(movieList, 25); // 25 movies per page let currentPage = 0; // Build the initial embed const buildEmbed = (page) => new EmbedBuilder() .setTitle('🎬 '+result.newMovies+' New Movies Added') .setColor(0x2ecc71) .setDescription(chunks[page].join('\n')) .setFooter({ text: `Total Movies: ${result.totalFiles}\nPage ${page + 1} of ${chunks.length}` }) .setTimestamp(); // Build navigation buttons const getRow = () => new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId('prev') .setLabel('⬅ Prev') .setStyle(1) .setDisabled(currentPage === 0), new ButtonBuilder() .setCustomId('next') .setLabel('Next ➡') .setStyle(1) .setDisabled(currentPage === chunks.length - 1) ); // Send the first embed with buttons const sent = await message.channel.send({ embeds: [buildEmbed(currentPage)], components: [getRow()] }); // Set up button collector const collector = sent.createMessageComponentCollector({ //time: 60_000 // 1 minute }); collector.on('collect', async (interaction) => { if (interaction.customId === 'prev') currentPage--; if (interaction.customId === 'next') currentPage++; await interaction.update({ embeds: [buildEmbed(currentPage)], components: [getRow()] }); }); collector.on('end', async () => { // Disable buttons after timeout //await sent.edit({ components: [] }).catch(() => {}); }); } } setTimeout(() => reply.delete().catch(() => {}), 5 * 60 * 1000); } catch (err) { console.error('Scan failed:', err); //message.reply('Scan failed. Check server logs for details.'); await message.channel.send('Scan failed. Check server logs for details.'+err).catch(() => {}); } finally { // Clear it safely only if it was set if (typingInterval) clearInterval(typingInterval); } } // play media if (command === 'play') { const imdbId = args[0]; if (!imdbId) return message.reply('Usage: .play <imdb/tmdb id>'); const status = await fetch('http://localhost:4000/status').then(res => res.json()); if (status.streaming) { return message.reply('⚠️ A stream is already running. Use `.stop` first.'); } const rows = await query( `SELECT * FROM Movie_Info WHERE imdb = ? LIMIT 1`, [imdbId] ); const movie = rows[0]; if (!movie) return message.reply(`❌ No movie found for ID ${imdbId}`); const filePath = path.join(MEDIA_DIR, movie.filepath); const voiceChannel = process.env.VOICE_CHANNEL; const token = process.env.DISCORD_TOKEN; //if (!voiceChannel) return message.reply('❌ You must be in a voice channel.'); if (!fs.existsSync(filePath)) { return message.reply('❌ Media file not found on disk.'); } if (!voiceChannel) { return message.reply('❌ VOICE_CHANNEL is not configured.'); } console.log('Sending to selfbot:', { guildId: message.guild.id, channelId: voiceChannel, filePath, token: token ? 'Yes' : 'No' }); try { await fetch('http://localhost:4000/play', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ guildId: message.guild.id, channelId: voiceChannel, filePath, token }) }); message.reply(`🎬 Streaming **${movie.title}**`); } catch (err) { console.error(err); message.reply('❌ Failed to start stream.'); } } if (command === 'stop') { await fetch('http://localhost:4000/stop', { method: 'POST' }); message.reply('🛑 Stream stopped.'); } if (command === 'pause') { await fetch('http://localhost:4000/pause', { method: 'POST' }); message.reply('⏸️ Stream paused.'); } if (command === 'resume') { await fetch('http://localhost:4000/resume', { method: 'POST' }); message.reply('▶️ Stream resumed.'); } await message.delete().catch(() => {}); // delete users command }); function formatBytes(a, b = 2, k = 1024) { if (a === 0) return '0 Bytes'; const i = Math.floor(Math.log(a) / Math.log(k)); return `${(a / Math.pow(k, i)).toFixed(b)} ${['Bytes', 'KB', 'MB', 'GB', 'TB'][i]}`; } function parseMovieArgs(args) { let queryText = null; let year = null; let limit = null; let joined = args.join(' '); const yearMatch = joined.match(/-y\s+(\d{4})/i); if (yearMatch) { year = yearMatch[1]; joined = joined.replace(yearMatch[0], '').trim(); } let limitMatch = joined.match(/-l\s+(\d+)/i); if (limitMatch) { limit = parseInt(limitMatch[1], 10); joined = joined.replace(limitMatch[0], '').trim(); } let limitMatch2 = joined.match(/-(\d+)/i); if (limitMatch2) { limit = parseInt(limitMatch2[1], 10); joined = joined.replace(limitMatch2[0], '').trim(); } return { queryText:joined.trim(), year, limit }; } function createMovieEmbed(movie, user, search) { const embed = new EmbedBuilder() .setTitle(`${movie.title || 'Title'} ${movie.year != '0000' ? '('+movie.year+')' : ''}`) .setDescription(movie.overview || 'No overview available.') .setColor(0x2ecc71) .addFields( { name: 'Format', value: movie.format || '', inline: true }, { name: 'Filesize', value: formatBytes(movie.filesize) || 'N/A', inline: true }, { name: 'Rating', value: movie.rating || 'N/A', inline: true } ) .setFooter({ text: `${user.tag}\n${search}`, iconURL: user.displayAvatarURL({ dynamic: true }) }) .setTimestamp(); let posterURL = movie.poster_fallback || movie.posterURL; if (posterURL?.startsWith('http')) { embed.setThumbnail(posterURL); } else { posterURL = `http://localhost:3000/movies/${encodeURI(movie.posterURL)}`; embed.setThumbnail(posterURL); } return embed; } function chunkLines(lines, size = 10) { const result = []; for (let i = 0; i < lines.length; i += size) { result.push(lines.slice(i, i + size)); } return result; } process.on('SIGINT', async () => { console.log('\nBot turned off.'); setBotRunning(false); process.exit(0); }); client.login(token);