From 35d388f7761d528f9e2c692c8643de7506d63942 Mon Sep 17 00:00:00 2001 From: Filip Znachor Date: Mon, 16 Sep 2024 18:41:28 +0200 Subject: [PATCH] Initial commit --- .gitignore | 3 + package.json | 18 +++++ src/cache/cache.ts | 9 +++ src/cache/redis.ts | 49 +++++++++++++ src/config.ts | 23 ++++++ src/index.ts | 73 +++++++++++++++++++ src/parser.ts | 29 ++++++++ src/yt.ts | 172 +++++++++++++++++++++++++++++++++++++++++++++ tsconfig.json | 13 ++++ 9 files changed, 389 insertions(+) create mode 100644 .gitignore create mode 100644 package.json create mode 100644 src/cache/cache.ts create mode 100644 src/cache/redis.ts create mode 100644 src/config.ts create mode 100644 src/index.ts create mode 100644 src/parser.ts create mode 100644 src/yt.ts create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e9407d7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules +/cache +/build diff --git a/package.json b/package.json new file mode 100644 index 0000000..f061668 --- /dev/null +++ b/package.json @@ -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" + } +} \ No newline at end of file diff --git a/src/cache/cache.ts b/src/cache/cache.ts new file mode 100644 index 0000000..ca6233b --- /dev/null +++ b/src/cache/cache.ts @@ -0,0 +1,9 @@ +export default interface Cache { + + save(key: string, data: any, ttl?: number): Promise; + + get(key: string): Promise; + + auto(key: string, fn: () => T | Promise, ttl?: number): Promise; + +} diff --git a/src/cache/redis.ts b/src/cache/redis.ts new file mode 100644 index 0000000..3b68b98 --- /dev/null +++ b/src/cache/redis.ts @@ -0,0 +1,49 @@ +import { createClient, RedisClientType, RedisDefaultModules, RedisFunctions, RedisModules, RedisScripts } from "redis"; +import Cache from "./cache"; + +type Redis = RedisClientType; + +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 { + 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(key: string, fn: () => T | Promise, ttl?: number): Promise { + 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; + } + } + +} diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..19694dc --- /dev/null +++ b/src/config.ts @@ -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 +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..25f70b6 --- /dev/null +++ b/src/index.ts @@ -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(); diff --git a/src/parser.ts b/src/parser.ts new file mode 100644 index 0000000..f6015f7 --- /dev/null +++ b/src/parser.ts @@ -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; + } + +} diff --git a/src/yt.ts b/src/yt.ts new file mode 100644 index 0000000..15bb684 --- /dev/null +++ b/src/yt.ts @@ -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 { + 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 { + 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 { + 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(key: string, fn: () => Promise | T, ttl?: number): Promise { + 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"); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..51e3f9b --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "commonjs", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true, + "rootDir": "./src/", + "outDir": "./build/", + "lib": ["ES2021", "DOM"] + } +}