Initial commit
This commit is contained in:
commit
35d388f776
9 changed files with 389 additions and 0 deletions
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
@ -0,0 +1,3 @@
|
|||
node_modules
|
||||
/cache
|
||||
/build
|
18
package.json
Normal file
18
package.json
Normal file
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"name": "pm-ytm",
|
||||
"devDependencies": {},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^9.0.1",
|
||||
"@types/node": "^22.5.5",
|
||||
"dotenv": "^16.4.5",
|
||||
"fastify": "^4.28.1",
|
||||
"redis": "^4.7.0",
|
||||
"youtubei.js": "^10.4.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "nodemon src/index.ts"
|
||||
}
|
||||
}
|
9
src/cache/cache.ts
vendored
Normal file
9
src/cache/cache.ts
vendored
Normal file
|
@ -0,0 +1,9 @@
|
|||
export default interface Cache {
|
||||
|
||||
save(key: string, data: any, ttl?: number): Promise<void>;
|
||||
|
||||
get<T>(key: string): Promise<T | null>;
|
||||
|
||||
auto<T>(key: string, fn: () => T | Promise<T>, ttl?: number): Promise<T>;
|
||||
|
||||
}
|
49
src/cache/redis.ts
vendored
Normal file
49
src/cache/redis.ts
vendored
Normal file
|
@ -0,0 +1,49 @@
|
|||
import { createClient, RedisClientType, RedisDefaultModules, RedisFunctions, RedisModules, RedisScripts } from "redis";
|
||||
import Cache from "./cache";
|
||||
|
||||
type Redis = RedisClientType<RedisDefaultModules & RedisModules, RedisFunctions, RedisScripts>;
|
||||
|
||||
export default class RedisCache implements Cache {
|
||||
|
||||
private client: Redis | null = null;
|
||||
private url: string;
|
||||
|
||||
public constructor(url: string) {
|
||||
this.url = url;
|
||||
}
|
||||
|
||||
private async getClient(): Promise<Redis> {
|
||||
if (!this.client) {
|
||||
let client = await createClient({
|
||||
url: this.url
|
||||
}).connect();
|
||||
this.client = client;
|
||||
return client;
|
||||
}
|
||||
return this.client;
|
||||
}
|
||||
|
||||
public async save(key: string, data: any, ttl: number = 30*60) {
|
||||
let redis = await this.getClient();
|
||||
await redis.set(key, JSON.stringify(data));
|
||||
await redis.expire(key, ttl);
|
||||
}
|
||||
|
||||
public async get(key: string) {
|
||||
let redis = await this.getClient();
|
||||
let item = await redis.get(key);
|
||||
return item ? JSON.parse(item) : null;
|
||||
}
|
||||
|
||||
public async auto<T>(key: string, fn: () => T | Promise<T>, ttl?: number): Promise<T> {
|
||||
let item = await this.get(key);
|
||||
if (!item) {
|
||||
let res = await fn();
|
||||
if (ttl) await this.save(key, res, ttl);
|
||||
return res;
|
||||
} else {
|
||||
return item;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
23
src/config.ts
Normal file
23
src/config.ts
Normal file
|
@ -0,0 +1,23 @@
|
|||
import 'dotenv/config';
|
||||
import { FastifyListenOptions } from 'fastify';
|
||||
|
||||
let port = process.env.PORT ? parseInt(process.env.PORT) : 3001;
|
||||
let host = process.env.HOST;
|
||||
let path = process.env.SOCKET_PATH;
|
||||
|
||||
export let config: Config = {
|
||||
listen: {},
|
||||
redis_url: process.env.REDIS_URL
|
||||
};
|
||||
|
||||
if (path) {
|
||||
config.listen.path = path;
|
||||
} else {
|
||||
config.listen.port = port;
|
||||
if (host) config.listen.host = host;
|
||||
}
|
||||
|
||||
interface Config {
|
||||
listen: FastifyListenOptions,
|
||||
redis_url?: string
|
||||
}
|
73
src/index.ts
Normal file
73
src/index.ts
Normal file
|
@ -0,0 +1,73 @@
|
|||
import Fastify from 'fastify';
|
||||
import cors from '@fastify/cors';
|
||||
import { backend } from './yt';
|
||||
import { config } from './config';
|
||||
|
||||
const fastify = Fastify({ logger: true });
|
||||
|
||||
fastify.register(cors, {
|
||||
origin: "*",
|
||||
methods: ["GET", "POST"]
|
||||
});
|
||||
|
||||
fastify.all("/search", async (req, r) => {
|
||||
let { q } = (req.query as any);
|
||||
let res = await backend.getSearch(q);
|
||||
return res;
|
||||
});
|
||||
|
||||
fastify.all("/search/suggestions", async (req, r) => {
|
||||
let { q } = (req.query as any);
|
||||
let res = await backend.getSearchSuggestions(q);
|
||||
return res;
|
||||
});
|
||||
|
||||
fastify.get("/track/:id", async (req, r) => {
|
||||
let { id } = (req.params as any);
|
||||
let res = await backend.getInfo(id);
|
||||
return res;
|
||||
});
|
||||
|
||||
fastify.get("/tracks/:ids", async (req, r) => {
|
||||
let { ids } = (req.params as any);
|
||||
let list = ids.split(",");
|
||||
const res = new Promise((resolve) => {
|
||||
let tracks: any = {}, i = 0;
|
||||
list.forEach(async (id: any) => {
|
||||
tracks[id] = await backend.getInfo(id);
|
||||
i++;
|
||||
if (i == list.length) resolve(tracks);
|
||||
});
|
||||
});
|
||||
return await res;
|
||||
});
|
||||
|
||||
fastify.get("/track/:id/cover/:size", async (req, r) => {
|
||||
let { id, size } = (req.params as any);
|
||||
let res = await backend.getThumbnail(id, size);
|
||||
return await fetch(res);
|
||||
});
|
||||
|
||||
fastify.get("/track/:id/stream", async (req, r) => {
|
||||
let { id } = (req.params as any);
|
||||
let res = await backend.stream(id);
|
||||
return res;
|
||||
});
|
||||
|
||||
fastify.get("/track/:id/download", async (req, r) => {
|
||||
let { id } = (req.params as any);
|
||||
let data = await backend.download(id);
|
||||
r.header("content-disposition", `inline; filename="${data.filename}"`);
|
||||
return data.stream;
|
||||
});
|
||||
|
||||
async function start() {
|
||||
try {
|
||||
await fastify.listen(config.listen);
|
||||
} catch (err) {
|
||||
fastify.log.error(err);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
start();
|
29
src/parser.ts
Normal file
29
src/parser.ts
Normal file
|
@ -0,0 +1,29 @@
|
|||
export default class Parser {
|
||||
|
||||
public static parseSearch(search: any): string[] {
|
||||
let results: string[] = [];
|
||||
if (search.contents) search.contents.forEach((e: any) => {
|
||||
if (e.type == "MusicShelf" && e.contents) {
|
||||
e.contents.forEach((i: any) => {
|
||||
if (i.item_type == "song" && i.id) {
|
||||
results.push(i.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
return results;
|
||||
}
|
||||
|
||||
public static parseSearchSuggestions(result: any) {
|
||||
let suggestions: string[] = [];
|
||||
result.forEach((s: any) => {
|
||||
if (s.type == "SearchSuggestionsSection" && s.contents && Array.isArray(s.contents)) {
|
||||
s.contents.forEach((i: any) => {
|
||||
if (i.suggestion && i.suggestion.text) suggestions.push(i.suggestion.text);
|
||||
});
|
||||
}
|
||||
});
|
||||
return suggestions;
|
||||
}
|
||||
|
||||
}
|
172
src/yt.ts
Normal file
172
src/yt.ts
Normal file
|
@ -0,0 +1,172 @@
|
|||
import Innertube, { UniversalCache } from "youtubei.js";
|
||||
import { TrackInfo } from "youtubei.js/dist/src/parser/ytmusic";
|
||||
import Cache from "./cache/cache";
|
||||
import RedisCache from "./cache/redis";
|
||||
import Parser from "./parser";
|
||||
import { config } from "./config";
|
||||
|
||||
class YouTubeMusic {
|
||||
|
||||
private yt: Innertube | null = null;
|
||||
|
||||
readonly lang: string;
|
||||
readonly location: string;
|
||||
|
||||
readonly format: any = {
|
||||
type: 'audio', // audio, video or video+audio
|
||||
quality: 'best', // best, bestefficiency, 144p, 240p, 480p, 720p and so on.
|
||||
format: 'mp4' // media container format
|
||||
};
|
||||
|
||||
private cache: Cache | null = config.redis_url ? new RedisCache(config.redis_url) : null;
|
||||
|
||||
public constructor(lang: string, location: string) {
|
||||
this.lang = lang;
|
||||
this.location = location;
|
||||
}
|
||||
|
||||
private async get(): Promise<Innertube> {
|
||||
this.yt = await Innertube.create({
|
||||
cache: new UniversalCache(false),
|
||||
lang: this.lang,
|
||||
location: this.location
|
||||
});
|
||||
return this.yt;
|
||||
}
|
||||
|
||||
/* Public cached methods */
|
||||
|
||||
public async getInfo(id: string): Promise<any> {
|
||||
return await this.tryCache(`track(${id})`, async () => {
|
||||
const { track } = await this.fetchTrackInfo(id);
|
||||
return track;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
public async getThumbnail(id: string, height: number = 192) {
|
||||
const t = await this.tryCache(`thumbnails(${id})`, async () => {
|
||||
const { thumbnails } = await this.fetchTrackInfo(id);
|
||||
return thumbnails;
|
||||
}, 0);
|
||||
return this.selectClosestThumbnail(t ?? null, height);
|
||||
}
|
||||
|
||||
public async getSearch(q: string) {
|
||||
return this.tryCache(`search(${q})`, async () => {
|
||||
const yt = await this.get();
|
||||
const res = await yt.music.search(q, {type: "song"});
|
||||
return Parser.parseSearch(res);
|
||||
});
|
||||
}
|
||||
|
||||
public async getSearchSuggestions(q: string) {
|
||||
return this.tryCache(`search_suggestions(${q})`, async () => {
|
||||
const yt = await this.get();
|
||||
const res = await yt.music.getSearchSuggestions(q);
|
||||
return Parser.parseSearchSuggestions(res);
|
||||
});
|
||||
}
|
||||
|
||||
/* Stream & download */
|
||||
|
||||
public async stream(id: string, rangeHeader?: string) {
|
||||
let yt = await this.get();
|
||||
let info = await yt.music.getInfo(id);
|
||||
let format = info.chooseFormat(this.format);
|
||||
let range = this.parseRangeHeader(rangeHeader);
|
||||
return this.fetchFileChunk(info, format, range);
|
||||
}
|
||||
|
||||
public async download(id: string) {
|
||||
let yt = await this.get();
|
||||
let info = await yt.music.getInfo(id);
|
||||
let stream = await info.download(this.format);
|
||||
let filename = this.convertFilename(`${info.basic_info.author} - ${info.basic_info.title}.m4a`);
|
||||
return {stream, filename};
|
||||
}
|
||||
|
||||
/* Data fetching */
|
||||
|
||||
private async fetchTrackInfo(id: string) {
|
||||
let yt = await this.get();
|
||||
let info = await yt.music.getInfo(id);
|
||||
let track = this.buildTrack(info);
|
||||
let thumbnails = info.basic_info.thumbnail;
|
||||
if (this.cache) {
|
||||
this.cache.save(`track(${id})`, track);
|
||||
this.cache.save(`thumbnails(${id})`, thumbnails);
|
||||
}
|
||||
return { track, thumbnails };
|
||||
}
|
||||
|
||||
private async fetchFileChunk(info: TrackInfo, format: any, range?: any): Promise<Response | null> {
|
||||
let len = format.content_length;
|
||||
if (!len) return null;
|
||||
if (range && (Number.isNaN(range.end) || !range.end)) range.end = len - 1;
|
||||
let file = await info.download({
|
||||
...this.format, range
|
||||
});
|
||||
let headers = new Headers;
|
||||
headers.set("accept-ranges", "bytes");
|
||||
headers.set("content-length", `${len}`);
|
||||
headers.set("content-type", format.mime_type);
|
||||
if (range) {
|
||||
headers.set("content-range", `bytes ${range.start}-${range.end}/${len}`);
|
||||
}
|
||||
return new Response(file, {headers, status: range ? 206 : 200});
|
||||
}
|
||||
|
||||
/* Helper methods */
|
||||
|
||||
private buildTrack(info: TrackInfo): Track {
|
||||
return {
|
||||
id: info.basic_info.id!,
|
||||
title: info.basic_info.title!,
|
||||
album: null,
|
||||
author: info.basic_info.author!,
|
||||
duration: info.basic_info.duration!
|
||||
};
|
||||
}
|
||||
|
||||
private selectClosestThumbnail(thumbnails: any[] | null, height: number) {
|
||||
if (!thumbnails || !thumbnails.length) return null;
|
||||
const differences = thumbnails.map(t => Math.abs(t.height - height));
|
||||
const minDifference = Math.min(...differences);
|
||||
const index = differences.indexOf(minDifference);
|
||||
return thumbnails[index].url;
|
||||
}
|
||||
|
||||
private parseRangeHeader(rangeHeader?: string): any | undefined {
|
||||
if (!rangeHeader || !rangeHeader.startsWith("bytes=")) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const [start, end] = rangeHeader.slice(6).split('-').map(Number);
|
||||
return {start, end};
|
||||
} catch (error) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private convertFilename(filename: string) {
|
||||
return filename.replace(/[^\w\d\-._~\s]/g, "");
|
||||
}
|
||||
|
||||
/* Caching */
|
||||
|
||||
private async tryCache<T>(key: string, fn: () => Promise<T> | T, ttl?: number): Promise<T> {
|
||||
if (!this.cache) return fn();
|
||||
return this.cache.auto(key, fn, ttl);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
interface Track {
|
||||
id: string,
|
||||
title: string,
|
||||
album: null,
|
||||
author: string,
|
||||
duration: number
|
||||
}
|
||||
|
||||
export let backend = new YouTubeMusic("cs", "CZ");
|
13
tsconfig.json
Normal file
13
tsconfig.json
Normal file
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "commonjs",
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"rootDir": "./src/",
|
||||
"outDir": "./build/",
|
||||
"lib": ["ES2021", "DOM"]
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue