initial commit
This commit is contained in:
commit
a4e457eb52
|
|
@ -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/
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
# Extera Music
|
||||
This is repository of Extera Music backend server.
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
export type Config = {
|
||||
api_port: number;
|
||||
web_port: number;
|
||||
api_base_url: string;
|
||||
web_base_url: string;
|
||||
jwt_privkey: string;
|
||||
};
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
};
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import {randomInt} from 'crypto';
|
||||
|
||||
export default function generateID() {
|
||||
return `${Date.now()}${randomInt(100000, 999999)}`;
|
||||
}
|
||||
|
|
@ -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 };
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
});
|
||||
});
|
||||
|
||||
}
|
||||
|
|
@ -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
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -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
|
||||
});
|
||||
});
|
||||
|
||||
}
|
||||
|
|
@ -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]();
|
||||
}
|
||||
}
|
||||
));
|
||||
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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];
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -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;
|
||||
Loading…
Reference in New Issue