discord-media-server
Version:
Self-hosted Discord bot for scanning and serving information on local media files.
455 lines (380 loc) • 14.6 kB
JavaScript
// lib/scanner.js (unified with database.js + supports SQLite/MySQL)
import fs from 'fs';
import path from 'path';
import axios from 'axios';
import dotenv from 'dotenv';
dotenv.config();
import { CONFIG_DIR, DATA_DIR } from './setupConfig.js';
import { writeFile, readFile } from 'fs/promises';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const envPath = path.join(CONFIG_DIR, '.env');
if (!fs.existsSync(envPath)) {
console.warn('No .env file found. Using environment variables only.');
}
//if (loaded.error) {
// console.error('\nFailed to load .env\nPlease run setup first\n\n', loaded.error);
// process.exit(1);
//}
const API_KEY = process.env.TMDB_API_KEY;
if (!API_KEY) {
console.error('Missing TMDB_API_KEY in .env');
//console.error(`Looked in: ${envPath}`);
//process.exit(1);
}
const MEDIA_DIR = process.env.MEDIA_DIR;
const DB_NAME = process.env.DB_NAME || 'media';
if (!MEDIA_DIR) {
console.error('\nMissing MEDIA_DIR in .env');
process.exit(1);
}
import { query, close, isSQLite, initDatabase, backupSQLite } from './database.js';
const CACHE_FILE = `${DB_NAME}.json`;
const cachePath = path.join(DATA_DIR, CACHE_FILE);
if (!fs.existsSync(cachePath)) {
console.warn('No .json cache file found. Creating one.');
}
const VIDEO_EXTENSIONS = ['.mp4', '.mkv', '.avi'];
function walk(dir, fileList = []) {
//let folder;
const files = fs.readdirSync(dir);
for (const file of files) {
const fullPath = path.join(dir, file);
const stat = fs.statSync(fullPath);
if (stat.isDirectory()) {
/*
folder = fullPath.replace(/\/$/, "").split("\\").pop();
if (!folder.startsWith('.')) {
console.log(' \\'+folder);
}
*/
walk(fullPath, fileList);
} else if (VIDEO_EXTENSIONS.includes(path.extname(file).toLowerCase())) {
console.log(' '+file);
fileList.push(fullPath);
}
}
return fileList;
}
function parseFileName(filename) {
const name = path.basename(filename, path.extname(filename));
const format = path.extname(filename).substring(1).toUpperCase();
// Normalize separators to spaces
const normalized = name.replace(/[\.\_]/g, ' ').replace(/\s+/g, ' ').trim();
const yearMatch = normalized.match(/\b(19\d{2}|20\d{2})\b/);
let year = '';
let title = '';
if (yearMatch) {
year = yearMatch[yearMatch.length - 1];
// Everything before the release year = title
title = normalized.split(year)[0].trim();
//year = yearMatch[0]; // Store the extracted year
//const yearIndex = normalized.indexOf(year); // Find the starting index of the year
// Extract the title as everything before the year
//title = normalized.substring(0, yearIndex).trim();
// Remove any trailing spaces or punctuation from the title
//title = title.replace(/[\s\(\)\.\-]+$/, '');
} else {
// If no year is found, consider the entire normalized string as the title
title = normalized;
}
title = title.replace("(", "").trim();
return { title, year, format };
}
async function fetchFromTMDb(title, year = null) {
try {
let release_year = '';
const params = {
api_key: API_KEY || '',
query: title || '',
...(year ? { year } : {})
};
const res = await axios.get('https://api.themoviedb.org/3/search/movie', { params });
const movie = res.data.results?.[0];
if (!movie) return null;
if (res.data.results.length == 1 && !year) {
year = movie.release_date?.substring(0, 4);
release_year = movie.release_date?.substring(0, 4) || '';
}
// Step 2: Get full details using the TMDB movie ID
const detailsRes = await axios.get(`https://api.themoviedb.org/3/movie/${movie.id}`, {
params: { api_key: API_KEY }
});
const details = detailsRes.data;
return {
tmdb: movie.id?.toString() || '',
imdb: details.imdb_id || '',
poster_fallback: movie.poster_path ? `https://image.tmdb.org/t/p/w500${movie.poster_path}` : '',
rating: movie.vote_average?.toFixed(1) || '',
runtime: details.runtime ? `${details.runtime} mins` : '',
overview: movie.overview || '',
year: year || '',
release: release_year || '',
backdrop: details.backdrop_path ? `https://image.tmdb.org/t/p/original${details.backdrop_path}` : '',
popularity: details.popularity || '',
realTitle: movie.title || ''
};
} catch (err) {
console.error(`TMDb error for "${title}" (${year || ''}):`, err.message);
return null;
}
}
// main scan function
export default async function runScanner(mediaDir = process.env.MEDIA_DIR, dbName = process.env.DB_NAME || 'media') {
if (!mediaDir) {
throw new Error('Missing MEDIA_DIR');
}
const startTime = Date.now(); // scan time start
await initDatabase(); // This is crucial for db to work!
await backupSQLite(); // backup the current db before scan
console.log(`\n${new Date().toLocaleString()} Run Scanner triggered\n`,"\x1b[1m");
console.log(`Scanning Directory: ${mediaDir}`,"\x1b[0m");
const files = walk(mediaDir);
const cached = await loadCache();
const currentList = [];
const added = [];
const updated = [];
const unchanged = [];
const removed = [];
const duplicates = [];
const possibleDuplicates = [];
const seenTitle = new Set();
const seenTitleYear = new Set();
const currentMap = new Map();
const realTitleClusters = new Map(); // realTitle => [filename1, filename2, ...]
console.log("\n","\x1b[1m");
console.log(`File Compare:`,"\x1b[0m");
for (const file of files) {
const filename = path.basename(file);
let { title, year, format } = parseFileName(filename);
if (!title) {
console.log(`* SKIPPING: ${title} - ${year} - ${filename}`);
continue;
}
if (!format) {
//console.log(`* SKIPPING: ${title} - ${year} - ${filename}`);
format = path.extname(filename).slice(1).toUpperCase();
}
const stat = fs.statSync(file);
//const mtime = fs.statSync(file).mtimeMs;
const relativePath = path.relative(mediaDir, file).replace(/\\/g, '/');
const folder = path.dirname(relativePath);
const baseName = path.basename(file, path.extname(file));
const relativePoster = `${folder}/${baseName}-poster.jpg`.replace(/\\/g, '/');
if (!year) {
year = '0000';
}
let meta = {};
if (API_KEY) {
meta = await fetchFromTMDb(title, year) || {};
}
let popularity = meta.popularity || '';
let backdrop = meta.backdrop || '';
// duplicate file check
const key = `${title}|${year}`;
if (seenTitleYear.has(key)) {
duplicates.push({ title, year: year, filename });
//console.log("\x1b[1;41m",`* DUPLICATE: ${title} (${year}) [${filename}]`,"\x1b[0m");
console.log(`* DUPLICATE: ${title} (${year}) [${filename}]`);
continue;
}
seenTitleYear.add(key);
// possible duplicates with same title
if (seenTitle.has(meta.realTitle || title)) {
if (!meta.release || year === '0000') {
possibleDuplicates.push({ title, filename });
//console.log("\x1b[0;41m",`* POSSIBLE DUPLICATE: ${title} [${filename}]`,"\x1b[0m");
console.log(`* POSSIBLE DUPLICATE: ${title} [${filename}]`);
}
}
seenTitle.add(meta.realTitle);
seenTitle.add(title);
// group possible duplicates
if (!realTitleClusters.has(title)) {
realTitleClusters.set(title, []);
}
realTitleClusters.get(title).push({
title,
year,
filename
});
// file data
const fileEntry = {
title: title,
year: year,
filename: filename,
filepath: relativePath,
poster: relativePoster,
format: format,
filesize: stat.size,
poster_fallback: meta.poster_fallback || '',
imdb: meta.imdb || '',
runtime: meta.runtime || '',
rating: meta.rating || '',
overview: meta.overview || ''
};
currentMap.set(key, fileEntry);
currentList.push(fileEntry);
const cachedEntry = cached.find(f => f.title === title && f.year === year);
if (!cachedEntry) {
added.push(fileEntry);
console.log("\x1b[42m",`ADDED: ${fileEntry.filename}`,"\x1b[0m");
} else if (
cachedEntry.filename !== filename ||
cachedEntry.filepath !== relativePath ||
cachedEntry.format !== format ||
cachedEntry.filesize !== stat.size
) {
updated.push(fileEntry);
console.log("\x1b[43m",`UPDATED: ${fileEntry.filename}`,"\x1b[0m");
} else {
unchanged.push(fileEntry);
console.log(`UNCHANGED: ${fileEntry.filename}`);
}
/*
// download image
if (meta.poster && !fs.existsSync(relativePoster)) {
console.log(`Downloading poster to ${relativePoster}`);
await downloadImage(meta.poster, relativePoster);
}
*/
} // end of movie for loop
// handle removed/missing files
for (const cachedEntry of cached) {
const key = `${cachedEntry.title}|${cachedEntry.year}`;
if (!currentMap.has(key)) {
removed.push(cachedEntry);
console.log("\x1b[101m", `REMOVED: ${cachedEntry.filename}`, "\x1b[0m");
}
}
console.log("\n","\x1b[1m");
console.log(`Database Operations:`,"\x1b[0m");
const scanTime = Date.now();
const scanDuration = (scanTime - startTime) / 1000; // file scan Time in seconds
// === Perform DB operations ===
for (const movie of added) {
try {
const result = await query(
`INSERT OR IGNORE INTO Movie_Info
(title, year, filename, poster, poster_fallback, filepath, filesize, imdb, format, runtime, rating, overview)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[movie.title, movie.year, movie.filename, movie.poster, movie.poster_fallback, movie.filepath, movie.filesize, movie.imdb, movie.format, movie.runtime, movie.rating, movie.overview]
);
if (result.changes === 1) {
console.log(`INSERTED: ${movie.filename}`);
//} else {
// console.log(` IGNORED: ${movie.filename}`);
}
} catch(err) {
console.log(`FAILED to insert: ${movie.filename}`, err.message);
}
}
for (const movie of updated) {
await query(
`UPDATE Movie_Info
SET filename = ?, format = ?, filesize = ?, filepath = ?, poster = ?
WHERE title = ? AND year = ?`,
[movie.filename, movie.format, movie.filesize, movie.filepath, movie.poster, movie.title, movie.year]
);
console.log(`UPDATED: ${movie.filename}`);
}
for (const movie of removed) {
await query(
`DELETE FROM Movie_Info WHERE filename = ?`,
[movie.filename]
);
console.log(`REMOVED: ${movie.filename}`);
}
await saveCache(currentList); // update cache file
const endTime = Date.now(); // Capture the end time and calculate the scan/db duration
const dbDuration = (endTime - scanTime) / 1000; // db actions time
const duration = (endTime - startTime) / 1000; // Time in seconds
console.log(`\nFile Scan ${scanDuration.toFixed(2)} seconds.`);
console.log(`DB Actions ${dbDuration.toFixed(2)} seconds.`);
console.log(`Completed in ${duration.toFixed(2)} seconds.`);
console.log("\n","\x1b[1;4m");
console.log(`Scan Summary:`,"\x1b[0m");
console.log(` Total scanned: ${files.length}`);
console.log(` Added: ${added.length}`);
console.log(` Updated: ${updated.length}`);
console.log(` Unchanged: ${unchanged.length}`);
console.log(` Removed: ${removed.length}`);
console.log(` Duplicates: ${duplicates.length}`);
console.log(` Possible Duplicates: ${possibleDuplicates.length}`);
try {
if (duplicates.length > 0) {
console.log("\n","\x1b[1;4m");
console.log('Duplicate Files Skipped:',"\x1b[0m");
duplicates.forEach(d => {
console.log(` - ${d.title} (${d.year}) [${d.filename}]`);
});
}
if (removed.length > 0) {
console.log("\n","\x1b[1;4m");
console.log('Removed Files:',"\x1b[0m");
removed.forEach(d => {
console.log(` - ${d.title} (${d.year}) - [${d.filename}]`);
});
}
if (possibleDuplicates.length > 0) {
console.log("\n","\x1b[1;4m");
console.log('Possible Duplicate Files:',"\x1b[0m");
possibleDuplicates.forEach(d => {
console.log(` - ${d.title} [${d.filename}]`);
});
console.log("\n","\x1b[4m");
console.log('Grouped by Real Title:',"\x1b[0m");
for (const [realTitle, files] of realTitleClusters.entries()) {
if (files.length < 2) continue; // only show clusters with >1
console.log(`"${realTitle}" (${files.length} files)`);
for (const file of files) {
console.log(` - ${file.title} (${file.year}) [${file.filename}]`);
}
console.log('');
}
}
} catch(err) {
console.log("Results error: ", err);
}
console.log(`\n${new Date().toLocaleString()} Scanner Finished\n`);
return {
totalFiles: files.length,
newMovies: added.length,
updatedMovies: updated.length,
removedMovies: removed.length,
addedList: added
};
}
// ========== JSON cache helpers ==========
async function loadCache() {
try {
const raw = await readFile(cachePath, 'utf8');
return JSON.parse(raw);
} catch {
return [];
}
}
async function saveCache(data) {
await writeFile(cachePath, JSON.stringify(data, null, 2), { encoding: 'utf8' });
}
const downloadImage = async (url, destPath) => {
const writer = fs.createWriteStream(destPath);
const response = await axios.get(url, { responseType: 'stream' });
response.data.pipe(writer);
return new Promise((resolve, reject) => {
writer.on('finish', resolve);
writer.on('error', reject);
});
};
// universal scan call (CLI only)
if (process.argv[1] === __filename) {
const MEDIA_DIR = process.env.MEDIA_DIR;
const DB_NAME = process.env.DB_NAME || 'media';
if (!MEDIA_DIR) {
console.error('\nMissing MEDIA_DIR in .env');
process.exit(1);
}
runScanner().catch(err => console.error('Scanner error:', err));
}