From b3979008ae9d0641213be141070cf0c85e3a2ad1 Mon Sep 17 00:00:00 2001 From: Justin Edmund Date: Fri, 13 Jun 2025 21:22:39 -0400 Subject: [PATCH 1/6] Apple Music API --- .env.example | 10 +- .gitignore | 4 + package-lock.json | 104 ++++++++ package.json | 2 + prd/PRD-url-embed-functionality.md | 47 +++- src/lib/components/Album.svelte | 175 +++++++++++-- src/lib/components/MusicPreview.svelte | 350 +++++++++++++++++++++++++ src/lib/server/apple-music-auth.ts | 67 +++++ src/lib/server/apple-music-client.ts | 255 ++++++++++++++++++ src/lib/stores/audio-preview.ts | 46 ++++ src/lib/types/apple-music.ts | 136 ++++++++++ src/lib/types/lastfm.ts | 16 ++ src/routes/api/lastfm/+server.ts | 84 +++--- 13 files changed, 1230 insertions(+), 66 deletions(-) create mode 100644 src/lib/components/MusicPreview.svelte create mode 100644 src/lib/server/apple-music-auth.ts create mode 100644 src/lib/server/apple-music-client.ts create mode 100644 src/lib/stores/audio-preview.ts create mode 100644 src/lib/types/apple-music.ts diff --git a/.env.example b/.env.example index cd4ee18..a4d84ba 100644 --- a/.env.example +++ b/.env.example @@ -13,4 +13,12 @@ CLOUDINARY_API_KEY="your-api-key" CLOUDINARY_API_SECRET="your-api-secret" # Admin Authentication (for later) -ADMIN_PASSWORD="your-admin-password" \ No newline at end of file +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-----" \ No newline at end of file diff --git a/.gitignore b/.gitignore index a3a7298..f926895 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,10 @@ Thumbs.db !.env.example !.env.test +# Apple Music Private Keys +keys/ +*.p8 + # Vite vite.config.js.timestamp-* vite.config.ts.timestamp-* diff --git a/package-lock.json b/package-lock.json index 0c1c7eb..3c1dd04 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,6 +36,7 @@ "@tiptap/pm": "^2.12.0", "@tiptap/starter-kit": "^2.12.0", "@tiptap/suggestion": "^2.12.0", + "@types/jsonwebtoken": "^9.0.9", "@types/multer": "^1.4.12", "@types/redis": "^4.0.10", "@types/steamapi": "^2.2.5", @@ -45,6 +46,7 @@ "giantbombing-api": "^1.0.4", "gray-matter": "^4.0.3", "ioredis": "^5.4.1", + "jsonwebtoken": "^9.0.2", "katex": "^0.16.22", "lowlight": "^3.3.0", "lucide-svelte": "^0.511.0", @@ -2875,6 +2877,15 @@ "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "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": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", @@ -2910,6 +2921,11 @@ "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", "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": { "version": "1.4.12", "resolved": "https://registry.npmjs.org/@types/multer/-/multer-1.4.12.tgz", @@ -3655,6 +3671,11 @@ "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": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -4305,6 +4326,14 @@ "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": { "version": "1.4.827", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.827.tgz", @@ -5682,6 +5711,27 @@ "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", "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": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.2.tgz", @@ -5697,6 +5747,25 @@ "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": { "version": "0.16.22", "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.22.tgz", @@ -5823,18 +5892,53 @@ "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", "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": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==", "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": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "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": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.3.tgz", diff --git a/package.json b/package.json index 3bc6a95..efc8181 100644 --- a/package.json +++ b/package.json @@ -81,6 +81,7 @@ "@tiptap/pm": "^2.12.0", "@tiptap/starter-kit": "^2.12.0", "@tiptap/suggestion": "^2.12.0", + "@types/jsonwebtoken": "^9.0.9", "@types/multer": "^1.4.12", "@types/redis": "^4.0.10", "@types/steamapi": "^2.2.5", @@ -90,6 +91,7 @@ "giantbombing-api": "^1.0.4", "gray-matter": "^4.0.3", "ioredis": "^5.4.1", + "jsonwebtoken": "^9.0.2", "katex": "^0.16.22", "lowlight": "^3.3.0", "lucide-svelte": "^0.511.0", diff --git a/prd/PRD-url-embed-functionality.md b/prd/PRD-url-embed-functionality.md index 6ce8d05..0ff3b55 100644 --- a/prd/PRD-url-embed-functionality.md +++ b/prd/PRD-url-embed-functionality.md @@ -1,18 +1,22 @@ # Product Requirements Document: URL Embed Functionality ## 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. ## 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. ## Goals + 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 3. Maintain consistency with existing UI/UX patterns 4. Ensure performance with proper loading states and error handling ## 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. 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. @@ -21,6 +25,7 @@ Currently, the Editor supports various content types including text, images, and ## Functional Requirements ### URL Detection and Conversion + 1. **Automatic Detection**: When a user pastes a plain URL (e.g., `https://example.com`), the system should: - Create a regular text link initially - 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 ### Embed Card Display + 1. **Metadata Fetching**: The system should fetch OpenGraph metadata including: - Title - 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 ### User Interactions + 1. **In-Editor Actions**: - Refresh metadata - 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 ### Content Rendering + 1. **Editor View**: Full interactive embed with action buttons 2. **Published View**: Static card with clickable elements 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 ### Architecture + 1. **TipTap Extensions**: + - `UrlEmbed`: Main node extension for URL detection and schema - `UrlEmbedPlaceholder`: Temporary node during loading - `UrlEmbedExtended`: Final node with metadata 2. **Components**: + - `UrlEmbedPlaceholder.svelte`: Loading/input UI - `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 ### Data Model + ```typescript interface UrlEmbedNode { - type: 'urlEmbed'; - attrs: { - url: string; - title?: string; - description?: string; - image?: string; - siteName?: string; - favicon?: string; - }; + type: 'urlEmbed' + attrs: { + url: string + title?: string + description?: string + image?: string + siteName?: string + favicon?: string + } } ``` ## UI/UX Specifications ### Visual Design + - Match existing `LinkCard` component styling - Use established color variables and spacing - Maintain consistency with overall site design ### Interaction Patterns + 1. **Paste Flow**: + - User pastes URL - URL appears as regular link text - Dropdown menu appears next to cursor with "Convert to embed" option @@ -116,23 +131,27 @@ interface UrlEmbedNode { - Same loading/rendering flow as paste ## Performance Considerations + 1. **Lazy Loading**: Only fetch metadata when URL is added 2. **Caching**: Cache fetched metadata to avoid redundant API calls 3. **Timeout**: Implement reasonable timeout for metadata fetching 4. **Image Optimization**: Consider lazy loading preview images ## Security Considerations + 1. **URL Validation**: Validate URLs before fetching metadata 2. **Content Sanitization**: Sanitize fetched metadata to prevent XSS 3. **CORS Handling**: Properly handle cross-origin requests ## Success Metrics + 1. **Adoption Rate**: Percentage of posts using URL embeds 2. **Error Rate**: Frequency of metadata fetch failures 3. **Performance**: Average time to fetch and display metadata 4. **User Satisfaction**: Feedback on embed functionality ## Future Enhancements + 1. **Custom Previews**: Allow manual editing of metadata 2. **Platform-Specific Embeds**: Special handling for YouTube, Twitter, etc. 3. **Embed Templates**: Different card styles for different content types @@ -140,9 +159,11 @@ interface UrlEmbedNode { ## Timeline ### Phase 1: Core Functionality + **Status**: In Progress #### Completed Tasks: + - [x] Create TipTap extension for URL detection (`UrlEmbed.ts`) - [x] Create placeholder component for loading state (`UrlEmbedPlaceholder.svelte`) - [x] Create extended component for rich preview (`UrlEmbedExtended.svelte`) @@ -154,6 +175,7 @@ interface UrlEmbedNode { - [x] Add content rendering for published posts #### Remaining Tasks: + - [x] Implement paste detection with dropdown menu - [x] Create dropdown component for "Convert to embed" option - [x] Add convert between embed/link functionality @@ -163,21 +185,26 @@ interface UrlEmbedNode { - [ ] Update documentation ### Phase 2: Platform-Specific Embeds + **Status**: Future + - [ ] YouTube video embeds with player - [ ] Twitter/X post embeds - [ ] Instagram post embeds - [ ] GitHub repository/gist embeds ### Phase 3: Advanced Customization + **Status**: Future + - [ ] Custom preview editing - [ ] Multiple embed templates/styles - [ ] Embed size options (compact/full) - [ ] Custom CSS for embeds ## Dependencies + - Existing `/api/og-metadata` endpoint - TipTap editor framework - Svelte 5 with runes mode -- Existing design system and CSS variables \ No newline at end of file +- Existing design system and CSS variables diff --git a/src/lib/components/Album.svelte b/src/lib/components/Album.svelte index 02624fb..204d726 100644 --- a/src/lib/components/Album.svelte +++ b/src/lib/components/Album.svelte @@ -1,6 +1,7 @@
{#if album} - (isHovering = true)} - onmouseleave={() => (isHovering = false)} - > - {album.name} -
- - {album.name} - -

- {album.artist.name} -

-
-
+ {:else}

No album provided

{/if} @@ -57,6 +144,14 @@ width: 100%; height: 100%; + .album-wrapper { + display: flex; + flex-direction: column; + gap: $unit; + width: 100%; + height: 100%; + } + a { display: flex; flex-direction: column; @@ -66,6 +161,12 @@ width: 100%; height: 100%; + .artwork-container { + position: relative; + width: 100%; + overflow: hidden; + } + img { border: 1px solid rgba(0, 0, 0, 0.1); border-radius: $unit; @@ -75,6 +176,34 @@ 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 { display: flex; flex-direction: column; @@ -103,5 +232,9 @@ } } } + + .preview-container { + margin-top: $unit; + } } diff --git a/src/lib/components/MusicPreview.svelte b/src/lib/components/MusicPreview.svelte new file mode 100644 index 0000000..5eb6030 --- /dev/null +++ b/src/lib/components/MusicPreview.svelte @@ -0,0 +1,350 @@ + + +
+
+ + diff --git a/src/lib/server/apple-music-auth.ts b/src/lib/server/apple-music-auth.ts new file mode 100644 index 0000000..53b03a6 --- /dev/null +++ b/src/lib/server/apple-music-auth.ts @@ -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 { + return { + Authorization: `Bearer ${generateAppleMusicToken()}`, + 'Music-User-Token': '', // Will be needed for user-specific features + Accept: 'application/json', + 'Content-Type': 'application/json' + } +} + diff --git a/src/lib/server/apple-music-client.ts b/src/lib/server/apple-music-client.ts new file mode 100644 index 0000000..c48b0f6 --- /dev/null +++ b/src/lib/server/apple-music-client.ts @@ -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 { + 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(endpoint: string): Promise { + 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 { + const encodedQuery = encodeURIComponent(query) + const endpoint = `/catalog/${DEFAULT_STOREFRONT}/search?types=albums&term=${encodedQuery}&limit=${limit}` + + return makeAppleMusicRequest(endpoint) +} + +export async function searchTracks( + query: string, + limit: number = 10 +): Promise { + const encodedQuery = encodeURIComponent(query) + const endpoint = `/catalog/${DEFAULT_STOREFRONT}/search?types=songs&term=${encodedQuery}&limit=${limit}` + + return makeAppleMusicRequest(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 { + 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 { + 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 + } +} + diff --git a/src/lib/stores/audio-preview.ts b/src/lib/stores/audio-preview.ts new file mode 100644 index 0000000..8ce29b0 --- /dev/null +++ b/src/lib/stores/audio-preview.ts @@ -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({ + 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() \ No newline at end of file diff --git a/src/lib/types/apple-music.ts b/src/lib/types/apple-music.ts new file mode 100644 index 0000000..d9ab9ca --- /dev/null +++ b/src/lib/types/apple-music.ts @@ -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[] + href?: string + next?: string + } + tracks?: { + data: AppleMusicResource[] + href?: string + next?: string + } +} + +export interface AppleMusicResource { + id: string + type: string + href: string + attributes: T + relationships?: AppleMusicRelationships +} + +export interface AppleMusicAlbum extends AppleMusicResource { + type: 'albums' +} + +export interface AppleMusicTrack extends AppleMusicResource { + 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}`) +} + diff --git a/src/lib/types/lastfm.ts b/src/lib/types/lastfm.ts index f647f6c..533da12 100644 --- a/src/lib/types/lastfm.ts +++ b/src/lib/types/lastfm.ts @@ -26,6 +26,22 @@ export interface Album { url: string rank: number 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 { diff --git a/src/routes/api/lastfm/+server.ts b/src/routes/api/lastfm/+server.ts index 8af67d0..b4a4d09 100644 --- a/src/routes/api/lastfm/+server.ts +++ b/src/routes/api/lastfm/+server.ts @@ -1,14 +1,10 @@ import 'dotenv/config' import { LastClient } from '@musicorum/lastfm' -import { - searchItunes, - ItunesSearchOptions, - ItunesMedia, - ItunesEntityMusic -} from 'node-itunes-search' import type { RequestHandler } from './$types' import type { Album, AlbumImages } from '$lib/types/lastfm' 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 USERNAME = 'jedmund' @@ -37,9 +33,9 @@ export const GET: RequestHandler = async ({ url }) => { ) 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' } }) } catch (error) { @@ -99,39 +95,59 @@ async function enrichAlbumWithInfo(client: LastClient, album: Album): Promise { - return Promise.all(albums.map(searchItunesForAlbum)) +async function addAppleMusicDataToAlbums(albums: Album[]): Promise { + return Promise.all(albums.map(searchAppleMusicForAlbum)) } -async function searchItunesForAlbum(album: Album): Promise { - const itunesResult = await searchItunesStores(album.name, album.artist.name) +async function searchAppleMusicForAlbum(album: Album): Promise { + 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) { - const firstResult = itunesResult.results[0] - album.images.itunes = firstResult.artworkUrl100.replace('100x100', '600x600') - } - - return album -} - -async function searchItunesStores(albumName: string, artistName: string): Promise { - 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 + if (cached) { + const cachedData = JSON.parse(cached) + console.log(`Using cached data for "${album.name}":`, { + hasPreview: !!cachedData.previewUrl, + trackCount: cachedData.tracks?.length || 0 }) - ) + 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 { From cc6eba7df1557e949ca72eac3cdb35b4889df41a Mon Sep 17 00:00:00 2001 From: Justin Edmund Date: Fri, 13 Jun 2025 21:22:49 -0400 Subject: [PATCH 2/6] Linter --- src/app.css | 2 +- src/assets/styles/imports.scss | 2 +- src/lib/components/SegmentedController.svelte | 4 +- .../components/admin/EditorWithUpload.svelte | 64 +++++++++------- .../link-context-menu/LinkContextMenu.ts | 20 ++--- .../edra/extensions/url-embed/UrlEmbed.ts | 74 ++++++++++++------- .../extensions/url-embed/UrlEmbedExtended.ts | 2 +- .../url-embed/UrlEmbedPlaceholder.ts | 2 +- .../components/EmbedContextMenu.svelte | 72 +++++++++--------- .../components/LinkContextMenu.svelte | 72 +++++++++--------- .../headless/components/LinkEditDialog.svelte | 57 ++++++-------- .../components/UrlConvertDropdown.svelte | 30 ++++---- .../components/UrlEmbedPlaceholder.svelte | 53 ++++++------- src/lib/utils/content.ts | 27 +++---- src/lib/utils/extractEmbeds.ts | 6 +- src/routes/api/og-metadata/+server.ts | 10 +-- 16 files changed, 260 insertions(+), 237 deletions(-) diff --git a/src/app.css b/src/app.css index 447401c..1f73230 100644 --- a/src/app.css +++ b/src/app.css @@ -1,3 +1,3 @@ /* Global styles for the entire application */ @import './assets/styles/reset.css'; -@import './assets/styles/globals.scss'; \ No newline at end of file +@import './assets/styles/globals.scss'; diff --git a/src/assets/styles/imports.scss b/src/assets/styles/imports.scss index 5b45e44..509590f 100644 --- a/src/assets/styles/imports.scss +++ b/src/assets/styles/imports.scss @@ -3,4 +3,4 @@ @import './variables.scss'; @import './fonts.scss'; -@import './themes.scss'; \ No newline at end of file +@import './themes.scss'; diff --git a/src/lib/components/SegmentedController.svelte b/src/lib/components/SegmentedController.svelte index c6a28ba..d93c478 100644 --- a/src/lib/components/SegmentedController.svelte +++ b/src/lib/components/SegmentedController.svelte @@ -119,9 +119,7 @@ onmouseenter={() => (hoveredIndex = index)} onmouseleave={() => (hoveredIndex = null)} > - + {item.text} {/each} diff --git a/src/lib/components/admin/EditorWithUpload.svelte b/src/lib/components/admin/EditorWithUpload.svelte index 7894c6c..c6a0da5 100644 --- a/src/lib/components/admin/EditorWithUpload.svelte +++ b/src/lib/components/admin/EditorWithUpload.svelte @@ -83,18 +83,18 @@ let mediaDropdownTriggerRef = $state() let dropdownPosition = $state({ top: 0, left: 0 }) let mediaDropdownPosition = $state({ top: 0, left: 0 }) - + // URL convert dropdown state let showUrlConvertDropdown = $state(false) let urlConvertDropdownPosition = $state({ x: 0, y: 0 }) let urlConvertPos = $state(null) - + // Link context menu state let showLinkContextMenu = $state(false) let linkContextMenuPosition = $state({ x: 0, y: 0 }) let linkContextUrl = $state(null) let linkContextPos = $state(null) - + // Link edit dialog state let showLinkEditDialog = $state(false) let linkEditDialogPosition = $state({ x: 0, y: 0 }) @@ -239,85 +239,89 @@ showLinkEditDialog = false } } - + // Handle URL convert dropdown const handleShowUrlConvertDropdown = (pos: number, url: string) => { if (!editor) return - + // Get the cursor coordinates const coords = editor.view.coordsAtPos(pos) urlConvertDropdownPosition = { x: coords.left, y: coords.bottom + 5 } urlConvertPos = pos showUrlConvertDropdown = true } - + // Handle link context menu - const handleShowLinkContextMenu = (pos: number, url: string, coords: { x: number, y: number }) => { + const handleShowLinkContextMenu = ( + pos: number, + url: string, + coords: { x: number; y: number } + ) => { if (!editor) return - + linkContextMenuPosition = { x: coords.x, y: coords.y + 5 } linkContextUrl = url linkContextPos = pos showLinkContextMenu = true } - + const handleConvertToEmbed = () => { if (!editor || urlConvertPos === null) return - + editor.commands.convertLinkToEmbed(urlConvertPos) showUrlConvertDropdown = false urlConvertPos = null } - + const handleConvertLinkToEmbed = () => { if (!editor || linkContextPos === null) return - + editor.commands.convertLinkToEmbed(linkContextPos) showLinkContextMenu = false linkContextPos = null linkContextUrl = null } - + const handleEditLink = () => { if (!editor || !linkContextUrl) return - + linkEditUrl = linkContextUrl linkEditPos = linkContextPos linkEditDialogPosition = { ...linkContextMenuPosition } showLinkEditDialog = true showLinkContextMenu = false } - + const handleSaveLink = (newUrl: string) => { if (!editor) return - + editor.chain().focus().extendMarkRange('link').setLink({ href: newUrl }).run() showLinkEditDialog = false linkEditPos = null linkEditUrl = '' } - + const handleCopyLink = () => { if (!linkContextUrl) return - + navigator.clipboard.writeText(linkContextUrl) showLinkContextMenu = false linkContextPos = null linkContextUrl = null } - + const handleRemoveLink = () => { if (!editor) return - + editor.chain().focus().extendMarkRange('link').unsetLink().run() showLinkContextMenu = false linkContextPos = null linkContextUrl = null } - + const handleOpenLink = () => { if (!linkContextUrl) return - + window.open(linkContextUrl, '_blank', 'noopener,noreferrer') showLinkContextMenu = false linkContextPos = null @@ -325,7 +329,13 @@ } $effect(() => { - if (showTextStyleDropdown || showMediaDropdown || showUrlConvertDropdown || showLinkContextMenu || showLinkEditDialog) { + if ( + showTextStyleDropdown || + showMediaDropdown || + showUrlConvertDropdown || + showLinkContextMenu || + showLinkEditDialog + ) { document.addEventListener('click', handleClickOutside) return () => { document.removeEventListener('click', handleClickOutside) @@ -484,16 +494,16 @@ // Dismiss URL convert dropdown if user types if (showUrlConvertDropdown && transaction.docChanged) { // Check if the change is actual typing (not just cursor movement) - const hasTextChange = transaction.steps.some(step => - step.toJSON().stepType === 'replace' || - step.toJSON().stepType === 'replaceAround' + const hasTextChange = transaction.steps.some( + (step) => + step.toJSON().stepType === 'replace' || step.toJSON().stepType === 'replaceAround' ) if (hasTextChange) { showUrlConvertDropdown = false urlConvertPos = null } } - + // Call the original onUpdate if provided if (onUpdate) { onUpdate({ editor: updatedEditor, transaction }) diff --git a/src/lib/components/edra/extensions/link-context-menu/LinkContextMenu.ts b/src/lib/components/edra/extensions/link-context-menu/LinkContextMenu.ts index c8b3626..5208b8d 100644 --- a/src/lib/components/edra/extensions/link-context-menu/LinkContextMenu.ts +++ b/src/lib/components/edra/extensions/link-context-menu/LinkContextMenu.ts @@ -2,7 +2,7 @@ import { Extension } from '@tiptap/core' import { Plugin, PluginKey } from '@tiptap/pm/state' export interface LinkContextMenuOptions { - onShowContextMenu?: (pos: number, url: string, coords: { x: number, y: number }) => void + onShowContextMenu?: (pos: number, url: string, coords: { x: number; y: number }) => void } export const LinkContextMenu = Extension.create({ @@ -16,7 +16,7 @@ export const LinkContextMenu = Extension.create({ addProseMirrorPlugins() { const options = this.options - + return [ new Plugin({ key: new PluginKey('linkContextMenu'), @@ -25,26 +25,26 @@ export const LinkContextMenu = Extension.create({ contextmenu: (view, event) => { const { state } = view const pos = view.posAtCoords({ left: event.clientX, top: event.clientY }) - + if (!pos) return false - + const $pos = state.doc.resolve(pos.pos) const marks = $pos.marks() - const linkMark = marks.find(mark => mark.type.name === 'link') - + const linkMark = marks.find((mark) => mark.type.name === 'link') + if (linkMark && linkMark.attrs.href) { event.preventDefault() - + if (options.onShowContextMenu) { options.onShowContextMenu(pos.pos, linkMark.attrs.href, { x: event.clientX, y: event.clientY }) } - + return true } - + return false } } @@ -52,4 +52,4 @@ export const LinkContextMenu = Extension.create({ }) ] } -}) \ No newline at end of file +}) diff --git a/src/lib/components/edra/extensions/url-embed/UrlEmbed.ts b/src/lib/components/edra/extensions/url-embed/UrlEmbed.ts index e52b7dd..511fbcf 100644 --- a/src/lib/components/edra/extensions/url-embed/UrlEmbed.ts +++ b/src/lib/components/edra/extensions/url-embed/UrlEmbed.ts @@ -78,7 +78,10 @@ export const UrlEmbed = Node.create({ }, renderHTML({ HTMLAttributes }) { - return ['div', mergeAttributes({ 'data-url-embed': '' }, this.options.HTMLAttributes, HTMLAttributes)] + return [ + 'div', + mergeAttributes({ 'data-url-embed': '' }, this.options.HTMLAttributes, HTMLAttributes) + ] }, addCommands() { @@ -102,35 +105,41 @@ export const UrlEmbed = Node.create({ (pos) => ({ state, commands, chain }) => { const { doc } = state - + // Find the link mark at the given position const $pos = doc.resolve(pos) const marks = $pos.marks() - const linkMark = marks.find(mark => mark.type.name === 'link') - + const linkMark = marks.find((mark) => mark.type.name === 'link') + if (!linkMark) return false - + const url = linkMark.attrs.href if (!url) return false - + // Find the complete range of text with this link mark let from = pos let to = pos - + // Walk backwards to find the start doc.nodesBetween(Math.max(0, pos - 300), pos, (node, nodePos) => { - if (node.isText && node.marks.some(m => m.type.name === 'link' && m.attrs.href === url)) { + if ( + node.isText && + node.marks.some((m) => m.type.name === 'link' && m.attrs.href === url) + ) { from = nodePos } }) - + // Walk forwards to find the end doc.nodesBetween(pos, Math.min(doc.content.size, pos + 300), (node, nodePos) => { - if (node.isText && node.marks.some(m => m.type.name === 'link' && m.attrs.href === url)) { + if ( + node.isText && + node.marks.some((m) => m.type.name === 'link' && m.attrs.href === url) + ) { to = nodePos + node.nodeSize } }) - + // Use Tiptap's chain commands to replace content return chain() .focus() @@ -179,40 +188,53 @@ export const UrlEmbed = Node.create({ // Check if it's a plain text paste if (text && !html) { // Simple URL regex check - const urlRegex = /^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)$/ - + const urlRegex = + /^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)$/ + if (urlRegex.test(text.trim())) { // It's a URL, let it paste as a link naturally (don't prevent default) // But track it so we can show dropdown after const pastedUrl = text.trim() - + // Get the position before paste const beforePos = view.state.selection.from - + setTimeout(() => { const { state } = view const { doc } = state - + // Find the link that was just inserted // Start from where we were before paste let linkStart = -1 let linkEnd = -1 - + // Search for the link in a reasonable range - for (let pos = beforePos; pos < Math.min(doc.content.size, beforePos + pastedUrl.length + 10); pos++) { + for ( + let pos = beforePos; + pos < Math.min(doc.content.size, beforePos + pastedUrl.length + 10); + pos++ + ) { try { const $pos = doc.resolve(pos) const marks = $pos.marks() - const linkMark = marks.find(m => m.type.name === 'link' && m.attrs.href === pastedUrl) - + const linkMark = marks.find( + (m) => m.type.name === 'link' && m.attrs.href === pastedUrl + ) + if (linkMark) { // Found the link, now find its boundaries linkStart = pos - + // Find the end of the link - for (let endPos = pos; endPos < Math.min(doc.content.size, pos + pastedUrl.length + 5); endPos++) { + for ( + let endPos = pos; + endPos < Math.min(doc.content.size, pos + pastedUrl.length + 5); + endPos++ + ) { const $endPos = doc.resolve(endPos) - const hasLink = $endPos.marks().some(m => m.type.name === 'link' && m.attrs.href === pastedUrl) + const hasLink = $endPos + .marks() + .some((m) => m.type.name === 'link' && m.attrs.href === pastedUrl) if (hasLink) { linkEnd = endPos + 1 } else { @@ -225,7 +247,7 @@ export const UrlEmbed = Node.create({ // Position might be invalid, continue } } - + if (linkStart !== -1) { // Store the pasted URL info with correct position const tr = state.tr.setMeta('urlEmbedPaste', { @@ -233,7 +255,7 @@ export const UrlEmbed = Node.create({ lastPastedPos: linkStart }) view.dispatch(tr) - + // Notify the editor to show dropdown if (options.onShowDropdown) { options.onShowDropdown(linkStart, pastedUrl) @@ -251,4 +273,4 @@ export const UrlEmbed = Node.create({ }) ] } -}) \ No newline at end of file +}) diff --git a/src/lib/components/edra/extensions/url-embed/UrlEmbedExtended.ts b/src/lib/components/edra/extensions/url-embed/UrlEmbedExtended.ts index 9edec3f..f41154f 100644 --- a/src/lib/components/edra/extensions/url-embed/UrlEmbedExtended.ts +++ b/src/lib/components/edra/extensions/url-embed/UrlEmbedExtended.ts @@ -49,4 +49,4 @@ export const UrlEmbedExtended = (component: any) => addNodeView() { return SvelteNodeViewRenderer(component) } - }) \ No newline at end of file + }) diff --git a/src/lib/components/edra/extensions/url-embed/UrlEmbedPlaceholder.ts b/src/lib/components/edra/extensions/url-embed/UrlEmbedPlaceholder.ts index 8ce71f0..2c81ff4 100644 --- a/src/lib/components/edra/extensions/url-embed/UrlEmbedPlaceholder.ts +++ b/src/lib/components/edra/extensions/url-embed/UrlEmbedPlaceholder.ts @@ -32,4 +32,4 @@ export const UrlEmbedPlaceholder = (component: any) => addNodeView() { return SvelteNodeViewRenderer(component) } - }) \ No newline at end of file + }) diff --git a/src/lib/components/edra/headless/components/EmbedContextMenu.svelte b/src/lib/components/edra/headless/components/EmbedContextMenu.svelte index d76bc5c..3d4ca3e 100644 --- a/src/lib/components/edra/headless/components/EmbedContextMenu.svelte +++ b/src/lib/components/edra/headless/components/EmbedContextMenu.svelte @@ -1,7 +1,7 @@ + +
+
+ + + +
+ {#if trackName} + {trackName} + {/if} +
+ + \ No newline at end of file diff --git a/src/lib/server/apple-music-client.ts b/src/lib/server/apple-music-client.ts index c48b0f6..421cf7d 100644 --- a/src/lib/server/apple-music-client.ts +++ b/src/lib/server/apple-music-client.ts @@ -175,7 +175,7 @@ export async function transformAlbumData(appleMusicAlbum: AppleMusicAlbum) { // Get preview URL from tracks if album doesn't have one let previewUrl = attributes.previews?.[0]?.url - let tracks: Array<{ name: string; previewUrl?: string }> = [] + let tracks: Array<{ name: string; previewUrl?: string; durationMs?: number }> = [] // Always fetch tracks to get preview URLs if (appleMusicAlbum.id) { @@ -209,12 +209,13 @@ export async function transformAlbumData(appleMusicAlbum: AppleMusicAlbum) { .filter((item: any) => item.type === 'songs') .map((track: any) => ({ name: track.attributes?.name || 'Unknown', - previewUrl: track.attributes?.previews?.[0]?.url + previewUrl: track.attributes?.previews?.[0]?.url, + durationMs: track.attributes?.durationInMillis })) // Log track details tracks.forEach((track, index) => { - console.log(`Track ${index + 1}: ${track.name} - Preview: ${track.previewUrl ? 'Yes' : 'No'}`) + console.log(`Track ${index + 1}: ${track.name} - Preview: ${track.previewUrl ? 'Yes' : 'No'} - Duration: ${track.durationMs}ms`) }) // Find the first track with a preview if we don't have one diff --git a/src/lib/types/lastfm.ts b/src/lib/types/lastfm.ts index 533da12..bbb084f 100644 --- a/src/lib/types/lastfm.ts +++ b/src/lib/types/lastfm.ts @@ -26,6 +26,8 @@ export interface Album { url: string rank: number images: AlbumImages + isNowPlaying?: boolean + nowPlayingTrack?: string appleMusicData?: { appleMusicId?: string highResArtwork?: string @@ -40,6 +42,7 @@ export interface Album { tracks?: Array<{ name: string previewUrl?: string + durationMs?: number }> } } diff --git a/src/routes/api/lastfm/+server.ts b/src/routes/api/lastfm/+server.ts index b4a4d09..3309921 100644 --- a/src/routes/api/lastfm/+server.ts +++ b/src/routes/api/lastfm/+server.ts @@ -10,13 +10,24 @@ const LASTFM_API_KEY = process.env.LASTFM_API_KEY const USERNAME = 'jedmund' const ALBUM_LIMIT = 10 +// Store last played tracks with timestamps +interface TrackPlayInfo { + albumName: string + trackName: string + scrobbleTime: Date + durationMs?: number +} + +let recentTracks: TrackPlayInfo[] = [] + export const GET: RequestHandler = async ({ url }) => { const client = new LastClient(LASTFM_API_KEY || '') + const testMode = url.searchParams.get('test') === 'nowplaying' try { // const albums = await getWeeklyAlbumChart(client, USERNAME) - const albums = await getRecentAlbums(client, USERNAME, ALBUM_LIMIT) + const albums = await getRecentAlbums(client, USERNAME, ALBUM_LIMIT, testMode) // console.log(albums) const enrichedAlbums = await Promise.all( albums.slice(0, ALBUM_LIMIT).map(async (album) => { @@ -58,16 +69,39 @@ async function getWeeklyAlbumChart(client: LastClient, username: string): Promis async function getRecentAlbums( client: LastClient, username: string, - limit: number + limit: number, + testMode: boolean = false ): Promise { - const recentTracks = await client.user.getRecentTracks(username, { limit: 50, extended: true }) + const recentTracksResponse = await client.user.getRecentTracks(username, { limit: 50, extended: true }) const uniqueAlbums = new Map() - - for (const track of recentTracks.tracks) { + let nowPlayingTrack: string | undefined + let isFirstAlbum = true + + // Clear old tracks and collect new track play information + recentTracks = [] + + for (const track of recentTracksResponse.tracks) { + // Store track play information for now playing calculation + if (track.date) { + recentTracks.push({ + albumName: track.album.name, + trackName: track.name, + scrobbleTime: track.date + }) + } + if (uniqueAlbums.size >= limit) break + // Check if this is the currently playing track + if (track.nowPlaying && !nowPlayingTrack) { + nowPlayingTrack = track.name + } + const albumKey = `${track.album.mbid || track.album.name}` if (!uniqueAlbums.has(albumKey)) { + // For testing: mark first album as now playing + const isNowPlaying = testMode && isFirstAlbum ? true : (track.nowPlaying || false) + uniqueAlbums.set(albumKey, { name: track.album.name, artist: { @@ -78,7 +112,19 @@ async function getRecentAlbums( images: transformImages(track.images), mbid: track.album.mbid || '', url: track.url, - rank: uniqueAlbums.size + 1 + rank: uniqueAlbums.size + 1, + // Mark if this album contains the now playing track + isNowPlaying: isNowPlaying, + nowPlayingTrack: isNowPlaying ? track.name : undefined + }) + isFirstAlbum = false + } else if (track.nowPlaying) { + // If album already exists but this track is now playing, update it + const existingAlbum = uniqueAlbums.get(albumKey)! + uniqueAlbums.set(albumKey, { + ...existingAlbum, + isNowPlaying: true, + nowPlayingTrack: track.name }) } } @@ -111,8 +157,12 @@ async function searchAppleMusicForAlbum(album: Album): Promise { hasPreview: !!cachedData.previewUrl, trackCount: cachedData.tracks?.length || 0 }) + + // Check if this album is currently playing based on track durations + const updatedAlbum = checkNowPlaying(album, cachedData) + return { - ...album, + ...updatedAlbum, images: { ...album.images, itunes: cachedData.highResArtwork || album.images.itunes @@ -129,9 +179,12 @@ async function searchAppleMusicForAlbum(album: Album): Promise { // Cache the result for 24 hours await redis.set(cacheKey, JSON.stringify(transformedData), 'EX', 86400) + + // Check if this album is currently playing based on track durations + const updatedAlbum = checkNowPlaying(album, transformedData) return { - ...album, + ...updatedAlbum, images: { ...album.images, itunes: transformedData.highResArtwork || album.images.itunes @@ -182,3 +235,46 @@ function transformImages(images: LastfmImage[]): AlbumImages { return transformedImages } + +function checkNowPlaying(album: Album, appleMusicData: any): Album { + // Don't override if already marked as now playing by Last.fm + if (album.isNowPlaying) { + return album + } + + // Check if any recent track from this album could still be playing + const now = new Date() + const SCROBBLE_LAG = 3 * 60 * 1000 // 3 minutes in milliseconds + + for (const trackInfo of recentTracks) { + if (trackInfo.albumName !== album.name) continue + + // Find the track duration from Apple Music data + const trackData = appleMusicData.tracks?.find((t: any) => + t.name.toLowerCase() === trackInfo.trackName.toLowerCase() + ) + + if (trackData?.durationMs) { + // Calculate when the track should end (scrobble time + duration + lag) + const trackEndTime = new Date(trackInfo.scrobbleTime.getTime() + trackData.durationMs + SCROBBLE_LAG) + + // If current time is before track end time, it's likely still playing + if (now < trackEndTime) { + console.log(`Detected now playing: "${trackInfo.trackName}" from "${album.name}"`, { + scrobbleTime: trackInfo.scrobbleTime, + durationMs: trackData.durationMs, + estimatedEndTime: trackEndTime, + currentTime: now + }) + + return { + ...album, + isNowPlaying: true, + nowPlayingTrack: trackInfo.trackName + } + } + } + } + + return album +} From 6a0f1d7d3f20b09f7f059fc2d3183491805a1fec Mon Sep 17 00:00:00 2001 From: Justin Edmund Date: Fri, 13 Jun 2025 22:18:05 -0400 Subject: [PATCH 4/6] Realtime now playing --- src/lib/components/Album.svelte | 23 ++- src/lib/components/StreamStatus.svelte | 78 ++++++++++ src/lib/stores/now-playing-stream.ts | 133 ++++++++++++++++ src/routes/about/+page.svelte | 3 + src/routes/api/lastfm/stream/+server.ts | 196 ++++++++++++++++++++++++ 5 files changed, 426 insertions(+), 7 deletions(-) create mode 100644 src/lib/components/StreamStatus.svelte create mode 100644 src/lib/stores/now-playing-stream.ts create mode 100644 src/routes/api/lastfm/stream/+server.ts diff --git a/src/lib/components/Album.svelte b/src/lib/components/Album.svelte index b4e1b4c..3844195 100644 --- a/src/lib/components/Album.svelte +++ b/src/lib/components/Album.svelte @@ -2,6 +2,7 @@ import { spring } from 'svelte/motion' import type { Album } from '$lib/types/lastfm' import { audioPreview } from '$lib/stores/audio-preview' + import { nowPlayingStream } from '$lib/stores/now-playing-stream' import NowPlaying from './NowPlaying.svelte' interface AlbumProps { @@ -81,16 +82,24 @@ const hasPreview = $derived(!!album?.appleMusicData?.previewUrl) - // Debug log + // Subscribe to real-time now playing updates + let realtimeNowPlaying = $state<{ isNowPlaying: boolean; nowPlayingTrack?: string } | null>(null) + $effect(() => { if (album) { - console.log(`Album ${album.name}:`, { - hasAppleMusicData: !!album.appleMusicData, - previewUrl: album.appleMusicData?.previewUrl, - hasPreview + const unsubscribe = nowPlayingStream.isAlbumPlaying.subscribe(checkAlbum => { + const status = checkAlbum(album.artist.name, album.name) + if (status !== null) { + realtimeNowPlaying = status + } }) + return unsubscribe } }) + + // Combine initial state with real-time updates + const isNowPlaying = $derived(realtimeNowPlaying?.isNowPlaying ?? album?.isNowPlaying ?? false) + const nowPlayingTrack = $derived(realtimeNowPlaying?.nowPlayingTrack ?? album?.nowPlayingTrack)
@@ -110,8 +119,8 @@ style="transform: scale({$scale})" loading="lazy" /> - {#if album.isNowPlaying} - + {#if isNowPlaying} + {/if} {#if hasPreview && isHovering}