Working proxy integration with api.zarz.moe and lossless quality enforcement

This commit is contained in:
Admin 2026-05-18 12:12:46 +07:00
parent 3355a28789
commit cf718f3ff2
10 changed files with 580 additions and 1612 deletions

View 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
View 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

View file

@ -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

File diff suppressed because it is too large Load diff

213
js/api.js
View file

@ -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 };

View file

@ -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);

View file

@ -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=' },

View file

@ -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) {

View file

@ -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',

View file

@ -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,