import { NextRequest, NextResponse } from "next/server"; // Global cache for the access token to avoid refetching on every request. // This persists in memory as long as the server (or lambda container) is alive. let cachedToken: string | null = null; let tokenExpiry: number = 0; async function getAccessToken(clientId: string, clientSecret: string): Promise { // Check if we have a valid cached token (with 5 minute buffer) if (cachedToken && Date.now() < tokenExpiry) { return cachedToken; } console.log("Refreshing TIDAL access token..."); try { const authString = Buffer.from(`${clientId}:${clientSecret}`).toString("base64"); const res = await fetch("https://auth.tidal.com/v1/oauth2/token", { method: "POST", headers: { "Authorization": `Basic ${authString}`, "Content-Type": "application/x-www-form-urlencoded", }, body: "grant_type=client_credentials", cache: 'no-store' // Ensure we don't get a stale cached response from Next.js }); if (!res.ok) { console.error("TIDAL Auth Failed:", await res.text()); return null; } const data = await res.json(); cachedToken = data.access_token; // Set expiry buffer (e.g., expires in 1 hour, we refresh after 55 mins) // expires_in is in seconds const expiresIn = data.expires_in || 3600; tokenExpiry = Date.now() + (expiresIn * 1000) - 300000; return cachedToken; } catch (error) { console.error("TIDAL Auth Exception:", error); return null; } } // Wrapper for TIDAL API requests that handles auth and 401 retries async function fetchTidal(url: string, clientId: string, clientSecret: string) { let token = await getAccessToken(clientId, clientSecret); if (!token) throw new Error("Could not obtain access token"); let res = await fetch(url, { headers: { "Authorization": `Bearer ${token}`, "Content-Type": "application/vnd.api+json", }, }); // If unauthorized, invalid token, or expired: clear cache and retry once if (res.status === 401) { console.warn("Got 401 from TIDAL, retrying with new token..."); cachedToken = null; tokenExpiry = 0; token = await getAccessToken(clientId, clientSecret); if (!token) throw new Error("Could not refresh access token"); res = await fetch(url, { headers: { "Authorization": `Bearer ${token}`, "Content-Type": "application/vnd.api+json", }, }); } return res; } export async function GET(request: NextRequest) { const searchParams = request.nextUrl.searchParams; const query = searchParams.get("query"); const countryCode = searchParams.get("countryCode") || "US"; if (!query) { return NextResponse.json({ error: "Query parameter is required" }, { status: 400 }); } const clientId = process.env.TIDAL_CLIENT_ID; const clientSecret = process.env.TIDAL_CLIENT_SECRET; if (!clientId || !clientSecret) { return NextResponse.json({ error: "TIDAL credentials not configured" }, { status: 500 }); } try { // 1. Search TIDAL const searchUrl = `https://openapi.tidal.com/v2/searchResults/${encodeURIComponent(query)}?countryCode=${countryCode}&explicitFilter=include%2C%20exclude&include=albums,albums.artists,albums.coverArt`; const searchRes = await fetchTidal(searchUrl, clientId, clientSecret); if (!searchRes.ok) { const errorText = await searchRes.text(); console.error("TIDAL Search Error:", errorText); return NextResponse.json({ error: "Failed to search TIDAL", details: errorText }, { status: searchRes.status }); } const searchData = await searchRes.json(); //console.log("TIDAL Search Data:", JSON.stringify(searchData, null, 2)); // Parse JSON:API response const albumsMap = new Map(); const artistIds = new Set(); const artworkIds = new Set(); // Process Albums from 'included' to get IDs if (searchData.included) { searchData.included.forEach((item: Record) => { if (item.type === "albums") { const attr = item.attributes; // Duration parsing let duration = 0; if (attr.duration) { try { const match = attr.duration.match(/PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?/); if (match) { duration = parseInt(match[1] || "0") * 3600 + parseInt(match[2] || "0") * 60 + parseInt(match[3] || "0"); } } catch (e) { } } // Collect Artist IDs if (item.relationships?.artists?.data) { const artistsData = Array.isArray(item.relationships.artists.data) ? item.relationships.artists.data : [item.relationships.artists.data]; artistsData.forEach((a: { id: string }) => artistIds.add(a.id)); } // Collect CoverArt IDs let coverArtId = null; if (item.relationships?.coverArt?.data) { const coverData = item.relationships.coverArt.data; const firstCover = Array.isArray(coverData) ? coverData[0] : coverData; if (firstCover && firstCover.id) { artworkIds.add(firstCover.id); coverArtId = firstCover.id; } } albumsMap.set(item.id, { id: item.id, title: attr.title, artists: [], // To be filled duration: duration, url: attr.externalLinks?.[0]?.href || `https://tidal.com/browse/album/${item.id}`, releaseDate: attr.releaseDate, mediaTags: attr.mediaTags || [], imageCover: [], // To be filled _artistIds: item.relationships?.artists?.data?.map((a: { id: string }) => a.id) || [], _coverArtId: coverArtId }); } }); } // Helper to chunk array const chunkArray = (array: string[], size: number) => { const chunks = []; for (let i = 0; i < array.length; i += size) { chunks.push(array.slice(i, i + size)); } return chunks; }; // 2. Bulk Fetch Artists const artistsMap = new Map(); if (artistIds.size > 0) { const allArtistIds = Array.from(artistIds); const chunks = chunkArray(allArtistIds, 20); // API Limit: 20 await Promise.all(chunks.map(async (chunk) => { const ids = chunk.join(","); const artistsUrl = `https://openapi.tidal.com/v2/artists?countryCode=${countryCode}&filter[id]=${ids}`; try { const res = await fetchTidal(artistsUrl, clientId, clientSecret); if (res.ok) { const data = await res.json(); data.data?.forEach((item: Record) => { artistsMap.set(item.id, { name: item.attributes.name }); }); } else { console.error("Failed to fetch artists batch:", await res.text()); } } catch (e) { console.error("Error fetching artists batch", e); } })); } // 3. Bulk Fetch Artworks const artworksMap = new Map(); if (artworkIds.size > 0) { const allArtworkIds = Array.from(artworkIds); const chunks = chunkArray(allArtworkIds, 20); // API Limit: 20 (assumed same as artists) await Promise.all(chunks.map(async (chunk) => { const ids = chunk.join(","); const artworksUrl = `https://openapi.tidal.com/v2/artworks?countryCode=${countryCode}&filter[id]=${ids}`; try { const res = await fetchTidal(artworksUrl, clientId, clientSecret); if (res.ok) { const data = await res.json(); data.data?.forEach((item: Record) => { const images: { url: string, width: number, height: number }[] = []; // Structure seen in logs: item.attributes.files is an array of { href, meta: { width, height } } if (item.attributes.files && Array.isArray(item.attributes.files)) { item.attributes.files.forEach((file: Record) => { images.push({ url: file.href, width: file.meta?.width || 0, height: file.meta?.height || 0 }); }); } // Fallback structure (imageLinks) else if (item.attributes.imageLinks && Array.isArray(item.attributes.imageLinks)) { item.attributes.imageLinks.forEach((link: Record) => { images.push({ url: link.href, width: link.width || 0, height: link.height || 0 }); }); } // Priority 3: Fallback - Construct URLs manually if we have an ID and parsing failed if (images.length === 0) { const availableSizes = [80, 160, 320, 640, 1280]; availableSizes.forEach(size => { images.push({ url: `https://resources.tidal.com/images/${item.id.replace(/-/g, '/')}/${size}x${size}.jpg`, width: size, height: size }); }); } artworksMap.set(item.id, images); }); } } catch (e) { console.error("Error fetching artworks batch", e); } })); } // 4. Merge Data const albums = Array.from(albumsMap.values() as IterableIterator>).map(album => { // Fill Artists if (album._artistIds) { album.artists = album._artistIds.map((id: string) => artistsMap.get(id) || { name: "Unknown" }); } // Fill Artwork if (album._coverArtId) { const covers = artworksMap.get(album._coverArtId); if (covers) { album.imageCover = covers; } else { // Fallback to direct resource URL if we have the ID but fetch failed album.imageCover = [ { url: `https://resources.tidal.com/images/${album._coverArtId.replace(/-/g, '/')}/640x640.jpg`, width: 640, height: 640 }, { url: `https://resources.tidal.com/images/${album._coverArtId.replace(/-/g, '/')}/320x320.jpg`, width: 320, height: 320 } ]; } } // Cleanup internal keys delete album._artistIds; delete album._coverArtId; return album; }); return NextResponse.json({ albums }); } catch (error) { console.error("Search API Error:", error); return NextResponse.json({ error: "Internal server error" }, { status: 500 }); } }