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
+1
View File
@@ -14,6 +14,7 @@ export const toConnectionDTO = (c: Connection): ConnectionDTO => ({
status: c.status,
lastError: c.lastError,
lastPolledAt: c.lastPolledAt?.toISOString() ?? null,
lastActivityAt: c.lastActivityAt?.toISOString() ?? null,
webhookUrl: c.webhookSecret ? `${env.API_PUBLIC_URL}/webhooks/${c.provider}/${c.webhookSecret}` : null,
});
+3 -1
View File
@@ -33,6 +33,7 @@ export async function getNowPlaying(userIds: string[]): Promise<NowPlayingDTO[]>
isPlaying: np.isPlaying && !(PUSH_SOURCES.has(np.source) && Date.now() - np.updatedAt.getTime() > DEVICE_STALE_MS),
source: np.source,
sourceApp: np.sourceApp,
sourceUrl: np.sourceUrl,
progressMs: np.progressMs,
updatedAt: np.updatedAt.toISOString(),
}))
@@ -96,7 +97,7 @@ export async function applyPollResult(
userId: string,
source: NowPlayingProvider,
result: PollResult | null,
opts: { sourceApp?: string | null } = {},
opts: { sourceApp?: string | null; sourceUrl?: string | null } = {},
): Promise<boolean> {
const [current] = await db.select().from(schema.nowPlaying).where(eq(schema.nowPlaying.userId, userId));
const now = new Date();
@@ -122,6 +123,7 @@ export async function applyPollResult(
trackId: track.id,
source,
sourceApp: opts.sourceApp ?? null,
sourceUrl: opts.sourceUrl ?? null,
isPlaying: true,
progressMs: result.progressMs,
updatedAt: now,