Pick the music services you use
CI / check (push) Canceled after 0s

Songs offered links to all five services. Settings now has "Your music
services": pick the ones you use and songs only show those, with the
main one as the big button. Until someone picks, they still see all of
them. Onboarding sets it to the app you said you listen with.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-14 20:45:29 -04:00
co-authored by Claude Opus 5.5
parent 08631589e3
commit d00eb6674e
16 changed files with 1607 additions and 13 deletions
+2
View File
@@ -26,6 +26,8 @@ export interface MeDTO extends UserSummaryDTO {
pendingEmail: string | null;
bio: string | null;
preferredService: StreamingService | null;
/** The services they use; null until chosen, meaning all of them. */
services: StreamingService[] | null;
role: 'user' | 'admin';
/** When onboarding was finished; null means show it. */
onboardedAt: string | null;
+6
View File
@@ -46,6 +46,12 @@ export const updateMeSchema = z.object({
displayName: z.string().trim().max(64).nullable().optional(),
bio: z.string().trim().max(280).nullable().optional(),
preferredService: z.enum(STREAMING_SERVICES).nullable().optional(),
services: z
.array(z.enum(STREAMING_SERVICES))
.max(STREAMING_SERVICES.length)
.transform((s) => [...new Set(s)])
.nullable()
.optional(),
/** Mark onboarding as finished. */
onboarded: z.literal(true).optional(),
});
+14 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { STREAMING_SERVICES, isQuietScrobbler, openUrl, searchUrl } from './services';
import { STREAMING_SERVICES, isQuietScrobbler, openUrl, searchUrl, viewerServices } from './services';
const track = { title: 'Midnight City', artist: 'M83', links: { spotify: 'https://open.spotify.com/track/abc' } };
@@ -40,3 +40,16 @@ describe('isQuietScrobbler', () => {
expect(isQuietScrobbler(conn('spotify', 30), now)).toBe(false);
});
});
describe('viewerServices', () => {
it('offers every service, main one first, until they choose', () => {
expect(viewerServices(null)).toEqual([...STREAMING_SERVICES]);
expect(viewerServices({ preferredService: 'tidal', services: null })[0]).toBe('tidal');
expect(viewerServices({ preferredService: 'tidal', services: null })).toHaveLength(STREAMING_SERVICES.length);
});
it('offers only the services they use', () => {
expect(viewerServices({ preferredService: 'apple_music', services: ['spotify', 'apple_music'] })).toEqual(['apple_music', 'spotify']);
expect(viewerServices({ preferredService: null, services: ['deezer'] })).toEqual(['deezer']);
});
});
+12
View File
@@ -47,6 +47,18 @@ export const SERVICE_COLORS: Record<StreamingService | NowPlayingProvider, strin
android_media_session: '#5b4cf0',
};
/**
* The services to offer for a song, main one first: the ones the viewer uses,
* or all of them if they haven't said.
*/
export function viewerServices(
viewer: { preferredService: StreamingService | null; services: StreamingService[] | null } | null | undefined,
): StreamingService[] {
const chosen = viewer?.services?.length ? viewer.services : STREAMING_SERVICES;
const main = viewer?.preferredService;
return main ? [main, ...chosen.filter((s) => s !== main)] : [...chosen];
}
/** Fallback when we have no direct link: a search on that service. */
export function searchUrl(service: StreamingService, title: string, artist: string): string {
const q = encodeURIComponent(`${artist} ${title}`);