Skip to content

Commit 8832726

Browse files
committed
Add Soundcloud meta provider
1 parent 0111570 commit 8832726

6 files changed

Lines changed: 325 additions & 2 deletions

File tree

packages/core/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
"@babel/core": "7.26.7",
2323
"@babel/preset-env": "7.26.7",
2424
"@babel/preset-react": "7.26.3",
25-
"@distube/ytdl-core": "4.15.9",
25+
"@distube/ytdl-core": "4.16.5",
2626
"@distube/ytpl": "^1.2.1",
2727
"@distube/ytsr": "^2.0.4",
2828
"@electron/remote": "^2.1.2",
@@ -47,6 +47,7 @@
4747
"react-dom": "16.14.0",
4848
"redux": "^4.0.5",
4949
"search-azlyrics": "0.0.3",
50+
"soundcloud.ts": "^0.6.3",
5051
"tough-cookie": "^4.1.4",
5152
"ts-jest": "^27.1.3",
5253
"uuid": "^9.0.0"
Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
import { SoundcloudMetaProvider } from './SoundcloudPlugin';
2+
import { SearchResultsSource } from '../plugins.types';
3+
4+
jest.mock('soundcloud.ts', () => {
5+
return function() {
6+
return {
7+
users: {
8+
search: jest.fn(),
9+
get: jest.fn(),
10+
tracks: jest.fn()
11+
},
12+
tracks: {
13+
search: jest.fn()
14+
}
15+
};
16+
};
17+
});
18+
19+
describe('SoundcloudMetaProvider', () => {
20+
let provider: SoundcloudMetaProvider;
21+
22+
beforeEach(() => {
23+
provider = new SoundcloudMetaProvider();
24+
jest.clearAllMocks();
25+
});
26+
27+
describe('searchForArtists', () => {
28+
it('should convert Soundcloud artists to SearchResultsArtist format', async () => {
29+
const mockArtists = {
30+
collection: [
31+
{
32+
id: 123456,
33+
avatar_url: 'https://example.com/avatar.jpg',
34+
username: 'Test Artist',
35+
permalink_url: 'https://soundcloud.com/testartist'
36+
}
37+
]
38+
};
39+
40+
provider.soundcloud.users.search = jest.fn().mockResolvedValue(mockArtists);
41+
42+
const results = await provider.searchForArtists('Test Artist');
43+
44+
expect(provider.soundcloud.users.search).toHaveBeenCalledWith({ q: 'Test Artist' });
45+
expect(results).toHaveLength(1);
46+
expect(results[0]).toEqual({
47+
id: '123456',
48+
coverImage: 'https://example.com/avatar.jpg',
49+
thumb: 'https://example.com/avatar.jpg',
50+
name: 'Test Artist',
51+
image: 'https://example.com/avatar.jpg',
52+
url: 'https://soundcloud.com/testartist',
53+
resourceUrl: 'https://soundcloud.com/testartist',
54+
source: SearchResultsSource.Soundcloud
55+
});
56+
});
57+
});
58+
59+
describe('searchForTracks', () => {
60+
it('should convert Soundcloud tracks to SearchResultsTrack format', async () => {
61+
const mockTracks = {
62+
collection: [
63+
{
64+
id: 987654,
65+
title: 'Test Track',
66+
user: {
67+
username: 'Test Artist'
68+
}
69+
}
70+
]
71+
};
72+
73+
provider.soundcloud.tracks.search = jest.fn().mockResolvedValue(mockTracks);
74+
75+
const results = await provider.searchForTracks('Test Track');
76+
77+
expect(provider.soundcloud.tracks.search).toHaveBeenCalledWith({ q: 'Test Track' });
78+
expect(results).toHaveLength(1);
79+
expect(results[0]).toEqual({
80+
id: '987654',
81+
title: 'Test Track',
82+
artist: 'Test Artist',
83+
source: SearchResultsSource.Soundcloud
84+
});
85+
});
86+
});
87+
88+
describe('fetchArtistDetails', () => {
89+
it('should fetch artist details and convert to ArtistDetails format', async () => {
90+
const mockArtist = {
91+
id: 123456,
92+
username: 'Test Artist',
93+
description: 'This is a test artist description',
94+
avatar_url: 'https://example.com/avatar.jpg'
95+
};
96+
97+
const mockTracks = [
98+
{
99+
id: 987654,
100+
title: 'Top Track 1',
101+
user: {
102+
username: 'Test Artist'
103+
},
104+
playback_count: 1000,
105+
likes_count: 500
106+
},
107+
{
108+
id: 987655,
109+
title: 'Top Track 2',
110+
user: {
111+
username: 'Test Artist'
112+
},
113+
playback_count: 800,
114+
likes_count: 400
115+
}
116+
];
117+
118+
provider.soundcloud.users.get = jest.fn().mockResolvedValue(mockArtist);
119+
provider.soundcloud.users.tracks = jest.fn().mockResolvedValue(mockTracks);
120+
121+
const result = await provider.fetchArtistDetails('123456');
122+
123+
expect(provider.soundcloud.users.get).toHaveBeenCalledWith('123456');
124+
expect(provider.soundcloud.users.tracks).toHaveBeenCalledWith(123456);
125+
126+
expect(result).toEqual({
127+
id: '123456',
128+
name: 'Test Artist',
129+
description: 'This is a test artist description',
130+
coverImage: 'https://example.com/avatar.jpg',
131+
similar: [],
132+
topTracks: [
133+
{
134+
artist: { name: 'Test Artist' },
135+
title: 'Top Track 1',
136+
playcount: 1000,
137+
listeners: 500
138+
},
139+
{
140+
artist: { name: 'Test Artist' },
141+
title: 'Top Track 2',
142+
playcount: 800,
143+
listeners: 400
144+
}
145+
],
146+
source: SearchResultsSource.Soundcloud
147+
});
148+
});
149+
});
150+
151+
describe('fetchArtistDetailsByName', () => {
152+
it('should search for artist and then fetch details', async () => {
153+
const mockSearch = {
154+
collection: [
155+
{
156+
id: 123456,
157+
username: 'Test Artist'
158+
}
159+
]
160+
};
161+
162+
const mockArtistDetails = {
163+
id: '123456',
164+
name: 'Test Artist',
165+
description: 'Artist description',
166+
coverImage: 'https://example.com/avatar.jpg',
167+
similar: [],
168+
topTracks: [],
169+
source: SearchResultsSource.Soundcloud
170+
};
171+
172+
provider.soundcloud.users.search = jest.fn().mockResolvedValue(mockSearch);
173+
provider.fetchArtistDetails = jest.fn().mockResolvedValue(mockArtistDetails);
174+
175+
const result = await provider.fetchArtistDetailsByName('Test Artist');
176+
177+
expect(provider.soundcloud.users.search).toHaveBeenCalledWith({ q: 'Test Artist' });
178+
expect(provider.fetchArtistDetails).toHaveBeenCalledWith('123456');
179+
expect(result).toEqual(mockArtistDetails);
180+
});
181+
182+
it('should throw an error when artist is not found', async () => {
183+
const mockSearch = {
184+
collection: []
185+
};
186+
187+
provider.soundcloud.users.search = jest.fn().mockResolvedValue(mockSearch);
188+
189+
await expect(provider.fetchArtistDetailsByName('Nonexistent Artist')).rejects.toThrow('Artist not found');
190+
});
191+
});
192+
193+
describe('searchForReleases', () => {
194+
it('should return an empty array', async () => {
195+
const mockTracks = {
196+
collection: [
197+
{
198+
id: 987654,
199+
title: 'Test Track',
200+
user: {
201+
username: 'Test Artist'
202+
}
203+
}
204+
]
205+
};
206+
207+
provider.soundcloud.tracks.search = jest.fn().mockResolvedValue(mockTracks);
208+
209+
const results = await provider.searchForReleases('Test Release');
210+
211+
expect(provider.soundcloud.tracks.search).toHaveBeenCalledWith({ q: 'Test Release' });
212+
expect(results).toEqual([]);
213+
});
214+
});
215+
216+
describe('fetchAlbumDetails and fetchAlbumDetailsByName', () => {
217+
it('should return null for fetchAlbumDetails', async () => {
218+
const result = await provider.fetchAlbumDetails('123', 'master');
219+
expect(result).toBeNull();
220+
});
221+
222+
it('should return null for fetchAlbumDetailsByName', async () => {
223+
const result = await provider.fetchAlbumDetailsByName('Test Album', 'master', 'Test Artist');
224+
expect(result).toBeNull();
225+
});
226+
});
227+
});
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import Soundcloud from 'soundcloud.ts';
2+
import MetaProvider from '../metaProvider';
3+
import { AlbumDetails, ArtistDetails, SearchResultsAlbum, SearchResultsArtist, SearchResultsSource, SearchResultsTrack } from '../plugins.types';
4+
5+
export class SoundcloudMetaProvider extends MetaProvider {
6+
soundcloud: Soundcloud;
7+
constructor() {
8+
super();
9+
this.name = 'Soundcloud Meta Provider';
10+
this.sourceName = 'Soundcloud Meta Provider';
11+
this.description = 'Metadata provider that uses Soundcloud as a source.';
12+
this.searchName = 'Soundcloud';
13+
this.image = null;
14+
this.soundcloud = new Soundcloud();
15+
}
16+
17+
async searchForArtists(query: string): Promise<Array<SearchResultsArtist>> {
18+
const searchResults = await this.soundcloud.users.search({q: query});
19+
return searchResults.collection.map((result) => ({
20+
id: result.id.toString(),
21+
coverImage: result.avatar_url,
22+
thumb: result.avatar_url,
23+
name: result.username,
24+
image: result.avatar_url,
25+
url: result.permalink_url,
26+
resourceUrl: result.permalink_url,
27+
source: SearchResultsSource.Soundcloud
28+
}));
29+
}
30+
31+
async searchForReleases(query: string): Promise<Array<SearchResultsAlbum>> {
32+
const searchResults = await this.soundcloud.tracks.search({q: query});
33+
return [];
34+
}
35+
36+
async searchForTracks(query: string): Promise<Array<SearchResultsTrack>> {
37+
const searchResults = await this.soundcloud.tracks.search({q: query});
38+
return searchResults.collection.map((result) => ({
39+
id: result.id.toString(),
40+
title: result.title,
41+
artist: result.user.username,
42+
source: SearchResultsSource.Soundcloud
43+
}));
44+
}
45+
46+
async fetchArtistAlbums(artistId: string): Promise<Array<SearchResultsAlbum>> {
47+
return [];
48+
}
49+
50+
async fetchArtistDetailsByName(artistName: string): Promise<ArtistDetails> {
51+
52+
const searchResults = await this.soundcloud.users.search({ q: artistName });
53+
const artist = searchResults.collection[0];
54+
if (artist) {
55+
return this.fetchArtistDetails(artist.id.toString());
56+
}
57+
throw new Error('Artist not found');
58+
}
59+
60+
async fetchArtistDetails(artistId: string): Promise<ArtistDetails> {
61+
const user = await this.soundcloud.users.get(artistId);
62+
const topTracks = await this.soundcloud.users.tracks(user.id);
63+
64+
if (user) {
65+
return {
66+
id: user.id.toString(),
67+
name: user.username,
68+
description: user.description,
69+
coverImage: user.avatar_url,
70+
similar: [],
71+
topTracks: topTracks.map((track) => ({
72+
artist: { name: track.user.username },
73+
title: track.title,
74+
playcount: track.playback_count,
75+
listeners: track.likes_count
76+
})),
77+
source: SearchResultsSource.Soundcloud
78+
};
79+
}
80+
}
81+
82+
async fetchAlbumDetails(albumId: string, albumType: ('master' | 'release'), resourceUrl?: string): Promise<AlbumDetails> {
83+
return null;
84+
}
85+
86+
async fetchAlbumDetailsByName(albumName: string, albumType?: ('master' | 'release'), artist?: string): Promise<AlbumDetails> {
87+
return null;
88+
}
89+
90+
searchAll(query: string): Promise<{ artists: Array<SearchResultsArtist>; releases: Array<SearchResultsAlbum>; tracks: Array<SearchResultsTrack>; }> {
91+
throw new Error('Method not implemented.');
92+
}
93+
}

packages/core/src/plugins/meta/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@ export { default as BandcampMetaProvider } from './bandcamp';
55
export { default as iTunesPodcastMetaProvider } from './itunespodcast';
66
export { default as iTunesMusicMetaProvider } from './itunesmusic';
77
export { SpotifyMetaProvider } from './spotify';
8+
export {SoundcloudMetaProvider} from './SoundcloudPlugin';

packages/core/src/plugins/plugins.types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export enum SearchResultsSource {
88
iTunesPodcast = 'iTunesPodcast',
99
iTunesMusic = 'iTunesMusic',
1010
Spotify = 'Spotify',
11+
Soundcloud = 'Soundcloud',
1112
}
1213

1314
export enum AlbumType {

packages/ui/lib/components/SearchBox/index.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import Button from '../Button';
1313
type SearchBarProps = {
1414
loading: boolean;
1515
disabled: boolean;
16-
isFocused: boolean
16+
isFocused: boolean;
1717
placeholder: string;
1818
searchProviders: SearchProviderOption[];
1919
searchHistory: string[];

0 commit comments

Comments
 (0)