diff --git a/lib/adapters/tidal.ts b/lib/adapters/tidal.ts new file mode 100644 --- /dev/null +++ b/lib/adapters/tidal.ts @@ -0,0 +1,339 @@ +import { buildTidalAuthUrl, exchangeTidalCode, refreshTidalToken } from "@/lib/auth/tidal-auth"; +import { type MusicPlatformAdapter } from "@/lib/adapters/types"; +import { fetchWithRetry } from "@/lib/adapters/http-retry"; +import { + indexIncluded, + parseIsoDurationMs, + resolveRelationshipNames, + type JsonApiDocument, + type JsonApiResourceObject, +} from "@/lib/adapters/jsonapi"; +import { computeCanonicalAlbumId, computeCanonicalArtistId, computeCanonicalTrackId } from "@/lib/matching/identity"; +import type { + AccountProfile, + CanonicalAlbum, + CanonicalArtist, + CanonicalPlaylist, + CanonicalTrack, + TokenSet, + TrackSearchQuery, +} from "@/lib/types/model"; + +const API_BASE = "https://openapi.tidal.com/v2"; +const ADD_BATCH_SIZE = 50; // JSON:API relationship-add payloads cap at 50 items + +interface TrackAttributes { + title: string; + isrc?: string; + duration?: string; // ISO 8601, e.g. "PT2M58S" +} + +interface ArtistAttributes { + name: string; +} + +interface AlbumAttributes { + title: string; +} + +interface PlaylistAttributes { + name: string; + description?: string; +} + +interface ResourceIdentifiersDocument { + data: { id: string; type: string }[] | { id: string; type: string }; + links?: { self?: string; next?: string }; +} + +interface UserAttributes { + username: string; + email?: string; + firstName?: string; + lastName?: string; +} + +async function tidalFetch(path: string, tokens: TokenSet, init?: RequestInit): Promise { + const res = await fetchWithRetry(() => + fetch(`${API_BASE}${path}`, { + ...init, + headers: { + ...init?.headers, + Authorization: `Bearer ${tokens.accessToken}`, + }, + }) + ); + + if (res.status === 429) { + const retryAfter = Number(res.headers.get("Retry-After") ?? "1"); + throw new TidalRateLimitError(retryAfter * 1000); + } + + const text = await res.text(); + if (!res.ok) { + const method = init?.method ?? "GET"; + throw new Error(`Tidal API error ${res.status} on ${method} ${path}: ${text}`); + } + + if (!text) return undefined as T; + return JSON.parse(text) as T; +} + +export class TidalRateLimitError extends Error { + constructor(public retryAfterMs: number) { + super(`Tidal rate limited, retry after ${retryAfterMs}ms`); + } +} + +function toCanonicalTrack( + resource: JsonApiResourceObject, + included: Map +): CanonicalTrack { + const artists = resolveRelationshipNames(resource.relationships?.artists?.data, included, "name"); + const albums = resolveRelationshipNames(resource.relationships?.albums?.data, included, "title"); + const durationMs = parseIsoDurationMs(resource.attributes?.duration); + const isrc = resource.attributes?.isrc; + const title = resource.attributes?.title ?? ""; + + return { + canonicalId: computeCanonicalTrackId({ isrc, artists, title, durationMs }), + isrc, + title, + artists, + albumTitle: albums[0], + durationMs, + platformIds: { tidal: resource.id }, + }; +} + +function toCanonicalArtist(resource: JsonApiResourceObject): CanonicalArtist { + const name = resource.attributes?.name ?? ""; + return { canonicalId: computeCanonicalArtistId(name), name, platformIds: { tidal: resource.id } }; +} + +function toCanonicalAlbum( + resource: JsonApiResourceObject, + included: Map +): CanonicalAlbum { + const artists = resolveRelationshipNames(resource.relationships?.artists?.data, included, "name"); + const title = resource.attributes?.title ?? ""; + return { + canonicalId: computeCanonicalAlbumId({ title, artists }), + title, + artists, + platformIds: { tidal: resource.id }, + }; +} + +function extractIdentifiers(data: ResourceIdentifiersDocument["data"], type?: string): string[] { + const identifiers = Array.isArray(data) ? data : [data]; + return identifiers.filter((identifier) => !type || identifier.type === type).map((identifier) => identifier.id); +} + +/** Batch-resolves track IDs to full CanonicalTrack data (title/isrc/duration/artist+album names). */ +async function resolveTracksByIds(tokens: TokenSet, ids: string[]): Promise { + if (ids.length === 0) return []; + const doc = await tidalFetch>( + `/tracks?filter[id]=${ids.map(encodeURIComponent).join(",")}&include=artists,albums`, + tokens + ); + const included = indexIncluded(doc.included); + const resources = Array.isArray(doc.data) ? doc.data : [doc.data]; + return resources.map((resource) => toCanonicalTrack(resource, included)); +} + +async function resolveArtistsByIds(tokens: TokenSet, ids: string[]): Promise { + if (ids.length === 0) return []; + const doc = await tidalFetch>( + `/artists?filter[id]=${ids.map(encodeURIComponent).join(",")}`, + tokens + ); + const resources = Array.isArray(doc.data) ? doc.data : [doc.data]; + return resources.map(toCanonicalArtist); +} + +async function resolveAlbumsByIds(tokens: TokenSet, ids: string[]): Promise { + if (ids.length === 0) return []; + const doc = await tidalFetch>( + `/albums?filter[id]=${ids.map(encodeURIComponent).join(",")}&include=artists`, + tokens + ); + const included = indexIncluded(doc.included); + const resources = Array.isArray(doc.data) ? doc.data : [doc.data]; + return resources.map((resource) => toCanonicalAlbum(resource, included)); +} + +/** Paginates a `relationships/items`-shaped endpoint, resolving each page of IDs via `resolve`. */ +async function* paginateRelationshipItems( + tokens: TokenSet, + startPath: string, + itemType: string, + resolve: (tokens: TokenSet, ids: string[]) => Promise +): AsyncGenerator { + let path: string | null = startPath; + while (path) { + const doc: ResourceIdentifiersDocument = await tidalFetch(path, tokens); + const ids = extractIdentifiers(doc.data, itemType); + yield await resolve(tokens, ids); + path = doc.links?.next ?? null; + } +} + +/** POSTs a JSON:API `{data: [{id, type}]}` relationship-add payload, batched. */ +async function addRelationshipItems(tokens: TokenSet, path: string, type: string, ids: string[]): Promise { + for (let i = 0; i < ids.length; i += ADD_BATCH_SIZE) { + const batch = ids.slice(i, i + ADD_BATCH_SIZE); + await tidalFetch(path, tokens, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ data: batch.map((id) => ({ id, type })) }), + }); + } +} + +async function searchResultIds(tokens: TokenSet, query: string, relationship: string, type: string): Promise { + const doc = await tidalFetch>( + `/searchResults?filter[query]=${encodeURIComponent(query)}&include=${relationship}`, + tokens + ); + const resource = doc.data as JsonApiResourceObject; + const data = resource.relationships?.[relationship]?.data; + return extractIdentifiers(data ?? [], type); +} + +export const tidalAdapter: MusicPlatformAdapter = { + platform: "tidal", + + getAuthUrl(redirectUri) { + return buildTidalAuthUrl(redirectUri); + }, + + async exchangeCode(code, codeVerifier, redirectUri) { + return exchangeTidalCode(code, codeVerifier, redirectUri); + }, + + async refreshToken(tokens) { + return refreshTidalToken(tokens); + }, + + async getAccountProfile(tokens): Promise { + const doc = await tidalFetch>("/users/me", tokens); + const resource = doc.data as JsonApiResourceObject; + const { username, email, firstName, lastName } = resource.attributes ?? { username: resource.id }; + const displayName = [firstName, lastName].filter(Boolean).join(" ") || username; + return { id: resource.id, displayName, email }; + }, + + fetchLikedSongs(tokens: TokenSet): AsyncGenerator { + return paginateRelationshipItems( + tokens, + "/userCollectionTracks/me/relationships/items?page[cursor]=", + "tracks", + resolveTracksByIds + ); + }, + + async *fetchPlaylists(tokens: TokenSet): AsyncGenerator { + let path: string | null = "/playlists?filter[owners.id]=me"; + while (path) { + const doc: JsonApiDocument = await tidalFetch>( + path, + tokens + ); + const resources = Array.isArray(doc.data) ? doc.data : [doc.data]; + yield resources.map((playlist: JsonApiResourceObject) => ({ + sourcePlaylistId: playlist.id, + name: playlist.attributes?.name ?? "", + description: playlist.attributes?.description, + trackRefs: [], + })); + path = doc.links?.next ?? null; + } + }, + + fetchPlaylistTracks(tokens: TokenSet, playlistId: string): AsyncGenerator { + return paginateRelationshipItems( + tokens, + `/playlists/${playlistId}/relationships/items`, + "tracks", + resolveTracksByIds + ); + }, + + fetchFollowedArtists(tokens: TokenSet): AsyncGenerator { + return paginateRelationshipItems( + tokens, + "/userCollectionArtists/me/relationships/items?page[cursor]=", + "artists", + resolveArtistsByIds + ); + }, + + fetchSavedAlbums(tokens: TokenSet): AsyncGenerator { + return paginateRelationshipItems( + tokens, + "/userCollectionAlbums/me/relationships/items?page[cursor]=", + "albums", + resolveAlbumsByIds + ); + }, + + async searchTrack(tokens, query: TrackSearchQuery): Promise { + const ids = await searchResultIds(tokens, `${query.artist} ${query.title}`, "tracks", "tracks"); + return resolveTracksByIds(tokens, ids); + }, + + async lookupTrackByIsrc(tokens, isrc): Promise { + const doc = await tidalFetch>( + `/tracks?filter[isrc]=${encodeURIComponent(isrc)}&include=artists,albums`, + tokens + ); + const resources = Array.isArray(doc.data) ? doc.data : [doc.data]; + if (resources.length === 0) return null; + const included = indexIncluded(doc.included); + return toCanonicalTrack(resources[0], included); + }, + + async searchArtist(tokens, name): Promise { + const ids = await searchResultIds(tokens, name, "artists", "artists"); + return resolveArtistsByIds(tokens, ids); + }, + + async searchAlbum(tokens, query): Promise { + const ids = await searchResultIds(tokens, `${query.artist} ${query.title}`, "albums", "albums"); + return resolveAlbumsByIds(tokens, ids); + }, + + async addLikedSongs(tokens, platformTrackIds): Promise { + await addRelationshipItems(tokens, "/userCollectionTracks/me/relationships/items", "tracks", platformTrackIds); + }, + + async createPlaylist(tokens, name, description): Promise<{ id: string }> { + const doc = await tidalFetch>("/playlists", tokens, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ data: { type: "playlists", attributes: { name, description } } }), + }); + const resource = doc.data as JsonApiResourceObject; + return { id: resource.id }; + }, + + async addTracksToPlaylist(tokens, playlistId, platformTrackIds): Promise { + await addRelationshipItems(tokens, `/playlists/${playlistId}/relationships/items`, "tracks", platformTrackIds); + }, + + async followArtist(tokens, platformArtistId): Promise { + // Tidal's literal /artists/{id}/relationships/following endpoint requires the + // INTERNAL-only w_usr scope. Adding to the user's artist collection (collection.write, + // confirmed working for THIRD_PARTY apps) is the closest available equivalent. + await addRelationshipItems(tokens, "/userCollectionArtists/me/relationships/items", "artists", [platformArtistId]); + }, + + async saveAlbum(tokens, platformAlbumId): Promise { + await addRelationshipItems(tokens, "/userCollectionAlbums/me/relationships/items", "albums", [platformAlbumId]); + }, + + getRetryDelayMs(error: unknown): number | null { + return error instanceof TidalRateLimitError ? error.retryAfterMs : null; + }, +}; diff --git a/lib/auth/tidal-auth.ts b/lib/auth/tidal-auth.ts new file mode 100644 --- /dev/null +++ b/lib/auth/tidal-auth.ts @@ -0,0 +1,118 @@ +import { generateCodeChallenge, generateCodeVerifier, generateState } from "@/lib/auth/pkce"; +import type { TokenSet } from "@/lib/types/model"; + +const AUTHORIZE_URL = "https://login.tidal.com/authorize"; +const TOKEN_URL = "https://auth.tidal.com/v1/oauth2/token"; + +// Deliberately omits r_usr/w_usr — confirmed via live testing that a THIRD_PARTY +// app does NOT need them; only the resource-specific scopes below (each of which +// must also be individually approved in the app's developer.tidal.com dashboard). +export const TIDAL_SCOPES = [ + "collection.read", + "collection.write", + "playlists.read", + "playlists.write", + "user.read", + "search.read", +].join(" "); + +function clientId(): string { + const id = process.env.TIDAL_CLIENT_ID; + if (!id) throw new Error("TIDAL_CLIENT_ID is not set"); + return id; +} + +export function buildTidalAuthUrl( + redirectUri: string, + scope: string = TIDAL_SCOPES +): { + url: string; + codeVerifier: string; + state: string; + scope: string; +} { + const codeVerifier = generateCodeVerifier(); + const codeChallenge = generateCodeChallenge(codeVerifier); + const state = generateState(); + + const params = new URLSearchParams({ + response_type: "code", + client_id: clientId(), + scope, + redirect_uri: redirectUri, + state, + code_challenge_method: "S256", + code_challenge: codeChallenge, + }); + + return { url: `${AUTHORIZE_URL}?${params.toString()}`, codeVerifier, state, scope }; +} + +export interface TidalTokenResponse { + access_token: string; + refresh_token?: string; + expires_in: number; + scope: string; + token_type: string; +} + +function toTokenSet(data: TidalTokenResponse, fallbackRefreshToken?: string): TokenSet { + return { + accessToken: data.access_token, + refreshToken: data.refresh_token ?? fallbackRefreshToken, + expiresAt: Date.now() + data.expires_in * 1000, + scope: data.scope, + }; +} + +export async function exchangeTidalCode( + code: string, + codeVerifier: string, + redirectUri: string +): Promise { + const body = new URLSearchParams({ + grant_type: "authorization_code", + code, + redirect_uri: redirectUri, + client_id: clientId(), + code_verifier: codeVerifier, + }); + + const res = await fetch(TOKEN_URL, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body, + }); + + const text = await res.text(); + if (!res.ok) { + throw new Error(`Tidal token exchange failed: ${res.status} ${text}`); + } + + return toTokenSet(JSON.parse(text) as TidalTokenResponse); +} + +export async function refreshTidalToken(tokens: TokenSet): Promise { + if (!tokens.refreshToken) { + throw new Error("No refresh token available for Tidal account"); + } + + const body = new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: tokens.refreshToken, + client_id: clientId(), + }); + + const res = await fetch(TOKEN_URL, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body, + }); + + const text = await res.text(); + if (!res.ok) { + throw new Error(`Tidal token refresh failed: ${res.status} ${text}`); + } + + return toTokenSet(JSON.parse(text) as TidalTokenResponse, tokens.refreshToken); +} diff --git a/app/api/auth/tidal/callback/route.ts b/app/api/auth/tidal/callback/route.ts new file mode 100644 --- /dev/null +++ b/app/api/auth/tidal/callback/route.ts @@ -0,0 +1,30 @@ +import { NextRequest, NextResponse } from "next/server"; +import { consumePendingAuth } from "@/lib/auth/pkce"; +import { tidalAdapter } from "@/lib/adapters/tidal"; +import { upsertAccount } from "@/lib/db/repositories/accounts"; + +export async function GET(request: NextRequest) { + const url = new URL(request.url); + const code = url.searchParams.get("code"); + const state = url.searchParams.get("state"); + const error = url.searchParams.get("error"); + + if (error) { + return NextResponse.redirect(new URL(`/accounts?error=${encodeURIComponent(error)}`, request.url)); + } + + if (!code || !state) { + return NextResponse.redirect(new URL("/accounts?error=missing_code_or_state", request.url)); + } + + const pending = consumePendingAuth(state); + if (!pending || pending.platform !== "tidal") { + return NextResponse.redirect(new URL("/accounts?error=invalid_state", request.url)); + } + + const tokens = await tidalAdapter.exchangeCode(code, pending.codeVerifier, pending.redirectUri); + const profile = await tidalAdapter.getAccountProfile(tokens); + upsertAccount("tidal", profile, tokens); + + return NextResponse.redirect(new URL("/accounts?connected=tidal", request.url)); +} diff --git a/app/api/auth/tidal/start/route.ts b/app/api/auth/tidal/start/route.ts new file mode 100644 --- /dev/null +++ b/app/api/auth/tidal/start/route.ts @@ -0,0 +1,15 @@ +import { NextRequest, NextResponse } from "next/server"; +import { buildTidalAuthUrl } from "@/lib/auth/tidal-auth"; +import { storePendingAuth } from "@/lib/auth/pkce"; + +export async function GET(request: NextRequest) { + const callbackUrl = new URL("/api/auth/tidal/callback", request.url); + callbackUrl.hostname = "127.0.0.1"; + const redirectUri = callbackUrl.toString(); + + const scopeOverride = request.nextUrl.searchParams.get("scopes"); + const { url, codeVerifier, state, scope } = buildTidalAuthUrl(redirectUri, scopeOverride ?? undefined); + storePendingAuth(state, { platform: "tidal", codeVerifier, redirectUri, requestedScope: scope }); + + return NextResponse.redirect(url); +}