diff --git a/apps/api/drizzle/0006_grey_korvac.sql b/apps/api/drizzle/0006_grey_korvac.sql new file mode 100644 index 0000000..ef350cf --- /dev/null +++ b/apps/api/drizzle/0006_grey_korvac.sql @@ -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; \ No newline at end of file diff --git a/apps/api/drizzle/meta/0006_snapshot.json b/apps/api/drizzle/meta/0006_snapshot.json new file mode 100644 index 0000000..3843c73 --- /dev/null +++ b/apps/api/drizzle/meta/0006_snapshot.json @@ -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": {} + } +} \ No newline at end of file diff --git a/apps/api/drizzle/meta/_journal.json b/apps/api/drizzle/meta/_journal.json index 922aa76..252d42f 100644 --- a/apps/api/drizzle/meta/_journal.json +++ b/apps/api/drizzle/meta/_journal.json @@ -43,6 +43,13 @@ "when": 1791059998457, "tag": "0005_cooing_thena", "breakpoints": true + }, + { + "idx": 6, + "version": "7", + "when": 1791114009783, + "tag": "0006_grey_korvac", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts index bd4bd16..e5ed5b6 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -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>().notNull().default({}), /** Secret path segment for inbound webhooks (/webhooks/:provider/:secret). */ @@ -138,6 +140,8 @@ export const nowPlaying = pgTable( source: text('source').$type().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(), diff --git a/apps/api/src/providers/parsers.test.ts b/apps/api/src/providers/parsers.test.ts index 974375b..33503e3 100644 --- a/apps/api/src/providers/parsers.test.ts +++ b/apps/api/src/providers/parsers.test.ts @@ -31,6 +31,7 @@ function conn(overrides: Partial): Connection { status: 'active', lastError: null, lastPolledAt: null, + lastActivityAt: null, meta: {}, webhookSecret: null, createdAt: new Date(), diff --git a/apps/api/src/routes/users.ts b/apps/api/src/routes/users.ts index ed7dd75..99076a2 100644 --- a/apps/api/src/routes/users.ts +++ b/apps/api/src/routes/users.ts @@ -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 => { 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, }; }); diff --git a/apps/api/src/services/connections.ts b/apps/api/src/services/connections.ts index 26cb07d..e30792c 100644 --- a/apps/api/src/services/connections.ts +++ b/apps/api/src/services/connections.ts @@ -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, }); diff --git a/apps/api/src/services/nowPlaying.ts b/apps/api/src/services/nowPlaying.ts index b5cdd7d..488fe30 100644 --- a/apps/api/src/services/nowPlaying.ts +++ b/apps/api/src/services/nowPlaying.ts @@ -33,6 +33,7 @@ export async function getNowPlaying(userIds: string[]): Promise 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 { 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, diff --git a/apps/api/src/worker.ts b/apps/api/src/worker.ts index fd6c58f..85a2307 100644 --- a/apps/api/src/worker.ts +++ b/apps/api/src/worker.ts @@ -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)) diff --git a/apps/api/test/connections.test.ts b/apps/api/test/connections.test.ts index 4b0039e..6a05dec 100644 --- a/apps/api/test/connections.test.ts +++ b/apps/api/test/connections.test.ts @@ -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 () => { diff --git a/apps/api/test/history.test.ts b/apps/api/test/history.test.ts index 276e301..d09975b 100644 --- a/apps/api/test/history.test.ts +++ b/apps/api/test/history.test.ts @@ -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' }); + }); +}); diff --git a/apps/api/test/social.test.ts b/apps/api/test/social.test.ts index 727fbe8..4f02aeb 100644 --- a/apps/api/test/social.test.ts +++ b/apps/api/test/social.test.ts @@ -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); - }); }); diff --git a/apps/mobile/src/app/(tabs)/index.tsx b/apps/mobile/src/app/(tabs)/index.tsx index 5e2c4c5..7dda3cc 100644 --- a/apps/mobile/src/app/(tabs)/index.tsx +++ b/apps/mobile/src/app/(tabs)/index.tsx @@ -5,6 +5,7 @@ import { SafeAreaView } from 'react-native-safe-area-context'; import useSWR from 'swr'; import { sortFeed, type ConnectionDTO, type FeedItemDTO, type ProfileDTO } from '@musicshare/shared'; import { NowPlayingCard } from '@/components/NowPlayingCard'; +import { RelinkReminder } from '@/components/RelinkReminder'; import { SharingControls } from '@/components/SharingControls'; import { Body, Button, Card, Title } from '@/components/ui'; import { api } from '@/lib/api'; @@ -78,6 +79,7 @@ export default function FeedScreen() { ListHeaderComponent={ + {offerAppleMusic && ( Connect Apple Music so friends see what you play. One tap. diff --git a/apps/mobile/src/app/(tabs)/settings.tsx b/apps/mobile/src/app/(tabs)/settings.tsx index 409a9f6..2ad7c75 100644 --- a/apps/mobile/src/app/(tabs)/settings.tsx +++ b/apps/mobile/src/app/(tabs)/settings.tsx @@ -13,6 +13,7 @@ import { type NowPlayingProvider, type TrackSearchResultDTO, } from '@musicshare/shared'; +import { RelinkReminder } from '@/components/RelinkReminder'; import { Screen } from '@/components/Screen'; import { SharingControls } from '@/components/SharingControls'; import { Body, Button, Card, Chip, Field, Heading, Row, Title } from '@/components/ui'; @@ -30,6 +31,7 @@ export default function SettingsScreen() { Settings + diff --git a/apps/mobile/src/components/NowPlayingCard.tsx b/apps/mobile/src/components/NowPlayingCard.tsx index 8a16e96..32de971 100644 --- a/apps/mobile/src/components/NowPlayingCard.tsx +++ b/apps/mobile/src/components/NowPlayingCard.tsx @@ -1,5 +1,5 @@ import { router } from 'expo-router'; -import { Pressable, StyleSheet, Text, View } from 'react-native'; +import { Linking, Pressable, StyleSheet, Text, View } from 'react-native'; import { SERVICE_LABELS, timeAgo, type NowPlayingDTO, type StreamingService } from '@musicshare/shared'; import { colors, space } from '@/lib/theme'; import { Avatar } from './Avatar'; @@ -34,7 +34,12 @@ export function NowPlayingCard({ {np.isPlaying && } {np.isPlaying ? 'Listening now' : `Played ${timeAgo(np.updatedAt)}`} - · {np.sourceApp ?? SERVICE_LABELS[np.source]} + Linking.openURL(np.sourceUrl!) : undefined}> + {' · '} + {np.sourceApp ?? SERVICE_LABELS[np.source]} + {onTogglePin && ( Nothing playing right now. )} - + ); } diff --git a/apps/mobile/src/components/RecentPlays.tsx b/apps/mobile/src/components/RecentPlays.tsx index 5bbcc81..03522c5 100644 --- a/apps/mobile/src/components/RecentPlays.tsx +++ b/apps/mobile/src/components/RecentPlays.tsx @@ -1,11 +1,20 @@ import { Image } from 'expo-image'; import { Linking, Pressable, StyleSheet, Text, View } from 'react-native'; -import { openUrl, timeAgo, type RecentPlayDTO, type StreamingService } from '@musicshare/shared'; +import { openUrl, timeAgo, type ProfileDTO, type RecentPlayDTO, type StreamingService } from '@musicshare/shared'; import { showError } from '@/lib/errors'; import { colors, radius, space } from '@/lib/theme'; import { Card, Heading } from './ui'; -export function RecentPlays({ plays, preferred }: { plays: RecentPlayDTO[]; preferred: StreamingService | null | undefined }) { +export function RecentPlays({ + plays, + preferred, + attribution = {}, +}: { + plays: RecentPlayDTO[]; + preferred: StreamingService | null | undefined; + attribution?: ProfileDTO['attribution']; +}) { + const credits = (['lastfm', 'listenbrainz'] as const).filter((s) => attribution[s] && plays.some((p) => p.source === s)); if (plays.length === 0) return null; return ( @@ -35,6 +44,11 @@ export function RecentPlays({ plays, preferred }: { plays: RecentPlayDTO[]; pref {timeAgo(p.playedAt)} ))} + {credits.map((s) => ( + Linking.openURL(attribution[s]!)}> + Some plays via {s === 'lastfm' ? 'Last.fm' : 'ListenBrainz'} + + ))} ); } @@ -47,4 +61,5 @@ const styles = StyleSheet.create({ title: { color: colors.text, fontWeight: '600', fontSize: 14 }, artist: { color: colors.muted, fontSize: 13 }, time: { color: colors.muted, fontSize: 12 }, + credit: { color: colors.muted, fontSize: 12, marginTop: space.sm }, }); diff --git a/apps/mobile/src/components/RelinkReminder.tsx b/apps/mobile/src/components/RelinkReminder.tsx new file mode 100644 index 0000000..5620898 --- /dev/null +++ b/apps/mobile/src/components/RelinkReminder.tsx @@ -0,0 +1,27 @@ +import * as WebBrowser from 'expo-web-browser'; +import useSWR from 'swr'; +import { SERVICE_LABELS, SPOTIFY_LINK_PAGES, isQuietScrobbler, type ConnectionDTO } from '@musicshare/shared'; +import { Body, Button, Card } from './ui'; + +/** Nudge when Last.fm/ListenBrainz has gone quiet: usually Spotify's 6-month link expired. */ +export function RelinkReminder() { + const { data: connections = [] } = useSWR('/connections'); + const quiet = connections.find((c) => isQuietScrobbler(c)); + if (!quiet || !isQuietScrobbler(quiet)) return null; + const name = SERVICE_LABELS[quiet.provider]; + return ( + + + Nothing new from {name} in over a week. If you listen on Spotify, its link to {name} expires every 6 months. + Re-link it to keep sharing. + +