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();
});
});