discord-media-server
Version:
Self-hosted Discord bot for scanning and serving information on local media files.
383 lines (317 loc) • 12.7 kB
JavaScript
// lib/bot.js (ES module version using database.js directly)
import { Client, GatewayIntentBits, EmbedBuilder, ButtonBuilder, ActionRowBuilder } from 'discord.js';
import path from 'path';
import { fileURLToPath } from 'url';
import { setBotRunning } from './botStatus.js';
import runScanner from './scanner.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(1);
}
client.on('ready', () => {
console.log(`Logged in as ${client.user.tag}`);
setBotRunning(true);
});
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();
const dbModule = await import('./database.js');
await dbModule.initDatabase();
const { query, close, isSQLite } = dbModule;
if (command === 'm' || command === 'movie') {
const { queryText, year, limit } = parseMovieArgs(args);
let searchText = queryText;
try {
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 = 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) => `${m.year!='0000' ? '**['+(i + 1)+'. '+m.title+' ('+m.year+')](https://www.imdb.com/title/'+m.imdb+')**' : '**['+(i + 1)+'. '+m.title+'](https://www.imdb.com/search/title/?title='+encodeURI(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: `Page ${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.');
} finally {
// Clear it safely only if it was set
if (typingInterval) clearInterval(typingInterval);
}
}
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 (\d{4})/i);
if (yearMatch) {
year = yearMatch[1];
joined = joined.replace(yearMatch[0], '').trim();
}
let limitMatch = joined.match(/-l (\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, 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);