Compare commits

...

3 Commits

Author SHA1 Message Date
e623ca717e Finish backend _in theory_ (we need more testing) 2025-12-09 22:52:33 +01:00
fec862e223 Auto update song on move 2025-12-09 22:20:24 +01:00
8271b1e147 Do the backend, sorta 2025-12-09 22:16:44 +01:00
21 changed files with 2296 additions and 0 deletions

15
.env.example Normal file
View 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
View File

@ -0,0 +1,2 @@
*/node_modules/
.env

13
api/Dockerfile Normal file
View 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

File diff suppressed because it is too large Load Diff

30
api/package.json Normal file
View 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
View 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
View 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}`));
})();

View File

@ -0,0 +1,8 @@
export interface QueueSongRequest {
userId: string,
fingerprint: string,
artist: string,
title: string,
comment: string,
youtubeLink?: string
}

View 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" });
}
};
}

View 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" });
}
}

View 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
View 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" });
}
}

View 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;

View 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" });
}
}

View 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;

View 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 })));
}

View 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
View 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
View File

@ -0,0 +1,10 @@
{
"compilerOptions": {
"outDir": "dist",
"module": "commonjs",
"target": "es2019",
"esModuleInterop": true,
"strict": true,
"rootDir": "src"
}
}

2
app/Dockerfile Normal file
View File

@ -0,0 +1,2 @@
FROM nginx:alpine
COPY public /usr/share/nginx/html

43
docker-compose.yml Normal file
View 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: