first commit
This commit is contained in:
commit
a515df90de
|
|
@ -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 };
|
||||
|
|
@ -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);
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -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();
|
||||
})();
|
||||
|
|
@ -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 |
File diff suppressed because one or more lines are too long
|
|
@ -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}`);
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
<% } %>
|
||||
|
|
@ -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>
|
||||
<% } %>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
<% } %>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
<div class="form-card">
|
||||
<h1><%= title %></h1>
|
||||
<p class="muted"><%= message %></p>
|
||||
<a href="/" class="btn-link">← Home</a>
|
||||
</div>
|
||||
|
|
@ -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>
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
<% if (flash) { %>
|
||||
<div class="flash flash-<%= flash.type %>"><%= flash.message %></div>
|
||||
<% } %>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 }) %>
|
||||
<% } %>
|
||||
|
|
@ -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>
|
||||
<% } %>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
<% } %>
|
||||
Loading…
Reference in New Issue