Initial commit

This commit is contained in:
Filip Znachor 2024-09-16 18:41:28 +02:00
commit 35d388f776
9 changed files with 389 additions and 0 deletions

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
node_modules
/cache
/build

18
package.json Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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"]
}
}