Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
```
Expand Down Expand Up @@ -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
);
```

Expand Down Expand Up @@ -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.)
(Optional: Add sections for how others can contribute, e.g., bug reports, feature requests, pull requests.)
4 changes: 4 additions & 0 deletions migrations/0003_add_playlist_preferences.sql
Original file line number Diff line number Diff line change
@@ -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;
7 changes: 5 additions & 2 deletions schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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
);
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
);
50 changes: 50 additions & 0 deletions src/lib/preferences.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
55 changes: 55 additions & 0 deletions src/lib/preferences.ts
Original file line number Diff line number Diff line change
@@ -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
};
}
22 changes: 14 additions & 8 deletions src/middleware/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -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) {
Expand Down Expand Up @@ -86,4 +92,4 @@ export const requireAuth = createMiddleware<{ Bindings: CloudflareBindings; }>(a

c.set('currentUser', user);
await next();
});
});
86 changes: 48 additions & 38 deletions src/routes/add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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;
};
Expand All @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -189,31 +197,33 @@ 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);
if (!playlist) {
// 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);
Expand Down
15 changes: 12 additions & 3 deletions src/routes/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.`);
}
Expand All @@ -116,4 +125,4 @@ auth.get('/callback', async (c) => {
}
});

export default auth;
export default auth;
Loading