diff --git a/README.md b/README.md index d7ae689..9436493 100644 --- a/README.md +++ b/README.md @@ -33,9 +33,14 @@ npm i @slademan/soundcloud ``` ```ts -import { greet } from "soundcloud"; +import Soundcloud from "@slademan/soundcloud"; -greet("Hello, world!"); +const sc = new Soundcloud(ClientID, OauthTokem); + +const me = await sc.me.get(); +if (me.success && me.data) { + console.log(me.data); +} ``` ## Development diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 180b509..3074717 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1177,11 +1177,11 @@ packages: /@types/chai-subset@1.3.3: resolution: {integrity: sha512-frBecisrNGz+F4T6bcc+NLeolfiojh5FxW2klu669+8BARtyQv2C/GkNW6FUodVe4BroGMP/wER/YDGc7rEllw==} dependencies: - '@types/chai': 4.3.5 + '@types/chai': 4.3.9 dev: true - /@types/chai@4.3.5: - resolution: {integrity: sha512-mEo1sAde+UCE6b2hxn332f1g1E8WfYRu6p5SvTKr2ZKC1f7gFJXk4h5PyGP9Dt6gCaG8y8XhwnXWC6Iy2cmBng==} + /@types/chai@4.3.9: + resolution: {integrity: sha512-69TtiDzu0bcmKQv3yg1Zx409/Kd7r0b5F1PfpYJfSHzLGtB53547V4u+9iqKYsTu/O2ai6KTb0TInNpvuQ3qmg==} dev: true /@types/eslint@8.37.0: @@ -6365,7 +6365,7 @@ packages: webdriverio: optional: true dependencies: - '@types/chai': 4.3.5 + '@types/chai': 4.3.9 '@types/chai-subset': 1.3.3 '@types/node': 18.11.18 '@vitest/expect': 0.33.0 diff --git a/src/api.ts b/src/api.ts index 9ec1d4d..ec6cac5 100644 --- a/src/api.ts +++ b/src/api.ts @@ -1,5 +1,12 @@ -/* eslint-disable eslint-comments/disable-enable-pair */ /* eslint-disable @typescript-eslint/no-explicit-any */ + +interface RequestOpts { + params?: Record; + body?: Record; +} + +type HttpVerb = "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; + export class API { public static headers: Record = { Origin: "https://soundcloud.com", @@ -16,13 +23,14 @@ export class API { API.headers.Authorization = `OAuth ${oauthToken}`; } - public async get( + private async makeRequest( endpoint: string, - params?: Record | undefined, - ): Promise { + method: HttpVerb, + opts: RequestOpts = {}, + ) { const url = new URL(`${this.apiURL}${endpoint}`); - if (params) { - url.search = `${new URLSearchParams(params).toString()}&client_id=${ + if (opts.params) { + url.search = `${new URLSearchParams(opts.params).toString()}&client_id=${ this.clientID }`; } else { @@ -30,16 +38,40 @@ export class API { } const response = await fetch(url.toString(), { + method, + headers: API.headers, + body: JSON.stringify(opts.body), + }); + + return response; + } + + public async fetch( + endpoint: string, + method: HttpVerb, + opts?: RequestOpts, + ) { + const res = await this.makeRequest(endpoint, method, opts); + return { + success: res.ok, + data: res.ok ? ((await res.json()) as T) : null, + }; + } + + public async getURL(url: string) { + const urlObj = new URL(url); + if (!urlObj.searchParams.has("client_id")) { + urlObj.searchParams.append("client_id", this.clientID); + } + const response = await fetch(urlObj.toString(), { + method: "GET", headers: API.headers, }); if (!response.ok) { throw new Error( - `Failed to fetch ${url.toString()}: "${ - response.statusText - }" with status code ${response.status}`, + `Failed to fetch ${urlObj.toString()}: ${response.status}`, ); } - return response; } diff --git a/src/entities/Discover.ts b/src/entities/Discover.ts new file mode 100644 index 0000000..3c4883e --- /dev/null +++ b/src/entities/Discover.ts @@ -0,0 +1,28 @@ +import type { + SoundcloudFilter, + SoundcloudSelectionSearch, + SoundcloudTrackSearch, +} from "../types"; +import { Base } from "./Base"; + +export class Discover extends Base { + public recentTracks = async (genre: string, params?: SoundcloudFilter) => { + return this.api.fetch( + `/recent_tracks/${genre}`, + "GET", + { + params, + }, + ); + }; + + public mixedCollections = async (params?: SoundcloudFilter) => { + return this.api.fetch( + `/mixed_collections`, + "GET", + { + params, + }, + ); + }; +} diff --git a/src/entities/Me.ts b/src/entities/Me.ts index fd7bc1e..fbe2315 100644 --- a/src/entities/Me.ts +++ b/src/entities/Me.ts @@ -1,10 +1,72 @@ +import type { + SoundcloudFilter, + SoundcloudPlaylist, + SoundcloudTrack, +} from "../types"; +import { + type SoundcloudLikesIDsSearch, + type SoundcloudWhoToFollowFilter, + type SoundcloudWhoToFollowSearch, +} from "../types/MeTypes"; import type { SoundcloudUser } from "../types/UserTypes"; import { Base } from "./Base.js"; export class Me extends Base { public get = async () => { - const response = await this.sc.api.get("/me"); - return (await response.json()) as SoundcloudUser; + return this.api.fetch("/me", "GET"); + }; + + trackReposts = { + put: async (id: number) => { + return this.sc.api.fetch(`/me/track_reposts/${id}`, "PUT"); + }, + delete: async (id: number) => { + return this.sc.api.fetch(`/me/track_reposts/${id}`, "DELETE"); + }, + caption: async (id: number, caption: string) => { + return await this.sc.api.fetch( + `/me/track_reposts/${id}/caption`, + "PUT", + { + body: { payload: { caption }, type: "raw" }, + }, + ); + }, + }; + trackLikes = { + get: async (params?: SoundcloudFilter) => { + return this.sc.api.fetch( + "/me/track_likes/ids", + "GET", + { + params, + }, + ); + }, + put: async (id: number) => { + return this.sc.api.fetch(`/me/track_likes/${id}`, "PUT"); + }, + delete: async (id: number) => { + return this.sc.api.fetch(`/me/track_likes/${id}`, "DELETE"); + }, + }; + library = { + all: async (params?: SoundcloudFilter) => { + return this.api.fetch("/me/library", "GET", { + params, + }); + }, + }; + suggested = { + who_to_follow: async (params?: SoundcloudWhoToFollowFilter) => { + return this.api.fetch( + "/me/suggested/who_to_follow", + "GET", + { + params, + }, + ); + }, }; } diff --git a/src/entities/Search.ts b/src/entities/Search.ts new file mode 100644 index 0000000..79f4b6e --- /dev/null +++ b/src/entities/Search.ts @@ -0,0 +1,64 @@ +import type { + SoundcloudAlbumFilter, + SoundcloudAlbumSearch, + SoundcloudAllSearch, + SoundcloudPlaylistFilter, + SoundcloudPlaylistSearch, + SoundcloudQuerySearch, + SoundcloudSearchFilter, + SoundcloudTrackFilter, + SoundcloudTrackSearch, + SoundcloudUserFilter, + SoundcloudUserSearch, +} from "../types"; +import { Base } from "./Base"; + +export class Search extends Base { + public tracks = async (params?: SoundcloudTrackFilter) => { + return await this.api.fetch( + "/search/tracks", + "GET", + { params }, + ); + }; + + public users = async (params?: SoundcloudUserFilter) => { + return await this.api.fetch("/search/users", "GET", { + params, + }); + }; + + public queries = async (params?: SoundcloudSearchFilter) => { + return await this.api.fetch( + "/search/queries", + "GET", + { params }, + ); + }; + + public playlists = async (params?: SoundcloudPlaylistFilter) => { + return await this.api.fetch( + "/search/playlists", + "GET", + { params }, + ); + }; + + public albums = async (params?: SoundcloudAlbumFilter) => { + return await this.api.fetch( + "/search/albums", + "GET", + { params }, + ); + }; + + public all = async (params?: SoundcloudSearchFilter) => { + return await this.api.fetch("/search", "GET", { + params, + }); + }; + + public async searchNext(next_href: string) { + return await this.api.getURL(next_href); + } +} diff --git a/src/entities/Tracks.ts b/src/entities/Tracks.ts index 8e83c6a..525399c 100644 --- a/src/entities/Tracks.ts +++ b/src/entities/Tracks.ts @@ -1,24 +1,38 @@ import type { + SoundcloudCommentSearch, SoundcloudTrack, - SoundcloudTrackFilter, SoundcloudTrackSearch, + SoundcloudCommentFilter, + SoundcloudFilter, } from "../types"; import { Base } from "./Base.js"; export class Tracks extends Base { public get = async (id: number) => { - const response = await this.api.get(`/tracks/${id}`); - return (await response.json()) as SoundcloudTrack; + return await this.api.fetch(`/tracks/${id}`, "GET"); }; - public related = async (id: number, limit?: number) => { - const response = await this.api.get(`/tracks/${id}/related`, { limit }); - return (await response.json()) as SoundcloudTrack[]; + public related = async (id: number, params?: SoundcloudFilter) => { + return await this.api.fetch( + `/tracks/${id}/related`, + "GET", + { params }, + ); }; - public search = async (params?: SoundcloudTrackFilter) => { - const response = await this.api.get("/search/tracks", params); - return (await response.json()) as SoundcloudTrackSearch; + public getMultiple = async (ids: number[]) => { + const params = { ids: ids.join(",") }; + return await this.api.fetch(`/tracks`, "GET", { + params, + }); + }; + + public comments = async (id: number, params?: SoundcloudCommentFilter) => { + return await this.api.fetch( + `/tracks/${id}/comments`, + "GET", + { params }, + ); }; } diff --git a/src/entities/Util.ts b/src/entities/Util.ts index f7ddac0..82ff26d 100644 --- a/src/entities/Util.ts +++ b/src/entities/Util.ts @@ -17,10 +17,14 @@ export class Util extends Base { ? `&client_id=${client_id}` : `?client_id=${client_id}`; try { - return await fetch(url + connect, { headers }) - .then((r) => r.json()) - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - .then((r) => r.url as string); + const res = await fetch(url + connect, { + method: "GET", + headers: headers, + }); + + return (await res.json()) as { + url: string; + } | null; } catch { return null; } @@ -51,4 +55,9 @@ export class Util extends Base { return await this.getStreamLink(transcodings[0]); }; + + public hqArtwork = (track: SoundcloudTrack) => { + const hqArtwork = track.artwork_url.replace("large", "t500x500"); + return hqArtwork; + }; } diff --git a/src/types/APITypes.ts b/src/types/APITypes.ts index 158b43d..6d0580a 100644 --- a/src/types/APITypes.ts +++ b/src/types/APITypes.ts @@ -1,11 +1,24 @@ export interface SoundcloudFilter { limit?: number; offset?: number; +} + +export interface SoundcloudSearchFilter extends SoundcloudFilter { q: string; } export interface SoundcloudSearch { - next_href: string; + next_href: string | null; query_urn: string; - total_results: number; + total_results?: number; } + +export type SoundcloudLicense = + | "all-rights-reserved" + | "cc-by" + | "cc-by-nc" + | "cc-by-nc-nd" + | "cc-by-nc-sa" + | "cc-by-nd" + | "cc-by-sa" + | "no-rights-reserved"; diff --git a/src/types/AlbumTypes.ts b/src/types/AlbumTypes.ts new file mode 100644 index 0000000..c235a11 --- /dev/null +++ b/src/types/AlbumTypes.ts @@ -0,0 +1,39 @@ +import type { SoundcloudLicense } from "./APITypes"; +import type { SoundcloudTrack } from "./TrackTypes"; +import type { SoundcloudUser } from "./UserTypes"; + +export interface SoundcloudAlbum { + artwork_url: string; + created_at: string; + description: string; + duration: number; + embeddable_by: "all" | "me" | "none"; + genre: string; + id: number; + kind: string; + label_name: string; + last_modified: string; + license: SoundcloudLicense; + likes_count: number; + managed_by_feeds: boolean; + permalink: string; + permalink_url: string; + public: boolean; + purchase_title: string | null; + purchase_url: string | null; + release_date: string; + reposts_count: number; + secret_token: string | null; + sharing: "public" | "private"; + tag_list: string; + title: string; + uri: string; + user_id: number; + set_type: string; + is_album: boolean; + published_at: string; + display_date: string; + user: SoundcloudUser; + tracks: SoundcloudTrack[]; + track_count: number; +} diff --git a/src/types/DiscoverTypes.ts b/src/types/DiscoverTypes.ts new file mode 100644 index 0000000..0ddb3f4 --- /dev/null +++ b/src/types/DiscoverTypes.ts @@ -0,0 +1,21 @@ +import type { SoundcloudSearch } from "./APITypes"; +import type { SoundcloudTrackSearch } from "./SearchTypes"; + +export interface SoundcloudSelection { + urn: string; + query_urn: string; + title: string; + description: string; + tracking_feature_name: string; + last_updated: string; + style: string | null; + social_proof: unknown | null; + social_proof_users: unknown | null; + items: SoundcloudTrackSearch; + kind: string; + id: string; +} + +export interface SoundcloudSelectionSearch extends SoundcloudSearch { + collection: SoundcloudSelection[]; +} diff --git a/src/types/MeTypes.ts b/src/types/MeTypes.ts new file mode 100644 index 0000000..7936746 --- /dev/null +++ b/src/types/MeTypes.ts @@ -0,0 +1,14 @@ +import type { SoundcloudFilter, SoundcloudSearch } from "./APITypes"; +import type { SoundcloudUser } from "./UserTypes"; + +export interface SoundcloudWhoToFollowFilter extends SoundcloudFilter { + view?: "recommended-first"; +} + +export interface SoundcloudLikesIDsSearch extends SoundcloudSearch { + collection: number[]; +} + +export interface SoundcloudWhoToFollowSearch extends SoundcloudSearch { + collection: SoundcloudUser[]; +} diff --git a/src/types/PlaylistTypes.ts b/src/types/PlaylistTypes.ts new file mode 100644 index 0000000..0825514 --- /dev/null +++ b/src/types/PlaylistTypes.ts @@ -0,0 +1,39 @@ +import type { SoundcloudTrack } from "./TrackTypes"; +import type { SoundcloudLicense } from "./APITypes"; +import type { SoundcloudUser } from "./UserTypes"; + +export interface SoundcloudPlaylist { + duration: number; + permalink_url: string; + reposts_count: number; + genre: string | null; + permalink: string; + purchase_url: string | null; + description: string | null; + uri: string; + label_name: string | null; + tag_list: string; + set_type: string; + public: boolean; + track_count: number; + user_id: number; + last_modified: string; + license: SoundcloudLicense; + tracks: SoundcloudTrack[]; + id: number; + release_date: string | null; + display_date: string; + sharing: "public" | "private"; + secret_token: string | null; + created_at: string; + likes_count: number; + kind: string; + title: string; + purchase_title: string | null; + managed_by_feeds: boolean; + artwork_url: string | null; + is_album: boolean; + user: SoundcloudUser; + published_at: string | null; + embeddable_by: "all" | "me" | "none"; +} diff --git a/src/types/SearchTypes.ts b/src/types/SearchTypes.ts new file mode 100644 index 0000000..81687dd --- /dev/null +++ b/src/types/SearchTypes.ts @@ -0,0 +1,67 @@ +import type { SoundcloudTrack } from "./TrackTypes"; +import type { SoundcloudUser } from "./UserTypes"; +import type { SoundcloudSearchFilter, SoundcloudSearch } from "./APITypes"; +import type { SoundcloudPlaylist } from "./PlaylistTypes"; +import type { SoundcloudAlbum } from "./AlbumTypes"; + +export interface SoundcloudTrackSearch extends SoundcloudSearch { + collection: SoundcloudTrack[]; +} + +export interface SoundcloudTrackFilter extends SoundcloudSearchFilter { + "filter.created_at"?: + | "last_day" + | "last_hour" + | "last_month" + | "last_week" + | "last_year"; + "filter.duration"?: "epic" | "long" | "medium" | "short"; + "filter.genre_or_tag"?: string; + "filter.license"?: + | "to_modify_commercially" + | "to_share" + | "to_use_commercially"; + "filer.content_tier"?: "SUB_HIGH_TIER"; // TODO: find out other content tiers, SUB_HIGH_TIER is go+ +} + +export interface SoundcloudUserSearch extends SoundcloudSearch { + collection: SoundcloudUser[]; +} + +export interface SoundcloudUserFilter extends SoundcloudSearchFilter { + "filter.place"?: string; +} + +export interface SoundcloudQuery { + output: string; + query: string; +} + +export interface SoundcloudQuerySearch extends SoundcloudSearch { + collection: SoundcloudQuery[]; +} + +export interface SoundcloudPlaylistSearch extends SoundcloudSearch { + collection: SoundcloudPlaylist[]; +} + +export interface SoundcloudPlaylistFilter extends SoundcloudSearchFilter { + "filter.genre_or_tag"?: string; +} + +export interface SoundcloudAlbumSearch extends SoundcloudSearch { + collection: SoundcloudAlbum[]; +} + +export interface SoundcloudAlbumFilter extends SoundcloudSearchFilter { + "filter.genre_or_tag"?: string; +} + +export interface SoundcloudAllSearch extends SoundcloudSearch { + collection: ( + | SoundcloudTrack + | SoundcloudUser + | SoundcloudPlaylist + | SoundcloudAlbum + )[]; +} diff --git a/src/types/TrackTypes.ts b/src/types/TrackTypes.ts index 98b13b2..88ce6a2 100644 --- a/src/types/TrackTypes.ts +++ b/src/types/TrackTypes.ts @@ -1,15 +1,6 @@ -import { SoundcloudFilter, SoundcloudSearch } from "./APITypes.js"; -import { SoundcloudUser } from "./UserTypes.js"; - -export type SoundcloudLicense = - | "all-rights-reserved" - | "cc-by" - | "cc-by-nc" - | "cc-by-nc-nd" - | "cc-by-nc-sa" - | "cc-by-nd" - | "cc-by-sa" - | "no-rights-reserved"; +import type { SoundcloudFilter, SoundcloudSearch } from "./APITypes"; +import type { SoundcloudUser } from "./UserTypes"; +import type { SoundcloudLicense } from "./APITypes"; export interface SoundcloudTrack { artwork_url: string; @@ -63,10 +54,6 @@ export interface SoundcloudTrack { waveform_url: string; } -export interface SoundcloudTrackSearch extends SoundcloudSearch { - collection: SoundcloudTrack[]; -} - export interface SoundcloudTranscoding { duration: number; format: { @@ -79,17 +66,22 @@ export interface SoundcloudTranscoding { url: string; } -export interface SoundcloudTrackFilter extends SoundcloudFilter { - "filter.created_at"?: - | "last_day" - | "last_hour" - | "last_month" - | "last_week" - | "last_year"; - "filter.duration"?: "epic" | "long" | "medium" | "short"; - "filter.genre_or_tag"?: string; - "filter.license"?: - | "to_modify_commercially" - | "to_share" - | "to_use_commercially"; +export interface SoundloudComment { + kind: string; + id: number; + body: string; + created_at: string; + timestamp: number; + track_id: number; + user_id: number; + self: { urn: string }; + user: SoundcloudUser; +} + +export interface SoundcloudCommentFilter extends SoundcloudFilter { + threaded: 0 | 1; +} + +export interface SoundcloudCommentSearch extends SoundcloudSearch { + collection: SoundloudComment[]; } diff --git a/src/types/index.ts b/src/types/index.ts index 58e9591..3faf4eb 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,3 +1,7 @@ -export * from "./APITypes.js"; -export * from "./UserTypes.js"; -export * from "./TrackTypes.js"; +export * from "./APITypes"; +export * from "./UserTypes"; +export * from "./TrackTypes"; +export * from "./SearchTypes"; +export * from "./PlaylistTypes"; +export * from "./AlbumTypes"; +export * from "./DiscoverTypes";