543 lines
22 KiB
JavaScript
543 lines
22 KiB
JavaScript
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'),
|
||
nsfw: z.preprocess((v) => v === 'on', z.boolean()).default(false),
|
||
});
|
||
|
||
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'),
|
||
nsfw: z.preprocess((v) => v === 'on', z.boolean()).default(false),
|
||
});
|
||
|
||
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'),
|
||
nsfw: z.preprocess((v) => v === 'on', z.boolean()).default(false),
|
||
});
|
||
|
||
// 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.post('/songs/bulk-visibility', csrfProtection, (req, res, next) => {
|
||
try {
|
||
const canSetVip = req.session.user.isVip || req.session.user.role === 'admin';
|
||
let visibility = req.body.visibility;
|
||
if (!VISIBILITY_VALUES.includes(visibility)) return res.status(400).json({ error: 'Invalid visibility' });
|
||
if (visibility === 'vip' && !canSetVip) return res.status(403).json({ error: 'VIP permission required' });
|
||
const songIds = [].concat(req.body['songIds[]'] || []).map(Number).filter(Boolean);
|
||
let updated = 0;
|
||
for (const songId of songIds) {
|
||
const song = songs.findById(songId);
|
||
if (!song || !canManage(song, req.session.user)) continue;
|
||
songs.update(songId, {
|
||
title: song.title, artist: song.artist,
|
||
album: song.album || null, genre: song.genre || null,
|
||
year: song.year ?? null, visibility,
|
||
});
|
||
updated++;
|
||
}
|
||
res.json({ ok: true, updated, visibility });
|
||
} 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,
|
||
isNsfw: parsed.data.nsfw,
|
||
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,
|
||
isNsfw: parsed.data.nsfw,
|
||
});
|
||
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,
|
||
isNsfw: parsed.data.nsfw,
|
||
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,
|
||
isNsfw: parsed.data.nsfw,
|
||
});
|
||
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;
|
||
};
|