Add self-hosted Tidal proxy server and integrate token forwarding
This commit is contained in:
parent
cf718f3ff2
commit
5bb63e6e2a
9 changed files with 504 additions and 6 deletions
21
js/api.js
21
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') {
|
||||
|
|
|
|||
17
tidal-proxy/.env.example
Normal file
17
tidal-proxy/.env.example
Normal 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
4
tidal-proxy/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
tidal-proxy
|
||||
tidal-proxy.exe
|
||||
*.log
|
||||
.env
|
||||
20
tidal-proxy/Dockerfile
Normal file
20
tidal-proxy/Dockerfile
Normal 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
127
tidal-proxy/README.md
Normal 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
|
||||
13
tidal-proxy/docker-compose.yml
Normal file
13
tidal-proxy/docker-compose.yml
Normal 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
3
tidal-proxy/go.mod
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
module tidal-proxy
|
||||
|
||||
go 1.26.3
|
||||
305
tidal-proxy/main.go
Normal file
305
tidal-proxy/main.go
Normal 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)
|
||||
}
|
||||
}
|
||||
BIN
tidal-proxy/tidal-proxy.exe~
Normal file
BIN
tidal-proxy/tidal-proxy.exe~
Normal file
Binary file not shown.
Loading…
Reference in a new issue