Urgently fix API spam issues

This commit is contained in:
binimum 2026-02-08 20:00:53 +00:00 committed by GitHub
parent 05043505f6
commit 133f484e4e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 96 additions and 188 deletions

View file

@ -3136,12 +3136,11 @@
<div class="info">
<span class="label">API Instances</span>
<span class="description"
>Manage and prioritize API instances. Automatically sorted by
speed.</span
>Manage and prioritize API instances.</span
>
</div>
<button id="refresh-speed-test-btn" class="btn-secondary">
Refresh Speed Test
Refresh Instance List
</button>
</div>
<ul id="api-instance-list"></ul>

View file

@ -48,7 +48,7 @@ export class LosslessAPI {
const maxTotalAttempts = instances.length * 2; // Allow some retries across instances
let lastError = null;
let instanceIndex = 0;
let instanceIndex = Math.floor(Math.random() * instances.length);
for (let attempt = 1; attempt <= maxTotalAttempts; attempt++) {
const baseUrl = instances[instanceIndex % instances.length];
@ -174,7 +174,7 @@ export class LosslessAPI {
return artist;
}
async enrichTracksWithAlbumDates(tracks) {
async enrichTracksWithAlbumDates(tracks, maxRequests = 20) {
if (!trackDateSettings.useAlbumYear()) return tracks;
const albumIdsToFetch = [];
@ -186,13 +186,26 @@ export class LosslessAPI {
if (albumIdsToFetch.length === 0) return tracks;
const albumDateMap = new Map();
const results = await Promise.allSettled(albumIdsToFetch.map((id) => this.getAlbum(id)));
// Limit the number of albums to fetch to prevent spamming
const limitedIds = albumIdsToFetch.slice(0, maxRequests);
if (albumIdsToFetch.length > maxRequests) {
console.warn(`[Enrich] Too many albums to fetch (${albumIdsToFetch.length}). limiting to ${maxRequests}.`);
}
for (let i = 0; i < results.length; i++) {
const result = results[i];
if (result.status === 'fulfilled' && result.value.album?.releaseDate) {
albumDateMap.set(albumIdsToFetch[i], result.value.album.releaseDate);
const albumDateMap = new Map();
// Chunk requests to avoid spamming
const chunkSize = 5;
for (let i = 0; i < limitedIds.length; i += chunkSize) {
const chunk = limitedIds.slice(i, i + chunkSize);
const results = await Promise.allSettled(chunk.map((id) => this.getAlbum(id)));
for (let j = 0; j < results.length; j++) {
const result = results[j];
const id = chunk[j];
if (result.status === 'fulfilled' && result.value.album?.releaseDate) {
albumDateMap.set(id, result.value.album.releaseDate);
}
}
}
@ -304,10 +317,11 @@ export class LosslessAPI {
const data = await response.json();
const normalized = this.normalizeSearchResponse(data, 'tracks');
const preparedTracks = normalized.items.map((t) => this.prepareTrack(t));
const enrichedTracks = await this.enrichTracksWithAlbumDates(preparedTracks);
// Skip enrichment for search to be fast and lightweight
// const enrichedTracks = await this.enrichTracksWithAlbumDates(preparedTracks);
const result = {
...normalized,
items: enrichedTracks,
items: preparedTracks,
};
await this.cache.set('search_tracks', query, result);
@ -616,7 +630,8 @@ export class LosslessAPI {
}
// Enrich tracks with album release dates
tracks = await this.enrichTracksWithAlbumDates(tracks);
// Removed to reduce API load. Playlists can be very large.
// tracks = await this.enrichTracksWithAlbumDates(tracks);
const result = { playlist, tracks };
@ -641,7 +656,8 @@ export class LosslessAPI {
let tracks = items.map((i) => this.prepareTrack(i.item || i));
// Enrich tracks with album release dates
tracks = await this.enrichTracksWithAlbumDates(tracks);
// Limited to reduce API load
tracks = await this.enrichTracksWithAlbumDates(tracks, 10);
const mix = {
id: mixData.id,
@ -657,8 +673,9 @@ export class LosslessAPI {
return result;
}
async getArtist(artistId) {
const cached = await this.cache.get('artist', artistId);
async getArtist(artistId, options = {}) {
const cacheKey = options.lightweight ? `artist_${artistId}_light` : `artist_${artistId}`;
const cached = await this.cache.get('artist', cacheKey);
if (cached) return cached;
const [primaryResponse, contentResponse] = await Promise.all([
@ -709,25 +726,27 @@ export class LosslessAPI {
entries.forEach((entry) => scan(entry));
// Attempt to find more albums/EPs via search since the direct feed might be limited
try {
const searchResults = await this.searchAlbums(artist.name);
if (searchResults && searchResults.items) {
const numericArtistId = Number(artistId);
if (!options.lightweight) {
// Attempt to find more albums/EPs via search since the direct feed might be limited
try {
const searchResults = await this.searchAlbums(artist.name);
if (searchResults && searchResults.items) {
const numericArtistId = Number(artistId);
for (const item of searchResults.items) {
const itemArtistId = item.artist?.id;
const matchesArtist =
itemArtistId === numericArtistId ||
(Array.isArray(item.artists) && item.artists.some((a) => a.id === numericArtistId));
for (const item of searchResults.items) {
const itemArtistId = item.artist?.id;
const matchesArtist =
itemArtistId === numericArtistId ||
(Array.isArray(item.artists) && item.artists.some((a) => a.id === numericArtistId));
if (matchesArtist && !albumMap.has(item.id)) {
albumMap.set(item.id, item);
if (matchesArtist && !albumMap.has(item.id)) {
albumMap.set(item.id, item);
}
}
}
} catch (e) {
console.warn('Failed to fetch additional albums via search:', e);
}
} catch (e) {
console.warn('Failed to fetch additional albums via search:', e);
}
const rawReleases = Array.from(albumMap.values());
@ -743,11 +762,11 @@ export class LosslessAPI {
.slice(0, 15);
// Enrich tracks with album release dates
const tracks = await this.enrichTracksWithAlbumDates(topTracks);
const tracks = options.lightweight ? topTracks : await this.enrichTracksWithAlbumDates(topTracks);
const result = { ...artist, albums, eps, tracks };
await this.cache.set('artist', artistId, result);
await this.cache.set('artist', cacheKey, result);
return result;
}
@ -851,23 +870,32 @@ export class LosslessAPI {
const artistsToProcess = artists.slice(0, Math.min(5, artists.length));
console.log(`Processing ${artistsToProcess.length} artists for recommendations`);
for (const artist of artistsToProcess) {
const artistPromises = artistsToProcess.map(async (artist) => {
try {
console.log(`Fetching tracks for artist: ${artist.name} (ID: ${artist.id})`);
const artistData = await this.getArtist(artist.id);
const artistData = await this.getArtist(artist.id, { lightweight: true });
if (artistData && artistData.tracks && artistData.tracks.length > 0) {
const newTracks = artistData.tracks.filter((track) => !seenTrackIds.has(track.id)).slice(0, 4);
console.log(`Found ${newTracks.length} new tracks from ${artist.name}`);
recommendedTracks.push(...newTracks);
seenTrackIds.add(...newTracks.map((t) => t.id));
return newTracks;
} else {
console.warn(`No tracks found for artist ${artist.name}`);
return [];
}
} catch (e) {
console.warn(`Failed to get tracks for artist ${artist.name}:`, e);
return [];
}
}
});
const results = await Promise.all(artistPromises);
results.forEach((tracks) => {
if (tracks.length > 0) {
recommendedTracks.push(...tracks);
seenTrackIds.add(...tracks.map((t) => t.id));
}
});
console.log(`Total recommended tracks found: ${recommendedTracks.length}`);

View file

@ -1054,7 +1054,7 @@ export function initializeSettings(scrobbler, player, api, ui) {
btn.disabled = true;
try {
await api.settings.refreshSpeedTests();
await api.settings.refreshInstances();
ui.renderApiSettings();
btn.textContent = 'Done!';
setTimeout(() => {

View file

@ -2,8 +2,6 @@
export const apiSettings = {
STORAGE_KEY: 'monochrome-api-instances-v6',
INSTANCES_URL: 'instances.json',
SPEED_TEST_CACHE_KEY: 'monochrome-instance-speeds',
SPEED_TEST_CACHE_DURATION: 1000 * 60 * 60,
defaultInstances: { api: [], streaming: [] },
instancesLoaded: false,
@ -88,98 +86,6 @@ export const apiSettings = {
}
},
async speedTestInstance(url, type = 'api') {
let testUrl;
// API instances might not support /track/ endpoint (which checks for streamability)
// So we test API instances with a lightweight metadata endpoint
if (type === 'streaming') {
testUrl = url.endsWith('/')
? `${url}track/?id=204567804&quality=HIGH`
: `${url}/track/?id=204567804&quality=HIGH`;
} else {
testUrl = url.endsWith('/')
? `${url}artist/?id=3532302` // Daft Punk
: `${url}/artist/?id=3532302`;
}
const startTime = performance.now();
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
const response = await fetch(testUrl, {
signal: controller.signal,
cache: 'no-store',
});
clearTimeout(timeout);
if (!response.ok) {
return { url, type, speed: Infinity, error: `HTTP ${response.status}` };
}
const endTime = performance.now();
const speed = endTime - startTime;
return { url, type, speed, error: null };
} catch (error) {
return { url, type, speed: Infinity, error: error.message };
}
},
getCachedSpeedTests() {
try {
const cached = localStorage.getItem(this.SPEED_TEST_CACHE_KEY);
if (!cached) return { speeds: {}, timestamp: Date.now() };
const data = JSON.parse(cached);
if (Date.now() - data.timestamp > this.SPEED_TEST_CACHE_DURATION) {
return { speeds: {}, timestamp: Date.now() };
}
return data;
} catch {
return { speeds: {}, timestamp: Date.now() };
}
},
updateSpeedCache(newResults) {
const currentCache = this.getCachedSpeedTests();
newResults.forEach((r) => {
// Use distinct keys for streaming tests to avoid overwriting API tests for same URL
// API tests use raw URL as key (for backward compatibility with UI)
const key = r.type === 'streaming' ? `${r.url}#streaming` : r.url;
currentCache.speeds[key] = { speed: r.speed, error: r.error };
});
currentCache.timestamp = Date.now();
try {
localStorage.setItem(this.SPEED_TEST_CACHE_KEY, JSON.stringify(currentCache));
} catch {
console.warn('[SpeedTest] Failed to cache results');
}
return currentCache;
},
async testSpecificUrls(urls, type) {
if (!urls || urls.length === 0) return [];
console.log(`[SpeedTest] Testing ${urls.length} instances for ${type}...`);
const results = await Promise.all(urls.map((url) => this.speedTestInstance(url, type)));
const validResults = results.filter((r) => r.speed !== Infinity);
console.log(
`[SpeedTest] ${type} Results:`,
validResults.map((r) => `${r.url}: ${r.speed.toFixed(0)}ms`)
);
return results;
},
async getInstances(type = 'api', sortBySpeed = false) {
let instancesObj;
@ -207,60 +113,32 @@ export const apiSettings = {
const targetUrls = instancesObj[type] || instancesObj.api || [];
if (targetUrls.length === 0) return [];
const speedCache = this.getCachedSpeedTests();
// Construct cache key based on type
const getCacheKey = (u) => (type === 'streaming' ? `${u}#streaming` : u);
const urlsToTest = targetUrls.filter((url) => !speedCache.speeds[getCacheKey(url)]);
if (urlsToTest.length > 0) {
const results = await this.testSpecificUrls(urlsToTest, type);
this.updateSpeedCache(results);
Object.assign(speedCache, this.getCachedSpeedTests());
}
// Default: return instances in their stored/manual order (respects manual reordering)
// Only sort by speed when explicitly requested (e.g., refresh speed test)
if (!sortBySpeed) {
return targetUrls;
}
const sortList = (list) => {
return [...list].sort((a, b) => {
const speedA = speedCache.speeds[getCacheKey(a)]?.speed ?? Infinity;
const speedB = speedCache.speeds[getCacheKey(b)]?.speed ?? Infinity;
return speedA - speedB;
});
};
const sortedList = sortList(targetUrls);
// Persist the sorted order
instancesObj[type] = sortedList;
this.saveInstances(instancesObj);
return sortedList;
return targetUrls;
},
async refreshSpeedTests() {
async refreshInstances() {
const instances = await this.loadInstancesFromGitHub();
const promises = [];
const shuffle = (array) => {
for (let i = array.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[array[i], array[j]] = [array[j], array[i]];
}
return array;
};
if (instances.api && instances.api.length) {
promises.push(this.testSpecificUrls(instances.api, 'api'));
instances.api = shuffle([...instances.api]);
}
if (instances.streaming && instances.streaming.length) {
promises.push(this.testSpecificUrls(instances.streaming, 'streaming'));
instances.streaming = shuffle([...instances.streaming]);
}
const resultsArray = await Promise.all(promises);
const allResults = resultsArray.flat();
this.updateSpeedCache(allResults);
this.saveInstances(instances);
// Return API instances for the UI to render (default view)
return this.getInstances('api', true);
return this.getInstances('api');
},
saveInstances(instances, type) {
if (type) {

View file

@ -3156,27 +3156,15 @@ export class UIRenderer {
const container = document.getElementById('api-instance-list');
Promise.all([this.api.settings.getInstances('api'), this.api.settings.getInstances('streaming')]).then(
([apiInstances, streamingInstances]) => {
const cachedData = this.api.settings.getCachedSpeedTests();
const speeds = cachedData?.speeds || {};
const renderGroup = (instances, type) => {
if (!instances || instances.length === 0) return '';
const listHtml = instances
.map((url, index) => {
const cacheKey = type === 'streaming' ? `${url}#streaming` : url;
const speedInfo = speeds[cacheKey];
const speedText = speedInfo
? speedInfo.speed === Infinity || typeof speedInfo.speed !== 'number'
? `<span style="color: var(--muted-foreground); font-size: 0.8rem;">Failed</span>`
: `<span style="color: var(--muted-foreground); font-size: 0.8rem;">${speedInfo.speed.toFixed(0)}ms</span>`
: '';
return `
<li data-index="${index}" data-type="${type}">
<div style="flex: 1; min-width: 0;">
<div class="instance-url">${url}</div>
${speedText}
</div>
<div class="controls">
<button class="move-up" title="Move Up" ${index === 0 ? 'disabled' : ''}>

15
package-lock.json generated
View file

@ -74,6 +74,7 @@
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5",
@ -1603,6 +1604,7 @@
"integrity": "sha512-FA5LmZVF1VziNc0bIdCSA1IoSVnDCqE8HJIZZv2/W8YmoAM50+tnUgJR/gQZwEeIMleuIOnRnHA/UaZRNeV4iQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@keyv/serialize": "^1.1.1"
}
@ -1644,6 +1646,7 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
@ -1687,6 +1690,7 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
}
@ -3138,6 +3142,7 @@
"resolved": "https://registry.npmjs.org/@svta/cml-xml/-/cml-xml-1.0.1.tgz",
"integrity": "sha512-11LkJa5kDEcsRMWkVI1ABH3KLCxGoiSVe4kQ293ItVj8ncTTQ7htmCGiJDjS+Cmy35UgF3e/vc0ysJIiWRTx2g==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">=20"
},
@ -3186,6 +3191,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@ -3209,6 +3215,7 @@
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
@ -3496,6 +3503,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@ -4280,6 +4288,7 @@
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@ -6636,6 +6645,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@ -6719,6 +6729,7 @@
"integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
@ -7668,6 +7679,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"@csstools/css-parser-algorithms": "^3.0.5",
"@csstools/css-syntax-patches-for-csstree": "^1.0.19",
@ -8082,6 +8094,7 @@
"integrity": "sha512-t/R3R/n0MSwnnazuPpPNVO60LX0SKL45pyl9YlvxIdkH0Of7D5qM2EVe+yASRIlY5pZ73nclYJfNANGWPwFDZw==",
"dev": true,
"license": "BSD-2-Clause",
"peer": true,
"dependencies": {
"@jridgewell/source-map": "^0.3.3",
"acorn": "^8.15.0",
@ -8406,6 +8419,7 @@
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@ -8793,6 +8807,7 @@
"integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"rollup": "dist/bin/rollup"
},