From 12bbba74a30507fac34c2c107587d32c7ae8793c Mon Sep 17 00:00:00 2001 From: Lioncat6 Date: Sat, 27 Sep 2025 01:21:23 -0500 Subject: [PATCH 01/20] start soundcloud --- providers/SoundCloud/json_types.ts | 0 providers/SoundCloud/mod.ts | 46 ++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) create mode 100644 providers/SoundCloud/json_types.ts create mode 100644 providers/SoundCloud/mod.ts diff --git a/providers/SoundCloud/json_types.ts b/providers/SoundCloud/json_types.ts new file mode 100644 index 00000000..e69de29b diff --git a/providers/SoundCloud/mod.ts b/providers/SoundCloud/mod.ts new file mode 100644 index 00000000..d592fbc7 --- /dev/null +++ b/providers/SoundCloud/mod.ts @@ -0,0 +1,46 @@ +import type { + ArtistCreditName, + Artwork, + ArtworkType, + EntityId, + HarmonyEntityType, + HarmonyRelease, + HarmonyTrack, + Label, + LinkType, +} from '@/harmonizer/types.ts'; +import { type CacheEntry, MetadataProvider, ReleaseLookup } from '@/providers/base.ts'; +import { DurationPrecision, FeatureQuality, FeatureQualityMap } from '@/providers/features.ts'; +import { parseISODateTime, PartialDate } from '@/utils/date.ts'; +import { ProviderError, ResponseError } from '@/utils/errors.ts'; +import { extractDataAttribute, extractMetadataTag, extractTextFromHtml } from '@/utils/html.ts'; +import { plural, pluralWithCount } from '@/utils/plural.ts'; +import { isNotNull } from '@/utils/predicate.ts'; +import { similarNames } from '@/utils/similarity.ts'; +import { toTrackRanges } from '@/utils/tracklist.ts'; +import { simplifyName } from 'utils/string/simplify.js'; + +export default class SoundCloudProvider extends MetadataProvider { + readonly name = "SoundCloud"; + + readonly supportedUrls = new URLPattern({ + hostname: 'soundcloud.com', + pathname: '/:artist/set/:title', + }); + + readonly trackUrlPattern = new URLPattern({ + hostname: 'soundcloud.com', + pathname: '/:artist/set/:title', + }); + + readonly artistUrlPattern = new URLPattern({ + hostname: 'soundcloud.com', + pathname: '/:artist/set/:title', + }); + + + override extractEntityFromUrl(url: URL): EntityId | undefined { + + } + +} \ No newline at end of file From 353a0bb6ece84f116481e956697c0b047b19c65b Mon Sep 17 00:00:00 2001 From: Lioncat6 <95449321+Lioncat6@users.noreply.github.com> Date: Fri, 3 Oct 2025 03:00:45 -0500 Subject: [PATCH 02/20] progress --- providers/SoundCloud/mod.ts | 104 ++++++++++++++++++++++++++---------- 1 file changed, 76 insertions(+), 28 deletions(-) diff --git a/providers/SoundCloud/mod.ts b/providers/SoundCloud/mod.ts index d592fbc7..fc286d3e 100644 --- a/providers/SoundCloud/mod.ts +++ b/providers/SoundCloud/mod.ts @@ -8,39 +8,87 @@ import type { HarmonyTrack, Label, LinkType, -} from '@/harmonizer/types.ts'; -import { type CacheEntry, MetadataProvider, ReleaseLookup } from '@/providers/base.ts'; -import { DurationPrecision, FeatureQuality, FeatureQualityMap } from '@/providers/features.ts'; -import { parseISODateTime, PartialDate } from '@/utils/date.ts'; -import { ProviderError, ResponseError } from '@/utils/errors.ts'; -import { extractDataAttribute, extractMetadataTag, extractTextFromHtml } from '@/utils/html.ts'; -import { plural, pluralWithCount } from '@/utils/plural.ts'; -import { isNotNull } from '@/utils/predicate.ts'; -import { similarNames } from '@/utils/similarity.ts'; -import { toTrackRanges } from '@/utils/tracklist.ts'; -import { simplifyName } from 'utils/string/simplify.js'; +} from "@/harmonizer/types.ts"; +import { + type CacheEntry, + MetadataProvider, + ReleaseLookup, +} from "@/providers/base.ts"; +import { + DurationPrecision, + FeatureQuality, + FeatureQualityMap, +} from "@/providers/features.ts"; +import { parseISODateTime, PartialDate } from "@/utils/date.ts"; +import { ProviderError, ResponseError } from "@/utils/errors.ts"; +import { + extractDataAttribute, + extractMetadataTag, + extractTextFromHtml, +} from "@/utils/html.ts"; +import { plural, pluralWithCount } from "@/utils/plural.ts"; +import { isNotNull } from "@/utils/predicate.ts"; +import { similarNames } from "@/utils/similarity.ts"; +import { toTrackRanges } from "@/utils/tracklist.ts"; +import { simplifyName } from "utils/string/simplify.js"; export default class SoundCloudProvider extends MetadataProvider { - readonly name = "SoundCloud"; + readonly name = "SoundCloud"; - readonly supportedUrls = new URLPattern({ - hostname: 'soundcloud.com', - pathname: '/:artist/set/:title', - }); + readonly supportedUrls = new URLPattern({ + hostname: "soundcloud.com", + pathname: "/:artist/set/:title", + }); - readonly trackUrlPattern = new URLPattern({ - hostname: 'soundcloud.com', - pathname: '/:artist/set/:title', - }); + readonly trackUrlPattern = new URLPattern({ + hostname: "soundcloud.com", + pathname: "/:artist/:title", + }); - readonly artistUrlPattern = new URLPattern({ - hostname: 'soundcloud.com', - pathname: '/:artist/set/:title', - }); + readonly artistUrlPattern = new URLPattern({ + hostname: "soundcloud.com", + pathname: "/:artist", + }); + override readonly launchDate: PartialDate = { + year: 2008, + month: 10, + day: 17, + }; - override extractEntityFromUrl(url: URL): EntityId | undefined { - - } + override readonly features: FeatureQualityMap = { + 'cover size': 500, + 'duration precision': DurationPrecision.MS, + 'GTIN lookup': FeatureQuality.MISSING, + 'MBID resolving': FeatureQuality.PRESENT, + }; -} \ No newline at end of file + readonly entityTypeMap = { + artist: 'user', + release: ['playlist', 'album'], + }; + + + + override extractEntityFromUrl(url: URL): EntityId | undefined { + const albumResult = this.supportedUrls.exec(url); + if (albumResult) { + const artist = albumResult.hostname.groups.artist!; + const { type, title } = albumResult.pathname.groups; + if (type && title) { + return { + type, + id: [artist, title].join('/'), + }; + } + } + + const artistResult = this.artistUrlPattern.exec(url); + if (artistResult) { + return { + type: 'artist', + id: artistResult.hostname.groups.artist!, + }; + } + } +} From 01641e1493fc8eed14101b4418a90250276383ec Mon Sep 17 00:00:00 2001 From: Lioncat6 Date: Fri, 10 Oct 2025 14:24:52 -0500 Subject: [PATCH 03/20] sc changes --- .vscode/settings.json | 1 + providers/SoundCloud/mod.ts | 35 +++++++++++++++++++++++++++++------ 2 files changed, 30 insertions(+), 6 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index f280afa4..daef8f52 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -40,6 +40,7 @@ "runtimes", "secondhandsongs", "smartradio", + "soundcloud", "spotify", "streamable", "tabler", diff --git a/providers/SoundCloud/mod.ts b/providers/SoundCloud/mod.ts index fc286d3e..e13bfd52 100644 --- a/providers/SoundCloud/mod.ts +++ b/providers/SoundCloud/mod.ts @@ -10,6 +10,8 @@ import type { LinkType, } from "@/harmonizer/types.ts"; import { + type ApiAccessToken, + type ApiQueryOptions, type CacheEntry, MetadataProvider, ReleaseLookup, @@ -26,12 +28,17 @@ import { extractMetadataTag, extractTextFromHtml, } from "@/utils/html.ts"; +import { getFromEnv } from '@/utils/config.ts'; import { plural, pluralWithCount } from "@/utils/plural.ts"; import { isNotNull } from "@/utils/predicate.ts"; import { similarNames } from "@/utils/similarity.ts"; import { toTrackRanges } from "@/utils/tracklist.ts"; import { simplifyName } from "utils/string/simplify.js"; +const soundcloudClientId = getFromEnv('HARMONY_SOUNDCLOUD_CLIENT_ID') || ''; +const soundcloudClientSecret = getFromEnv('HARMONY_SOUNDCLOUD_CLIENT_SECRET') || ''; + + export default class SoundCloudProvider extends MetadataProvider { readonly name = "SoundCloud"; @@ -68,7 +75,29 @@ export default class SoundCloudProvider extends MetadataProvider { release: ['playlist', 'album'], }; + //Soundcloud's client credentials authentication works surprisingly simularly to Spotify's https://developers.soundcloud.com/docs#authentication + private async requestAccessToken(): Promise { + // See https://developer.spotify.com/documentation/web-api/tutorials/client-credentials-flow + const url = new URL('https://secure.soundcloud.com/oauth/token'); + const auth = encodeBase64(`${soundcloudClientId}:${soundcloudClientSecret}`); + const body = new URLSearchParams(); + body.append('grant_type', 'client_credentials'); + const response = await fetch(url, { + method: 'POST', + headers: { + 'Authorization': `Basic ${auth}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: body, + }); + + const content = await response.json(); + return { + accessToken: content?.access_token, + validUntilTimestamp: Date.now() + (content.expires_in * 1000), + }; + } override extractEntityFromUrl(url: URL): EntityId | undefined { const albumResult = this.supportedUrls.exec(url); @@ -84,11 +113,5 @@ export default class SoundCloudProvider extends MetadataProvider { } const artistResult = this.artistUrlPattern.exec(url); - if (artistResult) { - return { - type: 'artist', - id: artistResult.hostname.groups.artist!, - }; - } } } From 187d4d112e0ad08a132dcfad1871e462d9a628d8 Mon Sep 17 00:00:00 2001 From: Lioncat6 <95449321+Lioncat6@users.noreply.github.com> Date: Fri, 10 Oct 2025 14:52:52 -0500 Subject: [PATCH 04/20] Sc progres --- providers/SoundCloud/api_types.ts | 8 ++++ providers/SoundCloud/json_types.ts | 0 providers/SoundCloud/mod.ts | 76 +++++++++++++++++++++++++----- 3 files changed, 72 insertions(+), 12 deletions(-) create mode 100644 providers/SoundCloud/api_types.ts delete mode 100644 providers/SoundCloud/json_types.ts diff --git a/providers/SoundCloud/api_types.ts b/providers/SoundCloud/api_types.ts new file mode 100644 index 00000000..0644415e --- /dev/null +++ b/providers/SoundCloud/api_types.ts @@ -0,0 +1,8 @@ +export type ApiError = { + code: number; + message: string; + link: string; + status: string; + errors: string[]; + error: string | null; +}; diff --git a/providers/SoundCloud/json_types.ts b/providers/SoundCloud/json_types.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/providers/SoundCloud/mod.ts b/providers/SoundCloud/mod.ts index e13bfd52..fd322736 100644 --- a/providers/SoundCloud/mod.ts +++ b/providers/SoundCloud/mod.ts @@ -13,6 +13,7 @@ import { type ApiAccessToken, type ApiQueryOptions, type CacheEntry, + MetadataApiProvider, MetadataProvider, ReleaseLookup, } from "@/providers/base.ts"; @@ -34,12 +35,19 @@ import { isNotNull } from "@/utils/predicate.ts"; import { similarNames } from "@/utils/similarity.ts"; import { toTrackRanges } from "@/utils/tracklist.ts"; import { simplifyName } from "utils/string/simplify.js"; +import type { + ApiError +} from './api_types.ts'; +import { encodeBase64 } from 'std/encoding/base64.ts'; +import { ResponseError as SnapResponseError } from 'snap-storage'; + + const soundcloudClientId = getFromEnv('HARMONY_SOUNDCLOUD_CLIENT_ID') || ''; const soundcloudClientSecret = getFromEnv('HARMONY_SOUNDCLOUD_CLIENT_SECRET') || ''; -export default class SoundCloudProvider extends MetadataProvider { +export default class SoundCloudProvider extends MetadataApiProvider { readonly name = "SoundCloud"; readonly supportedUrls = new URLPattern({ @@ -75,6 +83,48 @@ export default class SoundCloudProvider extends MetadataProvider { release: ['playlist', 'album'], }; + async query(apiUrl: URL, options: ApiQueryOptions): Promise> { + try { + await this.requestDelay; + const accessToken = await this.cachedAccessToken(this.requestAccessToken); + const cacheEntry = await this.fetchJSON(apiUrl, { + policy: { maxTimestamp: options.snapshotMaxTimestamp }, + requestInit: { + headers: { + 'Authorization': `Bearer ${accessToken}`, + }, + }, + }); + const apiError = cacheEntry.content as ApiError; + if (apiError.error) { + throw new SoundCloudReponseError(apiError, apiUrl); + } + return cacheEntry; + } catch (error) { + let apiError: ApiError | undefined; + if (error instanceof SnapResponseError) { + const { response } = error; + this.handleRateLimit(response); + // Retry API query when we encounter a 429 rate limit error. + if (response.status === 429) { + return this.query(apiUrl, options); + } + try { + // Clone the response so the body of the original response can be + // consumed later if the error gets re-thrown. + apiError = await response.clone().json(); + } catch { + // Ignore secondary JSON parsing error, rethrow original error. + } + } + if (apiError?.error) { + throw new SoundCloudReponseError(apiError, apiUrl); + } else { + throw error; + } + } + } + //Soundcloud's client credentials authentication works surprisingly simularly to Spotify's https://developers.soundcloud.com/docs#authentication private async requestAccessToken(): Promise { // See https://developer.spotify.com/documentation/web-api/tutorials/client-credentials-flow @@ -100,18 +150,20 @@ export default class SoundCloudProvider extends MetadataProvider { } override extractEntityFromUrl(url: URL): EntityId | undefined { - const albumResult = this.supportedUrls.exec(url); - if (albumResult) { - const artist = albumResult.hostname.groups.artist!; - const { type, title } = albumResult.pathname.groups; - if (type && title) { - return { - type, - id: [artist, title].join('/'), - }; - } + const playlistResult = this.supportedUrls.exec(url); + if (playlistResult) { + } + const trackResult = this.trackUrlPattern.exec(url); + if (trackResult) { } - const artistResult = this.artistUrlPattern.exec(url); + if (artistResult) { + } + } +} + +class SoundCloudReponseError extends ResponseError { + constructor(readonly details: ApiError, url: URL) { + super('SoundCloud', details?.status, url); //While there exists a message feild in the error response, it's usually empty, despite status being depricated. } } From 99d450d94ad4c8e4b23dfb6b6d3d1b1598c66d7e Mon Sep 17 00:00:00 2001 From: Lioncat6 Date: Fri, 10 Oct 2025 22:29:30 -0500 Subject: [PATCH 05/20] God I hate deno --- .vscode/settings.json | 2 + providers/SoundCloud/api_types.ts | 422 ++++++++++++++++++++++++++++++ providers/SoundCloud/mod.ts | 39 ++- 3 files changed, 452 insertions(+), 11 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index daef8f52..91686c39 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -6,6 +6,7 @@ "Beatport", "brainz", "Brainz", + "commentable", "deezer", "Deezer", "Deno", @@ -37,6 +38,7 @@ "nums", "preact", "preorder", + "reposts", "runtimes", "secondhandsongs", "smartradio", diff --git a/providers/SoundCloud/api_types.ts b/providers/SoundCloud/api_types.ts index 0644415e..735fbdbd 100644 --- a/providers/SoundCloud/api_types.ts +++ b/providers/SoundCloud/api_types.ts @@ -1,3 +1,425 @@ +export type SoundcloudImageFormats = "t500x500" | "crop" | "t300x300" | "large" | "t67x67" | "badge" | "small" | "tiny" | "mini" + +export type SoundcloudLicense = + | "no-rights-reserved" + | "all-rights-reserved" + | "cc-by" + | "cc-by-nc" + | "cc-by-nd" + | "cc-by-sa" + | "cc-by-nc-nd" + | "cc-by-nc-sa" + +export type SoundcloudTrackType = + | "original" + | "remix" + | "live" + | "recording" + | "spoken" + | "podcast" + | "demo" + | "in progress" + | "stem" + | "loop" + | "sound effect" + | "sample" + | "other" + +export interface SoundcloudTrack { + comment_count: number + full_duration: number + downloadable: boolean + created_at: string + description: string | null + media: { + transcodings: SoundcloudTranscoding[] + } + title: string + publisher_metadata: { + id: number + urn: string + artist: string + album_title: string + contains_music: boolean + upc_or_ean: string + isrc: string + explicit: boolean + p_line: string + p_line_for_display: string + c_line: string + c_line_for_display: string + writer_composer: string + release_title: string + publisher: string + } + duration: number + has_downloads_left: boolean + artwork_url: string + public: boolean + streamable: boolean + tag_list: string + genre: string + id: number + reposts_count: number + state: "processing" | "failed" | "finished" + label_name: string | null + last_modified: string + commentable: boolean + policy: string + visuals: string | null + kind: string + purchase_url: string | null + sharing: "private" | "public" + uri: string + secret_token: string | null + download_count: number + likes_count: number + urn: string + license: SoundcloudLicense + purchase_title: string | null + display_date: string + embeddable_by: "all" | "me" | "none" + release_date: string + user_id: number + monetization_model: string + waveform_url: string + permalink: string + permalink_url: string + user: SoundcloudUser + playback_count: number +} +export interface SoundcloudTrackSearch extends SoundcloudSearch { + collection: SoundcloudTrack[] +} + +export interface SoundcloudSecretToken { + kind: "secret-token" + token: string + uri: string + resource_uri: string +} + +export interface SoundcloudTranscoding { + url: string + preset: string + duration: number + snipped: boolean + format: { + protocol: string + mime_type: string + } + quality: string +} + +export interface SoundcloudTrackFilter extends SoundcloudFilter { + "filter.genre_or_tag"?: string + "filter.duration"?: "short" | "medium" | "long" | "epic" + "filter.created_at"?: "last_hour" | "last_day" | "last_week" | "last_month" | "last_year" + "filter.license"?: "to_modify_commercially" | "to_share" | "to_use_commercially" +} +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 + urn: 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" +} + +export interface SoundcloudPlaylistSearch extends SoundcloudSearch { + collection: SoundcloudPlaylist[] +} + +export interface SoundcloudPlaylistFilter extends SoundcloudFilter { + "filter.genre_or_tag"?: string +} +export interface SoundcloudApp { + id: number + kind: "app" + name: string + uri: string + permalink_url: string + external_url: string + creator: string +} + +export interface SoundcloudSearch { + total_results: number + next_href: string + query_urn: string +} + +export interface SoundcloudFilter { + q: string + limit?: number + offset?: number +} +export interface SoundcloudUserMini { + avatar_url: string + id: number + kind: string + permalink_url: string + uri: string + username: string + permalink: string + last_modified: string +} + +export interface SoundcloudUser { + avatar_url: string + city: string + comments_count: number + country_code: number | null + created_at: string + creator_subscriptions: SoundcloudCreatorSubscription[] + creator_subscription: SoundcloudCreatorSubscription + description: string + followers_count: number + followings_count: number + first_name: string + full_name: string + groups_count: number + id: number + kind: string + last_modified: string + last_name: string + likes_count: number + playlist_likes_count: number + permalink: string + permalink_url: string + playlist_count: number + reposts_count: number | null + track_count: number + uri: string + urn: string + username: string + verified: boolean + visuals: { + urn: string + enabled: boolean + visuals: SoundcloudVisual[] + tracking: null + } +} + +export interface SoundcloudUserSearch extends SoundcloudSearch { + collection: SoundcloudUser[] +} + +export interface SoundcloudWebProfile { + network: string + title: string + url: string + username: string | null +} + +export interface SoundcloudUserCollection { + collection: SoundcloudUser + next_href: string | null +} + +export interface SoundcloudVisual { + urn: string + entry_time: number + visual_url: string +} + +export interface SoundcloudCreatorSubscription { + product: { + id: string + } +} + +export interface SoundcloudUserFilter extends SoundcloudFilter { + "filter.place"?: string +} +export interface SoundcloudUserMini { + avatar_url: string + id: number + kind: string + permalink_url: string + uri: string + username: string + permalink: string + last_modified: string +} + +export interface SoundcloudUser { + avatar_url: string + city: string + comments_count: number + country_code: number | null + created_at: string + creator_subscriptions: SoundcloudCreatorSubscription[] + creator_subscription: SoundcloudCreatorSubscription + description: string + followers_count: number + followings_count: number + first_name: string + full_name: string + groups_count: number + id: number + kind: string + last_modified: string + last_name: string + likes_count: number + playlist_likes_count: number + permalink: string + permalink_url: string + playlist_count: number + reposts_count: number | null + track_count: number + uri: string + urn: string + username: string + verified: boolean + visuals: { + urn: string + enabled: boolean + visuals: SoundcloudVisual[] + tracking: null + } +} + +export interface SoundcloudUserSearch extends SoundcloudSearch { + collection: SoundcloudUser[] +} + +export interface SoundcloudWebProfile { + network: string + title: string + url: string + username: string | null +} + +export interface SoundcloudUserCollection { + collection: SoundcloudUser + next_href: string | null +} + +export interface SoundcloudVisual { + urn: string + entry_time: number + visual_url: string +} + +export interface SoundcloudCreatorSubscription { + product: { + id: string + } +} + +export interface SoundcloudUserFilter extends SoundcloudFilter { + "filter.place"?: string +} +export interface SoundcloudUserMini { + avatar_url: string + id: number + kind: string + permalink_url: string + uri: string + username: string + permalink: string + last_modified: string +} + +export interface SoundcloudUser { + avatar_url: string + city: string + comments_count: number + country_code: number | null + created_at: string + creator_subscriptions: SoundcloudCreatorSubscription[] + creator_subscription: SoundcloudCreatorSubscription + description: string + followers_count: number + followings_count: number + first_name: string + full_name: string + groups_count: number + id: number + kind: string + last_modified: string + last_name: string + likes_count: number + playlist_likes_count: number + permalink: string + permalink_url: string + playlist_count: number + reposts_count: number | null + track_count: number + uri: string + urn: string + username: string + verified: boolean + visuals: { + urn: string + enabled: boolean + visuals: SoundcloudVisual[] + tracking: null + } +} + +export interface SoundcloudUserSearch extends SoundcloudSearch { + collection: SoundcloudUser[] +} + +export interface SoundcloudWebProfile { + network: string + title: string + url: string + username: string | null +} + +export interface SoundcloudUserCollection { + collection: SoundcloudUser + next_href: string | null +} + +export interface SoundcloudVisual { + urn: string + entry_time: number + visual_url: string +} + +export interface SoundcloudCreatorSubscription { + product: { + id: string + } +} + +export interface SoundcloudUserFilter extends SoundcloudFilter { + "filter.place"?: string +} + export type ApiError = { code: number; message: string; diff --git a/providers/SoundCloud/mod.ts b/providers/SoundCloud/mod.ts index fd322736..96cf0fa2 100644 --- a/providers/SoundCloud/mod.ts +++ b/providers/SoundCloud/mod.ts @@ -36,7 +36,12 @@ import { similarNames } from "@/utils/similarity.ts"; import { toTrackRanges } from "@/utils/tracklist.ts"; import { simplifyName } from "utils/string/simplify.js"; import type { - ApiError + ApiError, + SoundcloudPlaylist, + SoundcloudUser, + SoundCloudTrack, + SoundcloudSearch, + SoundcloudFilter, } from './api_types.ts'; import { encodeBase64 } from 'std/encoding/base64.ts'; import { ResponseError as SnapResponseError } from 'snap-storage'; @@ -50,6 +55,8 @@ const soundcloudClientSecret = getFromEnv('HARMONY_SOUNDCLOUD_CLIENT_SECRET') || export default class SoundCloudProvider extends MetadataApiProvider { readonly name = "SoundCloud"; + readonly apiBaseUrl = new URL('https://api.soundcloud.com/'); + readonly supportedUrls = new URLPattern({ hostname: "soundcloud.com", pathname: "/:artist/set/:title", @@ -97,7 +104,7 @@ export default class SoundCloudProvider extends MetadataApiProvider { }); const apiError = cacheEntry.content as ApiError; if (apiError.error) { - throw new SoundCloudReponseError(apiError, apiUrl); + throw new SoundCloudResponseError(apiError, apiUrl); } return cacheEntry; } catch (error) { @@ -118,14 +125,14 @@ export default class SoundCloudProvider extends MetadataApiProvider { } } if (apiError?.error) { - throw new SoundCloudReponseError(apiError, apiUrl); + throw new SoundCloudResponseError(apiError, apiUrl); } else { throw error; } } } - //Soundcloud's client credentials authentication works surprisingly simularly to Spotify's https://developers.soundcloud.com/docs#authentication + //Soundcloud's client credentials authentication works surprisingly similarly to Spotify's https://developers.soundcloud.com/docs#authentication private async requestAccessToken(): Promise { // See https://developer.spotify.com/documentation/web-api/tutorials/client-credentials-flow const url = new URL('https://secure.soundcloud.com/oauth/token'); @@ -151,19 +158,29 @@ export default class SoundCloudProvider extends MetadataApiProvider { override extractEntityFromUrl(url: URL): EntityId | undefined { const playlistResult = this.supportedUrls.exec(url); - if (playlistResult) { - } const trackResult = this.trackUrlPattern.exec(url); - if (trackResult) { - } const artistResult = this.artistUrlPattern.exec(url); - if (artistResult) { + const query = new URLSearchParams(); + query.set('url', url.href); + const resolveUrl = new URL('resolve', this.apiBaseUrl); + resolveUrl.search = query.toString(); + if (playlistResult | trackResult | artistResult) { + const cacheEntry = this.query(resolveUrl, { resolveUrl: this.options.snapshotMaxTimestamp }); + this.updateCacheTime(cacheEntry.timestamp); + const entity = cacheEntry.content + if (entity) { + return entity.urn; + } } } } -class SoundCloudReponseError extends ResponseError { +class SoundCloudResponseError extends ResponseError { constructor(readonly details: ApiError, url: URL) { - super('SoundCloud', details?.status, url); //While there exists a message feild in the error response, it's usually empty, despite status being depricated. + super('SoundCloud', details?.status, url); //While there exists a message field in the error response, it's usually empty, despite status being deprecated. } } + +export class SoundCloudReleaseLookup extends ReleaseLookup { + +} From d9c97e78213517c557305fdac70e6013d6169552 Mon Sep 17 00:00:00 2001 From: Lioncat6 <95449321+Lioncat6@users.noreply.github.com> Date: Mon, 13 Oct 2025 16:18:58 -0500 Subject: [PATCH 06/20] Finalize soundcloud support --- providers/SoundCloud/api_types.ts | 577 +++++++++--------------------- providers/SoundCloud/mod.ts | 468 +++++++++++++++++++----- providers/mod.ts | 2 + 3 files changed, 555 insertions(+), 492 deletions(-) diff --git a/providers/SoundCloud/api_types.ts b/providers/SoundCloud/api_types.ts index 735fbdbd..5ee048f2 100644 --- a/providers/SoundCloud/api_types.ts +++ b/providers/SoundCloud/api_types.ts @@ -1,424 +1,192 @@ -export type SoundcloudImageFormats = "t500x500" | "crop" | "t300x300" | "large" | "t67x67" | "badge" | "small" | "tiny" | "mini" +// Classes from https://github.com/Moebytes/soundcloud.ts +// MIT License +export type SoundcloudImageFormats = + | 't500x500' + | 'crop' + | 't300x300' + | 'large' + | 't67x67' + | 'badge' + | 'small' + | 'tiny' + | 'mini'; export type SoundcloudLicense = - | "no-rights-reserved" - | "all-rights-reserved" - | "cc-by" - | "cc-by-nc" - | "cc-by-nd" - | "cc-by-sa" - | "cc-by-nc-nd" - | "cc-by-nc-sa" + | 'no-rights-reserved' + | 'all-rights-reserved' + | 'cc-by' + | 'cc-by-nc' + | 'cc-by-nd' + | 'cc-by-sa' + | 'cc-by-nc-nd' + | 'cc-by-nc-sa'; export type SoundcloudTrackType = - | "original" - | "remix" - | "live" - | "recording" - | "spoken" - | "podcast" - | "demo" - | "in progress" - | "stem" - | "loop" - | "sound effect" - | "sample" - | "other" + | 'original' + | 'remix' + | 'live' + | 'recording' + | 'spoken' + | 'podcast' + | 'demo' + | 'in progress' + | 'stem' + | 'loop' + | 'sound effect' + | 'sample' + | 'other'; export interface SoundcloudTrack { - comment_count: number - full_duration: number - downloadable: boolean - created_at: string - description: string | null - media: { - transcodings: SoundcloudTranscoding[] - } - title: string - publisher_metadata: { - id: number - urn: string - artist: string - album_title: string - contains_music: boolean - upc_or_ean: string - isrc: string - explicit: boolean - p_line: string - p_line_for_display: string - c_line: string - c_line_for_display: string - writer_composer: string - release_title: string - publisher: string - } - duration: number - has_downloads_left: boolean - artwork_url: string - public: boolean - streamable: boolean - tag_list: string - genre: string - id: number - reposts_count: number - state: "processing" | "failed" | "finished" - label_name: string | null - last_modified: string - commentable: boolean - policy: string - visuals: string | null - kind: string - purchase_url: string | null - sharing: "private" | "public" - uri: string - secret_token: string | null - download_count: number - likes_count: number - urn: string - license: SoundcloudLicense - purchase_title: string | null - display_date: string - embeddable_by: "all" | "me" | "none" - release_date: string - user_id: number - monetization_model: string - waveform_url: string - permalink: string - permalink_url: string - user: SoundcloudUser - playback_count: number -} -export interface SoundcloudTrackSearch extends SoundcloudSearch { - collection: SoundcloudTrack[] -} - -export interface SoundcloudSecretToken { - kind: "secret-token" - token: string - uri: string - resource_uri: string -} - -export interface SoundcloudTranscoding { - url: string - preset: string - duration: number - snipped: boolean - format: { - protocol: string - mime_type: string - } - quality: string + artwork_url: string; + comment_count: number; + commentable: boolean; + created_at: string; + description: string; + display_date: string; + download_count: number; + downloadable: boolean; + duration: number; + embeddable_by: "all" | "me" | "none"; + full_duration: number; + genre: string; + has_downloads_left: boolean; + id: number; + kind: string; + label_name: string; + last_modified: string; + license: SoundcloudLicense; + likes_count: number; + monetization_model: string; + permalink: string; + permalink_url: string; + playback_count: number; + policy: string; + public: boolean; + purchase_title: string; + purchase_url: string; + reposts_count: number; + secret_token: string; + sharing: "private" | "public"; + state: "processing" | "failed" | "finished"; + streamable: boolean; + tag_list: string; + title: string; + uri: string; + urn: string; + user: SoundcloudUser; + user_id: number; + visuals: string; + waveform_url: string; + release: string | null; + key_signature: string | null; + isrc: string | null; + bpm: number | null; + release_year: number | null; + release_month: number | null; + release_day: number | null; + stream_url: string; + download_url: string | null; + available_country_codes: string[] | null; + secret_uri: string | null; + user_favorite: boolean | null; + user_playback_count: number | null; + favoritings_count: number; + access: string; + metadata_artist: string; } -export interface SoundcloudTrackFilter extends SoundcloudFilter { - "filter.genre_or_tag"?: string - "filter.duration"?: "short" | "medium" | "long" | "epic" - "filter.created_at"?: "last_hour" | "last_day" | "last_week" | "last_month" | "last_year" - "filter.license"?: "to_modify_commercially" | "to_share" | "to_use_commercially" -} 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 - urn: 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" -} - -export interface SoundcloudPlaylistSearch extends SoundcloudSearch { - collection: SoundcloudPlaylist[] -} - -export interface SoundcloudPlaylistFilter extends SoundcloudFilter { - "filter.genre_or_tag"?: string -} -export interface SoundcloudApp { - id: number - kind: "app" - name: string - uri: string - permalink_url: string - external_url: string - creator: string -} - -export interface SoundcloudSearch { - total_results: number - next_href: string - query_urn: string -} - -export interface SoundcloudFilter { - q: string - limit?: number - offset?: number -} -export interface SoundcloudUserMini { - avatar_url: string - id: number - kind: string - permalink_url: string - uri: string - username: string - permalink: string - last_modified: string -} - -export interface SoundcloudUser { - avatar_url: string - city: string - comments_count: number - country_code: number | null - created_at: string - creator_subscriptions: SoundcloudCreatorSubscription[] - creator_subscription: SoundcloudCreatorSubscription - description: string - followers_count: number - followings_count: number - first_name: string - full_name: string - groups_count: number - id: number - kind: string - last_modified: string - last_name: string - likes_count: number - playlist_likes_count: number - permalink: string - permalink_url: string - playlist_count: number - reposts_count: number | null - track_count: number - uri: string - urn: string - username: string - verified: boolean - visuals: { - urn: string - enabled: boolean - visuals: SoundcloudVisual[] - tracking: null - } -} - -export interface SoundcloudUserSearch extends SoundcloudSearch { - collection: SoundcloudUser[] -} - -export interface SoundcloudWebProfile { - network: string - title: string - url: string - username: string | null -} - -export interface SoundcloudUserCollection { - collection: SoundcloudUser - next_href: string | null -} - -export interface SoundcloudVisual { - urn: string - entry_time: number - visual_url: string -} - -export interface SoundcloudCreatorSubscription { - product: { - id: string - } -} - -export interface SoundcloudUserFilter extends SoundcloudFilter { - "filter.place"?: string -} -export interface SoundcloudUserMini { - avatar_url: string - id: number - kind: string - permalink_url: string - uri: string - username: string - permalink: string - last_modified: string + duration: number; + permalink_url: string; + reposts_count: number; + genre: string | null; + permalink: string; + purchase_url: string | null; + description: string | null; + uri: string; + urn: 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; + 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'; + release_year: number | null; + release_month: number | null; + release_day: number | null; + type: string | null; + playlist_type: string | null; } export interface SoundcloudUser { - avatar_url: string - city: string - comments_count: number - country_code: number | null - created_at: string - creator_subscriptions: SoundcloudCreatorSubscription[] - creator_subscription: SoundcloudCreatorSubscription - description: string - followers_count: number - followings_count: number - first_name: string - full_name: string - groups_count: number - id: number - kind: string - last_modified: string - last_name: string - likes_count: number - playlist_likes_count: number - permalink: string - permalink_url: string - playlist_count: number - reposts_count: number | null - track_count: number - uri: string - urn: string - username: string - verified: boolean - visuals: { - urn: string - enabled: boolean - visuals: SoundcloudVisual[] - tracking: null - } -} - -export interface SoundcloudUserSearch extends SoundcloudSearch { - collection: SoundcloudUser[] -} - -export interface SoundcloudWebProfile { - network: string - title: string - url: string - username: string | null -} - -export interface SoundcloudUserCollection { - collection: SoundcloudUser - next_href: string | null + avatar_url: string; + city: string; + comments_count: number; + country_code: number | null; + created_at: string; + creator_subscriptions: SoundcloudCreatorSubscription[]; + creator_subscription: SoundcloudCreatorSubscription; + description: string; + followers_count: number; + followings_count: number; + first_name: string; + full_name: string; + groups_count: number; + id: number; + kind: string; + last_modified: string; + last_name: string; + likes_count: number; + playlist_likes_count: number; + permalink: string; + permalink_url: string; + playlist_count: number; + reposts_count: number | null; + track_count: number; + uri: string; + urn: string; + username: string; + verified: boolean; + visuals: { + urn: string; + enabled: boolean; + visuals: SoundcloudVisual[]; + tracking: null; + }; } export interface SoundcloudVisual { - urn: string - entry_time: number - visual_url: string + urn: string; + entry_time: number; + visual_url: string; } export interface SoundcloudCreatorSubscription { - product: { - id: string - } -} - -export interface SoundcloudUserFilter extends SoundcloudFilter { - "filter.place"?: string -} -export interface SoundcloudUserMini { - avatar_url: string - id: number - kind: string - permalink_url: string - uri: string - username: string - permalink: string - last_modified: string -} - -export interface SoundcloudUser { - avatar_url: string - city: string - comments_count: number - country_code: number | null - created_at: string - creator_subscriptions: SoundcloudCreatorSubscription[] - creator_subscription: SoundcloudCreatorSubscription - description: string - followers_count: number - followings_count: number - first_name: string - full_name: string - groups_count: number - id: number - kind: string - last_modified: string - last_name: string - likes_count: number - playlist_likes_count: number - permalink: string - permalink_url: string - playlist_count: number - reposts_count: number | null - track_count: number - uri: string - urn: string - username: string - verified: boolean - visuals: { - urn: string - enabled: boolean - visuals: SoundcloudVisual[] - tracking: null - } -} - -export interface SoundcloudUserSearch extends SoundcloudSearch { - collection: SoundcloudUser[] -} - -export interface SoundcloudWebProfile { - network: string - title: string - url: string - username: string | null + product: { + id: string; + }; } -export interface SoundcloudUserCollection { - collection: SoundcloudUser - next_href: string | null -} -export interface SoundcloudVisual { - urn: string - entry_time: number - visual_url: string -} - -export interface SoundcloudCreatorSubscription { - product: { - id: string - } -} - -export interface SoundcloudUserFilter extends SoundcloudFilter { - "filter.place"?: string -} +//Custom Classes export type ApiError = { code: number; @@ -428,3 +196,12 @@ export type ApiError = { errors: string[]; error: string | null; }; + +export type RawReponse = { + /** Raw Data from the soundcloud API */ + apiResponse: SoundcloudTrack | SoundcloudPlaylist; + /** The type of release, either 'track' or 'playlist' */ + type: 'track' | 'playlist'; + /** Url of the release */ + href: string; +} diff --git a/providers/SoundCloud/mod.ts b/providers/SoundCloud/mod.ts index 96cf0fa2..aec02b71 100644 --- a/providers/SoundCloud/mod.ts +++ b/providers/SoundCloud/mod.ts @@ -2,13 +2,14 @@ import type { ArtistCreditName, Artwork, ArtworkType, + CountryCode, EntityId, HarmonyEntityType, HarmonyRelease, HarmonyTrack, Label, LinkType, -} from "@/harmonizer/types.ts"; +} from '@/harmonizer/types.ts'; import { type ApiAccessToken, type ApiQueryOptions, @@ -16,62 +17,130 @@ import { MetadataApiProvider, MetadataProvider, ReleaseLookup, -} from "@/providers/base.ts"; -import { - DurationPrecision, - FeatureQuality, - FeatureQualityMap, -} from "@/providers/features.ts"; -import { parseISODateTime, PartialDate } from "@/utils/date.ts"; -import { ProviderError, ResponseError } from "@/utils/errors.ts"; -import { - extractDataAttribute, - extractMetadataTag, - extractTextFromHtml, -} from "@/utils/html.ts"; +} from '@/providers/base.ts'; +import { DurationPrecision, FeatureQuality, FeatureQualityMap } from '@/providers/features.ts'; +import { parseISODateTime, PartialDate } from '@/utils/date.ts'; +import { ProviderError, ResponseError } from '@/utils/errors.ts'; +import { extractDataAttribute, extractMetadataTag, extractTextFromHtml } from '@/utils/html.ts'; import { getFromEnv } from '@/utils/config.ts'; -import { plural, pluralWithCount } from "@/utils/plural.ts"; -import { isNotNull } from "@/utils/predicate.ts"; -import { similarNames } from "@/utils/similarity.ts"; -import { toTrackRanges } from "@/utils/tracklist.ts"; -import { simplifyName } from "utils/string/simplify.js"; -import type { +import { plural, pluralWithCount } from '@/utils/plural.ts'; +import { isNotNull } from '@/utils/predicate.ts'; +import { similarNames } from '@/utils/similarity.ts'; +import { toTrackRanges } from '@/utils/tracklist.ts'; +import { simplifyName } from 'utils/string/simplify.js'; +import { ApiError, + RawReponse, SoundcloudPlaylist, + SoundcloudTrack, SoundcloudUser, - SoundCloudTrack, - SoundcloudSearch, - SoundcloudFilter, } from './api_types.ts'; import { encodeBase64 } from 'std/encoding/base64.ts'; import { ResponseError as SnapResponseError } from 'snap-storage'; - - +import { capitalizeReleaseType } from '../../harmonizer/release_types.ts'; const soundcloudClientId = getFromEnv('HARMONY_SOUNDCLOUD_CLIENT_ID') || ''; const soundcloudClientSecret = getFromEnv('HARMONY_SOUNDCLOUD_CLIENT_SECRET') || ''; - export default class SoundCloudProvider extends MetadataApiProvider { - readonly name = "SoundCloud"; + readonly name = 'SoundCloud'; readonly apiBaseUrl = new URL('https://api.soundcloud.com/'); + readonly releaseLookup = SoundCloudReleaseLookup; + readonly supportedUrls = new URLPattern({ - hostname: "soundcloud.com", - pathname: "/:artist/set/:title", + hostname: 'soundcloud.com', + pathname: '/:artist/sets/:title', }); readonly trackUrlPattern = new URLPattern({ - hostname: "soundcloud.com", - pathname: "/:artist/:title", + hostname: 'soundcloud.com', + pathname: '/:artist/:title', }); readonly artistUrlPattern = new URLPattern({ - hostname: "soundcloud.com", - pathname: "/:artist", + hostname: 'soundcloud.com', + pathname: '/:artist', }); + readonly entityTypeMap = { + artist: 'user', + release: ['playlist', 'track'], + }; + + override extractEntityFromUrl(url: URL): EntityId | undefined { + const playlistResult = this.supportedUrls.exec(url); + if (playlistResult) { + const { title, artist } = playlistResult.pathname.groups; + + if (title) { + return { + type: 'playlist', + id: [artist, title].join('/'), + }; + } + } + const trackResult = this.trackUrlPattern.exec(url); + if (trackResult) { + const { title, artist } = trackResult.pathname.groups; + if (title) { + return { + type: 'track', + id: [artist, title].join('/'), + }; + } + } + + const artistResult = this.artistUrlPattern.exec(url); + if (artistResult) { + return { + type: 'artist', + id: artistResult.pathname.groups.artist!, + }; + } + } + + constructUrl(entity: EntityId): URL { + const [artist, title] = entity.id.split('/', 2); + if (entity.type === 'artist') return new URL(artist, 'https://soundcloud.com'); + + if (!title) { + throw new ProviderError(this.name, `Incomplete release ID '${entity.id}' does not match format \`user/title\``); + } + if (entity.type === 'track') return new URL([artist, title].join('/'), 'https://soundcloud.com'); + return new URL([artist, 'sets', title].join('/'), 'https://soundcloud.com'); + } + + override serializeProviderId(entity: EntityId): string { + if (entity.type === 'track') { + return entity.id.replace('/', '/track/'); + } else { + return entity.id; + } + } + + override parseProviderId(id: string, entityType: HarmonyEntityType): EntityId { + if (entityType === 'release') { + if (id.includes('/track/')) { + return { id: id.replace('/track/', '/'), type: 'track' }; + } else { + return { id, type: 'album' }; + } + } else { + return { id, type: this.entityTypeMap[entityType] }; + } + } + + override getLinkTypesForEntity(entity: EntityId): LinkType[] { + if (entity.type === 'track') { + // All tracks offer free streaming, but some don't have free downloads enabled. + return ['free streaming']; + } + // MB has special handling for Bandcamp artist URLs + return ['discography page']; + } + override readonly launchDate: PartialDate = { year: 2008, month: 10, @@ -85,52 +154,47 @@ export default class SoundCloudProvider extends MetadataApiProvider { 'MBID resolving': FeatureQuality.PRESENT, }; - readonly entityTypeMap = { - artist: 'user', - release: ['playlist', 'album'], - }; - async query(apiUrl: URL, options: ApiQueryOptions): Promise> { - try { - await this.requestDelay; - const accessToken = await this.cachedAccessToken(this.requestAccessToken); - const cacheEntry = await this.fetchJSON(apiUrl, { - policy: { maxTimestamp: options.snapshotMaxTimestamp }, - requestInit: { - headers: { - 'Authorization': `Bearer ${accessToken}`, - }, + try { + await this.requestDelay; + const accessToken = await this.cachedAccessToken(this.requestAccessToken); + const cacheEntry = await this.fetchJSON(apiUrl, { + policy: { maxTimestamp: options.snapshotMaxTimestamp }, + requestInit: { + headers: { + 'Authorization': `Bearer ${accessToken}`, }, - }); - const apiError = cacheEntry.content as ApiError; - if (apiError.error) { - throw new SoundCloudResponseError(apiError, apiUrl); - } - return cacheEntry; - } catch (error) { - let apiError: ApiError | undefined; - if (error instanceof SnapResponseError) { - const { response } = error; - this.handleRateLimit(response); - // Retry API query when we encounter a 429 rate limit error. - if (response.status === 429) { - return this.query(apiUrl, options); - } - try { - // Clone the response so the body of the original response can be - // consumed later if the error gets re-thrown. - apiError = await response.clone().json(); - } catch { - // Ignore secondary JSON parsing error, rethrow original error. - } + }, + }); + const apiError = cacheEntry.content as ApiError; + if (apiError.error) { + throw new SoundCloudResponseError(apiError, apiUrl); + } + return cacheEntry; + } catch (error) { + let apiError: ApiError | undefined; + if (error instanceof SnapResponseError) { + const { response } = error; + this.handleRateLimit(response); + // Retry API query when we encounter a 429 rate limit error. + if (response.status === 429) { + return this.query(apiUrl, options); } - if (apiError?.error) { - throw new SoundCloudResponseError(apiError, apiUrl); - } else { - throw error; + try { + // Clone the response so the body of the original response can be + // consumed later if the error gets re-thrown. + apiError = await response.clone().json(); + } catch { + // Ignore secondary JSON parsing error, rethrow original error. } } + if (apiError?.error) { + throw new SoundCloudResponseError(apiError, apiUrl); + } else { + throw error; + } } + } //Soundcloud's client credentials authentication works surprisingly similarly to Spotify's https://developers.soundcloud.com/docs#authentication private async requestAccessToken(): Promise { @@ -155,24 +219,6 @@ export default class SoundCloudProvider extends MetadataApiProvider { validUntilTimestamp: Date.now() + (content.expires_in * 1000), }; } - - override extractEntityFromUrl(url: URL): EntityId | undefined { - const playlistResult = this.supportedUrls.exec(url); - const trackResult = this.trackUrlPattern.exec(url); - const artistResult = this.artistUrlPattern.exec(url); - const query = new URLSearchParams(); - query.set('url', url.href); - const resolveUrl = new URL('resolve', this.apiBaseUrl); - resolveUrl.search = query.toString(); - if (playlistResult | trackResult | artistResult) { - const cacheEntry = this.query(resolveUrl, { resolveUrl: this.options.snapshotMaxTimestamp }); - this.updateCacheTime(cacheEntry.timestamp); - const entity = cacheEntry.content - if (entity) { - return entity.urn; - } - } - } } class SoundCloudResponseError extends ResponseError { @@ -181,6 +227,244 @@ class SoundCloudResponseError extends ResponseError { } } -export class SoundCloudReleaseLookup extends ReleaseLookup { +export class SoundCloudReleaseLookup extends ReleaseLookup { + rawReleaseUrl: URL | undefined; + + constructReleaseApiUrl(): URL | undefined { + return undefined; + } + + async getRawRelease(): Promise { + if (this.lookup.method === 'gtin') { + throw new ProviderError(this.provider.name, 'GTIN lookups are not supported'); + } + + // Entity is already defined for ID/URL lookups. + const webUrl = this.provider.constructUrl(this.entity!); + const lookupUrl = new URL('resolve', this.provider.apiBaseUrl); + lookupUrl.searchParams.set('url', webUrl.href); + this.rawReleaseUrl = webUrl; + if (this.entity!.type === 'playlist') { + const cacheEntry = await this.provider.query(lookupUrl, { + snapshotMaxTimestamp: this.options.snapshotMaxTimestamp, + }); + this.updateCacheTime(cacheEntry.timestamp); + const release = { + apiResponse: cacheEntry.content, + type: 'playlist' as const, + href: webUrl.href, + }; + return release; + } else if (this.entity!.type === 'track') { + const cacheEntry = await this.provider.query(lookupUrl, { + snapshotMaxTimestamp: this.options.snapshotMaxTimestamp, + }); + this.updateCacheTime(cacheEntry.timestamp); + const release = { + apiResponse: cacheEntry.content, + type: 'track' as const, + href: webUrl.href, + }; + return release; + } else { + throw new ProviderError(this.provider.name, `Unsupported entity type '${this.entity!.type}'`); + } + } + + async convertRawRelease(rawRelease: RawReponse): Promise { + const { apiResponse, type } = rawRelease; + if (type == 'track') { + const trackReponse = apiResponse as SoundcloudTrack; + const release: HarmonyRelease = { + title: trackReponse.title, + artists: [this.makeArtistCredit(trackReponse.user)], + externalLinks: [{ + url: rawRelease.href, + types: this.getReleaseTypes(trackReponse), + }], + media: [{ + format: 'Digital Media', + tracklist: [this.convertRawTrack(trackReponse)], + }], + releaseDate: this.getReleaseDate(trackReponse), + labels: this.getLabel(trackReponse), + packaging: 'None', + types: ['Single'], + availableIn: this.getCountryCodes(trackReponse), + info: this.generateReleaseInfo(), + images: this.getArtwork(trackReponse), + }; + return release; + } else { + const playlistResponse = apiResponse as SoundcloudPlaylist; + const release: HarmonyRelease = { + title: playlistResponse.title, + artists: [this.makeArtistCredit(playlistResponse.user)], + externalLinks: [{ + url: rawRelease.href, + types: this.getReleaseTypes(playlistResponse), + }], + media: [{ + format: 'Digital Media', + tracklist: playlistResponse.tracks?.filter(isNotNull).map((track, idx) => this.convertRawTrack(track, idx)) || [], + }], + releaseDate: this.getReleaseDate(playlistResponse), + labels: this.getLabel(playlistResponse), + packaging: 'None', + types: playlistResponse.playlist_type ? [capitalizeReleaseType(playlistResponse.playlist_type)] : undefined, + info: this.generateReleaseInfo(), + images: this.getArtwork(playlistResponse), + }; + return release; + + } + } + + getReleaseTypes(release: SoundcloudPlaylist | SoundcloudTrack): LinkType[] { + if (release.kind === 'playlist') { + const playlist = release as SoundcloudPlaylist; + const types: LinkType[] = ['free streaming']; + if (playlist.tracks?.length > 0) { + let allDownloadable = true; + for (const track of playlist.tracks) { + if (!track.downloadable) { + allDownloadable = false; + break; + } + } + if (allDownloadable) { + types.push('free download'); + } + } + return types; + } else if (release.kind === 'track') { + const track = release as SoundcloudTrack; + const types: LinkType[] = ['free streaming']; + if (track.downloadable) { + types.push('free download'); + } + return types; + } + return []; + } + + + getArtwork(release: SoundcloudPlaylist | SoundcloudTrack): Artwork[] | undefined { + const artworks: Artwork[] = []; + const artworkUrl = release.artwork_url; + if (artworkUrl) { + // Replace large/medium/small with t500x500 to get the largest available image. + const artworkFull = artworkUrl.replace(/-(large|medium|small)\./, '-t500x500.'); + artworks.push({ + thumbUrl: artworkUrl, + url: artworkFull, + types: ['front'], + provider: this.provider.name, + + }); + return artworks; + } + return undefined; + } + getLabel(release: SoundcloudPlaylist | SoundcloudTrack): Label[] | undefined { + if (release.kind === 'playlist') { + const playlist = release as SoundcloudPlaylist; + if (playlist.label_name) { + const label:Label = { + name: playlist.label_name, + } + return [label]; + } else { + if (playlist.tracks?.length > 0) { + const labelName = playlist.tracks[0].label_name; + for (const track of playlist.tracks) { + if (track.label_name !== track.label_name) { + // Different label names found, cannot determine a single label for the release. + return undefined; + } + } + if (labelName) { + const label:Label = { + name: labelName, + } + return [label]; + } + } + } + } else if (release.kind === 'track') { + const track = release as SoundcloudTrack; + if (track.label_name) { + const label:Label = { + name: track.label_name, + } + return [label]; + } + } + return undefined; + } + + getReleaseDate(release: SoundcloudTrack | SoundcloudPlaylist): PartialDate | undefined { + if (release.release_day, release.release_month, release.release_year) { + const date: PartialDate = { + year: release.release_year || undefined, + month: release.release_month || undefined, + day: release.release_day || undefined, + }; + return date; + } else if (release.created_at) { + const date = this.parseSoundcloudTimestamp(release.created_at); + if (date) { + return parseISODateTime(date.toISOString()); + } + } + return undefined; + } + + parseSoundcloudTimestamp(timestamp: string): Date | undefined { + const segments = timestamp.split(' '); + if (segments.length === 3) { + // Convert "2025/07/30 07:13:31 +0000" to "2025-07-30T07:13:31+00:00" + const iso = segments[0].replace(/\//g, '-') + 'T' + segments[1] + + segments[2].replace(/([+-]\d{2})(\d{2})$/, '$1:$2'); + const date = new Date(iso); + if (!isNaN(date.getTime())) { + return date; + } + } + return undefined; + } + + convertRawTrack(rawTrack: SoundcloudTrack, index: number = 0): HarmonyTrack { + const trackUrl: URL = new URL(rawTrack.permalink_url); + const trackEntity = this.provider.extractEntityFromUrl(trackUrl); + const trackNumber = index + 1; + const track: HarmonyTrack = { + number: trackNumber, + title: rawTrack.title, + artists: [this.makeArtistCredit(rawTrack.user)], + length: rawTrack.duration, + isrc: rawTrack.isrc || undefined, + availableIn: this.getCountryCodes(rawTrack), + recording: trackEntity ? { externalIds: this.provider.makeExternalIds(trackEntity) } : undefined, + }; + return track; + } + + getCountryCodes(track: SoundcloudTrack): CountryCode[] | undefined { + if (track.available_country_codes?.length) { + return track.available_country_codes.map((code: string) => code.toUpperCase() as CountryCode); + } + return undefined; + } + + makeArtistCredit(user: SoundcloudUser): ArtistCreditName { + const userUrl: URL = new URL(user.permalink_url); + const userEntity = this.provider.extractEntityFromUrl(userUrl); + return { + name: user.username, + creditedName: user.username, + externalIds: userEntity ? this.provider.makeExternalIds(userEntity) : undefined, + }; + } } diff --git a/providers/mod.ts b/providers/mod.ts index 534eac7c..6ede85a7 100644 --- a/providers/mod.ts +++ b/providers/mod.ts @@ -10,6 +10,7 @@ import iTunesProvider from './iTunes/mod.ts'; import MusicBrainzProvider from './MusicBrainz/mod.ts'; import SpotifyProvider from './Spotify/mod.ts'; import TidalProvider from './Tidal/mod.ts'; +import SoundCloudProvider from './SoundCloud/mod.ts'; /** Registry with all supported providers. */ export const providers = new ProviderRegistry({ @@ -26,6 +27,7 @@ providers.addMultiple( TidalProvider, BandcampProvider, BeatportProvider, + SoundCloudProvider ); /** Internal names of providers which are enabled by default (for GTIN lookups). */ From a4701ca8abe101b9fad23d73ddccd4ec7f1e25ac Mon Sep 17 00:00:00 2001 From: Lioncat6 <95449321+Lioncat6@users.noreply.github.com> Date: Mon, 13 Oct 2025 17:00:08 -0500 Subject: [PATCH 07/20] Added soundcloud icon & color --- server/components/ProviderIcon.tsx | 1 + server/routes/icon-sprite.svg.tsx | 2 ++ server/static/harmony.css | 4 ++++ 3 files changed, 7 insertions(+) diff --git a/server/components/ProviderIcon.tsx b/server/components/ProviderIcon.tsx index 91a30cb8..adc335ed 100644 --- a/server/components/ProviderIcon.tsx +++ b/server/components/ProviderIcon.tsx @@ -10,6 +10,7 @@ const providerIconMap: Record = { musicbrainz: 'brand-metabrainz', spotify: 'brand-spotify', tidal: 'brand-tidal', + soundcloud: 'brand-soundcloud', }; export type ProviderIconProps = Omit & { diff --git a/server/routes/icon-sprite.svg.tsx b/server/routes/icon-sprite.svg.tsx index 5baa1d09..8a67ad2c 100644 --- a/server/routes/icon-sprite.svg.tsx +++ b/server/routes/icon-sprite.svg.tsx @@ -6,6 +6,7 @@ import IconBrandBandcamp from 'tabler-icons/brand-bandcamp.tsx'; import IconBrandDeezer from 'tabler-icons/brand-deezer.tsx'; import IconBrandGit from 'tabler-icons/brand-git.tsx'; import IconBrandSpotify from 'tabler-icons/brand-spotify.tsx'; +import IconBraindSoundcloud from 'tabler-icons/brand-soundcloud.tsx'; import IconBrandTidal from 'tabler-icons/brand-tidal.tsx'; import IconAlertTriangle from 'tabler-icons/alert-triangle.tsx'; import IconBarcode from 'tabler-icons/barcode.tsx'; @@ -59,6 +60,7 @@ const icons: Icon[] = [ IconBrandMetaBrainz, IconBrandSpotify, IconBrandTidal, + IconBraindSoundcloud, IconPuzzle, ]; diff --git a/server/static/harmony.css b/server/static/harmony.css index 352498f7..8d61ee74 100644 --- a/server/static/harmony.css +++ b/server/static/harmony.css @@ -31,6 +31,7 @@ --musicbrainz: #ba478f; --spotify: #1db954; --tidal: #000000; + --soundcloud: #F37422; } @media (prefers-color-scheme: dark) { @@ -444,6 +445,9 @@ label.spotify, td.spotify { label.tidal, td.tidal { background-color: var(--tidal); } +label.soundcloud, td.soundcloud { + background-color: var(--soundcloud); +} /* ProviderIcon.tsx */ From bdc0f04d72c69b4a81aa8150e43201244658c6b1 Mon Sep 17 00:00:00 2001 From: Lioncat6 <95449321+Lioncat6@users.noreply.github.com> Date: Mon, 13 Oct 2025 17:26:21 -0500 Subject: [PATCH 08/20] Increase resolution of thumb cover art --- providers/SoundCloud/mod.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/providers/SoundCloud/mod.ts b/providers/SoundCloud/mod.ts index aec02b71..2d91bef2 100644 --- a/providers/SoundCloud/mod.ts +++ b/providers/SoundCloud/mod.ts @@ -351,7 +351,7 @@ export class SoundCloudReleaseLookup extends ReleaseLookup Date: Tue, 14 Oct 2025 17:20:25 -0500 Subject: [PATCH 09/20] Implement most suggestions --- providers/SoundCloud/mod.ts | 62 +++++++++---------------------------- 1 file changed, 15 insertions(+), 47 deletions(-) diff --git a/providers/SoundCloud/mod.ts b/providers/SoundCloud/mod.ts index 2d91bef2..e3cb72c4 100644 --- a/providers/SoundCloud/mod.ts +++ b/providers/SoundCloud/mod.ts @@ -15,26 +15,14 @@ import { type ApiQueryOptions, type CacheEntry, MetadataApiProvider, - MetadataProvider, ReleaseLookup, } from '@/providers/base.ts'; import { DurationPrecision, FeatureQuality, FeatureQualityMap } from '@/providers/features.ts'; import { parseISODateTime, PartialDate } from '@/utils/date.ts'; import { ProviderError, ResponseError } from '@/utils/errors.ts'; -import { extractDataAttribute, extractMetadataTag, extractTextFromHtml } from '@/utils/html.ts'; import { getFromEnv } from '@/utils/config.ts'; -import { plural, pluralWithCount } from '@/utils/plural.ts'; import { isNotNull } from '@/utils/predicate.ts'; -import { similarNames } from '@/utils/similarity.ts'; -import { toTrackRanges } from '@/utils/tracklist.ts'; -import { simplifyName } from 'utils/string/simplify.js'; -import { - ApiError, - RawReponse, - SoundcloudPlaylist, - SoundcloudTrack, - SoundcloudUser, -} from './api_types.ts'; +import { ApiError, RawReponse, SoundcloudPlaylist, SoundcloudTrack, SoundcloudUser } from './api_types.ts'; import { encodeBase64 } from 'std/encoding/base64.ts'; import { ResponseError as SnapResponseError } from 'snap-storage'; import { capitalizeReleaseType } from '../../harmonizer/release_types.ts'; @@ -306,7 +294,7 @@ export class SoundCloudReleaseLookup extends ReleaseLookup this.convertRawTrack(track, idx)) || [], + tracklist: playlistResponse.tracks?.filter(isNotNull).map(this.convertRawTrack.bind(this)) ?? [], }], releaseDate: this.getReleaseDate(playlistResponse), labels: this.getLabel(playlistResponse), @@ -316,7 +304,6 @@ export class SoundCloudReleaseLookup extends ReleaseLookup 0) { - let allDownloadable = true; - for (const track of playlist.tracks) { - if (!track.downloadable) { - allDownloadable = false; - break; - } - } - if (allDownloadable) { + if (playlist.tracks?.every((track) => track.downloadable)) { types.push('free download'); } } - return types; } else if (release.kind === 'track') { const track = release as SoundcloudTrack; const types: LinkType[] = ['free streaming']; @@ -348,19 +327,15 @@ export class SoundCloudReleaseLookup extends ReleaseLookup 0) { const labelName = playlist.tracks[0].label_name; - for (const track of playlist.tracks) { - if (track.label_name !== track.label_name) { - // Different label names found, cannot determine a single label for the release. - return undefined; - } - } - if (labelName) { - const label:Label = { + if (labelName && playlist.tracks.every((track) => track.label_name === labelName)) { + return [{ name: labelName, - } - return [label]; + }]; } } } } else if (release.kind === 'track') { const track = release as SoundcloudTrack; if (track.label_name) { - const label:Label = { + const label: Label = { name: track.label_name, - } + }; return [label]; } } @@ -405,7 +373,7 @@ export class SoundCloudReleaseLookup extends ReleaseLookup Date: Thu, 23 Oct 2025 00:37:22 -0500 Subject: [PATCH 10/20] Add test file --- providers/SoundCloud/mod.test.ts | 45 ++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 providers/SoundCloud/mod.test.ts diff --git a/providers/SoundCloud/mod.test.ts b/providers/SoundCloud/mod.test.ts new file mode 100644 index 00000000..289b8d5a --- /dev/null +++ b/providers/SoundCloud/mod.test.ts @@ -0,0 +1,45 @@ +import { describeProvider, makeProviderOptions } from '@/providers/test_spec.ts'; +import { stubProviderLookups } from '@/providers/test_stubs.ts'; +import { assert } from 'std/assert/assert.ts'; +import { afterAll, describe } from '@std/testing/bdd'; +import { assertSnapshot } from '@std/testing/snapshot'; + +import SoundCloudProvider from './mod.ts'; + +describe('SoundCloud provider', () => { + const sc = new SoundCloudProvider(makeProviderOptions()); + const lookupStub = stubProviderLookups(sc); + + describeProvider(sc, { + urls: [{ + description: 'album page', + url: new URL('https://soundcloud.com/ivycomb/sets/crimsongalaxies'), + id: { type: 'playlist', id: 'ivycomb/crimsongalaxies' }, + isCanonical: true, + }, { + description: 'standalone track page', + url: new URL('https://soundcloud.com/lonealphamusic/magazines'), + id: { type: 'track', id: 'lonealphamusic/magazines' }, + serializedId: 'lonealphamusic/track/magazines', + isCanonical: true, + }, { + description: 'artist page', + url: new URL('https://soundcloud.com/vocalokat'), + id: { type: 'artist', id: 'vocalokat' }, + isCanonical: true, + }], + releaseLookup: [{ + description: 'track release with downloads enabled', + release: 'leagueoflegends/track/piercing-light-mako-remix', + assert: async (release, ctx) => { + await assertSnapshot(ctx, release); + const isFree = release.externalLinks.some((link) => link.types?.includes('free download')); + assert(isFree, 'Release should be downloadable for free'); + }, + }], + }); + + afterAll(() => { + lookupStub.restore(); + }); +}); From f339b388590629de6e4d8770b54de3a4520bc79c Mon Sep 17 00:00:00 2001 From: Lioncat6 <95449321+Lioncat6@users.noreply.github.com> Date: Thu, 23 Oct 2025 00:37:38 -0500 Subject: [PATCH 11/20] Soundcloud relationship type handling --- musicbrainz/seeding.ts | 3 +++ providers/SoundCloud/mod.ts | 4 ++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/musicbrainz/seeding.ts b/musicbrainz/seeding.ts index f7c33ca7..490cc11e 100644 --- a/musicbrainz/seeding.ts +++ b/musicbrainz/seeding.ts @@ -145,6 +145,9 @@ export function convertLinkType(entityType: EntityType, linkType: LinkType, url? if (url?.hostname.endsWith('.bandcamp.com')) { return typeIds.bandcamp; } + if (url?.hostname.replace('www.','') == 'soundcloud.com') { + return typeIds.soundcloud; + } return typeIds['discography page'] ?? typeIds['discography entry']; case 'license': return typeIds['license']; diff --git a/providers/SoundCloud/mod.ts b/providers/SoundCloud/mod.ts index e3cb72c4..d77957cf 100644 --- a/providers/SoundCloud/mod.ts +++ b/providers/SoundCloud/mod.ts @@ -121,11 +121,11 @@ export default class SoundCloudProvider extends MetadataApiProvider { } override getLinkTypesForEntity(entity: EntityId): LinkType[] { - if (entity.type === 'track') { + if (entity.type != 'artist') { // All tracks offer free streaming, but some don't have free downloads enabled. return ['free streaming']; } - // MB has special handling for Bandcamp artist URLs + // MB has special handling for Soundcloud artist URLs return ['discography page']; } From df02363a06237fedba83d74041c7b9e25fd0e117 Mon Sep 17 00:00:00 2001 From: Lioncat6 <95449321+Lioncat6@users.noreply.github.com> Date: Thu, 23 Oct 2025 00:59:33 -0500 Subject: [PATCH 12/20] Added souncloud config --- .env.example | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.env.example b/.env.example index 2cd4d650..06a6c971 100644 --- a/.env.example +++ b/.env.example @@ -23,3 +23,7 @@ HARMONY_SPOTIFY_CLIENT_SECRET= # Tidal app config. See https://developer.tidal.com/reference/web-api HARMONY_TIDAL_CLIENT_ID= HARMONY_TIDAL_CLIENT_SECRET= + +# Soundcloud app config. See https://developers.soundcloud.com/docs/api/guide +HARMONY_SOUNDCLOUD_CLIENT_ID= +HARMONY_SOUNDCLOUD_CLIENT_SECRET= From 1ac64fade46d72e1fa28f1245187dd33f514bbf6 Mon Sep 17 00:00:00 2001 From: Lioncat6 <95449321+Lioncat6@users.noreply.github.com> Date: Thu, 23 Oct 2025 01:13:07 -0500 Subject: [PATCH 13/20] Fixed deno formatting --- musicbrainz/seeding.ts | 2 +- providers/SoundCloud/api_types.ts | 178 +++++++++++++++--------------- providers/SoundCloud/mod.ts | 2 +- providers/mod.ts | 2 +- server/static/harmony.css | 2 +- 5 files changed, 91 insertions(+), 95 deletions(-) diff --git a/musicbrainz/seeding.ts b/musicbrainz/seeding.ts index 490cc11e..058efe86 100644 --- a/musicbrainz/seeding.ts +++ b/musicbrainz/seeding.ts @@ -145,7 +145,7 @@ export function convertLinkType(entityType: EntityType, linkType: LinkType, url? if (url?.hostname.endsWith('.bandcamp.com')) { return typeIds.bandcamp; } - if (url?.hostname.replace('www.','') == 'soundcloud.com') { + if (url?.hostname.replace('www.', '') == 'soundcloud.com') { return typeIds.soundcloud; } return typeIds['discography page'] ?? typeIds['discography entry']; diff --git a/providers/SoundCloud/api_types.ts b/providers/SoundCloud/api_types.ts index 5ee048f2..d3b32a1d 100644 --- a/providers/SoundCloud/api_types.ts +++ b/providers/SoundCloud/api_types.ts @@ -1,98 +1,95 @@ // Classes from https://github.com/Moebytes/soundcloud.ts // MIT License -export type SoundcloudImageFormats = - | 't500x500' - | 'crop' - | 't300x300' - | 'large' - | 't67x67' - | 'badge' - | 'small' - | 'tiny' - | 'mini'; +export type SoundcloudImageFormats = 't500x500'; +'crop'; +'t300x300'; +'large'; +'t67x67'; +'badge'; +'small'; +'tiny'; +'mini'; -export type SoundcloudLicense = - | 'no-rights-reserved' - | 'all-rights-reserved' - | 'cc-by' - | 'cc-by-nc' - | 'cc-by-nd' - | 'cc-by-sa' - | 'cc-by-nc-nd' - | 'cc-by-nc-sa'; +export type SoundcloudLicense = 'no-rights-reserved'; +'all-rights-reserved'; +'cc-by'; +'cc-by-nc'; +'cc-by-nd'; +'cc-by-sa'; +'cc-by-nc-nd'; +'cc-by-nc-sa'; -export type SoundcloudTrackType = - | 'original' - | 'remix' - | 'live' - | 'recording' - | 'spoken' - | 'podcast' - | 'demo' - | 'in progress' - | 'stem' - | 'loop' - | 'sound effect' - | 'sample' - | 'other'; +export type SoundcloudTrackType = 'original'; +'remix'; +'live'; +'recording'; +'spoken'; +'podcast'; +'demo'; +'in progress'; +'stem'; +'loop'; +'sound effect'; +'sample'; +'other'; export interface SoundcloudTrack { - artwork_url: string; - comment_count: number; - commentable: boolean; - created_at: string; - description: string; - display_date: string; - download_count: number; - downloadable: boolean; - duration: number; - embeddable_by: "all" | "me" | "none"; - full_duration: number; - genre: string; - has_downloads_left: boolean; - id: number; - kind: string; - label_name: string; - last_modified: string; - license: SoundcloudLicense; - likes_count: number; - monetization_model: string; - permalink: string; - permalink_url: string; - playback_count: number; - policy: string; - public: boolean; - purchase_title: string; - purchase_url: string; - reposts_count: number; - secret_token: string; - sharing: "private" | "public"; - state: "processing" | "failed" | "finished"; - streamable: boolean; - tag_list: string; - title: string; - uri: string; - urn: string; - user: SoundcloudUser; - user_id: number; - visuals: string; - waveform_url: string; - release: string | null; - key_signature: string | null; - isrc: string | null; - bpm: number | null; - release_year: number | null; - release_month: number | null; - release_day: number | null; - stream_url: string; - download_url: string | null; - available_country_codes: string[] | null; - secret_uri: string | null; - user_favorite: boolean | null; - user_playback_count: number | null; - favoritings_count: number; - access: string; - metadata_artist: string; + artwork_url: string; + comment_count: number; + commentable: boolean; + created_at: string; + description: string; + display_date: string; + download_count: number; + downloadable: boolean; + duration: number; + embeddable_by: 'all' | 'me' | 'none'; + full_duration: number; + genre: string; + has_downloads_left: boolean; + id: number; + kind: string; + label_name: string; + last_modified: string; + license: SoundcloudLicense; + likes_count: number; + monetization_model: string; + permalink: string; + permalink_url: string; + playback_count: number; + policy: string; + public: boolean; + purchase_title: string; + purchase_url: string; + reposts_count: number; + secret_token: string; + sharing: 'private' | 'public'; + state: 'processing' | 'failed' | 'finished'; + streamable: boolean; + tag_list: string; + title: string; + uri: string; + urn: string; + user: SoundcloudUser; + user_id: number; + visuals: string; + waveform_url: string; + release: string | null; + key_signature: string | null; + isrc: string | null; + bpm: number | null; + release_year: number | null; + release_month: number | null; + release_day: number | null; + stream_url: string; + download_url: string | null; + available_country_codes: string[] | null; + secret_uri: string | null; + user_favorite: boolean | null; + user_playback_count: number | null; + favoritings_count: number; + access: string; + metadata_artist: string; } export interface SoundcloudPlaylist { @@ -185,7 +182,6 @@ export interface SoundcloudCreatorSubscription { }; } - //Custom Classes export type ApiError = { @@ -204,4 +200,4 @@ export type RawReponse = { type: 'track' | 'playlist'; /** Url of the release */ href: string; -} +}; diff --git a/providers/SoundCloud/mod.ts b/providers/SoundCloud/mod.ts index d77957cf..7bfebf23 100644 --- a/providers/SoundCloud/mod.ts +++ b/providers/SoundCloud/mod.ts @@ -259,7 +259,7 @@ export class SoundCloudReleaseLookup extends ReleaseLookup { + convertRawRelease(rawRelease: RawReponse): HarmonyRelease { const { apiResponse, type } = rawRelease; if (type == 'track') { const trackReponse = apiResponse as SoundcloudTrack; diff --git a/providers/mod.ts b/providers/mod.ts index 6ede85a7..51be1986 100644 --- a/providers/mod.ts +++ b/providers/mod.ts @@ -27,7 +27,7 @@ providers.addMultiple( TidalProvider, BandcampProvider, BeatportProvider, - SoundCloudProvider + SoundCloudProvider, ); /** Internal names of providers which are enabled by default (for GTIN lookups). */ diff --git a/server/static/harmony.css b/server/static/harmony.css index 8d61ee74..2a27e8c9 100644 --- a/server/static/harmony.css +++ b/server/static/harmony.css @@ -31,7 +31,7 @@ --musicbrainz: #ba478f; --spotify: #1db954; --tidal: #000000; - --soundcloud: #F37422; + --soundcloud: #f37422; } @media (prefers-color-scheme: dark) { From 4dd484d2db3a1fb8402fe39ec356f7bdd11b44c8 Mon Sep 17 00:00:00 2001 From: Lioncat6 <95449321+Lioncat6@users.noreply.github.com> Date: Thu, 23 Oct 2025 01:16:26 -0500 Subject: [PATCH 14/20] Ran test in download mode and generated snapshot `deno test --allow-net --allow-write -RE providers\SoundCloud\mod.test.ts -- --download --update` --- .../SoundCloud/__snapshots__/mod.test.ts.snap | 102 ++++++++++++++++++ ...undcloud.com!leagueoflegends!pierc#b6e4ddc | 1 + 2 files changed, 103 insertions(+) create mode 100644 providers/SoundCloud/__snapshots__/mod.test.ts.snap create mode 100644 testdata/https!/com.soundcloud.api/resolve!url=https!!!soundcloud.com!leagueoflegends!pierc#b6e4ddc diff --git a/providers/SoundCloud/__snapshots__/mod.test.ts.snap b/providers/SoundCloud/__snapshots__/mod.test.ts.snap new file mode 100644 index 00000000..e10f3dfa --- /dev/null +++ b/providers/SoundCloud/__snapshots__/mod.test.ts.snap @@ -0,0 +1,102 @@ +export const snapshot = {}; + +snapshot[`SoundCloud provider > release lookup > track release with downloads enabled 1`] = ` +{ + artists: [ + { + creditedName: "League of Legends", + externalIds: [ + { + id: "leagueoflegends", + provider: "soundcloud", + type: "artist", + }, + ], + name: "League of Legends", + }, + ], + availableIn: undefined, + externalLinks: [ + { + types: [ + "free streaming", + "free download", + ], + url: "https://soundcloud.com/leagueoflegends/piercing-light-mako-remix", + }, + ], + images: [ + { + provider: "SoundCloud", + thumbUrl: "https://i1.sndcdn.com/artworks-000142912000-f05col-t300x300.jpg", + types: [ + "front", + ], + url: "https://i1.sndcdn.com/artworks-000142912000-f05col-original.jpg", + }, + ], + info: { + messages: [], + providers: [ + { + apiUrl: undefined, + id: "leagueoflegends/track/piercing-light-mako-remix", + internalName: "soundcloud", + lookup: { + method: "id", + value: "leagueoflegends/track/piercing-light-mako-remix", + }, + name: "SoundCloud", + url: "https://soundcloud.com/leagueoflegends/piercing-light-mako-remix", + }, + ], + }, + labels: undefined, + media: [ + { + format: "Digital Media", + tracklist: [ + { + artists: [ + { + creditedName: "League of Legends", + externalIds: [ + { + id: "leagueoflegends", + provider: "soundcloud", + type: "artist", + }, + ], + name: "League of Legends", + }, + ], + availableIn: undefined, + isrc: undefined, + length: 291422, + number: 1, + recording: { + externalIds: [ + { + id: "leagueoflegends/piercing-light-mako-remix", + provider: "soundcloud", + type: "track", + }, + ], + }, + title: "Piercing Light (Mako Remix)", + }, + ], + }, + ], + packaging: "None", + releaseDate: { + day: 12, + month: 1, + year: 2016, + }, + title: "Piercing Light (Mako Remix)", + types: [ + "Single", + ], +} +`; diff --git a/testdata/https!/com.soundcloud.api/resolve!url=https!!!soundcloud.com!leagueoflegends!pierc#b6e4ddc b/testdata/https!/com.soundcloud.api/resolve!url=https!!!soundcloud.com!leagueoflegends!pierc#b6e4ddc new file mode 100644 index 00000000..7da95c2f --- /dev/null +++ b/testdata/https!/com.soundcloud.api/resolve!url=https!!!soundcloud.com!leagueoflegends!pierc#b6e4ddc @@ -0,0 +1 @@ +{"kind":"track","id":241674452,"urn":"soundcloud:tracks:241674452","created_at":"2016/01/12 22:46:33 +0000","duration":291422,"commentable":true,"comment_count":949,"sharing":"public","tag_list":" mako","streamable":true,"embeddable_by":"all","purchase_url":null,"purchase_title":null,"genre":"league of legends","title":"Piercing Light (Mako Remix)","description":"","label_name":null,"release":null,"key_signature":null,"isrc":null,"bpm":null,"release_year":null,"release_month":null,"release_day":null,"license":"all-rights-reserved","uri":"https://api.soundcloud.com/tracks/soundcloud:tracks:241674452","user":{"avatar_url":"https://i1.sndcdn.com/avatars-WAFzFJD2fBdDWjkN-EpFPPw-large.jpg","id":20172471,"urn":"soundcloud:users:20172471","kind":"user","permalink_url":"https://soundcloud.com/leagueoflegends?utm_medium=api&utm_campaign=social_sharing&utm_source=id_319451","uri":"https://api.soundcloud.com/users/soundcloud:users:20172471","username":"League of Legends","permalink":"leagueoflegends","created_at":"2012/07/12 20:46:18 +0000","last_modified":"2025/09/16 01:23:36 +0000","first_name":"","last_name":"","full_name":"","city":"","description":"","country":null,"track_count":1363,"public_favorites_count":0,"reposts_count":0,"followers_count":215108,"followings_count":2,"plan":"Pro Unlimited","myspace_name":null,"discogs_name":null,"website_title":"League of Legends","website":"http://www.leagueoflegends.com","comments_count":0,"online":false,"likes_count":0,"playlist_count":65,"subscriptions":[{"product":{"id":"creator-pro-unlimited","name":"Pro Unlimited"}}]},"permalink_url":"https://soundcloud.com/leagueoflegends/piercing-light-mako-remix?utm_medium=api&utm_campaign=social_sharing&utm_source=id_319451","artwork_url":"https://i1.sndcdn.com/artworks-000142912000-f05col-large.jpg","stream_url":"https://api.soundcloud.com/tracks/soundcloud:tracks:241674452/stream","download_url":"https://api.soundcloud.com/tracks/soundcloud:tracks:241674452/download","waveform_url":"https://wave.sndcdn.com/cR3GtXVqVaU7_m.png","available_country_codes":null,"secret_uri":null,"user_favorite":null,"user_playback_count":null,"playback_count":4762338,"download_count":37389,"favoritings_count":56473,"reposts_count":3647,"downloadable":true,"access":"playable","policy":null,"monetization_model":null,"metadata_artist":null} \ No newline at end of file From bf0554bab20c2f78f8db5a3a0b2f5d80b17e7677 Mon Sep 17 00:00:00 2001 From: Lioncat6 <95449321+Lioncat6@users.noreply.github.com> Date: Thu, 23 Oct 2025 01:35:30 -0500 Subject: [PATCH 15/20] Revert resolution change --- providers/SoundCloud/mod.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/providers/SoundCloud/mod.ts b/providers/SoundCloud/mod.ts index 7bfebf23..789dfaf7 100644 --- a/providers/SoundCloud/mod.ts +++ b/providers/SoundCloud/mod.ts @@ -219,7 +219,19 @@ export class SoundCloudReleaseLookup extends ReleaseLookup { @@ -333,7 +345,7 @@ export class SoundCloudReleaseLookup extends ReleaseLookup Date: Thu, 23 Oct 2025 01:52:20 -0500 Subject: [PATCH 16/20] Remove time and timezone from parseSoundcloudTimestamp --- providers/SoundCloud/mod.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/providers/SoundCloud/mod.ts b/providers/SoundCloud/mod.ts index 789dfaf7..dce4b190 100644 --- a/providers/SoundCloud/mod.ts +++ b/providers/SoundCloud/mod.ts @@ -405,8 +405,7 @@ export class SoundCloudReleaseLookup extends ReleaseLookup Date: Thu, 23 Oct 2025 01:54:19 -0500 Subject: [PATCH 17/20] Update snapshot --- providers/SoundCloud/__snapshots__/mod.test.ts.snap | 4 ++-- ...e!url=https!!!soundcloud.com!leagueoflegends!pierc#b6e4ddc | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/providers/SoundCloud/__snapshots__/mod.test.ts.snap b/providers/SoundCloud/__snapshots__/mod.test.ts.snap index e10f3dfa..095debca 100644 --- a/providers/SoundCloud/__snapshots__/mod.test.ts.snap +++ b/providers/SoundCloud/__snapshots__/mod.test.ts.snap @@ -32,14 +32,14 @@ snapshot[`SoundCloud provider > release lookup > track release with downloads en types: [ "front", ], - url: "https://i1.sndcdn.com/artworks-000142912000-f05col-original.jpg", + url: "https://i1.sndcdn.com/artworks-000142912000-f05col-t500x500.jpg", }, ], info: { messages: [], providers: [ { - apiUrl: undefined, + apiUrl: "https://api.soundcloud.com/resolve?url=https%3A%2F%2Fsoundcloud.com%2Fleagueoflegends%2Fpiercing-light-mako-remix", id: "leagueoflegends/track/piercing-light-mako-remix", internalName: "soundcloud", lookup: { diff --git a/testdata/https!/com.soundcloud.api/resolve!url=https!!!soundcloud.com!leagueoflegends!pierc#b6e4ddc b/testdata/https!/com.soundcloud.api/resolve!url=https!!!soundcloud.com!leagueoflegends!pierc#b6e4ddc index 7da95c2f..2adb4d8f 100644 --- a/testdata/https!/com.soundcloud.api/resolve!url=https!!!soundcloud.com!leagueoflegends!pierc#b6e4ddc +++ b/testdata/https!/com.soundcloud.api/resolve!url=https!!!soundcloud.com!leagueoflegends!pierc#b6e4ddc @@ -1 +1 @@ -{"kind":"track","id":241674452,"urn":"soundcloud:tracks:241674452","created_at":"2016/01/12 22:46:33 +0000","duration":291422,"commentable":true,"comment_count":949,"sharing":"public","tag_list":" mako","streamable":true,"embeddable_by":"all","purchase_url":null,"purchase_title":null,"genre":"league of legends","title":"Piercing Light (Mako Remix)","description":"","label_name":null,"release":null,"key_signature":null,"isrc":null,"bpm":null,"release_year":null,"release_month":null,"release_day":null,"license":"all-rights-reserved","uri":"https://api.soundcloud.com/tracks/soundcloud:tracks:241674452","user":{"avatar_url":"https://i1.sndcdn.com/avatars-WAFzFJD2fBdDWjkN-EpFPPw-large.jpg","id":20172471,"urn":"soundcloud:users:20172471","kind":"user","permalink_url":"https://soundcloud.com/leagueoflegends?utm_medium=api&utm_campaign=social_sharing&utm_source=id_319451","uri":"https://api.soundcloud.com/users/soundcloud:users:20172471","username":"League of Legends","permalink":"leagueoflegends","created_at":"2012/07/12 20:46:18 +0000","last_modified":"2025/09/16 01:23:36 +0000","first_name":"","last_name":"","full_name":"","city":"","description":"","country":null,"track_count":1363,"public_favorites_count":0,"reposts_count":0,"followers_count":215108,"followings_count":2,"plan":"Pro Unlimited","myspace_name":null,"discogs_name":null,"website_title":"League of Legends","website":"http://www.leagueoflegends.com","comments_count":0,"online":false,"likes_count":0,"playlist_count":65,"subscriptions":[{"product":{"id":"creator-pro-unlimited","name":"Pro Unlimited"}}]},"permalink_url":"https://soundcloud.com/leagueoflegends/piercing-light-mako-remix?utm_medium=api&utm_campaign=social_sharing&utm_source=id_319451","artwork_url":"https://i1.sndcdn.com/artworks-000142912000-f05col-large.jpg","stream_url":"https://api.soundcloud.com/tracks/soundcloud:tracks:241674452/stream","download_url":"https://api.soundcloud.com/tracks/soundcloud:tracks:241674452/download","waveform_url":"https://wave.sndcdn.com/cR3GtXVqVaU7_m.png","available_country_codes":null,"secret_uri":null,"user_favorite":null,"user_playback_count":null,"playback_count":4762338,"download_count":37389,"favoritings_count":56473,"reposts_count":3647,"downloadable":true,"access":"playable","policy":null,"monetization_model":null,"metadata_artist":null} \ No newline at end of file +{"kind":"track","id":241674452,"urn":"soundcloud:tracks:241674452","created_at":"2016/01/12 22:46:33 +0000","duration":291422,"commentable":true,"comment_count":949,"sharing":"public","tag_list":" mako","streamable":true,"embeddable_by":"all","purchase_url":null,"purchase_title":null,"genre":"league of legends","title":"Piercing Light (Mako Remix)","description":"","label_name":null,"release":null,"key_signature":null,"isrc":null,"bpm":null,"release_year":null,"release_month":null,"release_day":null,"license":"all-rights-reserved","uri":"https://api.soundcloud.com/tracks/soundcloud:tracks:241674452","user":{"avatar_url":"https://i1.sndcdn.com/avatars-WAFzFJD2fBdDWjkN-EpFPPw-large.jpg","id":20172471,"urn":"soundcloud:users:20172471","kind":"user","permalink_url":"https://soundcloud.com/leagueoflegends?utm_medium=api&utm_campaign=social_sharing&utm_source=id_319451","uri":"https://api.soundcloud.com/users/soundcloud:users:20172471","username":"League of Legends","permalink":"leagueoflegends","created_at":"2012/07/12 20:46:18 +0000","last_modified":"2025/09/16 01:23:36 +0000","first_name":"","last_name":"","full_name":"","city":"","description":"","country":null,"track_count":1363,"public_favorites_count":0,"reposts_count":0,"followers_count":215109,"followings_count":2,"plan":"Pro Unlimited","myspace_name":null,"discogs_name":null,"website_title":"League of Legends","website":"http://www.leagueoflegends.com","comments_count":0,"online":false,"likes_count":0,"playlist_count":65,"subscriptions":[{"product":{"id":"creator-pro-unlimited","name":"Pro Unlimited"}}]},"permalink_url":"https://soundcloud.com/leagueoflegends/piercing-light-mako-remix?utm_medium=api&utm_campaign=social_sharing&utm_source=id_319451","artwork_url":"https://i1.sndcdn.com/artworks-000142912000-f05col-large.jpg","stream_url":"https://api.soundcloud.com/tracks/soundcloud:tracks:241674452/stream","download_url":"https://api.soundcloud.com/tracks/soundcloud:tracks:241674452/download","waveform_url":"https://wave.sndcdn.com/cR3GtXVqVaU7_m.png","available_country_codes":null,"secret_uri":null,"user_favorite":null,"user_playback_count":null,"playback_count":4762344,"download_count":37389,"favoritings_count":56473,"reposts_count":3647,"downloadable":true,"access":"playable","policy":null,"monetization_model":null,"metadata_artist":null} \ No newline at end of file From 6aa35c7c33a2afc57643ea66d1c3f33a78fc9308 Mon Sep 17 00:00:00 2001 From: Lioncat6 <95449321+Lioncat6@users.noreply.github.com> Date: Sat, 25 Oct 2025 16:43:28 -0500 Subject: [PATCH 18/20] Simplified date logic --- providers/SoundCloud/mod.ts | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/providers/SoundCloud/mod.ts b/providers/SoundCloud/mod.ts index dce4b190..bc102eab 100644 --- a/providers/SoundCloud/mod.ts +++ b/providers/SoundCloud/mod.ts @@ -393,23 +393,22 @@ export class SoundCloudReleaseLookup extends ReleaseLookup Date: Sat, 25 Oct 2025 16:44:25 -0500 Subject: [PATCH 19/20] Fixed unions --- providers/SoundCloud/api_types.ts | 63 ++++++++++++++++--------------- 1 file changed, 33 insertions(+), 30 deletions(-) diff --git a/providers/SoundCloud/api_types.ts b/providers/SoundCloud/api_types.ts index d3b32a1d..ff9b2979 100644 --- a/providers/SoundCloud/api_types.ts +++ b/providers/SoundCloud/api_types.ts @@ -1,37 +1,40 @@ // Classes from https://github.com/Moebytes/soundcloud.ts // MIT License -export type SoundcloudImageFormats = 't500x500'; -'crop'; -'t300x300'; -'large'; -'t67x67'; -'badge'; -'small'; -'tiny'; -'mini'; +export type SoundcloudImageFormats = + | 't500x500' + | 'crop' + | 't300x300' + | 'large' + | 't67x67' + | 'badge' + | 'small' + | 'tiny' + | 'mini'; -export type SoundcloudLicense = 'no-rights-reserved'; -'all-rights-reserved'; -'cc-by'; -'cc-by-nc'; -'cc-by-nd'; -'cc-by-sa'; -'cc-by-nc-nd'; -'cc-by-nc-sa'; +export type SoundcloudLicense = + | 'no-rights-reserved' + | 'all-rights-reserved' + | 'cc-by' + | 'cc-by-nc' + | 'cc-by-nd' + | 'cc-by-sa' + | 'cc-by-nc-nd' + | 'cc-by-nc-sa'; -export type SoundcloudTrackType = 'original'; -'remix'; -'live'; -'recording'; -'spoken'; -'podcast'; -'demo'; -'in progress'; -'stem'; -'loop'; -'sound effect'; -'sample'; -'other'; +export type SoundcloudTrackType = + | 'original' + | 'remix' + | 'live' + | 'recording' + | 'spoken' + | 'podcast' + | 'demo' + | 'in progress' + | 'stem' + | 'loop' + | 'sound effect' + | 'sample' + | 'other'; export interface SoundcloudTrack { artwork_url: string; From 5b604aa5b11cb3b0b165677f145d5ef12e605465 Mon Sep 17 00:00:00 2001 From: Lioncat6 <95449321+Lioncat6@users.noreply.github.com> Date: Sat, 25 Oct 2025 16:45:13 -0500 Subject: [PATCH 20/20] Fix deno formatting --- providers/SoundCloud/mod.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/providers/SoundCloud/mod.ts b/providers/SoundCloud/mod.ts index bc102eab..fa0d6fca 100644 --- a/providers/SoundCloud/mod.ts +++ b/providers/SoundCloud/mod.ts @@ -402,13 +402,13 @@ export class SoundCloudReleaseLookup extends ReleaseLookup