From 0487aa2b77f33a75b14a919f675180ca280d660d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 24 Dec 2025 16:13:18 +0000 Subject: [PATCH 1/8] Initial plan From 3cfd6b7cbd3cd8e137ebf36034dc96d728a6b97d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 24 Dec 2025 16:24:59 +0000 Subject: [PATCH 2/8] feat: add user playlist preferences Co-authored-by: Copystrike <26123873+Copystrike@users.noreply.github.com> --- README.md | 8 +- migrations/0003_add_playlist_preferences.sql | 4 + schema.sql | 7 +- src/lib/preferences.test.ts | 50 ++++++++++++ src/lib/preferences.ts | 49 ++++++++++++ src/middleware/auth.ts | 22 ++++-- src/routes/add.ts | 80 ++++++++++---------- src/routes/auth.ts | 13 +++- src/routes/dashboard.tsx | 65 +++++++++++++++- vite.config.ts | 7 +- 10 files changed, 248 insertions(+), 57 deletions(-) create mode 100644 migrations/0003_add_playlist_preferences.sql create mode 100644 src/lib/preferences.test.ts create mode 100644 src/lib/preferences.ts diff --git a/README.md b/README.md index 626022b..0770d29 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ This application aims to simplify adding music to Spotify playlists, especially * **Spotify Authentication:** Users can securely authenticate with their Spotify account using the Authorization Code Flow. * **Personal API Token:** Upon first login, each user is issued a unique, secure API token. This token acts as a lightweight, per-user authentication mechanism for the `/add` endpoint. * **API Token Management:** Users can view their API token on a dashboard, regenerate it (invalidating the old one), or delete their entire account and token. +* **Per-user playlist preferences:** Users can choose their default playlist, a low-similarity fallback playlist, and adjust the similarity threshold directly from the dashboard instead of relying on server environment values. * **iPhone Shortcut Integration:** The core functionality enables a simple HTTP GET request from an iPhone Shortcut (or similar automation tool) to add a song to a specified playlist. The API key can be provided either as a query parameter or in the `Authorization` header: * Query parameter: ``` @@ -79,7 +80,10 @@ CREATE TABLE users ( access_token TEXT NOT NULL, refresh_token TEXT NOT NULL, expires_at INTEGER NOT NULL, -- UNIX timestamp (when access_token expires) - api_key TEXT NOT NULL -- Secure token used in /add endpoint + api_key TEXT NOT NULL, -- Secure token used in /add endpoint + default_playlist TEXT, -- User-configurable default playlist + uncertain_playlist TEXT, -- User-configurable playlist when similarity is low + similarity_threshold REAL DEFAULT 0.6 ); ``` @@ -258,4 +262,4 @@ The application will be accessible at `http://localhost:8787` (or similar port). ## Contributing -(Optional: Add sections for how others can contribute, e.g., bug reports, feature requests, pull requests.) \ No newline at end of file +(Optional: Add sections for how others can contribute, e.g., bug reports, feature requests, pull requests.) diff --git a/migrations/0003_add_playlist_preferences.sql b/migrations/0003_add_playlist_preferences.sql new file mode 100644 index 0000000..cd1a737 --- /dev/null +++ b/migrations/0003_add_playlist_preferences.sql @@ -0,0 +1,4 @@ +-- Migration number: 0003 2025-12-24T16:14:23.000Z +ALTER TABLE users ADD COLUMN default_playlist TEXT; +ALTER TABLE users ADD COLUMN uncertain_playlist TEXT; +ALTER TABLE users ADD COLUMN similarity_threshold REAL DEFAULT 0.6; diff --git a/schema.sql b/schema.sql index 7bc22c9..ebe2a1b 100644 --- a/schema.sql +++ b/schema.sql @@ -3,5 +3,8 @@ CREATE TABLE IF NOT EXISTS users ( access_token TEXT NOT NULL, refresh_token TEXT NOT NULL, expires_at INTEGER NOT NULL, -- UNIX timestamp - api_key TEXT NOT NULL -- Secure token used in /add -); \ No newline at end of file + api_key TEXT NOT NULL, -- Secure token used in /add + default_playlist TEXT, -- User-configurable default playlist for matches + uncertain_playlist TEXT, -- User-configurable playlist for low-similarity matches + similarity_threshold REAL DEFAULT 0.6 -- Threshold for deciding uncertain matches +); diff --git a/src/lib/preferences.test.ts b/src/lib/preferences.test.ts new file mode 100644 index 0000000..31d1123 --- /dev/null +++ b/src/lib/preferences.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect } from 'vitest'; +import { resolvePlaylistTarget } from './preferences'; + +describe('resolvePlaylistTarget', () => { + it('prefers user configured playlists when provided', () => { + const result = resolvePlaylistTarget({ + requestedPlaylist: null, + similarity: 0.4, + preferences: { + defaultPlaylist: 'Daily Mix', + uncertainPlaylist: 'Needs Review', + similarityThreshold: 0.5 + }, + envDefault: 'Env Default', + envUncertain: 'Env Uncertain' + }); + + expect(result.basePlaylist).toBe('Daily Mix'); + expect(result.targetPlaylist).toBe('Needs Review'); + expect(result.similarityThreshold).toBe(0.5); + }); + + it('falls back to env defaults when user settings are missing', () => { + const result = resolvePlaylistTarget({ + requestedPlaylist: undefined, + similarity: 0.9, + preferences: {}, + envDefault: 'Env Default', + envUncertain: 'Env Uncertain' + }); + + expect(result.basePlaylist).toBe('Env Default'); + expect(result.targetPlaylist).toBe('Env Default'); + }); + + it('uses provided playlist override even when similarity is high', () => { + const result = resolvePlaylistTarget({ + requestedPlaylist: 'From Request', + similarity: 0.95, + preferences: { + defaultPlaylist: 'Default', + uncertainPlaylist: 'Low Confidence', + similarityThreshold: 0.6 + } + }); + + expect(result.basePlaylist).toBe('From Request'); + expect(result.targetPlaylist).toBe('From Request'); + }); +}); diff --git a/src/lib/preferences.ts b/src/lib/preferences.ts new file mode 100644 index 0000000..9f25029 --- /dev/null +++ b/src/lib/preferences.ts @@ -0,0 +1,49 @@ +export type PlaylistPreferences = { + defaultPlaylist?: string | null; + uncertainPlaylist?: string | null; + similarityThreshold?: number | null; +}; + +const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max); + +const normalize = (value?: string | null) => { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + return trimmed.length ? trimmed : null; +}; + +export function resolvePlaylistTarget(options: { + requestedPlaylist?: string | null; + similarity: number; + preferences: PlaylistPreferences; + envDefault?: string | null; + envUncertain?: string | null; +}) { + const similarityThreshold = clamp( + typeof options.preferences.similarityThreshold === 'number' + ? options.preferences.similarityThreshold + : 0.6, + 0, + 1 + ); + + const basePlaylist = + normalize(options.requestedPlaylist) ?? + normalize(options.preferences.defaultPlaylist) ?? + normalize(options.envDefault); + + const uncertainPlaylist = + normalize(options.preferences.uncertainPlaylist) ?? + normalize(options.envUncertain); + + const targetPlaylist = + options.similarity < similarityThreshold && uncertainPlaylist + ? uncertainPlaylist + : basePlaylist; + + return { + basePlaylist: basePlaylist ?? null, + targetPlaylist: targetPlaylist ?? null, + similarityThreshold + }; +} diff --git a/src/middleware/auth.ts b/src/middleware/auth.ts index 35d299b..db0c525 100644 --- a/src/middleware/auth.ts +++ b/src/middleware/auth.ts @@ -8,13 +8,16 @@ declare module 'hono' { interface ContextRenderer { } interface ContextVariableMap { currentUser: { - id: string; - access_token: string; - refresh_token: string; - expires_at: number; - api_key: string; - }; - } + id: string; + access_token: string; + refresh_token: string; + expires_at: number; + api_key: string; + default_playlist?: string | null; + uncertain_playlist?: string | null; + similarity_threshold?: number | null; + }; +} } export const requireAuth = createMiddleware<{ Bindings: CloudflareBindings; }>(async (c, next) => { @@ -40,6 +43,9 @@ export const requireAuth = createMiddleware<{ Bindings: CloudflareBindings; }>(a refresh_token: string; expires_at: number; api_key: string; + default_playlist: string | null; + uncertain_playlist: string | null; + similarity_threshold: number | null; }>(); if (!user) { @@ -86,4 +92,4 @@ export const requireAuth = createMiddleware<{ Bindings: CloudflareBindings; }>(a c.set('currentUser', user); await next(); -}); \ No newline at end of file +}); diff --git a/src/routes/add.ts b/src/routes/add.ts index ef5ccc8..38bbe1c 100644 --- a/src/routes/add.ts +++ b/src/routes/add.ts @@ -7,6 +7,7 @@ import { calculateSimilarity } from '../lib/utils'; // Import similarity utility import type { SpotifyApi } from '@spotify/web-api-ts-sdk'; import { SongInfo } from '../dto/SongInfo'; import { searchTrackBySongInfo } from '../lib/spotify'; +import { resolvePlaylistTarget } from '../lib/preferences'; // Define the context type for Hono type CustomContext = { @@ -18,6 +19,9 @@ type CustomContext = { refresh_token: string; expires_at: number; api_key: string; + default_playlist?: string | null; + uncertain_playlist?: string | null; + similarity_threshold?: number | null; }; spotifySdk: SpotifyApi; }; @@ -42,7 +46,16 @@ export const validateApiToken = async (c: any, next: any) => { return c.text('Error: API key (token) is missing in query parameter or Authorization header.', 401); } - const user = await DB.prepare('SELECT * FROM users WHERE api_key = ?').bind(apiToken).first(); + const user = await DB.prepare('SELECT * FROM users WHERE api_key = ?').bind(apiToken).first<{ + id: string; + access_token: string; + refresh_token: string; + expires_at: number; + api_key: string; + default_playlist: string | null; + uncertain_playlist: string | null; + similarity_threshold: number | null; + }>(); if (!user) { return c.text('Error: Invalid API key (token).', 403); @@ -129,35 +142,22 @@ add.on(['GET', 'POST'], '/', validateApiToken, async (c) => { // Use the correct context key and type assertion const authenticatedUser = c.get('currentUser') as CustomContext['Variables']['currentUser']; const spotifySdk = c.get('spotifySdk') as SpotifyApi; + const envDefaults = env(c); + const userPreferences = { + defaultPlaylist: authenticatedUser.default_playlist, + uncertainPlaylist: authenticatedUser.uncertain_playlist, + similarityThreshold: authenticatedUser.similarity_threshold + }; if (!songQuery) { // Changed from songName to songQuery return c.text('Error: Song query (query) is missing.', 400); // Updated message } if (!playlistName) { - const { PLAYLIST_NAME } = env(c); - if (PLAYLIST_NAME) { - playlistName = PLAYLIST_NAME; - } - } - - if (!playlistName) { - return c.text('Error: Playlist name (playlist) is missing.', 400); + playlistName = userPreferences.defaultPlaylist ?? envDefaults.PLAYLIST_NAME; } - let cleanedSongInfo: SongInfo | null = null; - if (!playlistName) { - const { PLAYLIST_NAME } = env(c); - if (PLAYLIST_NAME) { - playlistName = PLAYLIST_NAME; - } - } - - if (!playlistName) { - return c.text('Error: Playlist name (playlist) is missing.', 400); - } - // Execute AI query cleaning if requested and the API key is available if (useAi) { const { GROQ_API_KEY } = env(c); @@ -189,21 +189,23 @@ add.on(['GET', 'POST'], '/', validateApiToken, async (c) => { const queryString = `${cleanedSongInfo.title} ${queryArtistString}`; const similarity = calculateSimilarity(trackString, queryString); - console.log(`Similarity score: ${similarity} (Query: "${queryString}", Result: "${trackString}")`); - - let targetPlaylistName = playlistName; - const SIMILARITY_THRESHOLD = 0.6; - - if (similarity < SIMILARITY_THRESHOLD) { - console.warn(`Similarity ${similarity} is below threshold ${SIMILARITY_THRESHOLD}. Attempting to use uncertain playlist.`); - const bindings = env(c) as CloudflareBindings; - const uncertainPlaylist = bindings.UNCERTAIN_PLAYLIST_NAME; - if (uncertainPlaylist) { - targetPlaylistName = uncertainPlaylist; - console.log(`Redirecting to uncertain playlist: ${targetPlaylistName}`); - } else { - console.warn('UNCERTAIN_PLAYLIST_NAME not defined. Proceeding with original playlist.'); - } + const playlistSelection = resolvePlaylistTarget({ + requestedPlaylist: playlistName, + similarity, + preferences: userPreferences, + envDefault: envDefaults.PLAYLIST_NAME, + envUncertain: envDefaults.UNCERTAIN_PLAYLIST_NAME + }); + + console.log(`Similarity score: ${similarity} (Query: "${queryString}", Result: "${trackString}"), threshold: ${playlistSelection.similarityThreshold}`); + + if (!playlistSelection.basePlaylist || !playlistSelection.targetPlaylist) { + return c.text('Error: Playlist name (playlist) is missing.', 400); + } + + let targetPlaylistName = playlistSelection.targetPlaylist; + if (targetPlaylistName !== playlistSelection.basePlaylist) { + console.warn(`Similarity ${similarity} is below threshold ${playlistSelection.similarityThreshold}. Using uncertain playlist "${targetPlaylistName}".`); } const playlist = await findUserPlaylist(spotifySdk, targetPlaylistName); @@ -211,9 +213,9 @@ add.on(['GET', 'POST'], '/', validateApiToken, async (c) => { // If uncertain playlist is missing, maybe fallback to original or error? // For now, if we tried uncertain and failed, we should probably fail or fallback. // Let's fallback to original if target was uncertain and not found, provided original is different. - if (targetPlaylistName !== playlistName) { - console.warn(`Uncertain playlist "${targetPlaylistName}" not found. Falling back to "${playlistName}".`); - const fallbackPlaylist = await findUserPlaylist(spotifySdk, playlistName); + if (targetPlaylistName !== playlistSelection.basePlaylist) { + console.warn(`Uncertain playlist "${targetPlaylistName}" not found. Falling back to "${playlistSelection.basePlaylist}".`); + const fallbackPlaylist = await findUserPlaylist(spotifySdk, playlistSelection.basePlaylist); if (fallbackPlaylist) { // recursive-ish but simple const wasAdded = await addTrackToPlaylist(spotifySdk, fallbackPlaylist.id, track.uri); diff --git a/src/routes/auth.ts b/src/routes/auth.ts index c5094ac..d37c693 100644 --- a/src/routes/auth.ts +++ b/src/routes/auth.ts @@ -69,6 +69,9 @@ auth.get('/callback', async (c) => { refresh_token: string; expires_at: number; api_key: string; + default_playlist: string | null; + uncertain_playlist: string | null; + similarity_threshold: number | null; }>(); // Cast to the full user type to access api_key let apiKeyToSave: string; // This variable will hold the API key we want to store/preserve @@ -89,14 +92,18 @@ auth.get('/callback', async (c) => { } else { // New user, generate a NEW API key for them and insert the full record. apiKeyToSave = generateApiKey(); // Generate API key ONLY for new users + const { PLAYLIST_NAME, UNCERTAIN_PLAYLIST_NAME } = env(c); await DB.prepare( - 'INSERT INTO users (id, access_token, refresh_token, expires_at, api_key) VALUES (?, ?, ?, ?, ?)' + 'INSERT INTO users (id, access_token, refresh_token, expires_at, api_key, default_playlist, uncertain_playlist, similarity_threshold) VALUES (?, ?, ?, ?, ?, ?, ?, ?)' ).bind( spotifyUserId, tokenData.access_token, tokenData.refresh_token, expiresAt, - apiKeyToSave // Use the newly generated key for the new user + apiKeyToSave, // Use the newly generated key for the new user + PLAYLIST_NAME ?? null, + UNCERTAIN_PLAYLIST_NAME ?? null, + 0.6 ).run(); console.log(`Inserted new user ${spotifyUserId} into D1 with a new API key.`); } @@ -116,4 +123,4 @@ auth.get('/callback', async (c) => { } }); -export default auth; \ No newline at end of file +export default auth; diff --git a/src/routes/dashboard.tsx b/src/routes/dashboard.tsx index 788aa44..ec6d486 100644 --- a/src/routes/dashboard.tsx +++ b/src/routes/dashboard.tsx @@ -14,6 +14,7 @@ dashboard.get('/', (c) => { const user = c.get('currentUser'); const message = c.req.query('message'); const error = c.req.query('error'); + const similarityThreshold = user.similarity_threshold ?? 0.6; return c.render(
@@ -40,6 +41,44 @@ dashboard.get('/', (c) => {

+
+

Playlist Preferences

+

Choose where songs should go when matches are confident versus when they are uncertain.

+
+ + + + + + + + + + +

When no playlist is passed to the API, your defaults are used instead of server environment variables.

+
+
+

IOS Shortcut

Use this shortcut to quickly add songs from your Shazam to your Spotify playlist using PlaylistUrlify:

@@ -91,6 +130,30 @@ dashboard.get('/', (c) => { ); }); +dashboard.post('/preferences', async (c) => { + const user = c.get('currentUser'); + const { DB } = env(c) as unknown as Cloudflare.Env; + + try { + const body = await c.req.parseBody(); + + const defaultPlaylist = typeof body?.default_playlist === 'string' ? body.default_playlist.trim() : ''; + const uncertainPlaylist = typeof body?.uncertain_playlist === 'string' ? body.uncertain_playlist.trim() : ''; + const similarityRaw = typeof body?.similarity_threshold === 'string' ? body.similarity_threshold : ''; + const parsedThreshold = parseFloat(similarityRaw); + const similarityThreshold = Number.isFinite(parsedThreshold) ? Math.min(Math.max(parsedThreshold, 0), 1) : 0.6; + + await DB.prepare('UPDATE users SET default_playlist = ?, uncertain_playlist = ?, similarity_threshold = ? WHERE id = ?') + .bind(defaultPlaylist || null, uncertainPlaylist || null, similarityThreshold, user.id) + .run(); + + return c.redirect('/dashboard?message=Playlist preferences updated.'); + } catch (err) { + console.error('Error updating playlist preferences:', err); + return c.redirect('/dashboard?error=Failed to update playlist preferences.'); + } +}); + dashboard.post('/logout', async (c) => { const sessionId = getCookie(c, '__session'); const { DB } = env(c) as unknown as Cloudflare.Env; @@ -108,4 +171,4 @@ dashboard.post('/logout', async (c) => { return c.redirect('/?message=You have been logged out.'); }); -export default dashboard; \ No newline at end of file +export default dashboard; diff --git a/vite.config.ts b/vite.config.ts index 723a8ec..ba15c57 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,10 +1,13 @@ /// import { cloudflare } from '@cloudflare/vite-plugin'; -import { defineConfig } from 'vite'; +import { defineConfig, type PluginOption } from 'vite'; import ssrPlugin from 'vite-ssr-components/plugin'; +const isTest = process.env.VITEST === 'true'; +const plugins: PluginOption[] = [!isTest && cloudflare(), ssrPlugin()].filter(Boolean) as PluginOption[]; + export default defineConfig({ - plugins: [cloudflare(), ssrPlugin()], + plugins, test: { globals: true, environment: 'node', From 6a984c9b6f813997e36abd7164d2e8e988e5093c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 24 Dec 2025 16:28:22 +0000 Subject: [PATCH 3/8] chore: address review feedback for preferences Co-authored-by: Copystrike <26123873+Copystrike@users.noreply.github.com> --- src/routes/add.ts | 3 +++ src/routes/dashboard.tsx | 8 ++++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/routes/add.ts b/src/routes/add.ts index 38bbe1c..d2b7e55 100644 --- a/src/routes/add.ts +++ b/src/routes/add.ts @@ -154,6 +154,9 @@ add.on(['GET', 'POST'], '/', validateApiToken, async (c) => { } if (!playlistName) { playlistName = userPreferences.defaultPlaylist ?? envDefaults.PLAYLIST_NAME; + if (!playlistName && !userPreferences.defaultPlaylist && !envDefaults.PLAYLIST_NAME) { + return c.text('Error: Playlist name (playlist) is missing.', 400); + } } let cleanedSongInfo: SongInfo | null = null; diff --git a/src/routes/dashboard.tsx b/src/routes/dashboard.tsx index ec6d486..f16437a 100644 --- a/src/routes/dashboard.tsx +++ b/src/routes/dashboard.tsx @@ -5,6 +5,8 @@ import { env } from 'hono/adapter'; import './../style.css'; +const DEFAULT_SIMILARITY_THRESHOLD = 0.6; + const dashboard = new Hono(); @@ -14,7 +16,7 @@ dashboard.get('/', (c) => { const user = c.get('currentUser'); const message = c.req.query('message'); const error = c.req.query('error'); - const similarityThreshold = user.similarity_threshold ?? 0.6; + const similarityThreshold = user.similarity_threshold ?? DEFAULT_SIMILARITY_THRESHOLD; return c.render(
@@ -141,7 +143,9 @@ dashboard.post('/preferences', async (c) => { const uncertainPlaylist = typeof body?.uncertain_playlist === 'string' ? body.uncertain_playlist.trim() : ''; const similarityRaw = typeof body?.similarity_threshold === 'string' ? body.similarity_threshold : ''; const parsedThreshold = parseFloat(similarityRaw); - const similarityThreshold = Number.isFinite(parsedThreshold) ? Math.min(Math.max(parsedThreshold, 0), 1) : 0.6; + const similarityThreshold = Number.isFinite(parsedThreshold) + ? Math.min(Math.max(parsedThreshold, 0), 1) + : DEFAULT_SIMILARITY_THRESHOLD; await DB.prepare('UPDATE users SET default_playlist = ?, uncertain_playlist = ?, similarity_threshold = ? WHERE id = ?') .bind(defaultPlaylist || null, uncertainPlaylist || null, similarityThreshold, user.id) From f3c1071d32969ff5c28a35b6a842cbf539192cfc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 24 Dec 2025 16:29:37 +0000 Subject: [PATCH 4/8] chore: simplify playlist validation and fix input default Co-authored-by: Copystrike <26123873+Copystrike@users.noreply.github.com> --- src/routes/add.ts | 2 +- src/routes/dashboard.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/routes/add.ts b/src/routes/add.ts index d2b7e55..447a787 100644 --- a/src/routes/add.ts +++ b/src/routes/add.ts @@ -154,7 +154,7 @@ add.on(['GET', 'POST'], '/', validateApiToken, async (c) => { } if (!playlistName) { playlistName = userPreferences.defaultPlaylist ?? envDefaults.PLAYLIST_NAME; - if (!playlistName && !userPreferences.defaultPlaylist && !envDefaults.PLAYLIST_NAME) { + if (!playlistName) { return c.text('Error: Playlist name (playlist) is missing.', 400); } } diff --git a/src/routes/dashboard.tsx b/src/routes/dashboard.tsx index f16437a..4a4e521 100644 --- a/src/routes/dashboard.tsx +++ b/src/routes/dashboard.tsx @@ -73,7 +73,7 @@ dashboard.get('/', (c) => { min="0" max="1" step="0.01" - defaultValue={similarityThreshold} + defaultValue={similarityThreshold.toString()} /> From 2303a6053ba06a281033801600a44bfda9f48ec5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 24 Dec 2025 16:30:52 +0000 Subject: [PATCH 5/8] chore: clarify song info handling comment Co-authored-by: Copystrike <26123873+Copystrike@users.noreply.github.com> --- src/routes/add.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/routes/add.ts b/src/routes/add.ts index 447a787..69c42af 100644 --- a/src/routes/add.ts +++ b/src/routes/add.ts @@ -159,6 +159,7 @@ add.on(['GET', 'POST'], '/', validateApiToken, async (c) => { } } + // Holder for the user-provided or AI-cleaned song info let cleanedSongInfo: SongInfo | null = null; // Execute AI query cleaning if requested and the API key is available From 512e1f2d5514b8af3da5532003a5e41b140d016c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 24 Dec 2025 16:32:12 +0000 Subject: [PATCH 6/8] chore: handle env defaults and guard preference update Co-authored-by: Copystrike <26123873+Copystrike@users.noreply.github.com> --- src/lib/preferences.ts | 14 +++++++------- src/routes/auth.ts | 6 ++++-- src/routes/dashboard.tsx | 6 +++++- 3 files changed, 16 insertions(+), 10 deletions(-) diff --git a/src/lib/preferences.ts b/src/lib/preferences.ts index 9f25029..e373470 100644 --- a/src/lib/preferences.ts +++ b/src/lib/preferences.ts @@ -4,8 +4,6 @@ export type PlaylistPreferences = { similarityThreshold?: number | null; }; -const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max); - const normalize = (value?: string | null) => { if (typeof value !== 'string') return null; const trimmed = value.trim(); @@ -19,11 +17,13 @@ export function resolvePlaylistTarget(options: { envDefault?: string | null; envUncertain?: string | null; }) { - const similarityThreshold = clamp( - typeof options.preferences.similarityThreshold === 'number' - ? options.preferences.similarityThreshold - : 0.6, - 0, + const similarityThreshold = Math.min( + Math.max( + typeof options.preferences.similarityThreshold === 'number' + ? options.preferences.similarityThreshold + : 0.6, + 0 + ), 1 ); diff --git a/src/routes/auth.ts b/src/routes/auth.ts index d37c693..0d3595d 100644 --- a/src/routes/auth.ts +++ b/src/routes/auth.ts @@ -93,6 +93,8 @@ auth.get('/callback', async (c) => { // New user, generate a NEW API key for them and insert the full record. apiKeyToSave = generateApiKey(); // Generate API key ONLY for new users const { PLAYLIST_NAME, UNCERTAIN_PLAYLIST_NAME } = env(c); + const defaultPlaylist = PLAYLIST_NAME ?? null; + const uncertainPlaylist = UNCERTAIN_PLAYLIST_NAME ?? null; await DB.prepare( 'INSERT INTO users (id, access_token, refresh_token, expires_at, api_key, default_playlist, uncertain_playlist, similarity_threshold) VALUES (?, ?, ?, ?, ?, ?, ?, ?)' ).bind( @@ -101,8 +103,8 @@ auth.get('/callback', async (c) => { tokenData.refresh_token, expiresAt, apiKeyToSave, // Use the newly generated key for the new user - PLAYLIST_NAME ?? null, - UNCERTAIN_PLAYLIST_NAME ?? null, + defaultPlaylist, + uncertainPlaylist, 0.6 ).run(); console.log(`Inserted new user ${spotifyUserId} into D1 with a new API key.`); diff --git a/src/routes/dashboard.tsx b/src/routes/dashboard.tsx index 4a4e521..729c259 100644 --- a/src/routes/dashboard.tsx +++ b/src/routes/dashboard.tsx @@ -147,10 +147,14 @@ dashboard.post('/preferences', async (c) => { ? Math.min(Math.max(parsedThreshold, 0), 1) : DEFAULT_SIMILARITY_THRESHOLD; - await DB.prepare('UPDATE users SET default_playlist = ?, uncertain_playlist = ?, similarity_threshold = ? WHERE id = ?') + const result = await DB.prepare('UPDATE users SET default_playlist = ?, uncertain_playlist = ?, similarity_threshold = ? WHERE id = ?') .bind(defaultPlaylist || null, uncertainPlaylist || null, similarityThreshold, user.id) .run(); + if (!result.success) { + return c.redirect('/dashboard?error=Failed to update playlist preferences.'); + } + return c.redirect('/dashboard?message=Playlist preferences updated.'); } catch (err) { console.error('Error updating playlist preferences:', err); From 0d33f02e228642f1ed4ae4bf94a612f8fd27d6dc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 24 Dec 2025 16:33:15 +0000 Subject: [PATCH 7/8] chore: normalize env defaults and validate updates Co-authored-by: Copystrike <26123873+Copystrike@users.noreply.github.com> --- src/routes/add.ts | 6 +++++- src/routes/dashboard.tsx | 3 ++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/routes/add.ts b/src/routes/add.ts index 69c42af..220e956 100644 --- a/src/routes/add.ts +++ b/src/routes/add.ts @@ -142,7 +142,11 @@ add.on(['GET', 'POST'], '/', validateApiToken, async (c) => { // Use the correct context key and type assertion const authenticatedUser = c.get('currentUser') as CustomContext['Variables']['currentUser']; const spotifySdk = c.get('spotifySdk') as SpotifyApi; - const envDefaults = env(c); + const bindings = env(c) as Partial; + const envDefaults = { + PLAYLIST_NAME: bindings.PLAYLIST_NAME ?? null, + UNCERTAIN_PLAYLIST_NAME: bindings.UNCERTAIN_PLAYLIST_NAME ?? null + }; const userPreferences = { defaultPlaylist: authenticatedUser.default_playlist, uncertainPlaylist: authenticatedUser.uncertain_playlist, diff --git a/src/routes/dashboard.tsx b/src/routes/dashboard.tsx index 729c259..f57c746 100644 --- a/src/routes/dashboard.tsx +++ b/src/routes/dashboard.tsx @@ -151,7 +151,8 @@ dashboard.post('/preferences', async (c) => { .bind(defaultPlaylist || null, uncertainPlaylist || null, similarityThreshold, user.id) .run(); - if (!result.success) { + const changes = (result as any)?.meta?.changes ?? 0; + if (!result.success || changes === 0) { return c.redirect('/dashboard?error=Failed to update playlist preferences.'); } From 2bf48b2e5c79945b4ee9702469cca9ad2f0a168f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 24 Dec 2025 16:34:58 +0000 Subject: [PATCH 8/8] chore: address review nits Co-authored-by: Copystrike <26123873+Copystrike@users.noreply.github.com> --- src/lib/preferences.ts | 6 ++++++ src/routes/add.ts | 2 +- src/routes/dashboard.tsx | 5 +++-- vite.config.ts | 5 ++++- 4 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/lib/preferences.ts b/src/lib/preferences.ts index e373470..5ce584b 100644 --- a/src/lib/preferences.ts +++ b/src/lib/preferences.ts @@ -1,3 +1,9 @@ +/** + * Per-user playlist routing preferences. + * - defaultPlaylist: target when similarity is above threshold or unspecified. + * - uncertainPlaylist: fallback when similarity is below threshold. + * - similarityThreshold: range 0–1; null/undefined uses 0.6 default. + */ export type PlaylistPreferences = { defaultPlaylist?: string | null; uncertainPlaylist?: string | null; diff --git a/src/routes/add.ts b/src/routes/add.ts index 220e956..7620249 100644 --- a/src/routes/add.ts +++ b/src/routes/add.ts @@ -142,7 +142,7 @@ add.on(['GET', 'POST'], '/', validateApiToken, async (c) => { // Use the correct context key and type assertion const authenticatedUser = c.get('currentUser') as CustomContext['Variables']['currentUser']; const spotifySdk = c.get('spotifySdk') as SpotifyApi; - const bindings = env(c) as Partial; + const bindings = env(c) as CloudflareBindings; const envDefaults = { PLAYLIST_NAME: bindings.PLAYLIST_NAME ?? null, UNCERTAIN_PLAYLIST_NAME: bindings.UNCERTAIN_PLAYLIST_NAME ?? null diff --git a/src/routes/dashboard.tsx b/src/routes/dashboard.tsx index f57c746..518beff 100644 --- a/src/routes/dashboard.tsx +++ b/src/routes/dashboard.tsx @@ -147,11 +147,12 @@ dashboard.post('/preferences', async (c) => { ? Math.min(Math.max(parsedThreshold, 0), 1) : DEFAULT_SIMILARITY_THRESHOLD; + type D1Result = { success: boolean; meta?: { changes?: number } }; const result = await DB.prepare('UPDATE users SET default_playlist = ?, uncertain_playlist = ?, similarity_threshold = ? WHERE id = ?') .bind(defaultPlaylist || null, uncertainPlaylist || null, similarityThreshold, user.id) - .run(); + .run() as D1Result; - const changes = (result as any)?.meta?.changes ?? 0; + const changes = result.meta?.changes ?? 0; if (!result.success || changes === 0) { return c.redirect('/dashboard?error=Failed to update playlist preferences.'); } diff --git a/vite.config.ts b/vite.config.ts index ba15c57..ad06110 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -4,7 +4,10 @@ import { defineConfig, type PluginOption } from 'vite'; import ssrPlugin from 'vite-ssr-components/plugin'; const isTest = process.env.VITEST === 'true'; -const plugins: PluginOption[] = [!isTest && cloudflare(), ssrPlugin()].filter(Boolean) as PluginOption[]; +const plugins: PluginOption[] = [ + ssrPlugin(), + ...(isTest ? [] as PluginOption[] : [cloudflare()]) +]; export default defineConfig({ plugins,