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