Skip to content
This repository was archived by the owner on Feb 28, 2026. It is now read-only.

Commit 2932ac6

Browse files
authored
Merge pull request #42 from NuclearPlayer/feat/playlist-providers
Playlist providers
2 parents a70fc24 + 4fcc766 commit 2932ac6

27 files changed

+879
-84
lines changed

packages/i18n/src/locales/en_US.json

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@
6666
"play": "Play",
6767
"addToQueue": "Add to queue",
6868
"saveLocally": "Save locally",
69-
"readOnlyBadge": "External playlist",
69+
"readOnlyBadge": "{{source}}",
7070
"readOnlyTooltip": "This playlist is from {{source}} and is read-only. Save a local copy to edit it.",
7171
"trackCount": "{{count}} track",
7272
"trackCount_other": "{{count}} tracks",
@@ -79,7 +79,12 @@
7979
"exportSuccess": "Playlist exported",
8080
"exportError": "Failed to export playlist",
8181
"importSuccess": "Playlist imported",
82-
"importError": "Failed to import playlist"
82+
"importError": "Failed to import playlist",
83+
"importUrl": "Import from URL",
84+
"importUrlTitle": "Import playlist from URL",
85+
"importUrlPlaceholder": "Paste a playlist URL...",
86+
"importUrlNoProvider": "No plugin can handle this URL. Install a plugin that supports this service.",
87+
"importUrlImporting": "Importing..."
8388
},
8489
"search": {
8590
"title": "Search",

packages/player/src/integration-tests/StreamResolution.test-wrapper.tsx

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,31 @@ export const StreamResolutionWrapper = {
4141
await waitFor(() => {
4242
expect(useSoundStore.getState().src).not.toBeNull();
4343
});
44+
45+
const audio = document.querySelector('audio');
46+
if (audio) {
47+
audio.dispatchEvent(new Event('canplay'));
48+
}
49+
50+
await waitFor(() => {
51+
const item = useQueueStore.getState().getCurrentItem();
52+
expect(item?.status).toBe('success');
53+
});
54+
},
55+
56+
simulateCanPlay() {
57+
const audio = document.querySelector('audio');
58+
if (audio) {
59+
audio.dispatchEvent(new Event('canplay'));
60+
}
61+
},
62+
63+
async waitForSuccess() {
64+
this.simulateCanPlay();
65+
await waitFor(() => {
66+
const item = useQueueStore.getState().getCurrentItem();
67+
expect(item?.status).toBe('success');
68+
});
4469
},
4570

4671
async waitForError() {

packages/player/src/integration-tests/playlist-export-import.test.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,8 +65,7 @@ describe('Playlist export → import round trip', () => {
6565
(fs.readTextFile as Mock).mockResolvedValueOnce(capturedFileContent);
6666

6767
await PlaylistsWrapper.mount();
68-
await PlaylistsWrapper.importButton.click();
69-
await PlaylistsWrapper.importJsonOption.click();
68+
await PlaylistsWrapper.import.fromJson.click();
7069

7170
await vi.waitFor(() => {
7271
expect(PlaylistsWrapper.cards).toHaveLength(1);

packages/player/src/integration-tests/stream-resolution.test.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,12 @@ describe('Stream Resolution Integration', () => {
107107

108108
resolveStream!(createMockStream('yt-1'));
109109

110+
await waitFor(() => {
111+
expect(useSoundStore.getState().src).not.toBeNull();
112+
});
113+
114+
StreamResolutionWrapper.simulateCanPlay();
115+
110116
await waitFor(() => {
111117
const item = StreamResolutionWrapper.getCurrentQueueItem();
112118
expect(item?.status).toBe('success');

packages/player/src/routeTree.gen.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { Route as PlaylistsPlaylistIdRouteImport } from './routes/playlists/$pla
1717
import { Route as FavoritesTracksRouteImport } from './routes/favorites/tracks'
1818
import { Route as FavoritesArtistsRouteImport } from './routes/favorites/artists'
1919
import { Route as FavoritesAlbumsRouteImport } from './routes/favorites/albums'
20+
import { Route as PlaylistsImportProviderIdRouteImport } from './routes/playlists/import.$providerId'
2021
import { Route as ArtistProviderIdArtistIdRouteImport } from './routes/artist/$providerId/$artistId'
2122
import { Route as AlbumProviderIdAlbumIdRouteImport } from './routes/album/$providerId/$albumId'
2223

@@ -60,6 +61,12 @@ const FavoritesAlbumsRoute = FavoritesAlbumsRouteImport.update({
6061
path: '/favorites/albums',
6162
getParentRoute: () => rootRouteImport,
6263
} as any)
64+
const PlaylistsImportProviderIdRoute =
65+
PlaylistsImportProviderIdRouteImport.update({
66+
id: '/playlists/import/$providerId',
67+
path: '/playlists/import/$providerId',
68+
getParentRoute: () => rootRouteImport,
69+
} as any)
6370
const ArtistProviderIdArtistIdRoute =
6471
ArtistProviderIdArtistIdRouteImport.update({
6572
id: '/artist/$providerId/$artistId',
@@ -83,6 +90,7 @@ export interface FileRoutesByFullPath {
8390
'/playlists': typeof PlaylistsIndexRoute
8491
'/album/$providerId/$albumId': typeof AlbumProviderIdAlbumIdRoute
8592
'/artist/$providerId/$artistId': typeof ArtistProviderIdArtistIdRoute
93+
'/playlists/import/$providerId': typeof PlaylistsImportProviderIdRoute
8694
}
8795
export interface FileRoutesByTo {
8896
'/': typeof IndexRoute
@@ -95,6 +103,7 @@ export interface FileRoutesByTo {
95103
'/playlists': typeof PlaylistsIndexRoute
96104
'/album/$providerId/$albumId': typeof AlbumProviderIdAlbumIdRoute
97105
'/artist/$providerId/$artistId': typeof ArtistProviderIdArtistIdRoute
106+
'/playlists/import/$providerId': typeof PlaylistsImportProviderIdRoute
98107
}
99108
export interface FileRoutesById {
100109
__root__: typeof rootRouteImport
@@ -108,6 +117,7 @@ export interface FileRoutesById {
108117
'/playlists/': typeof PlaylistsIndexRoute
109118
'/album/$providerId/$albumId': typeof AlbumProviderIdAlbumIdRoute
110119
'/artist/$providerId/$artistId': typeof ArtistProviderIdArtistIdRoute
120+
'/playlists/import/$providerId': typeof PlaylistsImportProviderIdRoute
111121
}
112122
export interface FileRouteTypes {
113123
fileRoutesByFullPath: FileRoutesByFullPath
@@ -122,6 +132,7 @@ export interface FileRouteTypes {
122132
| '/playlists'
123133
| '/album/$providerId/$albumId'
124134
| '/artist/$providerId/$artistId'
135+
| '/playlists/import/$providerId'
125136
fileRoutesByTo: FileRoutesByTo
126137
to:
127138
| '/'
@@ -134,6 +145,7 @@ export interface FileRouteTypes {
134145
| '/playlists'
135146
| '/album/$providerId/$albumId'
136147
| '/artist/$providerId/$artistId'
148+
| '/playlists/import/$providerId'
137149
id:
138150
| '__root__'
139151
| '/'
@@ -146,6 +158,7 @@ export interface FileRouteTypes {
146158
| '/playlists/'
147159
| '/album/$providerId/$albumId'
148160
| '/artist/$providerId/$artistId'
161+
| '/playlists/import/$providerId'
149162
fileRoutesById: FileRoutesById
150163
}
151164
export interface RootRouteChildren {
@@ -159,6 +172,7 @@ export interface RootRouteChildren {
159172
PlaylistsIndexRoute: typeof PlaylistsIndexRoute
160173
AlbumProviderIdAlbumIdRoute: typeof AlbumProviderIdAlbumIdRoute
161174
ArtistProviderIdArtistIdRoute: typeof ArtistProviderIdArtistIdRoute
175+
PlaylistsImportProviderIdRoute: typeof PlaylistsImportProviderIdRoute
162176
}
163177

164178
declare module '@tanstack/react-router' {
@@ -219,6 +233,13 @@ declare module '@tanstack/react-router' {
219233
preLoaderRoute: typeof FavoritesAlbumsRouteImport
220234
parentRoute: typeof rootRouteImport
221235
}
236+
'/playlists/import/$providerId': {
237+
id: '/playlists/import/$providerId'
238+
path: '/playlists/import/$providerId'
239+
fullPath: '/playlists/import/$providerId'
240+
preLoaderRoute: typeof PlaylistsImportProviderIdRouteImport
241+
parentRoute: typeof rootRouteImport
242+
}
222243
'/artist/$providerId/$artistId': {
223244
id: '/artist/$providerId/$artistId'
224245
path: '/artist/$providerId/$artistId'
@@ -247,6 +268,7 @@ const rootRouteChildren: RootRouteChildren = {
247268
PlaylistsIndexRoute: PlaylistsIndexRoute,
248269
AlbumProviderIdAlbumIdRoute: AlbumProviderIdAlbumIdRoute,
249270
ArtistProviderIdArtistIdRoute: ArtistProviderIdArtistIdRoute,
271+
PlaylistsImportProviderIdRoute: PlaylistsImportProviderIdRoute,
250272
}
251273
export const routeTree = rootRouteImport
252274
._addFileChildren(rootRouteChildren)
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { createFileRoute } from '@tanstack/react-router';
2+
import { z } from 'zod';
3+
4+
import { PlaylistImport } from '../../views/PlaylistImport/PlaylistImport';
5+
6+
export const Route = createFileRoute('/playlists/import/$providerId')({
7+
component: PlaylistImport,
8+
validateSearch: z.object({
9+
url: z.string(),
10+
}),
11+
});

packages/player/src/test/builders/PlaylistBuilder.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,13 @@ export class PlaylistBuilder {
8383
return this;
8484
}
8585

86+
withArtwork(url: string): this {
87+
this.playlist.artwork = {
88+
items: [{ url }],
89+
};
90+
return this;
91+
}
92+
8693
build(): Playlist {
8794
return cloneDeep(this.playlist);
8895
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import type { Playlist } from '@nuclearplayer/model';
2+
import type { PlaylistProvider } from '@nuclearplayer/plugin-sdk';
3+
4+
export class PlaylistProviderBuilder {
5+
private provider: PlaylistProvider;
6+
7+
constructor() {
8+
this.provider = {
9+
id: 'test-playlist-provider',
10+
kind: 'playlists',
11+
name: 'Test Playlist Provider',
12+
matchesUrl: () => false,
13+
fetchPlaylistByUrl: async () => ({
14+
id: 'fetched-playlist',
15+
name: 'Fetched Playlist',
16+
createdAtIso: new Date().toISOString(),
17+
lastModifiedIso: new Date().toISOString(),
18+
isReadOnly: false,
19+
items: [],
20+
}),
21+
};
22+
}
23+
24+
withId(id: string): this {
25+
this.provider.id = id;
26+
return this;
27+
}
28+
29+
withName(name: string): this {
30+
this.provider.name = name;
31+
return this;
32+
}
33+
34+
withMatchesUrl(matchesUrl: PlaylistProvider['matchesUrl']): this {
35+
this.provider.matchesUrl = matchesUrl;
36+
return this;
37+
}
38+
39+
withFetchPlaylistByUrl(
40+
fetchPlaylistByUrl: PlaylistProvider['fetchPlaylistByUrl'],
41+
): this {
42+
this.provider.fetchPlaylistByUrl = fetchPlaylistByUrl;
43+
return this;
44+
}
45+
46+
thatMatchesUrl(urlPattern: string): this {
47+
this.provider.matchesUrl = (url: string) => url.includes(urlPattern);
48+
return this;
49+
}
50+
51+
thatReturnsPlaylist(playlist: Playlist): this {
52+
this.provider.fetchPlaylistByUrl = async () => playlist;
53+
return this;
54+
}
55+
56+
build(): PlaylistProvider {
57+
return this.provider;
58+
}
59+
}

packages/player/src/views/PlaylistDetail/PlaylistDetail.test-wrapper.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,9 @@ export const PlaylistDetailWrapper = {
4949
get emptyState() {
5050
return screen.queryByTestId('empty-state');
5151
},
52+
get artwork() {
53+
return screen.queryByTestId('playlist-artwork');
54+
},
5255

5356
actionsButton: {
5457
get element() {

packages/player/src/views/PlaylistDetail/PlaylistDetail.test.tsx

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import * as dialog from '@tauri-apps/plugin-dialog';
22
import * as fs from '@tauri-apps/plugin-fs';
33
import { screen } from '@testing-library/react';
4+
import userEvent from '@testing-library/user-event';
45
import { type Mock } from 'vitest';
56

67
import { PlayerBarWrapper } from '../../integration-tests/PlayerBar.test-wrapper';
@@ -70,7 +71,7 @@ describe('PlaylistDetail view', () => {
7071
.withId('external-playlist')
7172
.withName('External Playlist')
7273
.readOnly()
73-
.withOrigin({ provider: 'spotify', id: 'ext-1' })
74+
.withOrigin({ provider: 'example-music', id: 'ext-1' })
7475
.withTrackNames(['Track A']),
7576
);
7677

@@ -79,6 +80,27 @@ describe('PlaylistDetail view', () => {
7980
expect(PlaylistDetailWrapper.readOnlyBadge).toBeInTheDocument();
8081
});
8182

83+
it('shows tooltip on read-only badge hover', async () => {
84+
PlaylistDetailWrapper.seedPlaylist(
85+
new PlaylistBuilder()
86+
.withId('external-playlist')
87+
.withName('External Playlist')
88+
.readOnly()
89+
.withOrigin({ provider: 'example-music', id: 'ext-1' })
90+
.withTrackNames(['Track A']),
91+
);
92+
93+
await PlaylistDetailWrapper.mount('external-playlist');
94+
95+
await userEvent.hover(screen.getByTestId('read-only-badge'));
96+
97+
await vi.waitFor(() => {
98+
expect(screen.getByRole('tooltip')).toHaveTextContent(
99+
'This playlist is from example-music and is read-only. Save a local copy to edit it.',
100+
);
101+
});
102+
});
103+
82104
it('deletes playlist and navigates back to playlists list', async () => {
83105
await PlaylistDetailWrapper.mount('test-playlist');
84106

@@ -139,7 +161,7 @@ describe('PlaylistDetail view', () => {
139161
.withId('readonly-playlist')
140162
.withName('Read-Only Playlist')
141163
.readOnly()
142-
.withOrigin({ provider: 'spotify', id: 'ext-1' })
164+
.withOrigin({ provider: 'example-music', id: 'ext-1' })
143165
.withTrackNames(['Track A', 'Track B']),
144166
);
145167

@@ -162,7 +184,7 @@ describe('PlaylistDetail view', () => {
162184
.withId('readonly-playlist')
163185
.withName('Read-Only')
164186
.readOnly()
165-
.withOrigin({ provider: 'spotify', id: 'ext-1' })
187+
.withOrigin({ provider: 'example-music', id: 'ext-1' })
166188
.withTrackNames(['Track A', 'Track B']),
167189
);
168190

@@ -192,6 +214,22 @@ describe('PlaylistDetail view', () => {
192214
expect(queueItems[2]?.title).toBe('So What');
193215
});
194216

217+
it('displays playlist artwork when available', async () => {
218+
PlaylistDetailWrapper.seedPlaylist(
219+
new PlaylistBuilder()
220+
.withId('art-playlist')
221+
.withName('Playlist With Art')
222+
.withArtwork('https://example.com/cover.jpg')
223+
.withTrackNames(['Track A']),
224+
);
225+
226+
await PlaylistDetailWrapper.mount('art-playlist');
227+
228+
const artwork = screen.getByTestId('playlist-artwork');
229+
expect(artwork).toBeInTheDocument();
230+
expect(artwork).toHaveAttribute('src', 'https://example.com/cover.jpg');
231+
});
232+
195233
describe('JSON export', () => {
196234
it('exports playlist as JSON file via save dialog', async () => {
197235
const expectedPath = '/downloads/Test Playlist.json';

0 commit comments

Comments
 (0)