feat: add social features, user uploads, AI music generation, and PWA enhancements

- Add social features: like/favorite songs and playlists, notifications, and public profile pages
- Enable user music: logged-in users can upload songs and create playlists with visibility controls and NSFW flag
- Implement AI music generation via ComfyUI with per-user rate limiting, workflow configuration, and optional cover art generation
- Expand admin panel for user management and AI settings
- Enhance PWA support: lock-screen controls, background playback, and visualizer
- Add email verification via SMTP and avatar cropping
- Update package version to 1.0.0 and bump service worker cache
This commit is contained in:
Brian Fertig 2026-04-17 20:12:34 -06:00
parent 13a2ad52e5
commit 8170cfb7a2
4 changed files with 118 additions and 53 deletions

141
README.md
View File

@ -1,36 +1,56 @@
# Tunes # Tunes
A self-contained music website: browse and listen to songs and playlists, A self-hosted music website: browse and listen to songs and playlists, with
with user accounts and an admin upload UI. Everything runs from a single user accounts, social features, and AI-assisted music and cover art generation.
Node.js process with an embedded SQLite database and local media storage — Everything runs from a single Node.js process with an embedded SQLite database
no external services required. and local media storage — no external services required beyond an optional
ComfyUI instance for AI generation.
## Features (v1) ## Features
- **Public browse & listen** - **Public browse & listen**
- Home page with featured playlists and recently added songs - Home page with featured playlists and recently added songs
- Paginated, searchable song list (title / artist / album) - Paginated, searchable song list (title / artist / album)
- Playlist grid and detail view with "Play all" - Playlist grid and detail view with "Play all"
- Persistent bottom-of-page audio player with queue support - Persistent bottom-of-page audio player with queue support, repeat, and visualizer
- HTTP Range streaming, so seeking/scrubbing works correctly - HTTP Range streaming so seeking/scrubbing works correctly
- PWA-ready with Media Session API: lock-screen controls and background playback on mobile
- Lightbox for cover art
- **User accounts** - **User accounts**
- Email + password registration (argon2id hashing) - Email + password registration (argon2id hashing)
- Optional email verification via SMTP
- Login / logout with SQLite-backed sessions (survive restarts) - Login / logout with SQLite-backed sessions (survive restarts)
- Account page: update display name, change password - Account page: update display name, avatar, and password
- Public profile pages with song/playlist activity
- CSRF protection on all state-changing routes - CSRF protection on all state-changing routes
- **Admin**
- Upload songs with cover art; title/artist/album/genre/year metadata - **Social**
- Like and favorite songs and playlists
- In-app notifications
- Profile pages showing liked and favorited content
- **User music**
- Logged-in users can upload their own songs and create playlists
- Visibility controls: `public`, `logged_in`, `vip`, or `private`
- NSFW flag for explicit content
- **AI music generation** (requires ComfyUI)
- Generate songs from style prompts, lyrics, BPM, key, duration, and creativity settings
- Per-user rate limiting (cooldown + hourly cap)
- Generated songs can be previewed, published, or deleted
- Optionally generate album cover art after publishing
- **Admin panel** (`/admin`)
- User management: disable, enable, verify, grant/revoke VIP, delete
- ComfyUI integration settings: base URL, music workflow JSON, image workflow JSON, node ID mappings
- Toggle music generation and image generation on/off site-wide
- **My Music** (`/mymusic`) — for any logged-in user
- Upload songs with cover art; title/artist/album/genre/year/BPM metadata
- Duration auto-extracted from the audio file - Duration auto-extracted from the audio file
- Create and edit playlists, add/remove tracks - Create and edit playlists, add/remove/reorder tracks
- Toggle songs and playlists between public and private - Bulk visibility changes
- First user who registers with `ADMIN_BOOTSTRAP_EMAIL` is auto-promoted
to admin (only while no admin exists yet)
## Roadmap (not in v1)
- Generate new music via an external API from within the app
- Let logged-in users publish their own songs and create playlists
- Tune into streaming radio stations
## Tech stack ## Tech stack
@ -39,10 +59,13 @@ no external services required.
- **EJS** + `express-ejs-layouts` — server-rendered views - **EJS** + `express-ejs-layouts` — server-rendered views
- **argon2** — password hashing - **argon2** — password hashing
- **express-session** + `better-sqlite3-session-store` — persistent sessions - **express-session** + `better-sqlite3-session-store` — persistent sessions
- **multer** — file uploads (audio + cover art) - **multer** — file uploads (audio, cover art, avatars)
- **sharp** — server-side image resizing and conversion
- **music-metadata** — extract duration from uploaded audio - **music-metadata** — extract duration from uploaded audio
- **nodemailer** — email verification
- **csrf-sync**, **zod**, **pino** — CSRF, validation, logging - **csrf-sync**, **zod**, **pino** — CSRF, validation, logging
- **htmx** (vendored) — small sprinklings of interactivity, no build step - **htmx** (vendored) — progressive interactivity, no build step
- **CropperJS** (vendored) — client-side avatar cropping
## Getting started ## Getting started
@ -50,6 +73,7 @@ no external services required.
- Node.js 18+ (tested on 18.19) - Node.js 18+ (tested on 18.19)
- npm - npm
- *(optional)* A running [ComfyUI](https://github.com/comfyanonymous/ComfyUI) instance for AI generation
### Install and run ### Install and run
@ -58,13 +82,11 @@ git clone <your-repo-url> tunes
cd tunes cd tunes
npm install npm install
cp .env.example .env cp .env.example .env
# Edit .env: # Edit .env — at minimum set SESSION_SECRET and ADMIN_BOOTSTRAP_EMAIL
# SESSION_SECRET=<a long random string>
# ADMIN_BOOTSTRAP_EMAIL=<your email>
node server.js node server.js
``` ```
The server boots at http://localhost:3000 and runs database migrations The server starts at http://localhost:3000 and runs database migrations
automatically against `tunes.db` in the project root. automatically against `tunes.db` in the project root.
For auto-reload during development: For auto-reload during development:
@ -78,35 +100,51 @@ npm run dev
1. Open http://localhost:3000/register 1. Open http://localhost:3000/register
2. Register with the email you set as `ADMIN_BOOTSTRAP_EMAIL` in `.env`. 2. Register with the email you set as `ADMIN_BOOTSTRAP_EMAIL` in `.env`.
That first matching registration is automatically given the `admin` role. That first matching registration is automatically given the `admin` role.
3. Once logged in, visit `/admin/songs` to upload audio and `/admin/playlists` 3. Visit `/admin` to manage users and configure generation settings.
to create playlists. 4. Visit `/mymusic/songs` to upload audio and `/mymusic/playlists` to create playlists.
Any subsequent registrations are normal users. Any subsequent registrations are normal users.
### Adding music ### Adding music
1. Go to `/admin/songs/new` 1. Go to `/mymusic/songs/new`
2. Fill in title, artist, and (optionally) album/genre/year 2. Fill in title, artist, and (optionally) album/genre/year
3. Choose an audio file (mp3, m4a, ogg, flac, wav, opus, webm — up to 100 MB) 3. Choose an audio file (mp3, m4a, ogg, flac, wav, opus, webm — up to 100 MB)
4. Optionally add a cover image 4. Optionally add a cover image
5. Check "Public" so visitors can see and play it, then click **Upload** 5. Set visibility to **Public** so visitors can see and play it, then click **Upload**
Uploaded files live under `media/audio/` and `media/covers/`. The database Uploaded files live under `media/audio/` and `media/covers/`. The database
stores relative paths, so you can move the whole project folder freely as stores relative paths, so you can move the whole project folder freely as long
long as `media/` and `tunes.db` stay together. as `media/` and `tunes.db` stay together.
### AI generation setup
1. Stand up a ComfyUI instance accessible from the server.
2. In `/admin`, set the **ComfyUI Base URL** (e.g. `http://localhost:8188`).
3. Upload a **Music Workflow JSON** and fill in the node ID mappings for your workflow.
4. Optionally upload an **Image Workflow JSON** and set the prompt node ID for cover art.
5. Check **Enable Song Generation** (and **Enable Album Cover Generation** if desired).
6. VIP users (and admins) can now visit `/generate` to produce songs.
## Configuration ## Configuration
All settings come from environment variables (loaded from `.env` via All settings come from environment variables (loaded from `.env` via `dotenv`):
`dotenv`):
| Variable | Default | Purpose | | Variable | Default | Purpose |
| --- | --- | --- | | --- | --- | --- |
| `PORT` | `3000` | HTTP port | | `PORT` | `3000` | HTTP port |
| `SESSION_SECRET` | *(insecure default)* | Session cookie signing secret — **set this in production** | | `SESSION_SECRET` | *(insecure default)* | Session cookie signing key — **set this in production** |
| `ADMIN_BOOTSTRAP_EMAIL` | *(unset)* | Email that auto-becomes admin on first registration | | `ADMIN_BOOTSTRAP_EMAIL` | *(unset)* | Email auto-promoted to admin on first registration |
| `DB_PATH` | `./tunes.db` | SQLite database file | | `DB_PATH` | `./tunes.db` | SQLite database file path |
| `MEDIA_DIR` | `./media` | Root directory for audio and cover uploads | | `MEDIA_DIR` | `./media` | Root directory for all uploaded and generated files |
| `SITE_NAME` | `Bri-Tunes` | Site name shown in the header and page titles |
| `APP_BASE_URL` | `http://localhost:3000` | Full URL used in email verification links |
| `SMTP_HOST` | *(unset)* | SMTP server hostname (leave blank to print verify URLs to console) |
| `SMTP_PORT` | `587` | SMTP port |
| `SMTP_SECURE` | `false` | Use TLS (`true`) or STARTTLS (`false`) |
| `SMTP_USER` | *(unset)* | SMTP username |
| `SMTP_PASS` | *(unset)* | SMTP password |
| `SMTP_FROM` | *(unset)* | `From` address for outgoing email |
## Project layout ## Project layout
@ -115,26 +153,31 @@ tunes/
├── server.js # entry point: loads env, runs migrations, starts app ├── server.js # entry point: loads env, runs migrations, starts app
├── db/ ├── db/
│ ├── index.js # SQLite connection + migration runner │ ├── index.js # SQLite connection + migration runner
│ └── migrations/*.sql # schema migrations │ └── migrations/*.sql # numbered schema migrations
├── src/ ├── src/
│ ├── app.js # Express wiring (sessions, CSRF, views, routes) │ ├── app.js # Express wiring (sessions, CSRF, views, routes)
│ ├── middleware/auth.js # requireUser / requireAdmin │ ├── middleware/auth.js # requireUser / requireAdmin / requireVip
│ ├── routes/ │ ├── routes/
│ │ ├── public.js # /, /songs, /playlists, /stream/:id │ │ ├── public.js # /, /songs, /playlists, /profiles, /stream/:id
│ │ ├── auth.js # /register, /login, /logout │ │ ├── auth.js # /register, /login, /logout, /verify-email
│ │ ├── account.js # /account │ │ ├── account.js # /account — profile and password settings
│ │ └── admin.js # /admin/songs, /admin/playlists │ │ ├── admin.js # /mymusic — user song and playlist management
│ ├── services/ # users, songs, playlists (DB access) │ │ ├── admin-panel.js # /admin — site administration
│ │ ├── generate.js # /generate — AI music + cover art generation
│ │ └── social.js # /api — likes, favorites
│ ├── services/ # users, songs, playlists, social, generation, settings
│ └── views/ # EJS templates │ └── views/ # EJS templates
├── public/ # static CSS, JS, vendored htmx ├── public/
└── media/ # runtime audio + cover uploads (git-ignored) │ ├── css/app.css # main stylesheet
│ └── js/ # player, visualizer, social, lightbox, etc.
└── media/ # runtime uploads and generated files (git-ignored)
``` ```
## Data and backups ## Data and backups
Everything you care about lives in two places: Everything you care about lives in two places:
- `tunes.db` — users, songs, playlists, sessions - `tunes.db` — users, songs, playlists, sessions, notifications, generation records
- `media/` — audio files and cover art - `media/` — audio files, cover art, avatars, and AI-generated files
Back up both together to preserve the full state of the site. Back up both together to preserve the full state of the site.

View File

@ -1,6 +1,6 @@
{ {
"name": "tunes", "name": "tunes",
"version": "0.1.0", "version": "1.0.0",
"private": true, "private": true,
"description": "Self-contained music website with browse/listen + user accounts", "description": "Self-contained music website with browse/listen + user accounts",
"main": "server.js", "main": "server.js",

View File

@ -91,6 +91,7 @@
const cur = state.queue[state.index]; const cur = state.queue[state.index];
if (!cur) return; if (!cur) return;
window.briTunesNowPlaying = cur; window.briTunesNowPlaying = cur;
transitioning = true;
audio.src = `/stream/${cur.id}`; audio.src = `/stream/${cur.id}`;
audio.play() audio.play()
.then(() => window.dispatchEvent(new CustomEvent('briTunes:play', { detail: cur }))) .then(() => window.dispatchEvent(new CustomEvent('briTunes:play', { detail: cur })))
@ -128,9 +129,30 @@
if (state.index > 0) { state.index -= 1; playCurrent(); } if (state.index > 0) { state.index -= 1; playCurrent(); }
} }
// Track whether we're in the middle of a track transition so the 'pause'
// event fired by src reassignment doesn't mislead iOS into thinking playback stopped.
let transitioning = false;
audio.addEventListener('ended', next); audio.addEventListener('ended', next);
audio.addEventListener('play', () => { if ('mediaSession' in navigator) navigator.mediaSession.playbackState = 'playing'; }); audio.addEventListener('play', () => {
audio.addEventListener('pause', () => { if ('mediaSession' in navigator) navigator.mediaSession.playbackState = 'paused'; }); transitioning = false;
if ('mediaSession' in navigator) navigator.mediaSession.playbackState = 'playing';
});
audio.addEventListener('pause', () => {
if (transitioning) return;
if ('mediaSession' in navigator) navigator.mediaSession.playbackState = 'paused';
});
// setPositionState keeps iOS from throttling the JS thread during background playback.
audio.addEventListener('timeupdate', () => {
if (!('mediaSession' in navigator) || !audio.duration || !isFinite(audio.duration)) return;
try {
navigator.mediaSession.setPositionState({
duration: audio.duration,
playbackRate: audio.playbackRate,
position: audio.currentTime,
});
} catch (_) {}
});
nextBtn.addEventListener('click', next); nextBtn.addEventListener('click', next);
if ('mediaSession' in navigator) { if ('mediaSession' in navigator) {

View File

@ -1,4 +1,4 @@
const CACHE = 'bri-tunes-v2'; const CACHE = 'bri-tunes-v3';
const STATIC_PRECACHE = [ const STATIC_PRECACHE = [
'/', '/',
'/static/css/app.css', '/static/css/app.css',