[Feature] Support Spotify links
This commit is contained in:
parent
a666d01e28
commit
208b8d7806
10 changed files with 240 additions and 20 deletions
21
README.md
21
README.md
|
@ -13,6 +13,7 @@ This bot supports concurrency, meaning that if multiple users request the same c
|
|||
- 👯 Tiktok (Video)
|
||||
- 📸 Instagram (Video)
|
||||
- ☁️ SoundCloud
|
||||
- 🔊 Spotify
|
||||
|
||||
## Admin's Commands
|
||||
|
||||
|
@ -41,7 +42,9 @@ Just send your media link to bot and get your content 😃. Of course, the admin
|
|||
ADMIN_ID="" # You can get it from @userinfobot.
|
||||
ADMIN_UN="" # Your username without @ if you want.
|
||||
BOT_TOKEN="" # Your bot token.
|
||||
LOCAL_API="" # Your telegram-bot-api container address
|
||||
LOCAL_API="" # Your telegram-bot-api container address.
|
||||
SPOTIFY_CLIENT_ID="" # Get this from developer.spotify.com.
|
||||
SPOTIFY_CLIENT_SECRET="" # Get this from developer.spotify.com.
|
||||
CAPTION="" # Bot messages caption.
|
||||
```
|
||||
|
||||
|
@ -65,3 +68,19 @@ bun i
|
|||
bun run init --name new_update
|
||||
|
||||
```
|
||||
|
||||
## FAQ
|
||||
|
||||
#### How does the bot communicate with ytdlp ?
|
||||
|
||||
It executes ytdlp as a process and understands its output. Although not advanced, it is good for having a lightweight wrapper and not depending on additional packages.
|
||||
|
||||
#### Why we need local bot api ?
|
||||
|
||||
To bypass the limitations of regualr bots on Telegram. [Read This](https://core.telegram.org/bots/api#using-a-local-bot-api-server)
|
||||
|
||||
#### Why we use Spotify developer console ?
|
||||
|
||||
Spotify uses a DRM to prevent crawling and ytdlp is not able to directly get its content. So using the official Spotify APIs we get the song name and its creator and using ytdlp I download that song from YouTube Music.
|
||||
|
||||
The reason I didn't use spotdl was that it required a separate kernel installation and had limited documentation and parameters for implementing the warpper.
|
||||
|
|
51
src/helpers/spotify.ts
Normal file
51
src/helpers/spotify.ts
Normal file
|
@ -0,0 +1,51 @@
|
|||
import type { SpotifyResponse } from "types/interface";
|
||||
|
||||
const spotifyClientId = Bun.env.SPOTIFY_CLIENT_ID as string;
|
||||
const spotifyclientSecret = Bun.env.SPOTIFY_CLIENT_SECRET as string;
|
||||
|
||||
async function getAccessToken(): Promise<string> {
|
||||
const auth = btoa(`${spotifyClientId}:${spotifyclientSecret}`);
|
||||
const data = new URLSearchParams();
|
||||
data.append("grant_type", "client_credentials");
|
||||
try {
|
||||
const response = await fetch("https://accounts.spotify.com/api/token", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Basic ${auth}`,
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: data.toString(),
|
||||
});
|
||||
const jsonResponse = await response.json();
|
||||
return jsonResponse.access_token;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getTrackDetails(trackId: string): Promise<SpotifyResponse> {
|
||||
try {
|
||||
const token = await getAccessToken();
|
||||
|
||||
const response = await fetch(`https://api.spotify.com/v1/tracks/${trackId}`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
const res = await response.json();
|
||||
const trackData: SpotifyResponse = {
|
||||
song: res.album.name,
|
||||
artist: res.artists[0].name,
|
||||
};
|
||||
return trackData;
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function getTrackId(url: string) {
|
||||
const trackId = url.match(/(?<=track\/)([a-zA-Z0-9]+)/)?.at(0) as string;
|
||||
return trackId;
|
||||
}
|
|
@ -25,28 +25,26 @@ checker.on("message", (ctx, next) => {
|
|||
return ctx.reply("Playlist is not support currently.");
|
||||
}
|
||||
user!.media = { type: "yt", url: link };
|
||||
}
|
||||
else if (domain == "music.youtube.com") {
|
||||
} else if (domain == "music.youtube.com") {
|
||||
if (link.includes("list=")) {
|
||||
return ctx.reply("Playlist is not support currently.");
|
||||
}
|
||||
user!.media = { type: "ytm", url: link };
|
||||
}
|
||||
else if (domain == "pin.it" || domain == "pinterest.com") {
|
||||
} else if (domain == "open.spotify.com") {
|
||||
if (!link.includes("track")) {
|
||||
return ctx.reply("Anything except track is not support currently.");
|
||||
}
|
||||
user!.media = { type: "sf", url: link };
|
||||
} else if (domain == "pin.it" || domain == "pinterest.com") {
|
||||
user!.media = { type: "pin", url: link };
|
||||
}
|
||||
else if (domain == "vm.tiktok.com" || domain == "tiktok.com") {
|
||||
} else if (domain == "vm.tiktok.com" || domain == "tiktok.com") {
|
||||
user!.media = { type: "tt", url: link };
|
||||
}
|
||||
else if (domain == "www.instagram.com" || domain == "instagram.com") {
|
||||
} else if (domain == "www.instagram.com" || domain == "instagram.com") {
|
||||
user!.media = { type: "ig", url: link };
|
||||
}
|
||||
else if (domain == "soundcloud.com" || domain == "on.soundcloud.com") {
|
||||
} else if (domain == "soundcloud.com" || domain == "on.soundcloud.com") {
|
||||
user!.media = { type: "sc", url: link };
|
||||
}
|
||||
else {
|
||||
return ctx.reply("Unsupported platform! ❌");
|
||||
}
|
||||
} else return ctx.reply("Unsupported platform! ❌");
|
||||
|
||||
next();
|
||||
});
|
||||
|
||||
|
|
|
@ -15,7 +15,8 @@ it support currently:
|
|||
🎧 Youtube Music
|
||||
👯 Tiktok (Video)
|
||||
📸 Instagram (Video)
|
||||
☁️ SoundCloud`);
|
||||
☁️ SoundCloud
|
||||
🔊 Spotify`);
|
||||
});
|
||||
|
||||
function auth(ctx: UserContext, next: NextFunction) {
|
||||
|
@ -23,7 +24,7 @@ function auth(ctx: UserContext, next: NextFunction) {
|
|||
if (user?.role != "ADMIN") {
|
||||
return ctx.reply("Sorry you aren't admin.");
|
||||
}
|
||||
next()
|
||||
next();
|
||||
}
|
||||
|
||||
commands.command("clean", auth, async (ctx) => {
|
||||
|
|
|
@ -8,6 +8,7 @@ import handleYTMusic from "middlewares/services/youtube-muisc";
|
|||
import handleTiktok from "middlewares/services/tiktok";
|
||||
import handleInstagram from "./services/instagram";
|
||||
import handleSoundCloud from "./services/soundcloud";
|
||||
import handleSpotify from "./services/spotify";
|
||||
|
||||
import { youtubeFormatsList } from "middlewares/services/youtube";
|
||||
|
||||
|
@ -38,6 +39,10 @@ downloader.use((ctx, next) => {
|
|||
runDetached(() => handleSoundCloud(ctx, media.url));
|
||||
break;
|
||||
|
||||
case "sf":
|
||||
runDetached(() => handleSpotify(ctx, media.url));
|
||||
break;
|
||||
|
||||
case "yt":
|
||||
if (ctx.callbackQuery) {
|
||||
return next();
|
||||
|
|
71
src/middlewares/services/spotify.ts
Normal file
71
src/middlewares/services/spotify.ts
Normal file
|
@ -0,0 +1,71 @@
|
|||
import { InputFile } from "grammy";
|
||||
|
||||
import type { UserContext } from "types/type";
|
||||
|
||||
import Spotify from "models/spotify";
|
||||
import { waitList, waitForDownload, waitForArchiving } from "helpers/ytdlp";
|
||||
import { sendFromArchive, addToArchive } from "helpers/archive";
|
||||
|
||||
import { getTrackDetails, getTrackId } from "helpers/spotify";
|
||||
|
||||
const caption = Bun.env.CAPTION as string;
|
||||
|
||||
async function handleSpotify(ctx: UserContext, url: string) {
|
||||
const archive = await sendFromArchive(ctx, url);
|
||||
if (archive) return;
|
||||
|
||||
let ytdlp: Spotify;
|
||||
const instance = waitList.get(url);
|
||||
|
||||
if (instance) ytdlp = instance as Spotify;
|
||||
else {
|
||||
ytdlp = new Spotify(url);
|
||||
waitList.set(url, ytdlp);
|
||||
}
|
||||
|
||||
try {
|
||||
const msg = await ctx.reply("⬇️ Downloading on the server...", {
|
||||
reply_parameters: { message_id: ctx.msgId! },
|
||||
});
|
||||
|
||||
const trackId = getTrackId(url);
|
||||
const track = await getTrackDetails(trackId);
|
||||
|
||||
if (ytdlp.status == "INACTIVE") {
|
||||
await ytdlp.downloadAudio(track.song, track.artist);
|
||||
} else {
|
||||
await waitForDownload(ytdlp);
|
||||
await waitForArchiving(ytdlp);
|
||||
ctx.api.editMessageText(ctx.chatId!, msg.message_id, "⬆️ Uploading to Telegram...");
|
||||
await sendFromArchive(ctx, url);
|
||||
ctx.api.deleteMessage(msg.chat.id, msg.message_id);
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.api.editMessageText(ctx.chatId!, msg.message_id, "⬆️ Uploading to Telegram...");
|
||||
|
||||
const file = await ctx.replyWithAudio(new InputFile(ytdlp.filePath), {
|
||||
reply_parameters: { message_id: ctx.msgId! },
|
||||
caption,
|
||||
});
|
||||
|
||||
ctx.api.deleteMessage(msg.chat.id, msg.message_id);
|
||||
|
||||
await addToArchive(url, {
|
||||
chatId: file.chat.id.toString(),
|
||||
msgId: file.message_id,
|
||||
});
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
|
||||
return ctx.reply("An internal operation has been failed.", {
|
||||
reply_parameters: { message_id: ctx.msgId! },
|
||||
});
|
||||
} finally {
|
||||
await ytdlp.clean();
|
||||
}
|
||||
|
||||
waitList.delete(url);
|
||||
}
|
||||
|
||||
export default handleSpotify;
|
58
src/models/spotify.ts
Normal file
58
src/models/spotify.ts
Normal file
|
@ -0,0 +1,58 @@
|
|||
import Ytdlp from "models/ytdlp";
|
||||
|
||||
import { spawn } from "bun";
|
||||
import { join } from "path";
|
||||
|
||||
import { rootPath, sanitizePath } from "helpers/utils";
|
||||
|
||||
const cookies = join(rootPath(), "ytcookies.txt");
|
||||
|
||||
class Spotify extends Ytdlp {
|
||||
async downloadAudio(artist: string, song: string) {
|
||||
const _artist = artist.replaceAll(" ", "+");
|
||||
const _song = song.replaceAll(" ", "+");
|
||||
const outPath = join(rootPath(), "downloads", `%(title)s.%(ext)s`);
|
||||
this.status = "ACTIVE";
|
||||
const p = spawn(
|
||||
[
|
||||
"yt-dlp",
|
||||
"--cookies",
|
||||
cookies,
|
||||
"--extract-audio",
|
||||
"--audio-format",
|
||||
"mp3",
|
||||
"-f",
|
||||
"ba",
|
||||
"--embed-thumbnail",
|
||||
"--add-metadata",
|
||||
"-o",
|
||||
outPath,
|
||||
"--downloader",
|
||||
"aria2c",
|
||||
"--downloader-args",
|
||||
"aria2c:-x 16 -k 1M",
|
||||
"-I",
|
||||
"1",
|
||||
`https://music.youtube.com/search?q=${_artist}+${_song}`,
|
||||
"--quiet",
|
||||
"--exec",
|
||||
"echo {}",
|
||||
],
|
||||
{
|
||||
stderr: "pipe",
|
||||
stdout: "pipe",
|
||||
}
|
||||
);
|
||||
const exitCode = await p.exited;
|
||||
if (exitCode != 0) {
|
||||
this.status = "INACTIVE";
|
||||
const stderr = await new Response(p.stderr).text();
|
||||
throw stderr;
|
||||
}
|
||||
const path = await new Response(p.stdout).text();
|
||||
this.filePath = sanitizePath(path);
|
||||
this.status = "PENDING";
|
||||
}
|
||||
}
|
||||
|
||||
export default Spotify;
|
|
@ -27,8 +27,8 @@ abstract class Ytdlp {
|
|||
PENDING: "PENDING",
|
||||
} as const;
|
||||
|
||||
downloadVideo?(videoId?: string): Promise<void>;
|
||||
downloadAudio?(): Promise<void>;
|
||||
downloadVideo?(...args: any): Promise<void>;
|
||||
downloadAudio?(...args: any): Promise<void>;
|
||||
formats?(): any;
|
||||
|
||||
public async clean() {
|
||||
|
|
|
@ -3,6 +3,8 @@ import db from "database";
|
|||
const adminId = Bun.env.ADMIN_ID as string;
|
||||
const botToken = Bun.env.BOT_TOKEN as string;
|
||||
const localApi = Bun.env.LOCAL_API as string;
|
||||
const spotifyClientId = Bun.env.SPOTIFY_CLIENT_ID as string;
|
||||
const spotifyclientSecret = Bun.env.SPOTIFY_CLIENT_SECRET as string;
|
||||
|
||||
if (!adminId) {
|
||||
console.log("Admin id isn't provided");
|
||||
|
@ -19,6 +21,16 @@ if (!localApi) {
|
|||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!spotifyClientId) {
|
||||
console.log("Spotify client id isn't provided");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!spotifyclientSecret) {
|
||||
console.log("Spotify client secret isn't provided");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
//? Create admin if it isn't exist
|
||||
const admin = await db.user.findUnique({ where: { id: adminId } });
|
||||
|
|
|
@ -3,7 +3,7 @@ export interface UserSessionData {
|
|||
id: string;
|
||||
role: "ADMIN" | "USER";
|
||||
media?: {
|
||||
type: "pin" | "yt" | "ytm" | "tt" | "ig" | "sc";
|
||||
type: "pin" | "yt" | "ytm" | "tt" | "ig" | "sc" | "sf";
|
||||
url: string;
|
||||
};
|
||||
} | null;
|
||||
|
@ -25,3 +25,8 @@ export interface VideoMetadata {
|
|||
width: number;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
export interface SpotifyResponse {
|
||||
song: string;
|
||||
artist: string;
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue