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..5ce584b --- /dev/null +++ b/src/lib/preferences.ts @@ -0,0 +1,55 @@ +/** + * 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; + similarityThreshold?: number | null; +}; + +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 = Math.min( + Math.max( + 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..7620249 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,30 @@ 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 CloudflareBindings; + 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, + 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; + playlistName = userPreferences.defaultPlaylist ?? envDefaults.PLAYLIST_NAME; + if (!playlistName) { + return c.text('Error: Playlist name (playlist) is missing.', 400); } } - if (!playlistName) { - return c.text('Error: Playlist name (playlist) is missing.', 400); - } - - + // Holder for the user-provided or AI-cleaned song info 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 +197,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 +221,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..0d3595d 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,20 @@ 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); + 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) 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 + defaultPlaylist, + uncertainPlaylist, + 0.6 ).run(); console.log(`Inserted new user ${spotifyUserId} into D1 with a new API key.`); } @@ -116,4 +125,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..518beff 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,6 +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 ?? DEFAULT_SIMILARITY_THRESHOLD; return c.render(
@@ -40,6 +43,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 +132,38 @@ 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) + : 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() as D1Result; + + const changes = result.meta?.changes ?? 0; + if (!result.success || changes === 0) { + 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); + 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 +181,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..ad06110 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,10 +1,16 @@ /// 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[] = [ + ssrPlugin(), + ...(isTest ? [] as PluginOption[] : [cloudflare()]) +]; + export default defineConfig({ - plugins: [cloudflare(), ssrPlugin()], + plugins, test: { globals: true, environment: 'node',