mirror of
https://github.com/tamius-han/ghetto-karaoke-playlist.git
synced 2026-08-12 18:47:04 +02:00
Compare commits
3 Commits
c4c6ce13f9
...
e623ca717e
| Author | SHA1 | Date | |
|---|---|---|---|
| e623ca717e | |||
| fec862e223 | |||
| 8271b1e147 |
15
.env.example
Normal file
15
.env.example
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
# Postgres
|
||||||
|
PG_USER=karaoke
|
||||||
|
PG_PASSWORD=karaoke123
|
||||||
|
PG_DB=karaoke
|
||||||
|
PG_HOST=db
|
||||||
|
PG_PORT=5432
|
||||||
|
|
||||||
|
# API
|
||||||
|
API_PORT=8080
|
||||||
|
|
||||||
|
# APP (nginx/static)
|
||||||
|
APP_PORT=80
|
||||||
|
|
||||||
|
JWT_SECRET=your_super_secret_key_here
|
||||||
|
JWT_EXPIRES_IN=7d
|
||||||
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
*/node_modules/
|
||||||
|
.env
|
||||||
13
api/Dockerfile
Normal file
13
api/Dockerfile
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
FROM node:20-alpine
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
|
||||||
|
COPY tsconfig.json ./
|
||||||
|
COPY src ./src
|
||||||
|
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
CMD ["node", "dist/index.js"]
|
||||||
1474
api/package-lock.json
generated
Normal file
1474
api/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
30
api/package.json
Normal file
30
api/package.json
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"name": "ghetto-karaoke-api",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"license": "MIT",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "ts-node-dev src/index.ts",
|
||||||
|
"build": "tsc"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@types/pg": "^8.15.6",
|
||||||
|
"bcrypt": "^6.0.0",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^16.4.1",
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"jsonwebtoken": "^9.0.3",
|
||||||
|
"pg": "^8.16.3",
|
||||||
|
"uuid": "^9.0.1",
|
||||||
|
"ws": "^8.18.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bcrypt": "^6.0.0",
|
||||||
|
"@types/express": "^4.17.21",
|
||||||
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
|
"@types/node": "^20.11.0",
|
||||||
|
"@types/uuid": "^11.0.0",
|
||||||
|
"ts-node-dev": "^2.0.0",
|
||||||
|
"typescript": "^5.3.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
40
api/src/db.ts
Normal file
40
api/src/db.ts
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import { Pool } from "pg";
|
||||||
|
import dotenv from "dotenv";
|
||||||
|
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
export const pool = new Pool({
|
||||||
|
connectionString: process.env.DATABASE_URL,
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function initDb() {
|
||||||
|
await pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS app_users (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
fingerprint TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
username TEXT UNIQUE NOT NULL,
|
||||||
|
password TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL CHECK (role IN ('admin', 'dj'))
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS songs (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
artist TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
comment TEXT,
|
||||||
|
youtube_link TEXT,
|
||||||
|
user_id UUID, -- who submitted the song
|
||||||
|
fingerprint TEXT, -- unique fingerprint per user/device
|
||||||
|
status INT NOT NULL DEFAULT 0, -- 0=queued, 1=playing, 2=played
|
||||||
|
locked BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
position BIGINT NOT NULL
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
}
|
||||||
29
api/src/index.ts
Normal file
29
api/src/index.ts
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import express from "express";
|
||||||
|
import cors from "cors";
|
||||||
|
import dotenv from "dotenv";
|
||||||
|
import { initDb } from "./db";
|
||||||
|
import publicRoutes from './modules/public-queue/routes';
|
||||||
|
import authRoutes from './modules/auth/routes';
|
||||||
|
import djRoutes from './modules/dj/routes';
|
||||||
|
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
app.use(cors());
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
|
||||||
|
// app.use("/api", routes);
|
||||||
|
app.use('/public', publicRoutes);
|
||||||
|
app.use('/auth', authRoutes);
|
||||||
|
app.use('/dj', djRoutes);
|
||||||
|
|
||||||
|
|
||||||
|
const port = process.env.API_PORT || 8080;
|
||||||
|
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
await initDb();
|
||||||
|
app.listen(port, () => console.log(`API running on port ${port}`));
|
||||||
|
})();
|
||||||
8
api/src/itf.queue-song-request.ts
Normal file
8
api/src/itf.queue-song-request.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
export interface QueueSongRequest {
|
||||||
|
userId: string,
|
||||||
|
fingerprint: string,
|
||||||
|
artist: string,
|
||||||
|
title: string,
|
||||||
|
comment: string,
|
||||||
|
youtubeLink?: string
|
||||||
|
}
|
||||||
70
api/src/modules/auth/auth-middleware.ts
Normal file
70
api/src/modules/auth/auth-middleware.ts
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import jwt from "jsonwebtoken";
|
||||||
|
import dotenv from "dotenv";
|
||||||
|
import { pool } from "./db";
|
||||||
|
import { verifyJwt } from './auth';
|
||||||
|
|
||||||
|
dotenv.config();
|
||||||
|
const SECRET = process.env.JWT_SECRET || "secret";
|
||||||
|
|
||||||
|
export interface AuthRequest extends Request {
|
||||||
|
user?: { id: string, username: string, role: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requireAuth(req: AuthRequest, res: Response, next: NextFunction) {
|
||||||
|
const authHeader = req.headers.authorization;
|
||||||
|
if (!authHeader) return res.status(401).json({ error: "Missing Authorization header" });
|
||||||
|
|
||||||
|
const token = authHeader.split(" ")[1];
|
||||||
|
if (!token) return res.status(401).json({ error: "Missing token" });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const decoded = jwt.verify(token, SECRET) as any;
|
||||||
|
req.user = decoded;
|
||||||
|
next();
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(401).json({ error: "Invalid token" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requireAdmin(req: AuthRequest, res: Response, next: NextFunction) {
|
||||||
|
await requireAuth(req, res, async () => {
|
||||||
|
if (req.user?.role !== "admin") {
|
||||||
|
return res.status(403).json({ error: "Admin access required" });
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requireDj(req: AuthRequest, res: Response, next: NextFunction) {
|
||||||
|
await requireAuth(req, res, async () => {
|
||||||
|
if (req.user?.role !== "dj") {
|
||||||
|
return res.status(403).json({ error: "Admin access required" });
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requireRole(allowedRoles: string[]) {
|
||||||
|
return (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||||
|
const authHeader = req.headers.authorization;
|
||||||
|
if (!authHeader) return res.status(401).json({ error: "Missing Authorization header" });
|
||||||
|
|
||||||
|
const token = authHeader.split(" ")[1];
|
||||||
|
if (!token) return res.status(401).json({ error: "Missing token" });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const decoded = verifyJwt<{ id: string; username: string; role: string }>(token);
|
||||||
|
req.user = decoded;
|
||||||
|
|
||||||
|
if (!allowedRoles.includes(decoded.role)) {
|
||||||
|
return res.status(403).json({ error: "Insufficient role" });
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("JWT verification failed:", err);
|
||||||
|
return res.status(401).json({ error: "Invalid token" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
95
api/src/modules/auth/auth.ts
Normal file
95
api/src/modules/auth/auth.ts
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
import jwt, {JwtPayload, Secret, SignOptions} from "jsonwebtoken";
|
||||||
|
import dotenv from "dotenv";
|
||||||
|
import bcrypt from "bcrypt";
|
||||||
|
import { pool as db } from "../../db";
|
||||||
|
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
const SECRET: string = process.env.JWT_SECRET ?? "secret";
|
||||||
|
const SALT_ROUNDS = 10;
|
||||||
|
|
||||||
|
export function signJwt(payload: object, options?: SignOptions) {
|
||||||
|
return jwt.sign(
|
||||||
|
payload,
|
||||||
|
(SECRET!) as Secret,
|
||||||
|
{
|
||||||
|
expiresIn: process.env.EXPIRES_IN || "1d" as any,
|
||||||
|
...options
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyJwt<T = JwtPayload>(token: string): T {
|
||||||
|
return jwt.verify(token, SECRET) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function hasUsers(res: any) {
|
||||||
|
try {
|
||||||
|
const result = await db.query(`SELECT COUNT(*) AS c FROM users`);
|
||||||
|
const count = Number(result.rows[0].c);
|
||||||
|
res.json({ exists: count > 0 });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error checking first user:", err);
|
||||||
|
res.status(500).json({ error: "Server error" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function login(loginData: {username: string, password: string}, res: any) {
|
||||||
|
const { username, password } = loginData;
|
||||||
|
|
||||||
|
if (!username || !password) return res.status(400).json({ error: "Missing username/password" });
|
||||||
|
|
||||||
|
const result = await db.query(`SELECT * FROM users WHERE username=$1`, [username]);
|
||||||
|
if (result.rowCount === 0) return res.status(401).json({ error: "Invalid credentials" });
|
||||||
|
|
||||||
|
const user = result.rows[0];
|
||||||
|
const match = await bcrypt.compare(password, user.password);
|
||||||
|
if (!match) return res.status(401).json({ error: "Invalid credentials" });
|
||||||
|
|
||||||
|
const token = signJwt({ id: user.id, username: user.username, role: user.role });
|
||||||
|
res.json({ token });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createUser(userData: {username: string, password: string, type: string}, req: any, res: any) {
|
||||||
|
const { username, password } = userData;
|
||||||
|
if (!username || !password) return res.status(400).json({ error: "Missing username/password" });
|
||||||
|
|
||||||
|
const usersCount = await db.query(`SELECT COUNT(*) as c FROM users`);
|
||||||
|
const count = Number(usersCount.rows[0].c);
|
||||||
|
|
||||||
|
let role = "dj"; // default for normal users
|
||||||
|
|
||||||
|
if (count === 0) {
|
||||||
|
// First user → admin
|
||||||
|
role = "admin";
|
||||||
|
} else {
|
||||||
|
// Subsequent users → must be created by admin
|
||||||
|
if (!req.headers.authorization) {
|
||||||
|
return res.status(401).json({ error: "Admin token required" });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const authHeader = req.headers.authorization.split(" ")[1];
|
||||||
|
const decoded = verifyJwt(authHeader); // validate token
|
||||||
|
const { role: userRole } = decoded as any;
|
||||||
|
if (userRole !== "admin") return res.status(403).json({ error: "Admin access required" });
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(401).json({ error: "Invalid token" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hashed = await bcrypt.hash(password, SALT_ROUNDS);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await db.query(
|
||||||
|
`INSERT INTO users (username, password, role) VALUES ($1, $2, $3) RETURNING id, username, role`,
|
||||||
|
[username, hashed, role]
|
||||||
|
);
|
||||||
|
res.json({ user: result.rows[0] });
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err.code === "23505") {
|
||||||
|
return res.status(409).json({ error: "Username already exists" });
|
||||||
|
}
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: "Server error" });
|
||||||
|
}
|
||||||
|
}
|
||||||
20
api/src/modules/auth/routes.ts
Normal file
20
api/src/modules/auth/routes.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { createUser, hasUsers, login } from './auth';
|
||||||
|
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.get('/first-time', async (req, res) => {
|
||||||
|
return await hasUsers(res);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/login', async (req, res) => {
|
||||||
|
return await login(req.body, res);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/create', async (req, res) => {
|
||||||
|
return await createUser(req.body, req, res);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
||||||
209
api/src/modules/dj/dj.ts
Normal file
209
api/src/modules/dj/dj.ts
Normal file
@ -0,0 +1,209 @@
|
|||||||
|
import { pool as db } from "../../db";
|
||||||
|
import { broadcastDisplayUpdate, broadcastUserUpdate } from '../ws/wsClients';
|
||||||
|
|
||||||
|
|
||||||
|
async function normalizePositions() {
|
||||||
|
const songs = await db.query(`SELECT id FROM songs ORDER BY position ASC`);
|
||||||
|
let pos = 0;
|
||||||
|
for (const s of songs.rows) {
|
||||||
|
await db.query(`UPDATE songs SET position=$1 WHERE id=$2`, [pos, s.id]);
|
||||||
|
pos++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function moveSongDb(id: string, newPos: number) {
|
||||||
|
const allSongs = await db.query(`SELECT id FROM songs ORDER BY position ASC`);
|
||||||
|
const songIds = allSongs.rows.map((s: any) => s.id);
|
||||||
|
const oldIndex = songIds.indexOf(id);
|
||||||
|
if (oldIndex === -1) throw new Error("Song not found");
|
||||||
|
|
||||||
|
songIds.splice(oldIndex, 1); // remove from old position
|
||||||
|
songIds.splice(newPos, 0, id); // insert at new position
|
||||||
|
|
||||||
|
// Update positions in DB
|
||||||
|
await db.query("BEGIN");
|
||||||
|
for (let i = 0; i < songIds.length; i++) {
|
||||||
|
await db.query(`UPDATE songs SET position=$1 WHERE id=$2`, [i, songIds[i]]);
|
||||||
|
}
|
||||||
|
await db.query("COMMIT");
|
||||||
|
|
||||||
|
// Return updated song info
|
||||||
|
const res = await db.query(`SELECT * FROM songs WHERE id=$1`, [id]);
|
||||||
|
return res.rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export async function lockSong(id: string, locked: boolean, res: any) {
|
||||||
|
if (typeof locked !== "boolean") return res.status(400).json({ error: "locked must be boolean" });
|
||||||
|
|
||||||
|
|
||||||
|
const result = await db.query(`UPDATE songs SET locked=$1 WHERE id=$2 RETURNING *`, [locked,id]);
|
||||||
|
if(result.rowCount === 0) {
|
||||||
|
return res.status(404).json({ error:"Song not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
broadcastDisplayUpdate("songLocked", result.rows[0]);
|
||||||
|
broadcastUserUpdate(result.rows[0].user_id,"yourSongLocked", result.rows[0]);
|
||||||
|
res.json({ song: result.rows[0] });
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export async function moveSong(id: string, position: number, res: any) {
|
||||||
|
try {
|
||||||
|
await db.query("BEGIN");
|
||||||
|
|
||||||
|
// Fetch the song
|
||||||
|
const updated = await moveSongDb(id, position);
|
||||||
|
|
||||||
|
// Auto status adjustment
|
||||||
|
if(updated.status === 2 && position < +(await db.query(`SELECT COUNT(*) FROM songs`))) {
|
||||||
|
await db.query(`UPDATE songs SET status=0 WHERE id=$1`, [id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
broadcastDisplayUpdate("songMoved", updated);
|
||||||
|
broadcastUserUpdate(updated.user_id,"yourSongPositionChanged", updated);
|
||||||
|
|
||||||
|
// If song became next to be played
|
||||||
|
const nextSongRes = await db.query(`SELECT * FROM songs WHERE status=0 ORDER BY position ASC LIMIT 1`);
|
||||||
|
if(nextSongRes.rowCount !== null && nextSongRes.rowCount > 0 && nextSongRes.rows[0].id === id) {
|
||||||
|
broadcastUserUpdate(updated.user_id,"yourSongIsNext", updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ song: updated });
|
||||||
|
} catch (err) {
|
||||||
|
await db.query("ROLLBACK");
|
||||||
|
console.error("Error moving song:", err);
|
||||||
|
res.status(500).json({ error: "Server error" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function changeSongStatus(id: string, status: number, res: any) {
|
||||||
|
if (![0, 1, 2].includes(status)) return res.status(400).json({ error: "Invalid status" });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await db.query(
|
||||||
|
`UPDATE songs SET status=$1 WHERE id=$2 RETURNING *`,
|
||||||
|
[status, id]
|
||||||
|
);
|
||||||
|
if (result.rowCount === 0) return res.status(404).json({ error: "Song not found" });
|
||||||
|
|
||||||
|
res.json({ song: result.rows[0] });
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: "Server error" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function nextSong(res: any, lockCount: number = 0) {
|
||||||
|
try {
|
||||||
|
await db.query("BEGIN");
|
||||||
|
|
||||||
|
// 1️⃣ Currently playing song → played
|
||||||
|
const currentRes = await db.query(`SELECT * FROM songs WHERE status=1 ORDER BY position ASC LIMIT 1`);
|
||||||
|
const currentSong = currentRes.rows[0];
|
||||||
|
if (currentSong) {
|
||||||
|
await db.query(`UPDATE songs SET status=2 WHERE id=$1`, [currentSong.id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2️⃣ Next queued song → playing
|
||||||
|
const nextRes = await db.query(`SELECT * FROM songs WHERE status=0 ORDER BY position ASC LIMIT 1`);
|
||||||
|
const nextSong = nextRes.rows[0];
|
||||||
|
if (!nextSong) {
|
||||||
|
await db.query("COMMIT");
|
||||||
|
return res.status(200).json({ message: "No more queued songs" });
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.query(`UPDATE songs SET status=1 WHERE id=$1`, [nextSong.id]);
|
||||||
|
|
||||||
|
// 3️⃣ Apply lockCount
|
||||||
|
if (lockCount > 0) {
|
||||||
|
const toLockRes = await db.query(
|
||||||
|
`SELECT * FROM songs WHERE status=0 ORDER BY position ASC LIMIT $1`,
|
||||||
|
[lockCount]
|
||||||
|
);
|
||||||
|
for (const s of toLockRes.rows) {
|
||||||
|
await db.query(`UPDATE songs SET locked=true WHERE id=$1`, [s.id]);
|
||||||
|
broadcastUserUpdate(s.user_id, "yourSongLocked", s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4️⃣ WebSocket updates
|
||||||
|
broadcastDisplayUpdate("nextSong", nextSong);
|
||||||
|
broadcastUserUpdate(nextSong.user_id, "yourSongIsNext", nextSong);
|
||||||
|
|
||||||
|
await db.query("COMMIT");
|
||||||
|
res.json({ playing: nextSong });
|
||||||
|
} catch (err) {
|
||||||
|
await db.query("ROLLBACK");
|
||||||
|
console.error("Error moving to next song:", err);
|
||||||
|
res.status(500).json({ error: "Server error" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function prevSong(res: any) {
|
||||||
|
try {
|
||||||
|
await db.query("BEGIN");
|
||||||
|
|
||||||
|
// 1️⃣ Currently playing song → queued
|
||||||
|
const currentRes = await db.query(`SELECT * FROM songs WHERE status=1 ORDER BY position ASC LIMIT 1`);
|
||||||
|
const currentSong = currentRes.rows[0];
|
||||||
|
if (!currentSong) {
|
||||||
|
await db.query("COMMIT");
|
||||||
|
return res.status(200).json({ message: "No song currently playing" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2️⃣ Previous played song → playing
|
||||||
|
const prevRes = await db.query(
|
||||||
|
`SELECT * FROM songs WHERE status=2 AND position < $1 ORDER BY position DESC LIMIT 1`,
|
||||||
|
[currentSong.position]
|
||||||
|
);
|
||||||
|
const prevSong = prevRes.rows[0];
|
||||||
|
if (!prevSong) {
|
||||||
|
await db.query("COMMIT");
|
||||||
|
return res.status(200).json({ message: "No previous song" });
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.query(`UPDATE songs SET status=0 WHERE id=$1`, [currentSong.id]);
|
||||||
|
await db.query(`UPDATE songs SET status=1 WHERE id=$1`, [prevSong.id]);
|
||||||
|
|
||||||
|
// WebSocket updates
|
||||||
|
broadcastDisplayUpdate("prevSong", prevSong);
|
||||||
|
broadcastUserUpdate(prevSong.user_id, "yourSongIsNext", prevSong);
|
||||||
|
|
||||||
|
await db.query("COMMIT");
|
||||||
|
res.json({ playing: prevSong });
|
||||||
|
} catch (err) {
|
||||||
|
await db.query("ROLLBACK");
|
||||||
|
console.error("Error moving to previous song:", err);
|
||||||
|
res.status(500).json({ error: "Server error" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeSong(id: string, res: any) {
|
||||||
|
try {
|
||||||
|
const result = await db.query(`DELETE FROM songs WHERE id=$1 RETURNING *`, [id]);
|
||||||
|
|
||||||
|
if (result.rowCount===0) {
|
||||||
|
return res.status(404).json({ error:"Song not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
await normalizePositions();
|
||||||
|
|
||||||
|
broadcastDisplayUpdate("songRemoved", result.rows[0]);
|
||||||
|
broadcastUserUpdate(result.rows[0].user_id,"yourSongRemoved", result.rows[0]);
|
||||||
|
res.json({ removed: result.rows[0] });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error removing song:", err);
|
||||||
|
res.status(500).json({ error: "Server error" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearPlaylist(res: any) {
|
||||||
|
try {
|
||||||
|
const result = await db.query(`DELETE FROM songs RETURNING *`);
|
||||||
|
res.json({ removedCount: result.rowCount, removed: result.rows });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error clearing playlist:", err);
|
||||||
|
res.status(500).json({ error: "Server error" });
|
||||||
|
}
|
||||||
|
}
|
||||||
57
api/src/modules/dj/routes.ts
Normal file
57
api/src/modules/dj/routes.ts
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import { requireRole, AuthRequest } from "../auth/auth-middleware";
|
||||||
|
import { changeSongStatus, clearPlaylist, lockSong, moveSong, nextSong, prevSong, removeSong } from './dj';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.patch("/playlist/:id/lock", requireRole(["dj", "admin"]), async (req: AuthRequest, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { locked } = req.body;
|
||||||
|
return await lockSong(id, locked, res);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Move song
|
||||||
|
router.patch("/playlist/:id/move", requireRole(["dj", "admin"]), async (req: AuthRequest, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { position } = req.body;
|
||||||
|
if (typeof position !== "number") return res.status(400).json({ error: "position must be a number" });
|
||||||
|
|
||||||
|
return await moveSong(id, position, res);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Change song status
|
||||||
|
router.patch("/playlist/:id/status", requireRole(["dj", "admin"]), async (req: AuthRequest, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { status } = req.body;
|
||||||
|
|
||||||
|
return await changeSongStatus(id, status, res);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete("/songs/:id", requireRole(["dj", "admin"]), async (req: AuthRequest, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
return await removeSong(id, res);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Clear entire playlist ---
|
||||||
|
router.delete("/songs", requireRole(["dj", "admin"]), async (_req: AuthRequest, res) => {
|
||||||
|
return await clearPlaylist(res);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/next", requireRole(["dj", "admin"]), async (req: AuthRequest, res) => {
|
||||||
|
const { lockCount } = req.body;
|
||||||
|
const lockNum = typeof lockCount === "number" ? lockCount : 0;
|
||||||
|
|
||||||
|
return await nextSong(res, lockNum);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move to previous song
|
||||||
|
* POST /playback/prev
|
||||||
|
*/
|
||||||
|
router.post("/prev", requireRole(["dj", "admin"]), async (_req, res) => {
|
||||||
|
return await prevSong(res);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
export default router;
|
||||||
105
api/src/modules/public-queue/public-queue.ts
Normal file
105
api/src/modules/public-queue/public-queue.ts
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
import {v4 as uuid} from 'uuid';
|
||||||
|
import { QueueSongRequest as QueueSongRequestData } from '../../itf.queue-song-request';
|
||||||
|
import { pool as db } from '../../db';
|
||||||
|
import { broadcastDisplayUpdate } from '../ws/wsClients';
|
||||||
|
|
||||||
|
|
||||||
|
export async function getId(res: any) {
|
||||||
|
const id = uuid();
|
||||||
|
await db.query(`INSERT INTO app_users (id) VALUES ($1)`, [id]);
|
||||||
|
res.json({ userId: id });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function queueSong(requestData: QueueSongRequestData, res: any) {
|
||||||
|
const { userId, fingerprint, artist, title, comment, youtubeLink } = requestData;
|
||||||
|
|
||||||
|
if (!userId || !fingerprint || (!artist && !title)) {
|
||||||
|
return res.status(400).json({ error: "Missing fields" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check if this user+fingerprint already has a song in the queue (queued or playing)
|
||||||
|
const existing = await db.query(
|
||||||
|
`SELECT id FROM songs WHERE user_id=$1 AND fingerprint=$2 AND status IN (0,1)`,
|
||||||
|
[userId, fingerprint]
|
||||||
|
);
|
||||||
|
|
||||||
|
const nextPosResult = await db.query(`SELECT COALESCE(MAX(position), 0) + 1000 AS nextPos FROM songs`);
|
||||||
|
let position = Number(nextPosResult.rows[0].nextPos);
|
||||||
|
|
||||||
|
// If user+fingerprint already exists, append normally (no priority bump)
|
||||||
|
let locked = false;
|
||||||
|
if (existing.rowCount === 0) {
|
||||||
|
// Insert with priority: find first non-locked song after last locked song
|
||||||
|
const lastLockedResult = await db.query(
|
||||||
|
`SELECT COALESCE(MAX(position),0) AS pos FROM songs WHERE locked=true`
|
||||||
|
);
|
||||||
|
const lastLockedPos = Number(lastLockedResult.rows[0].pos);
|
||||||
|
position = lastLockedPos + 1; // insert right after last locked song
|
||||||
|
} else {
|
||||||
|
// use regular position at end
|
||||||
|
position = position;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert song
|
||||||
|
const result = await db.query(
|
||||||
|
`INSERT INTO songs
|
||||||
|
(artist, title, comment, youtube_link, user_id, fingerprint, status, locked, position)
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6,0,$7,$8)
|
||||||
|
RETURNING id, artist, title, comment, status, position`,
|
||||||
|
[artist, title, comment || "", youtubeLink || "", userId, fingerprint, locked, position]
|
||||||
|
);
|
||||||
|
|
||||||
|
const newSong = result.rows[0];
|
||||||
|
broadcastDisplayUpdate("songAdded", newSong);
|
||||||
|
res.json({ song: newSong });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error adding song:", err);
|
||||||
|
res.status(500).json({ error: "Server error" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listQueue(res: any, userId?: string) {
|
||||||
|
try {
|
||||||
|
const queueResult = await db.query(
|
||||||
|
`SELECT id, artist, title, comment, status, position, user_id
|
||||||
|
FROM songs WHERE status IN (0,1)
|
||||||
|
ORDER BY position ASC`
|
||||||
|
);
|
||||||
|
|
||||||
|
const queue = queueResult.rows.map((s: any) => ({
|
||||||
|
artist: s.artist,
|
||||||
|
title: s.title,
|
||||||
|
comment: s.comment,
|
||||||
|
status: s.status,
|
||||||
|
position: s.position,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// User-specific info
|
||||||
|
if (typeof userId === "string") {
|
||||||
|
const userSongs = queueResult.rows
|
||||||
|
.filter((s: any) => s.user_id === userId)
|
||||||
|
.map((s: any) => {
|
||||||
|
const queueAhead = queueResult.rows.filter(
|
||||||
|
(o: any) => o.status === 0 && o.position < s.position
|
||||||
|
).length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
artist: s.artist,
|
||||||
|
title: s.title,
|
||||||
|
comment: s.comment,
|
||||||
|
status: s.status,
|
||||||
|
position: s.position,
|
||||||
|
queueAhead,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json({ queue, userSongs });
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json(queue);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error fetching queue:", err);
|
||||||
|
res.status(500).json({ error: "Server error" });
|
||||||
|
}
|
||||||
|
}
|
||||||
21
api/src/modules/public-queue/routes.ts
Normal file
21
api/src/modules/public-queue/routes.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { getId, listQueue, queueSong } from './public-queue';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.get('/id', async (req, res) => {
|
||||||
|
await getId(res);
|
||||||
|
});
|
||||||
|
router.post("/queue", async (req, res) => {
|
||||||
|
await queueSong(req.body, res)
|
||||||
|
});
|
||||||
|
router.get("/queue", async (req, res) => {
|
||||||
|
const {userId} = req.query;
|
||||||
|
if (Array.isArray(req.query.userId)) {
|
||||||
|
res.status(400);
|
||||||
|
}
|
||||||
|
|
||||||
|
await listQueue(res, userId ? String(userId) : undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
10
api/src/modules/ws/wsClients.ts
Normal file
10
api/src/modules/ws/wsClients.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { clients } from "./wsServer";
|
||||||
|
|
||||||
|
export function broadcastDisplayUpdate(event: string, payload: any) {
|
||||||
|
clients.filter(c => c.type === "display").forEach(c => c.ws.send(JSON.stringify({ event, payload })));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function broadcastUserUpdate(userId: string, event: string, payload: any) {
|
||||||
|
clients.filter(c => c.type === "user" && c.userId === userId)
|
||||||
|
.forEach(c => c.ws.send(JSON.stringify({ event, payload })));
|
||||||
|
}
|
||||||
36
api/src/modules/ws/wsServer.ts
Normal file
36
api/src/modules/ws/wsServer.ts
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import { Server as HTTPServer } from "http";
|
||||||
|
import WebSocket, { WebSocketServer } from "ws";
|
||||||
|
|
||||||
|
// Keep track of connected clients
|
||||||
|
interface Client {
|
||||||
|
ws: WebSocket;
|
||||||
|
type: "display" | "user";
|
||||||
|
userId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const clients: Client[] = [];
|
||||||
|
|
||||||
|
export function setupWsServer(server: HTTPServer) {
|
||||||
|
const wss = new WebSocketServer({ server });
|
||||||
|
|
||||||
|
wss.on("connection", (ws, req) => {
|
||||||
|
// Example: client can send type info after connecting
|
||||||
|
ws.on("message", (msg) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(msg.toString());
|
||||||
|
if (data.type === "register") {
|
||||||
|
const type = data.view === "display" ? "display" : "user";
|
||||||
|
const userId = type === "user" ? data.userId : undefined;
|
||||||
|
clients.push({ ws, type, userId });
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on("close", () => {
|
||||||
|
const idx = clients.findIndex(c => c.ws === ws);
|
||||||
|
if (idx !== -1) clients.splice(idx, 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("WebSocket server running");
|
||||||
|
}
|
||||||
7
api/src/routes.ts
Normal file
7
api/src/routes.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import { pool } from "./db";
|
||||||
|
import { v4 as uuid } from "uuid";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
export default router;
|
||||||
10
api/tsconfig.json
Normal file
10
api/tsconfig.json
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "dist",
|
||||||
|
"module": "commonjs",
|
||||||
|
"target": "es2019",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"strict": true,
|
||||||
|
"rootDir": "src"
|
||||||
|
}
|
||||||
|
}
|
||||||
2
app/Dockerfile
Normal file
2
app/Dockerfile
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
FROM nginx:alpine
|
||||||
|
COPY public /usr/share/nginx/html
|
||||||
43
docker-compose.yml
Normal file
43
docker-compose.yml
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
version: "3.9"
|
||||||
|
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:16
|
||||||
|
container_name: ghetto-karaoke-db-net-postgres
|
||||||
|
env_file: ./.env
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: ${PG_USER}
|
||||||
|
POSTGRES_PASSWORD: ${PG_PASSWORD}
|
||||||
|
POSTGRES_DB: ${PG_DB}
|
||||||
|
volumes:
|
||||||
|
- ghetto-karaoke-db-net-db:/var/lib/postgresql/data
|
||||||
|
networks: [ghetto-karaoke-db-net]
|
||||||
|
|
||||||
|
|
||||||
|
api:
|
||||||
|
build: ./api
|
||||||
|
container_name: ghetto-karaoke-db-net-api
|
||||||
|
env_file: ./.env
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgres://${PG_USER}:${PG_PASSWORD}@${PG_HOST}:${PG_PORT}/${PG_DB}
|
||||||
|
depends_on: [db]
|
||||||
|
networks: [ghetto-karaoke-db-net]
|
||||||
|
ports:
|
||||||
|
- "${API_PORT}:8080"
|
||||||
|
|
||||||
|
|
||||||
|
app:
|
||||||
|
build: ./app
|
||||||
|
container_name: ghetto-karaoke-db-net-app
|
||||||
|
env_file: ./.env
|
||||||
|
networks: [ghetto-karaoke-db-net]
|
||||||
|
depends_on: [api]
|
||||||
|
ports:
|
||||||
|
- "${APP_PORT}:80"
|
||||||
|
|
||||||
|
|
||||||
|
networks:
|
||||||
|
ghetto-karaoke-db-net:
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
ghetto-karaoke-db-volume:
|
||||||
Loading…
Reference in New Issue
Block a user