Waveform Seekbar
@@ -3135,13 +3155,10 @@
API Instances
- Manage and prioritize API instances. Automatically sorted by
- speed.
+ Manage and prioritize API instances.
diff --git a/js/api.js b/js/api.js
index 23cca36..3ad0e65 100644
--- a/js/api.js
+++ b/js/api.js
@@ -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}`);
diff --git a/js/app.js b/js/app.js
index 58e975d..ae561cb 100644
--- a/js/app.js
+++ b/js/app.js
@@ -1432,6 +1432,20 @@ document.addEventListener('DOMContentLoaded', async () => {
showKeyboardShortcuts();
});
+ // Font Settings
+ const fontSelect = document.getElementById('font-select');
+ if (fontSelect) {
+ const savedFont = localStorage.getItem('monochrome-font');
+ if (savedFont) {
+ fontSelect.value = savedFont;
+ }
+ fontSelect.addEventListener('change', (e) => {
+ const font = e.target.value;
+ document.documentElement.style.setProperty('--font-family', font);
+ localStorage.setItem('monochrome-font', font);
+ });
+ }
+
// Listener for Pocketbase Sync updates
window.addEventListener('library-changed', () => {
const path = window.location.pathname;
diff --git a/js/settings.js b/js/settings.js
index 0d00c08..be6c6eb 100644
--- a/js/settings.js
+++ b/js/settings.js
@@ -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(() => {
diff --git a/js/storage.js b/js/storage.js
index 13aa69a..bd2831d 100644
--- a/js/storage.js
+++ b/js/storage.js
@@ -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,99 +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 +112,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) {
diff --git a/js/ui.js b/js/ui.js
index 8f97063..9ab52ee 100644
--- a/js/ui.js
+++ b/js/ui.js
@@ -42,6 +42,8 @@ import {
createProjectCardHTML,
createTrackFromSong,
} from './tracker.js';
+const savedFont = localStorage.getItem('monochrome-font');
+if (savedFont) document.documentElement.style.setProperty('--font-family', savedFont);
function sortTracks(tracks, sortType) {
if (sortType === 'custom') return [...tracks];
@@ -3156,27 +3158,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'
- ? `
Failed`
- : `
${speedInfo.speed.toFixed(0)}ms`
- : '';
-
return `