diff --git a/js/api.js b/js/api.js index f2aa50e..3cdb64a 100644 --- a/js/api.js +++ b/js/api.js @@ -1495,50 +1495,102 @@ export class LosslessAPI { 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; - if (import.meta.env.DEV && proxyUrl.startsWith('http://localhost')) { - baseUrl = '/tidal-proxy'; + if (proxyUrl.startsWith('http://localhost') || proxyUrl.startsWith('http://127.0.0.1')) { + baseUrl = proxyUrl.replace(/\/+$/g, ''); } else { baseUrl = proxyUrl.replace(/\/+$/g, ''); } - const isSelfHosted = baseUrl.includes('localhost') || baseUrl.includes('127.0.0.1'); + const fallbacks = tidalWebSettings.getProxyFallbacks(); - const headers = { - 'Content-Type': 'application/json', - 'User-Agent': 'SpotiFLAC-Mobile/4.5.5', - }; + try { + return await tryProxyRequest(baseUrl); + } catch (primaryError) { + console.warn(`[proxy] Primary proxy failed: ${primaryError.message}, trying ${fallbacks.length} fallbacks`); - if (isSelfHosted) { - const token = tidalWebSettings.getPublicToken(); - if (token) { - headers['X-Tidal-Token'] = token; + for (const fallback of fallbacks) { + if (fallback === proxyUrl) continue; + + 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; - - 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; + throw new Error('All proxy endpoints failed'); } #extractStreamUrlFromProxyResponse(data) { diff --git a/js/proxy-utils.js b/js/proxy-utils.js index 1e00385..0b97558 100644 --- a/js/proxy-utils.js +++ b/js/proxy-utils.js @@ -18,6 +18,11 @@ export const getProxyUrl = (url) => { if (url.startsWith('blob:')) 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 for (let i = 0; i < PROXIES.length; i++) { const proxy = PROXIES[(proxyIndex + i) % PROXIES.length]; diff --git a/js/storage.js b/js/storage.js index 6e72c20..5964f6c 100644 --- a/js/storage.js +++ b/js/storage.js @@ -3038,10 +3038,12 @@ export const pwaUpdateSettings = { export const tidalWebSettings = { PROXY_URL_KEY: 'tidal-web-proxy-url', + PROXY_FALLBACKS_KEY: 'tidal-web-proxy-fallbacks', PUBLIC_TOKEN_KEY: 'tidal-web-public-token', COUNTRY_CODE_KEY: 'tidal-web-country-code', DEFAULT_PUBLIC_TOKEN: '49YxDN9a2aFV6RTG', DEFAULT_COUNTRY_CODE: 'US', + DEFAULT_PROXY_FALLBACKS: ['https://api.zarz.moe'], getProxyUrl() { try { @@ -3053,6 +3055,21 @@ export const tidalWebSettings = { setProxyUrl(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() { try { return localStorage.getItem(this.PUBLIC_TOKEN_KEY) || this.DEFAULT_PUBLIC_TOKEN; diff --git a/tidal-proxy/main.go b/tidal-proxy/main.go index edf9547..6b544bb 100644 --- a/tidal-proxy/main.go +++ b/tidal-proxy/main.go @@ -1,6 +1,7 @@ package main import ( + "bytes" "encoding/base64" "encoding/json" "fmt" @@ -9,12 +10,14 @@ import ( "net/http" "os" "strings" + "sync" "time" ) const ( - tidalBaseURL = "https://api.tidal.com/v1" - defaultPort = ":8080" + tidalBaseURL = "https://api.tidal.com/v1" + tidalAuthURL = "https://auth.tidal.com/v1/oauth2/token" + defaultPort = ":8080" ) type Config struct { @@ -22,6 +25,8 @@ type Config struct { RequiredUA string AllowAnyQuality bool CountryCode string + TidalClientID string + TidalSecret string } type TrackRequest struct { @@ -53,10 +58,17 @@ type ProxyResponse struct { Error string `json:"error,omitempty"` } +type tokenCache struct { + mu sync.RWMutex + token string + expiresAt time.Time +} + var ( - config Config - client *http.Client - startTime time.Time + config Config + client *http.Client + startTime time.Time + appTokenCache tokenCache ) func loadConfig() Config { @@ -65,6 +77,8 @@ func loadConfig() Config { RequiredUA: getEnv("REQUIRED_UA_PREFIX", "SpotiFLAC-Mobile/"), AllowAnyQuality: getEnv("ALLOW_ANY_QUALITY", "false") == "true", 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") } +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) { req, err := http.NewRequest("GET", endpoint, 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.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") 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) { if r.Method != http.MethodPost { writeJSON(w, http.StatusMethodNotAllowed, ProxyResponse{Success: false, Error: "Method not allowed"}) @@ -137,39 +258,100 @@ func handleDownloadTrack(w http.ResponseWriter, r *http.Request) { return } - token := getBearerToken(r) - if token == "" { - writeJSON(w, http.StatusUnauthorized, ProxyResponse{ - Success: false, - Error: "No Tidal token provided. Send your Tidal access token via Authorization: Bearer or X-Tidal-Token header", - }) - return - } - + userToken := getBearerToken(r) quality := normalizeQuality(req.Quality) - playbackInfo, err := getPlaybackInfo(req.ID, quality, token) - if err != nil { - log.Printf("Error getting playback info for track %s: %v", req.ID, err) - writeJSON(w, http.StatusBadGateway, ProxyResponse{ - Success: false, - Error: fmt.Sprintf("Failed to get playback info: %v", err), - }) - return + // Try direct Tidal API first (uses fallback credentials if needed) + playbackInfo, apiErr := getPlaybackInfo(req.ID, quality, userToken) + if apiErr == nil { + // Reject PREVIEW (30s demo) — fall through to zarz.moe for full track + if strings.EqualFold(playbackInfo.AssetPresentation, "PREVIEW") { + log.Printf("Direct Tidal API returned PREVIEW for track %s, falling back to api.zarz.moe", req.ID) + } else { + 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 { - rq := playbackInfo.AudioQuality + rq := info.AudioQuality if rq == "LOW" || rq == "HIGH" { writeJSON(w, http.StatusBadGateway, ProxyResponse{ 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 } } - dataBytes, err := json.Marshal(playbackInfo) + dataBytes, err := json.Marshal(info) if err != nil { writeJSON(w, http.StatusInternalServerError, ProxyResponse{Success: false, Error: "Failed to encode response"}) return @@ -195,8 +377,9 @@ func normalizeQuality(quality string) string { func getPlaybackInfo(trackID, quality, token string) (*TidalPlaybackInfo, error) { params := map[string]string{ - "playbackMode": "STREAM", - "audioquality": quality, + "playbackMode": "STREAM", + "audioquality": quality, + "assetpresentation": "FULL", } resp, err := callTidalAPI( @@ -262,21 +445,207 @@ func extractFormatsFromManifest(manifest, mimeType string) []interface{} { } func handleHealth(w http.ResponseWriter, r *http.Request) { - writeJSON(w, http.StatusOK, map[string]string{ - "status": "ok", - "uptime": time.Since(startTime).String(), + hasToken := config.TidalClientID != "" || appTokenCache.token != "" + writeJSON(w, http.StatusOK, map[string]interface{}{ + "status": "ok", + "uptime": time.Since(startTime).String(), + "hasToken": hasToken, }) } func handleAuth(w http.ResponseWriter, r *http.Request) { - writeJSON(w, http.StatusOK, map[string]string{ - "status": "ok", - "message": "Send your Tidal token via Authorization: Bearer or X-Tidal-Token header", - }) + if r.Method != http.MethodPost { + writeJSON(w, http.StatusMethodNotAllowed, ProxyResponse{Success: false, Error: "Send POST with Tidal credentials"}) + 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{}) { w.Header().Set("Content-Type", "application/json") + w.Header().Set("Access-Control-Allow-Origin", "*") w.WriteHeader(status) json.NewEncoder(w).Encode(v) } @@ -289,15 +658,35 @@ func main() { 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.HandleFunc("/v1/dl/tid2", handleDownloadTrack) - mux.HandleFunc("/health", handleHealth) - mux.HandleFunc("/auth", handleAuth) + mux.HandleFunc("/v1/dl/tid2", corsMiddleware(handleDownloadTrack)) + mux.HandleFunc("/v1/dl/qbz", corsMiddleware(handleDownloadQobuz)) + 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("Required UA prefix: %s", config.RequiredUA) log.Printf("Allow any quality: %v", config.AllowAnyQuality) log.Printf("Country code: %s", config.CountryCode) + log.Printf("Has Tidal credentials: %v", config.TidalClientID != "") if err := http.ListenAndServe(config.Port, mux); err != nil { log.Fatalf("Server failed: %v", err) diff --git a/tidal-proxy/tidal-proxy.exe~ b/tidal-proxy/tidal-proxy.exe~ index e5822a3..0e664c4 100644 Binary files a/tidal-proxy/tidal-proxy.exe~ and b/tidal-proxy/tidal-proxy.exe~ differ diff --git a/vite.config.ts b/vite.config.ts index f87d79d..e591244 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -73,6 +73,11 @@ export default defineConfig((_options) => { target: 'http://localhost:8080', changeOrigin: true, rewrite: (path) => path.replace(/^\/tidal-proxy/, ''), + configure: (proxy) => { + proxy.on('proxyReq', (proxyReq) => { + proxyReq.setHeader('User-Agent', 'SpotiFLAC-Mobile/4.5.5'); + }); + }, }, }, },