Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: redis tracks caching #37

Draft
wants to merge 9 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,8 @@
- Multiple Lavalink nodes support
- Configure-able response timeout
- Vercel Serverless support
- Use preferred custom node using `x-node-name` headers
- 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`
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down Expand Up @@ -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"
Expand Down
10 changes: 10 additions & 0 deletions src/Entities/Playlist.ts
Original file line number Diff line number Diff line change
@@ -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[]" }
});
17 changes: 17 additions & 0 deletions src/Entities/Track.ts
Original file line number Diff line number Diff line change
@@ -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" }
});
26 changes: 26 additions & 0 deletions src/app.cache.service.ts
Original file line number Diff line number Diff line change
@@ -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<Track> {
return this.client.fetchRepository(TrackSchema);
}

public getPlaylistTrackRepository(): Repository<Track> {
return this.client.fetchRepository(PlaylistSchema);
}
}
130 changes: 122 additions & 8 deletions src/app.controller.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 {
Expand All @@ -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 });
Expand All @@ -55,15 +98,43 @@ export class AppController {
): Promise<Response> {
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 });
}
Expand All @@ -78,17 +149,60 @@ export class AppController {
): Promise<Response> {
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;
}
}
}
3 changes: 2 additions & 1 deletion src/app.module.ts
Original file line number Diff line number Diff line change
@@ -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: []
})

Expand Down
3 changes: 2 additions & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@
"sourceMap": true,
"baseUrl": "./",
"incremental": true,
"outDir": "./dist",
"skipLibCheck": true
},
"include": ["./**/**.ts"],
"exclude": ["node_modules"]
"exclude": ["node_modules", "dist"]
}