287 lines
12 KiB
TypeScript
287 lines
12 KiB
TypeScript
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<string | null> {
|
|
// 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<string, unknown>();
|
|
const artistIds = new Set<string>();
|
|
const artworkIds = new Set<string>();
|
|
|
|
// Process Albums from 'included' to get IDs
|
|
if (searchData.included) {
|
|
searchData.included.forEach((item: Record<string, any>) => {
|
|
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<string, { name: string }>();
|
|
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<string, any>) => {
|
|
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<string, { url: string, width: number, height: number }[]>();
|
|
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<string, any>) => {
|
|
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<string, any>) => {
|
|
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<string, any>) => {
|
|
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<Record<string, any>>).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 });
|
|
}
|
|
}
|