Restart repository history from target event

This commit is contained in:
2026-07-18 16:54:26 +01:00
commit e08aa0a667
71 changed files with 15963 additions and 0 deletions
+286
View File
@@ -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 });
}
}