From 7da10fb2c30ebc4d5d54cd2a19dd658662ca921b Mon Sep 17 00:00:00 2001 From: Khoa Vo Date: Mon, 27 Apr 2026 17:51:09 +0700 Subject: [PATCH] feat: Add rotating proxy with health check, fix CORS for localhost, update extension --- bun.lock | 4 +- docker-compose.yml | 12 +++ extension/manifest.json | 20 ++++- extension/rules.json | 4 +- js/api.js | 10 ++- js/instance-health.js | 162 ++++++++++++++++++++++++++++++++++++++++ js/music-api.js | 12 ++- js/proxy-utils.js | 15 +++- js/storage.js | 68 ++++++++++------- js/ui.js | 21 +++++- 10 files changed, 290 insertions(+), 38 deletions(-) create mode 100644 docker-compose.yml create mode 100644 js/instance-health.js diff --git a/bun.lock b/bun.lock index 1e31d77..e16de85 100644 --- a/bun.lock +++ b/bun.lock @@ -19,7 +19,7 @@ "@svta/common-media-library": "^0.18.1", "@types/wicg-file-system-access": "^2023.10.7", "@typescript-eslint/eslint-plugin": "^8.57.2", - "@uimaxbai/am-lyrics": "^1.2.8", + "@uimaxbai/am-lyrics": "^1.2.9", "@vitest/web-worker": "^4.1.2", "appwrite": "^23.0.0", "butterchurn": "^2.6.7", @@ -678,7 +678,7 @@ "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.58.0", "", { "dependencies": { "@typescript-eslint/types": "8.58.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-XJ9UD9+bbDo4a4epraTwG3TsNPeiB9aShrUneAVXy8q4LuwowN+qu89/6ByLMINqvIMeI9H9hOHQtg/ijrYXzQ=="], - "@uimaxbai/am-lyrics": ["@uimaxbai/am-lyrics@1.2.8", "", { "dependencies": { "@babel/runtime": "^7.27.6", "lit": "^3.1.4" }, "peerDependencies": { "@lit/react": "^1.0.0", "react": ">=17.0.0" }, "optionalPeers": ["@lit/react", "react"] }, "sha512-aR8kxqIYcVlsMCH6bbH8ANG+bN/2OAw66ZFjYD1a25hkMTyxtULWgWwAZlUfreP9V47bFvNgXIKvOqhO5JFpeg=="], + "@uimaxbai/am-lyrics": ["@uimaxbai/am-lyrics@1.3.0", "", { "dependencies": { "@babel/runtime": "^7.27.6", "lit": "^3.1.4" }, "peerDependencies": { "@lit/react": "^1.0.0", "react": ">=17.0.0" }, "optionalPeers": ["@lit/react", "react"] }, "sha512-0IpvmCY6384cIKntIap/N9Rq/JuacSjL7Dq+QXPqRAjewZVVGcxEgka7tXtm1BnhwqIZWYWT/2Uzz6aA71RSmQ=="], "@vitest/browser": ["@vitest/browser@4.1.2", "", { "dependencies": { "@blazediff/core": "1.9.1", "@vitest/mocker": "4.1.2", "@vitest/utils": "4.1.2", "magic-string": "^0.30.21", "pngjs": "^7.0.0", "sirv": "^3.0.2", "tinyrainbow": "^3.1.0", "ws": "^8.19.0" }, "peerDependencies": { "vitest": "4.1.2" } }, "sha512-CwdIf90LNf1Zitgqy63ciMAzmyb4oIGs8WZ40VGYrWkssQKeEKr32EzO8MKUrDPPcPVHFI9oQ5ni2Hp24NaNRQ=="], diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c1ed4d3 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,12 @@ +version: '3.8' + +services: + monochrome: + build: . + ports: + - '8080:4173' + restart: unless-stopped + volumes: + - ./data:/data # For local storage if needed + environment: + - NODE_ENV=production diff --git a/extension/manifest.json b/extension/manifest.json index 2c2f39c..8348293 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -31,7 +31,15 @@ }, "content_scripts": [ { - "matches": ["*://monochrome.samidy.com/*", "*://monochrome.tf/*", "*://lossless.wtf/*", "*://localhost/*"], + "matches": [ + "*://monochrome.samidy.com/*", + "*://monochrome.tf/*", + "*://lossless.wtf/*", + "*://localhost:5173/*", + "*://localhost:5174/*", + "*://localhost:5175/*", + "*://localhost:5176/*" + ], "js": ["content.js"], "run_at": "document_start", "all_frames": true @@ -45,7 +53,15 @@ "web_accessible_resources": [ { "resources": ["inject.js"], - "matches": ["*://monochrome.samidy.com/*", "*://monochrome.tf/*", "*://lossless.wtf/*", "*://localhost/*"] + "matches": [ + "*://monochrome.samidy.com/*", + "*://monochrome.tf/*", + "*://lossless.wtf/*", + "*://localhost:5173/*", + "*://localhost:5174/*", + "*://localhost:5175/*", + "*://localhost:5176/*" + ] } ] } diff --git a/extension/rules.json b/extension/rules.json index 8e87651..5d858a2 100644 --- a/extension/rules.json +++ b/extension/rules.json @@ -24,7 +24,7 @@ }, "condition": { "urlFilter": "||tidal.com*", - "initiatorDomains": ["monochrome.tf", "monochrome.samidy.com"], + "initiatorDomains": ["monochrome.tf", "monochrome.samidy.com", "localhost"], "resourceTypes": ["xmlhttprequest", "media"] } }, @@ -48,7 +48,7 @@ }, "condition": { "urlFilter": "||tidal.com*", - "initiatorDomains": ["monochrome.tf", "monochrome.samidy.com"], + "initiatorDomains": ["monochrome.tf", "monochrome.samidy.com", "localhost"], "resourceTypes": ["xmlhttprequest", "media"] } } diff --git a/js/api.js b/js/api.js index 696b8c8..9524fdc 100644 --- a/js/api.js +++ b/js/api.js @@ -21,6 +21,7 @@ import { resolveDownloadTotalBytes } from './downloadProgressUtils.js'; import { readableStreamIterator } from './readableStreamIterator.js'; import { HiFiClient, TidalResponse } from './HiFi.ts'; import { isIos, isSafari, isChrome } from './platform-detection.js'; +import { instanceHealth } from './instance-health.js'; import { TrackAlbum, EnrichedAlbum, @@ -112,9 +113,9 @@ export class LosslessAPI { }; const tryInstances = async (instances) => { - const maxTotalAttempts = instances.length * 2; // Allow some retries across instances + const maxTotalAttempts = instances.length * 2; let lastError = null; - let instanceIndex = Math.floor(Math.random() * instances.length); + let instanceIndex = 0; for (let attempt = 1; attempt <= maxTotalAttempts; attempt++) { const instance = instances[instanceIndex % instances.length]; @@ -128,12 +129,14 @@ export class LosslessAPI { if (response.status === 429) { console.warn(`Rate limit hit on ${baseUrl}. Trying next instance...`); + if (!import.meta?.env?.DEV) instanceHealth.checkInstance(baseUrl, type); instanceIndex++; await delay(500); continue; } if (response.ok) { + if (!import.meta?.env?.DEV) instanceHealth.checkInstance(baseUrl, type); return response; } @@ -144,6 +147,7 @@ export class LosslessAPI { .catch(() => null); if (errorData?.subStatus === 11002) { console.warn(`Auth failed on ${baseUrl}. Trying next instance...`); + if (!import.meta?.env?.DEV) instanceHealth.checkInstance(baseUrl, type); instanceIndex++; continue; } @@ -151,6 +155,7 @@ export class LosslessAPI { if (response.status >= 500) { console.warn(`Server error ${response.status} on ${baseUrl}. Trying next instance...`); + if (!import.meta?.env?.DEV) instanceHealth.checkInstance(baseUrl, type); instanceIndex++; continue; } @@ -161,6 +166,7 @@ export class LosslessAPI { if (error.name === 'AbortError') throw error; lastError = error; console.warn(`Network error on ${baseUrl}: ${error.message}. Trying next instance...`); + if (!import.meta?.env?.DEV) instanceHealth.checkInstance(baseUrl, type); instanceIndex++; await delay(200); } diff --git a/js/instance-health.js b/js/instance-health.js new file mode 100644 index 0000000..7599c2e --- /dev/null +++ b/js/instance-health.js @@ -0,0 +1,162 @@ +//instance-health.js + +const HEALTH_CHECK_INTERVAL = 5 * 60 * 1000; +const HEALTH_CHECK_TIMEOUT = 8000; +const MAX_CONSECUTIVE_FAILURES = 3; +const INITIAL_DELAY = 10000; +const IS_DEV = typeof window !== 'undefined' && window.location?.hostname === 'localhost'; + +class InstanceHealthChecker { + constructor() { + this.healthCache = new Map(); + this.checkTimers = {}; + this.listeners = []; + } + + onHealthUpdate(callback) { + this.listeners.push(callback); + } + + notifyListeners(instances) { + this.listeners.forEach((cb) => cb(instances)); + } + + async checkInstance(url, type = 'api') { + const testPath = '/'; + const fullUrl = url.endsWith('/') ? `${url}${testPath.substring(1)}` : `${url}${testPath}`; + + const startTime = Date.now(); + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), HEALTH_CHECK_TIMEOUT); + + try { + const response = await fetch(fullUrl, { + method: 'GET', + signal: controller.signal, + }); + + clearTimeout(timeoutId); + const latency = Date.now() - startTime; + + const existing = this.healthCache.get(url) || { url, failures: 0 }; + const status = response.ok || response.status === 401 || response.status === 404 ? 'online' : 'degraded'; + + this.healthCache.set(url, { + ...existing, + status, + latency, + lastCheck: Date.now(), + failures: response.ok ? 0 : existing.failures, + type, + }); + + return { url, status, latency, online: response.ok }; + } catch (error) { + clearTimeout(timeoutId); + const existing = this.healthCache.get(url) || { url, failures: 0 }; + const newFailures = existing.failures + 1; + const status = newFailures >= MAX_CONSECUTIVE_FAILURES ? 'offline' : 'degraded'; + + this.healthCache.set(url, { + url, + status, + latency: null, + lastCheck: Date.now(), + failures: newFailures, + error: error.message, + type, + }); + + return { url, status, latency: null, online: false, error: error.message }; + } + } + + async checkAll(instances, type = 'api') { + const checks = instances.map((inst) => { + const url = typeof inst === 'string' ? inst : inst.url; + return this.checkInstance(url, type); + }); + + const results = await Promise.allSettled(checks); + return results.map((r) => r.value || { url: 'unknown', status: 'offline', online: false }); + } + + getHealthStatus(url) { + return this.healthCache.get(url) || { url, status: 'unknown', latency: null, lastCheck: null }; + } + + getHealthyInstances(instances, type = 'api') { + return instances.filter((inst) => { + const url = typeof inst === 'string' ? inst : inst.url; + const health = this.healthCache.get(url); + return !health || health.status === 'online' || health.status === 'degraded'; + }); + } + + getHealthyAndSorted(instances, type = 'api') { + const withHealth = instances.map((inst) => { + const url = typeof inst === 'string' ? inst : inst.url; + const health = this.healthCache.get(url); + return { + ...inst, + healthStatus: health?.status || 'unknown', + latency: health?.latency ?? null, + }; + }); + + return withHealth.sort((a, b) => { + if (a.healthStatus === 'offline' && b.healthStatus !== 'offline') return 1; + if (b.healthStatus === 'offline' && a.healthStatus !== 'offline') return -1; + if (a.latency !== null && b.latency !== null) return a.latency - b.latency; + if (a.latency !== null) return -1; + if (b.latency !== null) return 1; + return 0; + }); + } + + startPeriodicCheck(instancesGetter, type = 'api') { + if (this.checkTimers[type]) { + clearInterval(this.checkTimers[type]); + } + + if (IS_DEV) { + console.log(`Health check disabled in dev mode for ${type}`); + return; + } + + const check = async () => { + const instances = await instancesGetter(type); + if (!instances || instances.length === 0) return; + const results = await this.checkAll(instances, type); + this.notifyListeners(results); + }; + + setTimeout(check, INITIAL_DELAY); + this.checkTimers[type] = setInterval(check, HEALTH_CHECK_INTERVAL); + } + + stopPeriodicCheck(type = 'api') { + if (this.checkTimers[type]) { + clearInterval(this.checkTimers[type]); + delete this.checkTimers[type]; + } + } + + stopAllChecks() { + Object.keys(this.checkTimers).forEach((type) => this.stopPeriodicCheck(type)); + } + + async quickCheck(url, type = 'api') { + return this.checkInstance(url, type); + } + + getCachedStatus(url) { + return this.healthCache.get(url); + } + + clearCache() { + this.healthCache.clear(); + } +} + +export const instanceHealth = new InstanceHealthChecker(); diff --git a/js/music-api.js b/js/music-api.js index 6b0d7c9..c210a8c 100644 --- a/js/music-api.js +++ b/js/music-api.js @@ -2,7 +2,8 @@ import { LosslessAPI } from './api.js'; import { PodcastsAPI } from './podcasts-api.js'; -import { musicProviderSettings } from './storage.js'; +import { musicProviderSettings, apiSettings } from './storage.js'; +import { instanceHealth } from './instance-health.js'; /** * MusicAPI - Singleton class that provides a unified interface for accessing music streaming services. @@ -64,7 +65,14 @@ export class MusicAPI { } const api = new MusicAPI(settings); - return (MusicAPI.#instance = api); + MusicAPI.#instance = api; + + if (!window.location?.hostname?.includes('localhost')) { + instanceHealth.startPeriodicCheck((type) => apiSettings.getInstances(type), 'api'); + instanceHealth.startPeriodicCheck((type) => apiSettings.getInstances(type), 'streaming'); + } + + return MusicAPI.#instance; } getCurrentProvider() { diff --git a/js/proxy-utils.js b/js/proxy-utils.js index fc9939a..5311fc9 100644 --- a/js/proxy-utils.js +++ b/js/proxy-utils.js @@ -1,7 +1,20 @@ +const PROXIES = [ + { url: 'https://audio-proxy.binimum.org/proxy-audio', param: 'url=' }, + { url: 'https://corsproxy.io/?', param: '' }, +]; + +let proxyIndex = 0; + export const getProxyUrl = (url) => { if (!url || typeof url !== 'string') return url; if (window.__tidalOriginExtension) return url; if (url.startsWith('blob:')) return url; if (url.startsWith('https://audio-proxy.binimum.org/')) return url; - return `https://audio-proxy.binimum.org/proxy-audio?url=${url}`; + + const proxy = PROXIES[proxyIndex % PROXIES.length]; + return `${proxy.url}${proxy.param}${encodeURIComponent(url)}`; +}; + +export const rotateProxy = () => { + proxyIndex = (proxyIndex + 1) % PROXIES.length; }; diff --git a/js/storage.js b/js/storage.js index bffaa57..3c216fe 100644 --- a/js/storage.js +++ b/js/storage.js @@ -4,9 +4,38 @@ import { SVG_RIGHT_ARROW } from './icons'; export const apiSettings = { STORAGE_KEY: 'monochrome-api-instances-v9', - INSTANCES_URLS: [ - 'https://tidal-uptime.geeked.wtf', - ], + INSTANCES_URLS: ['https://tidal-uptime.geeked.wtf'], + FALLBACK_INSTANCES: { + api: [ + { url: 'https://eu-central.monochrome.tf', version: '2.10' }, + { url: 'https://us-west.monochrome.tf', version: '2.10' }, + { url: 'https://api.monochrome.tf', version: '2.5' }, + { url: 'https://arran.monochrome.tf', version: '2.6' }, + { url: 'https://tidal.kinoplus.online', version: '2.2' }, + { url: 'https://tidal-api.binimum.org', version: '2.6' }, + { url: 'https://wolf.qqdl.site', version: '2.2' }, + { url: 'https://maus.qqdl.site', version: '2.6' }, + { url: 'https://vogel.qqdl.site', version: '2.6' }, + { url: 'https://katze.qqdl.site', version: '2.6' }, + { url: 'https://hund.qqdl.site', version: '2.6' }, + { url: 'https://hifi-one.spotisaver.net', version: '2.5' }, + { url: 'https://hifi-two.spotisaver.net', version: '2.5' }, + ], + streaming: [ + { url: 'https://eu-central.monochrome.tf', version: '2.10' }, + { url: 'https://us-west.monochrome.tf', version: '2.10' }, + { url: 'https://arran.monochrome.tf', version: '2.6' }, + { url: 'https://tidal.kinoplus.online', version: '2.2' }, + { url: 'https://tidal-api.binimum.org', version: '2.6' }, + { url: 'https://wolf.qqdl.site', version: '2.2' }, + { url: 'https://maus.qqdl.site', version: '2.6' }, + { url: 'https://vogel.qqdl.site', version: '2.6' }, + { url: 'https://katze.qqdl.site', version: '2.6' }, + { url: 'https://hund.qqdl.site', version: '2.6' }, + { url: 'https://hifi-one.spotisaver.net', version: '2.5' }, + { url: 'https://hifi-two.spotisaver.net', version: '2.5' }, + ], + }, defaultInstances: { api: [], streaming: [] }, userInstances: null, instancesLoaded: false, @@ -75,27 +104,8 @@ export const apiSettings = { if (!data) { console.error('Failed to load instances from all uptime APIs:', fetchError); this.defaultInstances = { - api: [ - { url: 'https://hifi.geeked.wtf', version: '2.7' }, - { url: 'https://eu-central.monochrome.tf', version: '2.7' }, - { url: 'https://us-west.monochrome.tf', version: '2.7' }, - { url: 'https://api.monochrome.tf', version: '2.5' }, - { url: 'https://monochrome-api.samidy.com', version: '2.3' }, - { url: 'https://maus.qqdl.site', version: '2.6' }, - { url: 'https://vogel.qqdl.site', version: '2.6' }, - { url: 'https://katze.qqdl.site', version: '2.6' }, - { url: 'https://hund.qqdl.site', version: '2.6' }, - { url: 'https://tidal.kinoplus.online', version: '2.2' }, - { url: 'https://wolf.qqdl.site', version: '2.2' }, - ], - streaming: [ - { url: 'https://hifi.geeked.wtf', version: '2.7' }, - { url: 'https://maus.qqdl.site', version: '2.6' }, - { url: 'https://vogel.qqdl.site', version: '2.6' }, - { url: 'https://katze.qqdl.site', version: '2.6' }, - { url: 'https://hund.qqdl.site', version: '2.6' }, - { url: 'https://wolf.qqdl.site', version: '2.6' }, - ], + api: [...this.FALLBACK_INSTANCES.api], + streaming: [...this.FALLBACK_INSTANCES.streaming], }; this.instancesLoaded = true; this._loadPromise = null; @@ -113,12 +123,20 @@ export const apiSettings = { groupedInstances.api = data.api.filter((item) => !isBlockedInstance(item)); } - if (data.streaming && Array.isArray(data.streaming)) { + if (data.streaming && Array.isArray(data.streaming) && data.streaming.length > 0) { groupedInstances.streaming = data.streaming.filter((item) => !isBlockedInstance(item)); } else if (groupedInstances.api.length > 0) { groupedInstances.streaming = [...groupedInstances.api]; } + if (groupedInstances.streaming.length === 0) { + groupedInstances.streaming = [...this.FALLBACK_INSTANCES.streaming]; + } + + if (groupedInstances.api.length === 0) { + groupedInstances.api = [...this.FALLBACK_INSTANCES.api]; + } + this.defaultInstances = groupedInstances; this.instancesLoaded = true; diff --git a/js/ui.js b/js/ui.js index 652550b..efd63c1 100644 --- a/js/ui.js +++ b/js/ui.js @@ -31,6 +31,7 @@ import { fullscreenCoverNoRoundSettings, artistBannerSettings, } from './storage.js'; +import { instanceHealth } from './instance-health.js'; import { db } from './db.js'; import { getVibrantColorFromImage } from './vibrant-color.js'; import { syncManager } from './accounts/pocketbase.js'; @@ -6103,12 +6104,28 @@ export class UIRenderer { const safeUrl = escapeHtml(instanceUrl || ''); const safeVersion = escapeHtml(instanceVersion); + const health = instanceHealth.getCachedStatus(instanceUrl); + const statusColor = + health?.status === 'online' + ? '#10b981' + : health?.status === 'degraded' + ? '#f59e0b' + : health?.status === 'offline' + ? '#ef4444' + : '#6b7280'; + const latencyDisplay = health?.latency ? `${health.latency}ms` : ''; + return `
  • -
    ${safeName} ${isUser ? 'U' : ''}
    +
    + + ${safeName} ${isUser ? 'U' : ''} +
    ${safeUrl && safeUrl !== safeName ? `
    ${safeUrl}
    ` : ''} - ${safeVersion ? `
    v${safeVersion}
    ` : ''} +
    + ${safeVersion ? `v${safeVersion}` : ''}${safeVersion && latencyDisplay ? ' ยท ' : ''}${latencyDisplay} +
    ${