diff --git a/README.md b/README.md index 3bb8f99..f4c81df 100644 --- a/README.md +++ b/README.md @@ -17,4 +17,8 @@ - Multiple Lavalink nodes support - Configure-able response timeout - Vercel Serverless support -- Use preferred custom node using `x-node-name` headers \ No newline at end of file +- Use preferred custom node using `x-node-name` headers + +# Redis Caching (NEW) +- Tracks cache are guaranteed not to be bypassed +- Redis must be using `RedisSearch` and `RedisJSON` \ No newline at end of file diff --git a/package.json b/package.json index b1197b4..9146faf 100644 --- a/package.json +++ b/package.json @@ -2,9 +2,9 @@ "name": "nezly", "version": "1.0.0", "description": "A REST Proxy container for the Lavalink REST API.", - "main": "api/index.js", + "main": "dist/api/index.js", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1", + "compile": "rimraf dist && tsc", "lint": "eslint **/*.ts", "lint:fix": "eslint **/*.ts --fix" }, @@ -53,6 +53,7 @@ "dotenv": "^16.0.1", "express": "^4.18.1", "lavalink-api-types": "^1.1.2", + "redis-om": "^0.3.6", "reflect-metadata": "^0.1.13", "rxjs": "^7.5.6", "undici": "^5.10.0" diff --git a/src/Entities/Playlist.ts b/src/Entities/Playlist.ts new file mode 100644 index 0000000..1f655ac --- /dev/null +++ b/src/Entities/Playlist.ts @@ -0,0 +1,10 @@ +import { Entity, Schema } from "redis-om"; + +export class Playlist extends Entity { } + +export const PlaylistSchema = new Schema(Playlist, { + playlistName: { type: "string" }, + playlistUrl: { type: "string" }, + playlistSelectedTrack: { type: "number" }, + tracks: { type: "string[]" } +}); diff --git a/src/Entities/Track.ts b/src/Entities/Track.ts new file mode 100644 index 0000000..75c9a27 --- /dev/null +++ b/src/Entities/Track.ts @@ -0,0 +1,17 @@ +import { Entity, Schema } from "redis-om"; + +export class Track extends Entity { } + +export const TrackSchema = new Schema(Track, { + track: { type: "string" }, + identifier: { type: "string" }, + isSeekable: { type: "boolean" }, + author: { type: "string" }, + length: { type: "number" }, + isStream: { type: "boolean" }, + position: { type: "number" }, + title: { type: "text" }, + uri: { type: "text" }, + sourceName: { type: "string" }, + artworkUrl: { type: "string" } +}); diff --git a/src/app.cache.service.ts b/src/app.cache.service.ts new file mode 100644 index 0000000..86047c7 --- /dev/null +++ b/src/app.cache.service.ts @@ -0,0 +1,26 @@ +import { Injectable } from "@nestjs/common"; +import { Result } from "@sapphire/result"; +import { Client, Repository } from "redis-om"; +import { PlaylistSchema } from "./Entities/Playlist"; +import { TrackSchema, Track } from "./Entities/Track"; + +@Injectable() +export class AppCacheService { + public client = new Client(); + public constructor( + ) { + if (process.env.REDIS_URL) { + void Result.fromAsync(() => this.client.open(process.env.REDIS_URL)); + void Result.fromAsync(() => this.client.fetchRepository(TrackSchema).createIndex()); + void Result.fromAsync(() => this.client.fetchRepository(PlaylistSchema).createIndex()); + } + } + + public getTrackRepository(): Repository { + return this.client.fetchRepository(TrackSchema); + } + + public getPlaylistTrackRepository(): Repository { + return this.client.fetchRepository(PlaylistSchema); + } +} diff --git a/src/app.controller.ts b/src/app.controller.ts index a130a46..216165a 100644 --- a/src/app.controller.ts +++ b/src/app.controller.ts @@ -1,3 +1,6 @@ +/* eslint-disable @typescript-eslint/no-unnecessary-condition */ +/* eslint-disable no-nested-ternary */ +/* eslint-disable max-len */ /* eslint-disable @typescript-eslint/no-unsafe-member-access */ import { Body, Controller, Get, Post, Query, Req, Res } from "@nestjs/common"; import { Response, Request } from "express"; @@ -6,11 +9,15 @@ import { AppNodeService } from "./app.node.service"; import { REST } from "@kirishima/rest"; import { Result } from "@sapphire/result"; import { Time } from "@sapphire/time-utilities"; +import { AppCacheService } from "./app.cache.service"; @Controller() export class AppController { - public constructor(private readonly appNodeService: AppNodeService) {} + public constructor( + private readonly appNodeService: AppNodeService, + private readonly appCacheService: AppCacheService + ) {} @Get() public getIndex(@Res() res: Response): Response { @@ -28,18 +35,54 @@ export class AppController { try { if (req.headers.authorization !== process.env.AUTHORIZATION) return res.sendStatus(401); if (!identifier || (resolveAttempt && resolveAttempt > 3)) return res.json({ playlistInfo: {}, loadType: LoadTypeEnum.NO_MATCHES, tracks: [] }); - const node = this.appNodeService.getLavalinkNode(req.headers["x-node-name"] as string, excludeNode); - const nodeRest = new REST(node.secure ? `https://${node.host}` : `http://${node.host}`) - .setAuthorization(node.auth); const source = identifier.split(":")[0]; const query = identifier.split(":")[1]; + const cachedTracks = this.appCacheService.client.isOpen() + ? await this.appCacheService.getTrackRepository() + .search() + .where("title") + .matches(query) + .and("sourceName") + .equalTo("youtube") + .return.all() + : []; + + if (cachedTracks.length) { + return res + .header({ "X-Cache-Hits": true }) + .json({ + // TODO: rework this + loadType: LoadTypeEnum.SEARCH_RESULT, + tracks: cachedTracks.map(x => ({ + info: { + identifier: x.toJSON().identifier, + isSeekable: x.toJSON().isSeekable, + author: x.toJSON().author, + length: x.toJSON().length, + isStream: x.toJSON().isStream, + position: x.toJSON().position, + title: x.toJSON().title, + uri: x.toJSON().uri, + sourceName: x.toJSON().sourceName, + artworkUrl: x.toJSON().artworkUrl + }, + track: x.toJSON().track + })) + }); + } + + const node = this.appNodeService.getLavalinkNode(req.headers["x-node-name"] as string, excludeNode); + const nodeRest = new REST(node.secure ? `https://${node.host}` : `http://${node.host}`) + .setAuthorization(node.auth); + const timeout = setTimeout(() => Result.fromAsync(this.getLoadTracks(res, req, identifier, node.name)), Time.Second * Number(process.env.TIMEOUT_SECONDS ?? 3)); const result = await nodeRest.loadTracks(source ? { source, query } : identifier); clearTimeout(timeout); if (!result.tracks.length) return await this.getLoadTracks(res, req, identifier, node.name, (resolveAttempt ?? 0) + 1); + if (this.appCacheService.client.isOpen()) for (const track of result.tracks) await this.appCacheService.getTrackRepository().createAndSave({ track: track.track, ...track.info }); return res.json(result); } catch (e) { return res.status(500).json({ status: 500, message: e.message }); @@ -55,15 +98,43 @@ export class AppController { ): Promise { try { if (req.headers.authorization !== process.env.AUTHORIZATION) return res.sendStatus(401); + + const cachedTrack = this.appCacheService.client.isOpen() + ? await this.appCacheService.getTrackRepository() + .search() + .where("track") + .equalTo(track) + .return.first() + : null; + + if (cachedTrack) { + return res + .header({ "X-Cache-Hits": true }) + .json({ + identifier: cachedTrack.toJSON().identifier, + isSeekable: cachedTrack.toJSON().isSeekable, + author: cachedTrack.toJSON().author, + length: cachedTrack.toJSON().length, + isStream: cachedTrack.toJSON().isStream, + position: cachedTrack.toJSON().position, + title: cachedTrack.toJSON().title, + uri: cachedTrack.toJSON().uri, + sourceName: cachedTrack.toJSON().sourceName, + artworkUrl: cachedTrack.toJSON().artworkUrl + }); + } + const node = this.appNodeService.getLavalinkNode(req.headers["x-node-name"] as string, excludeNode); const nodeRest = new REST(node.secure ? `https://${node.host}` : `http://${node.host}`) .setAuthorization(node.auth); const timeout = setTimeout(() => Result.fromAsync(this.getDecodeTrack(res, req, track, node.name)), Time.Second * Number(process.env.TIMEOUT_SECONDS ?? 3)); - const result = await nodeRest.decodeTracks([track]); + const results = await nodeRest.decodeTracks([track]); clearTimeout(timeout); - return res.json(result[0].info); + if (this.appCacheService.client.isOpen()) for (const lavalinkTrack of results) await this.appCacheService.getTrackRepository().createAndSave({ track, ...lavalinkTrack.info }); + + return res.json(results[0].info); } catch (e) { return res.status(500).json({ status: 500, message: e.message }); } @@ -78,17 +149,60 @@ export class AppController { ): Promise { try { if (req.headers.authorization !== process.env.AUTHORIZATION) return res.sendStatus(401); + const results: any[] = []; + + for (const track of tracks) { + const cachedTrack = this.appCacheService.client.isOpen() + ? await this.appCacheService.getTrackRepository() + .search() + .where("track") + .equalTo(track) + .return.first() + : null; + + if (cachedTrack) { + results.push({ + identifier: cachedTrack.toJSON().identifier, + isSeekable: cachedTrack.toJSON().isSeekable, + author: cachedTrack.toJSON().author, + length: cachedTrack.toJSON().length, + isStream: cachedTrack.toJSON().isStream, + position: cachedTrack.toJSON().position, + title: cachedTrack.toJSON().title, + uri: cachedTrack.toJSON().uri, + sourceName: cachedTrack.toJSON().sourceName, + artworkUrl: cachedTrack.toJSON().artworkUrl + }); + } + } + + if (results.length) { + return res + .header({ "X-Cache-Hits": true }) + .json(results); + } + const node = this.appNodeService.getLavalinkNode(req.headers["x-node-name"] as string, excludeNode); const nodeRest = new REST(node.secure ? `https://${node.host}` : `http://${node.host}`) .setAuthorization(node.auth); const timeout = setTimeout(() => Result.fromAsync(this.postDecodeTracks(res, req, tracks, node.name)), Time.Second * Number(process.env.TIMEOUT_SECONDS ?? 3)); - const result = await nodeRest.decodeTracks(tracks); + const decodeResults = await nodeRest.decodeTracks(tracks); clearTimeout(timeout); - return res.json(result.map(x => x.info)); + if (this.appCacheService.client.isOpen()) for (const track of decodeResults) await this.appCacheService.getTrackRepository().createAndSave({ track: track.track, ...track.info }); + + return res.json(decodeResults.map(x => x.info)); } catch (e) { return res.status(500).json({ status: 500, message: e.message }); } } + + public parseUrl(rawUrl: string): string | null { + try { + return rawUrl.split(":").slice(1, 3).join(":"); + } catch { + return null; + } + } } diff --git a/src/app.module.ts b/src/app.module.ts index 36deb27..5388f7a 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,11 +1,12 @@ import { Module } from "@nestjs/common"; import "dotenv/config"; +import { AppCacheService } from "./app.cache.service"; import { AppController } from "./app.controller"; import { AppNodeService } from "./app.node.service"; @Module({ controllers: [AppController], - providers: [AppNodeService], + providers: [AppNodeService, AppCacheService], imports: [] }) diff --git a/tsconfig.json b/tsconfig.json index 898cc27..5fb0b41 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,8 +11,9 @@ "sourceMap": true, "baseUrl": "./", "incremental": true, + "outDir": "./dist", "skipLibCheck": true }, "include": ["./**/**.ts"], - "exclude": ["node_modules"] + "exclude": ["node_modules", "dist"] }