Apple Music API
This commit is contained in:
parent
f3119885bc
commit
b3979008ae
13 changed files with 1230 additions and 66 deletions
|
|
@ -14,3 +14,11 @@ CLOUDINARY_API_SECRET="your-api-secret"
|
||||||
|
|
||||||
# Admin Authentication (for later)
|
# Admin Authentication (for later)
|
||||||
ADMIN_PASSWORD="your-admin-password"
|
ADMIN_PASSWORD="your-admin-password"
|
||||||
|
|
||||||
|
# Apple Music API
|
||||||
|
APPLE_MUSIC_TEAM_ID="your-team-id"
|
||||||
|
APPLE_MUSIC_KEY_ID="your-key-id"
|
||||||
|
# For local development, use path:
|
||||||
|
APPLE_MUSIC_PRIVATE_KEY_PATH="path/to/your/private-key.p8"
|
||||||
|
# For production, paste the entire .p8 file content:
|
||||||
|
# APPLE_MUSIC_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\nMIGTAgEAMBMGByq...\n-----END PRIVATE KEY-----"
|
||||||
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -16,6 +16,10 @@ Thumbs.db
|
||||||
!.env.example
|
!.env.example
|
||||||
!.env.test
|
!.env.test
|
||||||
|
|
||||||
|
# Apple Music Private Keys
|
||||||
|
keys/
|
||||||
|
*.p8
|
||||||
|
|
||||||
# Vite
|
# Vite
|
||||||
vite.config.js.timestamp-*
|
vite.config.js.timestamp-*
|
||||||
vite.config.ts.timestamp-*
|
vite.config.ts.timestamp-*
|
||||||
|
|
|
||||||
104
package-lock.json
generated
104
package-lock.json
generated
|
|
@ -36,6 +36,7 @@
|
||||||
"@tiptap/pm": "^2.12.0",
|
"@tiptap/pm": "^2.12.0",
|
||||||
"@tiptap/starter-kit": "^2.12.0",
|
"@tiptap/starter-kit": "^2.12.0",
|
||||||
"@tiptap/suggestion": "^2.12.0",
|
"@tiptap/suggestion": "^2.12.0",
|
||||||
|
"@types/jsonwebtoken": "^9.0.9",
|
||||||
"@types/multer": "^1.4.12",
|
"@types/multer": "^1.4.12",
|
||||||
"@types/redis": "^4.0.10",
|
"@types/redis": "^4.0.10",
|
||||||
"@types/steamapi": "^2.2.5",
|
"@types/steamapi": "^2.2.5",
|
||||||
|
|
@ -45,6 +46,7 @@
|
||||||
"giantbombing-api": "^1.0.4",
|
"giantbombing-api": "^1.0.4",
|
||||||
"gray-matter": "^4.0.3",
|
"gray-matter": "^4.0.3",
|
||||||
"ioredis": "^5.4.1",
|
"ioredis": "^5.4.1",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
"katex": "^0.16.22",
|
"katex": "^0.16.22",
|
||||||
"lowlight": "^3.3.0",
|
"lowlight": "^3.3.0",
|
||||||
"lucide-svelte": "^0.511.0",
|
"lucide-svelte": "^0.511.0",
|
||||||
|
|
@ -2875,6 +2877,15 @@
|
||||||
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
|
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/jsonwebtoken": {
|
||||||
|
"version": "9.0.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.9.tgz",
|
||||||
|
"integrity": "sha512-uoe+GxEuHbvy12OUQct2X9JenKM3qAscquYymuQN4fMWG9DBQtykrQEFcAbVACF7qaLw9BePSodUL0kquqBJpQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/ms": "*",
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/linkify-it": {
|
"node_modules/@types/linkify-it": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz",
|
||||||
|
|
@ -2910,6 +2921,11 @@
|
||||||
"integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==",
|
"integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/ms": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="
|
||||||
|
},
|
||||||
"node_modules/@types/multer": {
|
"node_modules/@types/multer": {
|
||||||
"version": "1.4.12",
|
"version": "1.4.12",
|
||||||
"resolved": "https://registry.npmjs.org/@types/multer/-/multer-1.4.12.tgz",
|
"resolved": "https://registry.npmjs.org/@types/multer/-/multer-1.4.12.tgz",
|
||||||
|
|
@ -3655,6 +3671,11 @@
|
||||||
"node": ">=8.0.0"
|
"node": ">=8.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/buffer-equal-constant-time": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="
|
||||||
|
},
|
||||||
"node_modules/buffer-from": {
|
"node_modules/buffer-from": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||||
|
|
@ -4305,6 +4326,14 @@
|
||||||
"safer-buffer": "^2.1.0"
|
"safer-buffer": "^2.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ecdsa-sig-formatter": {
|
||||||
|
"version": "1.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
|
||||||
|
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/electron-to-chromium": {
|
"node_modules/electron-to-chromium": {
|
||||||
"version": "1.4.827",
|
"version": "1.4.827",
|
||||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.827.tgz",
|
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.827.tgz",
|
||||||
|
|
@ -5682,6 +5711,27 @@
|
||||||
"integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==",
|
"integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/jsonwebtoken": {
|
||||||
|
"version": "9.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz",
|
||||||
|
"integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"jws": "^3.2.2",
|
||||||
|
"lodash.includes": "^4.3.0",
|
||||||
|
"lodash.isboolean": "^3.0.3",
|
||||||
|
"lodash.isinteger": "^4.0.4",
|
||||||
|
"lodash.isnumber": "^3.0.3",
|
||||||
|
"lodash.isplainobject": "^4.0.6",
|
||||||
|
"lodash.isstring": "^4.0.1",
|
||||||
|
"lodash.once": "^4.0.0",
|
||||||
|
"ms": "^2.1.1",
|
||||||
|
"semver": "^7.5.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12",
|
||||||
|
"npm": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/jsprim": {
|
"node_modules/jsprim": {
|
||||||
"version": "1.4.2",
|
"version": "1.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.2.tgz",
|
||||||
|
|
@ -5697,6 +5747,25 @@
|
||||||
"node": ">=0.6.0"
|
"node": ">=0.6.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jwa": {
|
||||||
|
"version": "1.4.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz",
|
||||||
|
"integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==",
|
||||||
|
"dependencies": {
|
||||||
|
"buffer-equal-constant-time": "^1.0.1",
|
||||||
|
"ecdsa-sig-formatter": "1.0.11",
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/jws": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==",
|
||||||
|
"dependencies": {
|
||||||
|
"jwa": "^1.4.1",
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/katex": {
|
"node_modules/katex": {
|
||||||
"version": "0.16.22",
|
"version": "0.16.22",
|
||||||
"resolved": "https://registry.npmjs.org/katex/-/katex-0.16.22.tgz",
|
"resolved": "https://registry.npmjs.org/katex/-/katex-0.16.22.tgz",
|
||||||
|
|
@ -5823,18 +5892,53 @@
|
||||||
"integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",
|
"integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/lodash.includes": {
|
||||||
|
"version": "4.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
|
||||||
|
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="
|
||||||
|
},
|
||||||
"node_modules/lodash.isarguments": {
|
"node_modules/lodash.isarguments": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",
|
||||||
"integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",
|
"integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/lodash.isboolean": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg=="
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isinteger": {
|
||||||
|
"version": "4.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
|
||||||
|
"integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA=="
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isnumber": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw=="
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isplainobject": {
|
||||||
|
"version": "4.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
|
||||||
|
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA=="
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isstring": {
|
||||||
|
"version": "4.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
|
||||||
|
"integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw=="
|
||||||
|
},
|
||||||
"node_modules/lodash.merge": {
|
"node_modules/lodash.merge": {
|
||||||
"version": "4.6.2",
|
"version": "4.6.2",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
||||||
"integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
|
"integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/lodash.once": {
|
||||||
|
"version": "4.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
|
||||||
|
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg=="
|
||||||
|
},
|
||||||
"node_modules/loupe": {
|
"node_modules/loupe": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.3.tgz",
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,7 @@
|
||||||
"@tiptap/pm": "^2.12.0",
|
"@tiptap/pm": "^2.12.0",
|
||||||
"@tiptap/starter-kit": "^2.12.0",
|
"@tiptap/starter-kit": "^2.12.0",
|
||||||
"@tiptap/suggestion": "^2.12.0",
|
"@tiptap/suggestion": "^2.12.0",
|
||||||
|
"@types/jsonwebtoken": "^9.0.9",
|
||||||
"@types/multer": "^1.4.12",
|
"@types/multer": "^1.4.12",
|
||||||
"@types/redis": "^4.0.10",
|
"@types/redis": "^4.0.10",
|
||||||
"@types/steamapi": "^2.2.5",
|
"@types/steamapi": "^2.2.5",
|
||||||
|
|
@ -90,6 +91,7 @@
|
||||||
"giantbombing-api": "^1.0.4",
|
"giantbombing-api": "^1.0.4",
|
||||||
"gray-matter": "^4.0.3",
|
"gray-matter": "^4.0.3",
|
||||||
"ioredis": "^5.4.1",
|
"ioredis": "^5.4.1",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
"katex": "^0.16.22",
|
"katex": "^0.16.22",
|
||||||
"lowlight": "^3.3.0",
|
"lowlight": "^3.3.0",
|
||||||
"lucide-svelte": "^0.511.0",
|
"lucide-svelte": "^0.511.0",
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,22 @@
|
||||||
# Product Requirements Document: URL Embed Functionality
|
# Product Requirements Document: URL Embed Functionality
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
This PRD outlines the implementation of URL paste functionality in the Editor that allows users to choose between displaying URLs as rich embed cards or simple links.
|
This PRD outlines the implementation of URL paste functionality in the Editor that allows users to choose between displaying URLs as rich embed cards or simple links.
|
||||||
|
|
||||||
## Background
|
## Background
|
||||||
|
|
||||||
Currently, the Editor supports various content types including text, images, and code blocks. Adding URL embed functionality will enhance the content creation experience by allowing users to share links with rich previews that include titles, descriptions, and images from the linked content.
|
Currently, the Editor supports various content types including text, images, and code blocks. Adding URL embed functionality will enhance the content creation experience by allowing users to share links with rich previews that include titles, descriptions, and images from the linked content.
|
||||||
|
|
||||||
## Goals
|
## Goals
|
||||||
|
|
||||||
1. Enable users to paste URLs and automatically convert them to rich embed cards
|
1. Enable users to paste URLs and automatically convert them to rich embed cards
|
||||||
2. Provide flexibility to display URLs as either embed cards or simple links
|
2. Provide flexibility to display URLs as either embed cards or simple links
|
||||||
3. Maintain consistency with existing UI/UX patterns
|
3. Maintain consistency with existing UI/UX patterns
|
||||||
4. Ensure performance with proper loading states and error handling
|
4. Ensure performance with proper loading states and error handling
|
||||||
|
|
||||||
## User Stories
|
## User Stories
|
||||||
|
|
||||||
1. **As a content creator**, I want to paste a URL and have it automatically display as a rich preview card so that my content is more engaging.
|
1. **As a content creator**, I want to paste a URL and have it automatically display as a rich preview card so that my content is more engaging.
|
||||||
2. **As a content creator**, I want to be able to choose between an embed card and a simple link so that I have control over how my content appears.
|
2. **As a content creator**, I want to be able to choose between an embed card and a simple link so that I have control over how my content appears.
|
||||||
3. **As a content creator**, I want to edit or remove URL embeds after adding them so that I can correct mistakes or update content.
|
3. **As a content creator**, I want to edit or remove URL embeds after adding them so that I can correct mistakes or update content.
|
||||||
|
|
@ -21,6 +25,7 @@ Currently, the Editor supports various content types including text, images, and
|
||||||
## Functional Requirements
|
## Functional Requirements
|
||||||
|
|
||||||
### URL Detection and Conversion
|
### URL Detection and Conversion
|
||||||
|
|
||||||
1. **Automatic Detection**: When a user pastes a plain URL (e.g., `https://example.com`), the system should:
|
1. **Automatic Detection**: When a user pastes a plain URL (e.g., `https://example.com`), the system should:
|
||||||
- Create a regular text link initially
|
- Create a regular text link initially
|
||||||
- Display a dropdown menu next to the cursor with the option to "Convert to embed"
|
- Display a dropdown menu next to the cursor with the option to "Convert to embed"
|
||||||
|
|
@ -32,6 +37,7 @@ Currently, the Editor supports various content types including text, images, and
|
||||||
- Direct input in placeholder
|
- Direct input in placeholder
|
||||||
|
|
||||||
### Embed Card Display
|
### Embed Card Display
|
||||||
|
|
||||||
1. **Metadata Fetching**: The system should fetch OpenGraph metadata including:
|
1. **Metadata Fetching**: The system should fetch OpenGraph metadata including:
|
||||||
- Title
|
- Title
|
||||||
- Description
|
- Description
|
||||||
|
|
@ -46,6 +52,7 @@ Currently, the Editor supports various content types including text, images, and
|
||||||
3. **Fallback**: If metadata fetching fails, display a simple card with the URL
|
3. **Fallback**: If metadata fetching fails, display a simple card with the URL
|
||||||
|
|
||||||
### User Interactions
|
### User Interactions
|
||||||
|
|
||||||
1. **In-Editor Actions**:
|
1. **In-Editor Actions**:
|
||||||
- Refresh metadata
|
- Refresh metadata
|
||||||
- Open link in new tab
|
- Open link in new tab
|
||||||
|
|
@ -55,6 +62,7 @@ Currently, the Editor supports various content types including text, images, and
|
||||||
3. **Error Handling**: Display user-friendly error messages
|
3. **Error Handling**: Display user-friendly error messages
|
||||||
|
|
||||||
### Content Rendering
|
### Content Rendering
|
||||||
|
|
||||||
1. **Editor View**: Full interactive embed with action buttons
|
1. **Editor View**: Full interactive embed with action buttons
|
||||||
2. **Published View**: Static card with clickable elements
|
2. **Published View**: Static card with clickable elements
|
||||||
3. **Responsive Design**: Cards should adapt to different screen sizes
|
3. **Responsive Design**: Cards should adapt to different screen sizes
|
||||||
|
|
@ -62,12 +70,15 @@ Currently, the Editor supports various content types including text, images, and
|
||||||
## Technical Implementation
|
## Technical Implementation
|
||||||
|
|
||||||
### Architecture
|
### Architecture
|
||||||
|
|
||||||
1. **TipTap Extensions**:
|
1. **TipTap Extensions**:
|
||||||
|
|
||||||
- `UrlEmbed`: Main node extension for URL detection and schema
|
- `UrlEmbed`: Main node extension for URL detection and schema
|
||||||
- `UrlEmbedPlaceholder`: Temporary node during loading
|
- `UrlEmbedPlaceholder`: Temporary node during loading
|
||||||
- `UrlEmbedExtended`: Final node with metadata
|
- `UrlEmbedExtended`: Final node with metadata
|
||||||
|
|
||||||
2. **Components**:
|
2. **Components**:
|
||||||
|
|
||||||
- `UrlEmbedPlaceholder.svelte`: Loading/input UI
|
- `UrlEmbedPlaceholder.svelte`: Loading/input UI
|
||||||
- `UrlEmbedExtended.svelte`: Rich preview card
|
- `UrlEmbedExtended.svelte`: Rich preview card
|
||||||
|
|
||||||
|
|
@ -76,29 +87,33 @@ Currently, the Editor supports various content types including text, images, and
|
||||||
- Implement caching to reduce redundant fetches
|
- Implement caching to reduce redundant fetches
|
||||||
|
|
||||||
### Data Model
|
### Data Model
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
interface UrlEmbedNode {
|
interface UrlEmbedNode {
|
||||||
type: 'urlEmbed';
|
type: 'urlEmbed'
|
||||||
attrs: {
|
attrs: {
|
||||||
url: string;
|
url: string
|
||||||
title?: string;
|
title?: string
|
||||||
description?: string;
|
description?: string
|
||||||
image?: string;
|
image?: string
|
||||||
siteName?: string;
|
siteName?: string
|
||||||
favicon?: string;
|
favicon?: string
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## UI/UX Specifications
|
## UI/UX Specifications
|
||||||
|
|
||||||
### Visual Design
|
### Visual Design
|
||||||
|
|
||||||
- Match existing `LinkCard` component styling
|
- Match existing `LinkCard` component styling
|
||||||
- Use established color variables and spacing
|
- Use established color variables and spacing
|
||||||
- Maintain consistency with overall site design
|
- Maintain consistency with overall site design
|
||||||
|
|
||||||
### Interaction Patterns
|
### Interaction Patterns
|
||||||
|
|
||||||
1. **Paste Flow**:
|
1. **Paste Flow**:
|
||||||
|
|
||||||
- User pastes URL
|
- User pastes URL
|
||||||
- URL appears as regular link text
|
- URL appears as regular link text
|
||||||
- Dropdown menu appears next to cursor with "Convert to embed" option
|
- Dropdown menu appears next to cursor with "Convert to embed" option
|
||||||
|
|
@ -116,23 +131,27 @@ interface UrlEmbedNode {
|
||||||
- Same loading/rendering flow as paste
|
- Same loading/rendering flow as paste
|
||||||
|
|
||||||
## Performance Considerations
|
## Performance Considerations
|
||||||
|
|
||||||
1. **Lazy Loading**: Only fetch metadata when URL is added
|
1. **Lazy Loading**: Only fetch metadata when URL is added
|
||||||
2. **Caching**: Cache fetched metadata to avoid redundant API calls
|
2. **Caching**: Cache fetched metadata to avoid redundant API calls
|
||||||
3. **Timeout**: Implement reasonable timeout for metadata fetching
|
3. **Timeout**: Implement reasonable timeout for metadata fetching
|
||||||
4. **Image Optimization**: Consider lazy loading preview images
|
4. **Image Optimization**: Consider lazy loading preview images
|
||||||
|
|
||||||
## Security Considerations
|
## Security Considerations
|
||||||
|
|
||||||
1. **URL Validation**: Validate URLs before fetching metadata
|
1. **URL Validation**: Validate URLs before fetching metadata
|
||||||
2. **Content Sanitization**: Sanitize fetched metadata to prevent XSS
|
2. **Content Sanitization**: Sanitize fetched metadata to prevent XSS
|
||||||
3. **CORS Handling**: Properly handle cross-origin requests
|
3. **CORS Handling**: Properly handle cross-origin requests
|
||||||
|
|
||||||
## Success Metrics
|
## Success Metrics
|
||||||
|
|
||||||
1. **Adoption Rate**: Percentage of posts using URL embeds
|
1. **Adoption Rate**: Percentage of posts using URL embeds
|
||||||
2. **Error Rate**: Frequency of metadata fetch failures
|
2. **Error Rate**: Frequency of metadata fetch failures
|
||||||
3. **Performance**: Average time to fetch and display metadata
|
3. **Performance**: Average time to fetch and display metadata
|
||||||
4. **User Satisfaction**: Feedback on embed functionality
|
4. **User Satisfaction**: Feedback on embed functionality
|
||||||
|
|
||||||
## Future Enhancements
|
## Future Enhancements
|
||||||
|
|
||||||
1. **Custom Previews**: Allow manual editing of metadata
|
1. **Custom Previews**: Allow manual editing of metadata
|
||||||
2. **Platform-Specific Embeds**: Special handling for YouTube, Twitter, etc.
|
2. **Platform-Specific Embeds**: Special handling for YouTube, Twitter, etc.
|
||||||
3. **Embed Templates**: Different card styles for different content types
|
3. **Embed Templates**: Different card styles for different content types
|
||||||
|
|
@ -140,9 +159,11 @@ interface UrlEmbedNode {
|
||||||
## Timeline
|
## Timeline
|
||||||
|
|
||||||
### Phase 1: Core Functionality
|
### Phase 1: Core Functionality
|
||||||
|
|
||||||
**Status**: In Progress
|
**Status**: In Progress
|
||||||
|
|
||||||
#### Completed Tasks:
|
#### Completed Tasks:
|
||||||
|
|
||||||
- [x] Create TipTap extension for URL detection (`UrlEmbed.ts`)
|
- [x] Create TipTap extension for URL detection (`UrlEmbed.ts`)
|
||||||
- [x] Create placeholder component for loading state (`UrlEmbedPlaceholder.svelte`)
|
- [x] Create placeholder component for loading state (`UrlEmbedPlaceholder.svelte`)
|
||||||
- [x] Create extended component for rich preview (`UrlEmbedExtended.svelte`)
|
- [x] Create extended component for rich preview (`UrlEmbedExtended.svelte`)
|
||||||
|
|
@ -154,6 +175,7 @@ interface UrlEmbedNode {
|
||||||
- [x] Add content rendering for published posts
|
- [x] Add content rendering for published posts
|
||||||
|
|
||||||
#### Remaining Tasks:
|
#### Remaining Tasks:
|
||||||
|
|
||||||
- [x] Implement paste detection with dropdown menu
|
- [x] Implement paste detection with dropdown menu
|
||||||
- [x] Create dropdown component for "Convert to embed" option
|
- [x] Create dropdown component for "Convert to embed" option
|
||||||
- [x] Add convert between embed/link functionality
|
- [x] Add convert between embed/link functionality
|
||||||
|
|
@ -163,20 +185,25 @@ interface UrlEmbedNode {
|
||||||
- [ ] Update documentation
|
- [ ] Update documentation
|
||||||
|
|
||||||
### Phase 2: Platform-Specific Embeds
|
### Phase 2: Platform-Specific Embeds
|
||||||
|
|
||||||
**Status**: Future
|
**Status**: Future
|
||||||
|
|
||||||
- [ ] YouTube video embeds with player
|
- [ ] YouTube video embeds with player
|
||||||
- [ ] Twitter/X post embeds
|
- [ ] Twitter/X post embeds
|
||||||
- [ ] Instagram post embeds
|
- [ ] Instagram post embeds
|
||||||
- [ ] GitHub repository/gist embeds
|
- [ ] GitHub repository/gist embeds
|
||||||
|
|
||||||
### Phase 3: Advanced Customization
|
### Phase 3: Advanced Customization
|
||||||
|
|
||||||
**Status**: Future
|
**Status**: Future
|
||||||
|
|
||||||
- [ ] Custom preview editing
|
- [ ] Custom preview editing
|
||||||
- [ ] Multiple embed templates/styles
|
- [ ] Multiple embed templates/styles
|
||||||
- [ ] Embed size options (compact/full)
|
- [ ] Embed size options (compact/full)
|
||||||
- [ ] Custom CSS for embeds
|
- [ ] Custom CSS for embeds
|
||||||
|
|
||||||
## Dependencies
|
## Dependencies
|
||||||
|
|
||||||
- Existing `/api/og-metadata` endpoint
|
- Existing `/api/og-metadata` endpoint
|
||||||
- TipTap editor framework
|
- TipTap editor framework
|
||||||
- Svelte 5 with runes mode
|
- Svelte 5 with runes mode
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { spring } from 'svelte/motion'
|
import { spring } from 'svelte/motion'
|
||||||
import type { Album } from '$lib/types/lastfm'
|
import type { Album } from '$lib/types/lastfm'
|
||||||
|
import { audioPreview } from '$lib/stores/audio-preview'
|
||||||
|
|
||||||
interface AlbumProps {
|
interface AlbumProps {
|
||||||
album?: Album
|
album?: Album
|
||||||
|
|
@ -9,6 +10,19 @@
|
||||||
let { album = undefined }: AlbumProps = $props()
|
let { album = undefined }: AlbumProps = $props()
|
||||||
|
|
||||||
let isHovering = $state(false)
|
let isHovering = $state(false)
|
||||||
|
let audio: HTMLAudioElement | null = $state(null)
|
||||||
|
|
||||||
|
// Create a unique ID for this album
|
||||||
|
const albumId = $derived(album ? `${album.artist.name}-${album.name}` : '')
|
||||||
|
|
||||||
|
// Subscribe to the store to know if this album is playing
|
||||||
|
let isPlaying = $state(false)
|
||||||
|
$effect(() => {
|
||||||
|
const unsubscribe = audioPreview.subscribe(state => {
|
||||||
|
isPlaying = state.currentAlbumId === albumId && state.isPlaying
|
||||||
|
})
|
||||||
|
return unsubscribe
|
||||||
|
})
|
||||||
|
|
||||||
const scale = spring(1, {
|
const scale = spring(1, {
|
||||||
stiffness: 0.2,
|
stiffness: 0.2,
|
||||||
|
|
@ -22,31 +36,104 @@
|
||||||
scale.set(1)
|
scale.set(1)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
async function togglePreview(e: Event) {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
|
||||||
|
if (!audio && album?.appleMusicData?.previewUrl) {
|
||||||
|
audio = new Audio(album.appleMusicData.previewUrl)
|
||||||
|
audio.addEventListener('ended', () => {
|
||||||
|
audioPreview.stop()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (audio) {
|
||||||
|
if (isPlaying) {
|
||||||
|
audioPreview.stop()
|
||||||
|
} else {
|
||||||
|
// Update the store first, then play
|
||||||
|
audioPreview.play(audio, albumId)
|
||||||
|
try {
|
||||||
|
await audio.play()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to play preview:', error)
|
||||||
|
audioPreview.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
// Cleanup audio when component unmounts
|
||||||
|
return () => {
|
||||||
|
if (audio && isPlaying) {
|
||||||
|
audioPreview.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Use high-res artwork if available
|
||||||
|
const artworkUrl = $derived(
|
||||||
|
album?.appleMusicData?.highResArtwork || album?.images.itunes || album?.images.mega || ''
|
||||||
|
)
|
||||||
|
|
||||||
|
const hasPreview = $derived(!!album?.appleMusicData?.previewUrl)
|
||||||
|
|
||||||
|
// Debug log
|
||||||
|
$effect(() => {
|
||||||
|
if (album) {
|
||||||
|
console.log(`Album ${album.name}:`, {
|
||||||
|
hasAppleMusicData: !!album.appleMusicData,
|
||||||
|
previewUrl: album.appleMusicData?.previewUrl,
|
||||||
|
hasPreview
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="album">
|
<div class="album">
|
||||||
{#if album}
|
{#if album}
|
||||||
<a
|
<div class="album-wrapper">
|
||||||
href={album.url}
|
<a
|
||||||
target="_blank"
|
href={album.url}
|
||||||
rel="noopener noreferrer"
|
target="_blank"
|
||||||
onmouseenter={() => (isHovering = true)}
|
rel="noopener noreferrer"
|
||||||
onmouseleave={() => (isHovering = false)}
|
onmouseenter={() => (isHovering = true)}
|
||||||
>
|
onmouseleave={() => (isHovering = false)}
|
||||||
<img
|
>
|
||||||
src={album.images.itunes ? album.images.itunes : album.images.mega}
|
<div class="artwork-container">
|
||||||
alt={album.name}
|
<img
|
||||||
style="transform: scale({$scale})"
|
src={artworkUrl}
|
||||||
/>
|
alt={album.name}
|
||||||
<div class="info">
|
style="transform: scale({$scale})"
|
||||||
<span class="album-name">
|
loading="lazy"
|
||||||
{album.name}
|
/>
|
||||||
</span>
|
{#if hasPreview && isHovering}
|
||||||
<p class="artist-name">
|
<button
|
||||||
{album.artist.name}
|
class="preview-button"
|
||||||
</p>
|
onclick={togglePreview}
|
||||||
</div>
|
aria-label={isPlaying ? 'Pause preview' : 'Play preview'}
|
||||||
</a>
|
class:playing={isPlaying}
|
||||||
|
>
|
||||||
|
{#if isPlaying}
|
||||||
|
<span aria-hidden="true">❚❚</span>
|
||||||
|
{:else}
|
||||||
|
<span aria-hidden="true">▶</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="info">
|
||||||
|
<span class="album-name">
|
||||||
|
{album.name}
|
||||||
|
</span>
|
||||||
|
<p class="artist-name">
|
||||||
|
{album.artist.name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<p>No album provided</p>
|
<p>No album provided</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -57,6 +144,14 @@
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|
||||||
|
.album-wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $unit;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
@ -66,6 +161,12 @@
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|
||||||
|
.artwork-container {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
img {
|
img {
|
||||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||||
border-radius: $unit;
|
border-radius: $unit;
|
||||||
|
|
@ -75,6 +176,34 @@
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.preview-button {
|
||||||
|
position: absolute;
|
||||||
|
bottom: $unit;
|
||||||
|
right: $unit;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
background: rgba(0, 0, 0, 0.8);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 16px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.9);
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.playing {
|
||||||
|
background: $accent-color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.info {
|
.info {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
@ -103,5 +232,9 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.preview-container {
|
||||||
|
margin-top: $unit;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
350
src/lib/components/MusicPreview.svelte
Normal file
350
src/lib/components/MusicPreview.svelte
Normal file
|
|
@ -0,0 +1,350 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { onMount, onDestroy } from 'svelte'
|
||||||
|
import { fade } from 'svelte/transition'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
previewUrl: string
|
||||||
|
albumName?: string
|
||||||
|
artistName?: string
|
||||||
|
onPlayStateChange?: (isPlaying: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
let { previewUrl, albumName = '', artistName = '', onPlayStateChange }: Props = $props()
|
||||||
|
|
||||||
|
let audio: HTMLAudioElement | null = $state(null)
|
||||||
|
let isPlaying = $state(false)
|
||||||
|
let isLoading = $state(false)
|
||||||
|
let currentTime = $state(0)
|
||||||
|
let duration = $state(30) // Apple Music previews are 30 seconds
|
||||||
|
let volume = $state(1)
|
||||||
|
let hasError = $state(false)
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (audio) {
|
||||||
|
audio.volume = volume
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
onPlayStateChange?.(isPlaying)
|
||||||
|
})
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
// Listen for other audio elements playing
|
||||||
|
const handleAudioPlay = (e: Event) => {
|
||||||
|
const playingAudio = e.target as HTMLAudioElement
|
||||||
|
if (playingAudio !== audio && audio && !audio.paused) {
|
||||||
|
pause()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('play', handleAudioPlay, true)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('play', handleAudioPlay, true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
if (audio) {
|
||||||
|
audio.pause()
|
||||||
|
audio = null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function togglePlayPause() {
|
||||||
|
if (isPlaying) {
|
||||||
|
pause()
|
||||||
|
} else {
|
||||||
|
play()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function play() {
|
||||||
|
if (!audio || hasError) return
|
||||||
|
|
||||||
|
isLoading = true
|
||||||
|
try {
|
||||||
|
await audio.play()
|
||||||
|
isPlaying = true
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to play preview:', error)
|
||||||
|
hasError = true
|
||||||
|
} finally {
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pause() {
|
||||||
|
if (!audio) return
|
||||||
|
audio.pause()
|
||||||
|
isPlaying = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTimeUpdate() {
|
||||||
|
if (audio) {
|
||||||
|
currentTime = audio.currentTime
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEnded() {
|
||||||
|
isPlaying = false
|
||||||
|
currentTime = 0
|
||||||
|
if (audio) {
|
||||||
|
audio.currentTime = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleError() {
|
||||||
|
hasError = true
|
||||||
|
isPlaying = false
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLoadedMetadata() {
|
||||||
|
if (audio) {
|
||||||
|
duration = audio.duration
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function seek(e: MouseEvent) {
|
||||||
|
const target = e.currentTarget as HTMLElement
|
||||||
|
const rect = target.getBoundingClientRect()
|
||||||
|
const x = e.clientX - rect.left
|
||||||
|
const percentage = x / rect.width
|
||||||
|
const newTime = percentage * duration
|
||||||
|
|
||||||
|
if (audio) {
|
||||||
|
audio.currentTime = newTime
|
||||||
|
currentTime = newTime
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.code === 'Space') {
|
||||||
|
e.preventDefault()
|
||||||
|
togglePlayPause()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(time: number): string {
|
||||||
|
const minutes = Math.floor(time / 60)
|
||||||
|
const seconds = Math.floor(time % 60)
|
||||||
|
return `${minutes}:${seconds.toString().padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const progressPercentage = $derived((currentTime / duration) * 100)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="music-preview" role="region" aria-label="Music preview player">
|
||||||
|
<audio
|
||||||
|
bind:this={audio}
|
||||||
|
src={previewUrl}
|
||||||
|
onloadedmetadata={handleLoadedMetadata}
|
||||||
|
ontimeupdate={handleTimeUpdate}
|
||||||
|
onended={handleEnded}
|
||||||
|
onerror={handleError}
|
||||||
|
preload="metadata"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="controls">
|
||||||
|
<button
|
||||||
|
class="play-button"
|
||||||
|
onclick={togglePlayPause}
|
||||||
|
onkeydown={handleKeydown}
|
||||||
|
disabled={hasError || isLoading}
|
||||||
|
aria-label={isPlaying ? 'Pause' : 'Play'}
|
||||||
|
aria-pressed={isPlaying}
|
||||||
|
>
|
||||||
|
{#if isLoading}
|
||||||
|
<span class="loading-spinner" aria-hidden="true">⟳</span>
|
||||||
|
{:else if hasError}
|
||||||
|
<span aria-hidden="true">⚠</span>
|
||||||
|
{:else if isPlaying}
|
||||||
|
<span aria-hidden="true">❚❚</span>
|
||||||
|
{:else}
|
||||||
|
<span aria-hidden="true">▶</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="progress-container">
|
||||||
|
<div
|
||||||
|
class="progress-bar"
|
||||||
|
onclick={seek}
|
||||||
|
role="slider"
|
||||||
|
aria-label="Seek"
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={duration}
|
||||||
|
aria-valuenow={currentTime}
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
|
<div class="progress-fill" style="width: {progressPercentage}%"></div>
|
||||||
|
</div>
|
||||||
|
<div class="time-display">
|
||||||
|
<span>{formatTime(currentTime)}</span>
|
||||||
|
<span>/</span>
|
||||||
|
<span>{formatTime(duration)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="volume-control">
|
||||||
|
<label for="volume" class="visually-hidden">Volume</label>
|
||||||
|
<input
|
||||||
|
id="volume"
|
||||||
|
type="range"
|
||||||
|
bind:value={volume}
|
||||||
|
min="0"
|
||||||
|
max="1"
|
||||||
|
step="0.1"
|
||||||
|
aria-label="Volume control"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if hasError}
|
||||||
|
<p class="error-message" transition:fade={{ duration: 200 }}>Preview unavailable</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.music-preview {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
padding: var(--spacing-sm);
|
||||||
|
background: var(--color-surface);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
padding: 0;
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: var(--color-primary-contrast);
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
transform: scale(1.05);
|
||||||
|
background: var(--color-primary-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-spinner {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-container {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-2xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
position: relative;
|
||||||
|
height: 6px;
|
||||||
|
background: var(--color-surface-secondary);
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&:hover .progress-fill {
|
||||||
|
background: var(--color-primary-hover);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-fill {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--color-primary);
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-display {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-2xs);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume-control {
|
||||||
|
width: 80px;
|
||||||
|
|
||||||
|
input[type='range'] {
|
||||||
|
width: 100%;
|
||||||
|
height: 6px;
|
||||||
|
background: var(--color-surface-secondary);
|
||||||
|
border-radius: 3px;
|
||||||
|
outline: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
|
||||||
|
&::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
background: var(--color-primary);
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-moz-range-thumb {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
background: var(--color-primary);
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
margin: 0;
|
||||||
|
padding: var(--spacing-xs);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--color-error);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.visually-hidden {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
67
src/lib/server/apple-music-auth.ts
Normal file
67
src/lib/server/apple-music-auth.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
import jwt from 'jsonwebtoken'
|
||||||
|
import { readFileSync } from 'fs'
|
||||||
|
import { env } from '$env/dynamic/private'
|
||||||
|
|
||||||
|
let cachedToken: string | null = null
|
||||||
|
let tokenExpiry: Date | null = null
|
||||||
|
|
||||||
|
export function generateAppleMusicToken(): string {
|
||||||
|
// Check if we have a valid cached token
|
||||||
|
if (cachedToken && tokenExpiry && tokenExpiry > new Date()) {
|
||||||
|
console.log('Using cached Apple Music token')
|
||||||
|
return cachedToken
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Generating new Apple Music token...')
|
||||||
|
console.log('Team ID:', env.APPLE_MUSIC_TEAM_ID)
|
||||||
|
console.log('Key ID:', env.APPLE_MUSIC_KEY_ID)
|
||||||
|
console.log('Key path configured:', !!env.APPLE_MUSIC_PRIVATE_KEY_PATH)
|
||||||
|
|
||||||
|
// Read the private key - support both file path and direct content
|
||||||
|
let privateKey: string
|
||||||
|
if (env.APPLE_MUSIC_PRIVATE_KEY) {
|
||||||
|
// Direct key content from environment variable
|
||||||
|
privateKey = env.APPLE_MUSIC_PRIVATE_KEY
|
||||||
|
} else if (env.APPLE_MUSIC_PRIVATE_KEY_PATH) {
|
||||||
|
// File path (for local development)
|
||||||
|
try {
|
||||||
|
privateKey = readFileSync(env.APPLE_MUSIC_PRIVATE_KEY_PATH, 'utf8')
|
||||||
|
console.log('Successfully read private key from file')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to read private key file:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error('Apple Music private key not configured')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Token expires in 6 months (max allowed by Apple)
|
||||||
|
const expiresIn = 15552000 // 180 days in seconds
|
||||||
|
|
||||||
|
// Generate the token
|
||||||
|
const token = jwt.sign({}, privateKey, {
|
||||||
|
algorithm: 'ES256',
|
||||||
|
expiresIn,
|
||||||
|
issuer: env.APPLE_MUSIC_TEAM_ID!,
|
||||||
|
header: {
|
||||||
|
alg: 'ES256',
|
||||||
|
kid: env.APPLE_MUSIC_KEY_ID!
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Cache the token
|
||||||
|
cachedToken = token
|
||||||
|
tokenExpiry = new Date(Date.now() + expiresIn * 1000)
|
||||||
|
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAppleMusicHeaders(): Record<string, string> {
|
||||||
|
return {
|
||||||
|
Authorization: `Bearer ${generateAppleMusicToken()}`,
|
||||||
|
'Music-User-Token': '', // Will be needed for user-specific features
|
||||||
|
Accept: 'application/json',
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
255
src/lib/server/apple-music-client.ts
Normal file
255
src/lib/server/apple-music-client.ts
Normal file
|
|
@ -0,0 +1,255 @@
|
||||||
|
import { getAppleMusicHeaders } from './apple-music-auth'
|
||||||
|
import type {
|
||||||
|
AppleMusicAlbum,
|
||||||
|
AppleMusicTrack,
|
||||||
|
AppleMusicSearchResponse,
|
||||||
|
AppleMusicErrorResponse
|
||||||
|
} from '$lib/types/apple-music'
|
||||||
|
import { isAppleMusicError } from '$lib/types/apple-music'
|
||||||
|
|
||||||
|
const APPLE_MUSIC_API_BASE = 'https://api.music.apple.com/v1'
|
||||||
|
const DEFAULT_STOREFRONT = 'us' // Default to US storefront
|
||||||
|
const RATE_LIMIT_DELAY = 200 // 200ms between requests to stay well under 3000/hour
|
||||||
|
|
||||||
|
let lastRequestTime = 0
|
||||||
|
|
||||||
|
async function rateLimitedFetch(url: string, options?: RequestInit): Promise<Response> {
|
||||||
|
const now = Date.now()
|
||||||
|
const timeSinceLastRequest = now - lastRequestTime
|
||||||
|
|
||||||
|
if (timeSinceLastRequest < RATE_LIMIT_DELAY) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, RATE_LIMIT_DELAY - timeSinceLastRequest))
|
||||||
|
}
|
||||||
|
|
||||||
|
lastRequestTime = Date.now()
|
||||||
|
return fetch(url, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function makeAppleMusicRequest<T>(endpoint: string): Promise<T> {
|
||||||
|
const url = `${APPLE_MUSIC_API_BASE}${endpoint}`
|
||||||
|
const headers = getAppleMusicHeaders()
|
||||||
|
|
||||||
|
console.log('Making Apple Music API request:', {
|
||||||
|
url,
|
||||||
|
headers: {
|
||||||
|
...headers,
|
||||||
|
Authorization: headers.Authorization ? 'Bearer [TOKEN]' : 'Missing'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await rateLimitedFetch(url, { headers })
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text()
|
||||||
|
console.error('Apple Music API error response:', {
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
body: errorText
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
const errorData = JSON.parse(errorText)
|
||||||
|
if (isAppleMusicError(errorData)) {
|
||||||
|
throw new Error(`Apple Music API Error: ${errorData.errors[0]?.detail || 'Unknown error'}`)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// If not JSON, throw the text error
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`HTTP ${response.status}: ${response.statusText} - ${errorText}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Apple Music API request failed:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchAlbums(
|
||||||
|
query: string,
|
||||||
|
limit: number = 10
|
||||||
|
): Promise<AppleMusicSearchResponse> {
|
||||||
|
const encodedQuery = encodeURIComponent(query)
|
||||||
|
const endpoint = `/catalog/${DEFAULT_STOREFRONT}/search?types=albums&term=${encodedQuery}&limit=${limit}`
|
||||||
|
|
||||||
|
return makeAppleMusicRequest<AppleMusicSearchResponse>(endpoint)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchTracks(
|
||||||
|
query: string,
|
||||||
|
limit: number = 10
|
||||||
|
): Promise<AppleMusicSearchResponse> {
|
||||||
|
const encodedQuery = encodeURIComponent(query)
|
||||||
|
const endpoint = `/catalog/${DEFAULT_STOREFRONT}/search?types=songs&term=${encodedQuery}&limit=${limit}`
|
||||||
|
|
||||||
|
return makeAppleMusicRequest<AppleMusicSearchResponse>(endpoint)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAlbum(id: string): Promise<{ data: AppleMusicAlbum[] }> {
|
||||||
|
const endpoint = `/catalog/${DEFAULT_STOREFRONT}/albums/${id}`
|
||||||
|
return makeAppleMusicRequest<{ data: AppleMusicAlbum[] }>(endpoint)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAlbumWithTracks(id: string): Promise<{ data: AppleMusicAlbum[] }> {
|
||||||
|
const endpoint = `/catalog/${DEFAULT_STOREFRONT}/albums/${id}?include=tracks`
|
||||||
|
return makeAppleMusicRequest<{ data: AppleMusicAlbum[] }>(endpoint)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get album with all details including tracks for preview URLs
|
||||||
|
export async function getAlbumDetails(id: string): Promise<AppleMusicAlbum | null> {
|
||||||
|
try {
|
||||||
|
const endpoint = `/catalog/${DEFAULT_STOREFRONT}/albums/${id}?include=tracks`
|
||||||
|
const response = await makeAppleMusicRequest<{ data: AppleMusicAlbum[]; included?: AppleMusicTrack[] }>(endpoint)
|
||||||
|
|
||||||
|
console.log(`Album details for ${id}:`, {
|
||||||
|
hasData: !!response.data?.[0],
|
||||||
|
hasRelationships: !!response.data?.[0]?.relationships,
|
||||||
|
hasTracks: !!response.data?.[0]?.relationships?.tracks,
|
||||||
|
hasIncluded: !!response.included,
|
||||||
|
includedCount: response.included?.length || 0
|
||||||
|
})
|
||||||
|
|
||||||
|
// Check if tracks are in the included array
|
||||||
|
if (response.included?.length) {
|
||||||
|
console.log('First included track:', JSON.stringify(response.included[0], null, 2))
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.data?.[0] || null
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to get album details for ID ${id}:`, error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTrack(id: string): Promise<{ data: AppleMusicTrack[] }> {
|
||||||
|
const endpoint = `/catalog/${DEFAULT_STOREFRONT}/songs/${id}`
|
||||||
|
return makeAppleMusicRequest<{ data: AppleMusicTrack[] }>(endpoint)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to search for an album by artist and album name
|
||||||
|
export async function findAlbum(artist: string, album: string): Promise<AppleMusicAlbum | null> {
|
||||||
|
try {
|
||||||
|
const searchQuery = `${artist} ${album}`
|
||||||
|
const response = await searchAlbums(searchQuery, 5)
|
||||||
|
|
||||||
|
console.log(`Search results for "${searchQuery}":`, JSON.stringify(response, null, 2))
|
||||||
|
|
||||||
|
if (!response.results?.albums?.data?.length) {
|
||||||
|
console.log('No albums found in search results')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to find the best match
|
||||||
|
const albums = response.results.albums.data
|
||||||
|
console.log(`Found ${albums.length} albums`)
|
||||||
|
|
||||||
|
// First try exact match
|
||||||
|
let match = albums.find(
|
||||||
|
(a) =>
|
||||||
|
a.attributes?.name?.toLowerCase() === album.toLowerCase() &&
|
||||||
|
a.attributes?.artistName?.toLowerCase() === artist.toLowerCase()
|
||||||
|
)
|
||||||
|
|
||||||
|
// If no exact match, try partial match
|
||||||
|
if (!match) {
|
||||||
|
match = albums.find(
|
||||||
|
(a) =>
|
||||||
|
a.attributes?.name?.toLowerCase().includes(album.toLowerCase()) &&
|
||||||
|
a.attributes?.artistName?.toLowerCase().includes(artist.toLowerCase())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return first result if no good match
|
||||||
|
return match || albums[0]
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to find album "${album}" by "${artist}":`, error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transform Apple Music album data to match existing format
|
||||||
|
export async function transformAlbumData(appleMusicAlbum: AppleMusicAlbum) {
|
||||||
|
const attributes = appleMusicAlbum.attributes
|
||||||
|
|
||||||
|
// Get preview URL from tracks if album doesn't have one
|
||||||
|
let previewUrl = attributes.previews?.[0]?.url
|
||||||
|
let tracks: Array<{ name: string; previewUrl?: string }> = []
|
||||||
|
|
||||||
|
// Always fetch tracks to get preview URLs
|
||||||
|
if (appleMusicAlbum.id) {
|
||||||
|
try {
|
||||||
|
// Fetch album details with tracks
|
||||||
|
const endpoint = `/catalog/${DEFAULT_STOREFRONT}/albums/${appleMusicAlbum.id}?include=tracks`
|
||||||
|
const response = await makeAppleMusicRequest<{
|
||||||
|
data: AppleMusicAlbum[];
|
||||||
|
included?: AppleMusicTrack[]
|
||||||
|
}>(endpoint)
|
||||||
|
|
||||||
|
console.log(`Album details response structure:`, {
|
||||||
|
hasData: !!response.data,
|
||||||
|
dataLength: response.data?.length,
|
||||||
|
hasIncluded: !!response.included,
|
||||||
|
includedLength: response.included?.length,
|
||||||
|
// Check if tracks are in relationships
|
||||||
|
hasRelationships: !!response.data?.[0]?.relationships,
|
||||||
|
hasTracks: !!response.data?.[0]?.relationships?.tracks
|
||||||
|
})
|
||||||
|
|
||||||
|
// Tracks are in relationships.tracks.data when using ?include=tracks
|
||||||
|
const albumData = response.data?.[0]
|
||||||
|
const tracksData = albumData?.relationships?.tracks?.data
|
||||||
|
|
||||||
|
if (tracksData?.length) {
|
||||||
|
console.log(`Found ${tracksData.length} tracks for album "${attributes.name}"`)
|
||||||
|
|
||||||
|
// Process all tracks
|
||||||
|
tracks = tracksData
|
||||||
|
.filter((item: any) => item.type === 'songs')
|
||||||
|
.map((track: any) => ({
|
||||||
|
name: track.attributes?.name || 'Unknown',
|
||||||
|
previewUrl: track.attributes?.previews?.[0]?.url
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Log track details
|
||||||
|
tracks.forEach((track, index) => {
|
||||||
|
console.log(`Track ${index + 1}: ${track.name} - Preview: ${track.previewUrl ? 'Yes' : 'No'}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Find the first track with a preview if we don't have one
|
||||||
|
if (!previewUrl) {
|
||||||
|
for (const track of tracksData) {
|
||||||
|
if (track.type === 'songs' && track.attributes?.previews?.[0]?.url) {
|
||||||
|
previewUrl = track.attributes.previews[0].url
|
||||||
|
console.log(`Using preview URL from track "${track.attributes.name}"`)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('No tracks found in album response')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch album tracks:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
appleMusicId: appleMusicAlbum.id,
|
||||||
|
highResArtwork: attributes.artwork
|
||||||
|
? attributes.artwork.url.replace('{w}x{h}', '3000x3000')
|
||||||
|
: undefined,
|
||||||
|
previewUrl,
|
||||||
|
// Store additional metadata for future use
|
||||||
|
genres: attributes.genreNames,
|
||||||
|
releaseDate: attributes.releaseDate,
|
||||||
|
trackCount: attributes.trackCount,
|
||||||
|
recordLabel: attributes.recordLabel,
|
||||||
|
copyright: attributes.copyright,
|
||||||
|
editorialNotes: attributes.editorialNotes,
|
||||||
|
isComplete: attributes.isComplete,
|
||||||
|
tracks
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
46
src/lib/stores/audio-preview.ts
Normal file
46
src/lib/stores/audio-preview.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
import { writable, derived, get } from 'svelte/store'
|
||||||
|
|
||||||
|
interface AudioPreviewState {
|
||||||
|
currentAudio: HTMLAudioElement | null
|
||||||
|
currentAlbumId: string | null
|
||||||
|
isPlaying: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function createAudioPreviewStore() {
|
||||||
|
const { subscribe, set, update } = writable<AudioPreviewState>({
|
||||||
|
currentAudio: null,
|
||||||
|
currentAlbumId: null,
|
||||||
|
isPlaying: false
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
subscribe,
|
||||||
|
play: (audio: HTMLAudioElement, albumId: string) => {
|
||||||
|
update(state => {
|
||||||
|
// Pause any currently playing audio
|
||||||
|
if (state.currentAudio && state.currentAudio !== audio) {
|
||||||
|
state.currentAudio.pause()
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
currentAudio: audio,
|
||||||
|
currentAlbumId: albumId,
|
||||||
|
isPlaying: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
stop: () => {
|
||||||
|
update(state => {
|
||||||
|
if (state.currentAudio) {
|
||||||
|
state.currentAudio.pause()
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
currentAudio: null,
|
||||||
|
currentAlbumId: null,
|
||||||
|
isPlaying: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const audioPreview = createAudioPreviewStore()
|
||||||
136
src/lib/types/apple-music.ts
Normal file
136
src/lib/types/apple-music.ts
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
export interface AppleMusicArtwork {
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
url: string
|
||||||
|
bgColor?: string
|
||||||
|
textColor1?: string
|
||||||
|
textColor2?: string
|
||||||
|
textColor3?: string
|
||||||
|
textColor4?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppleMusicPreview {
|
||||||
|
url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppleMusicAttributes {
|
||||||
|
artistName: string
|
||||||
|
artwork: AppleMusicArtwork
|
||||||
|
contentRating?: string
|
||||||
|
copyright?: string
|
||||||
|
editorialNotes?: {
|
||||||
|
short?: string
|
||||||
|
standard?: string
|
||||||
|
}
|
||||||
|
genreNames: string[]
|
||||||
|
isCompilation: boolean
|
||||||
|
isComplete: boolean
|
||||||
|
isMasteredForItunes: boolean
|
||||||
|
isSingle: boolean
|
||||||
|
name: string
|
||||||
|
playParams?: {
|
||||||
|
id: string
|
||||||
|
kind: string
|
||||||
|
}
|
||||||
|
previews?: AppleMusicPreview[]
|
||||||
|
recordLabel?: string
|
||||||
|
releaseDate: string
|
||||||
|
trackCount: number
|
||||||
|
upc?: string
|
||||||
|
url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppleMusicTrackAttributes {
|
||||||
|
albumName: string
|
||||||
|
artistName: string
|
||||||
|
artwork: AppleMusicArtwork
|
||||||
|
composerName?: string
|
||||||
|
contentRating?: string
|
||||||
|
discNumber: number
|
||||||
|
durationInMillis: number
|
||||||
|
genreNames: string[]
|
||||||
|
hasLyrics: boolean
|
||||||
|
isrc?: string
|
||||||
|
name: string
|
||||||
|
playParams?: {
|
||||||
|
id: string
|
||||||
|
kind: string
|
||||||
|
}
|
||||||
|
previews: AppleMusicPreview[]
|
||||||
|
releaseDate: string
|
||||||
|
trackNumber: number
|
||||||
|
url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppleMusicRelationships {
|
||||||
|
artists?: {
|
||||||
|
data: AppleMusicResource<any>[]
|
||||||
|
href?: string
|
||||||
|
next?: string
|
||||||
|
}
|
||||||
|
tracks?: {
|
||||||
|
data: AppleMusicResource<AppleMusicTrackAttributes>[]
|
||||||
|
href?: string
|
||||||
|
next?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppleMusicResource<T> {
|
||||||
|
id: string
|
||||||
|
type: string
|
||||||
|
href: string
|
||||||
|
attributes: T
|
||||||
|
relationships?: AppleMusicRelationships
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppleMusicAlbum extends AppleMusicResource<AppleMusicAttributes> {
|
||||||
|
type: 'albums'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppleMusicTrack extends AppleMusicResource<AppleMusicTrackAttributes> {
|
||||||
|
type: 'songs'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppleMusicSearchResponse {
|
||||||
|
results: {
|
||||||
|
albums?: {
|
||||||
|
data: AppleMusicAlbum[]
|
||||||
|
href?: string
|
||||||
|
next?: string
|
||||||
|
}
|
||||||
|
songs?: {
|
||||||
|
data: AppleMusicTrack[]
|
||||||
|
href?: string
|
||||||
|
next?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppleMusicErrorResponse {
|
||||||
|
errors: Array<{
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
detail: string
|
||||||
|
status: string
|
||||||
|
code: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Type guards
|
||||||
|
export function isAppleMusicError(response: any): response is AppleMusicErrorResponse {
|
||||||
|
return response && 'errors' in response && Array.isArray(response.errors)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAppleMusicAlbum(resource: any): resource is AppleMusicAlbum {
|
||||||
|
return resource && resource.type === 'albums' && 'attributes' in resource
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAppleMusicTrack(resource: any): resource is AppleMusicTrack {
|
||||||
|
return resource && resource.type === 'songs' && 'attributes' in resource
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to get high-resolution artwork URL
|
||||||
|
export function getArtworkUrl(artwork: AppleMusicArtwork, size: number = 3000): string {
|
||||||
|
return artwork.url.replace('{w}x{h}', `${size}x${size}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -26,6 +26,22 @@ export interface Album {
|
||||||
url: string
|
url: string
|
||||||
rank: number
|
rank: number
|
||||||
images: AlbumImages
|
images: AlbumImages
|
||||||
|
appleMusicData?: {
|
||||||
|
appleMusicId?: string
|
||||||
|
highResArtwork?: string
|
||||||
|
previewUrl?: string
|
||||||
|
genres?: string[]
|
||||||
|
releaseDate?: string
|
||||||
|
trackCount?: number
|
||||||
|
recordLabel?: string
|
||||||
|
copyright?: string
|
||||||
|
editorialNotes?: any
|
||||||
|
isComplete?: boolean
|
||||||
|
tracks?: Array<{
|
||||||
|
name: string
|
||||||
|
previewUrl?: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WeeklyAlbumChart {
|
export interface WeeklyAlbumChart {
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,10 @@
|
||||||
import 'dotenv/config'
|
import 'dotenv/config'
|
||||||
import { LastClient } from '@musicorum/lastfm'
|
import { LastClient } from '@musicorum/lastfm'
|
||||||
import {
|
|
||||||
searchItunes,
|
|
||||||
ItunesSearchOptions,
|
|
||||||
ItunesMedia,
|
|
||||||
ItunesEntityMusic
|
|
||||||
} from 'node-itunes-search'
|
|
||||||
import type { RequestHandler } from './$types'
|
import type { RequestHandler } from './$types'
|
||||||
import type { Album, AlbumImages } from '$lib/types/lastfm'
|
import type { Album, AlbumImages } from '$lib/types/lastfm'
|
||||||
import type { LastfmImage } from '@musicorum/lastfm/dist/types/packages/common'
|
import type { LastfmImage } from '@musicorum/lastfm/dist/types/packages/common'
|
||||||
|
import { findAlbum, transformAlbumData } from '$lib/server/apple-music-client'
|
||||||
|
import redis from '../redis-client'
|
||||||
|
|
||||||
const LASTFM_API_KEY = process.env.LASTFM_API_KEY
|
const LASTFM_API_KEY = process.env.LASTFM_API_KEY
|
||||||
const USERNAME = 'jedmund'
|
const USERNAME = 'jedmund'
|
||||||
|
|
@ -37,9 +33,9 @@ export const GET: RequestHandler = async ({ url }) => {
|
||||||
)
|
)
|
||||||
|
|
||||||
const validAlbums = enrichedAlbums.filter((album) => album !== null)
|
const validAlbums = enrichedAlbums.filter((album) => album !== null)
|
||||||
const albumsWithItunesArt = await addItunesArtToAlbums(validAlbums)
|
const albumsWithAppleMusicData = await addAppleMusicDataToAlbums(validAlbums)
|
||||||
|
|
||||||
return new Response(JSON.stringify({ albums: albumsWithItunesArt }), {
|
return new Response(JSON.stringify({ albums: albumsWithAppleMusicData }), {
|
||||||
headers: { 'Content-Type': 'application/json' }
|
headers: { 'Content-Type': 'application/json' }
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -99,39 +95,59 @@ async function enrichAlbumWithInfo(client: LastClient, album: Album): Promise<Al
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addItunesArtToAlbums(albums: Album[]): Promise<Album[]> {
|
async function addAppleMusicDataToAlbums(albums: Album[]): Promise<Album[]> {
|
||||||
return Promise.all(albums.map(searchItunesForAlbum))
|
return Promise.all(albums.map(searchAppleMusicForAlbum))
|
||||||
}
|
}
|
||||||
|
|
||||||
async function searchItunesForAlbum(album: Album): Promise<Album> {
|
async function searchAppleMusicForAlbum(album: Album): Promise<Album> {
|
||||||
const itunesResult = await searchItunesStores(album.name, album.artist.name)
|
try {
|
||||||
|
// Check cache first
|
||||||
|
const cacheKey = `apple:album:${album.artist.name}:${album.name}`
|
||||||
|
const cached = await redis.get(cacheKey)
|
||||||
|
|
||||||
if (itunesResult && itunesResult.results.length > 0) {
|
if (cached) {
|
||||||
const firstResult = itunesResult.results[0]
|
const cachedData = JSON.parse(cached)
|
||||||
album.images.itunes = firstResult.artworkUrl100.replace('100x100', '600x600')
|
console.log(`Using cached data for "${album.name}":`, {
|
||||||
}
|
hasPreview: !!cachedData.previewUrl,
|
||||||
|
trackCount: cachedData.tracks?.length || 0
|
||||||
return album
|
|
||||||
}
|
|
||||||
|
|
||||||
async function searchItunesStores(albumName: string, artistName: string): Promise<any | null> {
|
|
||||||
const stores = ['JP', 'US']
|
|
||||||
for (const store of stores) {
|
|
||||||
const encodedTerm = encodeURIComponent(`${albumName} ${artistName}`)
|
|
||||||
const result = await searchItunes(
|
|
||||||
new ItunesSearchOptions({
|
|
||||||
term: encodedTerm,
|
|
||||||
country: store,
|
|
||||||
media: ItunesMedia.Music,
|
|
||||||
entity: ItunesEntityMusic.Album,
|
|
||||||
limit: 1
|
|
||||||
})
|
})
|
||||||
)
|
return {
|
||||||
|
...album,
|
||||||
|
images: {
|
||||||
|
...album.images,
|
||||||
|
itunes: cachedData.highResArtwork || album.images.itunes
|
||||||
|
},
|
||||||
|
appleMusicData: cachedData
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (result.resultCount > 0) return result
|
// Search Apple Music
|
||||||
|
const appleMusicAlbum = await findAlbum(album.artist.name, album.name)
|
||||||
|
|
||||||
|
if (appleMusicAlbum) {
|
||||||
|
const transformedData = await transformAlbumData(appleMusicAlbum)
|
||||||
|
|
||||||
|
// Cache the result for 24 hours
|
||||||
|
await redis.set(cacheKey, JSON.stringify(transformedData), 'EX', 86400)
|
||||||
|
|
||||||
|
return {
|
||||||
|
...album,
|
||||||
|
images: {
|
||||||
|
...album.images,
|
||||||
|
itunes: transformedData.highResArtwork || album.images.itunes
|
||||||
|
},
|
||||||
|
appleMusicData: transformedData
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
`Failed to fetch Apple Music data for "${album.name}" by "${album.artist.name}":`,
|
||||||
|
error
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return null
|
// Return album unchanged if Apple Music search fails
|
||||||
|
return album
|
||||||
}
|
}
|
||||||
|
|
||||||
function transformImages(images: LastfmImage[]): AlbumImages {
|
function transformImages(images: LastfmImage[]): AlbumImages {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue