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= diff --git a/.vscode/settings.json b/.vscode/settings.json index f280afa4..91686c39 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -6,6 +6,7 @@ "Beatport", "brainz", "Brainz", + "commentable", "deezer", "Deezer", "Deno", @@ -37,9 +38,11 @@ "nums", "preact", "preorder", + "reposts", "runtimes", "secondhandsongs", "smartradio", + "soundcloud", "spotify", "streamable", "tabler", diff --git a/musicbrainz/seeding.ts b/musicbrainz/seeding.ts index f7c33ca7..058efe86 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/__snapshots__/mod.test.ts.snap b/providers/SoundCloud/__snapshots__/mod.test.ts.snap new file mode 100644 index 00000000..095debca --- /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-t500x500.jpg", + }, + ], + info: { + messages: [], + providers: [ + { + 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: { + 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/providers/SoundCloud/api_types.ts b/providers/SoundCloud/api_types.ts new file mode 100644 index 00000000..ff9b2979 --- /dev/null +++ b/providers/SoundCloud/api_types.ts @@ -0,0 +1,206 @@ +// 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'; + +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; +} + +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; + 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 SoundcloudVisual { + urn: string; + entry_time: number; + visual_url: string; +} + +export interface SoundcloudCreatorSubscription { + product: { + id: string; + }; +} + +//Custom Classes + +export type ApiError = { + code: number; + message: string; + link: string; + status: string; + 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.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(); + }); +}); diff --git a/providers/SoundCloud/mod.ts b/providers/SoundCloud/mod.ts new file mode 100644 index 00000000..fa0d6fca --- /dev/null +++ b/providers/SoundCloud/mod.ts @@ -0,0 +1,448 @@ +import type { + ArtistCreditName, + Artwork, + ArtworkType, + CountryCode, + EntityId, + HarmonyEntityType, + HarmonyRelease, + HarmonyTrack, + Label, + LinkType, +} from '@/harmonizer/types.ts'; +import { + type ApiAccessToken, + type ApiQueryOptions, + type CacheEntry, + MetadataApiProvider, + 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 { getFromEnv } from '@/utils/config.ts'; +import { isNotNull } from '@/utils/predicate.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'; + +const soundcloudClientId = getFromEnv('HARMONY_SOUNDCLOUD_CLIENT_ID') || ''; +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 releaseLookup = SoundCloudReleaseLookup; + + readonly supportedUrls = new URLPattern({ + hostname: 'soundcloud.com', + pathname: '/:artist/sets/:title', + }); + + readonly trackUrlPattern = new URLPattern({ + hostname: 'soundcloud.com', + pathname: '/:artist/:title', + }); + + readonly artistUrlPattern = new URLPattern({ + 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 != 'artist') { + // All tracks offer free streaming, but some don't have free downloads enabled. + return ['free streaming']; + } + // MB has special handling for Soundcloud artist URLs + return ['discography page']; + } + + override readonly launchDate: PartialDate = { + year: 2008, + month: 10, + day: 17, + }; + + override readonly features: FeatureQualityMap = { + 'cover size': 500, + 'duration precision': DurationPrecision.MS, + 'GTIN lookup': FeatureQuality.MISSING, + 'MBID resolving': FeatureQuality.PRESENT, + }; + + 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 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. + } + } + 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 { + // 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), + }; + } +} + +class SoundCloudResponseError extends ResponseError { + constructor(readonly details: ApiError, url: URL) { + 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 { + rawReleaseUrl: URL | undefined; + + constructReleaseApiUrl(): URL | undefined { + const { method, value } = this.lookup; + let lookupUrl: URL; + const query = new URLSearchParams(); + if (method === 'gtin') { + throw new ProviderError(this.provider.name, 'GTIN lookups are not supported'); + } else { + const entityId = this.provider.parseProviderId(value, 'release'); + const releaseUrl = this.provider.constructUrl(entityId); + lookupUrl = new URL(`resolve`, this.provider.apiBaseUrl); + query.set('url', releaseUrl.href); + } + lookupUrl.search = query.toString(); + return lookupUrl; + } + + 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}'`); + } + } + + convertRawRelease(rawRelease: RawReponse): HarmonyRelease { + 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(this.convertRawTrack.bind(this)) ?? [], + }], + 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) { + if (playlist.tracks?.every((track) => track.downloadable)) { + types.push('free download'); + } + } + } 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) { + artworks.push({ + thumbUrl: artworkUrl.replace(/-(large|medium|small)\./, '-t300x300.'), + url: artworkUrl.replace(/-(large|medium|small)\./, '-t500x500.'), + types: ['front' as ArtworkType], + 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; + if (labelName && playlist.tracks.every((track) => track.label_name === labelName)) { + return [{ + name: labelName, + }]; + } + } + } + } 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_year || release.release_month || release.release_day) { + 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) { + return this.parseSoundcloudTimestamp(release.created_at); + } + return undefined; + } + + parseSoundcloudTimestamp(timestamp: string): PartialDate | undefined { + const segments = timestamp.split(' '); + if (segments.length === 3) { + // Timestamp format "2025/07/30 07:13:31 +0000" + const dateSegments = segments[0].split('/'); + const date: PartialDate = { + year: Number(dateSegments[0]) || undefined, + month: Number(dateSegments[1]) || undefined, + day: Number(dateSegments[2]) || undefined, + }; + 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..51be1986 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). */ 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..2a27e8c9 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 */ 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..2adb4d8f --- /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":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