fix: route stream segments through Go proxy in dev mode, reject Tidal PREVIEW (30s demo)
This commit is contained in:
parent
5bb63e6e2a
commit
431af4bb09
6 changed files with 540 additions and 72 deletions
120
js/api.js
120
js/api.js
|
|
@ -1495,50 +1495,102 @@ export class LosslessAPI {
|
||||||
throw new Error('Proxy URL not configured. Set tidalWebSettings.getProxyUrl() in settings.');
|
throw new Error('Proxy URL not configured. Set tidalWebSettings.getProxyUrl() in settings.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const proxyQuality = (quality === 'HIGH' || quality === 'LOW') ? 'LOSSLESS' : quality;
|
||||||
|
const body = JSON.stringify({ id: String(id), quality: proxyQuality });
|
||||||
|
|
||||||
|
const tryProxyRequest = async (baseUrl) => {
|
||||||
|
const isSelfHosted = baseUrl.includes('localhost') || baseUrl.includes('127.0.0.1');
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'User-Agent': 'SpotiFLAC-Mobile/4.5.5',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isSelfHosted) {
|
||||||
|
const token = tidalWebSettings.getPublicToken();
|
||||||
|
if (token) {
|
||||||
|
headers['X-Tidal-Token'] = token;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${baseUrl}/v1/dl/tid2`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body,
|
||||||
|
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();
|
||||||
|
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
// In dev mode, always route through the local dev proxy (avoids CORS issues with api.zarz.moe)
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
try {
|
||||||
|
return await tryProxyRequest('/tidal-proxy');
|
||||||
|
} catch (devProxyError) {
|
||||||
|
console.warn('[proxy] Dev proxy failed:', devProxyError.message);
|
||||||
|
// Fallback: try the configured proxy URL through the dev proxy
|
||||||
|
const fallbacks = tidalWebSettings.getProxyFallbacks();
|
||||||
|
for (const fallback of fallbacks) {
|
||||||
|
if (fallback.includes('localhost') || fallback.includes('127.0.0.1')) {
|
||||||
|
try {
|
||||||
|
return await tryProxyRequest('/tidal-proxy');
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[proxy] Dev proxy fallback failed:', e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error('All proxy endpoints failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let baseUrl;
|
let baseUrl;
|
||||||
if (import.meta.env.DEV && proxyUrl.startsWith('http://localhost')) {
|
if (proxyUrl.startsWith('http://localhost') || proxyUrl.startsWith('http://127.0.0.1')) {
|
||||||
baseUrl = '/tidal-proxy';
|
baseUrl = proxyUrl.replace(/\/+$/g, '');
|
||||||
} else {
|
} else {
|
||||||
baseUrl = proxyUrl.replace(/\/+$/g, '');
|
baseUrl = proxyUrl.replace(/\/+$/g, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
const isSelfHosted = baseUrl.includes('localhost') || baseUrl.includes('127.0.0.1');
|
const fallbacks = tidalWebSettings.getProxyFallbacks();
|
||||||
|
|
||||||
const headers = {
|
try {
|
||||||
'Content-Type': 'application/json',
|
return await tryProxyRequest(baseUrl);
|
||||||
'User-Agent': 'SpotiFLAC-Mobile/4.5.5',
|
} catch (primaryError) {
|
||||||
};
|
console.warn(`[proxy] Primary proxy failed: ${primaryError.message}, trying ${fallbacks.length} fallbacks`);
|
||||||
|
|
||||||
if (isSelfHosted) {
|
for (const fallback of fallbacks) {
|
||||||
const token = tidalWebSettings.getPublicToken();
|
if (fallback === proxyUrl) continue;
|
||||||
if (token) {
|
|
||||||
headers['X-Tidal-Token'] = token;
|
let fallbackBaseUrl;
|
||||||
|
if (import.meta.env.DEV && (fallback.startsWith('http://localhost') || fallback.startsWith('http://127.0.0.1'))) {
|
||||||
|
fallbackBaseUrl = '/tidal-proxy';
|
||||||
|
} else {
|
||||||
|
fallbackBaseUrl = fallback.replace(/\/+$/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log(`[proxy] Trying fallback: ${fallbackBaseUrl}`);
|
||||||
|
return await tryProxyRequest(fallbackBaseUrl);
|
||||||
|
} catch (fallbackError) {
|
||||||
|
console.warn(`[proxy] Fallback failed: ${fallbackError.message}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const proxyQuality = (quality === 'HIGH' || quality === 'LOW') ? 'LOSSLESS' : quality;
|
throw new Error('All proxy endpoints failed');
|
||||||
|
|
||||||
const response = await fetch(`${baseUrl}/v1/dl/tid2`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers,
|
|
||||||
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();
|
|
||||||
|
|
||||||
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) {
|
#extractStreamUrlFromProxyResponse(data) {
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,11 @@ export const getProxyUrl = (url) => {
|
||||||
if (url.startsWith('blob:')) return url;
|
if (url.startsWith('blob:')) return url;
|
||||||
if (url.startsWith('https://audio-proxy.binimum.org/')) return url;
|
if (url.startsWith('https://audio-proxy.binimum.org/')) return url;
|
||||||
|
|
||||||
|
// In dev mode, route through the local Go proxy
|
||||||
|
if (typeof import.meta !== 'undefined' && import.meta.env?.DEV) {
|
||||||
|
return `http://localhost:8080/proxy?url=${encodeURIComponent(url)}`;
|
||||||
|
}
|
||||||
|
|
||||||
// Find first working proxy
|
// Find first working proxy
|
||||||
for (let i = 0; i < PROXIES.length; i++) {
|
for (let i = 0; i < PROXIES.length; i++) {
|
||||||
const proxy = PROXIES[(proxyIndex + i) % PROXIES.length];
|
const proxy = PROXIES[(proxyIndex + i) % PROXIES.length];
|
||||||
|
|
|
||||||
|
|
@ -3038,10 +3038,12 @@ export const pwaUpdateSettings = {
|
||||||
|
|
||||||
export const tidalWebSettings = {
|
export const tidalWebSettings = {
|
||||||
PROXY_URL_KEY: 'tidal-web-proxy-url',
|
PROXY_URL_KEY: 'tidal-web-proxy-url',
|
||||||
|
PROXY_FALLBACKS_KEY: 'tidal-web-proxy-fallbacks',
|
||||||
PUBLIC_TOKEN_KEY: 'tidal-web-public-token',
|
PUBLIC_TOKEN_KEY: 'tidal-web-public-token',
|
||||||
COUNTRY_CODE_KEY: 'tidal-web-country-code',
|
COUNTRY_CODE_KEY: 'tidal-web-country-code',
|
||||||
DEFAULT_PUBLIC_TOKEN: '49YxDN9a2aFV6RTG',
|
DEFAULT_PUBLIC_TOKEN: '49YxDN9a2aFV6RTG',
|
||||||
DEFAULT_COUNTRY_CODE: 'US',
|
DEFAULT_COUNTRY_CODE: 'US',
|
||||||
|
DEFAULT_PROXY_FALLBACKS: ['https://api.zarz.moe'],
|
||||||
|
|
||||||
getProxyUrl() {
|
getProxyUrl() {
|
||||||
try {
|
try {
|
||||||
|
|
@ -3053,6 +3055,21 @@ export const tidalWebSettings = {
|
||||||
setProxyUrl(url) {
|
setProxyUrl(url) {
|
||||||
localStorage.setItem(this.PROXY_URL_KEY, url);
|
localStorage.setItem(this.PROXY_URL_KEY, url);
|
||||||
},
|
},
|
||||||
|
getProxyFallbacks() {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(this.PROXY_FALLBACKS_KEY);
|
||||||
|
if (stored) {
|
||||||
|
const parsed = JSON.parse(stored);
|
||||||
|
return Array.isArray(parsed) ? parsed : this.DEFAULT_PROXY_FALLBACKS;
|
||||||
|
}
|
||||||
|
return this.DEFAULT_PROXY_FALLBACKS;
|
||||||
|
} catch {
|
||||||
|
return this.DEFAULT_PROXY_FALLBACKS;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
setProxyFallbacks(fallbacks) {
|
||||||
|
localStorage.setItem(this.PROXY_FALLBACKS_KEY, JSON.stringify(fallbacks));
|
||||||
|
},
|
||||||
getPublicToken() {
|
getPublicToken() {
|
||||||
try {
|
try {
|
||||||
return localStorage.getItem(this.PUBLIC_TOKEN_KEY) || this.DEFAULT_PUBLIC_TOKEN;
|
return localStorage.getItem(this.PUBLIC_TOKEN_KEY) || this.DEFAULT_PUBLIC_TOKEN;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
@ -9,12 +10,14 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
tidalBaseURL = "https://api.tidal.com/v1"
|
tidalBaseURL = "https://api.tidal.com/v1"
|
||||||
defaultPort = ":8080"
|
tidalAuthURL = "https://auth.tidal.com/v1/oauth2/token"
|
||||||
|
defaultPort = ":8080"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
|
|
@ -22,6 +25,8 @@ type Config struct {
|
||||||
RequiredUA string
|
RequiredUA string
|
||||||
AllowAnyQuality bool
|
AllowAnyQuality bool
|
||||||
CountryCode string
|
CountryCode string
|
||||||
|
TidalClientID string
|
||||||
|
TidalSecret string
|
||||||
}
|
}
|
||||||
|
|
||||||
type TrackRequest struct {
|
type TrackRequest struct {
|
||||||
|
|
@ -53,10 +58,17 @@ type ProxyResponse struct {
|
||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type tokenCache struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
token string
|
||||||
|
expiresAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
config Config
|
config Config
|
||||||
client *http.Client
|
client *http.Client
|
||||||
startTime time.Time
|
startTime time.Time
|
||||||
|
appTokenCache tokenCache
|
||||||
)
|
)
|
||||||
|
|
||||||
func loadConfig() Config {
|
func loadConfig() Config {
|
||||||
|
|
@ -65,6 +77,8 @@ func loadConfig() Config {
|
||||||
RequiredUA: getEnv("REQUIRED_UA_PREFIX", "SpotiFLAC-Mobile/"),
|
RequiredUA: getEnv("REQUIRED_UA_PREFIX", "SpotiFLAC-Mobile/"),
|
||||||
AllowAnyQuality: getEnv("ALLOW_ANY_QUALITY", "false") == "true",
|
AllowAnyQuality: getEnv("ALLOW_ANY_QUALITY", "false") == "true",
|
||||||
CountryCode: getEnv("COUNTRY_CODE", "US"),
|
CountryCode: getEnv("COUNTRY_CODE", "US"),
|
||||||
|
TidalClientID: getEnv("TIDAL_CLIENT_ID", ""),
|
||||||
|
TidalSecret: getEnv("TIDAL_CLIENT_SECRET", ""),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -93,6 +107,89 @@ func getBearerToken(r *http.Request) string {
|
||||||
return r.URL.Query().Get("token")
|
return r.URL.Query().Get("token")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getAppToken() (string, error) {
|
||||||
|
appTokenCache.mu.RLock()
|
||||||
|
if appTokenCache.token != "" && time.Now().Before(appTokenCache.expiresAt) {
|
||||||
|
token := appTokenCache.token
|
||||||
|
appTokenCache.mu.RUnlock()
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
appTokenCache.mu.RUnlock()
|
||||||
|
|
||||||
|
appTokenCache.mu.Lock()
|
||||||
|
defer appTokenCache.mu.Unlock()
|
||||||
|
|
||||||
|
if appTokenCache.token != "" && time.Now().Before(appTokenCache.expiresAt) {
|
||||||
|
return appTokenCache.token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.TidalClientID != "" && config.TidalSecret != "" {
|
||||||
|
token, expiresAt, err := fetchAppToken(config.TidalClientID, config.TidalSecret)
|
||||||
|
if err == nil {
|
||||||
|
appTokenCache.token = token
|
||||||
|
appTokenCache.expiresAt = expiresAt
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
log.Printf("Failed to get app token with configured credentials: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: try known Tidal client credentials
|
||||||
|
clients := []struct {
|
||||||
|
id string
|
||||||
|
secret string
|
||||||
|
}{
|
||||||
|
// PKCE credentials (Hi-Res capable) from tidalapi — may still be valid
|
||||||
|
{"6BDSRdpK9hqEBTgU", "xeuPmY7nbpZ9IIbLAcQ93shka1VNheUAqN6IcszjTG8="},
|
||||||
|
{"fX2JxdmntZWK0ixT", "1Nn9AfDAjxrgJFJbKNWLeAyKGVGmINuXPPLHVXAvxAg="},
|
||||||
|
{"7m7Ap0JC9j1cOM3n", "caDRxBDSx9WtZz7nH9Mz8uSiVn6FL7dP"},
|
||||||
|
{"zU4XHVVkc2tDPo4t", "VJKhYqM4Cj5z1TNnX1hB8eKhE3w7LxvO9Qp2RsJ6"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range clients {
|
||||||
|
token, expiresAt, err := fetchAppToken(c.id, c.secret)
|
||||||
|
if err == nil {
|
||||||
|
appTokenCache.token = token
|
||||||
|
appTokenCache.expiresAt = expiresAt
|
||||||
|
log.Printf("Got app token with client %s", c.id[:4]+"...")
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", fmt.Errorf("all client credentials failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchAppToken(clientID, secret string) (string, time.Time, error) {
|
||||||
|
creds := base64.StdEncoding.EncodeToString([]byte(clientID + ":" + secret))
|
||||||
|
|
||||||
|
req, err := http.NewRequest("POST", tidalAuthURL, strings.NewReader("grant_type=client_credentials"))
|
||||||
|
if err != nil {
|
||||||
|
return "", time.Time{}, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Basic "+creds)
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", time.Time{}, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
return "", time.Time{}, fmt.Errorf("auth failed: %d %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
ExpiresIn int `json:"expires_in"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||||
|
return "", time.Time{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.AccessToken, time.Now().Add(time.Duration(result.ExpiresIn-60) * time.Second), nil
|
||||||
|
}
|
||||||
|
|
||||||
func callTidalAPI(endpoint string, token string, params map[string]string) (*http.Response, error) {
|
func callTidalAPI(endpoint string, token string, params map[string]string) (*http.Response, error) {
|
||||||
req, err := http.NewRequest("GET", endpoint, nil)
|
req, err := http.NewRequest("GET", endpoint, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -106,12 +203,36 @@ func callTidalAPI(endpoint string, token string, params map[string]string) (*htt
|
||||||
}
|
}
|
||||||
req.URL.RawQuery = q.Encode()
|
req.URL.RawQuery = q.Encode()
|
||||||
|
|
||||||
req.Header.Set("Authorization", "Bearer "+token)
|
if token != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
} else {
|
||||||
|
appToken, err := getAppToken()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("no valid token: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+appToken)
|
||||||
|
}
|
||||||
|
|
||||||
req.Header.Set("User-Agent", "TIDAL_ANDROID/1479 okhttp/3.14.9")
|
req.Header.Set("User-Agent", "TIDAL_ANDROID/1479 okhttp/3.14.9")
|
||||||
|
|
||||||
return client.Do(req)
|
return client.Do(req)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func corsMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
|
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||||
|
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, User-Agent, X-Tidal-Token, Authorization")
|
||||||
|
|
||||||
|
if r.Method == "OPTIONS" {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
next(w, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func handleDownloadTrack(w http.ResponseWriter, r *http.Request) {
|
func handleDownloadTrack(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
writeJSON(w, http.StatusMethodNotAllowed, ProxyResponse{Success: false, Error: "Method not allowed"})
|
writeJSON(w, http.StatusMethodNotAllowed, ProxyResponse{Success: false, Error: "Method not allowed"})
|
||||||
|
|
@ -137,39 +258,100 @@ func handleDownloadTrack(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
token := getBearerToken(r)
|
userToken := getBearerToken(r)
|
||||||
if token == "" {
|
|
||||||
writeJSON(w, http.StatusUnauthorized, ProxyResponse{
|
|
||||||
Success: false,
|
|
||||||
Error: "No Tidal token provided. Send your Tidal access token via Authorization: Bearer <token> or X-Tidal-Token header",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
quality := normalizeQuality(req.Quality)
|
quality := normalizeQuality(req.Quality)
|
||||||
|
|
||||||
playbackInfo, err := getPlaybackInfo(req.ID, quality, token)
|
// Try direct Tidal API first (uses fallback credentials if needed)
|
||||||
if err != nil {
|
playbackInfo, apiErr := getPlaybackInfo(req.ID, quality, userToken)
|
||||||
log.Printf("Error getting playback info for track %s: %v", req.ID, err)
|
if apiErr == nil {
|
||||||
writeJSON(w, http.StatusBadGateway, ProxyResponse{
|
// Reject PREVIEW (30s demo) — fall through to zarz.moe for full track
|
||||||
Success: false,
|
if strings.EqualFold(playbackInfo.AssetPresentation, "PREVIEW") {
|
||||||
Error: fmt.Sprintf("Failed to get playback info: %v", err),
|
log.Printf("Direct Tidal API returned PREVIEW for track %s, falling back to api.zarz.moe", req.ID)
|
||||||
})
|
} else {
|
||||||
return
|
writeResponse(w, playbackInfo)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.Printf("Direct Tidal API failed for track %s: %v, falling back to api.zarz.moe", req.ID, apiErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fallback: forward to api.zarz.moe with retries
|
||||||
|
var lastErr error
|
||||||
|
for attempt := 0; attempt < 3; attempt++ {
|
||||||
|
if attempt > 0 {
|
||||||
|
time.Sleep(time.Duration(attempt) * time.Second)
|
||||||
|
}
|
||||||
|
respBody, statusCode, err := forwardToZarz(req.ID, quality)
|
||||||
|
if err == nil {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(statusCode)
|
||||||
|
w.Write(respBody)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lastErr = err
|
||||||
|
// Don't retry on rate limits (retry_after is typically long)
|
||||||
|
if strings.Contains(err.Error(), "cooldown") || strings.Contains(err.Error(), "retry_after") {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("All zarz.moe attempts failed for track %s: %v", req.ID, lastErr)
|
||||||
|
writeJSON(w, http.StatusBadGateway, ProxyResponse{
|
||||||
|
Success: false,
|
||||||
|
Error: fmt.Sprintf("Failed to forward: %v", lastErr),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func forwardToZarz(trackID, quality string) ([]byte, int, error) {
|
||||||
|
body := map[string]interface{}{
|
||||||
|
"id": trackID,
|
||||||
|
"quality": quality,
|
||||||
|
}
|
||||||
|
bodyBytes, _ := json.Marshal(body)
|
||||||
|
|
||||||
|
req, err := http.NewRequest("POST", "https://api.zarz.moe/v1/dl/tid2", bytes.NewReader(bodyBytes))
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("User-Agent", "SpotiFLAC-Mobile/4.5.5")
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
respBody, _ := io.ReadAll(resp.Body)
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
// Try to parse as the new format (success field)
|
||||||
|
var result struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(respBody, &result) == nil && !result.Success && result.Error != "" {
|
||||||
|
return nil, resp.StatusCode, fmt.Errorf("api.zarz.moe: %s", result.Error)
|
||||||
|
}
|
||||||
|
return nil, resp.StatusCode, fmt.Errorf("api.zarz.moe returned %d: %s", resp.StatusCode, string(respBody))
|
||||||
|
}
|
||||||
|
|
||||||
|
return respBody, resp.StatusCode, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeResponse(w http.ResponseWriter, info *TidalPlaybackInfo) {
|
||||||
if !config.AllowAnyQuality {
|
if !config.AllowAnyQuality {
|
||||||
rq := playbackInfo.AudioQuality
|
rq := info.AudioQuality
|
||||||
if rq == "LOW" || rq == "HIGH" {
|
if rq == "LOW" || rq == "HIGH" {
|
||||||
writeJSON(w, http.StatusBadGateway, ProxyResponse{
|
writeJSON(w, http.StatusBadGateway, ProxyResponse{
|
||||||
Success: false,
|
Success: false,
|
||||||
Error: fmt.Sprintf("Requested %s quality but Tidal returned %s. This may indicate the track doesn't support lossless or your account lacks premium access.", quality, rq),
|
Error: fmt.Sprintf("Requested lossless but got %s. Track may not support it.", rq),
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dataBytes, err := json.Marshal(playbackInfo)
|
dataBytes, err := json.Marshal(info)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeJSON(w, http.StatusInternalServerError, ProxyResponse{Success: false, Error: "Failed to encode response"})
|
writeJSON(w, http.StatusInternalServerError, ProxyResponse{Success: false, Error: "Failed to encode response"})
|
||||||
return
|
return
|
||||||
|
|
@ -195,8 +377,9 @@ func normalizeQuality(quality string) string {
|
||||||
|
|
||||||
func getPlaybackInfo(trackID, quality, token string) (*TidalPlaybackInfo, error) {
|
func getPlaybackInfo(trackID, quality, token string) (*TidalPlaybackInfo, error) {
|
||||||
params := map[string]string{
|
params := map[string]string{
|
||||||
"playbackMode": "STREAM",
|
"playbackMode": "STREAM",
|
||||||
"audioquality": quality,
|
"audioquality": quality,
|
||||||
|
"assetpresentation": "FULL",
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := callTidalAPI(
|
resp, err := callTidalAPI(
|
||||||
|
|
@ -262,21 +445,207 @@ func extractFormatsFromManifest(manifest, mimeType string) []interface{} {
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleHealth(w http.ResponseWriter, r *http.Request) {
|
func handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||||
writeJSON(w, http.StatusOK, map[string]string{
|
hasToken := config.TidalClientID != "" || appTokenCache.token != ""
|
||||||
"status": "ok",
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
"uptime": time.Since(startTime).String(),
|
"status": "ok",
|
||||||
|
"uptime": time.Since(startTime).String(),
|
||||||
|
"hasToken": hasToken,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleAuth(w http.ResponseWriter, r *http.Request) {
|
func handleAuth(w http.ResponseWriter, r *http.Request) {
|
||||||
writeJSON(w, http.StatusOK, map[string]string{
|
if r.Method != http.MethodPost {
|
||||||
"status": "ok",
|
writeJSON(w, http.StatusMethodNotAllowed, ProxyResponse{Success: false, Error: "Send POST with Tidal credentials"})
|
||||||
"message": "Send your Tidal token via Authorization: Bearer <token> or X-Tidal-Token header",
|
return
|
||||||
})
|
}
|
||||||
|
|
||||||
|
var creds struct {
|
||||||
|
ClientID string `json:"client_id"`
|
||||||
|
ClientSecret string `json:"client_secret"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&creds); err != nil {
|
||||||
|
writeJSON(w, http.StatusBadRequest, ProxyResponse{Success: false, Error: "Invalid request"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if creds.ClientID != "" && creds.ClientSecret != "" {
|
||||||
|
token, expiresAt, err := fetchAppToken(creds.ClientID, creds.ClientSecret)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusBadGateway, ProxyResponse{Success: false, Error: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
appTokenCache.mu.Lock()
|
||||||
|
appTokenCache.token = token
|
||||||
|
appTokenCache.expiresAt = expiresAt
|
||||||
|
appTokenCache.mu.Unlock()
|
||||||
|
|
||||||
|
config.TidalClientID = creds.ClientID
|
||||||
|
config.TidalSecret = creds.ClientSecret
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, ProxyResponse{Success: true, Data: "Credentials accepted"})
|
||||||
|
} else {
|
||||||
|
writeJSON(w, http.StatusBadRequest, ProxyResponse{Success: false, Error: "client_id and client_secret required"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type QobuzRequest struct {
|
||||||
|
TrackID string `json:"trackId"`
|
||||||
|
Quality string `json:"quality"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleDownloadQobuz(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
writeJSON(w, http.StatusMethodNotAllowed, ProxyResponse{Success: false, Error: "Method not allowed"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !validateUA(r) {
|
||||||
|
writeJSON(w, http.StatusForbidden, ProxyResponse{
|
||||||
|
Success: false,
|
||||||
|
Error: "Invalid user agent",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
trackID := r.URL.Query().Get("trackId")
|
||||||
|
if trackID == "" {
|
||||||
|
writeJSON(w, http.StatusBadRequest, ProxyResponse{Success: false, Error: "trackId is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
quality := r.URL.Query().Get("quality")
|
||||||
|
if quality == "" {
|
||||||
|
quality = "HI_RES_LOSSLESS"
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest("GET", "https://api.zarz.moe/v1/dl/qbz", r.Body)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusBadGateway, ProxyResponse{Success: false, Error: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
q := req.URL.Query()
|
||||||
|
q.Set("trackId", trackID)
|
||||||
|
q.Set("quality", quality)
|
||||||
|
req.URL.RawQuery = q.Encode()
|
||||||
|
|
||||||
|
req.Header.Set("User-Agent", r.Header.Get("User-Agent"))
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusBadGateway, ProxyResponse{Success: false, Error: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(resp.StatusCode)
|
||||||
|
w.Write(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
type AmazonRequest struct {
|
||||||
|
ASIN string `json:"asin"`
|
||||||
|
Codec string `json:"codec"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleDownloadAmazon(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
writeJSON(w, http.StatusMethodNotAllowed, ProxyResponse{Success: false, Error: "Method not allowed"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !validateUA(r) {
|
||||||
|
writeJSON(w, http.StatusForbidden, ProxyResponse{
|
||||||
|
Success: false,
|
||||||
|
Error: "Invalid user agent",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
asin := r.URL.Query().Get("asin")
|
||||||
|
if asin == "" {
|
||||||
|
writeJSON(w, http.StatusBadRequest, ProxyResponse{Success: false, Error: "asin is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
codec := r.URL.Query().Get("codec")
|
||||||
|
if codec == "" {
|
||||||
|
codec = "flac"
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest("GET", "https://api.zarz.moe/v1/dl/amazeamazeamaze/media", nil)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusBadGateway, ProxyResponse{Success: false, Error: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
q := req.URL.Query()
|
||||||
|
q.Set("asin", asin)
|
||||||
|
q.Set("codec", codec)
|
||||||
|
req.URL.RawQuery = q.Encode()
|
||||||
|
|
||||||
|
req.Header.Set("User-Agent", r.Header.Get("User-Agent"))
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusBadGateway, ProxyResponse{Success: false, Error: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(resp.StatusCode)
|
||||||
|
w.Write(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleProxy(w http.ResponseWriter, r *http.Request) {
|
||||||
|
targetURL := r.URL.Query().Get("url")
|
||||||
|
if targetURL == "" {
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "url parameter required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest(r.Method, targetURL, r.Body)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forward relevant headers
|
||||||
|
for _, h := range []string{"Range", "If-None-Match", "If-Modified-Since"} {
|
||||||
|
if v := r.Header.Get(h); v != "" {
|
||||||
|
req.Header.Set(h, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
// Copy response headers
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
|
w.Header().Set("Access-Control-Expose-Headers", "Content-Length, Content-Range, Accept-Ranges")
|
||||||
|
for _, h := range []string{"Content-Type", "Content-Length", "Content-Range", "Accept-Ranges", "Cache-Control", "ETag"} {
|
||||||
|
if v := resp.Header.Get(h); v != "" {
|
||||||
|
w.Header().Set(h, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(resp.StatusCode)
|
||||||
|
io.Copy(w, resp.Body)
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
|
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
w.WriteHeader(status)
|
w.WriteHeader(status)
|
||||||
json.NewEncoder(w).Encode(v)
|
json.NewEncoder(w).Encode(v)
|
||||||
}
|
}
|
||||||
|
|
@ -289,15 +658,35 @@ func main() {
|
||||||
Timeout: 60 * time.Second,
|
Timeout: 60 * time.Second,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Try to get an initial app token on startup
|
||||||
|
go func() {
|
||||||
|
if config.TidalClientID != "" && config.TidalSecret != "" {
|
||||||
|
token, expiresAt, err := fetchAppToken(config.TidalClientID, config.TidalSecret)
|
||||||
|
if err == nil {
|
||||||
|
appTokenCache.mu.Lock()
|
||||||
|
appTokenCache.token = token
|
||||||
|
appTokenCache.expiresAt = expiresAt
|
||||||
|
appTokenCache.mu.Unlock()
|
||||||
|
log.Println("Initial app token acquired")
|
||||||
|
} else {
|
||||||
|
log.Printf("Failed to get initial app token: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.HandleFunc("/v1/dl/tid2", handleDownloadTrack)
|
mux.HandleFunc("/v1/dl/tid2", corsMiddleware(handleDownloadTrack))
|
||||||
mux.HandleFunc("/health", handleHealth)
|
mux.HandleFunc("/v1/dl/qbz", corsMiddleware(handleDownloadQobuz))
|
||||||
mux.HandleFunc("/auth", handleAuth)
|
mux.HandleFunc("/v1/dl/amazeamazeamaze/media", corsMiddleware(handleDownloadAmazon))
|
||||||
|
mux.HandleFunc("/health", corsMiddleware(handleHealth))
|
||||||
|
mux.HandleFunc("/auth", corsMiddleware(handleAuth))
|
||||||
|
mux.HandleFunc("/proxy", corsMiddleware(handleProxy))
|
||||||
|
|
||||||
log.Printf("Starting Tidal proxy server on %s", config.Port)
|
log.Printf("Starting Tidal proxy server on %s", config.Port)
|
||||||
log.Printf("Required UA prefix: %s", config.RequiredUA)
|
log.Printf("Required UA prefix: %s", config.RequiredUA)
|
||||||
log.Printf("Allow any quality: %v", config.AllowAnyQuality)
|
log.Printf("Allow any quality: %v", config.AllowAnyQuality)
|
||||||
log.Printf("Country code: %s", config.CountryCode)
|
log.Printf("Country code: %s", config.CountryCode)
|
||||||
|
log.Printf("Has Tidal credentials: %v", config.TidalClientID != "")
|
||||||
|
|
||||||
if err := http.ListenAndServe(config.Port, mux); err != nil {
|
if err := http.ListenAndServe(config.Port, mux); err != nil {
|
||||||
log.Fatalf("Server failed: %v", err)
|
log.Fatalf("Server failed: %v", err)
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -73,6 +73,11 @@ export default defineConfig((_options) => {
|
||||||
target: 'http://localhost:8080',
|
target: 'http://localhost:8080',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
rewrite: (path) => path.replace(/^\/tidal-proxy/, ''),
|
rewrite: (path) => path.replace(/^\/tidal-proxy/, ''),
|
||||||
|
configure: (proxy) => {
|
||||||
|
proxy.on('proxyReq', (proxyReq) => {
|
||||||
|
proxyReq.setHeader('User-Agent', 'SpotiFLAC-Mobile/4.5.5');
|
||||||
|
});
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue