list-of-lp/scripts/fetch-song-links.ts

248 lines
6.3 KiB
TypeScript
Raw Normal View History

#!/usr/bin/env bun
import { join } from "path";
import { $ } from "bun";
import { albums } from "../src/lib/list";
// Configuration
const API_BASE = "https://api.song.link/v1-alpha.1/links";
const RATE_LIMIT_DELAY = 6000; // 6 seconds (10 requests per minute)
const USER_COUNTRY = "DE"; // Germany
const DATA_DIR = join(import.meta.dir, "../data");
const CACHE_FILE = join(DATA_DIR, "fetched-songs.json");
const OUTPUT_TS_FILE = join(import.meta.dir, "../src/lib/songLinks.ts");
const OUTPUT_JSON_FILE = join(DATA_DIR, "song-links.json");
interface SongLinks {
spotify?: string;
youtube?: string;
youtubeMusic?: string;
appleMusic?: string;
}
interface SongLinksData {
[songId: string]: SongLinks;
}
interface CachedSongs {
[songId: string]: boolean;
}
// Delay function for rate limiting
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
// Load cached songs
async function loadCache(): Promise<CachedSongs> {
const file = Bun.file(CACHE_FILE);
const exists = await file.exists();
if (!exists) {
console.log("No cache file found, starting fresh...");
return {};
}
try {
return await file.json();
} catch {
console.log("Invalid cache file, starting fresh...");
return {};
}
}
// Load existing song links
async function loadExistingSongLinks(): Promise<SongLinksData> {
const file = Bun.file(OUTPUT_JSON_FILE);
const exists = await file.exists();
if (!exists) {
console.log("No existing song links found, starting fresh...");
return {};
}
try {
return await file.json();
} catch {
console.log("Invalid song links file, starting fresh...");
return {};
}
}
// Ensure data directory exists
async function ensureDataDir(): Promise<void> {
await Bun.write(join(DATA_DIR, ".gitkeep"), "");
}
// Save cache
async function saveCache(cache: CachedSongs): Promise<void> {
await ensureDataDir();
await Bun.write(CACHE_FILE, JSON.stringify(cache, null, 2));
}
// Save song links to JSON
async function saveSongLinksJSON(songLinks: SongLinksData): Promise<void> {
await ensureDataDir();
await Bun.write(OUTPUT_JSON_FILE, JSON.stringify(songLinks, null, 2));
}
// Save song links to TypeScript file
async function saveSongLinksTS(songLinks: SongLinksData): Promise<void> {
const tsContent = `// This file is auto-generated by scripts/fetch-song-links.ts
// Do not edit manually
// Last updated: ${new Date().toISOString()}
// Run: bun run fetch-links
export interface SongLinks {
spotify?: string;
youtube?: string;
youtubeMusic?: string;
appleMusic?: string;
}
export interface SongLinksData {
[songId: string]: SongLinks;
}
export function getSongLink(songId: string): SongLinks {
return songLinks[songId];
}
export const songLinks: SongLinksData = ${JSON.stringify(songLinks, null, 2)};
`;
await Bun.write(OUTPUT_TS_FILE, tsContent);
}
// Fetch song links from API
async function fetchSongLinks(spotifyUrl: string, songName: string): Promise<SongLinks | null> {
try {
// Encode the Spotify URL
const encodedUrl = encodeURIComponent(spotifyUrl);
// Use the url parameter with songIfSingle for better matching
const url = `${API_BASE}?url=${encodedUrl}&userCountry=${USER_COUNTRY}&songIfSingle=true`;
console.log(` Fetching: ${songName}`);
console.log(` Spotify URL: ${spotifyUrl}`);
const response = await fetch(url);
if (!response.ok) {
const errorText = await response.text();
console.error(` ❌ Failed to fetch (${response.status}): ${errorText}`);
return null;
}
const data = await response.json();
// Extract links from the response
const links: SongLinks = {
spotify: data.linksByPlatform?.spotify?.url || undefined,
youtube: data.linksByPlatform?.youtube?.url || undefined,
youtubeMusic: data.linksByPlatform?.youtubeMusic?.url || undefined,
appleMusic: data.linksByPlatform?.appleMusic?.url || undefined,
};
console.log(` ✓ Success!`);
return links;
} catch (error) {
console.error(` ❌ Error:`, error);
return null;
}
}
// Main function
async function main() {
console.log("🎵 Starting song link fetcher...\n");
// Load existing data
const cache = await loadCache();
const songLinks = await loadExistingSongLinks();
let requestCount = 0;
let successCount = 0;
let skipCount = 0;
let failCount = 0;
// Process all albums and tracks
for (const album of albums) {
console.log(`\n📀 Album: ${album.label}`);
for (const track of album.tracks) {
const songId = track.id;
const songName = track.label;
const spotifyUrl = track.__SPOTIFY_URL__;
// Skip if already fetched
if (cache[songId]) {
console.log(` ⏭️ Skipping: ${songName} (already fetched)`);
skipCount++;
continue;
}
// Skip if no Spotify URL
if (!spotifyUrl) {
console.log(` ⏭️ Skipping: ${songName} (no Spotify URL)`);
skipCount++;
continue;
}
// Add delay before request (except for first request)
if (requestCount > 0) {
console.log(` ⏳ Waiting ${RATE_LIMIT_DELAY / 1000}s (rate limit)...`);
await delay(RATE_LIMIT_DELAY);
}
// Fetch links
const links = await fetchSongLinks(spotifyUrl, songName);
if (links) {
songLinks[songId] = links;
cache[songId] = true;
successCount++;
// Save progress after each successful fetch
await saveSongLinksJSON(songLinks);
await saveCache(cache);
} else {
failCount++;
}
requestCount++;
}
}
// Save final TypeScript file
console.log("\n💾 Generating TypeScript file...");
await saveSongLinksTS(songLinks);
// Summary
console.log("\n" + "=".repeat(50));
console.log("📊 Summary:");
console.log(` Total requests: ${requestCount}`);
console.log(` Successful: ${successCount}`);
console.log(` Skipped: ${skipCount}`);
console.log(` Failed: ${failCount}`);
console.log("=".repeat(50));
// Format files with prettier
console.log("\n🎨 Formatting files with Prettier...");
try {
await $`bunx prettier --write ${OUTPUT_TS_FILE} ${OUTPUT_JSON_FILE} ${CACHE_FILE}`;
console.log(` ✓ Formatted all files`);
} catch (error) {
console.error(" ⚠️ Prettier formatting failed:", error);
}
console.log("\n✨ Done!");
console.log(`\n📁 Output files:`);
console.log(` - ${OUTPUT_TS_FILE}`);
console.log(` - ${OUTPUT_JSON_FILE}`);
console.log(` - ${CACHE_FILE}`);
}
// Run the script
main().catch((error) => {
console.error("❌ Fatal error:", error);
process.exit(1);
});