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
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ SPOTIFY_REDIRECT_URI=http://localhost:3000/api/oauth/spotify/callback
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GOOGLE_REDIRECT_URI=http://localhost:3000/api/oauth/youtube/callback
YOUTUBE_API_KEY=

APPLE_MUSIC_TEAM_ID=
APPLE_MUSIC_KEY_ID=
Expand Down
42 changes: 41 additions & 1 deletion API_SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,47 @@ Spotify is implemented as both a source and destination adapter. Public playlist

## YouTube

Configure `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, and `GOOGLE_REDIRECT_URI`. The YouTube adapter must implement OAuth, catalogue search, playlist creation, and item insertion before real Spotify to YouTube conversion is enabled.
Configure:

```env
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GOOGLE_REDIRECT_URI=https://your-domain.example/api/oauth/youtube/callback
YOUTUBE_API_KEY=
```

Google Cloud setup:

1. Create or select a Google Cloud project.
2. Enable **YouTube Data API v3** for the project.
3. Configure the OAuth consent screen.
4. Add required test users while the app is in testing mode.
5. Create an OAuth 2.0 Client ID for a web application.
6. Add the exact redirect URI ending in `/api/oauth/youtube/callback`.
7. Copy the client ID, client secret, and redirect URI into the environment.
8. Optionally create an API key and restrict it to YouTube Data API v3 for unauthenticated search/detail calls where available.
9. Move the OAuth consent screen publishing status according to Google's verification requirements before public launch.

Scopes used:

- `https://www.googleapis.com/auth/youtube`: create destination playlists and insert videos into playlists on the user's YouTube account.

Operations using OAuth:

- Google authorization callback token exchange.
- YouTube playlist creation.
- YouTube playlist item insertion.
- Token refresh for a user-authorized export session.

Operations that may use an API key:

- YouTube video search.
- YouTube video detail/duration enrichment.

Quota notes:

- TuneBridge tracks internal estimated quota usage for `search.list`, `videos.list`, `playlists.insert`, and `playlistItems.insert`.
- Default YouTube API quotas are limited. Search and insert operations can exhaust project quota on large playlists, so the workflow reports quota estimates and structured quota errors.

## Apple Music and Deezer

Expand Down
6 changes: 5 additions & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,20 @@ TuneBridge is split into UI, route handlers, domain models, provider adapters, m

Real provider code must implement `MusicPlatformAdapter` from `src/lib/adapters/types.ts`. Conversion workflow code should depend on the interface, not provider SDKs.

Current real adapters are intentionally non-implemented. This prevents silent fallback to fake conversions.
Spotify and YouTube are implemented adapters. Spotify can act as a source and destination. YouTube is implemented as a destination platform using standard YouTube playlists and videos through the official YouTube Data API v3. Apple Music and Deezer remain intentionally non-implemented so the app cannot silently fall back to fake conversions.

## Matching

`src/lib/matching` is pure TypeScript and performs no API calls. It accepts normalized source tracks and destination candidates, then returns a `TrackMatch` with confidence, method, status, and reasons.

YouTube-specific search ranking happens before the pure matcher. It considers official audio/video indicators, topic channels, artist/channel alignment, duration, and penalties for covers, karaoke, reaction videos, slowed/reverb, nightcore, mismatched live versions, shorts, previews, and unavailable videos.

## API

Route handlers validate every external input with Zod. Current handlers are foundations that return explicit setup-required responses for real provider work.

Spotify-to-YouTube uses polling-compatible job state. `/api/jobs/[jobId]` returns job status, summary counts, quota estimates, and review queue data. `/api/jobs/[jobId]/review` records review decisions.

## Runtime

The app is compatible with Vercel server runtimes. Secrets are read only in server-side modules and are validated through `src/lib/config/env.ts`.
5 changes: 4 additions & 1 deletion DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ TuneBridge is designed for Vercel-compatible deployment.
1. Configure all required environment variables.
2. Apply Supabase migrations.
3. Keep `NEXT_PUBLIC_DEMO_MODE_ENABLED=false` in production.
4. Run:
4. Enable YouTube Data API v3 in Google Cloud and configure OAuth redirect URIs before enabling Spotify-to-YouTube export.
5. Run:

```bash
npm ci
Expand All @@ -16,3 +17,5 @@ npm run build
```

Playwright should run in CI with browser dependencies installed.

Production storage note: the current implementation keeps OAuth state, encrypted refresh tokens, job summaries, and review decisions in process memory. Deployments should wire these stores to Supabase before relying on serverless scaling or cold-start durability.
18 changes: 10 additions & 8 deletions PLATFORM_LIMITATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,20 @@

## Current Status

Spotify source import, Spotify catalogue search, Spotify OAuth/token refresh, Spotify playlist creation, and Spotify batched add-items are implemented.
Spotify source import, Spotify catalogue search, Spotify OAuth/token refresh, Spotify playlist creation, and Spotify batched add-items are implemented. Spotify-to-YouTube destination export is implemented through the official YouTube Data API v3.

## Spotify to YouTube

The initial target flow is represented in models, UI, routes, and adapter contracts. Real implementation still requires:
Implemented with real Spotify and Google/YouTube credentials.

- Spotify playlist import with pagination.
- YouTube OAuth scopes.
- YouTube catalogue search.
- Playlist creation.
- Batched track insertion.
- Tested failure handling.
Known limitations:

- YouTube Music-native APIs are not used because the official YouTube Data API exposes standard YouTube playlists and videos.
- Search quality depends on YouTube metadata. Low-confidence and unmatched items are separated for review.
- Official audio, official music video, artist topic channels, and artist/label channels are preferred; covers, karaoke, reaction videos, slowed/reverb, nightcore, mismatched live versions, shorts, and previews are penalized.
- Duration enrichment uses batched `videos.list` calls, but search and playlist item insertion still consume quota.
- Very large playlists can hit default Google quota limits before completion.
- Current route persistence uses in-memory foundation stores until Supabase job/token/review persistence is wired into the application layer.

## Spotify to Spotify

Expand Down
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ TuneBridge is a production-oriented foundation for an accountless playlist trans
- 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.
- Real YouTube destination adapter using the official YouTube Data API v3 for search, duration enrichment, playlist creation, and playlist item insertion.
- Google OAuth with PKCE, offline refresh-token handling, encrypted refresh-token storage abstraction, disconnect endpoint, quota estimates, and review data for low-confidence/unmatched tracks.

## Mock Functionality

Expand All @@ -25,11 +27,11 @@ TuneBridge is a production-oriented foundation for an accountless playlist trans

## Planned Functionality

- Real YouTube OAuth, catalogue search, playlist creation, and track insertion.
- Review UI actions wired to persist accept/reject/search-alternative/skip decisions into durable Supabase storage.
- Apple Music, Deezer, and future Tidal adapters.
- Supabase persistence for all API workflows.
- Universal link creation after real destination links exist.
- Token encryption implementation backed by `TOKEN_ENCRYPTION_KEY`.
- Supabase-backed token storage replacing the current in-memory foundation stores.

## External Setup Required

Expand All @@ -52,4 +54,4 @@ npm run build
npm run test:e2e
```

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.
With real Spotify and Google/YouTube credentials, Spotify-to-YouTube conversion is implemented through official APIs. YouTube Music is treated as standard YouTube playlists containing music videos or official audio uploads. Apple Music and Deezer conversion is not implemented yet.
53 changes: 53 additions & 0 deletions src/app/api/jobs/[jobId]/review/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { z } from "zod";
import {
addJobReviewDecision,
getFoundationJob,
getJobReviewDecisions,
getJobReviewMatches
} from "@/lib/api/jobs-store";
import { jsonResponse, readValidatedJson, validationError } from "@/lib/api/http";
import { jobIdInputSchema } from "@/lib/models";

const reviewDecisionSchema = z.object({
sourceTrackId: z.string().min(1),
action: z.enum(["accept", "reject", "skip", "search_alternative"]),
candidateId: z.string().min(1).optional()
});

export async function GET(
_request: Request,
context: { params: Promise<{ jobId: string }> }
) {
try {
const params = jobIdInputSchema.parse(await context.params);
if (!getFoundationJob(params.jobId)) {
return jsonResponse({ error: "not_found" }, 404);
}
return jsonResponse({
matches: getJobReviewMatches(params.jobId),
decisions: getJobReviewDecisions(params.jobId)
});
} catch (error) {
return validationError(error);
}
}

export async function POST(
request: Request,
context: { params: Promise<{ jobId: string }> }
) {
try {
const params = jobIdInputSchema.parse(await context.params);
if (!getFoundationJob(params.jobId)) {
return jsonResponse({ error: "not_found" }, 404);
}
const decision = await readValidatedJson(request, reviewDecisionSchema);
addJobReviewDecision(params.jobId, decision);
return jsonResponse({
recorded: true,
decisions: getJobReviewDecisions(params.jobId)
});
} catch (error) {
return validationError(error);
}
}
16 changes: 14 additions & 2 deletions src/app/api/jobs/[jobId]/route.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { getFoundationJob, getJobSummary } from "@/lib/api/jobs-store";
import {
getFoundationJob,
getJobReviewDecisions,
getJobReviewMatches,
getJobSummary
} from "@/lib/api/jobs-store";
import { jobIdInputSchema } from "@/lib/models";
import { jsonResponse, validationError } from "@/lib/api/http";

Expand All @@ -12,7 +17,14 @@ export async function GET(
if (!job) {
return jsonResponse({ error: "not_found" }, 404);
}
return jsonResponse({ job, summary: getJobSummary(params.jobId) });
return jsonResponse({
job,
summary: getJobSummary(params.jobId),
review: {
matches: getJobReviewMatches(params.jobId),
decisions: getJobReviewDecisions(params.jobId)
}
});
} catch (error) {
return validationError(error);
}
Expand Down
83 changes: 68 additions & 15 deletions src/app/api/jobs/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ import {
refreshSpotifyAccessToken,
spotifyTokenStore
} from "@/lib/adapters/spotify/token-store";
import {
refreshYouTubeAccessToken,
youtubeTokenStore
} from "@/lib/adapters/youtube/token-store";
import { createConversionJobInputSchema, platformLabels } from "@/lib/models";
import {
jsonResponse,
Expand All @@ -15,6 +19,7 @@ import {
setFoundationJob,
setJobSummary
} from "@/lib/api/jobs-store";
import { runSpotifyToYouTubeJob } from "@/lib/api/youtube-job";
import { detectMusicPlatformUrl } from "@/lib/security/url";

export async function POST(request: Request) {
Expand Down Expand Up @@ -101,26 +106,74 @@ export async function POST(request: Request) {
return jsonResponse({ job: completed, result }, 201);
}

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

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

const youtubeToken = await youtubeTokenStore.get(youtubeSessionId);
if (!youtubeToken) {
return jsonResponse(
{
job,
authorizationRequired: true,
authorizationStartUrl: `/api/oauth/youtube/start?redirectPath=/convert/${job.id}`,
message: "YouTube authorization has expired. Reauthorize Google/YouTube."
},
202
);
}

const validYouTubeToken =
youtubeToken.expiresAt.getTime() <= Date.now()
? await refreshYouTubeAccessToken({ anonymousSessionId: youtubeSessionId })
: youtubeToken;
const spotifySessionId = getCookieValue(request, "spotify_session");
const spotifyToken = spotifySessionId
? await spotifyTokenStore.get(spotifySessionId)
: null;
const validSpotifyToken =
spotifySessionId && spotifyToken && spotifyToken.expiresAt.getTime() <= Date.now()
? await refreshSpotifyAccessToken({ anonymousSessionId: spotifySessionId })
: spotifyToken;
const completed = await runSpotifyToYouTubeJob({
job,
playlistUrl: input.url,
youtubeAuth: {
accessToken: validYouTubeToken.accessToken,
scopes: validYouTubeToken.scopes,
expiresAt: validYouTubeToken.expiresAt
},
spotifyAuth: validSpotifyToken
? {
accessToken: validSpotifyToken.accessToken,
scopes: validSpotifyToken.scopes,
expiresAt: validSpotifyToken.expiresAt
}
: undefined
});
return jsonResponse({ job: completed }, 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.`
);
}

const job = createFoundationJob({
playlistUrl: input.url,
destinationPlatform: input.destinationPlatform
});

return jsonResponse(
{
job,
authorizationRequired: true,
message:
"Job foundation created. Real Spotify import and YouTube playlist creation require provider credentials and OAuth implementation."
},
202
);
} catch (error) {
if (error instanceof Error && error.message === "Request body is too large.") {
return jsonResponse({ error: "request_too_large" }, 413);
Expand Down
Loading
Loading