const express = require('express'); const multer = require('multer'); const path = require('path'); const fs = require('fs'); const http = require('http'); const app = express(); const PORT = process.env.PORT || 8080; const MEDIA_ROOT = process.env.MEDIA_ROOT || '/media'; const UPLOAD_PASSWORD = process.env.UPLOAD_PASSWORD || ''; const JELLYFIN_API_KEY = process.env.JELLYFIN_API_KEY || ''; const JELLYFIN_URL = process.env.JELLYFIN_URL || 'http://jellyfin:8096'; const MAX_FILE_SIZE = parseInt(process.env.MAX_FILE_SIZE) || 21474836480; // 20GB const TMP_DIR = path.join(MEDIA_ROOT, 'uploads', '.tmp'); const UNSORTED_DIR = path.join(MEDIA_ROOT, 'uploads', 'unsorted'); // Ensure dirs exist [TMP_DIR, UNSORTED_DIR].forEach(dir => fs.mkdirSync(dir, { recursive: true })); // Allowed extensions const AUDIO_EXT = new Set(['.mp3', '.flac', '.wav', '.aac', '.ogg', '.m4a', '.opus', '.wma', '.alac', '.ape']); const VIDEO_EXT = new Set(['.mkv', '.mp4', '.avi', '.mov', '.m4v', '.wmv', '.ts', '.webm', '.flv', '.mpg', '.mpeg']); const SUBTITLE_EXT = new Set(['.srt', '.ass', '.ssa', '.sub', '.idx', '.vtt']); const ALLOWED_EXT = new Set([...AUDIO_EXT, ...VIDEO_EXT, ...SUBTITLE_EXT]); // Multer disk storage to tmp dir const storage = multer.diskStorage({ destination: (req, file, cb) => cb(null, TMP_DIR), filename: (req, file, cb) => { const unique = Date.now() + '-' + Math.round(Math.random() * 1e6); cb(null, unique + '-' + file.originalname); } }); const upload = multer({ storage, limits: { fileSize: MAX_FILE_SIZE }, fileFilter: (req, file, cb) => { const ext = path.extname(file.originalname).toLowerCase(); if (!ALLOWED_EXT.has(ext)) { return cb(new Error(`File type ${ext} not allowed. Accepted: media files only.`)); } cb(null, true); } }); // Auth middleware function checkAuth(req, res, next) { if (!UPLOAD_PASSWORD) return next(); const pw = req.headers['x-upload-password'] || req.query.password; if (pw !== UPLOAD_PASSWORD) { return res.status(401).json({ error: 'Invalid password' }); } next(); } // Classify file and determine destination function classifyFile(filename) { const ext = path.extname(filename).toLowerCase(); const base = path.basename(filename, ext); if (AUDIO_EXT.has(ext)) { return { category: 'music', destination: path.join(MEDIA_ROOT, 'music') }; } if (VIDEO_EXT.has(ext) || SUBTITLE_EXT.has(ext)) { // Check for TV show patterns: S01E02, 1x02, etc. const tvMatch = base.match(/^(.+?)[.\s_-]+[Ss](\d{1,2})[Ee](\d{1,3})/); const tvMatch2 = base.match(/^(.+?)[.\s_-]+(\d{1,2})[xX](\d{1,3})/); const match = tvMatch || tvMatch2; if (match) { const showName = match[1].replace(/[._]/g, ' ').replace(/\s+/g, ' ').trim(); const season = parseInt(match[2]); const seasonDir = `Season ${String(season).padStart(2, '0')}`; const dest = path.join(MEDIA_ROOT, 'shows', showName, seasonDir); return { category: 'show', destination: dest, showName, season }; } // Default video → movies return { category: 'movie', destination: path.join(MEDIA_ROOT, 'movies') }; } return { category: 'unsorted', destination: UNSORTED_DIR }; } // Move file, falling back to copy+delete if rename fails (cross-device) async function moveFile(src, destDir, originalName) { await fs.promises.mkdir(destDir, { recursive: true }); const safeName = path.basename(originalName); const destPath = path.join(destDir, safeName); try { await fs.promises.rename(src, destPath); } catch (err) { if (err.code === 'EXDEV') { await fs.promises.copyFile(src, destPath); await fs.promises.unlink(src); } else { throw err; } } return destPath; } // Trigger Jellyfin library scan (fire-and-forget) function refreshJellyfin() { if (!JELLYFIN_API_KEY) return; const url = new URL('/Library/Refresh', JELLYFIN_URL); const req = http.request(url, { method: 'POST', headers: { 'X-Emby-Token': JELLYFIN_API_KEY }, timeout: 5000 }); req.on('error', () => {}); // swallow errors req.end(); } // Serve static files app.use(express.static(path.join(__dirname, 'public'))); // Upload endpoint app.post('/upload', checkAuth, upload.array('files', 50), async (req, res) => { if (!req.files || req.files.length === 0) { return res.status(400).json({ error: 'No files uploaded' }); } const results = []; let moved = 0; for (const file of req.files) { try { const classification = classifyFile(file.originalname); const finalPath = await moveFile(file.path, classification.destination, file.originalname); moved++; results.push({ filename: file.originalname, category: classification.category, destination: classification.destination.replace(MEDIA_ROOT, ''), size: file.size }); console.log(`[SORTED] ${file.originalname} → ${classification.category} (${classification.destination})`); } catch (err) { console.error(`[ERROR] ${file.originalname}: ${err.message}`); results.push({ filename: file.originalname, error: err.message }); } } if (moved > 0) refreshJellyfin(); res.json({ results }); }); // Error handler for multer app.use((err, req, res, next) => { if (err instanceof multer.MulterError) { if (err.code === 'LIMIT_FILE_SIZE') { return res.status(413).json({ error: `File too large. Max size: ${Math.round(MAX_FILE_SIZE / 1e9)}GB` }); } return res.status(400).json({ error: err.message }); } if (err) { return res.status(400).json({ error: err.message }); } next(); }); app.listen(PORT, () => { console.log(`Upload service listening on port ${PORT}`); console.log(`Media root: ${MEDIA_ROOT}`); console.log(`Auth: ${UPLOAD_PASSWORD ? 'enabled' : 'disabled'}`); });