diff --git a/packages/music_server/src/api.js b/packages/music_server/src/api.js index c2a552b8..3cc1a8ae 100755 --- a/packages/music_server/src/api.js +++ b/packages/music_server/src/api.js @@ -6,32 +6,107 @@ import http from "http" import EventEmitter from "@foxify/events" import ComtyClient from "@classes/ComtyClient" - -import routes from "./routes" +import DbManager from "@classes/DbManager" +import RedisClient from "@classes/RedisClient" +import StorageClient from "@classes/StorageClient" import RoomServer from "./roomsServer" -export default class Server { - constructor(options = {}) { - this.app = express() - this.httpServer = http.createServer(this.app) - this.websocketServer = new RoomServer(this.httpServer) +import pkg from "../package.json" + +export default class Server { + static useMiddlewaresOrder = ["useLogger", "useCors", "useAuth"] + + eventBus = global.eventBus = new EventEmitter() + + internalRouter = express.Router() + + constructor(options = {}) { + this.server = express() + this._http = http.createServer(this.server) + + this.websocketServer = new RoomServer(this._http) this.options = { - listenHost: process.env.LISTEN_HOST || "0.0.0.0", - listenPort: process.env.LISTEN_PORT || 3050, + listenHost: process.env.HTTP_LISTEN_IP ?? "0.0.0.0", + listenPort: process.env.HTTP_LISTEN_PORT ?? 3050, ...options } } comty = global.comty = ComtyClient() - eventBus = global.eventBus = new EventEmitter() + db = new DbManager() + + redis = global.redis = RedisClient() + + storage = global.storage = StorageClient() + + async __registerControllers() { + let controllersPath = fs.readdirSync(path.resolve(__dirname, "controllers")) + + this.internalRouter.routes = [] + + for await (const controllerPath of controllersPath) { + const controller = require(path.resolve(__dirname, "controllers", controllerPath)).default + + if (!controller) { + console.error(`Controller ${controllerPath} not found.`) + + continue + } + + const handler = await controller(express.Router()) + + if (!handler) { + console.error(`Controller ${controllerPath} returning not valid handler.`) + + continue + } + + // let middlewares = [] + + // if (Array.isArray(handler.useMiddlewares)) { + // middlewares = await getMiddlewares(handler.useMiddlewares) + // } + + // for (const middleware of middlewares) { + // handler.router.use(middleware) + // } + + this.internalRouter.use(handler.path ?? "/", handler.router) + + this.internalRouter.routes.push({ + path: handler.path ?? "/", + routers: handler.router.routes + }) + + continue + } + } async __registerInternalMiddlewares() { let middlewaresPath = fs.readdirSync(path.resolve(__dirname, "useMiddlewares")) + // sort middlewares + if (this.constructor.useMiddlewaresOrder) { + middlewaresPath = middlewaresPath.sort((a, b) => { + const aIndex = this.constructor.useMiddlewaresOrder.indexOf(a.replace(".js", "")) + const bIndex = this.constructor.useMiddlewaresOrder.indexOf(b.replace(".js", "")) + + if (aIndex === -1) { + return 1 + } + + if (bIndex === -1) { + return -1 + } + + return aIndex - bIndex + }) + } + for await (const middlewarePath of middlewaresPath) { const middleware = require(path.resolve(__dirname, "useMiddlewares", middlewarePath)).default @@ -41,31 +116,42 @@ export default class Server { continue } - this.app.use(middleware) + this.server.use(middleware) } } - registerRoutes() { - routes.forEach((route) => { - const order = [] + __registerInternalRoutes() { + this.internalRouter.get("/", (req, res) => { + return res.status(200).json({ + name: pkg.name, + version: pkg.version, + }) + }) - if (route.middlewares) { - route.middlewares.forEach((middleware) => { - order.push(middleware) - }) - } + this.internalRouter.get("/_routes", (req, res) => { + return res.status(200).json(this.__getRegisteredRoutes(this.internalRouter.routes)) + }) - order.push(route.routes) - - this.app.use(route.use, ...order) + this.internalRouter.get("*", (req, res) => { + return res.status(404).json({ + error: "Not found", + }) }) } - async registerBaseRoute() { - await this.app.get("/", async (req, res) => { - return res.json({ - uptimeMinutes: Math.floor(process.uptime() / 60), - }) + __getRegisteredRoutes(router) { + return router.map((entry) => { + if (Array.isArray(entry.routers)) { + return { + path: entry.path, + routes: this.__getRegisteredRoutes(entry.routers), + } + } + + return { + method: entry.method, + path: entry.path, + } }) } @@ -74,15 +160,22 @@ export default class Server { await this.websocketServer.initialize() + // initialize clients + await this.db.initialize() + await this.redis.initialize() + await this.storage.initialize() + + // register controllers & middlewares + await this.__registerControllers() await this.__registerInternalMiddlewares() + await this.__registerInternalRoutes() - this.app.use(express.json({ extended: false })) - this.app.use(express.urlencoded({ extended: true })) + this.server.use(this.internalRouter) - await this.registerBaseRoute() - await this.registerRoutes() + this.server.use(express.json({ extended: false })) + this.server.use(express.urlencoded({ extended: true })) - await this.httpServer.listen(this.options.listenPort, this.options.listenHost) + await this._http.listen(this.options.listenPort, this.options.listenHost) // calculate elapsed time const elapsedHrTime = process.hrtime(startHrTime) diff --git a/packages/music_server/src/controllers/lyrics/index.js b/packages/music_server/src/controllers/lyrics/index.js new file mode 100644 index 00000000..19ed6e30 --- /dev/null +++ b/packages/music_server/src/controllers/lyrics/index.js @@ -0,0 +1,21 @@ +import path from "path" +import createRoutesFromDirectory from "@utils/createRoutesFromDirectory" +import getMiddlewares from "@utils/getMiddlewares" + +export default async (router) => { + // create a file based router + const routesPath = path.resolve(__dirname, "routes") + + const middlewares = await getMiddlewares(["withOptionalAuth"]) + + for (const middleware of middlewares) { + router.use(middleware) + } + + router = createRoutesFromDirectory("routes", routesPath, router) + + return { + path: "/lyrics", + router, + } +} \ No newline at end of file diff --git a/packages/music_server/src/controllers/lyrics/routes/get/:track_id.js b/packages/music_server/src/controllers/lyrics/routes/get/:track_id.js new file mode 100644 index 00000000..89045a66 --- /dev/null +++ b/packages/music_server/src/controllers/lyrics/routes/get/:track_id.js @@ -0,0 +1,196 @@ +const syncLyricsProvider = `https://spotify-lyric-api.herokuapp.com` +const canvasProvider = `https://api.delitefully.com/api/canvas` + +import { Track } from "@models" +import axios from "axios" + +const clearQueryRegexs = [ + // remove titles with (feat. Something) + new RegExp(/\(feat\..*\)/, "gi"), + // remplace $ with S + new RegExp(/\$/, "gi"), + // remove special characters + new RegExp(/[\(\)\[\]\$\&\*\#\@\!\%\+\=\_\-\:\;\'\"\,\.]/, "gi"), + // remove words like "official video", "official audio", "official music video" + new RegExp(/official\s(video|audio|music\svideo)/, "gi"), +] + +async function findSpotifyTrack({ + title, + artist, + sessionToken, +} = {}) { + let query = `${title} artist:${artist}` + + // run clear query regexs + for (const regex of clearQueryRegexs) { + query = query.replace(regex, "") + } + + const { data } = await global.comty.instances.default({ + method: "GET", + headers: { + "Authorization": `Bearer ${sessionToken}`, + }, + params: { + query: query, + type: "track", + }, + url: "/sync/spotify/search", + }).catch((error) => { + console.error(error.response.data) + + return null + }) + + if (!data) { + return null + } + + return data.tracks.items[0] +} + +export default async (req, res) => { + const noCache = req.query["no-cache"] === "true" + + let track = await Track.findOne({ + _id: req.params.track_id, + }).catch((error) => { + return null + }) + + if (!track) { + return res.status(404).json({ + error: "Track not found", + }) + } + + console.log(track) + + if (!track.lyricsEnabled){ + return res.status(403).json({ + error: "Lyrics disabled for this track", + }) + } + + //console.log("Found track", track) + + track = track.toObject() + + let lyricData = { + syncType: null, + lines: null, + canvas_url: null, + } + + let cachedData = null + + try { + if (!noCache) { + cachedData = await global.redis.get(`lyrics:${track._id}`) + + if (cachedData) { + lyricData = JSON.parse(cachedData) + } + + if (track.videoCanvas) { + lyricData.canvas_url = track.videoCanvas + } + } + + if (!cachedData) { + // no cache, recosntruct lyrics data + + // first check if track has spotify id to fetch the lyrics + // if not present, try to search from spotify api and update the track with the spotify id + if (!track.spotifyId) { + if (!req.session) { + throw new Error("Session not found and track has no spotify id") + } + + console.log("Fetching spotify track") + + const spotifyTrack = await findSpotifyTrack({ + title: track.title, + artist: track.artist, + sessionToken: req.sessionToken, + }) + + console.log(spotifyTrack) + + if (spotifyTrack.id) { + track.spotifyId = spotifyTrack.id + + console.log("Updating track with spotify id") + + const result = await Track.findOneAndUpdate({ + _id: track._id.toString(), + }, { + spotifyId: spotifyTrack.id, + }) + + console.log(result) + } else { + throw new Error("Failed to search spotify id") + } + } + + // ok now we have the spotify id, try to fetch the lyrics + console.log("Fetching lyrics from sync provider, ID:", track.spotifyId) + + let { data } = await axios.get(`${syncLyricsProvider}/?trackid=${track.spotifyId}`) + + lyricData.syncType = data.syncType + lyricData.lines = data.lines + + // so we have the lyrics, now check if track has videoCanvas + // if not present, try to fetch from canvas provider and update the track with the videoCanvas + // handle errors silently + if (track.videoCanvas) { + lyricData.canvas_url = track.videoCanvas + } else { + try { + console.log("Fetching canvas for id", track.spotifyId) + + const { data } = await axios.get(`${canvasProvider}/${track.spotifyId}`) + + lyricData.canvas_url = data.canvas_url + + console.log("Updating track with canvas url") + + await Track.findOneAndUpdate({ + _id: track._id.toString(), + }, { + videoCanvas: data.canvas_url, + }) + } catch (error) { + console.error(error) + } + } + + // force rewrite cache + await global.redis.set(`lyrics:${track._id}`, JSON.stringify(data)) + + // check + // const _cachedData = await global.redis.get(`lyrics:${track._id}`) + + // console.log("Cached data", _cachedData, data) + } + } catch (error) { + console.error(error) + + return res.status(500).json({ + error: `Failed to generate lyrics for track ${track._id}`, + }) + } + + if (!lyricData.lines) { + return res.status(404).json({ + error: "Lyrics not found", + }) + } + + //console.log("Lyrics data", lyricData) + + return res.json(lyricData) +} \ No newline at end of file diff --git a/packages/music_server/src/controllers/playlists/get/:playlist_id.js b/packages/music_server/src/controllers/playlists/get/:playlist_id.js new file mode 100644 index 00000000..e69de29b diff --git a/packages/music_server/src/controllers/playlists/index.js b/packages/music_server/src/controllers/playlists/index.js new file mode 100644 index 00000000..93eb188c --- /dev/null +++ b/packages/music_server/src/controllers/playlists/index.js @@ -0,0 +1,14 @@ +import path from "path" +import createRoutesFromDirectory from "@utils/createRoutesFromDirectory" + +export default (router) => { + // create a file based router + const routesPath = path.resolve(__dirname, "routes") + + // router = createRoutesFromDirectory("routes", routesPath, router) + + return { + path: "/playlists", + router, + } +} \ No newline at end of file diff --git a/packages/music_server/src/index.js b/packages/music_server/src/index.js index 7c642795..fa8f0637 100755 --- a/packages/music_server/src/index.js +++ b/packages/music_server/src/index.js @@ -9,7 +9,10 @@ global.isProduction = process.env.NODE_ENV === "production" import path from "path" import { registerBaseAliases } from "linebridge/dist/server" +globalThis["__root"] = path.resolve(__dirname) + const customAliases = { + "root": globalThis["__root"], "@services": path.resolve(__dirname, "services"), } @@ -57,6 +60,32 @@ async function main() { const api = new API() await api.initialize() + + // kill on process exit + process.on("exit", () => { + api.server.close() + process.exit(0) + }) + + // kill on ctrl+c + process.on("SIGINT", () => { + api.server.close() + process.exit(0) + }) + + // kill on uncaught exceptions + process.on("uncaughtException", (error) => { + console.error(`🆘 [FATAL ERROR] >`, error) + api.server.close() + process.exit(1) + }) + + // kill on unhandled rejections + process.on("unhandledRejection", (error) => { + console.error(`🆘 [FATAL ERROR] >`, error) + api.server.close() + process.exit(1) + }) } main().catch((error) => { diff --git a/packages/music_server/src/middlewares/withAuth/index.js b/packages/music_server/src/middlewares/withAuth/index.js new file mode 100644 index 00000000..44ee47ff --- /dev/null +++ b/packages/music_server/src/middlewares/withAuth/index.js @@ -0,0 +1,25 @@ +export default async function (req, res, next) { + // extract authentification header + let auth = req.headers.authorization + + if (!auth) { + return res.status(401).json({ error: "Unauthorized, missing token" }) + } + + auth = auth.replace("Bearer ", "") + + // check if authentification is valid + const validation = await comty.rest.session.validateToken(auth).catch((error) => { + return { + valid: false, + } + }) + + if (!validation.valid) { + return res.status(401).json({ error: "Unauthorized" }) + } + + req.session = validation.session + + return next() +} \ No newline at end of file diff --git a/packages/music_server/src/middlewares/withOptionalAuth/index.js b/packages/music_server/src/middlewares/withOptionalAuth/index.js new file mode 100644 index 00000000..cbc4bb49 --- /dev/null +++ b/packages/music_server/src/middlewares/withOptionalAuth/index.js @@ -0,0 +1,26 @@ +export default async function (req, res, next) { + // extract authentification header + let auth = req.headers.authorization + + if (!auth) { + return next() + } + + auth = auth.replace("Bearer ", "") + + // check if authentification is valid + const validation = await comty.rest.session.validateToken(auth).catch((error) => { + return { + valid: false, + } + }) + + if (!validation.valid) { + return next() + } + + req.sessionToken = auth + req.session = validation.session + + return next() +} \ No newline at end of file diff --git a/packages/music_server/src/models/index.js b/packages/music_server/src/models/index.js new file mode 100755 index 00000000..e172c9be --- /dev/null +++ b/packages/music_server/src/models/index.js @@ -0,0 +1,19 @@ +import mongoose, { Schema } from "mongoose" +import fs from "fs" +import path from "path" + +function generateModels() { + let models = {} + + const dirs = fs.readdirSync(__dirname).filter(file => file !== "index.js") + + dirs.forEach((file) => { + const model = require(path.join(__dirname, file)).default + + models[model.name] = mongoose.model(model.name, new Schema(model.schema), model.collection) + }) + + return models +} + +module.exports = generateModels() \ No newline at end of file diff --git a/packages/music_server/src/models/playlist/index.js b/packages/music_server/src/models/playlist/index.js new file mode 100755 index 00000000..db51f78d --- /dev/null +++ b/packages/music_server/src/models/playlist/index.js @@ -0,0 +1,34 @@ +export default { + name: "Playlist", + collection: "playlists", + schema: { + user_id: { + type: String, + required: true + }, + title: { + type: String, + required: true + }, + description: { + type: String + }, + list: { + type: Object, + default: [], + required: true + }, + thumbnail: { + type: String, + default: "https://storage.ragestudio.net/comty-static-assets/default_song.png" + }, + created_at: { + type: Date, + required: true + }, + public: { + type: Boolean, + default: true, + }, + } +} \ No newline at end of file diff --git a/packages/music_server/src/models/track/index.js b/packages/music_server/src/models/track/index.js new file mode 100755 index 00000000..3179d131 --- /dev/null +++ b/packages/music_server/src/models/track/index.js @@ -0,0 +1,49 @@ +export default { + name: "Track", + collection: "tracks", + schema: { + user_id: { + type: String, + required: true, + }, + title: { + type: String, + required: true, + }, + album: { + type: String, + }, + artist: { + type: String, + }, + source: { + type: String, + required: true, + }, + metadata: { + type: Object, + }, + explicit: { + type: Boolean, + default: false, + }, + public: { + type: Boolean, + default: true, + }, + thumbnail: { + type: String, + default: "https://storage.ragestudio.net/comty-static-assets/default_song.png" + }, + videoCanvas: { + type: String, + }, + spotifyId: { + type: String, + }, + lyricsEnabled: { + type: Boolean, + default: true, + } + } +} \ No newline at end of file diff --git a/packages/music_server/src/routes/index.js b/packages/music_server/src/routes/index.js deleted file mode 100755 index 96c728da..00000000 --- a/packages/music_server/src/routes/index.js +++ /dev/null @@ -1,3 +0,0 @@ -export default [ - -] \ No newline at end of file diff --git a/packages/music_server/src/services/getTrackById.js b/packages/music_server/src/services/getTrackById.js new file mode 100644 index 00000000..b4c8e973 --- /dev/null +++ b/packages/music_server/src/services/getTrackById.js @@ -0,0 +1,25 @@ +import { Track, User } from "@models" + +export default async (_id) => { + if (!_id) { + throw new Error("Missing _id") + } + + let track = await Track.findById(_id).catch((err) => false) + + if (!track) { + throw new Error("Track not found") + } + + track = track.toObject() + + if (!track.metadata) { + // TODO: Get metadata from source + } + + const userData = await User.findById(track.user_id).catch((err) => false) + + track.artist = track.artist ?? userData?.fullName ?? userData?.username ?? "Unknown artist" + + return track +} \ No newline at end of file diff --git a/packages/music_server/src/utils/createRoutesFromDirectory/index.js b/packages/music_server/src/utils/createRoutesFromDirectory/index.js new file mode 100644 index 00000000..e3b30e1e --- /dev/null +++ b/packages/music_server/src/utils/createRoutesFromDirectory/index.js @@ -0,0 +1,54 @@ +import fs from "fs" + +function createRoutesFromDirectory(startFrom, directoryPath, router) { + const files = fs.readdirSync(directoryPath) + + if (typeof router.routes !== "object") { + router.routes = [] + } + + files.forEach((file) => { + const filePath = `${directoryPath}/${file}` + + const stat = fs.statSync(filePath) + + if (stat.isDirectory()) { + createRoutesFromDirectory(startFrom, filePath, router) + } else if (file.endsWith(".js") || file.endsWith(".jsx") || file.endsWith(".ts") || file.endsWith(".tsx")) { + let splitedFilePath = filePath.split("/") + + // slice the startFrom path + splitedFilePath = splitedFilePath.slice(splitedFilePath.indexOf(startFrom) + 1) + + const method = splitedFilePath[0] + + let route = splitedFilePath.slice(1, splitedFilePath.length).join("/") + + route = route.replace(".jsx", "") + route = route.replace(".js", "") + route = route.replace(".ts", "") + route = route.replace(".tsx", "") + + if (route === "index") { + route = "/" + } else { + route = `/${route}` + } + + let handler = require(filePath) + + handler = handler.default || handler + + router[method](route, handler) + + router.routes.push({ + method, + path: route, + }) + } + }) + + return router +} + +export default createRoutesFromDirectory \ No newline at end of file diff --git a/packages/music_server/src/utils/getMiddlewares/index.js b/packages/music_server/src/utils/getMiddlewares/index.js new file mode 100644 index 00000000..9a9a07f9 --- /dev/null +++ b/packages/music_server/src/utils/getMiddlewares/index.js @@ -0,0 +1,46 @@ +import fs from "node:fs" +import path from "node:path" + +export default async (middlewares, middlewaresPath) => { + if (typeof middlewaresPath === "undefined") { + middlewaresPath = path.resolve(globalThis["__root"], "middlewares") + } + + if (!fs.existsSync(middlewaresPath)) { + return undefined + } + + if (typeof middlewares === "string") { + middlewares = [middlewares] + } + + let fns = [] + + for await (const middlewareName of middlewares) { + const middlewarePath = path.resolve(middlewaresPath, middlewareName) + + if (!fs.existsSync(middlewarePath)) { + console.error(`Middleware ${middlewareName} not found.`) + + continue + } + + const middleware = require(middlewarePath).default + + if (!middleware) { + console.error(`Middleware ${middlewareName} not valid export.`) + + continue + } + + if (typeof middleware !== "function") { + console.error(`Middleware ${middlewareName} not valid function.`) + + continue + } + + fns.push(middleware) + } + + return fns +} \ No newline at end of file diff --git a/packages/music_server/src/utils/resolveUrl/index.js b/packages/music_server/src/utils/resolveUrl/index.js new file mode 100644 index 00000000..a9a33785 --- /dev/null +++ b/packages/music_server/src/utils/resolveUrl/index.js @@ -0,0 +1,20 @@ +export default (from, to) => { + const resolvedUrl = new URL(to, new URL(from, "resolve://")) + + if (resolvedUrl.protocol === "resolve:") { + let { pathname, search, hash } = resolvedUrl + + if (to.includes("@")) { + const fromUrl = new URL(from) + const toUrl = new URL(to, fromUrl.origin) + + pathname = toUrl.pathname + search = toUrl.search + hash = toUrl.hash + } + + return pathname + search + hash + } + + return resolvedUrl.toString() +} \ No newline at end of file