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
+2
View File
@@ -0,0 +1,2 @@
ALTER TABLE "connections" ADD COLUMN "last_activity_at" timestamp with time zone;--> statement-breakpoint
ALTER TABLE "now_playing" ADD COLUMN "source_url" text;
+996
View File
@@ -0,0 +1,996 @@
{
"id": "e78b7958-04e3-4e67-bf2e-1d40028ac400",
"prevId": "96e26cc1-ef9f-4bdb-9889-54a20705d43f",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.connections": {
"name": "connections",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"provider": {
"name": "provider",
"type": "text",
"primaryKey": false,
"notNull": true
},
"external_user_id": {
"name": "external_user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"display_name": {
"name": "display_name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"access_token_enc": {
"name": "access_token_enc",
"type": "text",
"primaryKey": false,
"notNull": false
},
"refresh_token_enc": {
"name": "refresh_token_enc",
"type": "text",
"primaryKey": false,
"notNull": false
},
"token_expires_at": {
"name": "token_expires_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'active'"
},
"last_error": {
"name": "last_error",
"type": "text",
"primaryKey": false,
"notNull": false
},
"last_polled_at": {
"name": "last_polled_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"last_activity_at": {
"name": "last_activity_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"meta": {
"name": "meta",
"type": "jsonb",
"primaryKey": false,
"notNull": true,
"default": "'{}'::jsonb"
},
"webhook_secret": {
"name": "webhook_secret",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"connections_user_provider_idx": {
"name": "connections_user_provider_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "provider",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"connections_user_id_users_id_fk": {
"name": "connections_user_id_users_id_fk",
"tableFrom": "connections",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"connections_webhook_secret_unique": {
"name": "connections_webhook_secret_unique",
"nullsNotDistinct": false,
"columns": [
"webhook_secret"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.devices": {
"name": "devices",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"token_hash": {
"name": "token_hash",
"type": "text",
"primaryKey": false,
"notNull": true
},
"platform": {
"name": "platform",
"type": "text",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "varchar(100)",
"primaryKey": false,
"notNull": true
},
"last_seen_at": {
"name": "last_seen_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"devices_user_idx": {
"name": "devices_user_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"devices_user_id_users_id_fk": {
"name": "devices_user_id_users_id_fk",
"tableFrom": "devices",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"devices_token_hash_unique": {
"name": "devices_token_hash_unique",
"nullsNotDistinct": false,
"columns": [
"token_hash"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.featured_songs": {
"name": "featured_songs",
"schema": "",
"columns": {
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": true,
"notNull": true
},
"track_id": {
"name": "track_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"comment": {
"name": "comment",
"type": "varchar(280)",
"primaryKey": false,
"notNull": false
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"featured_songs_user_id_users_id_fk": {
"name": "featured_songs_user_id_users_id_fk",
"tableFrom": "featured_songs",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"featured_songs_track_id_tracks_id_fk": {
"name": "featured_songs_track_id_tracks_id_fk",
"tableFrom": "featured_songs",
"tableTo": "tracks",
"columnsFrom": [
"track_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.follows": {
"name": "follows",
"schema": "",
"columns": {
"follower_id": {
"name": "follower_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"followee_id": {
"name": "followee_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"pinned_at": {
"name": "pinned_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"follows_followee_idx": {
"name": "follows_followee_idx",
"columns": [
{
"expression": "followee_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"follows_follower_id_users_id_fk": {
"name": "follows_follower_id_users_id_fk",
"tableFrom": "follows",
"tableTo": "users",
"columnsFrom": [
"follower_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"follows_followee_id_users_id_fk": {
"name": "follows_followee_id_users_id_fk",
"tableFrom": "follows",
"tableTo": "users",
"columnsFrom": [
"followee_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"follows_follower_id_followee_id_pk": {
"name": "follows_follower_id_followee_id_pk",
"columns": [
"follower_id",
"followee_id"
]
}
},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.listens": {
"name": "listens",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"track_id": {
"name": "track_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"source": {
"name": "source",
"type": "text",
"primaryKey": false,
"notNull": true
},
"started_at": {
"name": "started_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"listens_user_started_idx": {
"name": "listens_user_started_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "started_at",
"isExpression": false,
"asc": false,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"listens_user_id_users_id_fk": {
"name": "listens_user_id_users_id_fk",
"tableFrom": "listens",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"listens_track_id_tracks_id_fk": {
"name": "listens_track_id_tracks_id_fk",
"tableFrom": "listens",
"tableTo": "tracks",
"columnsFrom": [
"track_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.now_playing": {
"name": "now_playing",
"schema": "",
"columns": {
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": true,
"notNull": true
},
"track_id": {
"name": "track_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"source": {
"name": "source",
"type": "text",
"primaryKey": false,
"notNull": true
},
"source_app": {
"name": "source_app",
"type": "text",
"primaryKey": false,
"notNull": false
},
"source_url": {
"name": "source_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"is_playing": {
"name": "is_playing",
"type": "boolean",
"primaryKey": false,
"notNull": true
},
"progress_ms": {
"name": "progress_ms",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"started_at": {
"name": "started_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"now_playing_track_idx": {
"name": "now_playing_track_idx",
"columns": [
{
"expression": "track_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"now_playing_user_id_users_id_fk": {
"name": "now_playing_user_id_users_id_fk",
"tableFrom": "now_playing",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"now_playing_track_id_tracks_id_fk": {
"name": "now_playing_track_id_tracks_id_fk",
"tableFrom": "now_playing",
"tableTo": "tracks",
"columnsFrom": [
"track_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.sessions": {
"name": "sessions",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"sessions_user_idx": {
"name": "sessions_user_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"sessions_user_id_users_id_fk": {
"name": "sessions_user_id_users_id_fk",
"tableFrom": "sessions",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.track_links": {
"name": "track_links",
"schema": "",
"columns": {
"track_id": {
"name": "track_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"service": {
"name": "service",
"type": "text",
"primaryKey": false,
"notNull": true
},
"external_id": {
"name": "external_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"url": {
"name": "url",
"type": "text",
"primaryKey": false,
"notNull": true
},
"match_type": {
"name": "match_type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"track_links_external_idx": {
"name": "track_links_external_idx",
"columns": [
{
"expression": "service",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "external_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"track_links_track_id_tracks_id_fk": {
"name": "track_links_track_id_tracks_id_fk",
"tableFrom": "track_links",
"tableTo": "tracks",
"columnsFrom": [
"track_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"track_links_track_id_service_pk": {
"name": "track_links_track_id_service_pk",
"columns": [
"track_id",
"service"
]
}
},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.tracks": {
"name": "tracks",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"isrc": {
"name": "isrc",
"type": "varchar(12)",
"primaryKey": false,
"notNull": false
},
"match_key": {
"name": "match_key",
"type": "text",
"primaryKey": false,
"notNull": true
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true
},
"artist": {
"name": "artist",
"type": "text",
"primaryKey": false,
"notNull": true
},
"album": {
"name": "album",
"type": "text",
"primaryKey": false,
"notNull": false
},
"duration_ms": {
"name": "duration_ms",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"artwork_url": {
"name": "artwork_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"links_resolved_at": {
"name": "links_resolved_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"tracks_isrc_idx": {
"name": "tracks_isrc_idx",
"columns": [
{
"expression": "isrc",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"tracks_match_key_idx": {
"name": "tracks_match_key_idx",
"columns": [
{
"expression": "match_key",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.users": {
"name": "users",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"username": {
"name": "username",
"type": "varchar(32)",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "varchar(320)",
"primaryKey": false,
"notNull": true
},
"password_hash": {
"name": "password_hash",
"type": "text",
"primaryKey": false,
"notNull": true
},
"display_name": {
"name": "display_name",
"type": "varchar(64)",
"primaryKey": false,
"notNull": false
},
"bio": {
"name": "bio",
"type": "varchar(280)",
"primaryKey": false,
"notNull": false
},
"avatar_url": {
"name": "avatar_url",
"type": "varchar(500)",
"primaryKey": false,
"notNull": false
},
"preferred_service": {
"name": "preferred_service",
"type": "text",
"primaryKey": false,
"notNull": false
},
"sharing_paused_until": {
"name": "sharing_paused_until",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"plan": {
"name": "plan",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'free'"
},
"plan_expires_at": {
"name": "plan_expires_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"users_username_unique": {
"name": "users_username_unique",
"nullsNotDistinct": false,
"columns": [
"username"
]
},
"users_email_unique": {
"name": "users_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
+7
View File
@@ -43,6 +43,13 @@
"when": 1791059998457,
"tag": "0005_cooing_thena",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1791114009783,
"tag": "0006_grey_korvac",
"breakpoints": true
}
]
}
+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))
+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 () => {
+11
View File
@@ -71,3 +71,14 @@ describe('listening history', () => {
expect(await db.select().from(schema.listens)).toHaveLength(0);
});
});
describe('attribution', () => {
it('links Last.fm-sourced plays back to the Last.fm profile (required by Last.fm)', async () => {
const { user } = await createUser(app(), 'bob');
await db.insert(schema.connections).values({ userId: user.id, provider: 'lastfm', externalUserId: 'RJ', displayName: 'RJ' });
await applyPollResult(user.id, 'lastfm', playing('Disorder'), { sourceUrl: 'https://www.last.fm/user/RJ' });
const profile = (await app().inject({ url: '/users/bob' })).json();
expect(profile.nowPlaying.sourceUrl).toBe('https://www.last.fm/user/RJ');
expect(profile.attribution).toEqual({ lastfm: 'https://www.last.fm/user/RJ' });
});
});
+15 -16
View File
@@ -1,6 +1,5 @@
import { eq } from 'drizzle-orm';
import { describe, expect, it } from 'vitest';
import { db, schema } from '../src/db';
import { PLAN_ENTITLEMENTS } from '@musicshare/shared';
import { applyPollResult } from '../src/services/nowPlaying';
import { bearer, createUser, follow, useTestApp } from './helpers';
@@ -80,15 +79,21 @@ describe('pinned friends', () => {
expect(await feedOrder(alice.token)).toEqual(['*bob', '*dave', 'erin', 'carol']);
});
it('enforces the plan limit with 402, and plus raises it', async () => {
const { alice } = await setup();
for (const name of ['bob', 'carol', 'dave']) expect((await pin(alice.token, name)).statusCode).toBe(200);
const fourth = await pin(alice.token, 'erin');
expect(fourth.statusCode).toBe(402);
expect(fourth.json().error).toBe('You can pin up to 3 friends on your plan');
it('caps pins as an abuse guard (402 past the limit), and unpinning frees a slot', async () => {
const max = PLAN_ENTITLEMENTS.free.maxPinnedFriends;
const alice = await createUser(app(), 'alice');
const names = Array.from({ length: max + 1 }, (_, i) => `friend${i}`);
for (const name of names) {
await createUser(app(), name);
await follow(app(), alice.token, name);
}
for (const name of names.slice(0, max)) expect((await pin(alice.token, name)).statusCode).toBe(200);
const over = await pin(alice.token, names[max]!);
expect(over.statusCode).toBe(402);
expect(over.json().error).toBe(`You can pin up to ${max} friends on your plan`);
await db.update(schema.users).set({ plan: 'plus' }).where(eq(schema.users.id, alice.user.id));
expect((await pin(alice.token, 'erin')).statusCode).toBe(200);
await pin(alice.token, names[0]!, 'DELETE');
expect((await pin(alice.token, names[max]!)).statusCode).toBe(200);
});
it('pinning is idempotent and does not count twice', async () => {
@@ -108,10 +113,4 @@ describe('pinned friends', () => {
expect((await app().inject({ url: '/users/bob', headers: bearer(alice.token) })).json().isPinned).toBe(false);
});
it('unpin frees a slot', async () => {
const { alice } = await setup();
for (const name of ['bob', 'carol', 'dave']) await pin(alice.token, name);
await pin(alice.token, 'bob', 'DELETE');
expect((await pin(alice.token, 'erin')).statusCode).toBe(200);
});
});