Skip to content

Commit 1d967d4

Browse files
UI redesign, BYOCID setup guide, logo, 404 page
- Added Spotifort logo and favicon across all pages - Zine-style borders with offset shadows - Complete BYOCID setup guide with GIF placeholders - Safari/iOS crypto compatibility fix - In-app browser detection and user messaging - Improved artist list styling and link behavior - Custom 404 page - Single-column results layout - Footer attribution to commitconfirm.com Genre expansion [+] pending Spotify rate limit reset.
1 parent a7e2392 commit 1d967d4

17 files changed

Lines changed: 676 additions & 78 deletions

public/_redirects

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,3 @@
11
/callback/* /index.html 200
2+
3+
/* /404.html 404
718 KB
Loading

public/setup/step1-login.gif

707 KB
Loading

public/setup/step2-create-app.gif

520 KB
Loading
646 KB
Loading

public/setup/step4-select-api.gif

915 KB
Loading
1.39 MB
Loading

public/setup/step6-authorize.gif

241 KB
Loading

public/spotifort_transparent.png

716 KB
Loading

scripts/add-genres.js

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
#!/usr/bin/env node
2+
3+
/**
4+
* Add genres to existing lineup.json using GET /artists/{id}
5+
*
6+
* This script uses the individual artist endpoint instead of Search API,
7+
* which may have separate rate limits. It can resume from where it left off.
8+
*
9+
* Usage:
10+
* node scripts/add-genres.js
11+
*/
12+
13+
import { createInterface } from 'readline';
14+
import { readFileSync, writeFileSync } from 'fs';
15+
import { dirname, join } from 'path';
16+
import { fileURLToPath } from 'url';
17+
18+
const __dirname = dirname(fileURLToPath(import.meta.url));
19+
const LINEUP_PATH = join(__dirname, '..', 'public', 'lineup.json');
20+
21+
const API_BASE = 'https://api.spotify.com/v1';
22+
const REQUEST_DELAY = 500; // ms between API calls - be gentle
23+
24+
/**
25+
* Prompt user for input
26+
*/
27+
function prompt(question) {
28+
const rl = createInterface({
29+
input: process.stdin,
30+
output: process.stdout,
31+
});
32+
33+
return new Promise((resolve) => {
34+
rl.question(question, (answer) => {
35+
rl.close();
36+
resolve(answer.trim());
37+
});
38+
});
39+
}
40+
41+
/**
42+
* Sleep for a given number of milliseconds
43+
*/
44+
function sleep(ms) {
45+
return new Promise(resolve => setTimeout(resolve, ms));
46+
}
47+
48+
/**
49+
* Fetch artist by ID - returns genres or null
50+
* STOPS IMMEDIATELY on rate limit
51+
*/
52+
async function fetchArtistGenres(accessToken, artistId) {
53+
const url = `${API_BASE}/artists/${artistId}`;
54+
55+
const response = await fetch(url, {
56+
headers: {
57+
Authorization: `Bearer ${accessToken}`,
58+
},
59+
});
60+
61+
if (response.status === 429) {
62+
const retryAfter = response.headers.get('Retry-After') || 'unknown';
63+
console.log(`\n\n*** RATE LIMITED ***`);
64+
console.log(`Retry-After header: ${retryAfter} seconds`);
65+
console.log(`Stopping immediately to preserve progress.`);
66+
return { rateLimited: true };
67+
}
68+
69+
if (response.status === 403) {
70+
console.log(`\n WARNING: 403 Forbidden for artist ${artistId} - endpoint may be blocked`);
71+
return { rateLimited: false, genres: [], blocked: true };
72+
}
73+
74+
if (!response.ok) {
75+
console.log(`\n ERROR: ${response.status} for artist ${artistId}`);
76+
return { rateLimited: false, genres: [] };
77+
}
78+
79+
const data = await response.json();
80+
return { rateLimited: false, genres: data.genres || [] };
81+
}
82+
83+
/**
84+
* Main function
85+
*/
86+
async function main() {
87+
console.log('');
88+
console.log('Add Genres to Lineup');
89+
console.log('====================');
90+
console.log('');
91+
console.log('This script adds genre data to existing artists in lineup.json');
92+
console.log('using GET /artists/{id} endpoint. Stops immediately on rate limit.');
93+
console.log('');
94+
console.log('To get an access token:');
95+
console.log(' 1. Run the Spotifort app (npm run dev)');
96+
console.log(' 2. Connect your Spotify account');
97+
console.log(' 3. Copy the token from browser console');
98+
console.log('');
99+
100+
const accessToken = await prompt('Paste your access token: ');
101+
102+
if (!accessToken) {
103+
console.error('Error: No access token provided');
104+
process.exit(1);
105+
}
106+
107+
// Load existing lineup
108+
console.log('');
109+
console.log('Loading lineup.json...');
110+
const lineup = JSON.parse(readFileSync(LINEUP_PATH, 'utf8'));
111+
const artists = lineup.artists;
112+
113+
// Find artists that need genres (have spotifyId but no genres)
114+
const needsGenres = artists.filter(a =>
115+
a.spotifyId && (!a.genres || a.genres.length === 0)
116+
);
117+
const alreadyHasGenres = artists.filter(a => a.genres && a.genres.length > 0);
118+
119+
console.log(` Total artists: ${artists.length}`);
120+
console.log(` Already have genres: ${alreadyHasGenres.length}`);
121+
console.log(` Need genres: ${needsGenres.length}`);
122+
console.log('');
123+
124+
if (needsGenres.length === 0) {
125+
console.log('All artists already have genres!');
126+
return;
127+
}
128+
129+
// Test with first artist
130+
console.log('Testing API access...');
131+
const testArtist = needsGenres[0];
132+
const testResult = await fetchArtistGenres(accessToken, testArtist.spotifyId);
133+
134+
if (testResult.rateLimited) {
135+
console.log('Cannot proceed - rate limited on first request.');
136+
process.exit(1);
137+
}
138+
139+
if (testResult.blocked) {
140+
console.log('ERROR: GET /artists/{id} endpoint appears to be blocked (403).');
141+
console.log('This endpoint should work for Dev Mode apps. Check your token.');
142+
process.exit(1);
143+
}
144+
145+
console.log(` Test passed: ${testArtist.name} has ${testResult.genres.length} genres`);
146+
if (testResult.genres.length > 0) {
147+
console.log(` Genres: ${testResult.genres.slice(0, 3).join(', ')}${testResult.genres.length > 3 ? '...' : ''}`);
148+
}
149+
console.log('');
150+
151+
// Process all artists needing genres
152+
console.log(`Processing ${needsGenres.length} artists...`);
153+
console.log('');
154+
155+
let processed = 0;
156+
let withGenres = 0;
157+
let rateLimited = false;
158+
159+
for (const artist of needsGenres) {
160+
processed++;
161+
process.stdout.write(`\r ${processed}/${needsGenres.length}: ${artist.name.padEnd(45).slice(0, 45)}`);
162+
163+
const result = await fetchArtistGenres(accessToken, artist.spotifyId);
164+
165+
if (result.rateLimited) {
166+
rateLimited = true;
167+
break;
168+
}
169+
170+
// Update artist in the original array
171+
artist.genres = result.genres;
172+
if (result.genres.length > 0) {
173+
withGenres++;
174+
}
175+
176+
await sleep(REQUEST_DELAY);
177+
}
178+
179+
console.log('');
180+
console.log('');
181+
182+
// Save progress regardless of rate limit
183+
lineup.lastUpdated = new Date().toISOString().split('T')[0];
184+
writeFileSync(LINEUP_PATH, JSON.stringify(lineup, null, 2) + '\n');
185+
186+
// Summary
187+
const totalWithGenres = artists.filter(a => a.genres && a.genres.length > 0).length;
188+
const allGenres = new Set(artists.flatMap(a => a.genres || []));
189+
190+
console.log('Summary');
191+
console.log('-------');
192+
console.log(` Processed this run: ${processed}`);
193+
console.log(` Found genres for: ${withGenres}`);
194+
console.log(` Total with genres: ${totalWithGenres}/${artists.length}`);
195+
console.log(` Unique genres: ${allGenres.size}`);
196+
console.log('');
197+
console.log(`Saved to: ${LINEUP_PATH}`);
198+
199+
if (rateLimited) {
200+
const remaining = needsGenres.length - processed;
201+
console.log('');
202+
console.log(`*** Rate limited after ${processed} artists. ${remaining} remaining. ***`);
203+
console.log('Run this script again later to continue.');
204+
} else {
205+
console.log('');
206+
console.log('Done!');
207+
}
208+
}
209+
210+
main();

0 commit comments

Comments
 (0)