Skip to content
This repository has been archived by the owner on Mar 26, 2024. It is now read-only.

Commit

Permalink
feat: add playlist.ts
Browse files Browse the repository at this point in the history
  • Loading branch information
vaaski committed Feb 11, 2021
1 parent aaef08b commit 4383234
Show file tree
Hide file tree
Showing 15 changed files with 515 additions and 47 deletions.
9 changes: 8 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
SOUNDCLOUD_CLIENT_ID_V1=
SOUNDCLOUD_CLIENT_ID_V2=
SOUNDCLOUD_OAUTH_TOKEN=
SOUNDCLOUD_OAUTH_TOKEN=

EXAMPLE_PLAYLIST_ID=620756469
EXAMPLE_PLAYLIST_URL=https://soundcloud.com/vaaski/sets/rrrrrrrrr
EXAMPLE_USER_ID=51999125
EXAMPLE_USER_URL=https://soundcloud.com/vaaski
EXAMPLE_TRACK_ID=734769850
EXAMPLE_TRACK_URL=https://soundcloud.com/vaaski/slow-night
14 changes: 13 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,18 @@
{
"cSpell.words": [
"Playlistv",
"lcov",
"sndcdn",
"urlify"
],
"cSpell.ignorePaths": [
"**/package-lock.json",
"**/node_modules/**",
"**/vscode-extension/**",
"**/.git/objects/**",
".vscode",
".vscode-insiders",
"demo_out.json",
"*.d.ts"
]
}
}
43 changes: 26 additions & 17 deletions demo.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { resolve } from "./src"
/* eslint-disable @typescript-eslint/no-unused-vars */
import { playlist, resolve } from "./src"
import { writeFileSync } from "fs"
import { getClientIDv2 } from "./src/util"
import { join } from "path"

const byteLength = (input: any) => Buffer.byteLength(JSON.stringify(input), "utf8")
const byteLength = (input: any) => Buffer.byteLength(JSON.stringify(input) || "", "utf8")
const formatSize = (bytes: number, decimals = 2) => {
if (bytes === 0) return "0 Bytes"

Expand All @@ -14,24 +17,30 @@ const formatSize = (bytes: number, decimals = 2) => {
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i]
}

/* eslint-disable @typescript-eslint/no-unused-vars */
const client_id = process.env.SOUNDCLOUD_CLIENT_ID_V1 || "client_id"
const examplePlaylist = "https://soundcloud.com/vaaski/sets/rrrrrrrrr"
const exampleUser = "https://soundcloud.com/vaaski"
const exampleTrack = "https://soundcloud.com/vaaski/slow-night"
/* eslint-enable @typescript-eslint/no-unused-vars */
const client_id2 = process.env.SOUNDCLOUD_CLIENT_ID_V2 || "client_id"
const examplePlaylistURL = process.env.EXAMPLE_PLAYLIST_URL || ""
const examplePlaylistID = Number(process.env.EXAMPLE_PLAYLIST_ID) || 0
const exampleUserURL = process.env.EXAMPLE_USER_URL || ""
const exampleTrackURL = process.env.EXAMPLE_TRACK_URL || ""

!(async () => {
const startTime = Date.now()

const out = await resolve(exampleUser)
// const out = await resolve(exampleTrack)
// const out = await resolve(examplePlaylist)
// const out = await resolve.browser(exampleUser, client_id)
// const out = await resolve.browser(exampleTrack, client_id)
// const out = await resolve.browser(examplePlaylist, client_id)

console.log(`fetched ${formatSize(byteLength(out))} in ${Date.now() - startTime}ms`)

writeFileSync("demo_out.json", JSON.stringify(out, null, 2))
const out = await playlist(examplePlaylistURL)
// const out = await playlist(examplePlaylistID)
// const out = await getClientIDv2()
// const out = await resolve(exampleUserURL)
// const out = await resolve(exampleTrackURL)
// const out = await resolve(examplePlaylistURL)
// const out = await resolve.browser(exampleUserURL, client_id)
// const out = await resolve.browser(exampleTrackURL, client_id)
// const out = await resolve.browser(examplePlaylistURL, client_id)

console.log(
`fetched ${formatSize(byteLength(out))} in ${Date.now() - startTime}ms\n` +
`see ${join(__dirname, "demo_out.json")}`
)

writeFileSync("demo_out.json", JSON.stringify(out, null, 2) || "")
})()
10 changes: 6 additions & 4 deletions readme.md
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
<div align="center">
<p>
<a href="https://github.com/cloudr-app/opensoundcloud"><img src="https://raw.githubusercontent.com/cloudr-app/opensoundcloud/master/assets/opensoundcloud.svg" /></a>
<a target="_blank" href="https://github.com/cloudr-app/opensoundcloud">
<img src="https://raw.githubusercontent.com/cloudr-app/opensoundcloud/master/assets/opensoundcloud.svg" />
</a>
</p>
</div>

<p align="center">
<a href="https://npmjs.org/package/opensoundcloud" alt="version">
<a target="_blank" href="https://npmjs.org/package/opensoundcloud" alt="version">
<img src="https://img.shields.io/npm/v/opensoundcloud.svg?style=for-the-badge">
</a>
<a href="https://codecov.io/gh/cloudr-app/opensoundcloud" alt="downloads">
<a target="_blank" href="https://codecov.io/gh/cloudr-app/opensoundcloud" alt="downloads">
<img alt="Codecov" src="https://img.shields.io/codecov/c/gh/cloudr-app/opensoundcloud?style=for-the-badge">
</a>
<a href="https://npmjs.org/package/opensoundcloud" alt="downloads">
<a target="_blank" href="https://npmjs.org/package/opensoundcloud" alt="downloads">
<img src="https://img.shields.io/npm/dw/opensoundcloud.svg?style=for-the-badge">
</a>
</p>
Expand Down
5 changes: 3 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import resolve from "./resolve"
import playlist from "./playlist"

export default { resolve }
export { resolve }
export default { resolve, playlist }
export { resolve, playlist }
58 changes: 58 additions & 0 deletions src/playlist.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import type { ClientIDv2, URLorID } from "../types"
import type { Playlistv2 } from "../types/playlist"

import ky from "ky-universal"
import { APIv2, getClientIDv2, scrapeData, ScrapeIDs, urlify } from "./util"

/**
* Get a playlist using the APIv2 with a playlist ID
* @param id A playlist ID
* @param client_id client_id for APIv2
*/
const byID = async (id: number, client_id: ClientIDv2): Promise<Playlistv2> => {
const url = urlify(`playlists/${id}`, APIv2)
const searchParams = { client_id }

const data = await ky(url, { searchParams }).json()
return data as Playlistv2
}

/**
* Get a playlist using the APIv2 with a playlist URL
* @param url A playlist URL
*/
const byURL = async (url: string): Promise<Playlistv2> => {
const scraped = await scrapeData(urlify(url))
const playlistData = scraped.find(({ id }) => id === ScrapeIDs.playlist)
if (!playlistData) throw new Error("No playlist data found.")

const { data } = playlistData
return data[0] as Playlistv2
}

/**
* Get a playlist using the APIv2 with either a playlist URL or its ID.
*
* If you use a playlist ID, you can provide a v2 client_id (recommended).
*
* Uses `util.getClientIDv2` to find a client_id if none is provided.
* @param source A playlist URL or ID
* @param client_id Optional.
*/
const playlist = async (source: URLorID, client_id?: string): Promise<Playlistv2> => {
if (typeof source === "string") return await byURL(source)

if (typeof source === "number") {
if (!client_id) client_id = await getClientIDv2()
return await byID(source, client_id)
}

throw new Error("Source must be a string (URL) or a number (ID)")
}

playlist.id = byID
playlist.url = byURL

// TODO browser equivalents with pagination

export default playlist
14 changes: 6 additions & 8 deletions src/resolve.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
import { APIv1Resolve, ScrapeResolve } from "../types/resolve"
import type { APIv1Resolve, ScrapeResolve } from "../types/resolve"

import { APIv1, at, urlify } from "./util"
import ky from "ky-universal"

const htmlDataReg = /(\[{"id")(.*?)(?=\);)/i
import { APIv1, at, scrapeData, urlify } from "./util"

/**
* Resolve a track, user or playlist using web scraping.
Expand All @@ -12,10 +10,8 @@ const htmlDataReg = /(\[{"id")(.*?)(?=\);)/i
* @param url
*/
const resolve = async (url: string): Promise<ScrapeResolve> => {
const html = await ky(urlify(url)).text()
const [match] = html.match(htmlDataReg) || []
const parsed = JSON.parse(match)
const { data } = at(parsed, -1) // || at(parsed, -2)
const scraped = await scrapeData(urlify(url))
const { data } = at(scraped, -1) // || at(parsed, -2)

return data[0] as ScrapeResolve
}
Expand All @@ -29,6 +25,8 @@ const resolve = async (url: string): Promise<ScrapeResolve> => {
resolve.browser = async (url: string, client_id: string): Promise<APIv1Resolve> => {
url = urlify(url)

if (!client_id) throw new Error("APIv1 client_id has to be provided")

const searchParams = { url, client_id }
const data = await ky(`${APIv1}/resolve`, { searchParams }).json()

Expand Down
35 changes: 34 additions & 1 deletion src/util.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
// cspell:ignore urlify
import type { ScrapedData } from "../types/scrape"

import ky from "ky-universal"

export const issueURL = "https://github.com/cloudr-app/opensoundcloud/issues/new"

export const scrapeURL = "https://soundcloud.com"
export const APIv1 = "https://api.soundcloud.com"
Expand All @@ -11,3 +15,32 @@ export const at = <T>(arr: Array<T>, pos: number): T => {
if (pos >= 0) return arr[pos]
return arr[arr.length + pos]
}

const htmlDataReg = /(\[{"id")(.*?)(?=\);)/i
export const scrapeData = async (url: string): Promise<ScrapedData> => {
const html = await ky(url).text()
const [match] = html.match(htmlDataReg) || []

return JSON.parse(match) as ScrapedData
}

export const ScrapeIDs = {
user: 30,
playlist: 45,
}

const scriptReg = /<script(?: crossorigin)? src="(https:\/\/a-v2\.sndcdn\.com\/assets\/.+\.js)"/gm
const clientIDReg = /client_id=(\w+)/
export const getClientIDv2 = async (): Promise<string> => {
const html = await ky(scrapeURL).text()
const scriptURLs = Array.from(html.matchAll(scriptReg), (m: string[]) => m[1])

for (const url of scriptURLs) {
const script = await ky(url).text()
const match = script.match(clientIDReg)
if (match?.[1]) return match[1]
}

/* istanbul ignore next */
throw new Error(`Can't find client_id, please report this to ${issueURL}`)
}
44 changes: 44 additions & 0 deletions tests/playlist.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import test from "ava"

import { playlist } from "../src"
import { getClientIDv2 } from "../src/util"

const examplePlaylistURL = process.env.EXAMPLE_PLAYLIST_URL || ""
const examplePlaylistID = Number(process.env.EXAMPLE_PLAYLIST_ID) || 0

let client_id2: string

test.before(async () => {
client_id2 = await getClientIDv2()
})

test("get playlist using URL", async t => {
const data = await playlist(examplePlaylistURL)
t.is(data.kind, "playlist")
t.truthy(data.id)
})

test("get playlist using ID", async t => {
const data = await playlist(examplePlaylistID)
t.is(data.kind, "playlist")
t.truthy(data.id)
})

test("get playlist using ID and client_id", async t => {
const data = await playlist(examplePlaylistID, client_id2)
t.is(data.kind, "playlist")
t.truthy(data.id)
})

test("get playlist throws using wrong input", async t => {
// @ts-expect-error intentionally wrong input
await t.throwsAsync(playlist([]))
})

test("get playlist by URL throws when not found", async t => {
await t.throwsAsync(playlist(""))
})

test("get playlist by ID throws when not found", async t => {
await t.throwsAsync(playlist(0, client_id2))
})
53 changes: 42 additions & 11 deletions tests/resolve.test.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,56 @@
import test from "ava"

import resolve from "../src/resolve"
import { resolve } from "../src"

const client_id = process.env.SOUNDCLOUD_CLIENT_ID_V1 || "client_id"

// const examplePlaylist = "https://soundcloud.com/vaaski/sets/rrrrrrrrr"
const exampleUser = "https://soundcloud.com/vaaski"
// const exampleTrack = "https://soundcloud.com/vaaski/slow-night"
const examplePlaylistURL = process.env.EXAMPLE_PLAYLIST_URL || ""
const exampleUserURL = process.env.EXAMPLE_USER_URL || ""
const exampleTrackURL = process.env.EXAMPLE_TRACK_URL || ""

test("resolve user with scraping", async t => {
const data = await resolve(exampleUser)
test("resolve user", async t => {
const data = await resolve(exampleUserURL)
t.is(data.kind, "user")
t.truthy(data.username)
t.is(data.id, 51999125)
t.truthy(data.id)
})

test("resolve user using invalid URL with scraping", async t => {
test("resolve playlist", async t => {
const data = await resolve(examplePlaylistURL)
t.is(data.kind, "playlist")
t.truthy(data.id)
})

test("resolve track", async t => {
const data = await resolve(exampleTrackURL)
t.is(data.kind, "track")
t.truthy(data.id)
})

test("resolve user using invalid URL", async t => {
await t.throwsAsync(resolve("https://vaaski.dev"))
})

test("resolve user with APIv1", async t => {
const data = await resolve.browser(exampleUser, client_id)
test("resolve.browser user", async t => {
const data = await resolve.browser(exampleUserURL, client_id)
t.is(data.kind, "user")
t.truthy(data.username)
t.is(data.id, 51999125)
t.truthy(data.id)
})

test("resolve.browser playlist", async t => {
const data = await resolve.browser(examplePlaylistURL, client_id)
t.is(data.kind, "playlist")
t.truthy(data.id)
})

test("resolve.browser track", async t => {
const data = await resolve.browser(exampleTrackURL, client_id)
t.is(data.kind, "track")
t.truthy(data.id)
})

test("resolve.browser user throws without client_id", async t => {
// @ts-expect-error intentionally without client_id
await t.throwsAsync(resolve.browser(exampleUserURL))
})
7 changes: 6 additions & 1 deletion tests/util.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import test from "ava"

import { at } from "../src/util"
import { at, getClientIDv2 } from "../src/util"

const testArray = ["one", "two", "three"]

Expand All @@ -13,3 +13,8 @@ test("util function at works as expected", t => {
t.is(at(testArray, -2), "two")
t.is(at(testArray, -3), "one")
})

test("get client_id for APIv2", async t => {
const id = await getClientIDv2()
t.regex(id, /\w{32}/)
})
3 changes: 3 additions & 0 deletions types/index.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export type URLorID = string | number
export type ClientIDv1 = string
export type ClientIDv2 = string
Loading

0 comments on commit 4383234

Please sign in to comment.