diff --git a/js/api.js b/js/api.js index 932628b..f2aa50e 100644 --- a/js/api.js +++ b/js/api.js @@ -1502,15 +1502,25 @@ export class LosslessAPI { baseUrl = proxyUrl.replace(/\/+$/g, ''); } - // api.zarz.moe only accepts lossless quality formats; upgrade HIGH/LOW to LOSSLESS + 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 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', - }, + headers, body: JSON.stringify({ id: String(id), quality: proxyQuality }), signal, }); @@ -1522,7 +1532,6 @@ export class LosslessAPI { 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') { diff --git a/tidal-proxy/.env.example b/tidal-proxy/.env.example new file mode 100644 index 0000000..7b97d2a --- /dev/null +++ b/tidal-proxy/.env.example @@ -0,0 +1,17 @@ +# Tidal proxy server configuration + +# Server port (default: :8080) +PORT=:8080 + +# Required User-Agent prefix for API requests +# Only requests with UA starting with this prefix will be accepted +REQUIRED_UA_PREFIX=SpotiFLAC-Mobile/ + +# Allow any quality level in responses +# If false, the proxy rejects LOW/HIGH responses from Tidal +ALLOW_ANY_QUALITY=false + +# Optional: Tidal API credentials +# If not set, the proxy uses a known public client ID +# TIDAL_CLIENT_ID= +# TIDAL_CLIENT_SECRET= diff --git a/tidal-proxy/.gitignore b/tidal-proxy/.gitignore new file mode 100644 index 0000000..05bf621 --- /dev/null +++ b/tidal-proxy/.gitignore @@ -0,0 +1,4 @@ +tidal-proxy +tidal-proxy.exe +*.log +.env diff --git a/tidal-proxy/Dockerfile b/tidal-proxy/Dockerfile new file mode 100644 index 0000000..c0247fc --- /dev/null +++ b/tidal-proxy/Dockerfile @@ -0,0 +1,20 @@ +FROM golang:1.22-alpine AS builder + +WORKDIR /app +COPY . . +RUN go build -ldflags="-s -w" -o tidal-proxy . + +FROM alpine:3.19 + +RUN apk --no-cache add ca-certificates + +WORKDIR /app +COPY --from=builder /app/tidal-proxy . + +EXPOSE 8080 + +ENV PORT=:8080 +ENV REQUIRED_UA_PREFIX="SpotiFLAC-Mobile/" +ENV ALLOW_ANY_QUALITY=false + +CMD ["./tidal-proxy"] diff --git a/tidal-proxy/README.md b/tidal-proxy/README.md new file mode 100644 index 0000000..9a6efaa --- /dev/null +++ b/tidal-proxy/README.md @@ -0,0 +1,127 @@ +# Tidal Proxy Server + +Self-hosted proxy for Tidal API that provides lossless audio manifests. Compatible with Monochrome and SpotiFLAC-Mobile clients. + +## Features + +- Proxy Tidal playback info requests (`/v1/dl/tid2`) +- UA validation (compatible with SpotiFLAC-Mobile format) +- Quality enforcement (rejects LOW/HIGH responses when lossless requested) +- Format extraction from DASH manifests +- Health check endpoint + +## Requirements + +- Go 1.21+ (for building from source) +- Docker (optional, for containerized deployment) +- A valid Tidal access token (from user login) + +## Quick Start + +### Run with Go + +```bash +cd tidal-proxy +go build -o tidal-proxy +./tidal-proxy +``` + +### Run with Docker + +```bash +docker compose up -d +``` + +### Run with Docker (custom config) + +```bash +docker run -d \ + -p 8080:8080 \ + -e REQUIRED_UA_PREFIX="SpotiFLAC-Mobile/" \ + -e ALLOW_ANY_QUALITY=false \ + -e COUNTRY_CODE=US \ + tidal-proxy +``` + +## Configuration + +| Environment Variable | Default | Description | +|---------------------|---------|-------------| +| `PORT` | `:8080` | Server listen address | +| `REQUIRED_UA_PREFIX` | `SpotiFLAC-Mobile/` | Required User-Agent prefix | +| `ALLOW_ANY_QUALITY` | `false` | If true, don't reject LOW/HIGH responses | +| `COUNTRY_CODE` | `US` | Tidal country code for requests | + +## API Endpoints + +### `POST /v1/dl/tid2` + +Get track playback info and manifest. + +**Headers:** +- `User-Agent`: Must start with `REQUIRED_UA_PREFIX` (e.g., `SpotiFLAC-Mobile/4.5.5`) +- `Authorization`: `Bearer ` (or use `X-Tidal-Token` header) + +**Body:** +```json +{ + "id": "356965156", + "quality": "LOSSLESS" +} +``` + +**Quality values:** `LOW`, `HIGH`, `LOSSLESS`, `HI_RES_LOSSLESS` + +**Response:** +```json +{ + "success": true, + "data": "{\"trackId\":356965156,\"audioQuality\":\"LOSSLESS\",\"manifest\":\"...\",\"formats\":[\"FLAC\"]}" +} +``` + +### `GET /health` + +Health check endpoint. + +**Response:** +```json +{ + "status": "ok", + "uptime": "2h30m15s" +} +``` + +### `GET /auth` + +Returns auth instructions. + +## Getting a Tidal Token + +This proxy requires a valid Tidal access token. You can obtain one by: + +1. **Using the Tidal web app**: Open browser dev tools while logged into Tidal, find the `Authorization` header from any API request +2. **Using OAuth**: Register a Tidal developer app at https://developer.tidal.com and use the PKCE flow +3. **From Monochrome**: The token is stored in your browser's localStorage after login + +**Important:** Tokens expire. The proxy does not handle token refresh - you need to provide a valid token with each request. + +## Integration with Monochrome + +Update your Monochrome settings to use the self-hosted proxy: + +1. Go to Settings > Tidal Web +2. Set Proxy URL to `http://localhost:8080` +3. The proxy will use your existing Tidal token from Monochrome's localStorage + +Or programmatically: +```javascript +tidalWebSettings.setProxyUrl('http://localhost:8080'); +``` + +## Security Notes + +- **Never expose this proxy publicly** without proper authentication +- The proxy forwards your Tidal token to Tidal's API +- UA validation provides minimal protection - consider adding API keys for production use +- Tokens are not logged or stored by the proxy diff --git a/tidal-proxy/docker-compose.yml b/tidal-proxy/docker-compose.yml new file mode 100644 index 0000000..8602dd6 --- /dev/null +++ b/tidal-proxy/docker-compose.yml @@ -0,0 +1,13 @@ +services: + tidal-proxy: + build: . + ports: + - "${PORT:-8080}:8080" + env_file: + - .env + restart: unless-stopped + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/health"] + interval: 30s + timeout: 5s + retries: 3 diff --git a/tidal-proxy/go.mod b/tidal-proxy/go.mod new file mode 100644 index 0000000..7276958 --- /dev/null +++ b/tidal-proxy/go.mod @@ -0,0 +1,3 @@ +module tidal-proxy + +go 1.26.3 diff --git a/tidal-proxy/main.go b/tidal-proxy/main.go new file mode 100644 index 0000000..edf9547 --- /dev/null +++ b/tidal-proxy/main.go @@ -0,0 +1,305 @@ +package main + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os" + "strings" + "time" +) + +const ( + tidalBaseURL = "https://api.tidal.com/v1" + defaultPort = ":8080" +) + +type Config struct { + Port string + RequiredUA string + AllowAnyQuality bool + CountryCode string +} + +type TrackRequest struct { + ID string `json:"id"` + Quality string `json:"quality"` +} + +type TidalPlaybackInfo struct { + TrackID int `json:"trackId"` + AssetPresentation string `json:"assetPresentation"` + AudioQuality string `json:"audioQuality"` + AudioMode string `json:"audioMode"` + ManifestMimeType string `json:"manifestMimeType"` + ManifestHash string `json:"manifestHash"` + Manifest string `json:"manifest"` + BitDepth int `json:"bitDepth,omitempty"` + SampleRate int `json:"sampleRate,omitempty"` + ReplayGain float64 `json:"replayGain"` + AlbumReplayGain float64 `json:"albumReplayGain"` + TrackPeakAmplitude float64 `json:"trackPeakAmplitude"` + AlbumPeakAmplitude float64 `json:"albumPeakAmplitude"` + DRMData interface{} `json:"drmData"` + Formats []interface{} `json:"formats"` +} + +type ProxyResponse struct { + Success bool `json:"success"` + Data string `json:"data,omitempty"` + Error string `json:"error,omitempty"` +} + +var ( + config Config + client *http.Client + startTime time.Time +) + +func loadConfig() Config { + return Config{ + Port: getEnv("PORT", defaultPort), + RequiredUA: getEnv("REQUIRED_UA_PREFIX", "SpotiFLAC-Mobile/"), + AllowAnyQuality: getEnv("ALLOW_ANY_QUALITY", "false") == "true", + CountryCode: getEnv("COUNTRY_CODE", "US"), + } +} + +func getEnv(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} + +func validateUA(r *http.Request) bool { + ua := r.Header.Get("User-Agent") + if ua == "" { + return false + } + return strings.HasPrefix(ua, config.RequiredUA) +} + +func getBearerToken(r *http.Request) string { + if auth := r.Header.Get("Authorization"); auth != "" { + return strings.TrimPrefix(auth, "Bearer ") + } + if token := r.Header.Get("X-Tidal-Token"); token != "" { + return token + } + return r.URL.Query().Get("token") +} + +func callTidalAPI(endpoint string, token string, params map[string]string) (*http.Response, error) { + req, err := http.NewRequest("GET", endpoint, nil) + if err != nil { + return nil, err + } + + q := req.URL.Query() + q.Set("countryCode", config.CountryCode) + for k, v := range params { + q.Set(k, v) + } + req.URL.RawQuery = q.Encode() + + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("User-Agent", "TIDAL_ANDROID/1479 okhttp/3.14.9") + + return client.Do(req) +} + +func handleDownloadTrack(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + 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. Use the original app User-Agent in the format AppName/Version", + }) + return + } + + var req TrackRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, ProxyResponse{Success: false, Error: "Invalid request body"}) + return + } + + if req.ID == "" { + writeJSON(w, http.StatusBadRequest, ProxyResponse{Success: false, Error: "Track ID is required"}) + 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 + } + + 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 + } + + if !config.AllowAnyQuality { + rq := playbackInfo.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), + }) + return + } + } + + dataBytes, err := json.Marshal(playbackInfo) + if err != nil { + writeJSON(w, http.StatusInternalServerError, ProxyResponse{Success: false, Error: "Failed to encode response"}) + return + } + + writeJSON(w, http.StatusOK, ProxyResponse{Success: true, Data: string(dataBytes)}) +} + +func normalizeQuality(quality string) string { + switch strings.ToUpper(quality) { + case "HI_RES_LOSSLESS", "HIRES_LOSSLESS", "MASTER", "HI_RES", "HIRES": + return "HI_RES_LOSSLESS" + case "LOSSLESS", "FLAC", "HIFI": + return "LOSSLESS" + case "HIGH", "NORMAL": + return "HIGH" + case "LOW": + return "LOW" + default: + return "LOSSLESS" + } +} + +func getPlaybackInfo(trackID, quality, token string) (*TidalPlaybackInfo, error) { + params := map[string]string{ + "playbackMode": "STREAM", + "audioquality": quality, + } + + resp, err := callTidalAPI( + fmt.Sprintf("%s/tracks/%s/playbackinfo", tidalBaseURL, trackID), + token, + params, + ) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("Tidal API returned %d: %s", resp.StatusCode, string(body)) + } + + var info TidalPlaybackInfo + if err := json.NewDecoder(resp.Body).Decode(&info); err != nil { + return nil, err + } + + if info.Formats == nil { + info.Formats = extractFormatsFromManifest(info.Manifest, info.ManifestMimeType) + } + + return &info, nil +} + +func extractFormatsFromManifest(manifest, mimeType string) []interface{} { + formats := make([]interface{}, 0) + if mimeType == "" || manifest == "" { + return formats + } + + decoded, err := base64.StdEncoding.DecodeString(manifest) + if err != nil { + return formats + } + + content := string(decoded) + if !strings.Contains(content, " or X-Tidal-Token header", + }) +} + +func writeJSON(w http.ResponseWriter, status int, v interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(v) +} + +func main() { + config = loadConfig() + startTime = time.Now() + + client = &http.Client{ + Timeout: 60 * time.Second, + } + + mux := http.NewServeMux() + mux.HandleFunc("/v1/dl/tid2", handleDownloadTrack) + mux.HandleFunc("/health", handleHealth) + mux.HandleFunc("/auth", handleAuth) + + 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) + + 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~ new file mode 100644 index 0000000..e5822a3 Binary files /dev/null and b/tidal-proxy/tidal-proxy.exe~ differ