Unlock everything, add a donation link, credit Last.fm, nudge re-linking

- Every feature is free for everyone (the plan system stays for later);
  remaining limits are abuse guards
- Optional website donation link (NEXT_PUBLIC_DONATION_URL). Not shown in
  the mobile apps, where tips to the developer generally need in-app
  purchase
- Last.fm/ListenBrainz-sourced plays link to the user's profile, as
  Last.fm's API terms require
- Connections track when they last saw activity; a scrobbler silent for
  over a week prompts re-linking Spotify (its link expires every 6 months)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MzFywujuH9TiuXbr9vjoX8
This commit is contained in:
2026-10-04 08:13:54 -04:00
co-authored by Claude Opus 5.5
parent 91a78a8ad4
commit 63d618f0e3
33 changed files with 1277 additions and 38 deletions
+3 -3
View File
@@ -29,7 +29,7 @@ describe('username connections', () => {
expect(res.statusCode).toBe(404);
});
it('enforces the per-plan connection limit, but reconnecting is free', async () => {
it('lets everyone connect several services, and reconnecting never counts twice', async () => {
vi.stubGlobal(
'fetch',
routeFetch([
@@ -44,8 +44,8 @@ describe('username connections', () => {
expect(ok.statusCode).toBe(200);
const again = await app().inject({ method: 'POST', url: '/connections/lastfm', headers: bearer(token), payload: { username: 'rj' } });
expect(again.statusCode).toBe(200);
const over = await app().inject({ method: 'POST', url: '/connections/listenbrainz', headers: bearer(token), payload: { username: 'x' } });
expect(over.statusCode).toBe(402);
const fourth = await app().inject({ method: 'POST', url: '/connections/listenbrainz', headers: bearer(token), payload: { username: 'x' } });
expect(fourth.statusCode).toBe(200);
});
it('disconnect removes the connection and its schedule', async () => {