discord-media-server
Version:
Self-hosted Discord bot for scanning and serving information on local media files.
780 lines (671 loc) • 26.8 kB
JavaScript
// 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);