Restart repository history from target event
This commit is contained in:
@@ -0,0 +1,286 @@
|
||||
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 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user