add CLI parity flags and expand provider support

This brings the Go CLI closer to upstream behavior with global flag handling and clearer resolve failures, while adding Tidal video downloads plus initial Deezer and SoundCloud no-account flows for broader end-to-end coverage.
This commit is contained in:
2026-04-20 00:56:10 +02:00
parent 4da5114a70
commit b2688ce949
15 changed files with 1746 additions and 57 deletions

View File

@@ -381,6 +381,70 @@ func (c *Client) getDownloadableFromTrackManifest(ctx context.Context, trackID s
return &provider.Downloadable{URL: uri, Extension: ext, Source: "tidal"}, nil
}
func (c *Client) GetVideoDownloadable(ctx context.Context, videoID string) (*provider.Downloadable, error) {
if !c.loggedIn {
return nil, errors.New("tidal client not logged in")
}
params := url.Values{}
params.Set("videoquality", "HIGH")
params.Set("playbackmode", "STREAM")
params.Set("assetpresentation", "FULL")
resp, status, err := c.apiRequest(ctx, "videos/"+videoID+"/playbackinfopostpaywall", params, c.baseURL)
if err != nil {
return nil, err
}
if status != http.StatusOK {
return nil, fmt.Errorf("tidal video playbackinfo failed: status=%d", status)
}
manifestB64 := stringify(resp["manifest"])
if manifestB64 == "" {
return nil, errors.New("tidal video manifest missing")
}
b, err := base64.StdEncoding.DecodeString(manifestB64)
if err != nil {
return nil, fmt.Errorf("decode video manifest: %w", err)
}
manifest := map[string]any{}
if err = json.Unmarshal(b, &manifest); err != nil {
return nil, fmt.Errorf("parse video manifest json: %w", err)
}
urls, ok := manifest["urls"].([]any)
if !ok || len(urls) == 0 {
return nil, errors.New("tidal video manifest urls missing")
}
masterURL := stringify(urls[0])
if masterURL == "" {
return nil, errors.New("tidal video master url missing")
}
if err = c.limiter.Wait(ctx); err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, masterURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "streamrip-go/0.1")
respHTTP, err := c.http.Do(req)
if err != nil {
return nil, err
}
defer func() { _ = respHTTP.Body.Close() }()
if respHTTP.StatusCode < 200 || respHTTP.StatusCode >= 300 {
return nil, fmt.Errorf("tidal video playlist fetch failed: status=%d", respHTTP.StatusCode)
}
body, err := io.ReadAll(respHTTP.Body)
if err != nil {
return nil, err
}
streamURL := bestHLSVariantURL(masterURL, string(body))
return &provider.Downloadable{URL: streamURL, Extension: "mp4", Source: "tidal"}, nil
}
func downloadableFromPlaybackManifest(resp map[string]any) *provider.Downloadable {
manifestB64 := stringify(resp["manifest"])
if manifestB64 == "" {
@@ -410,6 +474,41 @@ func downloadableFromPlaybackManifest(resp map[string]any) *provider.Downloadabl
return &provider.Downloadable{URL: streamURL, Extension: ext, Source: "tidal"}
}
func bestHLSVariantURL(masterURL, playlist string) string {
lines := strings.Split(strings.ReplaceAll(playlist, "\r\n", "\n"), "\n")
best := strings.TrimSpace(masterURL)
for i := 0; i < len(lines)-1; i++ {
line := strings.TrimSpace(lines[i])
if !strings.HasPrefix(line, "#EXT-X-STREAM-INF:") {
continue
}
if strings.Contains(strings.ToLower(line), "codecs=\"jpeg") {
continue
}
next := strings.TrimSpace(lines[i+1])
if next == "" || strings.HasPrefix(next, "#") {
continue
}
best = resolvePlaylistURL(masterURL, next)
}
return best
}
func resolvePlaylistURL(baseRaw, refRaw string) string {
if strings.HasPrefix(refRaw, "http://") || strings.HasPrefix(refRaw, "https://") {
return refRaw
}
baseURL, err := url.Parse(baseRaw)
if err != nil {
return refRaw
}
refURL, err := url.Parse(refRaw)
if err != nil {
return refRaw
}
return baseURL.ResolveReference(refURL).String()
}
func (c *Client) apiRequest(ctx context.Context, path string, params url.Values, base string) (map[string]any, int, error) {
if err := c.limiter.Wait(ctx); err != nil {
return nil, 0, err

View File

@@ -2,6 +2,7 @@ package tidal
import (
"context"
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
@@ -50,3 +51,50 @@ func TestSearch(t *testing.T) {
t.Fatalf("pages = %d", len(pages))
}
}
func TestGetVideoDownloadable(t *testing.T) {
var server *httptest.Server
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/v1/sessions":
_ = json.NewEncoder(w).Encode(map[string]any{"countryCode": "US", "userId": 123})
case "/v1/videos/42/playbackinfopostpaywall":
manifest := map[string]any{"urls": []string{server.URL + "/master.m3u8"}}
b, _ := json.Marshal(manifest)
_ = json.NewEncoder(w).Encode(map[string]any{"manifest": base64.StdEncoding.EncodeToString(b)})
case "/master.m3u8":
_, _ = w.Write([]byte("#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=1000,CODECS=\"avc1.42E01E,mp4a.40.2\",RESOLUTION=640x360\nlow/stream.m3u8\n#EXT-X-STREAM-INF:BANDWIDTH=2000,CODECS=\"avc1.4D401F,mp4a.40.2\",RESOLUTION=1280x720\nhi/stream.m3u8\n"))
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer server.Close()
cfgData := config.DefaultConfigData()
cfgData.Tidal.AccessToken = "token"
cfgData.Tidal.CountryCode = "US"
c := New(&config.Config{File: cfgData, Session: cfgData})
c.baseURL = server.URL + "/v1"
if err := c.Login(context.Background()); err != nil {
t.Fatalf("login err = %v", err)
}
d, err := c.GetVideoDownloadable(context.Background(), "42")
if err != nil {
t.Fatalf("GetVideoDownloadable() err = %v", err)
}
if d.Extension != "mp4" {
t.Fatalf("extension = %q, want mp4", d.Extension)
}
if d.URL != server.URL+"/hi/stream.m3u8" {
t.Fatalf("url = %q, want %q", d.URL, server.URL+"/hi/stream.m3u8")
}
}
func TestBestHLSVariantURLFallsBackToMaster(t *testing.T) {
master := "https://example.com/master.m3u8"
got := bestHLSVariantURL(master, "#EXTM3U\n#comment")
if got != master {
t.Fatalf("url = %q, want %q", got, master)
}
}