From d17c92d27eb8eb9d5c303a6ba217362ba1d2d7a2 Mon Sep 17 00:00:00 2001 From: Sarabanda97 Date: Mon, 27 Jul 2026 18:43:16 +0100 Subject: [PATCH] Implement YouTube destination integration --- .env.example | 1 + API_SETUP.md | 42 +- ARCHITECTURE.md | 6 +- DEPLOYMENT.md | 5 +- PLATFORM_LIMITATIONS.md | 18 +- README.md | 8 +- src/app/api/jobs/[jobId]/review/route.ts | 53 +++ src/app/api/jobs/[jobId]/route.ts | 16 +- src/app/api/jobs/route.ts | 83 +++- .../api/oauth/[platform]/callback/route.ts | 103 ++++- src/app/api/oauth/[platform]/start/route.ts | 32 +- src/app/api/youtube/disconnect/route.ts | 26 ++ src/app/convert/[jobId]/page.tsx | 59 ++- src/components/conversion/PlaylistUrlForm.tsx | 4 + src/lib/adapters/registry.ts | 5 + src/lib/adapters/youtube/adapter.ts | 168 +++++++ src/lib/adapters/youtube/client.ts | 241 ++++++++++ src/lib/adapters/youtube/conversion.ts | 110 +++++ src/lib/adapters/youtube/duration.ts | 19 + src/lib/adapters/youtube/errors.ts | 54 +++ src/lib/adapters/youtube/oauth.ts | 105 +++++ src/lib/adapters/youtube/quota.ts | 45 ++ src/lib/adapters/youtube/retry.ts | 53 +++ src/lib/adapters/youtube/search-scoring.ts | 169 +++++++ src/lib/adapters/youtube/token-store.ts | 84 ++++ src/lib/adapters/youtube/types.ts | 74 +++ src/lib/adapters/youtube/youtube.test.ts | 425 ++++++++++++++++++ src/lib/api/jobs-store.ts | 30 +- src/lib/api/youtube-job.ts | 76 ++++ src/lib/config/env.ts | 1 + src/lib/models/conversion-job.ts | 2 + 31 files changed, 2066 insertions(+), 51 deletions(-) create mode 100644 src/app/api/jobs/[jobId]/review/route.ts create mode 100644 src/app/api/youtube/disconnect/route.ts create mode 100644 src/lib/adapters/youtube/adapter.ts create mode 100644 src/lib/adapters/youtube/client.ts create mode 100644 src/lib/adapters/youtube/conversion.ts create mode 100644 src/lib/adapters/youtube/duration.ts create mode 100644 src/lib/adapters/youtube/errors.ts create mode 100644 src/lib/adapters/youtube/oauth.ts create mode 100644 src/lib/adapters/youtube/quota.ts create mode 100644 src/lib/adapters/youtube/retry.ts create mode 100644 src/lib/adapters/youtube/search-scoring.ts create mode 100644 src/lib/adapters/youtube/token-store.ts create mode 100644 src/lib/adapters/youtube/types.ts create mode 100644 src/lib/adapters/youtube/youtube.test.ts create mode 100644 src/lib/api/youtube-job.ts diff --git a/.env.example b/.env.example index 54c1bb7..9ed51a1 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/API_SETUP.md b/API_SETUP.md index cabe7df..82eefc4 100644 --- a/API_SETUP.md +++ b/API_SETUP.md @@ -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 diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 118f3fc..c584ca8 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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`. diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 6204c10..72a8520 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -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 @@ -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. diff --git a/PLATFORM_LIMITATIONS.md b/PLATFORM_LIMITATIONS.md index 2ae7e83..e61a2d0 100644 --- a/PLATFORM_LIMITATIONS.md +++ b/PLATFORM_LIMITATIONS.md @@ -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 diff --git a/README.md b/README.md index c6da663..992e06c 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 @@ -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. diff --git a/src/app/api/jobs/[jobId]/review/route.ts b/src/app/api/jobs/[jobId]/review/route.ts new file mode 100644 index 0000000..4a892cb --- /dev/null +++ b/src/app/api/jobs/[jobId]/review/route.ts @@ -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); + } +} diff --git a/src/app/api/jobs/[jobId]/route.ts b/src/app/api/jobs/[jobId]/route.ts index bfc4ac7..ae92d51 100644 --- a/src/app/api/jobs/[jobId]/route.ts +++ b/src/app/api/jobs/[jobId]/route.ts @@ -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"; @@ -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); } diff --git a/src/app/api/jobs/route.ts b/src/app/api/jobs/route.ts index b170363..c61b689 100644 --- a/src/app/api/jobs/route.ts +++ b/src/app/api/jobs/route.ts @@ -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, @@ -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) { @@ -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); diff --git a/src/app/api/oauth/[platform]/callback/route.ts b/src/app/api/oauth/[platform]/callback/route.ts index 73c8084..3edcf42 100644 --- a/src/app/api/oauth/[platform]/callback/route.ts +++ b/src/app/api/oauth/[platform]/callback/route.ts @@ -6,6 +6,12 @@ import { createSpotifyTokenEncryptor, spotifyTokenStore } from "@/lib/adapters/spotify/token-store"; +import { YouTubeClient } from "@/lib/adapters/youtube/client"; +import { consumeYouTubeOAuthState } from "@/lib/adapters/youtube/oauth"; +import { + createYouTubeTokenEncryptor, + youtubeTokenStore +} from "@/lib/adapters/youtube/token-store"; import { getServerEnv } from "@/lib/config/env"; import { jsonResponse, setupRequired, validationError } from "@/lib/api/http"; import { @@ -14,6 +20,7 @@ import { setFoundationJob, setJobSummary } from "@/lib/api/jobs-store"; +import { runSpotifyToYouTubeJob } from "@/lib/api/youtube-job"; export async function GET( request: Request, @@ -31,12 +38,7 @@ export async function GET( } if (destinationPlatform === "spotify") { - const cookieState = request.headers - .get("cookie") - ?.split(";") - .map((value) => value.trim()) - .find((value) => value.startsWith("spotify_oauth_state=")) - ?.split("=")[1]; + const cookieState = getCookieValue(request, "spotify_oauth_state"); if (!cookieState || cookieState !== state) { return jsonResponse({ error: "invalid_oauth_state" }, 400); } @@ -132,11 +134,98 @@ export async function GET( return response; } + if (destinationPlatform === "youtube") { + const cookieState = getCookieValue(request, "youtube_oauth_state"); + if (!cookieState || cookieState !== state) { + return jsonResponse({ error: "invalid_oauth_state" }, 400); + } + + const consumed = await consumeYouTubeOAuthState(state); + const env = getServerEnv(); + if (!env.GOOGLE_REDIRECT_URI) { + return jsonResponse({ error: "google_setup_required" }, 501); + } + + const token = await new YouTubeClient().exchangeAuthorizationCode({ + code, + codeVerifier: consumed.codeVerifier, + redirectUri: env.GOOGLE_REDIRECT_URI + }); + const anonymousSessionId = crypto.randomUUID(); + const encryptor = createYouTubeTokenEncryptor(); + await youtubeTokenStore.save({ + anonymousSessionId, + platform: "youtube", + 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); + const spotifySessionId = getCookieValue(request, "spotify_session"); + const spotifyToken = spotifySessionId + ? await spotifyTokenStore.get(spotifySessionId) + : null; + + if ( + job && + sourceUrl && + job.sourcePlatform === "spotify" && + job.destinationPlatform === "youtube" + ) { + await runSpotifyToYouTubeJob({ + job, + playlistUrl: sourceUrl, + youtubeAuth: { + accessToken: token.access_token, + scopes: token.scope ? token.scope.split(" ") : [], + expiresAt: new Date(Date.now() + token.expires_in * 1000) + }, + spotifyAuth: spotifyToken + ? { + accessToken: spotifyToken.accessToken, + scopes: spotifyToken.scopes, + expiresAt: spotifyToken.expiresAt + } + : undefined + }); + } + } + + const response = Response.redirect(new URL(consumed.redirectPath, request.url)); + response.headers.append( + "Set-Cookie", + "youtube_oauth_state=; HttpOnly; SameSite=Lax; Max-Age=0; Path=/" + ); + response.headers.append( + "Set-Cookie", + `youtube_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 jsonResponse({ error: "oauth_setup_required", message: error.message }, 501); } 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 + ); +} diff --git a/src/app/api/oauth/[platform]/start/route.ts b/src/app/api/oauth/[platform]/start/route.ts index e1fe140..23fc5d2 100644 --- a/src/app/api/oauth/[platform]/start/route.ts +++ b/src/app/api/oauth/[platform]/start/route.ts @@ -1,4 +1,5 @@ import { createSpotifyAuthorizationUrl } from "@/lib/adapters/spotify/oauth"; +import { createYouTubeAuthorizationUrl, youtubeScopes } from "@/lib/adapters/youtube/oauth"; import { jsonResponse, setupRequired, validationError } from "@/lib/api/http"; import { musicPlatformSchema } from "@/lib/models"; @@ -44,10 +45,39 @@ async function startSpotifyOAuth( return response; } + if (destinationPlatform === "youtube") { + const url = new URL(request.url); + const redirectPath = url.searchParams.get("redirectPath") ?? "/convert"; + const authorization = await createYouTubeAuthorizationUrl({ + redirectPath, + destinationPlatform: "youtube" + }); + if (request.method === "GET") { + const redirect = Response.redirect(authorization.authorizationUrl); + redirect.headers.append( + "Set-Cookie", + `youtube_oauth_state=${authorization.state}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${10 * 60}${process.env.NODE_ENV === "production" ? "; Secure" : ""}` + ); + return redirect; + } + const response = jsonResponse({ + authorizationUrl: authorization.authorizationUrl, + scopes: youtubeScopes + }); + response.cookies.set("youtube_oauth_state", authorization.state, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + maxAge: 10 * 60, + path: "/" + }); + return response; + } + return setupRequired(`OAuth start for ${destinationPlatform} is not implemented yet.`); } catch (error) { if (error instanceof Error && error.message.includes("not configured")) { - return jsonResponse({ error: "spotify_setup_required", message: error.message }, 501); + return jsonResponse({ error: "oauth_setup_required", message: error.message }, 501); } return validationError(error); } diff --git a/src/app/api/youtube/disconnect/route.ts b/src/app/api/youtube/disconnect/route.ts new file mode 100644 index 0000000..4051cfe --- /dev/null +++ b/src/app/api/youtube/disconnect/route.ts @@ -0,0 +1,26 @@ +import { disconnectYouTube } from "@/lib/adapters/youtube/token-store"; +import { jsonResponse } from "@/lib/api/http"; + +export async function POST(request: Request) { + const sessionId = + request.headers + .get("cookie") + ?.split(";") + .map((value) => value.trim()) + .find((value) => value.startsWith("youtube_session=")) + ?.split("=")[1] ?? null; + + if (sessionId) { + await disconnectYouTube({ anonymousSessionId: sessionId }); + } + + const response = jsonResponse({ disconnected: true }); + response.cookies.set("youtube_session", "", { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + maxAge: 0, + path: "/" + }); + return response; +} diff --git a/src/app/convert/[jobId]/page.tsx b/src/app/convert/[jobId]/page.tsx index fafc490..a94bb24 100644 --- a/src/app/convert/[jobId]/page.tsx +++ b/src/app/convert/[jobId]/page.tsx @@ -1,6 +1,10 @@ import { AdSlot } from "@/components/ads/AdSlot"; import { ConversionStatus } from "@/components/conversion/ConversionStatus"; -import { getFoundationJob, getJobSummary } from "@/lib/api/jobs-store"; +import { + getFoundationJob, + getJobReviewMatches, + getJobSummary +} from "@/lib/api/jobs-store"; import type { ConversionJob, ConversionJobStatus } from "@/lib/models"; export const metadata = { @@ -35,6 +39,7 @@ export default async function ConversionJobPage({ errorMessage: null }; const summary = getJobSummary(jobId); + const reviewMatches = getJobReviewMatches(jobId); return (
@@ -45,48 +50,80 @@ export default async function ConversionJobPage({ {summary?.destinationPlaylistUrl ? ( <>

- Spotify created one destination playlist and added matched tracks in - order. + TuneBridge created one destination playlist and added accepted matches + in source order.

- Open Spotify playlist + Open playlist ) : ( <>

- Authorize Spotify to create a destination playlist. Real provider - conversion requires Spotify credentials and user authorization. + Authorize the destination platform to create one complete playlist. + Spotify-to-YouTube uses Google OAuth and the official YouTube Data API.

- Authorize Spotify + Authorize {job.destinationPlatform === "youtube" ? "YouTube" : "Spotify"} )} + {summary?.quota ? ( +
+

YouTube quota estimate

+

+ Estimated units: {summary.quota.estimatedUnits}. Search calls: + {" "} + {summary.quota.searchList}. Duration/detail calls: {summary.quota.videosList}. + Playlist inserts: {summary.quota.playlistsInsert}. Item inserts: + {" "} + {summary.quota.playlistItemsInsert}. +

+
+ ) : null}

Low-confidence tracks

{summary - ? `${summary.unmatchedTracks} unmatched tracks require review.` + ? `${summary.lowConfidenceTracks ?? 0} suggested matches require review.` : "No real matches have run yet."}

-

Failed tracks

+

Unmatched and failed tracks

{summary - ? `${summary.failedTracks} track additions failed.` + ? `${summary.unmatchedTracks} unmatched, ${summary.failedTracks} failed additions.` : "No real track additions have run yet."}

+ {reviewMatches.length > 0 ? ( +
+

Review queue

+
+ {reviewMatches.slice(0, 10).map((match) => ( +
+

{match.sourceTrack.title}

+

+ {match.status.replaceAll("_", " ")}. Review API supports + accept, reject, search alternative, and skip decisions. +

+
+ ))} +
+
+ ) : null} diff --git a/src/components/conversion/PlaylistUrlForm.tsx b/src/components/conversion/PlaylistUrlForm.tsx index 3d33df7..12a27f5 100644 --- a/src/components/conversion/PlaylistUrlForm.tsx +++ b/src/components/conversion/PlaylistUrlForm.tsx @@ -45,6 +45,10 @@ export function PlaylistUrlForm() { window.location.href = authBody.authorizationUrl; return; } + if (body.job?.id) { + window.location.href = `/convert/${body.job.id}`; + return; + } setMessage(authBody.message ?? "Spotify authorization could not be started."); return; } diff --git a/src/lib/adapters/registry.ts b/src/lib/adapters/registry.ts index 5b3d693..35da16c 100644 --- a/src/lib/adapters/registry.ts +++ b/src/lib/adapters/registry.ts @@ -2,6 +2,7 @@ import type { MusicPlatform } from "@/lib/models"; import { DemoAdapter } from "./demo/demo-adapter"; import { NotImplementedAdapter } from "./not-implemented"; import { SpotifyAdapter } from "./spotify/adapter"; +import { YouTubeAdapter } from "./youtube/adapter"; import type { MusicPlatformAdapter } from "./types"; export function getAdapter( @@ -16,6 +17,10 @@ export function getAdapter( return new SpotifyAdapter(); } + if (platform === "youtube") { + return new YouTubeAdapter(); + } + return new NotImplementedAdapter(platform); } diff --git a/src/lib/adapters/youtube/adapter.ts b/src/lib/adapters/youtube/adapter.ts new file mode 100644 index 0000000..e67ad6b --- /dev/null +++ b/src/lib/adapters/youtube/adapter.ts @@ -0,0 +1,168 @@ +import type { + AddTracksResult, + CreatePlaylistInput, + CreatedPlaylist, + MusicPlatformAdapter, + PlatformAuthContext, + SearchContext +} from "../types"; +import { ProviderNotImplementedError } from "../types"; +import type { + NormalizedPlaylist, + NormalizedTrack, + PlatformTrackCandidate +} from "@/lib/models"; +import { YouTubeClient } from "./client"; +import { buildYouTubeQueries, scoreYouTubeCandidate } from "./search-scoring"; +import type { YouTubeInsertFailure } from "./types"; + +export interface YouTubeAdapterOptions { + client?: YouTubeClient; + clientFactory?: (accessToken?: string, correlationId?: string) => YouTubeClient; +} + +export class YouTubeAdapter implements MusicPlatformAdapter { + readonly platform = "youtube" as const; + private readonly client?: YouTubeClient; + private readonly clientFactory?: (accessToken?: string, correlationId?: string) => YouTubeClient; + private readonly insertedItems = new Set(); + readonly insertFailures: YouTubeInsertFailure[] = []; + + constructor(options: YouTubeAdapterOptions = {}) { + this.client = options.client; + this.clientFactory = options.clientFactory; + } + + detectUrl(url: string): boolean { + try { + const parsed = new URL(url); + return ( + ["www.youtube.com", "youtube.com", "music.youtube.com", "youtu.be"].includes( + parsed.hostname.toLowerCase() + ) && parsed.protocol === "https:" + ); + } catch { + return false; + } + } + + async importPlaylist(): Promise { + throw new ProviderNotImplementedError("youtube", "importPlaylist"); + } + + async searchTrack( + track: NormalizedTrack, + context: SearchContext + ): Promise { + const client = this.getClient(context.auth?.accessToken, context.correlationId); + const scored = new Map>(); + + for (const query of buildYouTubeQueries(track)) { + const search = await client.searchVideos({ query, maxResults: Math.min(context.limit, 10) }); + const searchItems = search.items.filter((item) => item.id.videoId); + const videos = await client.getVideos( + searchItems.map((item) => item.id.videoId).filter((id): id is string => Boolean(id)) + ); + const videosById = new Map(videos.items.map((video) => [video.id, video])); + + for (const item of searchItems) { + const score = scoreYouTubeCandidate({ + sourceTrack: track, + searchItem: item, + video: item.id.videoId ? videosById.get(item.id.videoId) : undefined + }); + if (!score || score.rejected) { + continue; + } + const existing = scored.get(score.videoId); + if (!existing || score.score > existing.score) { + scored.set(score.videoId, score); + } + } + + if (scored.size >= context.limit) { + break; + } + } + + return [...scored.values()] + .filter((score): score is NonNullable => Boolean(score)) + .sort((a, b) => b.score - a.score) + .map((score) => score.candidate) + .slice(0, context.limit); + } + + async createPlaylist( + input: CreatePlaylistInput, + auth: PlatformAuthContext + ): Promise { + const client = this.getClient(auth.accessToken); + const playlist = await client.createPlaylist({ + title: input.title, + description: attributionDescription(input.description), + privacyStatus: input.isPublic ? "public" : "private" + }); + return { + id: playlist.id, + url: this.getPlaylistUrl(playlist.id) + }; + } + + async addTracks( + playlistId: string, + trackIds: string[], + auth: PlatformAuthContext + ): Promise { + const client = this.getClient(auth.accessToken); + let added = 0; + const failedTrackIds: string[] = []; + + for (const [position, videoId] of trackIds.entries()) { + const idempotencyKey = `${playlistId}:${position}:${videoId}`; + if (this.insertedItems.has(idempotencyKey)) { + continue; + } + try { + await client.insertPlaylistItem({ playlistId, videoId, position }); + this.insertedItems.add(idempotencyKey); + added += 1; + } catch (error) { + const message = error instanceof Error ? error.message : "YouTube insert failed."; + failedTrackIds.push(videoId); + this.insertFailures.push({ + videoId, + reason: message, + retryable: error instanceof Error && message.toLowerCase().includes("transient") + }); + } + } + + return { + requested: trackIds.length, + added, + failedTrackIds + }; + } + + getPlaylistUrl(playlistId: string): string { + return `https://www.youtube.com/playlist?list=${encodeURIComponent(playlistId)}`; + } + + private getClient(accessToken?: string, correlationId?: string): YouTubeClient { + if (this.client) { + if (accessToken) { + this.client.setAccessToken(accessToken); + } + return this.client; + } + return this.clientFactory + ? this.clientFactory(accessToken, correlationId) + : new YouTubeClient({ accessToken, correlationId }); + } +} + +function attributionDescription(description?: string | null): string { + const attribution = "Created with TuneBridge."; + const base = description?.trim(); + return base ? `${base}\n\n${attribution}`.slice(0, 300) : attribution; +} diff --git a/src/lib/adapters/youtube/client.ts b/src/lib/adapters/youtube/client.ts new file mode 100644 index 0000000..99d2570 --- /dev/null +++ b/src/lib/adapters/youtube/client.ts @@ -0,0 +1,241 @@ +import { getServerEnv } from "@/lib/config/env"; +import { createCorrelationId } from "@/lib/security/logging"; +import { YouTubeApiError, youtubeErrorCodeForStatus } from "./errors"; +import { YouTubeQuotaManager } from "./quota"; +import { withYouTubeRetries, type YouTubeRetryOptions } from "./retry"; +import type { + GoogleTokenResponse, + YouTubePlaylistResponse, + YouTubeSearchResponse, + YouTubeVideosResponse +} from "./types"; + +export interface YouTubeClientOptions { + accessToken?: string; + apiKey?: string; + fetchImpl?: typeof fetch; + retry?: Partial; + quota?: YouTubeQuotaManager; + correlationId?: string; +} + +export class YouTubeClient { + private readonly fetchImpl: typeof fetch; + private accessToken?: string; + private readonly apiKey?: string; + private readonly retry?: Partial; + readonly quota: YouTubeQuotaManager; + readonly correlationId: string; + + constructor(options: YouTubeClientOptions = {}) { + this.accessToken = options.accessToken; + this.apiKey = options.apiKey ?? getServerEnv().YOUTUBE_API_KEY; + this.fetchImpl = options.fetchImpl ?? fetch; + this.retry = options.retry; + this.quota = options.quota ?? new YouTubeQuotaManager(); + this.correlationId = options.correlationId ?? createCorrelationId(); + } + + setAccessToken(accessToken: string): void { + this.accessToken = accessToken; + } + + async searchVideos(input: { + query: string; + maxResults?: number; + pageToken?: string; + }): Promise { + this.quota.record("searchList"); + const url = new URL("https://www.googleapis.com/youtube/v3/search"); + url.searchParams.set("part", "snippet"); + url.searchParams.set("type", "video"); + url.searchParams.set("q", input.query); + url.searchParams.set("maxResults", String(input.maxResults ?? 5)); + url.searchParams.set("safeSearch", "none"); + url.searchParams.set("videoEmbeddable", "any"); + if (input.pageToken) { + url.searchParams.set("pageToken", input.pageToken); + } + this.attachApiKey(url); + return this.requestAbsolute(url.toString(), { + authOptional: true + }); + } + + async getVideos(ids: string[]): Promise { + if (ids.length === 0) { + return { items: [] }; + } + this.quota.record("videosList"); + const url = new URL("https://www.googleapis.com/youtube/v3/videos"); + url.searchParams.set("part", "snippet,contentDetails,status"); + url.searchParams.set("id", ids.slice(0, 50).join(",")); + this.attachApiKey(url); + return this.requestAbsolute(url.toString(), { + authOptional: true + }); + } + + async createPlaylist(input: { + title: string; + description: string; + privacyStatus?: "private" | "public" | "unlisted"; + }): Promise { + this.quota.record("playlistsInsert"); + return this.request( + "/youtube/v3/playlists?part=snippet,status", + { + method: "POST", + body: JSON.stringify({ + snippet: { + title: input.title, + description: input.description + }, + status: { + privacyStatus: input.privacyStatus ?? "private" + } + }) + } + ); + } + + async insertPlaylistItem(input: { + playlistId: string; + videoId: string; + position?: number; + }): Promise<{ id: string }> { + this.quota.record("playlistItemsInsert"); + return this.request<{ id: string }>( + "/youtube/v3/playlistItems?part=snippet", + { + method: "POST", + body: JSON.stringify({ + snippet: { + playlistId: input.playlistId, + position: input.position, + resourceId: { + kind: "youtube#video", + videoId: input.videoId + } + } + }) + } + ); + } + + async exchangeAuthorizationCode(input: { + code: string; + codeVerifier: string; + redirectUri: string; + }): Promise { + const env = getServerEnv(); + if (!env.GOOGLE_CLIENT_ID || !env.GOOGLE_CLIENT_SECRET) { + throw new Error("Google OAuth credentials are not configured."); + } + return this.tokenRequest( + new URLSearchParams({ + grant_type: "authorization_code", + code: input.code, + code_verifier: input.codeVerifier, + redirect_uri: input.redirectUri, + client_id: env.GOOGLE_CLIENT_ID, + client_secret: env.GOOGLE_CLIENT_SECRET + }) + ); + } + + async refreshAccessToken(refreshToken: string): Promise { + const env = getServerEnv(); + if (!env.GOOGLE_CLIENT_ID || !env.GOOGLE_CLIENT_SECRET) { + throw new Error("Google OAuth credentials are not configured."); + } + return this.tokenRequest( + new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: env.GOOGLE_CLIENT_ID, + client_secret: env.GOOGLE_CLIENT_SECRET + }) + ); + } + + private async tokenRequest(body: URLSearchParams): Promise { + return withYouTubeRetries(async () => { + const response = await this.fetchImpl("https://oauth2.googleapis.com/token", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + "X-Correlation-Id": this.correlationId + }, + body + }); + if (!response.ok) { + throw await this.toError(response); + } + return (await response.json()) as GoogleTokenResponse; + }, this.retry); + } + + private request(path: string, init: RequestInit = {}): Promise { + return this.requestAbsolute(`https://www.googleapis.com${path}`, { + ...init, + authOptional: false + }); + } + + private async requestAbsolute( + url: string, + init: RequestInit & { authOptional?: boolean } = {} + ): Promise { + return withYouTubeRetries(async () => { + const headers = new Headers(init.headers); + headers.set("Content-Type", "application/json"); + headers.set("X-Correlation-Id", this.correlationId); + if (this.accessToken) { + headers.set("Authorization", `Bearer ${this.accessToken}`); + } else if (!init.authOptional) { + throw new YouTubeApiError( + "youtube_authentication_failed", + "Google OAuth access token is required." + ); + } + + const response = await this.fetchImpl(url, { + ...init, + headers + }); + if (!response.ok) { + throw await this.toError(response); + } + return (await response.json()) as T; + }, this.retry); + } + + private attachApiKey(url: URL): void { + if (this.apiKey && !this.accessToken) { + url.searchParams.set("key", this.apiKey); + } + } + + private async toError(response: Response): Promise { + const retryAfter = response.headers.get("retry-after"); + let reason: string | undefined; + let message = `YouTube API request failed with status ${response.status}.`; + try { + const body = (await response.json()) as { + error?: { message?: string; errors?: Array<{ reason?: string }> }; + }; + message = body.error?.message ?? message; + reason = body.error?.errors?.[0]?.reason; + } catch { + // Keep generic status message when Google returns an empty or non-JSON body. + } + return new YouTubeApiError( + youtubeErrorCodeForStatus(response.status, reason), + message, + response.status, + reason, + retryAfter ? Number.parseInt(retryAfter, 10) : undefined + ); + } +} diff --git a/src/lib/adapters/youtube/conversion.ts b/src/lib/adapters/youtube/conversion.ts new file mode 100644 index 0000000..2f5c4a5 --- /dev/null +++ b/src/lib/adapters/youtube/conversion.ts @@ -0,0 +1,110 @@ +import type { PlatformAuthContext } from "../types"; +import { SpotifyAdapter } from "../spotify/adapter"; +import { YouTubeAdapter } from "./adapter"; +import type { TrackMatch } from "@/lib/models"; +import { matchTrack } from "@/lib/matching"; +import type { YouTubeQuotaMetrics } from "./quota"; + +export interface SpotifyToYouTubeConversionResult { + destinationPlaylistUrl: string | null; + importedTracks: number; + unavailableTracks: number; + matchedTracks: number; + unmatchedTracks: number; + lowConfidenceTracks: number; + addedTracks: number; + failedTracks: number; + matches: TrackMatch[]; + lowConfidenceMatches: TrackMatch[]; + unmatchedMatches: TrackMatch[]; + quota: YouTubeQuotaMetrics; +} + +export async function convertSpotifyToYouTube(input: { + playlistUrl: string; + youtubeAuth: PlatformAuthContext; + spotifyAuth?: PlatformAuthContext; + market?: string; + spotifyAdapter?: SpotifyAdapter; + youtubeAdapter?: YouTubeAdapter; + acceptLowConfidence?: boolean; +}): Promise { + const spotify = input.spotifyAdapter ?? new SpotifyAdapter(); + const youtube = input.youtubeAdapter ?? new YouTubeAdapter(); + const playlist = await spotify.importPlaylist({ + playlistUrl: input.playlistUrl, + market: input.market, + auth: input.spotifyAuth + }); + const matches: TrackMatch[] = []; + + for (const track of playlist.tracks) { + const candidates = await youtube.searchTrack(track, { + market: input.market, + limit: 8, + correlationId: crypto.randomUUID(), + auth: input.youtubeAuth + }); + matches.push(matchTrack(track, candidates)); + } + + const accepted = matches.filter((candidate) => { + if (candidate.status === "exact" || candidate.status === "high_confidence") { + return true; + } + return input.acceptLowConfidence && candidate.status === "low_confidence"; + }); + + const created = + accepted.length > 0 + ? await youtube.createPlaylist( + { + title: playlist.title, + description: playlist.description, + isPublic: false + }, + input.youtubeAuth + ) + : null; + const addResult = + created && accepted.length > 0 + ? await youtube.addTracks( + created.id, + accepted + .map((candidate) => candidate.destinationTrackId) + .filter((id): id is string => Boolean(id)), + input.youtubeAuth + ) + : { requested: 0, added: 0, failedTrackIds: [] }; + const quota = youtubeClientQuota(youtube); + + return { + destinationPlaylistUrl: created?.url ?? null, + importedTracks: playlist.tracks.length, + unavailableTracks: Math.max(0, playlist.totalTracks - playlist.tracks.length), + matchedTracks: accepted.length, + unmatchedTracks: matches.filter((candidate) => candidate.status === "unmatched").length, + lowConfidenceTracks: matches.filter((candidate) => candidate.status === "low_confidence").length, + addedTracks: addResult.added, + failedTracks: addResult.failedTrackIds.length, + matches, + lowConfidenceMatches: matches.filter( + (candidate) => candidate.status === "low_confidence" + ), + unmatchedMatches: matches.filter((candidate) => candidate.status === "unmatched"), + quota + }; +} + +function youtubeClientQuota(adapter: YouTubeAdapter): YouTubeQuotaMetrics { + const client = (adapter as unknown as { client?: { quota?: { snapshot: () => YouTubeQuotaMetrics } } }).client; + return ( + client?.quota?.snapshot() ?? { + searchList: 0, + videosList: 0, + playlistsInsert: 0, + playlistItemsInsert: 0, + estimatedUnits: 0 + } + ); +} diff --git a/src/lib/adapters/youtube/duration.ts b/src/lib/adapters/youtube/duration.ts new file mode 100644 index 0000000..ee8aaf9 --- /dev/null +++ b/src/lib/adapters/youtube/duration.ts @@ -0,0 +1,19 @@ +export function parseYouTubeDuration(duration: string | undefined): number | null { + if (!duration) { + return null; + } + const match = duration.match( + /^P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/ + ); + if (!match) { + return null; + } + const [, days = "0", hours = "0", minutes = "0", seconds = "0"] = match; + return ( + (Number(days) * 24 * 60 * 60 + + Number(hours) * 60 * 60 + + Number(minutes) * 60 + + Number(seconds)) * + 1000 + ); +} diff --git a/src/lib/adapters/youtube/errors.ts b/src/lib/adapters/youtube/errors.ts new file mode 100644 index 0000000..fc80797 --- /dev/null +++ b/src/lib/adapters/youtube/errors.ts @@ -0,0 +1,54 @@ +export type YouTubeErrorCode = + | "youtube_authentication_failed" + | "youtube_authorization_failed" + | "youtube_quota_exhausted" + | "youtube_rate_limited" + | "youtube_transient_failure" + | "youtube_validation_failed" + | "youtube_unavailable_video" + | "youtube_api_failure"; + +export class YouTubeApiError extends Error { + constructor( + public readonly code: YouTubeErrorCode, + message: string, + public readonly status?: number, + public readonly reason?: string, + public readonly retryAfterSeconds?: number + ) { + super(message); + this.name = "YouTubeApiError"; + } + + get retryable(): boolean { + return ( + this.code === "youtube_rate_limited" || + this.code === "youtube_transient_failure" + ); + } +} + +export function youtubeErrorCodeForStatus( + status: number, + reason?: string +): YouTubeErrorCode { + if (status === 401) { + return "youtube_authentication_failed"; + } + if (status === 403 && reason?.toLowerCase().includes("quota")) { + return "youtube_quota_exhausted"; + } + if (status === 403) { + return "youtube_authorization_failed"; + } + if (status === 429) { + return "youtube_rate_limited"; + } + if (status >= 500) { + return "youtube_transient_failure"; + } + if (status >= 400 && status < 500) { + return "youtube_validation_failed"; + } + return "youtube_api_failure"; +} diff --git a/src/lib/adapters/youtube/oauth.ts b/src/lib/adapters/youtube/oauth.ts new file mode 100644 index 0000000..478de59 --- /dev/null +++ b/src/lib/adapters/youtube/oauth.ts @@ -0,0 +1,105 @@ +import crypto from "node:crypto"; +import { getServerEnv } from "@/lib/config/env"; +import type { MusicPlatform } from "@/lib/models"; +import { + assertSafeRedirectPath, + createCodeChallenge, + createCodeVerifier, + createOAuthNonce +} from "@/lib/security/oauth"; + +export const youtubeScopes = [ + "https://www.googleapis.com/auth/youtube" +] as const; + +export interface YouTubeOAuthStateRecord { + stateHash: string; + codeVerifier: string; + destinationPlatform: MusicPlatform; + redirectPath: string; + expiresAt: Date; + consumedAt?: Date; +} + +export interface YouTubeOAuthStateStore { + save(state: YouTubeOAuthStateRecord): Promise; + consume(stateHash: string, now?: Date): Promise; +} + +export class InMemoryYouTubeOAuthStateStore implements YouTubeOAuthStateStore { + private readonly states = new Map(); + + async save(state: YouTubeOAuthStateRecord): Promise { + this.states.set(state.stateHash, state); + } + + async consume(stateHash: string, now = new Date()): Promise { + const record = this.states.get(stateHash); + if (!record) { + throw new Error("Invalid OAuth state."); + } + if (record.consumedAt) { + throw new Error("OAuth state has already been used."); + } + if (record.expiresAt.getTime() <= now.getTime()) { + throw new Error("OAuth state has expired."); + } + record.consumedAt = now; + this.states.set(stateHash, record); + return record; + } +} + +export const youtubeOAuthStateStore = new InMemoryYouTubeOAuthStateStore(); + +export function hashYouTubeOAuthState(state: string): string { + return crypto.createHash("sha256").update(state).digest("base64url"); +} + +export async function createYouTubeAuthorizationUrl(input: { + redirectPath: string; + destinationPlatform?: MusicPlatform; + stateStore?: YouTubeOAuthStateStore; + now?: Date; +}): Promise<{ authorizationUrl: string; state: string; codeVerifier: string }> { + const env = getServerEnv(); + if (!env.GOOGLE_CLIENT_ID || !env.GOOGLE_REDIRECT_URI) { + throw new Error("Google OAuth is not configured."); + } + + assertSafeRedirectPath(input.redirectPath); + const state = createOAuthNonce(); + const codeVerifier = createCodeVerifier(); + const codeChallenge = createCodeChallenge(codeVerifier); + const now = input.now ?? new Date(); + + await (input.stateStore ?? youtubeOAuthStateStore).save({ + stateHash: hashYouTubeOAuthState(state), + codeVerifier, + destinationPlatform: input.destinationPlatform ?? "youtube", + redirectPath: input.redirectPath, + expiresAt: new Date(now.getTime() + 10 * 60 * 1000) + }); + + const url = new URL("https://accounts.google.com/o/oauth2/v2/auth"); + url.searchParams.set("client_id", env.GOOGLE_CLIENT_ID); + url.searchParams.set("redirect_uri", env.GOOGLE_REDIRECT_URI); + url.searchParams.set("response_type", "code"); + url.searchParams.set("scope", youtubeScopes.join(" ")); + url.searchParams.set("state", state); + url.searchParams.set("access_type", "offline"); + url.searchParams.set("include_granted_scopes", "true"); + url.searchParams.set("prompt", "consent"); + url.searchParams.set("code_challenge_method", "S256"); + url.searchParams.set("code_challenge", codeChallenge); + + return { authorizationUrl: url.toString(), state, codeVerifier }; +} + +export function consumeYouTubeOAuthState( + state: string, + stateStore: YouTubeOAuthStateStore = youtubeOAuthStateStore, + now?: Date +): Promise { + return stateStore.consume(hashYouTubeOAuthState(state), now); +} diff --git a/src/lib/adapters/youtube/quota.ts b/src/lib/adapters/youtube/quota.ts new file mode 100644 index 0000000..bf6049b --- /dev/null +++ b/src/lib/adapters/youtube/quota.ts @@ -0,0 +1,45 @@ +import { YouTubeApiError } from "./errors"; + +export const youtubeQuotaCosts = { + searchList: 1, + videosList: 1, + playlistsInsert: 50, + playlistItemsInsert: 50 +} as const; + +export interface YouTubeQuotaMetrics { + searchList: number; + videosList: number; + playlistsInsert: number; + playlistItemsInsert: number; + estimatedUnits: number; +} + +export class YouTubeQuotaManager { + private metrics: YouTubeQuotaMetrics = { + searchList: 0, + videosList: 0, + playlistsInsert: 0, + playlistItemsInsert: 0, + estimatedUnits: 0 + }; + + constructor(private readonly maxEstimatedUnits = 10_000) {} + + record(operation: keyof typeof youtubeQuotaCosts): void { + const cost = youtubeQuotaCosts[operation]; + if (this.metrics.estimatedUnits + cost > this.maxEstimatedUnits) { + throw new YouTubeApiError( + "youtube_quota_exhausted", + "YouTube quota budget would be exceeded before completing this operation." + ); + } + + this.metrics[operation] += 1; + this.metrics.estimatedUnits += cost; + } + + snapshot(): YouTubeQuotaMetrics { + return { ...this.metrics }; + } +} diff --git a/src/lib/adapters/youtube/retry.ts b/src/lib/adapters/youtube/retry.ts new file mode 100644 index 0000000..f412cd1 --- /dev/null +++ b/src/lib/adapters/youtube/retry.ts @@ -0,0 +1,53 @@ +import { YouTubeApiError } from "./errors"; + +export interface YouTubeRetryOptions { + maxAttempts: number; + baseDelayMs: number; + maxDelayMs: number; + jitterRatio: number; + sleep?: (ms: number) => Promise; + random?: () => number; +} + +export const defaultYouTubeRetryOptions: YouTubeRetryOptions = { + maxAttempts: 4, + baseDelayMs: 300, + maxDelayMs: 5_000, + jitterRatio: 0.2 +}; + +export async function withYouTubeRetries( + operation: () => Promise, + options: Partial = {} +): Promise { + const config = { ...defaultYouTubeRetryOptions, ...options }; + const sleep = config.sleep ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))); + const random = config.random ?? Math.random; + let attempt = 0; + + while (attempt < config.maxAttempts) { + attempt += 1; + try { + return await operation(); + } catch (error) { + const retryable = + error instanceof YouTubeApiError + ? error.retryable + : error instanceof TypeError; + if (!retryable || attempt >= config.maxAttempts) { + throw error; + } + const retryAfterMs = + error instanceof YouTubeApiError && error.retryAfterSeconds + ? error.retryAfterSeconds * 1000 + : null; + const exponentialMs = Math.min( + config.maxDelayMs, + config.baseDelayMs * 2 ** (attempt - 1) + ); + await sleep(retryAfterMs ?? exponentialMs + exponentialMs * config.jitterRatio * random()); + } + } + + throw new Error("YouTube retry loop exhausted unexpectedly."); +} diff --git a/src/lib/adapters/youtube/search-scoring.ts b/src/lib/adapters/youtube/search-scoring.ts new file mode 100644 index 0000000..b0695e5 --- /dev/null +++ b/src/lib/adapters/youtube/search-scoring.ts @@ -0,0 +1,169 @@ +import type { NormalizedTrack, PlatformTrackCandidate } from "@/lib/models"; +import { normalizeArtistName, normalizeText, normalizeTitle } from "@/lib/matching"; +import { parseYouTubeDuration } from "./duration"; +import type { YouTubeSearchItem, YouTubeVideoItem } from "./types"; + +export interface YouTubeCandidateScore { + candidate: PlatformTrackCandidate; + videoId: string; + score: number; + reasons: string[]; + rejected: boolean; +} + +const negativePatterns = [ + ["cover", 0.24], + ["karaoke", 0.3], + ["reaction", 0.35], + ["slowed", 0.22], + ["reverb", 0.22], + ["nightcore", 0.25], + ["remix", 0.18], + ["live", 0.2], + ["shorts", 0.3], + ["preview", 0.3], + ["instrumental", 0.18] +] as const; + +export function buildYouTubeQueries(track: NormalizedTrack): string[] { + const primaryArtist = track.artists[0] ?? track.normalizedArtists[0] ?? ""; + const album = track.album ? ` ${track.album}` : ""; + return [ + `${track.title} ${primaryArtist} official audio`, + `${track.title} ${primaryArtist} official video`, + `${track.normalizedTitle} ${track.normalizedArtists[0] ?? primaryArtist}`, + `${track.title} ${track.artists.join(" ")}${album}` + ].map((query) => query.trim()).filter(Boolean); +} + +export function scoreYouTubeCandidate(input: { + sourceTrack: NormalizedTrack; + searchItem: YouTubeSearchItem; + video?: YouTubeVideoItem; +}): YouTubeCandidateScore | null { + const videoId = input.searchItem.id.videoId ?? input.video?.id; + if (!videoId) { + return null; + } + + const videoTitle = input.video?.snippet.title ?? input.searchItem.snippet.title; + const channelTitle = + input.video?.snippet.channelTitle ?? input.searchItem.snippet.channelTitle; + const description = + input.video?.snippet.description ?? input.searchItem.snippet.description ?? ""; + const haystack = normalizeText(`${videoTitle} ${channelTitle} ${description}`); + const rawNormalizedVideoTitle = normalizeTitle(videoTitle); + const sourceTitle = input.sourceTrack.normalizedTitle; + const normalizedVideoTitle = + rawNormalizedVideoTitle.includes(sourceTitle) || + sourceTitle.includes(rawNormalizedVideoTitle) + ? sourceTitle + : rawNormalizedVideoTitle; + const primaryArtist = input.sourceTrack.normalizedArtists[0] ?? ""; + const sourceIsLive = normalizeText(input.sourceTrack.title).includes("live"); + const sourceIsInstrumental = normalizeText(input.sourceTrack.title).includes("instrumental"); + const reasons: string[] = []; + let score = 0.35; + + if (normalizedVideoTitle.includes(sourceTitle) || sourceTitle.includes(normalizedVideoTitle)) { + score += 0.22; + reasons.push("Video title contains normalized source title."); + } + + if (primaryArtist && haystack.includes(primaryArtist)) { + score += 0.18; + reasons.push("Primary artist appears in video title, channel, or description."); + } + + const allArtistHits = input.sourceTrack.normalizedArtists.filter((artist) => + haystack.includes(artist) + ).length; + if (allArtistHits > 1) { + score += Math.min(0.12, allArtistHits * 0.04); + reasons.push("Multiple source artists appear in YouTube metadata."); + } + + if (haystack.includes("official audio")) { + score += 0.18; + reasons.push("Official audio indicator."); + } else if (haystack.includes("official video") || haystack.includes("music video")) { + score += 0.14; + reasons.push("Official music video indicator."); + } + + const normalizedChannel = normalizeArtistName(channelTitle); + if (normalizedChannel.includes("topic")) { + score += 0.12; + reasons.push("Topic channel indication."); + } + + if (primaryArtist && normalizedChannel.includes(primaryArtist)) { + score += 0.08; + reasons.push("Artist or label channel alignment."); + } + + const durationMs = parseYouTubeDuration(input.video?.contentDetails?.duration); + if (durationMs !== null && input.sourceTrack.durationMs !== null) { + const delta = Math.abs(durationMs - input.sourceTrack.durationMs); + if (delta <= 3_000) { + score += 0.12; + reasons.push("Duration is within 3 seconds."); + } else if (delta > 20_000) { + score -= 0.2; + reasons.push("Duration differs materially from source track."); + } + } + + for (const [pattern, penalty] of negativePatterns) { + if (haystack.includes(pattern)) { + if (pattern === "live" && sourceIsLive) { + continue; + } + if (pattern === "instrumental" && sourceIsInstrumental) { + continue; + } + score -= penalty; + reasons.push(`${pattern} penalty.`); + } + } + + const rejected = + input.video?.status?.privacyStatus === "private" || + input.video?.status?.uploadStatus === "deleted" || + input.video?.snippet.liveBroadcastContent === "live"; + + if (rejected) { + reasons.push("Video is unavailable or live-only."); + } + + const matchedSourceArtists = input.sourceTrack.artists.filter((artist) => + haystack.includes(normalizeArtistName(artist)) + ); + const candidateArtists = + matchedSourceArtists.length > 0 + ? [...matchedSourceArtists, channelTitle] + : [channelTitle]; + + const candidate: PlatformTrackCandidate = { + platform: "youtube", + id: videoId, + url: `https://www.youtube.com/watch?v=${encodeURIComponent(videoId)}`, + title: videoTitle, + normalizedTitle: normalizedVideoTitle, + artists: candidateArtists, + normalizedArtists: candidateArtists.map(normalizeArtistName), + album: input.sourceTrack.album, + normalizedAlbum: input.sourceTrack.normalizedAlbum, + durationMs, + isrc: null, + releaseYear: input.sourceTrack.releaseYear + }; + + return { + candidate, + videoId, + score: Math.max(0, Math.min(1, score)), + reasons, + rejected + }; +} diff --git a/src/lib/adapters/youtube/token-store.ts b/src/lib/adapters/youtube/token-store.ts new file mode 100644 index 0000000..761b91a --- /dev/null +++ b/src/lib/adapters/youtube/token-store.ts @@ -0,0 +1,84 @@ +import { getServerEnv } from "@/lib/config/env"; +import { AesGcmTokenEncryptor, type TokenEncryptor } from "@/lib/security/crypto"; +import { YouTubeClient } from "./client"; + +export interface StoredYouTubeToken { + anonymousSessionId: string; + platform: "youtube"; + encryptedRefreshToken: string | null; + accessToken: string; + scopes: string[]; + expiresAt: Date; +} + +export interface YouTubeTokenStore { + save(token: StoredYouTubeToken): Promise; + get(anonymousSessionId: string): Promise; + disconnect(anonymousSessionId: string): Promise; +} + +export class InMemoryYouTubeTokenStore implements YouTubeTokenStore { + private readonly tokens = new Map(); + + async save(token: StoredYouTubeToken): Promise { + this.tokens.set(token.anonymousSessionId, token); + } + + async get(anonymousSessionId: string): Promise { + return this.tokens.get(anonymousSessionId) ?? null; + } + + async disconnect(anonymousSessionId: string): Promise { + this.tokens.delete(anonymousSessionId); + } +} + +export const youtubeTokenStore = new InMemoryYouTubeTokenStore(); + +export function createYouTubeTokenEncryptor( + secret = getServerEnv().TOKEN_ENCRYPTION_KEY +): TokenEncryptor { + if (!secret) { + throw new Error("TOKEN_ENCRYPTION_KEY is required for YouTube token storage."); + } + return new AesGcmTokenEncryptor(secret); +} + +export async function refreshYouTubeAccessToken(input: { + anonymousSessionId: string; + tokenStore?: YouTubeTokenStore; + encryptor?: TokenEncryptor; + client?: YouTubeClient; + now?: Date; +}): Promise { + const store = input.tokenStore ?? youtubeTokenStore; + const stored = await store.get(input.anonymousSessionId); + if (!stored?.encryptedRefreshToken) { + throw new Error("No Google refresh token is stored for this session."); + } + + const encryptor = input.encryptor ?? createYouTubeTokenEncryptor(); + const refreshToken = await encryptor.decrypt(stored.encryptedRefreshToken); + const refreshed = await (input.client ?? new YouTubeClient()).refreshAccessToken(refreshToken); + const encryptedRefreshToken = refreshed.refresh_token + ? await encryptor.encrypt(refreshed.refresh_token) + : stored.encryptedRefreshToken; + const now = input.now ?? new Date(); + const nextToken: StoredYouTubeToken = { + anonymousSessionId: input.anonymousSessionId, + platform: "youtube", + encryptedRefreshToken, + accessToken: refreshed.access_token, + scopes: refreshed.scope ? refreshed.scope.split(" ") : stored.scopes, + expiresAt: new Date(now.getTime() + refreshed.expires_in * 1000) + }; + await store.save(nextToken); + return nextToken; +} + +export async function disconnectYouTube(input: { + anonymousSessionId: string; + tokenStore?: YouTubeTokenStore; +}): Promise { + await (input.tokenStore ?? youtubeTokenStore).disconnect(input.anonymousSessionId); +} diff --git a/src/lib/adapters/youtube/types.ts b/src/lib/adapters/youtube/types.ts new file mode 100644 index 0000000..9073ef4 --- /dev/null +++ b/src/lib/adapters/youtube/types.ts @@ -0,0 +1,74 @@ +export interface YouTubeThumbnail { + url: string; +} + +export interface YouTubeSearchItem { + id: { + kind: string; + videoId?: string; + }; + snippet: { + title: string; + channelTitle: string; + channelId: string; + description?: string; + publishedAt?: string; + thumbnails?: Record; + }; +} + +export interface YouTubeSearchResponse { + nextPageToken?: string; + items: YouTubeSearchItem[]; +} + +export interface YouTubeVideoItem { + id: string; + snippet: { + title: string; + channelTitle: string; + channelId: string; + description?: string; + categoryId?: string; + liveBroadcastContent?: string; + }; + contentDetails?: { + duration?: string; + regionRestriction?: { + blocked?: string[]; + allowed?: string[]; + }; + }; + status?: { + uploadStatus?: string; + privacyStatus?: string; + embeddable?: boolean; + madeForKids?: boolean; + }; +} + +export interface YouTubeVideosResponse { + items: YouTubeVideoItem[]; +} + +export interface YouTubePlaylistResponse { + id: string; + snippet: { + title: string; + description?: string; + }; +} + +export interface GoogleTokenResponse { + access_token: string; + expires_in: number; + refresh_token?: string; + scope?: string; + token_type: "Bearer"; +} + +export interface YouTubeInsertFailure { + videoId: string; + reason: string; + retryable: boolean; +} diff --git a/src/lib/adapters/youtube/youtube.test.ts b/src/lib/adapters/youtube/youtube.test.ts new file mode 100644 index 0000000..72d4a50 --- /dev/null +++ b/src/lib/adapters/youtube/youtube.test.ts @@ -0,0 +1,425 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { PlatformAuthContext } from "../types"; +import type { SpotifyClient } from "../spotify/client"; +import { SpotifyAdapter } from "../spotify/adapter"; +import { AesGcmTokenEncryptor } from "@/lib/security/crypto"; +import type { NormalizedTrack } from "@/lib/models"; +import { normalizeArtistName, normalizeTitle } from "@/lib/matching"; +import { YouTubeAdapter } from "./adapter"; +import { YouTubeClient } from "./client"; +import { convertSpotifyToYouTube } from "./conversion"; +import { parseYouTubeDuration } from "./duration"; +import { + consumeYouTubeOAuthState, + createYouTubeAuthorizationUrl, + hashYouTubeOAuthState, + InMemoryYouTubeOAuthStateStore +} from "./oauth"; +import { YouTubeQuotaManager } from "./quota"; +import { scoreYouTubeCandidate } from "./search-scoring"; +import { + InMemoryYouTubeTokenStore, + refreshYouTubeAccessToken +} from "./token-store"; +import type { + GoogleTokenResponse, + YouTubePlaylistResponse, + YouTubeSearchResponse, + YouTubeVideoItem, + YouTubeVideosResponse +} from "./types"; + +const youtubeAuth: PlatformAuthContext = { + accessToken: "youtube-access", + scopes: ["https://www.googleapis.com/auth/youtube"] +}; + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +function sourceTrack(overrides: Partial = {}): NormalizedTrack { + return { + sourcePlatform: "spotify", + sourceId: "spotify-track-1", + sourceUri: "spotify:track:spotify-track-1", + sourceUrl: "https://open.spotify.com/track/spotify-track-1", + title: "Cafe Dreams", + normalizedTitle: normalizeTitle("Cafe Dreams"), + artists: ["The Bridges"], + normalizedArtists: ["The Bridges"].map(normalizeArtistName), + album: "Night Routes", + normalizedAlbum: normalizeTitle("Night Routes"), + durationMs: 214_000, + isrc: "USDEM260001", + explicit: false, + releaseDate: "2026-01-01", + releaseYear: 2026, + artworkUrl: null, + position: 0, + occurrenceIndex: 0, + isAvailable: true, + ...overrides + }; +} + +function video(overrides: Partial = {}): YouTubeVideoItem { + return { + id: "video-1", + snippet: { + title: "The Bridges - Cafe Dreams (Official Audio)", + channelTitle: "The Bridges - Topic", + channelId: "channel-1", + description: "Official audio for Cafe Dreams." + }, + contentDetails: { + duration: "PT3M34S" + }, + status: { + uploadStatus: "processed", + privacyStatus: "public", + embeddable: true + }, + ...overrides + }; +} + +class MockYouTubeClient { + quota = new YouTubeQuotaManager(); + insertCalls: string[] = []; + searchCalls: string[] = []; + failSecondInsert = false; + + setAccessToken(): void {} + + async searchVideos(input: { query: string }): Promise { + this.quota.record("searchList"); + this.searchCalls.push(input.query); + return { + items: [ + { + id: { kind: "youtube#video", videoId: "video-1" }, + snippet: { + title: "The Bridges - Cafe Dreams (Official Audio)", + channelTitle: "The Bridges - Topic", + channelId: "channel-1", + description: "Official audio." + } + } + ] + }; + } + + async getVideos(): Promise { + this.quota.record("videosList"); + return { items: [video()] }; + } + + async createPlaylist(): Promise { + this.quota.record("playlistsInsert"); + return { + id: "youtube-playlist", + snippet: { title: "Created playlist" } + }; + } + + async insertPlaylistItem(input: { + videoId: string; + }): Promise<{ id: string }> { + this.quota.record("playlistItemsInsert"); + this.insertCalls.push(input.videoId); + if (this.failSecondInsert && this.insertCalls.length === 2) { + throw new Error("unavailable video"); + } + return { id: `insert-${this.insertCalls.length}` }; + } +} + +class MockSpotifyClient { + setAccessToken(): void {} + + async getClientCredentialsToken(): Promise<{ + access_token: string; + token_type: "Bearer"; + expires_in: number; + }> { + return { access_token: "spotify-client", token_type: "Bearer", expires_in: 3600 }; + } + + async getPlaylist() { + return { + id: "37i9dQZF1DXcBWIGoYBM5M", + name: "Spotify Source", + description: "Source playlist", + owner: { id: "owner", display_name: "Owner" }, + external_urls: { + spotify: "https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M" + }, + images: [], + public: true, + items: { total: 2 } + }; + } + + playlistItemsUrl(): string { + return "spotify-page"; + } + + async getPlaylistItemsPage() { + return { + href: "spotify-page", + limit: 50, + next: null, + offset: 0, + previous: null, + total: 2, + items: [ + { + track: { + id: "spotify-track-1", + uri: "spotify:track:spotify-track-1", + name: "Cafe Dreams", + external_urls: { spotify: "https://open.spotify.com/track/spotify-track-1" }, + artists: [{ name: "The Bridges" }], + album: { name: "Night Routes", release_date: "2026-01-01", images: [] }, + duration_ms: 214_000, + external_ids: { isrc: "USDEM260001" }, + explicit: false, + is_local: false + } + }, + { + track: { + id: "spotify-track-1", + uri: "spotify:track:spotify-track-1", + name: "Cafe Dreams", + external_urls: { spotify: "https://open.spotify.com/track/spotify-track-1" }, + artists: [{ name: "The Bridges" }], + album: { name: "Night Routes", release_date: "2026-01-01", images: [] }, + duration_ms: 214_000, + external_ids: { isrc: "USDEM260001" }, + explicit: false, + is_local: false + } + } + ] + }; + } +} + +describe("YouTube scoring", () => { + it("parses ISO 8601 video durations", () => { + expect(parseYouTubeDuration("PT3M34S")).toBe(214_000); + }); + + it("prefers official audio and topic-channel candidates", () => { + const score = scoreYouTubeCandidate({ + sourceTrack: sourceTrack(), + searchItem: { + id: { kind: "youtube#video", videoId: "video-1" }, + snippet: { + title: "The Bridges - Cafe Dreams (Official Audio)", + channelTitle: "The Bridges - Topic", + channelId: "channel-1" + } + }, + video: video() + }); + expect(score?.score).toBeGreaterThan(0.85); + expect(score?.candidate.id).toBe("video-1"); + }); + + it("penalizes cover, karaoke, live mismatch, and slowed/reverb candidates", () => { + const clean = scoreYouTubeCandidate({ + sourceTrack: sourceTrack(), + searchItem: { + id: { kind: "youtube#video", videoId: "clean" }, + snippet: { + title: "The Bridges - Cafe Dreams (Official Audio)", + channelTitle: "The Bridges - Topic", + channelId: "channel-1" + } + }, + video: video({ id: "clean" }) + }); + const bad = scoreYouTubeCandidate({ + sourceTrack: sourceTrack(), + searchItem: { + id: { kind: "youtube#video", videoId: "bad" }, + snippet: { + title: "Cafe Dreams cover karaoke live slowed reverb", + channelTitle: "Unknown Lyrics", + channelId: "channel-2" + } + }, + video: video({ + id: "bad", + snippet: { + title: "Cafe Dreams cover karaoke live slowed reverb", + channelTitle: "Unknown Lyrics", + channelId: "channel-2" + } + }) + }); + expect(clean?.score).toBeGreaterThan(bad?.score ?? 1); + }); +}); + +describe("YouTube OAuth and tokens", () => { + it("validates forged, expired, reused, and redirect-manipulated state", async () => { + vi.stubEnv("GOOGLE_CLIENT_ID", "google-client"); + vi.stubEnv("GOOGLE_REDIRECT_URI", "http://localhost:3000/api/oauth/youtube/callback"); + const store = new InMemoryYouTubeOAuthStateStore(); + const now = new Date("2026-07-27T12:00:00.000Z"); + const created = await createYouTubeAuthorizationUrl({ + redirectPath: "/convert/job", + stateStore: store, + now + }); + + await expect(consumeYouTubeOAuthState("forged", store, now)).rejects.toThrow(/Invalid/); + await expect(consumeYouTubeOAuthState(created.state, store, now)).resolves.toBeTruthy(); + await expect(consumeYouTubeOAuthState(created.state, store, now)).rejects.toThrow(/used/); + await store.save({ + stateHash: hashYouTubeOAuthState("expired"), + codeVerifier: "verifier", + destinationPlatform: "youtube", + redirectPath: "/convert/job", + expiresAt: now + }); + await expect( + consumeYouTubeOAuthState("expired", store, new Date("2026-07-27T12:01:00.000Z")) + ).rejects.toThrow(/expired/); + await expect( + createYouTubeAuthorizationUrl({ + redirectPath: "https://evil.test", + stateStore: store + }) + ).rejects.toThrow(/Unsafe redirect/); + }); + + it("refreshes tokens without leaking refresh token plaintext", async () => { + const store = new InMemoryYouTubeTokenStore(); + const encryptor = new AesGcmTokenEncryptor("youtube-encryption-key"); + await store.save({ + anonymousSessionId: "session", + platform: "youtube", + encryptedRefreshToken: await encryptor.encrypt("refresh-token"), + accessToken: "old", + scopes: ["https://www.googleapis.com/auth/youtube"], + expiresAt: new Date("2026-07-27T12:00:00.000Z") + }); + const client = { + refreshAccessToken: vi.fn(async (): Promise => ({ + access_token: "new", + expires_in: 3600, + refresh_token: "new-refresh", + scope: "https://www.googleapis.com/auth/youtube", + token_type: "Bearer" + })) + } as unknown as YouTubeClient; + const refreshed = await refreshYouTubeAccessToken({ + anonymousSessionId: "session", + tokenStore: store, + encryptor, + client, + now: new Date("2026-07-27T12:00:00.000Z") + }); + expect(refreshed.accessToken).toBe("new"); + expect(refreshed.encryptedRefreshToken).not.toContain("new-refresh"); + }); +}); + +describe("YouTube adapter", () => { + it("returns structured search candidates and records quota", async () => { + const client = new MockYouTubeClient(); + const adapter = new YouTubeAdapter({ client: client as unknown as YouTubeClient }); + const candidates = await adapter.searchTrack(sourceTrack(), { + limit: 1, + correlationId: "correlation", + auth: youtubeAuth + }); + expect(candidates[0]).toMatchObject({ + platform: "youtube", + id: "video-1" + }); + expect(client.quota.snapshot().searchList).toBe(1); + expect(client.quota.snapshot().videosList).toBe(1); + }); + + it("creates playlists and inserts duplicate source matches in order", async () => { + const client = new MockYouTubeClient(); + const adapter = new YouTubeAdapter({ client: client as unknown as YouTubeClient }); + const created = await adapter.createPlaylist( + { title: "Source", description: "Moved", isPublic: false }, + youtubeAuth + ); + const result = await adapter.addTracks(created.id, ["video-1", "video-1"], youtubeAuth); + expect(created.url).toBe("https://www.youtube.com/playlist?list=youtube-playlist"); + expect(client.insertCalls).toEqual(["video-1", "video-1"]); + expect(result.added).toBe(2); + }); + + it("records partial insert failures", async () => { + const client = new MockYouTubeClient(); + client.failSecondInsert = true; + const adapter = new YouTubeAdapter({ client: client as unknown as YouTubeClient }); + const result = await adapter.addTracks("playlist", ["video-1", "video-2"], youtubeAuth); + expect(result.added).toBe(1); + expect(result.failedTrackIds).toEqual(["video-2"]); + }); +}); + +describe("YouTube quota", () => { + it("stops early when quota is exhausted", () => { + const quota = new YouTubeQuotaManager(1); + quota.record("searchList"); + expect(() => quota.record("playlistsInsert")).toThrow(/quota budget/); + }); + + it("classifies quota errors", async () => { + const fetchImpl = vi.fn(async () => + new Response( + JSON.stringify({ + error: { + message: "quota exceeded", + errors: [{ reason: "quotaExceeded" }] + } + }), + { status: 403 } + ) + ); + const client = new YouTubeClient({ + accessToken: "token", + fetchImpl: fetchImpl as unknown as typeof fetch, + retry: { maxAttempts: 1 } + }); + await expect( + client.createPlaylist({ title: "x", description: "x" }) + ).rejects.toMatchObject({ code: "youtube_quota_exhausted" }); + }); +}); + +describe("Spotify to YouTube conversion", () => { + it("creates one complete YouTube playlist for accepted mocked matches", async () => { + const youtubeClient = new MockYouTubeClient(); + const spotifyAdapter = new SpotifyAdapter({ + client: new MockSpotifyClient() as unknown as SpotifyClient + }); + const youtubeAdapter = new YouTubeAdapter({ + client: youtubeClient as unknown as YouTubeClient + }); + const result = await convertSpotifyToYouTube({ + playlistUrl: "https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M", + spotifyAdapter, + youtubeAdapter, + youtubeAuth + }); + expect(result.destinationPlaylistUrl).toBe( + "https://www.youtube.com/playlist?list=youtube-playlist" + ); + expect(result.importedTracks).toBe(2); + expect(result.addedTracks).toBe(2); + expect(youtubeClient.insertCalls).toEqual(["video-1", "video-1"]); + }); +}); diff --git a/src/lib/api/jobs-store.ts b/src/lib/api/jobs-store.ts index bd81686..0f1d48c 100644 --- a/src/lib/api/jobs-store.ts +++ b/src/lib/api/jobs-store.ts @@ -1,9 +1,12 @@ -import type { ConversionJob, MusicPlatform } from "@/lib/models"; +import type { YouTubeQuotaMetrics } from "@/lib/adapters/youtube/quota"; +import type { ConversionJob, MusicPlatform, TrackMatch } from "@/lib/models"; import { detectMusicPlatformUrl } from "@/lib/security/url"; const jobs = new Map(); const jobSummaries = new Map(); const jobSourceUrls = new Map(); +const jobReviewMatches = new Map(); +const jobReviewDecisions = new Map(); export interface ConversionJobSummary { playlistTitle?: string; @@ -15,6 +18,14 @@ export interface ConversionJobSummary { unmatchedTracks: number; addedTracks: number; failedTracks: number; + lowConfidenceTracks?: number; + quota?: YouTubeQuotaMetrics; +} + +export interface ReviewDecision { + sourceTrackId: string; + action: "accept" | "reject" | "skip" | "search_alternative"; + candidateId?: string; } export function createFoundationJob(input: { @@ -74,6 +85,23 @@ export function getJobSourceUrl(jobId: string): string | null { return jobSourceUrls.get(jobId) ?? null; } +export function setJobReviewMatches(jobId: string, matches: TrackMatch[]): void { + jobReviewMatches.set(jobId, matches); +} + +export function getJobReviewMatches(jobId: string): TrackMatch[] { + return jobReviewMatches.get(jobId) ?? []; +} + +export function addJobReviewDecision(jobId: string, decision: ReviewDecision): void { + const current = jobReviewDecisions.get(jobId) ?? []; + jobReviewDecisions.set(jobId, [...current, decision]); +} + +export function getJobReviewDecisions(jobId: string): ReviewDecision[] { + return jobReviewDecisions.get(jobId) ?? []; +} + export function cancelFoundationJob(id: string): ConversionJob | null { const current = jobs.get(id); if (!current) { diff --git a/src/lib/api/youtube-job.ts b/src/lib/api/youtube-job.ts new file mode 100644 index 0000000..db25f0f --- /dev/null +++ b/src/lib/api/youtube-job.ts @@ -0,0 +1,76 @@ +import { convertSpotifyToYouTube } from "@/lib/adapters/youtube/conversion"; +import type { PlatformAuthContext } from "@/lib/adapters/types"; +import type { ConversionJob } from "@/lib/models"; +import { + setFoundationJob, + setJobReviewMatches, + setJobSummary +} from "./jobs-store"; + +export async function runSpotifyToYouTubeJob(input: { + job: ConversionJob; + playlistUrl: string; + youtubeAuth: PlatformAuthContext; + spotifyAuth?: PlatformAuthContext; +}): Promise { + const importing = { + ...input.job, + status: "importing" as const, + currentStage: "import_playlist" as const, + updatedAt: new Date().toISOString() + }; + setFoundationJob(importing); + + const matching = { + ...importing, + status: "matching" as const, + currentStage: "search_youtube" as const, + updatedAt: new Date().toISOString() + }; + setFoundationJob(matching); + + const result = await convertSpotifyToYouTube({ + playlistUrl: input.playlistUrl, + youtubeAuth: input.youtubeAuth, + spotifyAuth: input.spotifyAuth + }); + + setJobReviewMatches(input.job.id, [ + ...result.lowConfidenceMatches, + ...result.unmatchedMatches + ]); + + const hasReviewItems = + result.lowConfidenceMatches.length > 0 || result.unmatchedMatches.length > 0; + const completed: ConversionJob = { + ...matching, + destinationPlaylistId: + result.destinationPlaylistUrl?.split("list=").pop() ?? null, + status: + result.failedTracks > 0 || result.unmatchedTracks > 0 || result.lowConfidenceTracks > 0 + ? "partially_completed" + : "completed", + currentStage: hasReviewItems ? "awaiting_review" : "done", + 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 ?? undefined, + importedTracks: result.importedTracks, + unavailableTracks: result.unavailableTracks, + matchedTracks: result.matchedTracks, + unmatchedTracks: result.unmatchedTracks, + lowConfidenceTracks: result.lowConfidenceTracks, + addedTracks: result.addedTracks, + failedTracks: result.failedTracks, + quota: result.quota + }); + + return completed; +} diff --git a/src/lib/config/env.ts b/src/lib/config/env.ts index 297a723..215fc83 100644 --- a/src/lib/config/env.ts +++ b/src/lib/config/env.ts @@ -28,6 +28,7 @@ export const serverEnvSchema = publicEnvSchema.extend({ GOOGLE_CLIENT_ID: z.string().optional(), GOOGLE_CLIENT_SECRET: z.string().optional(), GOOGLE_REDIRECT_URI: z.string().url().optional(), + YOUTUBE_API_KEY: z.string().optional(), APPLE_MUSIC_TEAM_ID: z.string().optional(), APPLE_MUSIC_KEY_ID: z.string().optional(), APPLE_MUSIC_PRIVATE_KEY: z.string().optional(), diff --git a/src/lib/models/conversion-job.ts b/src/lib/models/conversion-job.ts index 2f6494a..784eb3d 100644 --- a/src/lib/models/conversion-job.ts +++ b/src/lib/models/conversion-job.ts @@ -20,7 +20,9 @@ export const conversionStageSchema = z.enum([ "queued", "import_playlist", "authorize_destination", + "search_youtube", "match_tracks", + "awaiting_review", "create_playlist", "add_tracks", "summarize",