Skip to content
Merged
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
26 changes: 25 additions & 1 deletion API_SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,31 @@

## Spotify

Configure `SPOTIFY_CLIENT_ID`, `SPOTIFY_CLIENT_SECRET`, and `SPOTIFY_REDIRECT_URI`. The adapter must implement playlist import with pagination before real Spotify import is enabled.
Configure:

```env
SPOTIFY_CLIENT_ID=
SPOTIFY_CLIENT_SECRET=
SPOTIFY_REDIRECT_URI=https://your-domain.example/api/oauth/spotify/callback
```

Spotify Developer Dashboard steps:

1. Create an app in the Spotify Developer Dashboard.
2. Copy the client ID into `SPOTIFY_CLIENT_ID`.
3. Copy the client secret into `SPOTIFY_CLIENT_SECRET`.
4. Add the exact redirect URI used by the deployment, including `/api/oauth/spotify/callback`.
5. Keep the redirect URI scheme, host, port, and path identical between Spotify and the environment variable.
6. Request only the scopes listed below during OAuth.

Scopes used:

- `playlist-read-private`: import required metadata from private playlists the user authorizes.
- `playlist-read-collaborative`: import collaborative playlist items available to the authorized user.
- `playlist-modify-private`: create private destination playlists and add tracks.
- `playlist-modify-public`: create public destination playlists if the UI later enables public export.

Spotify is implemented as both a source and destination adapter. Public playlist import uses a server-side Spotify token when client credentials are configured. Authorized playlists and destination playlist creation require user OAuth.

## YouTube

Expand Down
11 changes: 10 additions & 1 deletion PLATFORM_LIMITATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

## Current Status

No real music provider conversion is complete yet.
Spotify source import, Spotify catalogue search, Spotify OAuth/token refresh, Spotify playlist creation, and Spotify batched add-items are implemented.

## Spotify to YouTube

Expand All @@ -15,6 +15,15 @@ The initial target flow is represented in models, UI, routes, and adapter contra
- Batched track insertion.
- Tested failure handling.

## Spotify to Spotify

Implemented with real Spotify credentials and user authorization. Limits still apply:

- Spotify API rate limits can delay conversion.
- Local files, deleted playlist entries, unavailable entries, and non-track items cannot be copied through the Web API.
- Low-confidence matches are not silently selected.
- Current runtime storage is an in-memory foundation store until Supabase persistence is wired into the route layer.

## Apple Music, Deezer, and Tidal

Apple Music and Deezer are modelled for future adapters. Tidal is planned architecturally but not included in the current `MusicPlatform` enum until implementation begins.
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ TuneBridge is a production-oriented foundation for an accountless playlist trans
- Mobile-first dark UI for required pages and result/status surfaces.
- Advertising and consent placeholders without loading real advertising scripts.
- Unit tests, React Testing Library tests, Playwright test, and GitHub Actions CI.
- Real Spotify source/destination adapter using the Spotify Web API for playlist import, catalogue search, playlist creation, token refresh, and batched track insertion.
- Spotify Authorization Code with PKCE endpoints, cryptographic state, short expiration, single-use validation, secure cookies, and encrypted refresh-token storage abstraction.

## Mock Functionality

Expand All @@ -23,7 +25,6 @@ TuneBridge is a production-oriented foundation for an accountless playlist trans

## Planned Functionality

- Real Spotify playlist import with API pagination.
- Real YouTube OAuth, catalogue search, playlist creation, and track insertion.
- Apple Music, Deezer, and future Tidal adapters.
- Supabase persistence for all API workflows.
Expand Down Expand Up @@ -51,4 +52,4 @@ npm run build
npm run test:e2e
```

Do not claim real Spotify, YouTube, Apple Music, or Deezer conversion works until those provider adapters are implemented and tested.
With real Spotify credentials, Spotify playlist import, Spotify catalogue search, Spotify playlist creation, and Spotify track insertion are implemented. YouTube, Apple Music, and Deezer conversion is not implemented yet.
5 changes: 4 additions & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ Implemented or scaffolded safeguards:
- OAuth redirect path validation.
- Secure, same-site consent cookie configuration.
- Token encryption interface.
- AES-GCM token encryptor for Spotify refresh-token storage.
- Spotify OAuth state hashing, short expiration, and single-use consumption.
- Spotify token refresh and disconnect storage flow.
- Rate limiting abstraction.
- Secure response headers and CSP in `next.config.ts`.
- Correlation IDs on JSON responses.
Expand All @@ -19,7 +22,7 @@ Implemented or scaffolded safeguards:
Production work still required:

- Persist OAuth state hashes and enforce one-time consumption.
- Implement token encryption.
- Move OAuth state and token storage from in-memory foundation stores to Supabase.
- Add Supabase RLS policies for signed anonymous sessions.
- Store rate-limit counters in Supabase.
- Complete provider-specific OAuth callback validation.
2 changes: 1 addition & 1 deletion next-env.d.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
import "./.next/types/routes.d.ts";

// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
4 changes: 2 additions & 2 deletions src/app/api/jobs/[jobId]/route.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { getFoundationJob } from "@/lib/api/jobs-store";
import { getFoundationJob, getJobSummary } from "@/lib/api/jobs-store";
import { jobIdInputSchema } from "@/lib/models";
import { jsonResponse, validationError } from "@/lib/api/http";

Expand All @@ -12,7 +12,7 @@ export async function GET(
if (!job) {
return jsonResponse({ error: "not_found" }, 404);
}
return jsonResponse({ job });
return jsonResponse({ job, summary: getJobSummary(params.jobId) });
} catch (error) {
return validationError(error);
}
Expand Down
105 changes: 97 additions & 8 deletions src/app/api/jobs/route.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,20 @@
import { convertSpotifyToSpotify } from "@/lib/adapters/spotify/conversion";
import {
createConversionJobInputSchema,
platformLabels
} from "@/lib/models";
refreshSpotifyAccessToken,
spotifyTokenStore
} from "@/lib/adapters/spotify/token-store";
import { createConversionJobInputSchema, platformLabels } from "@/lib/models";
import {
jsonResponse,
readValidatedJson,
setupRequired,
validationError
} from "@/lib/api/http";
import { createFoundationJob } from "@/lib/api/jobs-store";
import {
createFoundationJob,
setFoundationJob,
setJobSummary
} from "@/lib/api/jobs-store";
import { detectMusicPlatformUrl } from "@/lib/security/url";

export async function POST(request: Request) {
Expand All @@ -20,10 +26,82 @@ export async function POST(request: Request) {
return jsonResponse({ error: "unsupported_url", message: detected.reason }, 400);
}

if (
detected.platform !== "spotify" ||
input.destinationPlatform !== "youtube"
) {
if (detected.platform === "spotify" && input.destinationPlatform === "spotify") {
const job = createFoundationJob({
playlistUrl: input.url,
destinationPlatform: input.destinationPlatform
});
const spotifySessionId = getCookieValue(request, "spotify_session");

if (!spotifySessionId) {
return jsonResponse(
{
job,
authorizationRequired: true,
authorizationStartUrl: `/api/oauth/spotify/start?redirectPath=/convert/${job.id}`,
message: "Authorize Spotify to create a destination playlist."
},
202
);
}

const token = await spotifyTokenStore.get(spotifySessionId);
if (!token) {
return jsonResponse(
{
job,
authorizationRequired: true,
authorizationStartUrl: `/api/oauth/spotify/start?redirectPath=/convert/${job.id}`,
message: "Spotify authorization has expired. Reauthorize Spotify."
},
202
);
}

const validToken =
token.expiresAt.getTime() <= Date.now()
? await refreshSpotifyAccessToken({ anonymousSessionId: spotifySessionId })
: token;
const started = { ...job, status: "matching" as const, currentStage: "match_tracks" as const };
setFoundationJob(started);
const result = await convertSpotifyToSpotify({
playlistUrl: input.url,
auth: {
accessToken: validToken.accessToken,
scopes: validToken.scopes,
expiresAt: validToken.expiresAt
}
});
const completed = {
...started,
destinationPlaylistId: result.destinationPlaylistUrl.split("/").pop() ?? null,
status:
result.failedTracks > 0 || result.unmatchedTracks > 0
? ("partially_completed" as const)
: ("completed" as const),
currentStage: "done" as const,
importedTrackCount: result.importedTracks,
matchedTrackCount: result.matchedTracks,
unmatchedTrackCount: result.unmatchedTracks,
addedTrackCount: result.addedTracks,
failedTrackCount: result.failedTracks,
updatedAt: new Date().toISOString(),
completedAt: new Date().toISOString()
};
setFoundationJob(completed);
setJobSummary(completed.id, {
destinationPlaylistUrl: result.destinationPlaylistUrl,
importedTracks: result.importedTracks,
unavailableTracks: result.unavailableTracks,
matchedTracks: result.matchedTracks,
unmatchedTracks: result.unmatchedTracks,
addedTracks: result.addedTracks,
failedTracks: result.failedTracks
});
return jsonResponse({ job: completed, result }, 201);
}

if (detected.platform !== "spotify" || input.destinationPlatform !== "youtube") {
return setupRequired(
`The initial real-provider flow is Spotify to YouTube. ${platformLabels[detected.platform]} to ${platformLabels[input.destinationPlatform]} is planned but not implemented.`
);
Expand All @@ -50,3 +128,14 @@ export async function POST(request: Request) {
return validationError(error);
}
}

function getCookieValue(request: Request, name: string): string | null {
return (
request.headers
.get("cookie")
?.split(";")
.map((value) => value.trim())
.find((value) => value.startsWith(`${name}=`))
?.split("=")[1] ?? null
);
}
123 changes: 120 additions & 3 deletions src/app/api/oauth/[platform]/callback/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,19 @@
import { musicPlatformSchema } from "@/lib/models";
import { convertSpotifyToSpotify } from "@/lib/adapters/spotify/conversion";
import { SpotifyClient } from "@/lib/adapters/spotify/client";
import { consumeSpotifyOAuthState } from "@/lib/adapters/spotify/oauth";
import {
createSpotifyTokenEncryptor,
spotifyTokenStore
} from "@/lib/adapters/spotify/token-store";
import { getServerEnv } from "@/lib/config/env";
import { jsonResponse, setupRequired, validationError } from "@/lib/api/http";
import {
getFoundationJob,
getJobSourceUrl,
setFoundationJob,
setJobSummary
} from "@/lib/api/jobs-store";

export async function GET(
request: Request,
Expand All @@ -16,10 +30,113 @@ export async function GET(
return jsonResponse({ error: "invalid_oauth_callback" }, 400);
}

return setupRequired(
`OAuth callback for ${destinationPlatform} is scaffolded. State validation and token exchange must be backed by the Supabase oauth_states table before real provider use.`
);
if (destinationPlatform === "spotify") {
const cookieState = request.headers
.get("cookie")
?.split(";")
.map((value) => value.trim())
.find((value) => value.startsWith("spotify_oauth_state="))
?.split("=")[1];
if (!cookieState || cookieState !== state) {
return jsonResponse({ error: "invalid_oauth_state" }, 400);
}

const consumed = await consumeSpotifyOAuthState(state);
const env = getServerEnv();
if (!env.SPOTIFY_REDIRECT_URI) {
return jsonResponse({ error: "spotify_setup_required" }, 501);
}

const token = await new SpotifyClient().exchangeAuthorizationCode({
code,
codeVerifier: consumed.codeVerifier,
redirectUri: env.SPOTIFY_REDIRECT_URI
});
const anonymousSessionId = crypto.randomUUID();
const encryptor = createSpotifyTokenEncryptor();
await spotifyTokenStore.save({
anonymousSessionId,
platform: "spotify",
encryptedRefreshToken: token.refresh_token
? await encryptor.encrypt(token.refresh_token)
: null,
accessToken: token.access_token,
scopes: token.scope ? token.scope.split(" ") : [],
expiresAt: new Date(Date.now() + token.expires_in * 1000)
});

const jobId = consumed.redirectPath.match(/^\/convert\/([^/]+)$/)?.[1];
if (jobId) {
const job = getFoundationJob(jobId);
const sourceUrl = getJobSourceUrl(jobId);
if (
job &&
sourceUrl &&
job.sourcePlatform === "spotify" &&
job.destinationPlatform === "spotify"
) {
const started = {
...job,
status: "matching" as const,
currentStage: "match_tracks" as const,
updatedAt: new Date().toISOString()
};
setFoundationJob(started);
const result = await convertSpotifyToSpotify({
playlistUrl: sourceUrl,
auth: {
accessToken: token.access_token,
scopes: token.scope ? token.scope.split(" ") : [],
expiresAt: new Date(Date.now() + token.expires_in * 1000)
}
});
const completed = {
...started,
destinationPlaylistId:
result.destinationPlaylistUrl.split("/").pop() ?? null,
status:
result.failedTracks > 0 || result.unmatchedTracks > 0
? ("partially_completed" as const)
: ("completed" as const),
currentStage: "done" as const,
importedTrackCount: result.importedTracks,
matchedTrackCount: result.matchedTracks,
unmatchedTrackCount: result.unmatchedTracks,
addedTrackCount: result.addedTracks,
failedTrackCount: result.failedTracks,
updatedAt: new Date().toISOString(),
completedAt: new Date().toISOString()
};
setFoundationJob(completed);
setJobSummary(jobId, {
destinationPlaylistUrl: result.destinationPlaylistUrl,
importedTracks: result.importedTracks,
unavailableTracks: result.unavailableTracks,
matchedTracks: result.matchedTracks,
unmatchedTracks: result.unmatchedTracks,
addedTracks: result.addedTracks,
failedTracks: result.failedTracks
});
}
}

const response = Response.redirect(new URL(consumed.redirectPath, request.url));
response.headers.append(
"Set-Cookie",
"spotify_oauth_state=; HttpOnly; SameSite=Lax; Max-Age=0; Path=/"
);
response.headers.append(
"Set-Cookie",
`spotify_session=${anonymousSessionId}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${60 * 60}${process.env.NODE_ENV === "production" ? "; Secure" : ""}`
);
return response;
}

return setupRequired(`OAuth callback for ${destinationPlatform} is not implemented yet.`);
} catch (error) {
if (error instanceof Error && error.message.includes("configured")) {
return jsonResponse({ error: "spotify_setup_required", message: error.message }, 501);
}
return validationError(error);
}
}
Loading
Loading