first commit

This commit is contained in:
Brian Fertig 2026-04-09 18:41:01 -06:00
commit a515df90de
36 changed files with 3900 additions and 0 deletions

36
db/index.js Normal file
View File

@ -0,0 +1,36 @@
const path = require('path');
const fs = require('fs');
const Database = require('better-sqlite3');
const dbPath = process.env.DB_PATH || path.join(__dirname, '..', 'tunes.db');
const db = new Database(dbPath);
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
function runMigrations() {
db.exec(`CREATE TABLE IF NOT EXISTS _migrations (
name TEXT PRIMARY KEY,
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
)`);
const dir = path.join(__dirname, 'migrations');
const files = fs.readdirSync(dir).filter((f) => f.endsWith('.sql')).sort();
const applied = new Set(
db.prepare('SELECT name FROM _migrations').all().map((r) => r.name)
);
const insert = db.prepare('INSERT INTO _migrations (name) VALUES (?)');
for (const file of files) {
if (applied.has(file)) continue;
const sql = fs.readFileSync(path.join(dir, file), 'utf8');
const tx = db.transaction(() => {
db.exec(sql);
insert.run(file);
});
tx();
console.log(`[db] applied migration ${file}`);
}
}
module.exports = { db, runMigrations };

View File

@ -0,0 +1,45 @@
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
display_name TEXT NOT NULL,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user' CHECK(role IN ('user','admin')),
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS songs (
id INTEGER PRIMARY KEY,
title TEXT NOT NULL,
artist TEXT NOT NULL,
album TEXT,
genre TEXT,
year INTEGER,
duration_seconds INTEGER,
audio_path TEXT NOT NULL,
cover_path TEXT,
is_public INTEGER NOT NULL DEFAULT 1,
uploaded_by INTEGER REFERENCES users(id),
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_songs_artist ON songs(artist);
CREATE INDEX IF NOT EXISTS idx_songs_public ON songs(is_public);
CREATE INDEX IF NOT EXISTS idx_songs_created ON songs(created_at DESC);
CREATE TABLE IF NOT EXISTS playlists (
id INTEGER PRIMARY KEY,
title TEXT NOT NULL,
description TEXT,
cover_path TEXT,
is_public INTEGER NOT NULL DEFAULT 1,
created_by INTEGER REFERENCES users(id),
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_playlists_public ON playlists(is_public);
CREATE TABLE IF NOT EXISTS playlist_songs (
playlist_id INTEGER NOT NULL REFERENCES playlists(id) ON DELETE CASCADE,
song_id INTEGER NOT NULL REFERENCES songs(id) ON DELETE CASCADE,
position INTEGER NOT NULL,
PRIMARY KEY (playlist_id, song_id)
);
CREATE INDEX IF NOT EXISTS idx_playlist_songs_order ON playlist_songs(playlist_id, position);

2088
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

30
package.json Normal file
View File

@ -0,0 +1,30 @@
{
"name": "tunes",
"version": "0.1.0",
"private": true,
"description": "Self-contained music website with browse/listen + user accounts",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js"
},
"dependencies": {
"argon2": "^0.40.1",
"better-sqlite3": "^11.3.0",
"better-sqlite3-session-store": "^0.1.0",
"csrf-sync": "^4.0.3",
"dotenv": "^16.4.5",
"ejs": "^3.1.10",
"express": "^4.21.0",
"express-ejs-layouts": "^2.5.1",
"express-session": "^1.18.0",
"multer": "^2.1.1",
"music-metadata": "^11.12.3",
"pino": "^9.4.0",
"pino-http": "^10.3.0",
"zod": "^3.23.8"
},
"devDependencies": {
"nodemon": "^3.1.7"
}
}

160
public/css/app.css Normal file
View File

@ -0,0 +1,160 @@
:root {
--bg: #0f1115;
--bg-2: #171a21;
--bg-3: #1f232c;
--fg: #e6e8ee;
--muted: #8a92a4;
--accent: #6aa6ff;
--accent-2: #8f7bff;
--danger: #ff6a6a;
--border: #262b36;
--radius: 10px;
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Ubuntu, Cantarell, sans-serif;
background: var(--bg);
color: var(--fg);
line-height: 1.5;
padding-bottom: 120px;
}
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
.container { max-width: 1000px; margin: 0 auto; padding: 1rem 1.25rem; }
.site-header {
background: var(--bg-2);
border-bottom: 1px solid var(--border);
position: sticky; top: 0; z-index: 10;
}
.header-inner { display: flex; align-items: center; justify-content: space-between; padding: 0.75rem 1.25rem; max-width: 1000px; margin: 0 auto; }
.brand { font-weight: 700; font-size: 1.2rem; color: var(--fg); }
.nav { display: flex; gap: 1rem; align-items: center; }
.nav a { color: var(--muted); }
.nav a.active, .nav a:hover { color: var(--fg); }
.btn-small { background: var(--accent); color: #0b0d12 !important; padding: 0.35rem 0.75rem; border-radius: var(--radius); font-weight: 600; }
.btn-small:hover { text-decoration: none; background: var(--accent-2); }
.btn-primary {
background: var(--accent); color: #0b0d12; border: none;
padding: 0.55rem 1rem; border-radius: var(--radius); font-weight: 600; cursor: pointer;
}
.btn-primary:hover { background: var(--accent-2); }
.btn-link { color: var(--muted); margin-left: 0.75rem; }
button.linklike { background: none; border: none; color: var(--accent); padding: 0; cursor: pointer; font: inherit; }
button.linklike.danger { color: var(--danger); }
.inline-form { display: inline; margin-left: 0.5rem; }
.inline-row { display: flex; gap: 0.5rem; align-items: center; margin-top: 0.5rem; }
.hero { padding: 2rem 0 1rem; }
.hero h1 { margin: 0 0 0.25rem 0; font-size: 1.8rem; }
.muted { color: var(--muted); }
.small { font-size: 0.85rem; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 1rem; }
.card {
background: var(--bg-2); border: 1px solid var(--border); border-radius: var(--radius);
overflow: hidden; display: block; color: var(--fg);
}
.card:hover { border-color: var(--accent); text-decoration: none; }
.card img { width: 100%; aspect-ratio: 1 / 1; object-fit: cover; display: block; }
.cover-fallback {
aspect-ratio: 1 / 1; display: flex; align-items: center; justify-content: center;
background: linear-gradient(135deg, var(--bg-3), var(--bg-2));
font-size: 2.5rem; color: var(--muted);
}
.card-body { padding: 0.6rem 0.8rem; }
.card-title { font-weight: 600; }
.song-list { list-style: none; margin: 0; padding: 0; }
.song-row {
display: flex; align-items: center; gap: 0.75rem;
padding: 0.5rem 0.5rem; border-bottom: 1px solid var(--border);
}
.song-row:hover { background: var(--bg-2); }
.song-cover { width: 44px; height: 44px; border-radius: 6px; object-fit: cover; }
.song-cover.cover-fallback { font-size: 1.25rem; }
.song-main { flex: 1; min-width: 0; }
.song-title { display: block; color: var(--fg); font-weight: 500; }
.song-meta { width: 56px; text-align: right; }
.btn-play {
background: var(--bg-3); color: var(--fg); border: 1px solid var(--border);
padding: 0.35rem 0.7rem; border-radius: var(--radius); cursor: pointer;
}
.btn-play:hover { background: var(--accent); color: #0b0d12; border-color: var(--accent); }
.song-detail { display: flex; gap: 1.5rem; align-items: center; padding: 1rem 0 2rem; }
.song-detail-cover { width: 200px; height: 200px; border-radius: var(--radius); object-fit: cover; }
.form-card {
background: var(--bg-2); border: 1px solid var(--border); border-radius: var(--radius);
padding: 1.25rem 1.5rem; margin: 1rem 0; max-width: 560px;
}
.stacked { display: flex; flex-direction: column; gap: 0.9rem; }
.stacked label { display: flex; flex-direction: column; gap: 0.3rem; font-size: 0.9rem; color: var(--muted); }
.stacked input[type=text], .stacked input[type=email], .stacked input[type=password],
.stacked input[type=number], .stacked textarea, .stacked select, .stacked input[type=search] {
background: var(--bg-3); color: var(--fg); border: 1px solid var(--border);
border-radius: 8px; padding: 0.55rem 0.7rem; font: inherit;
}
.stacked input[type=file] { color: var(--muted); }
.stacked .row { flex-direction: row; gap: 0.75rem; align-items: center; }
.stacked .checkbox { flex-direction: row; align-items: center; gap: 0.5rem; }
.err { color: var(--danger); font-size: 0.8rem; }
.search-form { display: flex; gap: 0.5rem; align-items: center; margin: 1rem 0; }
.search-form input[type=search] {
flex: 1; background: var(--bg-2); color: var(--fg); border: 1px solid var(--border);
padding: 0.55rem 0.8rem; border-radius: var(--radius); font: inherit;
}
.search-form button {
background: var(--bg-3); color: var(--fg); border: 1px solid var(--border);
padding: 0.55rem 1rem; border-radius: var(--radius); cursor: pointer;
}
.pagination { display: flex; gap: 1rem; align-items: center; padding: 1rem 0; }
.flash { margin: 0.75rem auto; max-width: 1000px; padding: 0.6rem 1rem; border-radius: var(--radius); }
.flash-success { background: rgba(106, 255, 166, 0.1); color: #8af0b5; border: 1px solid rgba(106,255,166,0.3); }
.flash-error { background: rgba(255, 106, 106, 0.1); color: #ffb0b0; border: 1px solid rgba(255,106,106,0.3); }
.row-between { display: flex; justify-content: space-between; align-items: center; }
.tabs { display: flex; gap: 1rem; border-bottom: 1px solid var(--border); margin: 1rem 0; }
.tabs a { padding: 0.5rem 0; color: var(--muted); border-bottom: 2px solid transparent; }
.tabs a.active { color: var(--fg); border-color: var(--accent); }
.data-table { width: 100%; border-collapse: collapse; }
.data-table th, .data-table td { text-align: left; padding: 0.5rem 0.6rem; border-bottom: 1px solid var(--border); }
.data-table th { color: var(--muted); font-weight: 500; font-size: 0.85rem; }
.data-table .actions { white-space: nowrap; }
.track-list { padding-left: 1.2rem; }
.track-list li { margin: 0.35rem 0; display: flex; justify-content: space-between; gap: 1rem; }
.player-bar {
position: fixed; left: 0; right: 0; bottom: 0;
background: var(--bg-2); border-top: 1px solid var(--border);
padding: 0.6rem 1rem; display: flex; align-items: center; gap: 1rem;
z-index: 20;
}
.player-now { display: flex; align-items: center; gap: 0.75rem; min-width: 0; width: 260px; }
.player-now img { width: 44px; height: 44px; border-radius: 6px; object-fit: cover; background: var(--bg-3); }
.player-meta { min-width: 0; }
.player-meta #player-title { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
#player-audio { flex: 1; max-width: 100%; }
.player-queue { display: flex; align-items: center; gap: 0.5rem; }
#player-next { background: var(--bg-3); color: var(--fg); border: 1px solid var(--border); padding: 0.4rem 0.6rem; border-radius: 8px; cursor: pointer; }
@media (max-width: 720px) {
.player-bar { flex-wrap: wrap; }
.player-now { width: auto; flex: 1; }
#player-audio { width: 100%; order: 3; }
.song-detail { flex-direction: column; align-items: flex-start; }
.song-detail-cover { width: 100%; height: auto; max-width: 300px; }
}

82
public/js/player.js Normal file
View File

@ -0,0 +1,82 @@
(function () {
const audio = document.getElementById('player-audio');
const titleEl = document.getElementById('player-title');
const artistEl = document.getElementById('player-artist');
const coverEl = document.getElementById('player-cover');
const queueInfoEl = document.getElementById('player-queue-info');
const nextBtn = document.getElementById('player-next');
const state = {
queue: [], // array of {id, title, artist, cover}
index: -1,
};
function render() {
const cur = state.queue[state.index];
if (!cur) {
titleEl.textContent = 'Nothing playing';
artistEl.textContent = '';
coverEl.src = '/static/vendor/cover-placeholder.svg';
queueInfoEl.textContent = '';
return;
}
titleEl.textContent = cur.title;
artistEl.textContent = cur.artist || '';
coverEl.src = cur.cover || '/static/vendor/cover-placeholder.svg';
queueInfoEl.textContent = state.queue.length > 1
? `${state.index + 1} / ${state.queue.length}`
: '';
}
function playCurrent() {
const cur = state.queue[state.index];
if (!cur) return;
audio.src = `/stream/${cur.id}`;
audio.play().catch((err) => console.warn('play failed', err));
render();
}
function playOne(song) {
state.queue = [song];
state.index = 0;
playCurrent();
}
function playList(list) {
if (!Array.isArray(list) || list.length === 0) return;
state.queue = list;
state.index = 0;
playCurrent();
}
function next() {
if (state.index + 1 < state.queue.length) {
state.index += 1;
playCurrent();
}
}
audio.addEventListener('ended', next);
nextBtn.addEventListener('click', next);
// Delegated click handler for any [data-play-song] / [data-play-playlist] button.
document.addEventListener('click', (e) => {
const songBtn = e.target.closest('[data-play-song]');
if (songBtn) {
try {
const song = JSON.parse(songBtn.getAttribute('data-play-song'));
playOne(song);
} catch (err) { console.error(err); }
return;
}
const listBtn = e.target.closest('[data-play-playlist]');
if (listBtn) {
try {
const list = JSON.parse(listBtn.getAttribute('data-play-playlist'));
playList(list);
} catch (err) { console.error(err); }
}
});
render();
})();

1
public/vendor/cover-placeholder.svg vendored Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" fill="#1f232c"/><text x="50%" y="54%" font-family="sans-serif" font-size="32" fill="#8a92a4" text-anchor="middle" dominant-baseline="middle">♪</text></svg>

After

Width:  |  Height:  |  Size: 247 B

1
public/vendor/htmx.min.js vendored Normal file

File diff suppressed because one or more lines are too long

18
server.js Normal file
View File

@ -0,0 +1,18 @@
require('dotenv').config();
const path = require('path');
const fs = require('fs');
// Ensure media dirs exist before anything else.
const mediaDir = process.env.MEDIA_DIR || path.join(__dirname, 'media');
for (const sub of ['audio', 'covers']) {
fs.mkdirSync(path.join(mediaDir, sub), { recursive: true });
}
const { runMigrations } = require('./db');
runMigrations();
const app = require('./src/app');
const port = Number(process.env.PORT || 3000);
app.listen(port, () => {
console.log(`[tunes] listening on http://localhost:${port}`);
});

92
src/app.js Normal file
View File

@ -0,0 +1,92 @@
const path = require('path');
const express = require('express');
const session = require('express-session');
const expressLayouts = require('express-ejs-layouts');
const SqliteStoreFactory = require('better-sqlite3-session-store');
const { csrfSync } = require('csrf-sync');
const pinoHttp = require('pino-http');
const { db } = require('../db');
const SqliteStore = SqliteStoreFactory(session);
const app = express();
app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, 'views'));
app.set('layout', 'layout');
app.use(expressLayouts);
app.set('trust proxy', 1);
app.use(pinoHttp());
app.use(express.urlencoded({ extended: false }));
app.use(express.json());
// Static assets (CSS, JS, vendor).
app.use('/static', express.static(path.join(__dirname, '..', 'public'), { maxAge: '1h' }));
// Cover art is served statically; audio goes through /stream/:id for Range support + access control later.
const mediaDir = process.env.MEDIA_DIR || path.join(__dirname, '..', 'media');
app.use('/media/covers', express.static(path.join(mediaDir, 'covers'), { maxAge: '1d' }));
app.use(session({
store: new SqliteStore({
client: db,
expired: { clear: true, intervalMs: 15 * 60 * 1000 },
}),
secret: process.env.SESSION_SECRET || 'dev-insecure-secret-change-me',
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
sameSite: 'lax',
maxAge: 30 * 24 * 60 * 60 * 1000,
secure: false, // flip to true behind HTTPS
},
}));
const { csrfSynchronisedProtection, generateToken } = csrfSync({
getTokenFromRequest: (req) => req.body && req.body._csrf,
});
// Attach helpers for templates.
app.use((req, res, next) => {
res.locals.user = req.session.user || null;
res.locals.flash = req.session.flash || null;
delete req.session.flash;
res.locals.csrfToken = generateToken(req);
res.locals.currentPath = req.path;
next();
});
// Flash helper.
app.use((req, _res, next) => {
req.flash = (type, message) => {
req.session.flash = { type, message };
};
next();
});
// Routes.
app.use('/', require('./routes/public'));
app.use('/', require('./routes/auth')(csrfSynchronisedProtection));
app.use('/account', require('./routes/account')(csrfSynchronisedProtection));
app.use('/admin', require('./routes/admin')(csrfSynchronisedProtection));
// 404.
app.use((req, res) => {
res.status(404).render('error', { title: 'Not found', message: 'Page not found.' });
});
// Error handler.
// eslint-disable-next-line no-unused-vars
app.use((err, req, res, _next) => {
req.log ? req.log.error({ err }, 'request failed') : console.error(err);
const status = err.status || 500;
res.status(status).render('error', {
title: status === 403 ? 'Forbidden' : 'Error',
message: err.expose ? err.message : (status === 403 ? 'Forbidden.' : 'Something went wrong.'),
});
});
module.exports = app;

23
src/middleware/auth.js Normal file
View File

@ -0,0 +1,23 @@
function requireUser(req, res, next) {
if (!req.session.user) {
req.session.returnTo = req.originalUrl;
return res.redirect('/login');
}
next();
}
function requireAdmin(req, res, next) {
if (!req.session.user) {
req.session.returnTo = req.originalUrl;
return res.redirect('/login');
}
if (req.session.user.role !== 'admin') {
const err = new Error('Admin only.');
err.status = 403;
err.expose = true;
return next(err);
}
next();
}
module.exports = { requireUser, requireAdmin };

69
src/routes/account.js Normal file
View File

@ -0,0 +1,69 @@
const express = require('express');
const { z } = require('zod');
const users = require('../services/users');
const { requireUser } = require('../middleware/auth');
const profileSchema = z.object({
display_name: z.string().trim().min(1).max(80),
});
const passwordSchema = z.object({
current_password: z.string().min(1),
new_password: z.string().min(8).max(200),
});
module.exports = function accountRoutes(csrfProtection) {
const router = express.Router();
router.use(requireUser);
router.get('/', (req, res) => {
res.render('account/index', { title: 'Account', errors: {}, values: {} });
});
router.post('/', csrfProtection, (req, res, next) => {
try {
const parsed = profileSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).render('account/index', {
title: 'Account',
errors: { display_name: parsed.error.issues[0].message },
values: req.body,
});
}
const updated = users.updateDisplayName(req.session.user.id, parsed.data.display_name);
req.session.user = updated;
req.flash('success', 'Profile updated.');
res.redirect('/account');
} catch (err) { next(err); }
});
router.post('/password', csrfProtection, async (req, res, next) => {
try {
const parsed = passwordSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).render('account/index', {
title: 'Account',
errors: { new_password: 'Password must be at least 8 characters.' },
values: {},
});
}
const result = await users.changePassword(
req.session.user.id,
parsed.data.current_password,
parsed.data.new_password,
);
if (!result.ok) {
return res.status(400).render('account/index', {
title: 'Account',
errors: { current_password: 'Current password is incorrect.' },
values: {},
});
}
req.flash('success', 'Password changed.');
res.redirect('/account');
} catch (err) { next(err); }
});
return router;
};

288
src/routes/admin.js Normal file
View File

@ -0,0 +1,288 @@
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 { requireAdmin } = require('../middleware/auth');
const songs = require('../services/songs');
const playlists = require('../services/playlists');
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 songSchema = 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()),
is_public: z.any().optional(),
});
const playlistSchema = z.object({
title: z.string().trim().min(1).max(200),
description: z.string().trim().max(2000).optional().or(z.literal('')),
is_public: z.any().optional(),
});
async function extractDuration(absPath) {
try {
const { parseFile } = await import('music-metadata');
const meta = await parseFile(absPath);
if (meta.format && meta.format.duration) return Math.round(meta.format.duration);
} catch (err) {
console.warn('[admin] could not parse audio metadata:', err.message);
}
return null;
}
function relMedia(file, sub) {
return path.posix.join(sub, path.basename(file.path));
}
function unlinkQuiet(absPath) {
fs.unlink(absPath, () => {});
}
module.exports = function adminRoutes(csrfProtection) {
const router = express.Router();
router.use(requireAdmin);
// ---------- Songs ----------
router.get('/', (req, res) => res.redirect('/admin/songs'));
router.get('/songs', (req, res) => {
res.render('admin/songs', { title: 'Admin · Songs', songs: songs.listAll() });
});
router.get('/songs/new', (req, res) => {
res.render('admin/song_form', { title: 'New song', song: null, errors: {}, values: { is_public: 'on' } });
});
router.post('/songs', songFields, csrfProtection, async (req, res, next) => {
try {
const parsed = songSchema.safeParse(req.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.';
// Clean up any uploaded files on failure.
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('admin/song_form', { title: 'New song', song: null, errors, values: req.body });
}
const audioFile = req.files.audio[0];
const coverFile = req.files.cover?.[0];
const duration = await extractDuration(audioFile.path);
songs.create({
title: parsed.data.title,
artist: parsed.data.artist,
album: parsed.data.album || null,
genre: parsed.data.genre || null,
year: parsed.data.year ?? null,
durationSeconds: duration,
audioPath: relMedia(audioFile, 'audio'),
coverPath: coverFile ? relMedia(coverFile, 'covers') : null,
isPublic: !!parsed.data.is_public,
uploadedBy: req.session.user.id,
});
req.flash('success', 'Song uploaded.');
res.redirect('/admin/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();
res.render('admin/song_form', {
title: 'Edit song', song, errors: {},
values: { ...song, is_public: song.isPublic ? 'on' : '' },
});
});
router.post('/songs/:id', csrfProtection, (req, res, next) => {
try {
const id = Number(req.params.id);
const existing = songs.findById(id);
if (!existing) return next();
const parsed = songSchema.safeParse(req.body);
if (!parsed.success) {
const errors = {};
for (const i of parsed.error.issues) errors[i.path[0]] = i.message;
return res.status(400).render('admin/song_form', { title: 'Edit song', song: existing, errors, values: req.body });
}
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,
isPublic: !!parsed.data.is_public,
});
req.flash('success', 'Song updated.');
res.redirect('/admin/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();
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('/admin/songs');
} catch (err) { next(err); }
});
// ---------- Playlists ----------
router.get('/playlists', (req, res) => {
res.render('admin/playlists', { title: 'Admin · Playlists', playlists: playlists.listAll() });
});
router.get('/playlists/new', (req, res) => {
res.render('admin/playlist_form', { title: 'New playlist', playlist: null, tracks: [], allSongs: songs.listAll(), errors: {}, values: { is_public: 'on' } });
});
router.post('/playlists', playlistFields, csrfProtection, (req, res, next) => {
try {
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);
return res.status(400).render('admin/playlist_form', { title: 'New playlist', playlist: null, tracks: [], allSongs: songs.listAll(), errors, values: req.body });
}
const coverFile = req.files?.cover?.[0];
const created = playlists.create({
title: parsed.data.title,
description: parsed.data.description || null,
coverPath: coverFile ? relMedia(coverFile, 'covers') : null,
isPublic: !!parsed.data.is_public,
createdBy: req.session.user.id,
});
req.flash('success', 'Playlist created.');
res.redirect(`/admin/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();
res.render('admin/playlist_form', {
title: 'Edit playlist',
playlist,
tracks: playlists.getTracks(id),
allSongs: songs.listAll(),
errors: {},
values: { ...playlist, is_public: playlist.isPublic ? 'on' : '' },
});
});
router.post('/playlists/:id', playlistFields, csrfProtection, (req, res, next) => {
try {
const id = Number(req.params.id);
const existing = playlists.findById(id);
if (!existing) return next();
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);
return res.status(400).render('admin/playlist_form', {
title: 'Edit playlist', playlist: existing,
tracks: playlists.getTracks(id), allSongs: songs.listAll(),
errors, values: req.body,
});
}
playlists.update(id, {
title: parsed.data.title,
description: parsed.data.description || null,
isPublic: !!parsed.data.is_public,
});
const coverFile = req.files?.cover?.[0];
if (coverFile) {
if (existing.coverPath) unlinkQuiet(path.join(mediaDir, existing.coverPath));
playlists.setCover(id, relMedia(coverFile, 'covers'));
}
req.flash('success', 'Playlist updated.');
res.redirect(`/admin/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);
if (!playlists.findById(id) || !songs.findById(songId)) return next();
playlists.addTrack(id, songId);
res.redirect(`/admin/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);
playlists.removeTrack(id, songId);
res.redirect(`/admin/playlists/${id}/edit`);
} catch (err) { next(err); }
});
router.post('/playlists/:id/reorder', csrfProtection, (req, res, next) => {
try {
const id = Number(req.params.id);
const order = String(req.body.order || '')
.split(',').map((x) => Number(x.trim())).filter(Number.isFinite);
playlists.setOrder(id, order);
res.redirect(`/admin/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();
playlists.remove(id);
if (existing.coverPath) unlinkQuiet(path.join(mediaDir, existing.coverPath));
req.flash('success', 'Playlist deleted.');
res.redirect('/admin/playlists');
} catch (err) { next(err); }
});
return router;
};

80
src/routes/auth.js Normal file
View File

@ -0,0 +1,80 @@
const express = require('express');
const { z } = require('zod');
const users = require('../services/users');
const registerSchema = z.object({
email: z.string().email().max(200),
display_name: z.string().trim().min(1).max(80),
password: z.string().min(8).max(200),
});
const loginSchema = z.object({
email: z.string().email().max(200),
password: z.string().min(1).max(200),
});
module.exports = function authRoutes(csrfProtection) {
const router = express.Router();
router.get('/register', (req, res) => {
if (req.session.user) return res.redirect('/');
res.render('auth/register', { title: 'Register', values: {}, errors: {} });
});
router.post('/register', csrfProtection, async (req, res, next) => {
try {
const parsed = registerSchema.safeParse(req.body);
if (!parsed.success) {
const errors = {};
for (const issue of parsed.error.issues) errors[issue.path[0]] = issue.message;
return res.status(400).render('auth/register', { title: 'Register', values: req.body, errors });
}
if (users.findByEmail(parsed.data.email)) {
return res.status(400).render('auth/register', {
title: 'Register', values: req.body, errors: { email: 'Email already registered.' },
});
}
const user = await users.createUser({
email: parsed.data.email,
displayName: parsed.data.display_name,
password: parsed.data.password,
});
req.session.user = user;
req.flash('success', `Welcome, ${user.displayName}!`);
res.redirect('/');
} catch (err) {
next(err);
}
});
router.get('/login', (req, res) => {
if (req.session.user) return res.redirect('/');
res.render('auth/login', { title: 'Log in', values: {}, error: null });
});
router.post('/login', csrfProtection, async (req, res, next) => {
try {
const parsed = loginSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).render('auth/login', { title: 'Log in', values: req.body, error: 'Invalid input.' });
}
const user = await users.verifyCredentials(parsed.data.email, parsed.data.password);
if (!user) {
return res.status(401).render('auth/login', { title: 'Log in', values: req.body, error: 'Invalid email or password.' });
}
req.session.user = user;
const target = req.session.returnTo || '/';
delete req.session.returnTo;
req.flash('success', `Welcome back, ${user.displayName}.`);
res.redirect(target);
} catch (err) {
next(err);
}
});
router.post('/logout', csrfProtection, (req, res) => {
req.session.destroy(() => res.redirect('/'));
});
return router;
};

110
src/routes/public.js Normal file
View File

@ -0,0 +1,110 @@
const path = require('path');
const fs = require('fs');
const express = require('express');
const songs = require('../services/songs');
const playlists = require('../services/playlists');
const router = express.Router();
const mediaDir = process.env.MEDIA_DIR || path.join(__dirname, '..', '..', 'media');
// Fallback mime lookup without a new dep — basic extension map.
const MIME = {
'.mp3': 'audio/mpeg', '.m4a': 'audio/mp4', '.aac': 'audio/aac',
'.ogg': 'audio/ogg', '.oga': 'audio/ogg', '.opus': 'audio/ogg',
'.flac': 'audio/flac', '.wav': 'audio/wav', '.webm': 'audio/webm',
};
router.get('/', (req, res) => {
res.render('public/home', {
title: 'Tunes',
featured: playlists.featured(6),
recent: songs.recentPublic(8),
});
});
router.get('/songs', (req, res) => {
const page = Math.max(1, Number(req.query.page) || 1);
const pageSize = 25;
const { rows, total } = songs.listPublic({
q: req.query.q || '',
limit: pageSize,
offset: (page - 1) * pageSize,
});
res.render('public/songs', {
title: 'Songs',
songs: rows,
q: req.query.q || '',
page,
pageSize,
total,
totalPages: Math.max(1, Math.ceil(total / pageSize)),
});
});
router.get('/songs/:id', (req, res, next) => {
const song = songs.findById(Number(req.params.id));
if (!song || !song.isPublic) return next();
res.render('public/song', { title: song.title, song });
});
router.get('/playlists', (req, res) => {
res.render('public/playlists', { title: 'Playlists', playlists: playlists.listPublic() });
});
router.get('/playlists/:id', (req, res, next) => {
const id = Number(req.params.id);
const playlist = playlists.findById(id);
if (!playlist || !playlist.isPublic) return next();
res.render('public/playlist', {
title: playlist.title,
playlist,
tracks: playlists.getTracks(id),
});
});
// Audio streaming with HTTP Range support.
router.get('/stream/:id', (req, res, next) => {
const song = songs.findById(Number(req.params.id));
if (!song || !song.isPublic) return next();
const absPath = path.join(mediaDir, song.audioPath);
fs.stat(absPath, (err, stat) => {
if (err) return next();
const size = stat.size;
const ext = path.extname(absPath).toLowerCase();
const contentType = MIME[ext] || 'application/octet-stream';
const range = req.headers.range;
if (!range) {
res.writeHead(200, {
'Content-Type': contentType,
'Content-Length': size,
'Accept-Ranges': 'bytes',
'Cache-Control': 'no-cache',
});
return fs.createReadStream(absPath).pipe(res);
}
const match = /^bytes=(\d*)-(\d*)$/.exec(range);
if (!match) {
res.writeHead(416, { 'Content-Range': `bytes */${size}` });
return res.end();
}
let start = match[1] === '' ? 0 : parseInt(match[1], 10);
let end = match[2] === '' ? size - 1 : parseInt(match[2], 10);
if (isNaN(start) || isNaN(end) || start > end || end >= size) {
res.writeHead(416, { 'Content-Range': `bytes */${size}` });
return res.end();
}
res.writeHead(206, {
'Content-Range': `bytes ${start}-${end}/${size}`,
'Accept-Ranges': 'bytes',
'Content-Length': end - start + 1,
'Content-Type': contentType,
'Cache-Control': 'no-cache',
});
fs.createReadStream(absPath, { start, end }).pipe(res);
});
});
module.exports = router;

130
src/services/playlists.js Normal file
View File

@ -0,0 +1,130 @@
const { db } = require('../../db');
const insertStmt = db.prepare(`
INSERT INTO playlists (title, description, cover_path, is_public, created_by)
VALUES (@title, @description, @cover_path, @is_public, @created_by)
`);
const updateStmt = db.prepare(`
UPDATE playlists SET title = @title, description = @description, is_public = @is_public
WHERE id = @id
`);
const updateCoverStmt = db.prepare('UPDATE playlists SET cover_path = ? WHERE id = ?');
const deleteStmt = db.prepare('DELETE FROM playlists WHERE id = ?');
const findByIdStmt = db.prepare('SELECT * FROM playlists WHERE id = ?');
const listPublicStmt = db.prepare('SELECT * FROM playlists WHERE is_public = 1 ORDER BY created_at DESC');
const listAllStmt = db.prepare('SELECT * FROM playlists ORDER BY created_at DESC');
const featuredStmt = db.prepare('SELECT * FROM playlists WHERE is_public = 1 ORDER BY created_at DESC LIMIT ?');
const tracksStmt = db.prepare(`
SELECT s.*, ps.position FROM playlist_songs ps
JOIN songs s ON s.id = ps.song_id
WHERE ps.playlist_id = ?
ORDER BY ps.position ASC, s.title ASC
`);
const maxPosStmt = db.prepare('SELECT COALESCE(MAX(position), 0) AS m FROM playlist_songs WHERE playlist_id = ?');
const insertTrackStmt = db.prepare('INSERT OR IGNORE INTO playlist_songs (playlist_id, song_id, position) VALUES (?, ?, ?)');
const removeTrackStmt = db.prepare('DELETE FROM playlist_songs WHERE playlist_id = ? AND song_id = ?');
const clearTracksStmt = db.prepare('DELETE FROM playlist_songs WHERE playlist_id = ?');
const countStmt = db.prepare('SELECT COUNT(*) AS c FROM playlist_songs WHERE playlist_id = ?');
function toView(row) {
if (!row) return null;
return {
id: row.id,
title: row.title,
description: row.description,
coverPath: row.cover_path,
isPublic: !!row.is_public,
createdBy: row.created_by,
createdAt: row.created_at,
};
}
function trackView(row) {
return {
id: row.id,
title: row.title,
artist: row.artist,
album: row.album,
durationSeconds: row.duration_seconds,
coverPath: row.cover_path,
position: row.position,
};
}
function create(data) {
const info = insertStmt.run({
title: data.title,
description: data.description || null,
cover_path: data.coverPath || null,
is_public: data.isPublic ? 1 : 0,
created_by: data.createdBy || null,
});
return toView(findByIdStmt.get(info.lastInsertRowid));
}
function update(id, data) {
updateStmt.run({
id,
title: data.title,
description: data.description || null,
is_public: data.isPublic ? 1 : 0,
});
return toView(findByIdStmt.get(id));
}
function setCover(id, coverPath) {
updateCoverStmt.run(coverPath, id);
}
function remove(id) {
deleteStmt.run(id);
}
function findById(id) {
return toView(findByIdStmt.get(id));
}
function listPublic() {
return listPublicStmt.all().map(toView);
}
function listAll() {
return listAllStmt.all().map(toView);
}
function featured(n = 6) {
return featuredStmt.all(n).map(toView);
}
function getTracks(playlistId) {
return tracksStmt.all(playlistId).map(trackView);
}
function trackCount(playlistId) {
return countStmt.get(playlistId).c;
}
function addTrack(playlistId, songId) {
const nextPos = maxPosStmt.get(playlistId).m + 1;
insertTrackStmt.run(playlistId, songId, nextPos);
}
function removeTrack(playlistId, songId) {
removeTrackStmt.run(playlistId, songId);
}
function setOrder(playlistId, songIds) {
const tx = db.transaction(() => {
clearTracksStmt.run(playlistId);
songIds.forEach((songId, idx) => insertTrackStmt.run(playlistId, songId, idx + 1));
});
tx();
}
module.exports = {
create, update, setCover, remove, findById,
listPublic, listAll, featured,
getTracks, trackCount, addTrack, removeTrack, setOrder,
};

115
src/services/songs.js Normal file
View File

@ -0,0 +1,115 @@
const { db } = require('../../db');
const insertStmt = db.prepare(`
INSERT INTO songs (title, artist, album, genre, year, duration_seconds, audio_path, cover_path, is_public, uploaded_by)
VALUES (@title, @artist, @album, @genre, @year, @duration_seconds, @audio_path, @cover_path, @is_public, @uploaded_by)
`);
const updateStmt = db.prepare(`
UPDATE songs SET title = @title, artist = @artist, album = @album, genre = @genre, year = @year, is_public = @is_public
WHERE id = @id
`);
const updateCoverStmt = db.prepare('UPDATE songs SET cover_path = ? WHERE id = ?');
const deleteStmt = db.prepare('DELETE FROM songs WHERE id = ?');
const findByIdStmt = db.prepare('SELECT * FROM songs WHERE id = ?');
const listPublicStmt = db.prepare(`
SELECT * FROM songs WHERE is_public = 1
ORDER BY created_at DESC LIMIT ? OFFSET ?
`);
const countPublicStmt = db.prepare('SELECT COUNT(*) AS c FROM songs WHERE is_public = 1');
const searchPublicStmt = db.prepare(`
SELECT * FROM songs
WHERE is_public = 1 AND (title LIKE @q OR artist LIKE @q OR album LIKE @q)
ORDER BY created_at DESC LIMIT @limit OFFSET @offset
`);
const countSearchPublicStmt = db.prepare(`
SELECT COUNT(*) AS c FROM songs
WHERE is_public = 1 AND (title LIKE @q OR artist LIKE @q OR album LIKE @q)
`);
const listAllStmt = db.prepare('SELECT * FROM songs ORDER BY created_at DESC');
const recentPublicStmt = db.prepare('SELECT * FROM songs WHERE is_public = 1 ORDER BY created_at DESC LIMIT ?');
function toView(row) {
if (!row) return null;
return {
id: row.id,
title: row.title,
artist: row.artist,
album: row.album,
genre: row.genre,
year: row.year,
durationSeconds: row.duration_seconds,
audioPath: row.audio_path,
coverPath: row.cover_path,
isPublic: !!row.is_public,
uploadedBy: row.uploaded_by,
createdAt: row.created_at,
};
}
function create(data) {
const info = insertStmt.run({
title: data.title,
artist: data.artist,
album: data.album || null,
genre: data.genre || null,
year: data.year || null,
duration_seconds: data.durationSeconds || null,
audio_path: data.audioPath,
cover_path: data.coverPath || null,
is_public: data.isPublic ? 1 : 0,
uploaded_by: data.uploadedBy || null,
});
return toView(findByIdStmt.get(info.lastInsertRowid));
}
function update(id, data) {
updateStmt.run({
id,
title: data.title,
artist: data.artist,
album: data.album || null,
genre: data.genre || null,
year: data.year || null,
is_public: data.isPublic ? 1 : 0,
});
return toView(findByIdStmt.get(id));
}
function setCover(id, coverPath) {
updateCoverStmt.run(coverPath, id);
}
function remove(id) {
const row = findByIdStmt.get(id);
deleteStmt.run(id);
return row ? toView(row) : null;
}
function findById(id) {
return toView(findByIdStmt.get(id));
}
function listPublic({ q, limit = 25, offset = 0 }) {
if (q && q.trim()) {
const like = `%${q.trim()}%`;
const rows = searchPublicStmt.all({ q: like, limit, offset });
const total = countSearchPublicStmt.get({ q: like }).c;
return { rows: rows.map(toView), total };
}
const rows = listPublicStmt.all(limit, offset);
const total = countPublicStmt.get().c;
return { rows: rows.map(toView), total };
}
function listAll() {
return listAllStmt.all().map(toView);
}
function recentPublic(n = 8) {
return recentPublicStmt.all(n).map(toView);
}
module.exports = { create, update, setCover, remove, findById, listPublic, listAll, recentPublic };

68
src/services/users.js Normal file
View File

@ -0,0 +1,68 @@
const argon2 = require('argon2');
const { db } = require('../../db');
const insertStmt = db.prepare(
`INSERT INTO users (email, display_name, password_hash, role) VALUES (?, ?, ?, ?)`
);
const findByEmailStmt = db.prepare('SELECT * FROM users WHERE email = ?');
const findByIdStmt = db.prepare('SELECT * FROM users WHERE id = ?');
const updateNameStmt = db.prepare('UPDATE users SET display_name = ? WHERE id = ?');
const updatePasswordStmt = db.prepare('UPDATE users SET password_hash = ? WHERE id = ?');
const countAdminsStmt = db.prepare("SELECT COUNT(*) AS c FROM users WHERE role = 'admin'");
function publicView(row) {
if (!row) return null;
return { id: row.id, email: row.email, displayName: row.display_name, role: row.role };
}
async function createUser({ email, displayName, password }) {
email = email.trim().toLowerCase();
const hash = await argon2.hash(password, { type: argon2.argon2id });
// Auto-promote to admin if no admins exist yet and env bootstrap email matches.
let role = 'user';
const bootstrap = (process.env.ADMIN_BOOTSTRAP_EMAIL || '').trim().toLowerCase();
if (bootstrap && email === bootstrap && countAdminsStmt.get().c === 0) {
role = 'admin';
}
const info = insertStmt.run(email, displayName.trim(), hash, role);
return publicView(findByIdStmt.get(info.lastInsertRowid));
}
async function verifyCredentials(email, password) {
const row = findByEmailStmt.get(email.trim().toLowerCase());
if (!row) return null;
const ok = await argon2.verify(row.password_hash, password);
return ok ? publicView(row) : null;
}
function findByEmail(email) {
return publicView(findByEmailStmt.get(email.trim().toLowerCase()));
}
function findById(id) {
return publicView(findByIdStmt.get(id));
}
function updateDisplayName(id, displayName) {
updateNameStmt.run(displayName.trim(), id);
return findById(id);
}
async function changePassword(id, currentPassword, newPassword) {
const row = findByIdStmt.get(id);
if (!row) return { ok: false, reason: 'not_found' };
const ok = await argon2.verify(row.password_hash, currentPassword);
if (!ok) return { ok: false, reason: 'bad_current' };
const hash = await argon2.hash(newPassword, { type: argon2.argon2id });
updatePasswordStmt.run(hash, id);
return { ok: true };
}
module.exports = {
createUser,
verifyCredentials,
findByEmail,
findById,
updateDisplayName,
changePassword,
};

View File

@ -0,0 +1,33 @@
<h1>Account</h1>
<p class="muted"><%= user.email %> · <%= user.role %></p>
<section class="form-card">
<h2>Profile</h2>
<form method="post" action="/account" class="stacked">
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
<label>
Display name
<input type="text" name="display_name" value="<%= values.display_name || user.displayName %>" required>
<% if (errors.display_name) { %><span class="err"><%= errors.display_name %></span><% } %>
</label>
<button type="submit" class="btn-primary">Save</button>
</form>
</section>
<section class="form-card">
<h2>Change password</h2>
<form method="post" action="/account/password" class="stacked">
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
<label>
Current password
<input type="password" name="current_password" required>
<% if (errors.current_password) { %><span class="err"><%= errors.current_password %></span><% } %>
</label>
<label>
New password
<input type="password" name="new_password" required minlength="8">
<% if (errors.new_password) { %><span class="err"><%= errors.new_password %></span><% } %>
</label>
<button type="submit" class="btn-primary">Change password</button>
</form>
</section>

View File

@ -0,0 +1,61 @@
<h1><%= playlist ? 'Edit playlist' : 'New playlist' %></h1>
<form method="post"
action="<%= playlist ? '/admin/playlists/' + playlist.id : '/admin/playlists' %>"
enctype="multipart/form-data" class="stacked form-card">
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
<label>
Title
<input type="text" name="title" value="<%= values.title || '' %>" required>
<% if (errors.title) { %><span class="err"><%= errors.title %></span><% } %>
</label>
<label>
Description
<textarea name="description" rows="3"><%= values.description || '' %></textarea>
</label>
<label>
Cover art
<input type="file" name="cover" accept="image/*">
</label>
<label class="checkbox">
<input type="checkbox" name="is_public" <%= values.is_public ? 'checked' : '' %>>
Public
</label>
<div class="row">
<button type="submit" class="btn-primary"><%= playlist ? 'Save' : 'Create' %></button>
<a href="/admin/playlists" class="btn-link">Cancel</a>
</div>
</form>
<% if (playlist) { %>
<section class="form-card">
<h2>Tracks</h2>
<% if (tracks.length === 0) { %>
<p class="muted">No tracks yet.</p>
<% } else { %>
<ol class="track-list">
<% tracks.forEach((t) => { %>
<li>
<span><%= t.title %> — <span class="muted small"><%= t.artist %></span></span>
<form method="post" action="/admin/playlists/<%= playlist.id %>/remove-track" class="inline-form">
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
<input type="hidden" name="song_id" value="<%= t.id %>">
<button type="submit" class="linklike danger">Remove</button>
</form>
</li>
<% }) %>
</ol>
<% } %>
<form method="post" action="/admin/playlists/<%= playlist.id %>/add-track" class="inline-row">
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
<select name="song_id" required>
<option value="">— Add a song —</option>
<% allSongs.forEach((s) => { %>
<option value="<%= s.id %>"><%= s.title %> — <%= s.artist %></option>
<% }) %>
</select>
<button type="submit">Add</button>
</form>
</section>
<% } %>

View File

@ -0,0 +1,33 @@
<div class="row-between">
<h1>Admin · Playlists</h1>
<a href="/admin/playlists/new" class="btn-primary">New playlist</a>
</div>
<nav class="tabs">
<a href="/admin/songs">Songs</a>
<a href="/admin/playlists" class="active">Playlists</a>
</nav>
<% if (playlists.length === 0) { %>
<p class="muted">No playlists yet.</p>
<% } else { %>
<table class="data-table">
<thead><tr><th>Title</th><th>Public</th><th>Created</th><th></th></tr></thead>
<tbody>
<% playlists.forEach((p) => { %>
<tr>
<td><a href="/playlists/<%= p.id %>"><%= p.title %></a></td>
<td><%= p.isPublic ? 'yes' : 'no' %></td>
<td class="muted small"><%= p.createdAt %></td>
<td class="actions">
<a href="/admin/playlists/<%= p.id %>/edit">Edit</a>
<form method="post" action="/admin/playlists/<%= p.id %>/delete" class="inline-form" onsubmit="return confirm('Delete this playlist?');">
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
<button type="submit" class="linklike danger">Delete</button>
</form>
</td>
</tr>
<% }) %>
</tbody>
</table>
<% } %>

View File

@ -0,0 +1,56 @@
<h1><%= song ? 'Edit song' : 'Upload song' %></h1>
<form method="post"
action="<%= song ? '/admin/songs/' + song.id : '/admin/songs' %>"
enctype="multipart/form-data" class="stacked form-card">
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
<label>
Title
<input type="text" name="title" value="<%= values.title || '' %>" required>
<% if (errors.title) { %><span class="err"><%= errors.title %></span><% } %>
</label>
<label>
Artist
<input type="text" name="artist" value="<%= values.artist || '' %>" required>
<% if (errors.artist) { %><span class="err"><%= errors.artist %></span><% } %>
</label>
<label>
Album
<input type="text" name="album" value="<%= values.album || '' %>">
</label>
<div class="row">
<label>
Genre
<input type="text" name="genre" value="<%= values.genre || '' %>">
</label>
<label>
Year
<input type="number" name="year" min="1" max="9999" value="<%= values.year || '' %>">
</label>
</div>
<% if (!song) { %>
<label>
Audio file (required)
<input type="file" name="audio" accept="audio/*" required>
<% if (errors.audio) { %><span class="err"><%= errors.audio %></span><% } %>
</label>
<label>
Cover art (optional)
<input type="file" name="cover" accept="image/*">
</label>
<% } else { %>
<p class="muted small">Audio and cover can't be replaced from this form yet — delete and re-upload to change them.</p>
<% } %>
<label class="checkbox">
<input type="checkbox" name="is_public" <%= values.is_public ? 'checked' : '' %>>
Public (visible to everyone)
</label>
<div class="row">
<button type="submit" class="btn-primary"><%= song ? 'Save' : 'Upload' %></button>
<a href="/admin/songs" class="btn-link">Cancel</a>
</div>
</form>

34
src/views/admin/songs.ejs Normal file
View File

@ -0,0 +1,34 @@
<div class="row-between">
<h1>Admin · Songs</h1>
<a href="/admin/songs/new" class="btn-primary">Upload song</a>
</div>
<nav class="tabs">
<a href="/admin/songs" class="active">Songs</a>
<a href="/admin/playlists">Playlists</a>
</nav>
<% if (songs.length === 0) { %>
<p class="muted">No songs yet.</p>
<% } else { %>
<table class="data-table">
<thead><tr><th>Title</th><th>Artist</th><th>Album</th><th>Public</th><th></th></tr></thead>
<tbody>
<% songs.forEach((s) => { %>
<tr>
<td><a href="/songs/<%= s.id %>"><%= s.title %></a></td>
<td><%= s.artist %></td>
<td><%= s.album || '' %></td>
<td><%= s.isPublic ? 'yes' : 'no' %></td>
<td class="actions">
<a href="/admin/songs/<%= s.id %>/edit">Edit</a>
<form method="post" action="/admin/songs/<%= s.id %>/delete" class="inline-form" onsubmit="return confirm('Delete this song?');">
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
<button type="submit" class="linklike danger">Delete</button>
</form>
</td>
</tr>
<% }) %>
</tbody>
</table>
<% } %>

17
src/views/auth/login.ejs Normal file
View File

@ -0,0 +1,17 @@
<div class="form-card">
<h1>Log in</h1>
<% if (error) { %><div class="flash flash-error"><%= error %></div><% } %>
<form method="post" action="/login" class="stacked">
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
<label>
Email
<input type="email" name="email" value="<%= values.email || '' %>" required autofocus>
</label>
<label>
Password
<input type="password" name="password" required>
</label>
<button type="submit" class="btn-primary">Log in</button>
</form>
<p class="muted small">No account? <a href="/register">Register</a></p>
</div>

View File

@ -0,0 +1,23 @@
<div class="form-card">
<h1>Register</h1>
<form method="post" action="/register" class="stacked">
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
<label>
Email
<input type="email" name="email" value="<%= values.email || '' %>" required autofocus>
<% if (errors.email) { %><span class="err"><%= errors.email %></span><% } %>
</label>
<label>
Display name
<input type="text" name="display_name" value="<%= values.display_name || '' %>" required>
<% if (errors.display_name) { %><span class="err"><%= errors.display_name %></span><% } %>
</label>
<label>
Password
<input type="password" name="password" required minlength="8">
<% if (errors.password) { %><span class="err"><%= errors.password %></span><% } %>
</label>
<button type="submit" class="btn-primary">Create account</button>
</form>
<p class="muted small">Already have an account? <a href="/login">Log in</a></p>
</div>

5
src/views/error.ejs Normal file
View File

@ -0,0 +1,5 @@
<div class="form-card">
<h1><%= title %></h1>
<p class="muted"><%= message %></p>
<a href="/" class="btn-link">← Home</a>
</div>

19
src/views/layout.ejs Normal file
View File

@ -0,0 +1,19 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title><%= typeof title !== 'undefined' ? title : 'Tunes' %></title>
<link rel="stylesheet" href="/static/css/app.css">
</head>
<body>
<%- include('partials/header') %>
<%- include('partials/flash') %>
<main class="container">
<%- body %>
</main>
<%- include('partials/player') %>
<script src="/static/vendor/htmx.min.js" defer></script>
<script src="/static/js/player.js" defer></script>
</body>
</html>

View File

@ -0,0 +1,3 @@
<% if (flash) { %>
<div class="flash flash-<%= flash.type %>"><%= flash.message %></div>
<% } %>

View File

@ -0,0 +1,22 @@
<header class="site-header">
<div class="container header-inner">
<a class="brand" href="/">🎵 Tunes</a>
<nav class="nav">
<a href="/songs" class="<%= currentPath && currentPath.startsWith('/songs') ? 'active' : '' %>">Songs</a>
<a href="/playlists" class="<%= currentPath && currentPath.startsWith('/playlists') ? 'active' : '' %>">Playlists</a>
<% if (user) { %>
<% if (user.role === 'admin') { %>
<a href="/admin/songs" class="<%= currentPath && currentPath.startsWith('/admin') ? 'active' : '' %>">Admin</a>
<% } %>
<a href="/account">Account</a>
<form method="post" action="/logout" class="inline-form">
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
<button type="submit" class="linklike">Log out</button>
</form>
<% } else { %>
<a href="/login">Log in</a>
<a href="/register" class="btn-small">Register</a>
<% } %>
</nav>
</div>
</header>

View File

@ -0,0 +1,14 @@
<div id="player-bar" class="player-bar" aria-label="Audio player">
<div class="player-now">
<img id="player-cover" src="/static/vendor/cover-placeholder.svg" alt="" />
<div class="player-meta">
<div id="player-title">Nothing playing</div>
<div id="player-artist" class="muted"></div>
</div>
</div>
<audio id="player-audio" preload="metadata" controls></audio>
<div class="player-queue">
<span id="player-queue-info" class="muted"></span>
<button id="player-next" type="button" title="Next">⏭</button>
</div>
</div>

View File

@ -0,0 +1,22 @@
<ul class="song-list">
<% songs.forEach((s) => { %>
<li class="song-row">
<% if (s.coverPath) { %>
<img src="/media/<%= s.coverPath %>" alt="" class="song-cover" />
<% } else { %>
<div class="song-cover cover-fallback">♪</div>
<% } %>
<div class="song-main">
<a class="song-title" href="/songs/<%= s.id %>"><%= s.title %></a>
<div class="muted small"><%= s.artist %><% if (s.album) { %> · <%= s.album %><% } %></div>
</div>
<div class="song-meta muted small">
<% if (s.durationSeconds) { %><%= Math.floor(s.durationSeconds/60) %>:<%= String(s.durationSeconds % 60).padStart(2,'0') %><% } %>
</div>
<button type="button" class="btn-play"
data-play-song='<%- JSON.stringify({ id: s.id, title: s.title, artist: s.artist, cover: s.coverPath ? "/media/" + s.coverPath : null }) %>'>
▶ Play
</button>
</li>
<% }) %>
</ul>

36
src/views/public/home.ejs Normal file
View File

@ -0,0 +1,36 @@
<section class="hero">
<h1>Listen to the library</h1>
<p class="muted">Browse playlists and songs, or dive into the full catalog.</p>
</section>
<section>
<h2>Featured playlists</h2>
<% if (featured.length === 0) { %>
<p class="muted">No playlists yet.</p>
<% } else { %>
<div class="grid">
<% featured.forEach((p) => { %>
<a class="card" href="/playlists/<%= p.id %>">
<% if (p.coverPath) { %>
<img src="/media/<%= p.coverPath %>" alt="" />
<% } else { %>
<div class="cover-fallback">🎧</div>
<% } %>
<div class="card-body">
<div class="card-title"><%= p.title %></div>
<% if (p.description) { %><div class="muted small"><%= p.description %></div><% } %>
</div>
</a>
<% }) %>
</div>
<% } %>
</section>
<section>
<h2>Recently added</h2>
<% if (recent.length === 0) { %>
<p class="muted">No songs yet. <% if (user && user.role === 'admin') { %><a href="/admin/songs/new">Upload the first one →</a><% } %></p>
<% } else { %>
<%- include('../partials/song_list', { songs: recent }) %>
<% } %>
</section>

View File

@ -0,0 +1,22 @@
<div class="song-detail">
<% if (playlist.coverPath) { %>
<img src="/media/<%= playlist.coverPath %>" alt="" class="song-detail-cover" />
<% } else { %>
<div class="song-detail-cover cover-fallback">🎧</div>
<% } %>
<div>
<h1><%= playlist.title %></h1>
<% if (playlist.description) { %><p class="muted"><%= playlist.description %></p><% } %>
<p class="muted small"><%= tracks.length %> track<%= tracks.length === 1 ? '' : 's' %></p>
<button type="button" class="btn-primary"
id="play-playlist-btn"
data-play-playlist='<%- JSON.stringify(tracks.map((t) => ({ id: t.id, title: t.title, artist: t.artist, cover: t.coverPath ? "/media/" + t.coverPath : null }))) %>'>
▶ Play all
</button>
<a href="/playlists" class="btn-link">← Back</a>
</div>
</div>
<% if (tracks.length > 0) { %>
<%- include('../partials/song_list', { songs: tracks }) %>
<% } %>

View File

@ -0,0 +1,20 @@
<h1>Playlists</h1>
<% if (playlists.length === 0) { %>
<p class="muted">No playlists yet.</p>
<% } else { %>
<div class="grid">
<% playlists.forEach((p) => { %>
<a class="card" href="/playlists/<%= p.id %>">
<% if (p.coverPath) { %>
<img src="/media/<%= p.coverPath %>" alt="" />
<% } else { %>
<div class="cover-fallback">🎧</div>
<% } %>
<div class="card-body">
<div class="card-title"><%= p.title %></div>
<% if (p.description) { %><div class="muted small"><%= p.description %></div><% } %>
</div>
</a>
<% }) %>
</div>
<% } %>

21
src/views/public/song.ejs Normal file
View File

@ -0,0 +1,21 @@
<div class="song-detail">
<% if (song.coverPath) { %>
<img src="/media/<%= song.coverPath %>" alt="" class="song-detail-cover" />
<% } else { %>
<div class="song-detail-cover cover-fallback">♪</div>
<% } %>
<div>
<h1><%= song.title %></h1>
<p class="muted"><%= song.artist %><% if (song.album) { %> · <%= song.album %><% } %></p>
<p class="muted small">
<% if (song.genre) { %><%= song.genre %> · <% } %>
<% if (song.year) { %><%= song.year %> · <% } %>
<% if (song.durationSeconds) { %><%= Math.floor(song.durationSeconds/60) %>:<%= String(song.durationSeconds%60).padStart(2,'0') %><% } %>
</p>
<button type="button" class="btn-play btn-primary"
data-play-song='<%- JSON.stringify({ id: song.id, title: song.title, artist: song.artist, cover: song.coverPath ? "/media/" + song.coverPath : null }) %>'>
▶ Play
</button>
<a href="/songs" class="btn-link">← Back to songs</a>
</div>
</div>

View File

@ -0,0 +1,23 @@
<h1>Songs</h1>
<form method="get" action="/songs" class="search-form">
<input type="search" name="q" value="<%= q %>" placeholder="Search title, artist, album…" />
<button type="submit">Search</button>
<% if (q) { %><a href="/songs" class="muted small">clear</a><% } %>
</form>
<p class="muted small"><%= total %> song<%= total === 1 ? '' : 's' %><% if (q) { %> matching “<%= q %>”<% } %></p>
<% if (songs.length === 0) { %>
<p class="muted">No songs found.</p>
<% } else { %>
<%- include('../partials/song_list', { songs }) %>
<% } %>
<% if (totalPages > 1) { %>
<nav class="pagination">
<% if (page > 1) { %><a href="?<%= q ? 'q=' + encodeURIComponent(q) + '&' : '' %>page=<%= page - 1 %>">← Prev</a><% } %>
<span class="muted">Page <%= page %> / <%= totalPages %></span>
<% if (page < totalPages) { %><a href="?<%= q ? 'q=' + encodeURIComponent(q) + '&' : '' %>page=<%= page + 1 %>">Next →</a><% } %>
</nav>
<% } %>