const path = require('path'); const fs = require('fs'); const crypto = require('crypto'); const express = require('express'); const multer = require('multer'); const { z } = require('zod'); const { requireUser, requireAdmin, requireVip } = require('../middleware/auth'); const songs = require('../services/songs'); const playlists = require('../services/playlists'); const imageService = require('../services/image'); const mediaDir = process.env.MEDIA_DIR || path.join(__dirname, '..', '..', 'media'); const AUDIO_EXT = new Set(['.mp3', '.m4a', '.aac', '.ogg', '.oga', '.flac', '.wav', '.opus', '.webm']); const IMAGE_EXT = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif']); const storage = multer.diskStorage({ destination: (req, file, cb) => { const sub = file.fieldname === 'audio' ? 'audio' : 'covers'; cb(null, path.join(mediaDir, sub)); }, filename: (req, file, cb) => { const ext = path.extname(file.originalname).toLowerCase(); cb(null, `${crypto.randomUUID()}${ext}`); }, }); const upload = multer({ storage, limits: { fileSize: 100 * 1024 * 1024 }, // 100 MB per file fileFilter: (req, file, cb) => { const ext = path.extname(file.originalname).toLowerCase(); if (file.fieldname === 'audio' && !AUDIO_EXT.has(ext)) return cb(new Error('Unsupported audio format.')); if (file.fieldname === 'cover' && !IMAGE_EXT.has(ext)) return cb(new Error('Unsupported cover image format.')); cb(null, true); }, }); const songFields = upload.fields([{ name: 'audio', maxCount: 1 }, { name: 'cover', maxCount: 1 }]); const playlistFields = upload.fields([{ name: 'cover', maxCount: 1 }]); const VISIBILITY_VALUES = ['public', 'logged_in', 'vip', 'private']; const VISIBILITY_RANK = { public: 0, logged_in: 1, vip: 2, private: 3 }; // For uploads, all text fields are optional — we'll fill blanks from ID3 tags. // The edit form enforces title/artist via a stricter schema below. const songUploadSchema = z.object({ title: z.string().trim().max(200).optional().or(z.literal('')), artist: z.string().trim().max(200).optional().or(z.literal('')), album: z.string().trim().max(200).optional().or(z.literal('')), genre: z.string().trim().max(80).optional().or(z.literal('')), year: z.preprocess((v) => (v === '' || v == null ? null : Number(v)), z.number().int().min(1).max(9999).nullable().optional()), visibility: z.enum(VISIBILITY_VALUES).default('logged_in'), }); const songEditSchema = z.object({ title: z.string().trim().min(1).max(200), artist: z.string().trim().min(1).max(200), album: z.string().trim().max(200).optional().or(z.literal('')), genre: z.string().trim().max(80).optional().or(z.literal('')), year: z.preprocess((v) => (v === '' || v == null ? null : Number(v)), z.number().int().min(1).max(9999).nullable().optional()), visibility: z.enum(VISIBILITY_VALUES).default('logged_in'), }); const playlistSchema = z.object({ title: z.string().trim().min(1).max(200), description: z.string().trim().max(2000).optional().or(z.literal('')), visibility: z.enum(VISIBILITY_VALUES).default('logged_in'), }); // Parse embedded tags (ID3, Vorbis comments, etc.) from an audio file. // Returns a plain object; every field may be null if not present in the file. async function extractAudioMetadata(absPath) { const result = { duration: null, title: null, artist: null, album: null, genre: null, year: null, picture: null, }; try { const { parseFile } = await import('music-metadata'); const meta = await parseFile(absPath); if (meta.format?.duration) result.duration = Math.round(meta.format.duration); const c = meta.common || {}; if (c.title) result.title = String(c.title).trim() || null; if (c.artist) result.artist = String(c.artist).trim() || null; else if (Array.isArray(c.artists) && c.artists.length) result.artist = String(c.artists[0]).trim() || null; if (c.album) result.album = String(c.album).trim() || null; if (Array.isArray(c.genre) && c.genre.length) result.genre = String(c.genre[0]).trim() || null; if (c.year && Number.isFinite(Number(c.year))) result.year = Number(c.year); if (Array.isArray(c.picture) && c.picture.length) { const pic = c.picture[0]; if (pic && pic.data && pic.format) { result.picture = { data: pic.data, mime: String(pic.format) }; } } } catch (err) { console.warn('[admin] could not parse audio metadata:', err.message); } return result; } // Turn an image MIME type into a reasonable file extension. function extForMime(mime) { switch ((mime || '').toLowerCase()) { case 'image/jpeg': case 'image/jpg': return '.jpg'; case 'image/png': return '.png'; case 'image/webp': return '.webp'; case 'image/gif': return '.gif'; default: return '.jpg'; } } // Process any cover source (multer file or embedded picture buffer) into a // 1000×1000 JPEG and save it to media/covers/. Returns the relative path. // Pass { multerFile } or { pictureBuffer, pictureMime }. async function saveCover({ multerFile, pictureBuffer, pictureMime } = {}) { let tempAbs = null; let ownTemp = false; if (multerFile) { tempAbs = multerFile.path; // multer already wrote it to disk } else if (pictureBuffer) { // Write raw bytes to a temp file so sharp can read it const tempExt = extForMime(pictureMime); tempAbs = path.join(mediaDir, 'covers', `tmp-${crypto.randomUUID()}${tempExt}`); fs.writeFileSync(tempAbs, pictureBuffer); ownTemp = true; } else { return null; } const finalName = `${crypto.randomUUID()}.jpg`; const finalAbs = path.join(mediaDir, 'covers', finalName); try { await imageService.processCoverImage(tempAbs, finalAbs); } finally { // Always remove the temp/multer source file unlinkQuiet(tempAbs); } return path.posix.join('covers', finalName); } // Derive a fallback title from the uploaded filename (strip extension, tidy up). function titleFromFilename(originalName) { const base = path.basename(originalName, path.extname(originalName)); return base.replace(/[._-]+/g, ' ').trim() || 'Untitled'; } function relMedia(file, sub) { return path.posix.join(sub, path.basename(file.path)); } function unlinkQuiet(absPath) { fs.unlink(absPath, () => {}); } function canManage(item, user) { if (user.role === 'admin') return true; return (item.uploadedBy != null && item.uploadedBy === user.id) || (item.createdBy != null && item.createdBy === user.id); } function forbidden(next) { const err = new Error('Not authorized.'); err.status = 403; err.expose = true; next(err); } // Resolve the genre field from the genre select + optional "add new" text input. // genre_new (non-empty) wins; else use the select value unless it's the __new__ sentinel. function resolveGenre(body) { const newVal = (body.genre_new || '').trim(); const selVal = body.genre === '__new__' ? '' : (body.genre || ''); return { ...body, genre: newVal || selVal }; } module.exports = function adminRoutes(csrfProtection) { const router = express.Router(); router.use(requireVip); // ---------- Songs ---------- router.get('/', (req, res) => res.redirect('/mymusic/songs')); router.get('/songs', (req, res) => { const songList = songs.listByUser(req.session.user.id); const playlistList = playlists.listByUser(req.session.user.id); res.render('mymusic/songs', { title: 'My Music · Songs', songs: songList, playlists: playlistList }); }); router.post('/songs/bulk-add', csrfProtection, (req, res, next) => { try { const playlistId = Number(req.body.playlistId); const songIds = [].concat(req.body['songIds[]'] || []).map(Number).filter(Boolean); const playlist = playlists.findById(playlistId); if (!playlist || !canManage(playlist, req.session.user)) { return res.status(403).json({ error: 'Forbidden' }); } for (const songId of songIds) { const song = songs.findById(songId); if (!song) continue; playlists.addTrack(playlistId, songId); if (VISIBILITY_RANK[song.visibility] > VISIBILITY_RANK[playlist.visibility]) { playlists.setVisibility(playlistId, song.visibility); } } res.json({ ok: true, added: songIds.length }); } catch (err) { next(err); } }); router.get('/songs/new', (req, res) => { res.render('mymusic/song_form', { title: 'New song', song: null, errors: {}, values: { visibility: 'logged_in' }, genres: songs.listGenres() }); }); router.post('/songs', songFields, csrfProtection, async (req, res, next) => { try { const body = resolveGenre(req.body); const parsed = songUploadSchema.safeParse(body); if (!parsed.success || !req.files || !req.files.audio || !req.files.audio[0]) { const errors = {}; if (!parsed.success) for (const i of parsed.error.issues) errors[i.path[0]] = i.message; if (!req.files || !req.files.audio || !req.files.audio[0]) errors.audio = 'Audio file is required.'; if (req.files?.audio?.[0]) unlinkQuiet(req.files.audio[0].path); if (req.files?.cover?.[0]) unlinkQuiet(req.files.cover[0].path); return res.status(400).render('mymusic/song_form', { title: 'New song', song: null, errors, values: body, genres: songs.listGenres() }); } const audioFile = req.files.audio[0]; const coverFile = req.files.cover?.[0]; // Read embedded tags. Form values win; tags fill any blanks. const tags = await extractAudioMetadata(audioFile.path); const pick = (formVal, tagVal) => { const s = (formVal == null ? '' : String(formVal)).trim(); return s || tagVal || null; }; const merged = { title: pick(parsed.data.title, tags.title) || titleFromFilename(audioFile.originalname), artist: pick(parsed.data.artist, tags.artist) || 'Unknown Artist', album: pick(parsed.data.album, tags.album), genre: pick(parsed.data.genre, tags.genre), year: parsed.data.year ?? tags.year ?? null, }; // If the user didn't upload a cover but the file has embedded art, use it. let coverPath = null; if (coverFile) { try { coverPath = await saveCover({ multerFile: coverFile }); } catch (err) { console.warn('[admin] failed to process uploaded cover:', err.message); unlinkQuiet(coverFile.path); } } else if (tags.picture) { try { coverPath = await saveCover({ pictureBuffer: tags.picture.data, pictureMime: tags.picture.mime }); } catch (err) { console.warn('[admin] failed to write embedded cover:', err.message); } } const canSetVip = req.session.user.isVip || req.session.user.role === 'admin'; const visibility = parsed.data.visibility === 'vip' && !canSetVip ? 'logged_in' : parsed.data.visibility; songs.create({ title: merged.title, artist: merged.artist, album: merged.album, genre: merged.genre, year: merged.year, durationSeconds: tags.duration, audioPath: relMedia(audioFile, 'audio'), coverPath, visibility, uploadedBy: req.session.user.id, }); req.flash('success', 'Song uploaded. Tags were read from the file — review and edit if needed.'); res.redirect('/mymusic/songs'); } catch (err) { next(err); } }); router.get('/songs/:id/edit', (req, res, next) => { const song = songs.findById(Number(req.params.id)); if (!song) return next(); if (!canManage(song, req.session.user)) return forbidden(next); res.render('mymusic/song_form', { title: 'Edit song', song, errors: {}, values: { ...song }, genres: songs.listGenres(), }); }); router.post('/songs/:id', songFields, csrfProtection, async (req, res, next) => { try { const id = Number(req.params.id); const existing = songs.findById(id); if (!existing) return next(); if (!canManage(existing, req.session.user)) return forbidden(next); const body = resolveGenre(req.body); const parsed = songEditSchema.safeParse(body); if (!parsed.success) { const errors = {}; for (const i of parsed.error.issues) errors[i.path[0]] = i.message; if (req.files?.cover?.[0]) unlinkQuiet(req.files.cover[0].path); return res.status(400).render('mymusic/song_form', { title: 'Edit song', song: existing, errors, values: body, genres: songs.listGenres() }); } const canSetVip = req.session.user.isVip || req.session.user.role === 'admin'; const visibility = parsed.data.visibility === 'vip' && !canSetVip ? 'logged_in' : parsed.data.visibility; songs.update(id, { title: parsed.data.title, artist: parsed.data.artist, album: parsed.data.album || null, genre: parsed.data.genre || null, year: parsed.data.year ?? null, visibility, }); const coverFile = req.files?.cover?.[0]; if (coverFile) { if (existing.coverPath) unlinkQuiet(path.join(mediaDir, existing.coverPath)); try { const newCoverPath = await saveCover({ multerFile: coverFile }); songs.setCover(id, newCoverPath); } catch (err) { console.warn('[admin] failed to process cover on edit:', err.message); } } req.flash('success', 'Song updated.'); res.redirect('/mymusic/songs'); } catch (err) { next(err); } }); router.post('/songs/:id/delete', csrfProtection, (req, res, next) => { try { const id = Number(req.params.id); const existing = songs.findById(id); if (!existing) return next(); if (!canManage(existing, req.session.user)) return forbidden(next); songs.remove(id); // Best-effort cleanup of media. if (existing.audioPath) unlinkQuiet(path.join(mediaDir, existing.audioPath)); if (existing.coverPath) unlinkQuiet(path.join(mediaDir, existing.coverPath)); req.flash('success', 'Song deleted.'); res.redirect('/mymusic/songs'); } catch (err) { next(err); } }); // ---------- Playlists ---------- router.get('/playlists', (req, res) => { const playlistList = playlists.listByUser(req.session.user.id); res.render('mymusic/playlists', { title: 'My Music · Playlists', playlists: playlistList }); }); router.get('/playlists/new', (req, res) => { const allSongs = songs.listForManagement(req.session.user.id); const preselectIds = (req.query.songs || '').split(',').map(Number).filter(Boolean); res.render('mymusic/playlist_form', { title: 'New playlist', playlist: null, tracks: [], allSongs, errors: {}, values: { visibility: 'logged_in' }, preselectIds }); }); router.post('/playlists', playlistFields, csrfProtection, async (req, res, next) => { try { const user = req.session.user; const parsed = playlistSchema.safeParse(req.body); if (!parsed.success) { const errors = {}; for (const i of parsed.error.issues) errors[i.path[0]] = i.message; if (req.files?.cover?.[0]) unlinkQuiet(req.files.cover[0].path); const allSongs = songs.listForManagement(user.id); return res.status(400).render('mymusic/playlist_form', { title: 'New playlist', playlist: null, tracks: [], allSongs, errors, values: req.body, preselectIds: [] }); } const coverFile = req.files?.cover?.[0]; let coverPath = null; if (coverFile) { try { coverPath = await saveCover({ multerFile: coverFile }); } catch (err) { console.warn('[admin] failed to process playlist cover:', err.message); } } const canSetVip = req.session.user.isVip || req.session.user.role === 'admin'; const visibility = parsed.data.visibility === 'vip' && !canSetVip ? 'logged_in' : parsed.data.visibility; const created = playlists.create({ title: parsed.data.title, description: parsed.data.description || null, coverPath, visibility, createdBy: req.session.user.id, }); const preselectIds = [].concat(req.body['preselectIds[]'] || []).map(Number).filter(Boolean); for (const songId of preselectIds) { const song = songs.findById(songId); if (!song) continue; playlists.addTrack(created.id, songId); if (VISIBILITY_RANK[song.visibility] > VISIBILITY_RANK[created.visibility]) { playlists.setVisibility(created.id, song.visibility); } } req.flash('success', 'Playlist created.'); res.redirect(`/mymusic/playlists/${created.id}/edit`); } catch (err) { next(err); } }); router.get('/playlists/:id/edit', (req, res, next) => { const id = Number(req.params.id); const playlist = playlists.findById(id); if (!playlist) return next(); if (!canManage(playlist, req.session.user)) return forbidden(next); const allSongs = songs.listForManagement(req.session.user.id); res.render('mymusic/playlist_form', { title: 'Edit playlist', playlist, tracks: playlists.getTracks(id), allSongs, errors: {}, values: { ...playlist }, preselectIds: [], }); }); router.post('/playlists/:id', playlistFields, csrfProtection, async (req, res, next) => { try { const id = Number(req.params.id); const existing = playlists.findById(id); if (!existing) return next(); if (!canManage(existing, req.session.user)) return forbidden(next); const user = req.session.user; const parsed = playlistSchema.safeParse(req.body); if (!parsed.success) { const errors = {}; for (const i of parsed.error.issues) errors[i.path[0]] = i.message; if (req.files?.cover?.[0]) unlinkQuiet(req.files.cover[0].path); const allSongs = songs.listForManagement(user.id); return res.status(400).render('mymusic/playlist_form', { title: 'Edit playlist', playlist: existing, tracks: playlists.getTracks(id), allSongs, errors, values: req.body, }); } const canSetVip = req.session.user.isVip || req.session.user.role === 'admin'; const visibility = parsed.data.visibility === 'vip' && !canSetVip ? 'logged_in' : parsed.data.visibility; playlists.update(id, { title: parsed.data.title, description: parsed.data.description || null, visibility, }); const coverFile = req.files?.cover?.[0]; if (coverFile) { if (existing.coverPath) unlinkQuiet(path.join(mediaDir, existing.coverPath)); try { const newCoverPath = await saveCover({ multerFile: coverFile }); playlists.setCover(id, newCoverPath); } catch (err) { console.warn('[admin] failed to process playlist cover on edit:', err.message); } } req.flash('success', 'Playlist updated.'); res.redirect(`/mymusic/playlists/${id}/edit`); } catch (err) { next(err); } }); router.post('/playlists/:id/add-track', csrfProtection, (req, res, next) => { try { const id = Number(req.params.id); const songId = Number(req.body.song_id); const playlist = playlists.findById(id); const song = songs.findById(songId); if (!playlist || !song) return next(); if (!canManage(playlist, req.session.user)) return forbidden(next); playlists.addTrack(id, songId); // Auto-upgrade playlist visibility if the song is more restrictive. if (VISIBILITY_RANK[song.visibility] > VISIBILITY_RANK[playlist.visibility]) { playlists.setVisibility(id, song.visibility); } res.redirect(`/mymusic/playlists/${id}/edit`); } catch (err) { next(err); } }); router.post('/playlists/:id/remove-track', csrfProtection, (req, res, next) => { try { const id = Number(req.params.id); const songId = Number(req.body.song_id); const playlist = playlists.findById(id); if (!playlist) return next(); if (!canManage(playlist, req.session.user)) return forbidden(next); playlists.removeTrack(id, songId); res.redirect(`/mymusic/playlists/${id}/edit`); } catch (err) { next(err); } }); router.post('/playlists/:id/reorder', csrfProtection, (req, res, next) => { try { const id = Number(req.params.id); const playlist = playlists.findById(id); if (!playlist) return next(); if (!canManage(playlist, req.session.user)) return forbidden(next); const order = String(req.body.order || '') .split(',').map((x) => Number(x.trim())).filter(Number.isFinite); playlists.setOrder(id, order); res.redirect(`/mymusic/playlists/${id}/edit`); } catch (err) { next(err); } }); router.post('/playlists/:id/delete', csrfProtection, (req, res, next) => { try { const id = Number(req.params.id); const existing = playlists.findById(id); if (!existing) return next(); if (!canManage(existing, req.session.user)) return forbidden(next); playlists.remove(id); if (existing.coverPath) unlinkQuiet(path.join(mediaDir, existing.coverPath)); req.flash('success', 'Playlist deleted.'); res.redirect('/mymusic/playlists'); } catch (err) { next(err); } }); return router; };