implement music_server

This commit is contained in:
SrGooglo 2023-05-28 01:31:10 +00:00
parent e9deddafec
commit 0911883453
16 changed files with 683 additions and 35 deletions

View File

@ -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))
})
this.internalRouter.get("*", (req, res) => {
return res.status(404).json({
error: "Not found",
})
})
}
order.push(route.routes)
this.app.use(route.use, ...order)
})
__getRegisteredRoutes(router) {
return router.map((entry) => {
if (Array.isArray(entry.routers)) {
return {
path: entry.path,
routes: this.__getRegisteredRoutes(entry.routers),
}
}
async registerBaseRoute() {
await this.app.get("/", async (req, res) => {
return res.json({
uptimeMinutes: Math.floor(process.uptime() / 60),
})
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)

View File

@ -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,
}
}

View File

@ -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)
}

View File

@ -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,
}
}

View File

@ -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) => {

View File

@ -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()
}

View File

@ -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()
}

View File

@ -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()

View File

@ -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,
},
}
}

View File

@ -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,
}
}
}

View File

@ -1,3 +0,0 @@
export default [
]

View File

@ -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
}

View File

@ -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

View File

@ -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
}

View File

@ -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()
}