Skip to content

Commit

Permalink
add "sneaky" V2 API client (#77)
Browse files Browse the repository at this point in the history
  • Loading branch information
goto-bus-stop authored Nov 2, 2021
1 parent 4e5a43c commit 766bcd9
Show file tree
Hide file tree
Showing 6 changed files with 2,904 additions and 84 deletions.
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,11 @@
},
"dependencies": {
"get-artist-title": "^1.2.0",
"http-errors": "^1.8.0",
"node-fetch": "^2.6.0"
},
"devDependencies": {
"@types/http-errors": "^1.8.1",
"@types/node-fetch": "^2.5.4",
"nock": "^13.0.0",
"tape": "^5.0.0",
Expand Down
103 changes: 90 additions & 13 deletions src/Client.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { URL, URLSearchParams } from 'url';
import { URL } from 'url';
import httpErrors from 'http-errors';
import fetch from 'node-fetch';

export type ResolveTrackOptions = {
Expand Down Expand Up @@ -36,21 +37,27 @@ export type SearchOptions = {
limit: number,
};

export interface SoundCloudClient {
search(options: SearchOptions): Promise<TrackResource[]>;
resolveTrack(options: ResolveTrackOptions): Promise<TrackResource>;
getTracks(options: GetTracksOptions): Promise<TrackResource[]>;
}

/**
* A small SoundCloud API client.
*/
export default class SoundCloudClient {
private params: { [key: string]: string };
private baseUrl: string;
export class SoundCloudV1Client implements SoundCloudClient {
#params: Record<string, string>;
#baseUrl: string;

constructor(params: SoundCloudClient["params"]) {
this.params = params;
this.baseUrl = 'https://api.soundcloud.com';
constructor(params: Record<string, string>) {
this.#params = params;
this.#baseUrl = 'https://api.soundcloud.com';
}

async get(resource: string, options: SoundCloudClient["params"]) {
const url = new URL(resource, this.baseUrl);
for (const [key, value] of Object.entries({ ...this.params, ...options })) {
async #get(resource: string, options: Record<string, string>) {
const url = new URL(resource, this.#baseUrl);
for (const [key, value] of Object.entries({ ...this.#params, ...options })) {
url.searchParams.append(key, value);
}
const response = await fetch(url, {
Expand All @@ -66,18 +73,88 @@ export default class SoundCloudClient {
}

search(options: SearchOptions) {
return this.get('/tracks', {
return this.#get('/tracks', {
q: options.q,
offset: options.offset.toString(),
limit: options.limit.toString(),
});
}

resolveTrack(options: ResolveTrackOptions): Promise<TrackResource> {
return this.get('/resolve', options);
return this.#get('/resolve', options);
}

getTracks(options: GetTracksOptions): Promise<TrackResource[]> {
return this.get('/tracks', options);
return this.#get('/tracks', options);
}
}

export class SoundCloudV2Client implements SoundCloudClient {
#baseUrl = 'https://api-v2.soundcloud.com/'
#clientID = this.#stealClientID()

// I don't want to do this but it is literally impossible to use the V1 API right now.
// If SoundCloud starts issuing API keys again we'll get rid of this.
async #stealClientID() {
const url = 'https://soundcloud.com/discover'
const homeResponse = await fetch(url)
const homepage = await homeResponse.text()
for (const match of homepage.matchAll(/<script(?:.*?)src="(.*?)"(?:.*?)><\/script>/g)) {
const scriptResponse = await fetch(new URL(match[1], url))
const js = await scriptResponse.text()
const m = js.match(/client_id:"(.*?)"/);
if (m?.[1]) {
return m[1];
}
}
throw new Error('Could not determine client ID');
}

async #get<T>(resource: string, options: Record<string, string>, isRetry = false): Promise<T> {
const clientID = await this.#clientID;
const url = new URL(resource, this.#baseUrl);
for (const [key, value] of Object.entries({ client_id: clientID, ...options })) {
url.searchParams.append(key, value);
}
const response = await fetch(url, {
headers: {
accept: 'application/json',
},
});

if (response.status === 401) {
if (isRetry) {
throw new httpErrors.Unauthorized();
}

// Try to refresh the client ID.
this.#clientID = this.#stealClientID();
return this.#get(resource, options, true);
}

const data = await response.json();
if (!response.ok) {
throw new httpErrors[response.status](data.error?.message ?? data.message);
}

return data;
}

async search(options: SearchOptions): Promise<TrackResource[]> {
const { collection } = await this.#get('/search/tracks', {
q: options.q,
offset: `${options.offset}`,
limit: `${options.limit}`,
});

return collection;
}

async resolveTrack(options: ResolveTrackOptions): Promise<TrackResource> {
return this.#get('/resolve', { url: options.url });
}

async getTracks(options: GetTracksOptions): Promise<TrackResource[]> {
return this.#get('/tracks', { ids: options.ids });
}
}
12 changes: 4 additions & 8 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import getArtistTitle = require('get-artist-title');
import SoundCloudClient, { TrackResource } from './Client';
import { SoundCloudClient, TrackResource, SoundCloudV1Client, SoundCloudV2Client } from './Client';

const PAGE_SIZE = 50;

Expand Down Expand Up @@ -57,13 +57,9 @@ export type SoundCloudOptions = {
};

export default function soundCloudSource(uw: unknown, opts: SoundCloudOptions) {
if (!opts || !opts.key) {
throw new TypeError('Expected a SoundCloud API key in "options.key". For information on how to '
+ 'configure your SoundCloud API access, see '
+ 'https://soundcloud.com/you/apps.');
}

const client = new SoundCloudClient({ client_id: opts.key });
const client: SoundCloudClient = opts?.key
? new SoundCloudV1Client({ client_id: opts.key })
: new SoundCloudV2Client()

async function resolve(url: string) {
const body = await client.resolveTrack({ url });
Expand Down
151 changes: 89 additions & 62 deletions test/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,88 +7,115 @@ const soundCloudSource = require('..');

const test = promisifyTape(tape);

const FAKE_KEY = 'da5ad14e8278aedac18ba470373c7634';
const FAKE_V1_KEY = 'da5ad14e8278aedac18ba470373c7634';
const FAKE_V2_KEY = 'YxQYlFPNletSMSZ4b8Svv9FTYgbNbM79';

const createSource = () => soundCloudSource({}, { key: FAKE_KEY });
const createSourceV1 = () => soundCloudSource({}, { key: FAKE_V1_KEY });
const createSourceV2 = () => soundCloudSource({});

const API_HOST = 'https://api.soundcloud.com';
const WEB_HOST = 'https://soundcloud.com';
const API_V1_HOST = 'https://api.soundcloud.com';
const API_V2_HOST = 'https://api-v2.soundcloud.com';

const fixture = (name) => path.join(__dirname, 'responses', `${name}.json`);

test('providing a key is required', (t) => {
t.throws(
() => soundCloudSource({}),
/Expected a SoundCloud API key/,
);
test('v1', ({ test }) => {
test('searching for videos', async (t) => {
const src = createSourceV1();

t.end();
});

test('searching for videos', async (t) => {
const src = createSource();
nock(API_V1_HOST).get('/tracks')
.query({
q: 'oceanfromtheblue',
client_id: FAKE_V1_KEY,
offset: 0,
limit: 50,
})
.replyWithFile(200, fixture('search'));

nock(API_HOST).get('/tracks')
.query({
q: 'oceanfromtheblue',
client_id: FAKE_KEY,
offset: 0,
limit: 50,
})
.replyWithFile(200, fixture('search'));
const results = await src.search('oceanfromtheblue');

const results = await src.search('oceanfromtheblue');
// Limit is 50 but the results fixture only contains 10 :)
t.is(results.length, 10);

t.is(results.length, 10);
results.forEach((item) => {
t.true('artist' in item);
t.true('title' in item);
});

results.forEach((item) => {
t.true('artist' in item);
t.true('title' in item);
t.end();
});

t.end();
});
test('get videos by id', async (t) => {
const src = createSourceV1();

test('get videos by id', async (t) => {
const src = createSource();
nock(API_V1_HOST).get('/tracks')
.query({
client_id: FAKE_V1_KEY,
ids: '389870604,346713308',
})
.reply(200, () => [
JSON.parse(fs.readFileSync(fixture('track.389870604'), 'utf8')),
JSON.parse(fs.readFileSync(fixture('track.346713308'), 'utf8')),
]);

nock(API_HOST).get('/tracks')
.query({
client_id: FAKE_KEY,
ids: '389870604,346713308',
})
.reply(200, () => [
JSON.parse(fs.readFileSync(fixture('track.389870604'), 'utf8')),
JSON.parse(fs.readFileSync(fixture('track.346713308'), 'utf8')),
]);
const items = await src.get(['389870604', '346713308']);

const items = await src.get(['389870604', '346713308']);
t.is(items.length, 2);

t.is(items.length, 2);
t.is(items[0].artist, 'oceanfromtheblue(오션)');
t.is(items[1].artist, 'slchld');

t.is(items[0].artist, 'oceanfromtheblue(오션)');
t.is(items[1].artist, 'slchld');
t.end();
});

t.end();
test('missing authentication', async (t) => {
const src = createSourceV1();

nock(API_V1_HOST).get('/tracks')
.query({
client_id: FAKE_V1_KEY,
ids: '389870604,346713308',
})
.reply(401, () => {
return JSON.parse(fs.readFileSync(fixture('401'), 'utf8'));
});

try {
await src.get(['389870604', '346713308']);
} catch (error) {
t.ok(error.message.includes('A request must contain the Authorization header'));
return t.end();
}

t.fail('expected error');
});
});

test('missing authentication', async (t) => {
const src = createSource();

nock(API_HOST).get('/tracks')
.query({
client_id: FAKE_KEY,
ids: '389870604,346713308',
})
.reply(401, () => {
return JSON.parse(fs.readFileSync(fixture('401'), 'utf8'));
test('v2', ({ test }) => {
test('searching for videos', async (t) => {
nock(WEB_HOST)
.get('/discover').reply(200, '<script crossorigin src="/fake.js"></script>')
.get('/fake.js').reply(200, `({client_id:"${FAKE_V2_KEY}"})`);
nock(API_V2_HOST).get('/search/tracks')
.query({
q: 'oceanfromtheblue',
client_id: FAKE_V2_KEY,
offset: 0,
limit: 50,
})
.replyWithFile(200, fixture('search2'));

const src = createSourceV2();
const results = await src.search('oceanfromtheblue');

// Limit is 50 but the results fixture only contains 20 :)
t.is(results.length, 20);

results.forEach((item) => {
t.true('artist' in item);
t.true('title' in item);
});

try {
await src.get(['389870604', '346713308']);
} catch (error) {
t.ok(error.message.includes('A request must contain the Authorization header'));
return t.end();
}

t.fail('expected error');
t.end();
});
});
Loading

0 comments on commit 766bcd9

Please sign in to comment.