jellyfin-media/upload-service/server.js

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'}`);
});