177 lines
5.7 KiB
JavaScript
177 lines
5.7 KiB
JavaScript
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'}`);
|
|
});
|