Add self-hosted Tidal proxy server and integrate token forwarding

This commit is contained in:
Admin 2026-05-18 12:28:03 +07:00
parent cf718f3ff2
commit 5bb63e6e2a
9 changed files with 504 additions and 6 deletions

View file

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

17
tidal-proxy/.env.example Normal file
View file

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

4
tidal-proxy/.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
tidal-proxy
tidal-proxy.exe
*.log
.env

20
tidal-proxy/Dockerfile Normal file
View file

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

127
tidal-proxy/README.md Normal file
View file

@ -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 <your-tidal-token>` (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

View file

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

3
tidal-proxy/go.mod Normal file
View file

@ -0,0 +1,3 @@
module tidal-proxy
go 1.26.3

305
tidal-proxy/main.go Normal file
View file

@ -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 <token> 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, "<MPD") {
return formats
}
if strings.Contains(content, "codecs=\"fLaC\"") || strings.Contains(content, "codecs=\"flac\"") {
if strings.Contains(content, "HI_RES") || strings.Contains(content, "MAX_SAMPLE_RATE") {
formats = append(formats, "FLAC_HIRES")
}
formats = append(formats, "FLAC")
}
if strings.Contains(content, "codecs=\"ec-3\"") {
formats = append(formats, "EAC3_JOC")
}
if strings.Contains(content, "codecs=\"mp4a.40.2\"") {
formats = append(formats, "AACLC")
}
if strings.Contains(content, "codecs=\"mp4a.40.5\"") {
formats = append(formats, "HEAACV1")
}
return formats
}
func handleHealth(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{
"status": "ok",
"uptime": time.Since(startTime).String(),
})
}
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 <token> 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)
}
}

Binary file not shown.