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
+1
View File
@@ -0,0 +1 @@
ALTER TABLE "users" ADD COLUMN "services" text[];
File diff suppressed because it is too large Load Diff
+7
View File
@@ -99,6 +99,13 @@
"when": 1791308012441,
"tag": "0013_reactions",
"breakpoints": true
},
{
"idx": 14,
"version": "7",
"when": 1792025011207,
"tag": "0014_user_services",
"breakpoints": true
}
]
}
+2
View File
@@ -31,6 +31,8 @@ export const users = pgTable('users', {
/** Path of the uploaded picture (`/avatars/<id>?v=<version>`), resolved against API_PUBLIC_URL when sent. */
avatarUrl: varchar('avatar_url', { length: 500 }),
preferredService: text('preferred_service').$type<StreamingService>(),
/** The services they use, so songs only offer those; null until they choose (offer all). */
services: text('services').array().$type<StreamingService[]>(),
/** Billing plan. Kept in sync by the billing webhook (RevenueCat/Stripe) — see lib/entitlements.ts */
/** The first account on a server becomes admin (moderation). */
role: text('role').$type<'user' | 'admin'>().notNull().default('user'),
+1
View File
@@ -24,6 +24,7 @@ export const toMeDTO = (u: User): MeDTO => ({
pendingEmail: u.pendingEmail,
bio: u.bio,
preferredService: u.preferredService,
services: u.services,
role: u.role,
onboardedAt: u.onboardedAt?.toISOString() ?? null,
sharingPausedUntil: isSharingPaused(u) ? u.sharingPausedUntil!.toISOString() : null,
+13
View File
@@ -115,3 +115,16 @@ describe('onboarding flag', () => {
expect(again.onboardedAt).toBe(first.onboardedAt);
});
});
describe('music services', () => {
it('saves the services you use, without duplicates, and clears them', async () => {
const { token, user } = await createUser(app(), 'listener');
expect(user.services).toBeNull();
const patch = (payload: object) => app().inject({ method: 'PATCH', url: '/me', headers: bearer(token), payload });
const saved = (await patch({ services: ['spotify', 'tidal', 'spotify'], preferredService: 'tidal' })).json();
expect(saved.services).toEqual(['spotify', 'tidal']);
expect(saved.preferredService).toBe('tidal');
expect((await patch({ services: ['napster'] })).statusCode).toBe(400);
expect((await patch({ services: null })).json().services).toBeNull();
});
});
+28 -6
View File
@@ -10,6 +10,7 @@ import {
type ConnectionDTO,
type MeDTO,
type NowPlayingProvider,
type StreamingService,
} from '@musicshare/shared';
import { Avatar } from '@/components/Avatar';
import { RelinkReminder } from '@/components/RelinkReminder';
@@ -34,7 +35,7 @@ export default function SettingsScreen() {
<ProfileSection />
<SharingControls />
<RelinkReminder />
<PreferredService />
<MyServices />
<ThisPhone />
<Connections />
<FeaturedPicker />
@@ -118,23 +119,44 @@ function AvatarPicker() {
);
}
function PreferredService() {
/** The services you use: songs offer only these, and open in the main one. */
function MyServices() {
const { me, setMe } = useSession();
const choose = async (service: (typeof STREAMING_SERVICES)[number]) => {
const mine = me?.services ?? [];
const save = async (services: StreamingService[], preferredService: StreamingService | null) => {
try {
setMe(await api.updateMe({ preferredService: service }));
setMe(await api.updateMe({ services: services.length ? services : null, preferredService }));
} catch (err) {
showError(err);
}
};
const toggle = (s: StreamingService) => {
const services = mine.includes(s) ? mine.filter((x) => x !== s) : [...mine, s];
// The main service has to be one you use; fall back to the first one left.
const main = me?.preferredService && services.includes(me.preferredService) ? me.preferredService : (services[0] ?? null);
save(services, main);
};
return (
<Card>
<Heading>Open songs in</Heading>
<Heading>Your music services</Heading>
<Body muted style={styles.small}>
Songs only show buttons for the ones you pick.
</Body>
<Row>
{STREAMING_SERVICES.map((s) => (
<Chip key={s} label={SERVICE_LABELS[s]} selected={me?.preferredService === s} onPress={() => choose(s)} />
<Chip key={s} label={SERVICE_LABELS[s]} selected={mine.includes(s)} onPress={() => toggle(s)} />
))}
</Row>
{mine.length > 1 && (
<>
<Heading>Open songs in</Heading>
<Row>
{mine.map((s) => (
<Chip key={s} label={SERVICE_LABELS[s]} selected={me?.preferredService === s} onPress={() => save(mine, s)} />
))}
</Row>
</>
)}
</Card>
);
}
+1 -1
View File
@@ -22,7 +22,7 @@ export default function ListenAppStep() {
if (!app) return;
setBusy(true);
try {
if (app.service) setMe(await api.updateMe({ preferredService: app.service }));
if (app.service) setMe(await api.updateMe({ preferredService: app.service, services: [app.service] }));
router.push({ pathname: '/onboarding/share', params: { app: app.id } });
} catch (err) {
showError(err);
+2 -2
View File
@@ -1,6 +1,6 @@
import { Image } from 'expo-image';
import { Linking, Pressable, Share, StyleSheet, Text, View } from 'react-native';
import { SERVICE_LABELS, STREAMING_SERVICES, openUrl, shareText, trackUrl, type StreamingService, type TrackDTO } from '@musicshare/shared';
import { SERVICE_LABELS, openUrl, shareText, trackUrl, viewerServices, type StreamingService, type TrackDTO } from '@musicshare/shared';
import { WEB_URL } from '@/lib/config';
import { useSession } from '@/lib/session';
import { showError } from '@/lib/errors';
@@ -23,7 +23,7 @@ function shareTrack(track: TrackDTO, from: string | undefined) {
export function TrackCard({ track, preferred }: { track: TrackDTO; preferred: StreamingService | null | undefined }) {
const { me } = useSession();
const primary = preferred ? openUrl(track, preferred) : null;
const others = STREAMING_SERVICES.filter((s) => s !== preferred);
const others = viewerServices(me).filter((s) => s !== preferred);
return (
<View style={styles.track}>
+2
View File
@@ -222,6 +222,8 @@ input:focus, textarea:focus, select:focus {
.card-header .pin { flex-shrink: 0; }
.form { display: flex; flex-direction: column; gap: 0.8rem; }
.form label { display: flex; flex-direction: column; gap: 0.35rem; font-weight: 600; font-size: 0.9rem; }
.services-picker { border: 0; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 0.35rem; }
.services-picker legend { font-weight: 600; font-size: 0.9rem; padding: 0; margin-bottom: 0.35rem; }
.form label.checkbox { flex-direction: row; align-items: center; gap: 0.5rem; font-weight: 500; }
/* Now-playing cards take their colour from the album art. */
+22 -1
View File
@@ -13,6 +13,7 @@ import {
type MeDTO,
type NowPlayingProvider,
type ProfileDTO,
type StreamingService,
type TrackSearchResultDTO,
type UserSummaryDTO,
} from '@musicshare/shared';
@@ -58,10 +59,18 @@ function ProfileForm({ me }: { me: MeDTO }) {
e.preventDefault();
const f = new FormData(e.currentTarget);
const str = (k: string) => (f.get(k) as string).trim() || null;
const services = f.getAll('services') as StreamingService[];
const preferred = str('preferredService') as StreamingService | null;
try {
const updated = await api<MeDTO>('/me', {
method: 'PATCH',
body: { displayName: str('displayName'), bio: str('bio'), preferredService: str('preferredService') },
body: {
displayName: str('displayName'),
bio: str('bio'),
services: services.length ? services : null,
// The main service has to be one you use.
preferredService: preferred && (!services.length || services.includes(preferred)) ? preferred : (services[0] ?? null),
},
});
await mutate(updated, { revalidate: false });
setError(null);
@@ -84,6 +93,18 @@ function ProfileForm({ me }: { me: MeDTO }) {
Bio
<textarea name="bio" defaultValue={me.bio ?? ''} maxLength={280} rows={2} />
</label>
<fieldset className="services-picker">
<legend>Your music services</legend>
<span className="muted">Songs only show links for the ones you pick.</span>
<div className="row">
{STREAMING_SERVICES.map((s) => (
<label key={s} className="checkbox">
<input type="checkbox" name="services" value={s} defaultChecked={me.services?.includes(s)} />
{SERVICE_LABELS[s]}
</label>
))}
</div>
</fieldset>
<label>
Open songs in
<select name="preferredService" defaultValue={me.preferredService ?? ''}>
+4 -2
View File
@@ -1,6 +1,7 @@
'use client';
import { STREAMING_SERVICES, SERVICE_LABELS, openUrl, type StreamingService, type TrackDTO } from '@musicshare/shared';
import { SERVICE_LABELS, openUrl, viewerServices, type StreamingService, type TrackDTO } from '@musicshare/shared';
import { useMe } from '@/lib/api';
import { ShareButton } from './ShareButton';
/**
@@ -20,7 +21,8 @@ export function TrackCard({
/** When the page already shows the artwork big. */
hideArtwork?: boolean;
}) {
const others = STREAMING_SERVICES.filter((s) => s !== preferred);
const { me } = useMe();
const others = viewerServices(me).filter((s) => s !== preferred);
const primary = preferred ? openUrl(track, preferred) : null;
return (
+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}`);