Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Very Basic support of SoundClound #1030

Open
wants to merge 6 commits into
base: master
Choose a base branch
from
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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@
"postinstall-postinstall": "^2.1.0",
"read-pkg": "7.1.0",
"reflect-metadata": "^0.1.13",
"soundcloud.ts": "^0.5.2",
"sponsorblock-api": "^0.2.4",
"spotify-uri": "^3.0.2",
"spotify-web-api-node": "^5.0.2",
Expand Down
2 changes: 1 addition & 1 deletion src/commands/play.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export default class implements Command {
.setDescription('play a song')
.addStringOption(option => option
.setName('query')
.setDescription('YouTube URL, Spotify URL, or search query')
.setDescription('YouTube URL, Spotify URL, SoundCloud URL, or search query')
.setAutocomplete(true)
.setRequired(true))
.addBooleanOption(option => option
Expand Down
2 changes: 2 additions & 0 deletions 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 @@ -62,6 +63,7 @@ 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<SpotifyAPI>(TYPES.Services.SpotifyAPI).to(SpotifyAPI).inSingletonScope();
container.bind<SoundCloudAPI>(TYPES.Services.SoundCloudAPI).to(SoundCloudAPI).inSingletonScope();

// 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.thirdparty, this.fileCache, guildId);

this.guildPlayers.set(guildId, player);
}
Expand Down
17 changes: 17 additions & 0 deletions src/services/add-query-to-queue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,10 @@ export default class AddQueryToQueue {
'www.music.youtube.com',
];

const SOUNDCLOUD_HOSTS = [
'soundcloud.com',
];

if (YOUTUBE_HOSTS.includes(url.host)) {
// YouTube source
if (url.searchParams.get('list')) {
Expand All @@ -81,6 +85,19 @@ export default class AddQueryToQueue {
} else {
const songs = await this.getSongs.youtubeVideo(url.href, shouldSplitChapters);

if (songs) {
newSongs.push(...songs);
} else {
throw new Error('that doesn\'t exist');
}
}
} else if (SOUNDCLOUD_HOSTS.includes(url.host)) {
if (url.pathname.includes('/sets/')) {
const songs = await this.getSongs.soundcloudPlaylist(url.href);
newSongs.push(...songs);
} else {
const songs = await this.getSongs.soundcloudVideo(url.href);

if (songs) {
newSongs.push(...songs);
} else {
Expand Down
21 changes: 20 additions & 1 deletion src/services/get-songs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,18 @@ import {TYPES} from '../types.js';
import ffmpeg from 'fluent-ffmpeg';
import YoutubeAPI from './youtube-api.js';
import SpotifyAPI, {SpotifyTrack} from './spotify-api.js';
import SoundCloudAPI from './soundcloud-api.js';

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

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

async youtubeVideoSearch(query: string, shouldSplitChapters: boolean): Promise<SongMetadata[]> {
Expand All @@ -28,6 +31,22 @@ export default class {
return this.youtubeAPI.getPlaylist(listId, shouldSplitChapters);
}

async soundcloudVideoSearch(query: string): Promise<SongMetadata[]> {
return this.soundcloudAPI.search(query);
}

async soundcloudVideo(url: string): Promise<SongMetadata[]> {
return this.soundcloudAPI.get(url);
}

async soundcloudPlaylist(listId: string): Promise<SongMetadata[]> {
return this.soundcloudAPI.getPlaylist(listId);
}

async soundcloudArtist(listId: string): Promise<SongMetadata[]> {
return this.soundcloudAPI.getArtist(listId);
}

async spotifySource(url: string, playlistLimit: number, shouldSplitChapters: boolean): Promise<[SongMetadata[], number, number]> {
const parsed = spotifyURI.parse(url);

Expand Down
14 changes: 12 additions & 2 deletions src/services/player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,13 @@ import FileCacheProvider from './file-cache.js';
import debug from '../utils/debug.js';
import {getGuildSettings} from '../utils/get-guild-settings.js';
import {buildPlayingMessageEmbed} from '../utils/build-embed.js';
import ThirdParty from './third-party.js';
import Soundcloud from 'soundcloud.ts';

export enum MediaSource {
Youtube,
HLS,
SoundCloud,
}

export interface QueuedPlaylist {
Expand Down Expand Up @@ -80,9 +83,11 @@ export default class {

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

constructor(fileCache: FileCacheProvider, guildId: string) {
constructor(thirdParty: ThirdParty, fileCache: FileCacheProvider, guildId: string) {
this.soundcloud = thirdParty.soundcloud;
this.fileCache = fileCache;
this.guildId = guildId;
}
Expand Down Expand Up @@ -433,6 +438,11 @@ export default class {
return this.createReadStream({url: song.url, cacheKey: song.url});
}

if (song.source === MediaSource.SoundCloud) {
const scSong = await this.soundcloud.util.streamTrack(song.url) as Readable;
return this.createReadStream({url: scSong, cacheKey: song.url});
}

let ffmpegInput: string | null;
const ffmpegInputOptions: string[] = [];
let shouldCacheVideo = false;
Expand Down Expand Up @@ -589,7 +599,7 @@ export default class {
}
}

private async createReadStream(options: {url: string; cacheKey: string; ffmpegInputOptions?: string[]; cache?: boolean; volumeAdjustment?: string}): Promise<Readable> {
private async createReadStream(options: {url: string | Readable; cacheKey: string; ffmpegInputOptions?: string[]; cache?: boolean; volumeAdjustment?: string}): Promise<Readable> {
return new Promise((resolve, reject) => {
const capacitor = new WriteStream();

Expand Down
150 changes: 150 additions & 0 deletions src/services/soundcloud-api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import {inject, injectable} from 'inversify';
import PQueue from 'p-queue';
import Soundcloud, {SoundcloudTrackV2} 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.searchV2({q: query}),
{
key: 'scsearch:' + query,
expiresIn: ONE_HOUR_IN_SECONDS,
},
));

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.getV2(query),
{
key: 'scget:' + query,
expiresIn: ONE_HOUR_IN_SECONDS,
},
));

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

return this.getMetadataFromVideo({track});
}

async getPlaylist(query: string): Promise<SongMetadata[]> {
const playlist = await this.cache.wrap(
async () => this.soundcloud.playlists.getV2(query),
{
key: 'scplaylist:' + query,
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.tracksV2(userName),
{
key: userName + 'tracks',
expiresIn: ONE_MINUTE_IN_SECONDS,
},
);

const user = await this.cache.wrap(
async () => this.soundcloud.users.getV2(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: SoundcloudTrackV2;
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({});

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'),
},
};
2 changes: 1 addition & 1 deletion src/utils/build-embed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ const getMaxSongTitleLength = (title: string) => {
};

const getSongTitle = ({title, url, offset, source}: QueuedSong, shouldTruncate = false) => {
if (source === MediaSource.HLS) {
if (source === MediaSource.HLS || source === MediaSource.SoundCloud) {
return `[${title}](${url})`;
}

Expand Down
Loading
Loading