mirror of
https://git.sr.ht/~joren/streamrip-go
synced 2026-06-17 15:05:39 +02:00
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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user