Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ YOUTUBE_API_KEY=
SPOTIFY_CLIENT_ID=
SPOTIFY_CLIENT_SECRET=

# See https://www.npmjs.com/package/soundcloud.ts for instructions to get this.
SOUNDCLOUD_CLIENT_ID=
SOUNDCLOUD_OAUTH_TOKEN=

############
# Optional #
############
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@
"postinstall-postinstall": "^2.1.0",
"read-pkg": "7.1.0",
"reflect-metadata": "^0.2.2",
"soundcloud.ts": "^0.6.5",
"sponsorblock-api": "^0.2.4",
"spotify-uri": "^3.0.2",
"spotify-web-api-node": "^5.0.2",
Expand Down
6 changes: 3 additions & 3 deletions src/commands/play.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import Spotify from 'spotify-web-api-node';
import Command from './index.js';
import {TYPES} from '../types.js';
import ThirdParty from '../services/third-party.js';
import getYouTubeAndSpotifySuggestionsFor from '../utils/get-youtube-and-spotify-suggestions-for.js';
import getSearchSuggestions from '../utils/get-youtube-and-spotify-suggestions-for.js';
import KeyValueCacheProvider from '../services/key-value-cache.js';
import {ONE_HOUR_IN_SECONDS} from '../utils/constants.js';
import AddQueryToQueue from '../services/add-query-to-queue.js';
Expand All @@ -28,7 +28,7 @@ export default class implements Command {

const queryDescription = thirdParty === undefined
? 'YouTube URL or search query'
: 'YouTube URL, Spotify URL, or search query';
: 'YouTube URL, Spotify URL, SoundCloud URL, or search query';

this.slashCommand = new SlashCommandBuilder()
.setName('play')
Expand Down Expand Up @@ -82,7 +82,7 @@ export default class implements Command {
} catch {}

const suggestions = await this.cache.wrap(
getYouTubeAndSpotifySuggestionsFor,
getSearchSuggestions,
query,
this.spotify,
10,
Expand Down
5 changes: 4 additions & 1 deletion src/inversify.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import AddQueryToQueue from './services/add-query-to-queue.js';
import GetSongs from './services/get-songs.js';
import YoutubeAPI from './services/youtube-api.js';
import SpotifyAPI from './services/spotify-api.js';
import SoundCloudAPI from './services/soundcloud-api.js';

// Commands
import Command from './commands/index.js';
Expand Down Expand Up @@ -42,6 +43,7 @@ import ThirdParty from './services/third-party.js';
import FileCacheProvider from './services/file-cache.js';
import KeyValueCacheProvider from './services/key-value-cache.js';


const container = new Container();

// Intents
Expand All @@ -64,13 +66,14 @@ container.bind(TYPES.Config).toConstantValue(new ConfigProvider());
container.bind<GetSongs>(TYPES.Services.GetSongs).to(GetSongs).inSingletonScope();
container.bind<AddQueryToQueue>(TYPES.Services.AddQueryToQueue).to(AddQueryToQueue).inSingletonScope();
container.bind<YoutubeAPI>(TYPES.Services.YoutubeAPI).to(YoutubeAPI).inSingletonScope();
container.bind<SoundCloudAPI>(TYPES.Services.SoundCloudAPI).to(SoundCloudAPI).inSingletonScope(); // SOUNDCLOUD

// Only instanciate spotify dependencies if the Spotify client ID and secret are set
const config = container.get<ConfigProvider>(TYPES.Config);
if (config.SPOTIFY_CLIENT_ID !== '' && config.SPOTIFY_CLIENT_SECRET !== '') {
container.bind<SpotifyAPI>(TYPES.Services.SpotifyAPI).to(SpotifyAPI).inSingletonScope();
container.bind(TYPES.ThirdParty).to(ThirdParty);
}
container.bind(TYPES.ThirdParty).to(ThirdParty);

// Commands
[
Expand Down
7 changes: 5 additions & 2 deletions src/managers/player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,25 @@ import {inject, injectable} from 'inversify';
import {TYPES} from '../types.js';
import Player from '../services/player.js';
import FileCacheProvider from '../services/file-cache.js';
import ThirdParty from '../services/third-party.js';

@injectable()
export default class {
private readonly guildPlayers: Map<string, Player>;
private readonly fileCache: FileCacheProvider;
private readonly thirdparty: ThirdParty;

constructor(@inject(TYPES.FileCache) fileCache: FileCacheProvider) {
constructor(@inject(TYPES.FileCache) fileCache: FileCacheProvider, @inject(TYPES.ThirdParty) thirdparty: ThirdParty) {
this.guildPlayers = new Map();
this.fileCache = fileCache;
this.thirdparty = thirdparty;
}

get(guildId: string): Player {
let player = this.guildPlayers.get(guildId);

if (!player) {
player = new Player(this.fileCache, guildId);
player = new Player(this.fileCache, guildId, this.thirdparty.soundcloud);

this.guildPlayers.set(guildId, player);
}
Expand Down
2 changes: 2 additions & 0 deletions src/services/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ export default class Config {
readonly YOUTUBE_API_KEY!: string;
readonly SPOTIFY_CLIENT_ID!: string;
readonly SPOTIFY_CLIENT_SECRET!: string;
readonly SOUNDCLOUD_CLIENT_ID!: string;
readonly SOUNDCLOUD_OAUTH_TOKEN!: string;
readonly REGISTER_COMMANDS_ON_BOT!: boolean;
readonly DATA_DIR!: string;
readonly CACHE_DIR!: string;
Expand Down
25 changes: 24 additions & 1 deletion src/services/get-songs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,19 @@ import {SongMetadata, QueuedPlaylist, MediaSource} from './player.js';
import {TYPES} from '../types.js';
import ffmpeg from 'fluent-ffmpeg';
import YoutubeAPI from './youtube-api.js';
import SoundCloudAPI from './soundcloud-api.js';
import SpotifyAPI, {SpotifyTrack} from './spotify-api.js';
import {URL} from 'node:url';

@injectable()
export default class {
private readonly youtubeAPI: YoutubeAPI;
private readonly soundcloudApi: SoundCloudAPI;
private readonly spotifyAPI?: SpotifyAPI;

constructor(@inject(TYPES.Services.YoutubeAPI) youtubeAPI: YoutubeAPI, @inject(TYPES.Services.SpotifyAPI) @optional() spotifyAPI?: SpotifyAPI) {
constructor(@inject(TYPES.Services.YoutubeAPI) youtubeAPI: YoutubeAPI, @inject(TYPES.Services.SoundCloudAPI) @optional() soundcloudApi: SoundCloudAPI, @inject(TYPES.Services.SpotifyAPI) @optional() spotifyAPI?: SpotifyAPI) {
this.youtubeAPI = youtubeAPI;
this.soundcloudApi = soundcloudApi;
this.spotifyAPI = spotifyAPI;
}

Expand Down Expand Up @@ -47,6 +50,14 @@ export default class {
throw new Error('that doesn\'t exist');
}
}
} else if (url.host === 'soundcloud.com') {
if (url.pathname.includes("/sets/")) {
const songs = await this.soundcloudPlaylist(url.href);
newSongs.push(...songs)
} else {
const songs = await this.soundcloudUrl(url.href);
newSongs.push(...songs)
}
} else if (url.protocol === 'spotify:' || url.host === 'open.spotify.com') {
if (this.spotifyAPI === undefined) {
throw new Error('Spotify is not enabled!');
Expand Down Expand Up @@ -110,6 +121,18 @@ export default class {
return this.youtubeAPI.getPlaylist(listId, shouldSplitChapters);
}

private async soundcloudSearch(query: string): Promise<SongMetadata[]> {
return this.soundcloudApi.search(query);
}

private async soundcloudUrl(url: string): Promise<SongMetadata[]> {
return this.soundcloudApi.get(url);
}

private async soundcloudPlaylist(url: string): Promise<SongMetadata[]> {
return this.soundcloudApi.getPlaylist(url);
}

private async spotifySource(url: string, playlistLimit: number, shouldSplitChapters: boolean): Promise<[SongMetadata[], number, number]> {
if (this.spotifyAPI === undefined) {
return [[], 0, 0];
Expand Down
11 changes: 10 additions & 1 deletion src/services/player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,12 @@ import debug from '../utils/debug.js';
import {getGuildSettings} from '../utils/get-guild-settings.js';
import {buildPlayingMessageEmbed} from '../utils/build-embed.js';
import {Setting} from '@prisma/client';
import {Soundcloud} from 'soundcloud.ts';

export enum MediaSource {
Youtube,
HLS,
SoundCloud
}

export interface QueuedPlaylist {
Expand Down Expand Up @@ -81,13 +83,15 @@ export default class {

private positionInSeconds = 0;
private readonly fileCache: FileCacheProvider;
private readonly soundcloud: Soundcloud;
private disconnectTimer: NodeJS.Timeout | null = null;

private readonly channelToSpeakingUsers: Map<string, Set<string>> = new Map();

constructor(fileCache: FileCacheProvider, guildId: string) {
constructor(fileCache: FileCacheProvider, guildId: string, soundcloud: Soundcloud) {
this.fileCache = fileCache;
this.guildId = guildId;
this.soundcloud = soundcloud;
}

async connect(channel: VoiceChannel): Promise<void> {
Expand Down Expand Up @@ -505,6 +509,11 @@ export default class {
return this.createReadStream({url: song.url, cacheKey: song.url});
}

if (song.source === MediaSource.SoundCloud) {
const stream: string = await this.soundcloud.util.streamLink(song.url);
return this.createReadStream({url: stream, cacheKey: song.url, cache: song.length < 30*60})
}

let ffmpegInput: string | null;
const ffmpegInputOptions: string[] = [];
let shouldCacheVideo = false;
Expand Down
155 changes: 155 additions & 0 deletions src/services/soundcloud-api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import {inject, injectable} from 'inversify';
import PQueue from 'p-queue';
import Soundcloud, {SoundcloudTrack, SoundcloudTrackSearch} from 'soundcloud.ts';
import {SongMetadata, QueuedPlaylist, MediaSource} from './player.js';
import {TYPES} from '../types.js';
import KeyValueCacheProvider from './key-value-cache.js';
import {ONE_HOUR_IN_SECONDS, ONE_MINUTE_IN_SECONDS} from '../utils/constants.js';
import ThirdParty from './third-party.js';

@injectable()
export default class {
private readonly cache: KeyValueCacheProvider;
private readonly soundcloud: Soundcloud;
private readonly scsrQueue: PQueue;

constructor(@inject(TYPES.KeyValueCache) cache: KeyValueCacheProvider, @inject(TYPES.ThirdParty) thirdParty: ThirdParty) {
this.soundcloud = thirdParty.soundcloud;
this.cache = cache;
this.scsrQueue = new PQueue({concurrency: 4});
}

async search(query: string): Promise<SongMetadata[]> {
const items = await this.scsrQueue.add(async () => this.cache.wrap(
async () => this.soundcloud.tracks.search({q: query}),
{
limit: 10,
},
{
expiresIn: ONE_HOUR_IN_SECONDS,
},
)) as SoundcloudTrackSearch;

const track = items.collection.at(0);

if (!track) {
throw new Error('Track could not be found.');
}

return this.getMetadataFromVideo({track});
}

async get(query: string): Promise<SongMetadata[]> {
const track = await this.scsrQueue.add(async () => this.cache.wrap(
async () => this.soundcloud.tracks.get(query),
// {q: query},
{
limit: 10,
},
{
expiresIn: ONE_HOUR_IN_SECONDS,
},
));

if (!track) {
throw new Error('Track could not be found.');
}

return this.getMetadataFromVideo({track});
}

async getPlaylist(listId: string): Promise<SongMetadata[]> {
const playlist = await this.cache.wrap(
async () => this.soundcloud.playlists.get(listId),
{
key: listId,
expiresIn: ONE_MINUTE_IN_SECONDS,
},
);

if (!playlist) {
throw new Error('Playlist could not be found.');
}

const queuedPlaylist = {title: playlist.title, source: playlist.id.toString()};

const songsToReturn: SongMetadata[] = [];

for (const track of playlist.tracks) {
try {
songsToReturn.push(...this.getMetadataFromVideo({
track,
queuedPlaylist,
}));
} catch (_: unknown) {
// Private and deleted videos are sometimes in playlists, duration of these
// is not returned and they should not be added to the queue.
}
}

return songsToReturn;
}

// not fully supported yet.
async getArtist(userName: string): Promise<SongMetadata[]> {
const tracks = await this.cache.wrap(
async () => this.soundcloud.users.tracks(userName),
{
key: userName + 'tracks',
expiresIn: ONE_MINUTE_IN_SECONDS,
},
);

const user = await this.cache.wrap(
async () => this.soundcloud.users.get(userName),
{
key: userName + 'user',
expiresIn: ONE_MINUTE_IN_SECONDS,
},
);

if (!tracks) {
throw new Error('Playlist could not be found.');
}

const queuedPlaylist = {title: user.username, source: user.id.toString()};

const songsToReturn: SongMetadata[] = [];

for (const track of tracks) {
try {
songsToReturn.push(...this.getMetadataFromVideo({
track,
queuedPlaylist,
}));
} catch (_: unknown) {
// Private and deleted videos are sometimes in playlists, duration of these
// is not returned and they should not be added to the queue.
}
}

return songsToReturn;
}

private getMetadataFromVideo({
track,
queuedPlaylist,
}: {
track: SoundcloudTrack;
queuedPlaylist?: QueuedPlaylist;
}): SongMetadata[] {
const base: SongMetadata = {
source: MediaSource.SoundCloud,
title: track.title,
artist: track.user.username,
length: track.duration / 1000,
offset: 0,
url: track.permalink_url,
playlist: queuedPlaylist ?? null,
isLive: false,
thumbnailUrl: track.artwork_url,
};

return [base];
}
}
4 changes: 4 additions & 0 deletions src/services/third-party.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import {inject, injectable} from 'inversify';
import SpotifyWebApi from 'spotify-web-api-node';
import {Soundcloud} from 'soundcloud.ts';
import pRetry from 'p-retry';
import {TYPES} from '../types.js';
import Config from './config.js';

@injectable()
export default class ThirdParty {
readonly spotify: SpotifyWebApi;
readonly soundcloud: Soundcloud;

private spotifyTokenTimerId?: NodeJS.Timeout;

Expand All @@ -16,6 +18,8 @@ export default class ThirdParty {
clientSecret: config.SPOTIFY_CLIENT_SECRET,
});

this.soundcloud = new Soundcloud(config.SOUNDCLOUD_CLIENT_ID, config.SOUNDCLOUD_OAUTH_TOKEN)

void this.refreshSpotifyToken();
}

Expand Down
1 change: 1 addition & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,6 @@ export const TYPES = {
GetSongs: Symbol('GetSongs'),
YoutubeAPI: Symbol('YoutubeAPI'),
SpotifyAPI: Symbol('SpotifyAPI'),
SoundCloudAPI: Symbol('SoundCloudAPI'),
},
};
Loading