diff --git a/apps/api/.env.example b/apps/api/.env.example index 0334073..319c0e4 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -19,6 +19,8 @@ LASTFM_API_KEY= LASTFM_SHARED_SECRET= # song.link API key (request at https://odesli.co). Enables YouTube Music/TIDAL links; without it those fall back to search. ODESLI_API_KEY= +# Lyrics source: LRCLIB (open source; run your own and point this at it). Empty turns lyrics off. +LRCLIB_URL=https://lrclib.net # Stable identifier for this app in Plex's auth flow PLEX_CLIENT_ID=musicshare-dev # Let users connect Jellyfin/Plex servers on private networks (LAN). Never enable in a hosted deployment. diff --git a/apps/api/drizzle/0015_lyrics.sql b/apps/api/drizzle/0015_lyrics.sql new file mode 100644 index 0000000..32760fd --- /dev/null +++ b/apps/api/drizzle/0015_lyrics.sql @@ -0,0 +1,10 @@ +CREATE TABLE "lyrics" ( + "track_id" uuid PRIMARY KEY NOT NULL, + "synced" text, + "plain" text, + "instrumental" boolean DEFAULT false NOT NULL, + "source_url" text, + "fetched_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "lyrics" ADD CONSTRAINT "lyrics_track_id_tracks_id_fk" FOREIGN KEY ("track_id") REFERENCES "public"."tracks"("id") ON DELETE cascade ON UPDATE no action; \ No newline at end of file diff --git a/apps/api/drizzle/meta/0015_snapshot.json b/apps/api/drizzle/meta/0015_snapshot.json new file mode 100644 index 0000000..0efd65c --- /dev/null +++ b/apps/api/drizzle/meta/0015_snapshot.json @@ -0,0 +1,1555 @@ +{ + "id": "f7a38758-1668-4e86-bb01-e20b2d1e36e0", + "prevId": "4f894073-ca01-4ef4-be6f-3d7722938c0e", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.avatars": { + "name": "avatars", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "image": { + "name": "image", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "avatars_user_id_users_id_fk": { + "name": "avatars_user_id_users_id_fk", + "tableFrom": "avatars", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.blocks": { + "name": "blocks", + "schema": "", + "columns": { + "blocker_id": { + "name": "blocker_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "blocked_id": { + "name": "blocked_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "blocks_blocked_idx": { + "name": "blocks_blocked_idx", + "columns": [ + { + "expression": "blocked_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "blocks_blocker_id_users_id_fk": { + "name": "blocks_blocker_id_users_id_fk", + "tableFrom": "blocks", + "tableTo": "users", + "columnsFrom": [ + "blocker_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "blocks_blocked_id_users_id_fk": { + "name": "blocks_blocked_id_users_id_fk", + "tableFrom": "blocks", + "tableTo": "users", + "columnsFrom": [ + "blocked_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "blocks_blocker_id_blocked_id_pk": { + "name": "blocks_blocker_id_blocked_id_pk", + "columns": [ + "blocker_id", + "blocked_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "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.email_verifications": { + "name": "email_verifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(320)", + "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": { + "email_verifications_user_idx": { + "name": "email_verifications_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "email_verifications_user_id_users_id_fk": { + "name": "email_verifications_user_id_users_id_fk", + "tableFrom": "email_verifications", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "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.lyrics": { + "name": "lyrics", + "schema": "", + "columns": { + "track_id": { + "name": "track_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "synced": { + "name": "synced", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plain": { + "name": "plain", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "instrumental": { + "name": "instrumental", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "fetched_at": { + "name": "fetched_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "lyrics_track_id_tracks_id_fk": { + "name": "lyrics_track_id_tracks_id_fk", + "tableFrom": "lyrics", + "tableTo": "tracks", + "columnsFrom": [ + "track_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "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.password_resets": { + "name": "password_resets", + "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 + }, + "used_at": { + "name": "used_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": { + "password_resets_user_idx": { + "name": "password_resets_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "password_resets_user_id_users_id_fk": { + "name": "password_resets_user_id_users_id_fk", + "tableFrom": "password_resets", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reactions": { + "name": "reactions", + "schema": "", + "columns": { + "listen_id": { + "name": "listen_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "emoji": { + "name": "emoji", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "reactions_listen_id_listens_id_fk": { + "name": "reactions_listen_id_listens_id_fk", + "tableFrom": "reactions", + "tableTo": "listens", + "columnsFrom": [ + "listen_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reactions_user_id_users_id_fk": { + "name": "reactions_user_id_users_id_fk", + "tableFrom": "reactions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "reactions_listen_id_user_id_pk": { + "name": "reactions_listen_id_user_id_pk", + "columns": [ + "listen_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reports": { + "name": "reports", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "reporter_id": { + "name": "reporter_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "reported_id": { + "name": "reported_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "note": { + "name": "note", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "reports_status_idx": { + "name": "reports_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "reports_reporter_id_users_id_fk": { + "name": "reports_reporter_id_users_id_fk", + "tableFrom": "reports", + "tableTo": "users", + "columnsFrom": [ + "reporter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "reports_reported_id_users_id_fk": { + "name": "reports_reported_id_users_id_fk", + "tableFrom": "reports", + "tableTo": "users", + "columnsFrom": [ + "reported_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "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 + }, + "email_verified_at": { + "name": "email_verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "pending_email": { + "name": "pending_email", + "type": "varchar(320)", + "primaryKey": false, + "notNull": false + }, + "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 + }, + "services": { + "name": "services", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "suspended_at": { + "name": "suspended_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "onboarded_at": { + "name": "onboarded_at", + "type": "timestamp with time zone", + "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 e9a1ce0..48e3730 100644 --- a/apps/api/drizzle/meta/_journal.json +++ b/apps/api/drizzle/meta/_journal.json @@ -106,6 +106,13 @@ "when": 1792025011207, "tag": "0014_user_services", "breakpoints": true + }, + { + "idx": 15, + "version": "7", + "when": 1792025274882, + "tag": "0015_lyrics", + "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 61b449f..4b3749c 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -290,6 +290,20 @@ export const passwordResets = pgTable( (t) => [index('password_resets_user_idx').on(t.userId)], ); +/** Lyrics looked up per track (misses too, so we don't keep asking). */ +export const lyrics = pgTable('lyrics', { + trackId: uuid('track_id') + .primaryKey() + .references(() => tracks.id, { onDelete: 'cascade' }), + /** LRC text with timestamps. */ + synced: text('synced'), + plain: text('plain'), + instrumental: boolean('instrumental').notNull().default(false), + /** Where they came from (the LRCLIB record), null when none were found. */ + sourceUrl: text('source_url'), + fetchedAt: timestamp('fetched_at', { withTimezone: true }).notNull().defaultNow(), +}); + const bytea = customType<{ data: Buffer }>({ dataType: () => 'bytea' }); /** Profile pictures, already resized and re-encoded (WebP, metadata stripped). */ diff --git a/apps/api/src/env.ts b/apps/api/src/env.ts index fdc59ba..af2d468 100644 --- a/apps/api/src/env.ts +++ b/apps/api/src/env.ts @@ -26,6 +26,11 @@ const schema = z.object({ /** Enables "Sign in with Last.fm" (web auth). Without it, users type their username. */ LASTFM_SHARED_SECRET: optional, ODESLI_API_KEY: optional, + /** Lyrics come from LRCLIB (open source, self-hostable). Point this at your own instance, or set it empty to turn lyrics off. */ + LRCLIB_URL: z + .string() + .default('https://lrclib.net') + .transform((v) => v.replace(/\/+$/, '')), /** Apple Music (MusicKit): Team ID, MusicKit key ID, and the key's .p8 contents (newlines may be written as \n). */ APPLE_TEAM_ID: optional, APPLE_MUSICKIT_KEY_ID: optional, diff --git a/apps/api/src/routes/tracks.ts b/apps/api/src/routes/tracks.ts index c993fb3..b0afa0c 100644 --- a/apps/api/src/routes/tracks.ts +++ b/apps/api/src/routes/tracks.ts @@ -1,9 +1,11 @@ import { eq } from 'drizzle-orm'; import type { FastifyInstance } from 'fastify'; +import { z } from 'zod'; import { STREAMING_SERVICES, openUrl, type StreamingService, type TrackSearchResultDTO } from '@musicshare/shared'; import { db, schema } from '../db'; import { HttpError } from '../lib/http'; import { deezerSearch } from '../providers/catalog'; +import { getLyrics } from '../services/lyrics'; import { resolveTrackLinks } from '../services/resolver'; import { getTrackDTOs } from '../services/tracks'; @@ -32,6 +34,22 @@ export async function trackRoutes(app: FastifyInstance) { return track; }); + /** Lyrics (synced when available) or null. Looked up once per song, then cached. */ + app.get<{ Params: { id: string } }>( + '/tracks/:id/lyrics', + { config: { rateLimit: { max: 60, timeWindow: '1 minute' } } }, + async (req, reply) => { + if (!z.uuid().safeParse(req.params.id).success) throw new HttpError(404, 'Track not found'); + try { + return reply.send(await getLyrics(req.params.id)); + } catch (err) { + if (err instanceof HttpError && err.status === 404) throw err; + req.log.warn(err, 'lyrics lookup failed'); + throw new HttpError(503, "Couldn't get lyrics right now"); + } + }, + ); + /** Shareable redirect: /tracks/:id/open?service=spotify (defaults to the viewer's preferred service). */ app.get<{ Params: { id: string }; Querystring: { service?: string } }>('/tracks/:id/open', async (req, reply) => { const service = (req.query.service ?? req.user?.preferredService ?? 'spotify') as StreamingService; diff --git a/apps/api/src/services/lyrics.ts b/apps/api/src/services/lyrics.ts new file mode 100644 index 0000000..776e2c1 --- /dev/null +++ b/apps/api/src/services/lyrics.ts @@ -0,0 +1,85 @@ +import { eq } from 'drizzle-orm'; +import { parseLrc, type LyricsDTO } from '@musicshare/shared'; +import { db, schema } from '../db'; +import { env } from '../env'; +import { HttpError, fetchJson, normalize, primaryArtist } from '../lib/http'; + +/** Look again for songs that had none after this long; lyrics get added to LRCLIB all the time. */ +const RETRY_MISS_MS = 7 * 86_400_000; +/** A search result has to be this close in length to count as the same recording. */ +const DURATION_SLACK_S = 4; + +interface LrclibRecord { + id: number; + trackName: string; + artistName: string; + duration: number | null; + instrumental: boolean; + plainLyrics: string | null; + syncedLyrics: string | null; +} + +type Row = typeof schema.lyrics.$inferSelect; + +/** The track's lyrics, from our cache or LRCLIB. Null when there are none (or lyrics are turned off). */ +export async function getLyrics(trackId: string): Promise { + if (!env.LRCLIB_URL) return null; + const [track] = await db.select().from(schema.tracks).where(eq(schema.tracks.id, trackId)); + if (!track) throw new HttpError(404, 'Track not found'); + + let [row] = await db.select().from(schema.lyrics).where(eq(schema.lyrics.trackId, trackId)); + const stale = row && !row.sourceUrl && Date.now() - row.fetchedAt.getTime() > RETRY_MISS_MS; + if (!row || stale) { + const found = await lookup(track); + const values = { + synced: found?.syncedLyrics || null, + plain: found?.plainLyrics || null, + instrumental: found?.instrumental ?? false, + sourceUrl: found ? `${env.LRCLIB_URL}/api/get/${found.id}` : null, + fetchedAt: new Date(), + }; + [row] = await db + .insert(schema.lyrics) + .values({ trackId, ...values }) + .onConflictDoUpdate({ target: schema.lyrics.trackId, set: values }) + .returning(); + } + return toDTO(row!); +} + +function toDTO(row: Row): LyricsDTO | null { + if (!row.sourceUrl || (!row.synced && !row.plain && !row.instrumental)) return null; + const synced = row.synced ? parseLrc(row.synced) : null; + return { + synced: synced?.length ? synced : null, + plain: row.plain, + instrumental: row.instrumental, + source: { name: 'LRCLIB', url: 'https://lrclib.net' }, + }; +} + +async function lookup(track: { title: string; artist: string; album: string | null; durationMs: number | null }) { + const artist = primaryArtist(track.artist); + const headers = { 'user-agent': 'musicshare (https://musicshare.binarygnome.com)' }; + // Exact lookup needs the length; LRCLIB matches it within a couple of seconds. + if (track.durationMs) { + const params = new URLSearchParams({ track_name: track.title, artist_name: artist, duration: String(Math.round(track.durationMs / 1000)) }); + if (track.album) params.set('album_name', track.album); + const exact = await fetchJson(`${env.LRCLIB_URL}/api/get?${params}`, { headers }).catch((e: unknown) => { + if (e instanceof HttpError && e.status === 404) return null; + throw e; + }); + if (exact) return exact; + } + const params = new URLSearchParams({ track_name: track.title, artist_name: artist }); + const results = (await fetchJson(`${env.LRCLIB_URL}/api/search?${params}`, { headers })) ?? []; + const title = normalize(track.title); + const matches = results.filter( + (r) => + normalize(r.trackName) === title && + normalize(r.artistName).includes(normalize(artist)) && + (!track.durationMs || !r.duration || Math.abs(r.duration - track.durationMs / 1000) <= DURATION_SLACK_S), + ); + // Prefer synced lyrics, then anything with words. + return matches.find((r) => r.syncedLyrics) ?? matches.find((r) => r.plainLyrics || r.instrumental) ?? null; +} diff --git a/apps/api/test/lyrics.test.ts b/apps/api/test/lyrics.test.ts new file mode 100644 index 0000000..5b8b1e4 --- /dev/null +++ b/apps/api/test/lyrics.test.ts @@ -0,0 +1,68 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { upsertTrack } from '../src/services/tracks'; +import { json, routeFetch, useTestApp } from './helpers'; + +const app = useTestApp(); +afterEach(() => vi.unstubAllGlobals()); + +const record = (over: object = {}) => ({ + id: 70627, + trackName: 'Midnight City', + artistName: 'M83', + duration: 241, + instrumental: false, + plainLyrics: 'Waiting in a car\nWaiting for a ride in the dark', + syncedLyrics: '[00:01.00] Waiting in a car\n[00:04.50] Waiting for a ride in the dark', + ...over, +}); +const lyrics = (id: string) => app().inject({ url: `/tracks/${id}/lyrics` }); + +describe('lyrics', () => { + it('looks up synced lyrics once and serves them from the cache after', async () => { + const { id } = await upsertTrack({ title: 'Midnight City', artist: 'M83', durationMs: 241_000 }); + const calls: string[] = []; + vi.stubGlobal('fetch', routeFetch([[/lrclib\.net\/api\/get\?/, (url) => (calls.push(url), json(record()))]])); + + const first = (await lyrics(id)).json(); + expect(first.synced).toEqual([ + { timeMs: 1000, text: 'Waiting in a car' }, + { timeMs: 4500, text: 'Waiting for a ride in the dark' }, + ]); + expect(first.source.name).toBe('LRCLIB'); + expect(new URL(calls[0]!).searchParams.get('duration')).toBe('241'); + expect((await lyrics(id)).json()).toEqual(first); + expect(calls).toHaveLength(1); + }); + + it('falls back to a search, skipping recordings of a different length', async () => { + const { id } = await upsertTrack({ title: 'Song', artist: 'Band', durationMs: 200_000 }); + vi.stubGlobal( + 'fetch', + routeFetch([ + [/\/api\/get\?/, () => json({ code: 404 }, 404)], + [ + /\/api\/search\?/, + () => + json([ + record({ id: 1, trackName: 'Song', artistName: 'Band', duration: 320, plainLyrics: 'extended mix' }), + record({ id: 2, trackName: 'Song', artistName: 'Band', duration: 201, syncedLyrics: null, plainLyrics: 'the one' }), + ]), + ], + ]), + ); + const body = (await lyrics(id)).json(); + expect(body.synced).toBeNull(); + expect(body.plain).toBe('the one'); + }); + + it('returns null when nothing matches, and 503 when LRCLIB is down', async () => { + const { id } = await upsertTrack({ title: 'Unknown', artist: 'Nobody', durationMs: 100_000 }); + vi.stubGlobal('fetch', routeFetch([[/lrclib/, () => json({ message: 'down' }, 500)]])); + expect((await lyrics(id)).statusCode).toBe(503); + vi.stubGlobal('fetch', routeFetch([[/\/api\/get\?/, () => json({}, 404)], [/\/api\/search\?/, () => json([])]])); + const res = await lyrics(id); + expect(res.statusCode).toBe(200); + expect(res.json()).toBeNull(); + expect((await lyrics('not-a-uuid')).statusCode).toBe(404); + }); +}); diff --git a/apps/mobile/src/components/Lyrics.tsx b/apps/mobile/src/components/Lyrics.tsx new file mode 100644 index 0000000..3d5202b --- /dev/null +++ b/apps/mobile/src/components/Lyrics.tsx @@ -0,0 +1,97 @@ +import { useEffect, useState } from 'react'; +import { Linking, Pressable, StyleSheet, Text, View } from 'react-native'; +import Animated, { LinearTransition } from 'react-native-reanimated'; +import useSWR from 'swr'; +import { currentLyricIndex, playbackPosition, type LyricLine, type LyricsDTO, type NowPlayingDTO, type TrackDTO } from '@musicshare/shared'; +import { colors, fonts, space } from '@/lib/theme'; + +/** Lines shown around the current one while following along. */ +const BEFORE = 1; +const AFTER = 3; +/** Lines shown before "All lyrics" when there's nothing to follow. */ +const PREVIEW = 8; + +/** + * A song's lyrics. With timed lyrics and a live position (`np`), shows a few + * lines around the one being sung and follows along; "All lyrics" shows the rest. + */ +export function Lyrics({ track, np }: { track: TrackDTO; np?: NowPlayingDTO }) { + const { data, error, isLoading } = useSWR(`/tracks/${track.id}/lyrics`); + const [full, setFull] = useState(false); + + if (isLoading) return Finding lyrics…; + if (error) return Couldn't load lyrics right now.; + if (!data) return No lyrics found for this song.; + if (data.instrumental && !data.synced && !data.plain) return ♪ Instrumental; + + const synced = data.synced; + const live = Boolean(np?.isPlaying && playbackPosition(np, 0)); + const following = Boolean(synced && live && !full); + const text = synced ? synced.map((l) => l.text).join('\n') : (data.plain ?? ''); + const all = text.split('\n'); + const clipped = !following && !full && all.length > PREVIEW; + const canToggle = (synced && live) || clipped || full; + + return ( + + setFull((v) => !v)} disabled={!canToggle} accessibilityRole="button" accessibilityLabel={full ? 'Show fewer lyrics' : 'Show all lyrics'}> + {following && synced && np ? ( + // Each fresh position from the server restarts the clock. + + ) : ( + (clipped ? all.slice(0, PREVIEW) : all).map((t, i) => ( + + {t} + + )) + )} + + + {canToggle && ( + setFull((v) => !v)}> + {!full ? 'All lyrics' : synced && live ? 'Follow along' : 'Less'} + + )} + Linking.openURL(data.source.url)}> + Lyrics from {data.source.name} + + + + ); +} + +/** The line being sung, one before it and a few after, moving with the song. */ +function FollowAlong({ lines, np }: { lines: LyricLine[]; np: NowPlayingDTO }) { + const [receivedAt] = useState(() => Date.now()); + const [now, setNow] = useState(receivedAt); + useEffect(() => { + const id = setInterval(() => setNow(Date.now()), 500); + return () => clearInterval(id); + }, []); + + const position = playbackPosition(np, now - receivedAt)?.positionMs ?? 0; + const current = currentLyricIndex(lines, position); + const from = Math.max(0, current - BEFORE); + return lines.slice(from, Math.max(current, 0) + AFTER + 1).map((l, i) => { + const index = from + i; + const state = index < current ? styles.past : index === current ? styles.now : styles.next; + return ( + + {l.text || '♪'} + + ); + }); +} + +const styles = StyleSheet.create({ + wrap: { gap: space.sm }, + note: { color: colors.muted, fontSize: 13 }, + line: { color: colors.text, fontSize: 15, lineHeight: 22 }, + plain: { opacity: 0.9 }, + past: { opacity: 0.4 }, + now: { fontFamily: fonts.display, fontSize: 17, lineHeight: 24 }, + next: { opacity: 0.65 }, + footer: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }, + toggle: { color: colors.accent2, fontSize: 13, fontWeight: '600' }, + source: { color: colors.muted, fontSize: 11, marginLeft: 'auto' }, +}); diff --git a/apps/mobile/src/components/NowPlayingCard.tsx b/apps/mobile/src/components/NowPlayingCard.tsx index b085dc7..05f2018 100644 --- a/apps/mobile/src/components/NowPlayingCard.tsx +++ b/apps/mobile/src/components/NowPlayingCard.tsx @@ -1,7 +1,7 @@ import { Image } from 'expo-image'; import { LinearGradient } from 'expo-linear-gradient'; import { router } from 'expo-router'; -import { useEffect, useRef } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { Linking, Pressable, StyleSheet, Text, View } from 'react-native'; import Animated, { FadeIn, @@ -17,9 +17,10 @@ import { successHaptic, tapHaptic } from '@/lib/haptics'; import { colors, radius, space } from '@/lib/theme'; import { Avatar } from './Avatar'; import { Equalizer } from './Equalizer'; +import { Lyrics } from './Lyrics'; import { ProgressBar } from './ProgressBar'; import { Reactions } from './Reactions'; -import { TrackCard } from './TrackCard'; +import { ServiceLinks, TrackRow } from './TrackCard'; export function NowPlayingCard({ np, @@ -42,6 +43,11 @@ export function NowPlayingCard({ wasPinned.current = pinned; }, [pinned, star]); const starStyle = useAnimatedStyle(() => ({ transform: [{ scale: star.value }] })); + const [expanded, setExpanded] = useState(false); + const toggle = () => { + tapHaptic(); + setExpanded((v) => !v); + }; const togglePin = () => { if (pinned) tapHaptic(); @@ -62,7 +68,7 @@ export function NowPlayingCard({ {showUser && ( router.push(`/u/${np.user.username}`)} accessibilityRole="link"> - + {np.user.displayName ?? np.user.username} @@ -93,10 +99,16 @@ export function NowPlayingCard({ - + - + + {expanded && ( + + + + + )} ); } @@ -104,13 +116,14 @@ export function NowPlayingCard({ const styles = StyleSheet.create({ card: { borderRadius: radius.lg, - padding: space.lg, - gap: space.md, + padding: space.md + 2, + gap: space.sm + 2, overflow: 'hidden', backgroundColor: colors.surface, borderWidth: 1, borderColor: colors.border, }, + details: { gap: space.md }, pinned: { borderColor: 'rgba(251,191,36,0.55)' }, backdrop: { position: 'absolute', top: '-40%', left: '-40%', width: '180%', height: '180%', opacity: 0.55 }, header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: space.sm }, diff --git a/apps/mobile/src/components/ProgressBar.tsx b/apps/mobile/src/components/ProgressBar.tsx index 4c29ca7..bb76d57 100644 --- a/apps/mobile/src/components/ProgressBar.tsx +++ b/apps/mobile/src/components/ProgressBar.tsx @@ -6,13 +6,13 @@ import { formatDuration, playbackPosition, type NowPlayingDTO } from '@musicshar import { colors } from '@/lib/theme'; /** Where they are in the song. Renders nothing when the source doesn't report a position. */ -export function ProgressBar({ np }: { np: NowPlayingDTO }) { +export function ProgressBar({ np, compact }: { np: NowPlayingDTO; /** Just the bar, no times. */ compact?: boolean }) { if (!np.isPlaying || playbackPosition(np, 0) === null) return null; // Each fresh position from the server restarts the clock, realigning the bar. - return ; + return ; } -function Bar({ np }: { np: NowPlayingDTO }) { +function Bar({ np, compact }: { np: NowPlayingDTO; compact?: boolean }) { const [receivedAt] = useState(() => Date.now()); const [now, setNow] = useState(receivedAt); useEffect(() => { @@ -36,16 +36,18 @@ function Bar({ np }: { np: NowPlayingDTO }) { - - {formatDuration(pos.positionMs)} - {formatDuration(pos.durationMs)} - + {!compact && ( + + {formatDuration(pos.positionMs)} + {formatDuration(pos.durationMs)} + + )} ); } const styles = StyleSheet.create({ - wrap: { marginTop: 12 }, + wrap: { marginTop: 2 }, track: { height: 4, borderRadius: 2, backgroundColor: 'rgba(255,255,255,0.14)', overflow: 'hidden' }, fill: { height: '100%', borderRadius: 2, overflow: 'hidden' }, times: { flexDirection: 'row', justifyContent: 'space-between', marginTop: 4 }, diff --git a/apps/mobile/src/components/TrackCard.tsx b/apps/mobile/src/components/TrackCard.tsx index c289775..8f6deff 100644 --- a/apps/mobile/src/components/TrackCard.tsx +++ b/apps/mobile/src/components/TrackCard.tsx @@ -20,50 +20,114 @@ function shareTrack(track: TrackDTO, from: string | undefined) { Share.share({ message: `${shareText(track)} ${url}`, url, title: shareText(track) }).catch((e) => showError(e, "Couldn't share")); } -export function TrackCard({ track, preferred }: { track: TrackDTO; preferred: StreamingService | null | undefined }) { - const { me } = useSession(); - const primary = preferred ? openUrl(track, preferred) : null; - const others = viewerServices(me).filter((s) => s !== preferred); +function Artwork({ track, size }: { track: TrackDTO; size: number }) { + const box = { width: size, height: size, borderRadius: size > 64 ? 14 : 10 }; + return track.artworkUrl ? ( + + ) : ( + + ♪ + + ); +} +/** The big "Open in Spotify" button for your main service ("Search in" when we have no direct link). */ +function PrimaryButton({ track, preferred, label }: { track: TrackDTO; preferred: StreamingService; label?: 'short' }) { + const { url, exact } = openUrl(track, preferred); + const name = SERVICE_LABELS[preferred]; + return ( + + )} + + Lyrics from {data.source.name} + + + + ); +} + +/** The line being sung, one before it and a few after, moving with the song. */ +function FollowAlong({ lines, np }: { lines: LyricLine[]; np: NowPlayingDTO }) { + const [receivedAt] = useState(() => Date.now()); + const [now, setNow] = useState(receivedAt); + useEffect(() => { + const id = setInterval(() => setNow(Date.now()), 500); + return () => clearInterval(id); + }, []); + + const position = playbackPosition(np, now - receivedAt)?.positionMs ?? 0; + const current = currentLyricIndex(lines, position); + const from = Math.max(0, current - BEFORE); + return lines.slice(from, Math.max(current, 0) + AFTER + 1).map((l, i) => { + const index = from + i; + const state = index < current ? 'past' : index === current ? 'now' : 'next'; + return ( +

+ {l.text || '♪'} +

+ ); + }); +} diff --git a/apps/web/src/components/NowPlayingCard.tsx b/apps/web/src/components/NowPlayingCard.tsx index 4dd091f..d5d9e84 100644 --- a/apps/web/src/components/NowPlayingCard.tsx +++ b/apps/web/src/components/NowPlayingCard.tsx @@ -1,13 +1,15 @@ 'use client'; import { AnimatePresence, motion } from 'motion/react'; +import { useState } from 'react'; import Link from 'next/link'; import { SERVICE_LABELS, timeAgo, type NowPlayingDTO, type StreamingService } from '@musicshare/shared'; import { Avatar } from './Avatar'; import { Equalizer } from './Equalizer'; +import { Lyrics } from './Lyrics'; import { ProgressBar } from './ProgressBar'; import { Reactions } from './Reactions'; -import { TrackCard } from './TrackCard'; +import { ServiceLinks, TrackRow } from './TrackCard'; export function NowPlayingCard({ np, @@ -23,6 +25,7 @@ export function NowPlayingCard({ onTogglePin?: () => void; }) { const sourceLabel = np.sourceApp ?? SERVICE_LABELS[np.source]; + const [expanded, setExpanded] = useState(false); return (
{/* The card glows with its album art; crossfades when the song changes. */} @@ -42,7 +45,7 @@ export function NowPlayingCard({
{showUser && ( - + {np.user.displayName ?? np.user.username} )} @@ -84,9 +87,25 @@ export function NowPlayingCard({ exit={{ opacity: 0, y: -12, filter: 'blur(6px)' }} transition={{ duration: 0.35, ease: [0.22, 1, 0.36, 1] }} > - - + setExpanded((v) => !v)} /> + + + {expanded && ( + +
+ +
+ +
+ )} +
diff --git a/apps/web/src/components/ProgressBar.tsx b/apps/web/src/components/ProgressBar.tsx index c90cd05..bf43a66 100644 --- a/apps/web/src/components/ProgressBar.tsx +++ b/apps/web/src/components/ProgressBar.tsx @@ -4,13 +4,13 @@ import { useEffect, useState } from 'react'; import { formatDuration, playbackPosition, type NowPlayingDTO } from '@musicshare/shared'; /** Where they are in the song. Renders nothing when the source doesn't report a position. */ -export function ProgressBar({ np }: { np: NowPlayingDTO }) { +export function ProgressBar({ np, compact }: { np: NowPlayingDTO; /** Just the bar, no times. */ compact?: boolean }) { if (!np.isPlaying || playbackPosition(np, 0) === null) return null; // Each fresh position from the server restarts the clock, realigning the bar. - return ; + return ; } -function Bar({ np }: { np: NowPlayingDTO }) { +function Bar({ np, compact }: { np: NowPlayingDTO; compact?: boolean }) { const [receivedAt] = useState(() => Date.now()); const [now, setNow] = useState(receivedAt); useEffect(() => { @@ -25,10 +25,12 @@ function Bar({ np }: { np: NowPlayingDTO }) {
-
- {formatDuration(pos.positionMs)} - {formatDuration(pos.durationMs)} -
+ {!compact && ( +
+ {formatDuration(pos.positionMs)} + {formatDuration(pos.durationMs)} +
+ )}
); } diff --git a/apps/web/src/components/TrackCard.tsx b/apps/web/src/components/TrackCard.tsx index 2aadac0..47a428c 100644 --- a/apps/web/src/components/TrackCard.tsx +++ b/apps/web/src/components/TrackCard.tsx @@ -4,6 +4,85 @@ import { SERVICE_LABELS, openUrl, viewerServices, type StreamingService, type Tr import { useMe } from '@/lib/api'; import { ShareButton } from './ShareButton'; +function Artwork({ track, small }: { track: TrackDTO; small?: boolean }) { + const cls = small ? 'artwork artwork-row' : 'artwork'; + return track.artworkUrl ? :
♪
; +} + +/** The big "Open in Spotify" button for your main service ("Search in" when we have no direct link). */ +function PrimaryLink({ track, preferred, short }: { track: TrackDTO; preferred: StreamingService; short?: boolean }) { + const { url, exact } = openUrl(track, preferred); + const full = `${exact ? 'Open' : 'Search'} in ${SERVICE_LABELS[preferred]}`; + return ( + + {short ? (exact ? 'Open ▶' : 'Search') : full} + + ); +} + +/** Quieter links for your other services, and Share. */ +export function ServiceLinks({ track, preferred }: { track: TrackDTO; preferred: StreamingService | null | undefined }) { + const { me } = useMe(); + const others = viewerServices(me).filter((s) => s !== preferred); + return ( + <> + {others.map((s) => { + const { url, exact } = openUrl(track, s); + return ( + + {SERVICE_LABELS[s]} + + ); + })} + + + ); +} + +/** + * One slim line: small artwork, title and artist, the open button, and an + * arrow that shows the rest (`expanded` / `onToggle`). + */ +export function TrackRow({ + track, + preferred, + expanded, + onToggle, +}: { + track: TrackDTO; + preferred: StreamingService | null | undefined; + expanded: boolean; + onToggle: () => void; +}) { + return ( +
+ + {preferred && } + +
+ ); +} + /** * Artwork + title, with a primary button for the viewer's preferred service * and quieter links for the rest. Links are https universal links, so on @@ -21,17 +100,9 @@ export function TrackCard({ /** When the page already shows the artwork big. */ hideArtwork?: boolean; }) { - const { me } = useMe(); - const others = viewerServices(me).filter((s) => s !== preferred); - const primary = preferred ? openUrl(track, preferred) : null; - return (
- {hideArtwork ? null : track.artworkUrl ? ( - - ) : ( -
♪
- )} + {!hideArtwork && }
{track.title}
@@ -39,27 +110,8 @@ export function TrackCard({ {track.album && · {track.album}}
- {preferred && primary && ( - - {primary.exact ? 'Open' : 'Search'} in {SERVICE_LABELS[preferred]} - - )} - {others.map((s) => { - const { url, exact } = openUrl(track, s); - return ( - - {SERVICE_LABELS[s]} - - ); - })} - + {preferred && } +
diff --git a/packages/shared/src/client.ts b/packages/shared/src/client.ts index ca1a2e6..3eda13f 100644 --- a/packages/shared/src/client.ts +++ b/packages/shared/src/client.ts @@ -11,6 +11,7 @@ import type { UserSummaryDTO, } from './dto'; import type { DeviceDTO } from './devices'; +import type { LyricsDTO } from './lyrics'; import type { ChangeEmailInput, ChangePasswordInput, @@ -132,6 +133,8 @@ export function createApiClient({ baseUrl, getToken, credentials }: ApiClientOpt unreact: (listenId: string) => request(`/listens/${q(listenId)}/reaction`, { method: 'DELETE' }), searchTracks: (query: string) => request(`/tracks/search?q=${q(query)}`), + /** Null when no lyrics were found for the song. */ + lyrics: (trackId: string) => request(`/tracks/${q(trackId)}/lyrics`), connections: () => request('/connections'), authorizeUrl: (provider: 'plex' | 'lastfm', body: { client: 'web' | 'mobile'; returnTo?: string }) => diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 9f91993..81d9a9a 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -9,3 +9,4 @@ export * from './links'; export * from './qr'; export * from './progress'; export * from './reactions'; +export * from './lyrics'; diff --git a/packages/shared/src/lyrics.test.ts b/packages/shared/src/lyrics.test.ts new file mode 100644 index 0000000..cfb4eed --- /dev/null +++ b/packages/shared/src/lyrics.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; +import { currentLyricIndex, parseLrc } from './lyrics'; + +describe('parseLrc', () => { + it('reads timestamps, skips tags, and sorts lines repeated by several tags', () => { + const lines = parseLrc('[ar:M83]\n[00:12.50] Waiting in a car\n[00:05.00][01:00.1]Chorus\n[00:20.00]\n\nnot a lyric'); + expect(lines).toEqual([ + { timeMs: 5000, text: 'Chorus' }, + { timeMs: 12500, text: 'Waiting in a car' }, + { timeMs: 20000, text: '' }, + { timeMs: 60100, text: 'Chorus' }, + ]); + }); +}); + +describe('currentLyricIndex', () => { + const lines = parseLrc('[00:05.00]a\n[00:10.00]b\n[00:15.00]c'); + it('finds the line being sung', () => { + expect(currentLyricIndex(lines, 0)).toBe(-1); + expect(currentLyricIndex(lines, 5000)).toBe(0); + expect(currentLyricIndex(lines, 14_999)).toBe(1); + expect(currentLyricIndex(lines, 99_000)).toBe(2); + expect(currentLyricIndex([], 1000)).toBe(-1); + }); +}); diff --git a/packages/shared/src/lyrics.ts b/packages/shared/src/lyrics.ts new file mode 100644 index 0000000..d498e6d --- /dev/null +++ b/packages/shared/src/lyrics.ts @@ -0,0 +1,47 @@ +/** One line of time-synced lyrics; empty text is an instrumental gap. */ +export interface LyricLine { + timeMs: number; + text: string; +} + +export interface LyricsDTO { + /** Lines with timestamps, when the song has synced lyrics. */ + synced: LyricLine[] | null; + /** Plain text, when there are no synced lyrics (or as a fallback). */ + plain: string | null; + instrumental: boolean; + /** Where they came from, for attribution. */ + source: { name: string; url: string }; +} + +const TIME_TAG = /\[(\d{1,3}):(\d{1,2}(?:\.\d{1,3})?)\]/g; + +/** + * Parse LRC ("[01:23.45] words"). A line may carry several time tags; tags + * that aren't times (like [ar:Artist]) are skipped. Sorted by time. + */ +export function parseLrc(lrc: string): LyricLine[] { + const lines: LyricLine[] = []; + for (const raw of lrc.split(/\r?\n/)) { + const times = [...raw.matchAll(TIME_TAG)]; + if (times.length === 0) continue; + const text = raw.replace(TIME_TAG, '').trim(); + for (const [, min, sec] of times) lines.push({ timeMs: Math.round((Number(min) * 60 + Number(sec)) * 1000), text }); + } + return lines.sort((a, b) => a.timeMs - b.timeMs); +} + +/** Index of the line being sung at `positionMs`, or -1 before the first one. */ +export function currentLyricIndex(lines: LyricLine[], positionMs: number): number { + let lo = 0; + let hi = lines.length - 1; + let found = -1; + while (lo <= hi) { + const mid = (lo + hi) >> 1; + if (lines[mid]!.timeMs <= positionMs) { + found = mid; + lo = mid + 1; + } else hi = mid - 1; + } + return found; +}