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:
@@ -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;
|
||||
@@ -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": {}
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,13 @@
|
||||
"when": 1791059998457,
|
||||
"tag": "0005_cooing_thena",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "7",
|
||||
"when": 1791114009783,
|
||||
"tag": "0006_grey_korvac",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -31,6 +31,7 @@ function conn(overrides: Partial<Connection>): Connection {
|
||||
status: 'active',
|
||||
lastError: null,
|
||||
lastPolledAt: null,
|
||||
lastActivityAt: null,
|
||||
meta: {},
|
||||
webhookSecret: null,
|
||||
createdAt: new Date(),
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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={
|
||||
<View style={styles.header}>
|
||||
<SharingControls compact />
|
||||
<RelinkReminder />
|
||||
{offerAppleMusic && (
|
||||
<Card>
|
||||
<Body>Connect Apple Music so friends see what you play. One tap.</Body>
|
||||
|
||||
@@ -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() {
|
||||
<Title>Settings</Title>
|
||||
<ProfileSection />
|
||||
<SharingControls />
|
||||
<RelinkReminder />
|
||||
<PreferredService />
|
||||
<ThisPhone />
|
||||
<Connections />
|
||||
|
||||
@@ -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 && <View style={styles.dot} />}
|
||||
<Text style={[styles.status, np.isPlaying && { color: colors.live }]} numberOfLines={1}>
|
||||
{np.isPlaying ? 'Listening now' : `Played ${timeAgo(np.updatedAt)}`}
|
||||
<Text style={styles.via}> · {np.sourceApp ?? SERVICE_LABELS[np.source]}</Text>
|
||||
<Text
|
||||
style={[styles.via, np.sourceUrl && styles.viaLink]}
|
||||
onPress={np.sourceUrl ? () => Linking.openURL(np.sourceUrl!) : undefined}>
|
||||
{' · '}
|
||||
{np.sourceApp ?? SERVICE_LABELS[np.source]}
|
||||
</Text>
|
||||
</Text>
|
||||
{onTogglePin && (
|
||||
<Pressable
|
||||
@@ -61,5 +66,6 @@ const styles = StyleSheet.create({
|
||||
dot: { width: 8, height: 8, borderRadius: 4, backgroundColor: colors.live },
|
||||
status: { color: colors.muted, fontSize: 13 },
|
||||
via: { color: colors.muted },
|
||||
viaLink: { textDecorationLine: 'underline' },
|
||||
pin: { color: colors.muted, fontSize: 20, marginLeft: space.xs },
|
||||
});
|
||||
|
||||
@@ -108,7 +108,7 @@ export function ProfileView({ username, edges }: { username: string; edges?: ('t
|
||||
<Body muted>Nothing playing right now.</Body>
|
||||
)}
|
||||
|
||||
<RecentPlays plays={profile.recent} preferred={me?.preferredService} />
|
||||
<RecentPlays plays={profile.recent} preferred={me?.preferredService} attribution={profile.attribution} />
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<Card style={{ gap: 0 }}>
|
||||
@@ -35,6 +44,11 @@ export function RecentPlays({ plays, preferred }: { plays: RecentPlayDTO[]; pref
|
||||
<Text style={styles.time}>{timeAgo(p.playedAt)}</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
{credits.map((s) => (
|
||||
<Text key={s} style={styles.credit} onPress={() => Linking.openURL(attribution[s]!)}>
|
||||
Some plays via <Text style={{ textDecorationLine: 'underline' }}>{s === 'lastfm' ? 'Last.fm' : 'ListenBrainz'}</Text>
|
||||
</Text>
|
||||
))}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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 },
|
||||
});
|
||||
|
||||
@@ -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<ConnectionDTO[]>('/connections');
|
||||
const quiet = connections.find((c) => isQuietScrobbler(c));
|
||||
if (!quiet || !isQuietScrobbler(quiet)) return null;
|
||||
const name = SERVICE_LABELS[quiet.provider];
|
||||
return (
|
||||
<Card>
|
||||
<Body>
|
||||
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.
|
||||
</Body>
|
||||
<Button
|
||||
small
|
||||
variant="primary"
|
||||
title={`Re-link Spotify on ${name}`}
|
||||
onPress={() => WebBrowser.openBrowserAsync(SPOTIFY_LINK_PAGES[quiet.provider])}
|
||||
style={{ alignSelf: 'flex-start' }}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1 +1,3 @@
|
||||
NEXT_PUBLIC_API_URL=http://localhost:4000
|
||||
# Optional donation link shown on the website (e.g. https://ko-fi.com/yourname)
|
||||
NEXT_PUBLIC_DONATION_URL=
|
||||
|
||||
@@ -18,7 +18,9 @@ COPY --from=prepare /app/tsconfig.base.json ./tsconfig.base.json
|
||||
# NEXT_PUBLIC_* values are inlined into the browser bundle at build time,
|
||||
# so the API URL is a build argument, not a runtime setting.
|
||||
ARG NEXT_PUBLIC_API_URL=http://localhost:4000
|
||||
ARG NEXT_PUBLIC_DONATION_URL=
|
||||
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL \
|
||||
NEXT_PUBLIC_DONATION_URL=$NEXT_PUBLIC_DONATION_URL \
|
||||
NEXT_TELEMETRY_DISABLED=1
|
||||
RUN npx turbo run build --filter=@musicshare/web
|
||||
|
||||
|
||||
@@ -54,6 +54,8 @@ input:focus, textarea:focus, select:focus { outline: 2px solid var(--accent); ou
|
||||
.nav-links a { text-decoration: none; color: var(--muted); }
|
||||
.nav-links a:hover { color: var(--text); }
|
||||
.nav-links a.btn-primary { color: var(--accent-text); }
|
||||
.nav-links a.support-link { color: #ff7aa8; }
|
||||
@media (max-width: 480px) { .nav-links a.support-link { display: none; } }
|
||||
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
@@ -117,6 +119,7 @@ a.recent-row:hover .recent-title { color: var(--accent); }
|
||||
.recent-text { display: flex; flex-direction: column; min-width: 0; flex: 1; }
|
||||
.recent-text > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.recent-title { font-weight: 600; }
|
||||
.credit { margin: 0.6rem 0 0; font-size: 0.8rem; }
|
||||
.recent-time { font-size: 0.8rem; white-space: nowrap; }
|
||||
.artwork-sm.artwork-empty { display: grid; place-items: center; background: var(--surface-2); color: var(--muted); }
|
||||
.picked { display: flex; gap: 0.75rem; align-items: center; }
|
||||
|
||||
@@ -5,13 +5,16 @@ import { Suspense, useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import {
|
||||
SERVICE_LABELS,
|
||||
SPOTIFY_LINK_PAGES,
|
||||
STREAMING_SERVICES,
|
||||
isQuietScrobbler,
|
||||
type ConnectionDTO,
|
||||
type MeDTO,
|
||||
type NowPlayingProvider,
|
||||
type TrackSearchResultDTO,
|
||||
} from '@musicshare/shared';
|
||||
import { SharingControls } from '@/components/SharingControls';
|
||||
import { SupportCard } from '@/components/SupportLink';
|
||||
import { api, fetcher, useMe } from '@/lib/api';
|
||||
import { timeAgo } from '@musicshare/shared';
|
||||
import { useDebounced } from '@/lib/useDebounced';
|
||||
@@ -36,6 +39,7 @@ function Settings() {
|
||||
<SharingControls />
|
||||
<Connections />
|
||||
<FeaturedPicker />
|
||||
<SupportCard />
|
||||
<DangerZone />
|
||||
</div>
|
||||
);
|
||||
@@ -189,6 +193,16 @@ function Connections() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{connections.filter((c) => isQuietScrobbler(c)).map((c) => (
|
||||
<div className="notice" key={`quiet-${c.provider}`}>
|
||||
Nothing new from {SERVICE_LABELS[c.provider]} in over a week. If you listen on Spotify, its link to{' '}
|
||||
{SERVICE_LABELS[c.provider]} expires every 6 months.{' '}
|
||||
<a href={SPOTIFY_LINK_PAGES[c.provider as 'lastfm' | 'listenbrainz']} target="_blank" rel="noreferrer">
|
||||
Re-link Spotify
|
||||
</a>
|
||||
.
|
||||
</div>
|
||||
))}
|
||||
{spotifyLimited && (
|
||||
<div className="notice">
|
||||
<strong>Spotify sign-in is limited during beta.</strong> Spotify only lets a few accounts sign in to new apps
|
||||
|
||||
@@ -98,7 +98,7 @@ export default function ProfilePage() {
|
||||
<p className="muted">Nothing playing right now.</p>
|
||||
)}
|
||||
|
||||
<RecentPlays plays={profile.recent} preferred={preferred} />
|
||||
<RecentPlays plays={profile.recent} preferred={preferred} attribution={profile.attribution} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { api, useMe } from '@/lib/api';
|
||||
import { SupportLink } from './SupportLink';
|
||||
|
||||
export function Nav() {
|
||||
const { me, mutate } = useMe();
|
||||
@@ -18,6 +19,7 @@ export function Nav() {
|
||||
musicshare
|
||||
</Link>
|
||||
<div className="nav-links">
|
||||
<SupportLink />
|
||||
{me ? (
|
||||
<>
|
||||
<Link href="/">Feed</Link>
|
||||
|
||||
@@ -31,7 +31,16 @@ export function NowPlayingCard({
|
||||
<span className="row">
|
||||
<span className={`status ${np.isPlaying ? 'status-live' : ''}`}>
|
||||
{np.isPlaying ? 'Listening now' : `Played ${timeAgo(np.updatedAt)}`}
|
||||
<span className="muted"> via {np.sourceApp ?? SERVICE_LABELS[np.source]}</span>
|
||||
<span className="muted">
|
||||
{' via '}
|
||||
{np.sourceUrl ? (
|
||||
<a href={np.sourceUrl} target="_blank" rel="noreferrer">
|
||||
{np.sourceApp ?? SERVICE_LABELS[np.source]}
|
||||
</a>
|
||||
) : (
|
||||
(np.sourceApp ?? SERVICE_LABELS[np.source])
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
{onTogglePin && (
|
||||
<button
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
'use client';
|
||||
|
||||
import { SERVICE_LABELS, openUrl, timeAgo, type RecentPlayDTO, type StreamingService } from '@musicshare/shared';
|
||||
import { SERVICE_LABELS, openUrl, timeAgo, type ProfileDTO, type RecentPlayDTO, type StreamingService } from '@musicshare/shared';
|
||||
|
||||
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 (
|
||||
<section className="card">
|
||||
@@ -39,6 +48,14 @@ export function RecentPlays({ plays, preferred }: { plays: RecentPlayDTO[]; pref
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
{credits.map((s) => (
|
||||
<p key={s} className="muted credit">
|
||||
Some plays via{' '}
|
||||
<a href={attribution[s]} target="_blank" rel="noreferrer">
|
||||
{s === 'lastfm' ? 'Last.fm' : 'ListenBrainz'}
|
||||
</a>
|
||||
</p>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Donation link (Ko-fi, Buy Me a Coffee, GitHub Sponsors…). Web only: in the
|
||||
* iOS and Android apps, tips to the developer must go through in-app
|
||||
* purchase in most countries, so the apps don't show this.
|
||||
*/
|
||||
export const DONATION_URL = process.env.NEXT_PUBLIC_DONATION_URL || null;
|
||||
|
||||
export function SupportLink({ className = 'support-link' }: { className?: string }) {
|
||||
if (!DONATION_URL) return null;
|
||||
return (
|
||||
<a className={className} href={DONATION_URL} target="_blank" rel="noreferrer">
|
||||
♥ Support musicshare
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
export function SupportCard() {
|
||||
if (!DONATION_URL) return null;
|
||||
return (
|
||||
<section className="card">
|
||||
<h2>Support musicshare</h2>
|
||||
<p className="muted">
|
||||
musicshare is free, with every feature unlocked for everyone. If you enjoy it, a donation helps cover servers and
|
||||
keeps it that way.
|
||||
</p>
|
||||
<SupportLink className="btn btn-primary" />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -87,6 +87,8 @@ services:
|
||||
args:
|
||||
# The URL the *browser* uses to reach the API.
|
||||
NEXT_PUBLIC_API_URL: ${PUBLIC_API_URL:-http://localhost:4000}
|
||||
# Optional donation link shown on the website, e.g. DONATION_URL=https://ko-fi.com/you
|
||||
NEXT_PUBLIC_DONATION_URL: ${DONATION_URL:-}
|
||||
image: musicshare-web:local
|
||||
ports:
|
||||
- "${WEB_PORT:-3737}:3737"
|
||||
|
||||
@@ -8,6 +8,7 @@ function item(username: string, opts: { pinnedAt?: string; isPlaying?: boolean;
|
||||
isPlaying: opts.isPlaying ?? false,
|
||||
source: 'spotify',
|
||||
sourceApp: null,
|
||||
sourceUrl: null,
|
||||
progressMs: null,
|
||||
updatedAt: opts.updatedAt ?? '2026-01-01T00:00:00.000Z',
|
||||
pinnedAt: opts.pinnedAt ?? null,
|
||||
|
||||
@@ -36,6 +36,8 @@ export interface NowPlayingDTO {
|
||||
source: NowPlayingProvider;
|
||||
/** The app it's playing in, when a phone reported it (e.g. "Spotify"). */
|
||||
sourceApp: string | null;
|
||||
/** Link to the source for attribution, e.g. the user's Last.fm profile (required by Last.fm's terms). */
|
||||
sourceUrl: string | null;
|
||||
progressMs: number | null;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -79,6 +81,8 @@ export interface ProfileDTO {
|
||||
nowPlaying: NowPlayingDTO | null;
|
||||
/** Most recent first, not including the track playing right now. */
|
||||
recent: RecentPlayDTO[];
|
||||
/** Profile links for sources whose data appears here (Last.fm requires attribution). */
|
||||
attribution: Partial<Record<'lastfm' | 'listenbrainz', string>>;
|
||||
}
|
||||
|
||||
export interface ConnectionDTO {
|
||||
@@ -88,6 +92,8 @@ export interface ConnectionDTO {
|
||||
status: 'active' | 'error';
|
||||
lastError: string | null;
|
||||
lastPolledAt: string | null;
|
||||
/** Last time this connection saw something playing. */
|
||||
lastActivityAt: string | null;
|
||||
/** Secret URL the user can paste into Plex/Jellyfin for instant updates. */
|
||||
webhookUrl: string | null;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,11 @@ export type Plan = (typeof PLANS)[number];
|
||||
|
||||
/**
|
||||
* Every feature gate lives here so paid tiers can be reshaped without
|
||||
* hunting through the codebase. Clients read these to show upsells.
|
||||
* hunting through the codebase.
|
||||
*
|
||||
* Currently everything is unlocked for everyone (the app is donation-supported);
|
||||
* the limits that remain are abuse guards, not upsells. "plus" is kept so paid
|
||||
* tiers can return without touching the rest of the code.
|
||||
*/
|
||||
export interface Entitlements {
|
||||
/** How often we refresh now-playing while someone is watching you. */
|
||||
@@ -16,7 +20,16 @@ export interface Entitlements {
|
||||
profileThemes: boolean;
|
||||
}
|
||||
|
||||
const EVERYTHING: Entitlements = {
|
||||
// 10s balances freshness against third-party API budgets (Spotify's quota is shared across all users).
|
||||
activePollSeconds: 10,
|
||||
maxConnections: 10,
|
||||
maxPinnedFriends: 20,
|
||||
fullHistory: true,
|
||||
profileThemes: true,
|
||||
};
|
||||
|
||||
export const PLAN_ENTITLEMENTS: Record<Plan, Entitlements> = {
|
||||
free: { activePollSeconds: 15, maxConnections: 3, maxPinnedFriends: 3, fullHistory: false, profileThemes: false },
|
||||
plus: { activePollSeconds: 5, maxConnections: 10, maxPinnedFriends: 20, fullHistory: true, profileThemes: true },
|
||||
free: EVERYTHING,
|
||||
plus: EVERYTHING,
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { STREAMING_SERVICES, openUrl, searchUrl } from './services';
|
||||
import { STREAMING_SERVICES, isQuietScrobbler, openUrl, searchUrl } from './services';
|
||||
|
||||
const track = { title: 'Midnight City', artist: 'M83', links: { spotify: 'https://open.spotify.com/track/abc' } };
|
||||
|
||||
@@ -22,3 +22,21 @@ describe('searchUrl', () => {
|
||||
expect(decodeURIComponent(url.href)).toContain('Ünïcode Café & Co');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isQuietScrobbler', () => {
|
||||
const now = new Date('2026-10-04T12:00:00Z').getTime();
|
||||
const conn = (provider: string, daysAgo: number | null) => ({
|
||||
provider,
|
||||
status: 'active',
|
||||
lastActivityAt: daysAgo === null ? null : new Date(now - daysAgo * 86_400_000).toISOString(),
|
||||
});
|
||||
it('flags Last.fm/ListenBrainz that went silent for over a week', () => {
|
||||
expect(isQuietScrobbler(conn('lastfm', 8), now)).toBe(true);
|
||||
expect(isQuietScrobbler(conn('listenbrainz', 30), now)).toBe(true);
|
||||
});
|
||||
it('ignores recent activity, never-active connections, and other providers', () => {
|
||||
expect(isQuietScrobbler(conn('lastfm', 2), now)).toBe(false);
|
||||
expect(isQuietScrobbler(conn('lastfm', null), now)).toBe(false);
|
||||
expect(isQuietScrobbler(conn('spotify', 30), now)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -62,3 +62,35 @@ export function openUrl(
|
||||
if (direct) return { url: direct, exact: true };
|
||||
return { url: searchUrl(service, track.title, track.artist), exact: false };
|
||||
}
|
||||
|
||||
/** Public profile page for scrobbling services (attribution links). */
|
||||
export function sourceProfileUrl(provider: string, username: string): string | null {
|
||||
const u = encodeURIComponent(username);
|
||||
if (provider === 'lastfm') return `https://www.last.fm/user/${u}`;
|
||||
if (provider === 'listenbrainz') return `https://listenbrainz.org/user/${u}/`;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Where users re-link Spotify on each scrobbling service. */
|
||||
export const SPOTIFY_LINK_PAGES = {
|
||||
lastfm: 'https://www.last.fm/settings/applications',
|
||||
listenbrainz: 'https://listenbrainz.org/settings/music-services/details/',
|
||||
} as const;
|
||||
|
||||
const QUIET_AFTER_MS = 7 * 86_400_000;
|
||||
|
||||
/**
|
||||
* A scrobbler that used to report plays but has gone silent for a week. Usually
|
||||
* Spotify's link to it expired (Spotify makes users re-approve every 6 months).
|
||||
*/
|
||||
export function isQuietScrobbler(
|
||||
c: { provider: string; status: string; lastActivityAt: string | null },
|
||||
now = Date.now(),
|
||||
): c is { provider: 'lastfm' | 'listenbrainz'; status: string; lastActivityAt: string } {
|
||||
return (
|
||||
(c.provider === 'lastfm' || c.provider === 'listenbrainz') &&
|
||||
c.status === 'active' &&
|
||||
c.lastActivityAt !== null &&
|
||||
now - new Date(c.lastActivityAt).getTime() > QUIET_AFTER_MS
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user