mirror of
https://github.com/tamius-han/ghetto-karaoke-playlist.git
synced 2026-08-12 18:47:04 +02:00
Do the backend, sorta
This commit is contained in:
parent
c4c6ce13f9
commit
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"]
|
||||||
1469
api/package-lock.json
generated
Normal file
1469
api/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
29
api/package.json
Normal file
29
api/package.json
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
"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;
|
||||||
|
|
||||||
159
api/src/modules/dj/dj.ts
Normal file
159
api/src/modules/dj/dj.ts
Normal file
@ -0,0 +1,159 @@
|
|||||||
|
import { pool as db } from "../../db";
|
||||||
|
|
||||||
|
export async function lockSong(id: number, locked: boolean, res: any) {
|
||||||
|
if (typeof locked !== "boolean") return res.status(400).json({ error: "locked must be boolean" });
|
||||||
|
|
||||||
|
try {
|
||||||
|
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" });
|
||||||
|
|
||||||
|
res.json({ song: result.rows[0] });
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: "Server error" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function moveSong(id: number, position: number, res: any) {
|
||||||
|
try {
|
||||||
|
const result = await db.query(
|
||||||
|
`UPDATE songs SET position=$1 WHERE id=$2 RETURNING *`,
|
||||||
|
[position, 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 changeSongStatus(id: number, 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️⃣ Find currently playing song
|
||||||
|
const currentRes = await db.query(`SELECT * FROM songs WHERE status=1 ORDER BY position ASC LIMIT 1`);
|
||||||
|
const currentSong = currentRes.rows[0];
|
||||||
|
|
||||||
|
if (currentSong) {
|
||||||
|
// Mark current as played
|
||||||
|
await db.query(`UPDATE songs SET status=2 WHERE id=$1`, [currentSong.id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2️⃣ Find next queued song
|
||||||
|
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" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark next as playing
|
||||||
|
await db.query(`UPDATE songs SET status=1 WHERE id=$1`, [nextSong.id]);
|
||||||
|
|
||||||
|
// 3️⃣ Apply lockCount
|
||||||
|
if (lockCount > 0) {
|
||||||
|
const songsToLock = await db.query(
|
||||||
|
`SELECT * FROM songs WHERE status=0 ORDER BY position ASC LIMIT $1`,
|
||||||
|
[lockCount]
|
||||||
|
);
|
||||||
|
for (const song of songsToLock.rows) {
|
||||||
|
await db.query(`UPDATE songs SET locked=true WHERE id=$1`, [song.id]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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");
|
||||||
|
|
||||||
|
// Find currently playing song
|
||||||
|
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" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find previous played song (highest position < current)
|
||||||
|
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" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Swap statuses
|
||||||
|
await db.query(`UPDATE songs SET status=0 WHERE id=$1`, [currentSong.id]); // back to queued
|
||||||
|
await db.query(`UPDATE songs SET status=1 WHERE id=$1`, [prevSong.id]); // now playing
|
||||||
|
|
||||||
|
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: number, 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" });
|
||||||
|
|
||||||
|
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 "./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;
|
||||||
102
api/src/modules/public-queue/public-queue.ts
Normal file
102
api/src/modules/public-queue/public-queue.ts
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
import {v4 as uuid} from 'uuid';
|
||||||
|
import { QueueSongRequest as QueueSongRequestData } from '../../itf.queue-song-request';
|
||||||
|
import { pool as db } from '../../db';
|
||||||
|
|
||||||
|
|
||||||
|
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]
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({ song: result.rows[0] });
|
||||||
|
} 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;
|
||||||
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