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
+4
View File
@@ -80,6 +80,8 @@ export const connections = pgTable(
status: text('status').$type<'active' | 'error'>().notNull().default('active'),
lastError: text('last_error'),
lastPolledAt: timestamp('last_polled_at', { withTimezone: true }),
/** Last time this connection reported something playing; used to spot links that silently broke. */
lastActivityAt: timestamp('last_activity_at', { withTimezone: true }),
/** Provider-specific state, e.g. Jellyfin server URL or discovered Plex servers. */
meta: jsonb('meta').$type<Record<string, unknown>>().notNull().default({}),
/** Secret path segment for inbound webhooks (/webhooks/:provider/:secret). */
@@ -138,6 +140,8 @@ export const nowPlaying = pgTable(
source: text('source').$type<NowPlayingProvider>().notNull(),
/** App name when a phone reported it, e.g. "Spotify". */
sourceApp: text('source_app'),
/** Where the data came from, for attribution (e.g. the user's Last.fm profile). */
sourceUrl: text('source_url'),
isPlaying: boolean('is_playing').notNull(),
progressMs: integer('progress_ms'),
startedAt: timestamp('started_at', { withTimezone: true }).notNull().defaultNow(),
+1
View File
@@ -31,6 +31,7 @@ function conn(overrides: Partial<Connection>): Connection {
status: 'active',
lastError: null,
lastPolledAt: null,
lastActivityAt: null,
meta: {},
webhookSecret: null,
createdAt: new Date(),
+13 -3
View File
@@ -1,6 +1,6 @@
import { and, count, eq, ilike, isNotNull, or } from 'drizzle-orm';
import { and, count, eq, ilike, inArray, isNotNull, or } from 'drizzle-orm';
import type { FastifyInstance } from 'fastify';
import { sortFeed, type FeedItemDTO, type ProfileDTO } from '@musicshare/shared';
import { sortFeed, sourceProfileUrl, type FeedItemDTO, type ProfileDTO } from '@musicshare/shared';
import { db, schema } from '../db';
import { requireUser } from '../lib/auth';
import { entitlementsFor } from '../lib/entitlements';
@@ -31,7 +31,7 @@ export async function userRoutes(app: FastifyInstance) {
app.get<{ Params: { username: string } }>('/users/:username', async (req): Promise<ProfileDTO> => {
const user = await findByUsername(req.params.username);
const viewer = req.user;
const [[followers], [following], [follow], [featured], nowPlaying, recent] = await Promise.all([
const [[followers], [following], [follow], [featured], nowPlaying, recent, scrobblers] = await Promise.all([
db.select({ n: count() }).from(schema.follows).where(eq(schema.follows.followeeId, user.id)),
db.select({ n: count() }).from(schema.follows).where(eq(schema.follows.followerId, user.id)),
viewer
@@ -43,7 +43,16 @@ export async function userRoutes(app: FastifyInstance) {
db.select().from(schema.featuredSongs).where(eq(schema.featuredSongs.userId, user.id)),
getNowPlaying([user.id]),
getRecentPlays(user.id),
db
.select({ provider: schema.connections.provider, externalUserId: schema.connections.externalUserId })
.from(schema.connections)
.where(and(eq(schema.connections.userId, user.id), inArray(schema.connections.provider, ['lastfm', 'listenbrainz']))),
]);
const attribution: ProfileDTO['attribution'] = {};
for (const c of scrobblers) {
const url = sourceProfileUrl(c.provider, c.externalUserId);
if (url && (c.provider === 'lastfm' || c.provider === 'listenbrainz')) attribution[c.provider] = url;
}
const featuredTrack = featured ? (await getTrackDTOs([featured.trackId])).get(featured.trackId) : undefined;
await watchUsers([user.id]);
@@ -60,6 +69,7 @@ export async function userRoutes(app: FastifyInstance) {
: null,
nowPlaying: nowPlaying[0] ?? null,
recent,
attribution,
};
});
+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,
+6 -2
View File
@@ -1,5 +1,6 @@
import { Worker } from 'bullmq';
import { and, eq, inArray } from 'drizzle-orm';
import { sourceProfileUrl } from '@musicshare/shared';
import { db, schema } from './db';
import { entitlementsFor } from './lib/entitlements';
import { closeConnections } from './lib/lifecycle';
@@ -43,10 +44,13 @@ async function pollConnection(connectionId: string) {
try {
const result = await provider.poll(conn);
const playing = await applyPollResult(user.id, conn.provider, result);
const playing = await applyPollResult(user.id, conn.provider, result, {
sourceUrl: sourceProfileUrl(conn.provider, conn.externalUserId),
});
const now = new Date();
await db
.update(schema.connections)
.set({ lastPolledAt: new Date(), lastError: null })
.set({ lastPolledAt: now, lastError: null, ...(result ? { lastActivityAt: now } : {}) })
.where(eq(schema.connections.id, conn.id));
const seconds = (await isWatched(user.id))