initial commit

This commit is contained in:
OfficialDakari 2024-10-12 20:21:51 +05:00
commit a4e457eb52
18 changed files with 4206 additions and 0 deletions

136
.gitignore vendored Normal file
View File

@ -0,0 +1,136 @@
# ---> Node
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
data/
cache/
cacheStore/
tmpStore/

2
README.md Normal file
View File

@ -0,0 +1,2 @@
# Extera Music
This is repository of Extera Music backend server.

7
config.ts Normal file
View File

@ -0,0 +1,7 @@
export type Config = {
api_port: number;
web_port: number;
api_base_url: string;
web_base_url: string;
jwt_privkey: string;
};

5
config.yml Normal file
View File

@ -0,0 +1,5 @@
api_port: 33223
api_base_url: http://localhost:33223
web_port: 8888
web_base_url: http://localhost:8888
jwt_privkey: AfhrynxvaH7XevethWCLzzp3Xq49NpqeENdgVVPJnqKNfdAJFuRNdPosAJXYznez

68
database.ts Normal file
View File

@ -0,0 +1,68 @@
import fs from 'fs';
function createTableIfNotExists(table: string) {
if (!fs.existsSync(`./data/${table}/`)) fs.mkdirSync(`./data/${table}/`);
}
function dbRead(table: string, key: string) {
createTableIfNotExists(table);
if (fs.existsSync(`./data/${table}/${key}`)) return JSON.parse(fs.readFileSync(`./data/${table}/${key}`, 'utf-8'));
if (!fs.existsSync(`./data/${table}/${encodeURIComponent(key)}`)) return null;
return JSON.parse(fs.readFileSync(`./data/${table}/${encodeURIComponent(key)}`, 'utf-8'));
}
function dbExists(table: string, key: string) {
createTableIfNotExists(table);
if (fs.existsSync(`./data/${table}/${key}`)) return true;
if (!fs.existsSync(`./data/${table}/${encodeURIComponent(key)}`)) return null;
return true;
}
function dbStore(table: string, key: string, value: any) {
createTableIfNotExists(table);
fs.writeFileSync(`./data/${table}/${encodeURIComponent(key)}`, JSON.stringify(value));
}
function dbDelete(table: string, key: string) {
createTableIfNotExists(table);
fs.rmSync(`./data/${table}/${encodeURIComponent(key)}`);
}
function dbSearch(table: string, condition: (v: any) => boolean) {
createTableIfNotExists(table);
const values: Record<string, any> = {};
for (const key of fs.readdirSync('./data/' + table + '/')) {
const value = dbRead(table, decodeURIComponent(key));
if (condition(value)) values[decodeURIComponent(key)] = value;
}
return values;
}
function dbAll(table: string) {
createTableIfNotExists(table);
const values: Record<string, any> = {};
for (const key of fs.readdirSync('./data/' + table + '/')) {
const value = dbRead(table, decodeURIComponent(key));
values[decodeURIComponent(key)] = value;
}
return values;
}
function dbFind(table: string, condition: (v: any) => boolean) {
createTableIfNotExists(table);
for (const key of fs.readdirSync('./data/' + table + '/')) {
const value = dbRead(table, decodeURIComponent(key));
if (condition(value)) return { key: decodeURIComponent(key), value };
}
return null;
}
export {
dbAll,
dbDelete,
dbExists,
dbFind,
dbRead,
dbSearch,
dbStore
};

5
idgen.ts Normal file
View File

@ -0,0 +1,5 @@
import {randomInt} from 'crypto';
export default function generateID() {
return `${Date.now()}${randomInt(100000, 999999)}`;
}

27
index.ts Normal file
View File

@ -0,0 +1,27 @@
import express from 'express';
import YAML from 'yaml';
import {readFileSync} from 'fs';
import { Config } from './config.js';
import init from './routes/index.js';
import ytmApi from './utils/ytmApi.js';
import cors from 'cors';
const config: Config = YAML.parse(
readFileSync('config.yml', 'utf-8')
);
const api = express();
api.use(cors());
api.use(express.json());
api.use(express.query({
}));
init(api);
api.listen(config.api_port);
console.log(`API listening on ${config.api_port}`);
export { config };

3580
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

38
package.json Normal file
View File

@ -0,0 +1,38 @@
{
"name": "exteramusic",
"version": "1.0.0",
"description": "Mom can we have Spotify? Mom: we already have spotify at home. Spotify at home:",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"build": "tsc -p .",
"start": "node dist/index.js"
},
"author": "extera.xyz",
"type": "module",
"license": "MIT",
"dependencies": {
"cors": "^2.8.5",
"express": "^4.21.0",
"express-async-handler": "^1.2.0",
"http-errors": "^2.0.0",
"jsonwebtoken": "^9.0.2",
"matrix-bot-sdk": "^0.7.1",
"md5": "^2.3.0",
"node-youtube-music": "^0.10.3",
"shelljs": "^0.8.5",
"typescript": "^5.6.2",
"uuid": "^10.0.0",
"yaml": "^2.5.1",
"yt-dlp-wrapper": "^1.2.2",
"ytmusic-api": "file:../ts-npm-ytmusic-api"
},
"devDependencies": {
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"@types/jsonwebtoken": "^9.0.7",
"@types/md5": "^2.3.5",
"@types/shelljs": "^0.8.15",
"@types/uuid": "^10.0.0"
}
}

59
routes/auth.ts Normal file
View File

@ -0,0 +1,59 @@
import { Application } from "express";
import createHttpError from "http-errors";
import { dbExists, dbRead, dbStore } from "../database.js";
import md5 from "md5";
import jwt from 'jsonwebtoken';
import { config } from "../index.js";
export default function authRoutes(app: Application) {
app.use((req, res, next) => {
if (req.path.startsWith('/auth/')) return next();
var authHeader = req.headers['authorization'];
if (!authHeader) throw createHttpError[401]();
if (req.headers['x-username']) throw createHttpError[403]('bruh');
if (authHeader.startsWith('Bearer ')) authHeader = authHeader.slice(7);
const username = jwt.verify(authHeader, config.jwt_privkey, {
complete: false
}) as string;
req.headers['x-username'] = username;
next();
});
app.post('/auth/register', (req, res) => {
if (typeof req.body.username !== 'string' || typeof req.body.password !== 'string') {
throw createHttpError[400]();
}
if (dbExists('users', req.body.username)) throw createHttpError[409]('Username is already taken');
const user = {
username: req.body.username,
password: md5(req.body.password),
playlists: []
};
dbStore('users', req.body.username, user);
const token = jwt.sign(req.body.username, config.jwt_privkey);
res.send({
token
});
});
app.post('/auth/login', (req, res) => {
if (typeof req.body.username !== 'string' || typeof req.body.password !== 'string') {
throw createHttpError[400]();
}
if (!dbExists('users', req.body.username)) throw createHttpError[404]('User not found');
const user = dbRead('users', req.body.username);
if (user.password !== md5(req.body.password)) throw createHttpError[403]('Invalid password');
const token = jwt.sign(req.body.username, config.jwt_privkey);
res.send({
token
});
});
}

11
routes/config.ts Normal file
View File

@ -0,0 +1,11 @@
import express from 'express';
import { config } from '../index.js';
export default function initConfigRoute(app: express.Application) {
app.get('/config', (req, res) => {
res.send({
api_base_url: config.api_base_url,
web_base_url: config.web_base_url
});
});
}

31
routes/index.ts Normal file
View File

@ -0,0 +1,31 @@
import { Application, Request, Response } from "express";
import initConfigRoute from "./config.js";
import routeYTM from "./ytm.js";
import authRoutes from "./auth.js";
import createHttpError from "http-errors";
export default function init(app: Application) {
initConfigRoute(app);
routeYTM(app);
authRoutes(app);
app.use((err: Error, req: Request, res: Response, next: any) => {
if (err && typeof (err as createHttpError.HttpError)['statusCode'] === 'number') {
const he = (err as createHttpError.HttpError);
res.status(he.statusCode).send({
code: he.statusCode,
message: he.message
});
} else if (err) {
console.error(err);
res.status(500).send({
code: 500,
message: 'Internal Server Error'
});
} else {
next();
}
});
}

95
routes/playlists.ts Normal file
View File

@ -0,0 +1,95 @@
import { Application } from "express";
import { dbDelete, dbRead, dbStore } from "../database.js";
import createHttpError from "http-errors";
import generateID from "../idgen.js";
export default function playlists(app: Application) {
app.get('/playlists/list', (req, res) => {
const username = req.headers['x-username'] as string;
const user = dbRead('users', username);
res.send(
user.playlists
.map((pId: string) => dbRead('playlists', pId))
);
});
app.get('/playlists/:id', (req, res) => {
const username = req.headers['x-username'] as string;
const user = dbRead('users', username);
if (!user.playlists.includes(req.params.id)) throw createHttpError[403]();
res.send(
dbRead('playlists', req.params.id)
);
});
app.post('/playlists/new', (req, res) => {
if (typeof req.body.name !== 'string') throw createHttpError[400]();
const username = req.headers['x-username'] as string;
const user = dbRead('users', username);
const id = generateID();
user.playlists.push(id);
dbStore('playlists', id, {
id,
owner: username,
songs: []
});
res.send({
id
});
});
app.delete('/playlists/:id', (req, res) => {
const username = req.headers['x-username'] as string;
const user = dbRead('users', username);
if (!user.playlists.includes(req.params.id)) throw createHttpError[403]();
user.playlists = user.playlists.filter((x: string) => x !== req.params.id);
dbDelete('playlists', req.params.id);
dbStore('users', username, user);
res.send({
ok: true
});
});
app.post('/playlists/:id', (req, res) => {
if (typeof req.params.id !== 'string' || typeof req.body.songId !== 'string') throw createHttpError[400]();
const username = req.headers['x-username'] as string;
const user = dbRead('users', username);
if (!user.playlists.includes(req.params.id)) throw createHttpError[404]();
const playlist = dbRead('playlists', req.params.id);
playlist.songs.push(req.body.songId);
dbStore('playlists', req.params.id, playlist);
res.send({
ok: true
});
});
app.delete('/playlists/:id/:songId', (req, res) => {
if (typeof req.params.id !== 'string' || typeof req.params.songId !== 'string') throw createHttpError[400]();
const username = req.headers['x-username'] as string;
const user = dbRead('users', username);
if (!user.playlists.includes(req.params.id)) throw createHttpError[404]();
const playlist = dbRead('playlists', req.params.id);
playlist.songs = playlist.songs.filter((x: string) => x !== req.params.songId);
dbStore('playlists', req.params.id, playlist);
res.send({
ok: true
});
});
}

63
routes/ytm.ts Normal file
View File

@ -0,0 +1,63 @@
import { Application } from "express";
import expressAsyncHandler from "express-async-handler";
import createHttpError from "http-errors";
import ytmApi from "../utils/ytmApi.js";
import downloadSong from "../utils/download.js";
import { existsSync, realpathSync } from 'fs';
import { dbRead, dbStore } from "../database.js";
import md5 from "md5";
import { SongDetailed, SongFull } from "ytmusic-api";
export default function routeYTM(app: Application) {
app.get('/music/search', expressAsyncHandler(
async (req, res) => {
if (typeof req.query.q !== 'string') throw createHttpError[400]();
var songs: SongDetailed[] | null = dbRead('cache', md5(req.query.q));
if (!songs) {
songs = await ytmApi.searchSongs(req.query.q);
dbStore('cache', md5(req.query.q), songs);
}
res.send(songs);
}
));
app.get('/music/get', expressAsyncHandler(
async (req, res) => {
if (typeof req.query.id !== 'string') throw createHttpError[400]();
var song: SongDetailed | SongFull | null = dbRead('cache', req.query.id);
if (!song) {
song = await ytmApi.getSong(req.query.id);
dbStore('cache', req.query.id, song);
}
res.send(song);
}
));
app.get('/music/lyrics', expressAsyncHandler(
async (req, res) => {
if (typeof req.query.id !== 'string') throw createHttpError[400]();
var lyrics = dbRead('cache', md5(`lyrics_${req.query.id}`));
if (!lyrics) {
lyrics = await ytmApi.getLyrics(req.query.id);
dbStore('cache', md5(`lyrics_${req.query.id}`), lyrics ?? 1);
}
if (lyrics === 1) throw createHttpError[404]();
res.send(lyrics);
}
));
app.get('/music/file', expressAsyncHandler(
async (req, res) => {
if (typeof req.query.id !== 'string') throw createHttpError[400]();
const filePath = `./tmpStore/${md5(req.query.id)}.mp3`;
if (!existsSync(filePath)) await downloadSong(req.query.id);
if (existsSync(filePath)) {
res.sendFile(realpathSync(filePath));
} else {
throw createHttpError[404]();
}
}
));
}

27
tsconfig.json Normal file
View File

@ -0,0 +1,27 @@
{
"exclude": [
"node_modules/",
"dist/",
"ts-npm-ytmusic-api/"
],
"compilerOptions": {
"composite": false,
"declaration": true,
"declarationMap": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"inlineSources": false,
"isolatedModules": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"preserveWatchOutput": true,
"skipLibCheck": true,
"strict": true,
"downlevelIteration": true,
"outDir": "dist",
"allowJs": true,
"target": "ES6",
"module": "NodeNext",
"moduleResolution": "nodenext"
}
}

34
utils/download.ts Normal file
View File

@ -0,0 +1,34 @@
import { v4 } from 'uuid';
import shjs from 'shelljs';
import fs from 'fs';
import md5 from 'md5';
const process: Record<string, boolean> = {};
export default function downloadSong(id: string): Promise<string> {
return new Promise<string>((resolve, reject) => {
const url: string = `https://music.youtube.com/watch?v=${id}`;
const filename: string = md5(id);
if (process[id]) {
reject(new Error(`Already downloading ${id}`));
return;
}
process[id] = true;
shjs.exec(`../yt-dlp.sh --sponsorblock-remove all -o ${filename} ${url}`, {
cwd: './tmpStore',
async: true
}).on('exit', () => {
const fileName = fs.readdirSync('./tmpStore').find(x => x.includes(filename));
const newFileName = `${filename}.mp3`;
shjs.exec(`ffmpeg -i '${fileName}' '${newFileName}'`, {
async: true,
cwd: './tmpStore'
}).on('exit', () => {
resolve(newFileName);
delete process[id];
});
});
});
}

15
utils/ytmApi.ts Normal file
View File

@ -0,0 +1,15 @@
import YTMusic from "ytmusic-api";
const ytmApi = new YTMusic({
proxy: {
protocol: 'http',
host: 'localhost',
port: 12334
}
});
ytmApi.initialize().then(() => {
console.log(`YT Music API initialized`);
});
export default ytmApi;

3
yt-dlp.sh Executable file
View File

@ -0,0 +1,3 @@
#!/usr/bin/sh
yt-dlp --proxy socks5://localhost:12334 $@