Working proxy integration with api.zarz.moe and lossless quality enforcement
This commit is contained in:
parent
3355a28789
commit
cf718f3ff2
10 changed files with 580 additions and 1612 deletions
107
.opencode/plans/tidal-web-backend.md
Normal file
107
.opencode/plans/tidal-web-backend.md
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
# Replace Tidal OAuth2 Backend with Public Web API + Self-Hosted Proxy
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
Replace Monochrome's Tidal OAuth2 backend (which is blocked in your country) with the SpotiFLAC tidal-web approach that uses:
|
||||||
|
1. **Tidal public web endpoints** (`tidal.com/v1/`) with a public token for metadata/search
|
||||||
|
2. **Self-hosted proxy** (SpotiFLAC Go backend) for audio streaming/downloads
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
### 1. `js/HiFi.ts` - Complete rewrite
|
||||||
|
- Remove OAuth2 token flow (`auth.tidal.com/v1/oauth2/token`)
|
||||||
|
- Use public web token (`x-tidal-token` header, default: `49YxDN9a2aFV6RTG`)
|
||||||
|
- Change API base from `api.tidal.com/v1/` to `tidal.com/v1/`
|
||||||
|
- Keep all TypeScript type interfaces (TidalTrack, TidalAlbum, etc.)
|
||||||
|
- Keep `TidalResponse` class
|
||||||
|
- Update `query()` to use `tidal.com/v1/` endpoints
|
||||||
|
- Methods updated: `getInfo`, `getTrack`, `getAlbum`, `getPlaylist`, `getPlaylistItems`, `getArtist`, `search`, `getVideo`, `getLyrics`, `getSimilarArtists`, `getSimilarAlbums`
|
||||||
|
- Constructor accepts: `publicToken`, `countryCode`, `locale`, `deviceType`
|
||||||
|
- Remove: `#fetchAppToken`, `#fetchAuthenticated`, `#fetchJson` (OAuth2-based), `fetchToken`, `getTrackManifest`
|
||||||
|
|
||||||
|
### 2. `js/api.js` - Rewrite LosslessAPI
|
||||||
|
- `fetchWithRetry()` - Remove HiFiClient OAuth2 fallback, remove proxy instance iteration for metadata (use HiFiClient directly). Keep proxy logic only for streaming if needed.
|
||||||
|
- `search()`, `searchTracks()`, `searchArtists()`, `searchAlbums()`, `searchPlaylists()`, `searchVideos()` - Update to use new HiFiClient.search()
|
||||||
|
- `getAlbum()` - Update to handle pages API response structure (`pages/album` returns page modules with `ALBUM_HEADER` and `ALBUM_ITEMS`)
|
||||||
|
- `getPlaylist()` - Update to use new HiFiClient methods
|
||||||
|
- `getArtist()` - Update to handle pages API response (`pages/artist` returns `ARTIST_HEADER` module)
|
||||||
|
- `getTrack()` - Route through self-hosted proxy instead of Tidal OpenAPI
|
||||||
|
- `getStreamUrl()` - Route through self-hosted proxy
|
||||||
|
- `getVideo()` - Update for new endpoints
|
||||||
|
- `downloadTrack()` - Stream URLs come from self-hosted proxy
|
||||||
|
- `enrichTrack()` - Update to use proxy for playback info
|
||||||
|
- `normalizeTrackManifestResponse()` - Adapt to proxy response format
|
||||||
|
- Keep: `getCoverUrl()`, `getCoverSrcset()`, `getArtistPictureUrl()`, `getArtistPictureSrcset()`, `getVideoCoverUrl()`, cache methods, prepare methods
|
||||||
|
|
||||||
|
### 3. `js/storage.js` - Add tidalWebSettings
|
||||||
|
```js
|
||||||
|
export const tidalWebSettings = {
|
||||||
|
STORAGE_KEY: 'tidal-web-settings',
|
||||||
|
DEFAULT_PROXY_URL: '', // User must set their self-hosted proxy
|
||||||
|
DEFAULT_PUBLIC_TOKEN: '49YxDN9a2aFV6RTG',
|
||||||
|
DEFAULT_COUNTRY_CODE: 'US',
|
||||||
|
getProxyUrl() { ... },
|
||||||
|
setProxyUrl(url) { ... },
|
||||||
|
getPublicToken() { ... },
|
||||||
|
setPublicToken(token) { ... },
|
||||||
|
getCountryCode() { ... },
|
||||||
|
setCountryCode(code) { ... },
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. `js/proxy-utils.js` - Simplify
|
||||||
|
- Remove tidal CDN URL proxying (no longer needed - streaming goes through self-hosted proxy)
|
||||||
|
- Keep CORS proxy list for any remaining direct stream URLs
|
||||||
|
|
||||||
|
### 5. `js/music-api.js` - Minimal changes
|
||||||
|
- `getTrack()`, `getStreamUrl()`, `downloadTrack()` already delegate to LosslessAPI
|
||||||
|
- No structural changes needed
|
||||||
|
|
||||||
|
### 6. `js/app.js` - Update initialization
|
||||||
|
- Change `HiFiClient.initialize()` to pass `publicToken`, `countryCode` from tidalWebSettings
|
||||||
|
- Remove OAuth2 token/tokenExpiry storage references
|
||||||
|
|
||||||
|
## Self-Hosted Proxy API Contract
|
||||||
|
|
||||||
|
The self-hosted SpotiFLAC Go backend must expose:
|
||||||
|
|
||||||
|
```
|
||||||
|
POST {proxyUrl}/v1/dl/tid2
|
||||||
|
Body: { "id": "123456", "quality": "HI_RES_LOSSLESS" }
|
||||||
|
|
||||||
|
Response:
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"manifest": "base64-encoded-manifest",
|
||||||
|
"manifestMimeType": "application/dash+xml",
|
||||||
|
"audioQuality": "HI_RES_LOSSLESS",
|
||||||
|
"bitDepth": 24,
|
||||||
|
"sampleRate": 96000,
|
||||||
|
"trackPresentation": "FULL"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Alternative response format (from mirror proxies):
|
||||||
|
```json
|
||||||
|
[{ "OriginalTrackUrl": "https://direct-stream-url.flac" }]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deployment Notes
|
||||||
|
|
||||||
|
The SpotiFLAC Go backend is at `go_backend/` in the SpotiFLAC-Mobile repo:
|
||||||
|
- Build: `cd go_backend && go build`
|
||||||
|
- Run: `./go_backend` (listens on port)
|
||||||
|
- Configure with Tidal credentials or public token
|
||||||
|
|
||||||
|
## Files Modified
|
||||||
|
1. `js/HiFi.ts` - Full rewrite
|
||||||
|
2. `js/api.js` - Major rewrite (LosslessAPI class)
|
||||||
|
3. `js/storage.js` - Add tidalWebSettings
|
||||||
|
4. `js/proxy-utils.js` - Simplify
|
||||||
|
5. `js/music-api.js` - Minimal changes
|
||||||
|
6. `js/app.js` - Update initialization
|
||||||
|
|
||||||
|
## Files NOT Modified
|
||||||
|
- Server-side functions (Cloudflare Workers for SEO) - continue using OAuth2
|
||||||
|
- Frontend UI components
|
||||||
|
- Player, downloads, metadata embedding logic (unchanged interfaces)
|
||||||
4
AGENTS.md
Normal file
4
AGENTS.md
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
## Permissions
|
||||||
|
- Allow edits to: js/*.js, js/*.ts, package.json
|
||||||
|
- Allow edits to: .opencode/plans/*.md
|
||||||
|
- Deny: git commit, git push, .git modifications
|
||||||
65
index.html
65
index.html
|
|
@ -5629,6 +5629,71 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-group">
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="info">
|
||||||
|
<span class="label">Tidal Web Proxy URL</span>
|
||||||
|
<span class="description">URL of your self-hosted SpotiFLAC proxy for streaming and downloads. Leave empty to use default instances.</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="tidal-web-proxy-url"
|
||||||
|
placeholder="http://localhost:8080"
|
||||||
|
style="
|
||||||
|
width: 220px;
|
||||||
|
padding: 0.35rem 0.5rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--card);
|
||||||
|
color: var(--foreground);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="info">
|
||||||
|
<span class="label">Public Token</span>
|
||||||
|
<span class="description">Tidal public web token for API requests</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="tidal-web-public-token"
|
||||||
|
placeholder="49YxDN9a2aFV6RTG"
|
||||||
|
style="
|
||||||
|
width: 220px;
|
||||||
|
padding: 0.35rem 0.5rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--card);
|
||||||
|
color: var(--foreground);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="info">
|
||||||
|
<span class="label">Country Code</span>
|
||||||
|
<span class="description">Country code for Tidal API requests (e.g., US, GB)</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="tidal-web-country-code"
|
||||||
|
placeholder="US"
|
||||||
|
maxlength="2"
|
||||||
|
style="
|
||||||
|
width: 80px;
|
||||||
|
padding: 0.35rem 0.5rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--card);
|
||||||
|
color: var(--foreground);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="settings-group">
|
<div class="settings-group">
|
||||||
<div class="setting-item">
|
<div class="setting-item">
|
||||||
<div class="info">
|
<div class="info">
|
||||||
|
|
|
||||||
1707
js/HiFi.ts
1707
js/HiFi.ts
File diff suppressed because it is too large
Load diff
213
js/api.js
213
js/api.js
|
|
@ -8,7 +8,7 @@ import {
|
||||||
getTrackDiscNumber,
|
getTrackDiscNumber,
|
||||||
normalizeQualityToken,
|
normalizeQualityToken,
|
||||||
} from './utils.js';
|
} from './utils.js';
|
||||||
import { preferDolbyAtmosSettings, trackDateSettings, devModeSettings } from './storage.js';
|
import { preferDolbyAtmosSettings, trackDateSettings, devModeSettings, tidalWebSettings } from './storage.js';
|
||||||
import { APICache } from './cache.js';
|
import { APICache } from './cache.js';
|
||||||
import { DashDownloader } from './dash-downloader.ts';
|
import { DashDownloader } from './dash-downloader.ts';
|
||||||
import { HlsDownloader } from './hls-downloader.js';
|
import { HlsDownloader } from './hls-downloader.js';
|
||||||
|
|
@ -190,32 +190,17 @@ export class LosslessAPI {
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
const shouldTryNative = type !== 'streaming';
|
// Use HiFiClient directly for metadata requests (no worker fallback needed)
|
||||||
|
if (type !== 'streaming' && !options.userInstancesOnly) {
|
||||||
if (shouldTryNative) {
|
|
||||||
try {
|
try {
|
||||||
if (import.meta.env.DEV) {
|
if (import.meta.env.DEV) {
|
||||||
console.log(relativePath);
|
console.log('[hifi]', relativePath);
|
||||||
}
|
}
|
||||||
|
return await HiFiClient.instance.query(relativePath, options.signal);
|
||||||
// HiFiClient.query fans out across the native TIDAL endpoints used by the route
|
|
||||||
// implementation, including api.tidal.com and openapi.tidal.com where applicable.
|
|
||||||
return await HiFiClient.instance.query(relativePath);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (options.directOnly) {
|
if (options.directOnly) throw err;
|
||||||
throw err;
|
if (import.meta.env.DEV) {
|
||||||
}
|
console.warn(`[hifi] HiFiClient query failed for ${relativePath}, falling back to workers`, err);
|
||||||
|
|
||||||
if (import.meta.env.DEV && isSearchRequest) {
|
|
||||||
console.warn(
|
|
||||||
`[search] native TIDAL query failed for ${relativePath}, trying HiFi worker instances`,
|
|
||||||
err
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
console.warn(
|
|
||||||
`Native TIDAL query failed for ${relativePath}. Falling back to configured HiFi API instances...`,
|
|
||||||
err
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1504,6 +1489,102 @@ export class LosslessAPI {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async #fetchProxy(id, quality, signal) {
|
||||||
|
let proxyUrl = tidalWebSettings.getProxyUrl();
|
||||||
|
if (!proxyUrl) {
|
||||||
|
throw new Error('Proxy URL not configured. Set tidalWebSettings.getProxyUrl() in settings.');
|
||||||
|
}
|
||||||
|
|
||||||
|
let baseUrl;
|
||||||
|
if (import.meta.env.DEV && proxyUrl.startsWith('http://localhost')) {
|
||||||
|
baseUrl = '/tidal-proxy';
|
||||||
|
} else {
|
||||||
|
baseUrl = proxyUrl.replace(/\/+$/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// api.zarz.moe only accepts lossless quality formats; upgrade HIGH/LOW to LOSSLESS
|
||||||
|
const proxyQuality = (quality === 'HIGH' || quality === 'LOW') ? 'LOSSLESS' : quality;
|
||||||
|
|
||||||
|
const response = await fetch(`${baseUrl}/v1/dl/tid2`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'User-Agent': 'SpotiFLAC-Mobile/4.5.5',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ id: String(id), quality: proxyQuality }),
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text().catch(() => '');
|
||||||
|
throw new Error(`Proxy request failed: ${response.status} ${response.statusText}${errorText ? ` - ${errorText}` : ''}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
// Validate response quality - reject if proxy downgraded to lossy
|
||||||
|
const inner = data.data ?? data;
|
||||||
|
const responseQuality = inner.audioQuality?.toUpperCase();
|
||||||
|
if (responseQuality === 'LOW' || responseQuality === 'HIGH') {
|
||||||
|
throw new Error(`Proxy returned ${responseQuality} quality; lossless not available for this track`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
#extractStreamUrlFromProxyResponse(data) {
|
||||||
|
if (!data) return null;
|
||||||
|
|
||||||
|
// Handle [{ OriginalTrackUrl: "..." }] format
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
for (const entry of data) {
|
||||||
|
if (entry?.OriginalTrackUrl) return entry.OriginalTrackUrl;
|
||||||
|
if (entry?.originalTrackUrl) return entry.originalTrackUrl;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle { data: { manifest, manifestMimeType, ... } } format
|
||||||
|
const inner = data.data ?? data;
|
||||||
|
|
||||||
|
// Direct stream URL
|
||||||
|
if (inner.url) return inner.url;
|
||||||
|
if (inner.streamUrl) return inner.streamUrl;
|
||||||
|
if (inner.OriginalTrackUrl) return inner.OriginalTrackUrl;
|
||||||
|
|
||||||
|
// Manifest-based
|
||||||
|
if (inner.manifest) {
|
||||||
|
return this.extractStreamUrlFromManifest(inner.manifest);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
#buildPlaybackInfoFromProxy(data, quality) {
|
||||||
|
const inner = data.data ?? data;
|
||||||
|
const normalizedQuality = inner.audioQuality || normalizeQualityToken(quality) || 'HIGH';
|
||||||
|
|
||||||
|
const info = {
|
||||||
|
trackId: inner.trackId ? Number(inner.trackId) : null,
|
||||||
|
assetPresentation: inner.trackPresentation || inner.assetPresentation || 'FULL',
|
||||||
|
audioQuality: normalizedQuality,
|
||||||
|
manifestMimeType: inner.manifestMimeType || 'application/dash+xml',
|
||||||
|
manifestHash: inner.manifestHash || '',
|
||||||
|
manifest: inner.manifest || '',
|
||||||
|
bitDepth: inner.bitDepth || (normalizedQuality === 'HI_RES_LOSSLESS' ? 24 : normalizedQuality === 'LOSSLESS' ? 16 : undefined),
|
||||||
|
sampleRate: inner.sampleRate || (normalizedQuality === 'HI_RES_LOSSLESS' ? 96000 : normalizedQuality === 'LOSSLESS' ? 44100 : undefined),
|
||||||
|
replayGain: inner.replayGain || inner.trackReplayGain,
|
||||||
|
trackReplayGain: inner.trackReplayGain,
|
||||||
|
trackPeakAmplitude: inner.trackPeakAmplitude,
|
||||||
|
albumReplayGain: inner.albumReplayGain,
|
||||||
|
albumPeakAmplitude: inner.albumPeakAmplitude,
|
||||||
|
drmData: inner.drmData || null,
|
||||||
|
formats: inner.formats || [],
|
||||||
|
};
|
||||||
|
|
||||||
|
return info;
|
||||||
|
}
|
||||||
|
|
||||||
async normalizeTrackManifestResponse(apiResponse, quality) {
|
async normalizeTrackManifestResponse(apiResponse, quality) {
|
||||||
if (!apiResponse || typeof apiResponse !== 'object') {
|
if (!apiResponse || typeof apiResponse !== 'object') {
|
||||||
return apiResponse;
|
return apiResponse;
|
||||||
|
|
@ -1613,23 +1694,41 @@ export class LosslessAPI {
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
|
|
||||||
const requestedQuality = normalizeQualityToken(quality) || quality || 'LOSSLESS';
|
const requestedQuality = normalizeQualityToken(quality) || quality || 'LOSSLESS';
|
||||||
const params = new URLSearchParams({
|
|
||||||
id: String(id),
|
// Route through self-hosted proxy
|
||||||
quality: requestedQuality,
|
const proxyData = await this.#fetchProxy(id, requestedQuality);
|
||||||
adaptive: String(adaptive),
|
|
||||||
});
|
// Handle [{ OriginalTrackUrl: "..." }] format (direct stream)
|
||||||
const formats = adaptive ? this.getAdaptiveTrackManifestFormats() : this.getTrackManifestFormats(quality);
|
if (Array.isArray(proxyData)) {
|
||||||
for (const format of formats) {
|
const entry = proxyData.find(e => e.OriginalTrackUrl || e.originalTrackUrl);
|
||||||
params.append('formats', format);
|
if (entry) {
|
||||||
|
const result = {
|
||||||
|
track: { id: Number(id), duration: 0 },
|
||||||
|
info: {
|
||||||
|
trackId: Number(id),
|
||||||
|
assetPresentation: 'FULL',
|
||||||
|
audioQuality: requestedQuality,
|
||||||
|
manifestMimeType: 'audio/flac',
|
||||||
|
manifestHash: '',
|
||||||
|
manifest: '',
|
||||||
|
},
|
||||||
|
originalTrackUrl: entry.OriginalTrackUrl || entry.originalTrackUrl,
|
||||||
|
};
|
||||||
|
await this.cache.set('track', cacheKey, result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await this.fetchWithRetry(`/trackManifests/?${params.toString()}`, { type: 'streaming' });
|
// Handle { data: { manifest, manifestMimeType, ... } } format
|
||||||
const jsonResponse = await response.json();
|
const playbackInfo = this.#buildPlaybackInfoFromProxy(proxyData, requestedQuality);
|
||||||
const result = this.parseTrackLookup(await this.normalizeTrackManifestResponse(jsonResponse, quality));
|
|
||||||
|
|
||||||
if (!(response instanceof TidalResponse)) {
|
const result = {
|
||||||
await this.cache.set('track', cacheKey, result);
|
track: { id: Number(id), duration: 0 },
|
||||||
}
|
info: playbackInfo,
|
||||||
|
originalTrackUrl: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.cache.set('track', cacheKey, result);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1640,30 +1739,34 @@ export class LosslessAPI {
|
||||||
return this.streamCache.get(cacheKey);
|
return this.streamCache.get(cacheKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const requestedQuality = normalizeQualityToken(quality) || quality || 'LOSSLESS';
|
||||||
|
|
||||||
|
// Route through self-hosted proxy
|
||||||
|
const proxyData = await this.#fetchProxy(id, requestedQuality);
|
||||||
|
|
||||||
let streamUrl;
|
let streamUrl;
|
||||||
let manifestRgInfo = null;
|
let manifestRgInfo = null;
|
||||||
|
|
||||||
const lookup = await this.getTrack(id, quality, { adaptive: this.shouldUseAdaptiveTrackManifest(download) });
|
// Handle direct stream URL format
|
||||||
|
if (Array.isArray(proxyData)) {
|
||||||
if (lookup.originalTrackUrl) {
|
const entry = proxyData.find(e => e.OriginalTrackUrl || e.originalTrackUrl);
|
||||||
streamUrl = lookup.originalTrackUrl;
|
if (entry) {
|
||||||
|
streamUrl = entry.OriginalTrackUrl || entry.originalTrackUrl;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
const manifest = lookup.info?.manifest;
|
// Handle manifest-based response
|
||||||
if (manifest) {
|
streamUrl = this.#extractStreamUrlFromProxyResponse(proxyData);
|
||||||
streamUrl = this.extractStreamUrlFromManifest(manifest);
|
manifestRgInfo = this.#buildPlaybackInfoFromProxy(proxyData, quality);
|
||||||
}
|
manifestRgInfo = {
|
||||||
if (!streamUrl) {
|
trackReplayGain: manifestRgInfo.trackReplayGain || manifestRgInfo.replayGain,
|
||||||
throw new Error('Could not resolve stream URL');
|
trackPeakAmplitude: manifestRgInfo.trackPeakAmplitude || manifestRgInfo.peakAmplitude,
|
||||||
}
|
albumReplayGain: manifestRgInfo.albumReplayGain,
|
||||||
|
albumPeakAmplitude: manifestRgInfo.albumPeakAmplitude,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (lookup.info) {
|
if (!streamUrl) {
|
||||||
manifestRgInfo = {
|
throw new Error('Could not resolve stream URL from proxy');
|
||||||
trackReplayGain: lookup.info.trackReplayGain || lookup.info.replayGain,
|
|
||||||
trackPeakAmplitude: lookup.info.trackPeakAmplitude || lookup.info.peakAmplitude,
|
|
||||||
albumReplayGain: lookup.info.albumReplayGain,
|
|
||||||
albumPeakAmplitude: lookup.info.albumPeakAmplitude,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = { url: streamUrl, rgInfo: manifestRgInfo };
|
const result = { url: streamUrl, rgInfo: manifestRgInfo };
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import {
|
||||||
pwaUpdateSettings,
|
pwaUpdateSettings,
|
||||||
modalSettings,
|
modalSettings,
|
||||||
keyboardShortcuts,
|
keyboardShortcuts,
|
||||||
|
tidalWebSettings,
|
||||||
} from './storage.js';
|
} from './storage.js';
|
||||||
import { UIRenderer } from './ui.js';
|
import { UIRenderer } from './ui.js';
|
||||||
import { Player } from './player.js';
|
import { Player } from './player.js';
|
||||||
|
|
@ -455,6 +456,9 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||||
new ThemeStore();
|
new ThemeStore();
|
||||||
|
|
||||||
await HiFiClient.initialize({
|
await HiFiClient.initialize({
|
||||||
|
publicToken: tidalWebSettings.getPublicToken(),
|
||||||
|
countryCode: tidalWebSettings.getCountryCode(),
|
||||||
|
baseUrl: tidalWebSettings.getProxyUrl(),
|
||||||
storage: [
|
storage: [
|
||||||
localStorage,
|
localStorage,
|
||||||
...(import.meta.env.DEV
|
...(import.meta.env.DEV
|
||||||
|
|
@ -466,8 +470,6 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
],
|
],
|
||||||
token: localStorage.getItem('hifi_token') || undefined,
|
|
||||||
tokenExpiry: parseInt(localStorage.getItem('hifi_token_expiry') || '0'),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await MusicAPI.initialize(apiSettings);
|
await MusicAPI.initialize(apiSettings);
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,7 @@
|
||||||
|
// CORS proxy utilities for streaming audio/video content.
|
||||||
|
// The self-hosted proxy provides stream URLs, but actual segment downloads
|
||||||
|
// may still require CORS proxying for blob://, DASH, and HLS streams.
|
||||||
|
|
||||||
const PROXIES = [
|
const PROXIES = [
|
||||||
{ url: 'http://your-nas-ip:8081/', param: 'url=' }, // Local proxy - change to your NAS IP
|
{ url: 'http://your-nas-ip:8081/', param: 'url=' }, // Local proxy - change to your NAS IP
|
||||||
{ url: 'https://audio-proxy.binimum.org/proxy-audio', param: 'url=' },
|
{ url: 'https://audio-proxy.binimum.org/proxy-audio', param: 'url=' },
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ import {
|
||||||
pwaUpdateSettings,
|
pwaUpdateSettings,
|
||||||
contentBlockingSettings,
|
contentBlockingSettings,
|
||||||
musicProviderSettings,
|
musicProviderSettings,
|
||||||
|
tidalWebSettings,
|
||||||
gaplessPlaybackSettings,
|
gaplessPlaybackSettings,
|
||||||
analyticsSettings,
|
analyticsSettings,
|
||||||
modalSettings,
|
modalSettings,
|
||||||
|
|
@ -838,6 +839,43 @@ export async function initializeSettings(scrobbler, player, api, ui) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tidal Web Proxy settings
|
||||||
|
const proxyUrlInput = document.getElementById('tidal-web-proxy-url');
|
||||||
|
if (proxyUrlInput) {
|
||||||
|
proxyUrlInput.value = tidalWebSettings.getProxyUrl();
|
||||||
|
let proxyUrlTimeout;
|
||||||
|
proxyUrlInput.addEventListener('input', (e) => {
|
||||||
|
clearTimeout(proxyUrlTimeout);
|
||||||
|
proxyUrlTimeout = setTimeout(() => {
|
||||||
|
tidalWebSettings.setProxyUrl(e.target.value.trim());
|
||||||
|
}, 500);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const publicTokenInput = document.getElementById('tidal-web-public-token');
|
||||||
|
if (publicTokenInput) {
|
||||||
|
publicTokenInput.value = tidalWebSettings.getPublicToken();
|
||||||
|
let tokenTimeout;
|
||||||
|
publicTokenInput.addEventListener('input', (e) => {
|
||||||
|
clearTimeout(tokenTimeout);
|
||||||
|
tokenTimeout = setTimeout(() => {
|
||||||
|
tidalWebSettings.setPublicToken(e.target.value.trim());
|
||||||
|
}, 500);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const countryCodeInput = document.getElementById('tidal-web-country-code');
|
||||||
|
if (countryCodeInput) {
|
||||||
|
countryCodeInput.value = tidalWebSettings.getCountryCode();
|
||||||
|
let countryTimeout;
|
||||||
|
countryCodeInput.addEventListener('input', (e) => {
|
||||||
|
clearTimeout(countryTimeout);
|
||||||
|
countryTimeout = setTimeout(() => {
|
||||||
|
tidalWebSettings.setCountryCode(e.target.value.trim().toUpperCase());
|
||||||
|
}, 500);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Streaming Quality setting
|
// Streaming Quality setting
|
||||||
const streamingQualitySetting = document.getElementById('streaming-quality-setting');
|
const streamingQualitySetting = document.getElementById('streaming-quality-setting');
|
||||||
if (streamingQualitySetting) {
|
if (streamingQualitySetting) {
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ export const apiSettings = {
|
||||||
INSTANCES_URLS: ['https://tidal-uptime.geeked.wtf'],
|
INSTANCES_URLS: ['https://tidal-uptime.geeked.wtf'],
|
||||||
FALLBACK_INSTANCES: {
|
FALLBACK_INSTANCES: {
|
||||||
api: [
|
api: [
|
||||||
|
{ url: 'https://api.zarz.moe', version: '2.10' },
|
||||||
{ url: 'https://eu-central.monochrome.tf', version: '2.10' },
|
{ url: 'https://eu-central.monochrome.tf', version: '2.10' },
|
||||||
{ url: 'https://us-west.monochrome.tf', version: '2.10' },
|
{ url: 'https://us-west.monochrome.tf', version: '2.10' },
|
||||||
{ url: 'https://api.monochrome.tf', version: '2.5' },
|
{ url: 'https://api.monochrome.tf', version: '2.5' },
|
||||||
|
|
@ -22,6 +23,7 @@ export const apiSettings = {
|
||||||
{ url: 'https://hifi-two.spotisaver.net', version: '2.5' },
|
{ url: 'https://hifi-two.spotisaver.net', version: '2.5' },
|
||||||
],
|
],
|
||||||
streaming: [
|
streaming: [
|
||||||
|
{ url: 'https://api.zarz.moe', version: '2.10' },
|
||||||
{ url: 'https://eu-central.monochrome.tf', version: '2.10' },
|
{ url: 'https://eu-central.monochrome.tf', version: '2.10' },
|
||||||
{ url: 'https://us-west.monochrome.tf', version: '2.10' },
|
{ url: 'https://us-west.monochrome.tf', version: '2.10' },
|
||||||
{ url: 'https://arran.monochrome.tf', version: '2.6' },
|
{ url: 'https://arran.monochrome.tf', version: '2.6' },
|
||||||
|
|
@ -3034,6 +3036,45 @@ export const pwaUpdateSettings = {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const tidalWebSettings = {
|
||||||
|
PROXY_URL_KEY: 'tidal-web-proxy-url',
|
||||||
|
PUBLIC_TOKEN_KEY: 'tidal-web-public-token',
|
||||||
|
COUNTRY_CODE_KEY: 'tidal-web-country-code',
|
||||||
|
DEFAULT_PUBLIC_TOKEN: '49YxDN9a2aFV6RTG',
|
||||||
|
DEFAULT_COUNTRY_CODE: 'US',
|
||||||
|
|
||||||
|
getProxyUrl() {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(this.PROXY_URL_KEY) || 'https://api.zarz.moe';
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
setProxyUrl(url) {
|
||||||
|
localStorage.setItem(this.PROXY_URL_KEY, url);
|
||||||
|
},
|
||||||
|
getPublicToken() {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(this.PUBLIC_TOKEN_KEY) || this.DEFAULT_PUBLIC_TOKEN;
|
||||||
|
} catch {
|
||||||
|
return this.DEFAULT_PUBLIC_TOKEN;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
setPublicToken(token) {
|
||||||
|
localStorage.setItem(this.PUBLIC_TOKEN_KEY, token);
|
||||||
|
},
|
||||||
|
getCountryCode() {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(this.COUNTRY_CODE_KEY) || this.DEFAULT_COUNTRY_CODE;
|
||||||
|
} catch {
|
||||||
|
return this.DEFAULT_COUNTRY_CODE;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
setCountryCode(code) {
|
||||||
|
localStorage.setItem(this.COUNTRY_CODE_KEY, code);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export const musicProviderSettings = {
|
export const musicProviderSettings = {
|
||||||
STORAGE_KEY: 'music-provider',
|
STORAGE_KEY: 'music-provider',
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,13 @@ export default defineConfig((_options) => {
|
||||||
// host: true,
|
// host: true,
|
||||||
// allowedHosts: ['<your_tailscale_hostname>'], // e.g. pi5.tailf5f622.ts.net
|
// allowedHosts: ['<your_tailscale_hostname>'], // e.g. pi5.tailf5f622.ts.net
|
||||||
},
|
},
|
||||||
|
proxy: {
|
||||||
|
'/tidal-proxy': {
|
||||||
|
target: 'http://localhost:8080',
|
||||||
|
changeOrigin: true,
|
||||||
|
rewrite: (path) => path.replace(/^\/tidal-proxy/, ''),
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
// preview: {
|
// preview: {
|
||||||
// host: true,
|
// host: true,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue