mirror of
https://git.sr.ht/~joren/streamrip-go
synced 2026-06-17 15:05:39 +02:00
harden qobuz and downloader reliability edge cases
This commit is contained in:
@@ -72,6 +72,17 @@ func buildFFmpegArgs(inputPath, outputPath string, p profile, cfg config.Convers
|
|||||||
"-c:a", p.codecLib,
|
"-c:a", p.codecLib,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if supportsAttachedPicture(p.ext) {
|
||||||
|
args = append(args,
|
||||||
|
"-map", "0:v:0?",
|
||||||
|
"-c:v", "mjpeg",
|
||||||
|
"-disposition:v:0", "attached_pic",
|
||||||
|
)
|
||||||
|
if p.ext == "mp3" {
|
||||||
|
args = append(args, "-id3v2_version", "3")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if p.lossless {
|
if p.lossless {
|
||||||
filter := buildLosslessFilter(cfg)
|
filter := buildLosslessFilter(cfg)
|
||||||
if filter != "" {
|
if filter != "" {
|
||||||
@@ -87,6 +98,15 @@ func buildFFmpegArgs(inputPath, outputPath string, p profile, cfg config.Convers
|
|||||||
return args
|
return args
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func supportsAttachedPicture(ext string) bool {
|
||||||
|
switch strings.TrimPrefix(strings.ToLower(ext), ".") {
|
||||||
|
case "flac", "mp3", "m4a", "mp4":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func buildLosslessFilter(cfg config.ConversionConfig) string {
|
func buildLosslessFilter(cfg config.ConversionConfig) string {
|
||||||
parts := make([]string, 0, 2)
|
parts := make([]string, 0, 2)
|
||||||
if cfg.SamplingRate > 0 {
|
if cfg.SamplingRate > 0 {
|
||||||
|
|||||||
@@ -28,6 +28,12 @@ func TestBuildFFmpegArgsLossless(t *testing.T) {
|
|||||||
if !strings.Contains(joined, "sample_fmts=s16p|s16") {
|
if !strings.Contains(joined, "sample_fmts=s16p|s16") {
|
||||||
t.Fatalf("missing bit depth filter args=%s", joined)
|
t.Fatalf("missing bit depth filter args=%s", joined)
|
||||||
}
|
}
|
||||||
|
if !strings.Contains(joined, "-map 0:v:0?") {
|
||||||
|
t.Fatalf("missing optional cover map args=%s", joined)
|
||||||
|
}
|
||||||
|
if !strings.Contains(joined, "-disposition:v:0 attached_pic") {
|
||||||
|
t.Fatalf("missing attached_pic disposition args=%s", joined)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBuildFFmpegArgsLossy(t *testing.T) {
|
func TestBuildFFmpegArgsLossy(t *testing.T) {
|
||||||
@@ -40,4 +46,16 @@ func TestBuildFFmpegArgsLossy(t *testing.T) {
|
|||||||
if !strings.Contains(joined, "-b:a 320k") {
|
if !strings.Contains(joined, "-b:a 320k") {
|
||||||
t.Fatalf("missing bitrate args=%s", joined)
|
t.Fatalf("missing bitrate args=%s", joined)
|
||||||
}
|
}
|
||||||
|
if !strings.Contains(joined, "-id3v2_version 3") {
|
||||||
|
t.Fatalf("missing id3v2 args=%s", joined)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildFFmpegArgsNoCoverForOpus(t *testing.T) {
|
||||||
|
cfg := config.ConversionConfig{Enabled: true, Codec: "OPUS", LossyBitrate: 192}
|
||||||
|
args := buildFFmpegArgs("in.flac", "out.opus", profiles["OPUS"], cfg)
|
||||||
|
joined := strings.Join(args, " ")
|
||||||
|
if strings.Contains(joined, "-map 0:v:0?") {
|
||||||
|
t.Fatalf("unexpected cover map args=%s", joined)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,7 +81,13 @@ func (d *Downloader) FileDeezerEncrypted(ctx context.Context, sourceURL, outputP
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer func() { _ = out.Close() }()
|
success := false
|
||||||
|
defer func() {
|
||||||
|
_ = out.Close()
|
||||||
|
if !success {
|
||||||
|
_ = os.Remove(outputPath)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
var bar *mpb.Bar
|
var bar *mpb.Bar
|
||||||
if d.ProgressEnabled() && resp.ContentLength > 0 {
|
if d.ProgressEnabled() && resp.ContentLength > 0 {
|
||||||
@@ -111,6 +117,7 @@ func (d *Downloader) FileDeezerEncrypted(ctx context.Context, sourceURL, outputP
|
|||||||
buf := make([]byte, deezerBFChunkSize)
|
buf := make([]byte, deezerBFChunkSize)
|
||||||
dec := make([]byte, deezerBFChunkSize)
|
dec := make([]byte, deezerBFChunkSize)
|
||||||
chunkIndex := 0
|
chunkIndex := 0
|
||||||
|
totalRead := int64(0)
|
||||||
for {
|
for {
|
||||||
n, readErr := io.ReadFull(resp.Body, buf)
|
n, readErr := io.ReadFull(resp.Body, buf)
|
||||||
if readErr == io.EOF {
|
if readErr == io.EOF {
|
||||||
@@ -128,6 +135,7 @@ func (d *Downloader) FileDeezerEncrypted(ctx context.Context, sourceURL, outputP
|
|||||||
if _, err = out.Write(chunk); err != nil {
|
if _, err = out.Write(chunk); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
totalRead += int64(n)
|
||||||
if bar != nil {
|
if bar != nil {
|
||||||
bar.IncrBy(n)
|
bar.IncrBy(n)
|
||||||
}
|
}
|
||||||
@@ -136,6 +144,10 @@ func (d *Downloader) FileDeezerEncrypted(ctx context.Context, sourceURL, outputP
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if resp.ContentLength > 0 && totalRead != resp.ContentLength {
|
||||||
|
return io.ErrUnexpectedEOF
|
||||||
|
}
|
||||||
|
success = true
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,7 +182,13 @@ func (d *Downloader) file(ctx context.Context, sourceURL, outputPath string, all
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer func() { _ = out.Close() }()
|
success := false
|
||||||
|
defer func() {
|
||||||
|
_ = out.Close()
|
||||||
|
if !success {
|
||||||
|
_ = os.Remove(outputPath)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
if d.ProgressEnabled() && allowProgress && resp.ContentLength > 0 {
|
if d.ProgressEnabled() && allowProgress && resp.ContentLength > 0 {
|
||||||
d.barStarted.Store(1)
|
d.barStarted.Store(1)
|
||||||
@@ -191,12 +209,14 @@ func (d *Downloader) file(ctx context.Context, sourceURL, outputPath string, all
|
|||||||
mpb.BarRemoveOnComplete(),
|
mpb.BarRemoveOnComplete(),
|
||||||
)
|
)
|
||||||
buf := make([]byte, 256*1024)
|
buf := make([]byte, 256*1024)
|
||||||
|
totalWritten := int64(0)
|
||||||
for {
|
for {
|
||||||
n, readErr := reader.Read(buf)
|
n, readErr := reader.Read(buf)
|
||||||
if n > 0 {
|
if n > 0 {
|
||||||
if _, writeErr := out.Write(buf[:n]); writeErr != nil {
|
if _, writeErr := out.Write(buf[:n]); writeErr != nil {
|
||||||
return writeErr
|
return writeErr
|
||||||
}
|
}
|
||||||
|
totalWritten += int64(n)
|
||||||
bar.IncrBy(n)
|
bar.IncrBy(n)
|
||||||
}
|
}
|
||||||
if readErr != nil {
|
if readErr != nil {
|
||||||
@@ -206,12 +226,23 @@ func (d *Downloader) file(ctx context.Context, sourceURL, outputPath string, all
|
|||||||
return readErr
|
return readErr
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if resp.ContentLength > 0 && totalWritten != resp.ContentLength {
|
||||||
|
return io.ErrUnexpectedEOF
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
if _, err = io.Copy(out, reader); err != nil {
|
written, copyErr := io.Copy(out, reader)
|
||||||
|
if copyErr != nil {
|
||||||
|
return copyErr
|
||||||
|
}
|
||||||
|
if resp.ContentLength > 0 && written != resp.ContentLength {
|
||||||
|
return io.ErrUnexpectedEOF
|
||||||
|
}
|
||||||
|
if err = out.Sync(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
success = true
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -268,6 +299,7 @@ func (d *Downloader) streamManifestWithFFmpeg(ctx context.Context, sourceURL, ou
|
|||||||
cmd := exec.CommandContext(ctx, "ffmpeg", args...)
|
cmd := exec.CommandContext(ctx, "ffmpeg", args...)
|
||||||
output, err := cmd.CombinedOutput()
|
output, err := cmd.CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
_ = os.Remove(outputPath)
|
||||||
return fmt.Errorf("ffmpeg stream copy failed: %w: %s", err, string(output))
|
return fmt.Errorf("ffmpeg stream copy failed: %w: %s", err, string(output))
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package download
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/cipher"
|
"crypto/cipher"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
@@ -120,3 +122,39 @@ func TestFileDeezerEncrypted(t *testing.T) {
|
|||||||
t.Fatalf("decrypted file mismatch")
|
t.Fatalf("decrypted file mismatch")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDownloaderFileTruncatedResponseRemovesPartialFile(t *testing.T) {
|
||||||
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set("Content-Length", "10")
|
||||||
|
_, _ = w.Write([]byte("abc"))
|
||||||
|
}))
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
d := NewWithOptions(true, false)
|
||||||
|
out := filepath.Join(t.TempDir(), "x", "a.bin")
|
||||||
|
err := d.File(context.Background(), ts.URL, out)
|
||||||
|
if err == nil || !errors.Is(err, io.ErrUnexpectedEOF) {
|
||||||
|
t.Fatalf("expected unexpected EOF, got %v", err)
|
||||||
|
}
|
||||||
|
if _, statErr := os.Stat(out); !errors.Is(statErr, os.ErrNotExist) {
|
||||||
|
t.Fatalf("expected no partial file, stat err=%v", statErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFileDeezerEncryptedTruncatedResponseRemovesPartialFile(t *testing.T) {
|
||||||
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set("Content-Length", "4096")
|
||||||
|
_, _ = w.Write([]byte("short"))
|
||||||
|
}))
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
d := NewWithOptions(true, false)
|
||||||
|
out := filepath.Join(t.TempDir(), "x", "a.flac")
|
||||||
|
err := d.FileDeezerEncrypted(context.Background(), ts.URL, out, "3135556")
|
||||||
|
if err == nil || !errors.Is(err, io.ErrUnexpectedEOF) {
|
||||||
|
t.Fatalf("expected unexpected EOF, got %v", err)
|
||||||
|
}
|
||||||
|
if _, statErr := os.Stat(out); !errors.Is(statErr, os.ErrNotExist) {
|
||||||
|
t.Fatalf("expected no partial file, stat err=%v", statErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ type Client struct {
|
|||||||
http *http.Client
|
http *http.Client
|
||||||
limiter *ratelimit.Limiter
|
limiter *ratelimit.Limiter
|
||||||
baseURL string
|
baseURL string
|
||||||
|
fetchCfg func(ctx context.Context) (string, []string, error)
|
||||||
loggedIn bool
|
loggedIn bool
|
||||||
secret string
|
secret string
|
||||||
uat string
|
uat string
|
||||||
@@ -46,6 +47,7 @@ func New(cfg *config.Config) *Client {
|
|||||||
http: netutil.NewHTTPClient(30*time.Second, cfg.Session.Downloads.VerifySSL),
|
http: netutil.NewHTTPClient(30*time.Second, cfg.Session.Downloads.VerifySSL),
|
||||||
limiter: ratelimit.New(cfg.Session.Downloads.RequestsPerMinute),
|
limiter: ratelimit.New(cfg.Session.Downloads.RequestsPerMinute),
|
||||||
baseURL: baseURL,
|
baseURL: baseURL,
|
||||||
|
fetchCfg: nil,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,22 +61,18 @@ func (c *Client) LoggedIn() bool {
|
|||||||
|
|
||||||
func (c *Client) Login(ctx context.Context) error {
|
func (c *Client) Login(ctx context.Context) error {
|
||||||
q := &c.cfg.Session.Qobuz
|
q := &c.cfg.Session.Qobuz
|
||||||
|
q.EmailOrUserID = strings.TrimSpace(q.EmailOrUserID)
|
||||||
|
q.PasswordOrToken = strings.TrimSpace(q.PasswordOrToken)
|
||||||
if q.EmailOrUserID == "" || q.PasswordOrToken == "" {
|
if q.EmailOrUserID == "" || q.PasswordOrToken == "" {
|
||||||
return errMissingCredentials
|
return errMissingCredentials
|
||||||
}
|
}
|
||||||
|
|
||||||
if q.AppID == "" || len(q.Secrets) == 0 {
|
refreshed := false
|
||||||
appID, secrets, err := c.fetchAppIDAndSecrets(ctx)
|
if err := c.ensureAppCredentials(ctx, q); err != nil {
|
||||||
if err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
q.AppID = appID
|
|
||||||
q.Secrets = secrets
|
|
||||||
c.cfg.File.Qobuz.AppID = appID
|
|
||||||
c.cfg.File.Qobuz.Secrets = append([]string(nil), secrets...)
|
|
||||||
_ = c.cfg.SaveFile()
|
|
||||||
}
|
|
||||||
|
|
||||||
|
loginOnce := func() (map[string]any, int, error) {
|
||||||
headers := map[string]string{"X-App-Id": q.AppID}
|
headers := map[string]string{"X-App-Id": q.AppID}
|
||||||
params := url.Values{}
|
params := url.Values{}
|
||||||
params.Set("app_id", q.AppID)
|
params.Set("app_id", q.AppID)
|
||||||
@@ -85,11 +83,22 @@ func (c *Client) Login(ctx context.Context) error {
|
|||||||
params.Set("email", q.EmailOrUserID)
|
params.Set("email", q.EmailOrUserID)
|
||||||
params.Set("password", q.PasswordOrToken)
|
params.Set("password", q.PasswordOrToken)
|
||||||
}
|
}
|
||||||
|
return c.apiRequest(ctx, "user/login", params, headers)
|
||||||
|
}
|
||||||
|
|
||||||
resp, status, err := c.apiRequest(ctx, "user/login", params, headers)
|
resp, status, err := loginOnce()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if status != http.StatusOK && !refreshed {
|
||||||
|
if refreshErr := c.refreshAppCredentials(ctx, q); refreshErr == nil {
|
||||||
|
refreshed = true
|
||||||
|
resp, status, err = loginOnce()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if status != http.StatusOK {
|
if status != http.StatusOK {
|
||||||
return fmt.Errorf("qobuz login failed: status=%d body=%v", status, resp)
|
return fmt.Errorf("qobuz login failed: status=%d body=%v", status, resp)
|
||||||
}
|
}
|
||||||
@@ -99,8 +108,15 @@ func (c *Client) Login(ctx context.Context) error {
|
|||||||
return fmt.Errorf("qobuz login missing user_auth_token")
|
return fmt.Errorf("qobuz login missing user_auth_token")
|
||||||
}
|
}
|
||||||
|
|
||||||
headers["X-User-Auth-Token"] = uat
|
headers := map[string]string{"X-App-Id": q.AppID, "X-User-Auth-Token": uat}
|
||||||
validSecret, err := c.getValidSecret(ctx, q.Secrets, headers)
|
validSecret, err := c.getValidSecret(ctx, q.Secrets, headers)
|
||||||
|
if err != nil && !refreshed {
|
||||||
|
if refreshErr := c.refreshAppCredentials(ctx, q); refreshErr == nil {
|
||||||
|
refreshed = true
|
||||||
|
headers["X-App-Id"] = q.AppID
|
||||||
|
validSecret, err = c.getValidSecret(ctx, q.Secrets, headers)
|
||||||
|
}
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -112,6 +128,31 @@ func (c *Client) Login(ctx context.Context) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *Client) ensureAppCredentials(ctx context.Context, q *config.QobuzConfig) error {
|
||||||
|
q.AppID = strings.TrimSpace(q.AppID)
|
||||||
|
if q.AppID != "" && len(q.Secrets) > 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return c.refreshAppCredentials(ctx, q)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) refreshAppCredentials(ctx context.Context, q *config.QobuzConfig) error {
|
||||||
|
fetch := c.fetchCfg
|
||||||
|
if fetch == nil {
|
||||||
|
fetch = c.fetchAppIDAndSecrets
|
||||||
|
}
|
||||||
|
appID, secrets, err := fetch(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
q.AppID = strings.TrimSpace(appID)
|
||||||
|
q.Secrets = append([]string(nil), secrets...)
|
||||||
|
c.cfg.File.Qobuz.AppID = q.AppID
|
||||||
|
c.cfg.File.Qobuz.Secrets = append([]string(nil), secrets...)
|
||||||
|
_ = c.cfg.SaveFile()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Client) GetMetadata(ctx context.Context, item, mediaType string) (map[string]any, error) {
|
func (c *Client) GetMetadata(ctx context.Context, item, mediaType string) (map[string]any, error) {
|
||||||
if !c.loggedIn {
|
if !c.loggedIn {
|
||||||
return nil, errNotLoggedIn
|
return nil, errNotLoggedIn
|
||||||
@@ -215,14 +256,12 @@ func (c *Client) GetDownloadable(ctx context.Context, item string, quality int)
|
|||||||
}
|
}
|
||||||
|
|
||||||
streamURL, _ := resp["url"].(string)
|
streamURL, _ := resp["url"].(string)
|
||||||
|
streamURL = strings.TrimSpace(streamURL)
|
||||||
if streamURL == "" {
|
if streamURL == "" {
|
||||||
return nil, fmt.Errorf("track is not streamable")
|
return nil, fmt.Errorf("track is not streamable")
|
||||||
}
|
}
|
||||||
|
|
||||||
ext := "mp3"
|
ext := qobuzDownloadExtension(resp, quality, streamURL)
|
||||||
if quality > 1 {
|
|
||||||
ext = "flac"
|
|
||||||
}
|
|
||||||
|
|
||||||
return &provider.Downloadable{
|
return &provider.Downloadable{
|
||||||
URL: streamURL,
|
URL: streamURL,
|
||||||
@@ -231,6 +270,41 @@ func (c *Client) GetDownloadable(ctx context.Context, item string, quality int)
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func qobuzDownloadExtension(resp map[string]any, quality int, streamURL string) string {
|
||||||
|
if parsed, err := url.Parse(strings.TrimSpace(streamURL)); err == nil {
|
||||||
|
p := strings.ToLower(parsed.Path)
|
||||||
|
if strings.HasSuffix(p, ".flac") {
|
||||||
|
return "flac"
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(p, ".mp3") {
|
||||||
|
return "mp3"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mimeType, _ := resp["mime_type"].(string)
|
||||||
|
mimeType = strings.ToLower(strings.TrimSpace(mimeType))
|
||||||
|
if strings.Contains(mimeType, "flac") {
|
||||||
|
return "flac"
|
||||||
|
}
|
||||||
|
if strings.Contains(mimeType, "mpeg") || strings.Contains(mimeType, "mp3") {
|
||||||
|
return "mp3"
|
||||||
|
}
|
||||||
|
|
||||||
|
if formatID, ok := intValue(resp["format_id"]); ok {
|
||||||
|
if formatID == 5 {
|
||||||
|
return "mp3"
|
||||||
|
}
|
||||||
|
if formatID > 5 {
|
||||||
|
return "flac"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if quality > 1 {
|
||||||
|
return "flac"
|
||||||
|
}
|
||||||
|
return "mp3"
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Client) Close() error {
|
func (c *Client) Close() error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package qobuz
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/md5"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@@ -219,3 +221,155 @@ func makeItems(start, end int) []map[string]any {
|
|||||||
}
|
}
|
||||||
return items
|
return items
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLoginRefreshesAppCredentialsWhenSecretInvalid(t *testing.T) {
|
||||||
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/user/login":
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"user_auth_token": "uat-token"})
|
||||||
|
case "/track/getFileUrl":
|
||||||
|
if r.Header.Get("X-App-Id") != "new-app" {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"message": "bad app"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tsValue := r.URL.Query().Get("request_ts")
|
||||||
|
sig := r.URL.Query().Get("request_sig")
|
||||||
|
if sig != qobuzSecretSig(tsValue, "good-secret") {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"message": "bad secret"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"message": "ok secret"})
|
||||||
|
default:
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
d := config.DefaultConfigData()
|
||||||
|
d.Qobuz.EmailOrUserID = "user@example.com"
|
||||||
|
d.Qobuz.PasswordOrToken = "hash"
|
||||||
|
d.Qobuz.AppID = "old-app"
|
||||||
|
d.Qobuz.Secrets = []string{"bad-secret"}
|
||||||
|
cfg := &config.Config{File: d, Session: d}
|
||||||
|
c := New(cfg)
|
||||||
|
c.baseURL = ts.URL
|
||||||
|
c.fetchCfg = func(context.Context) (string, []string, error) {
|
||||||
|
return "new-app", []string{"good-secret"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.Login(context.Background()); err != nil {
|
||||||
|
t.Fatalf("Login() error = %v", err)
|
||||||
|
}
|
||||||
|
if !c.loggedIn {
|
||||||
|
t.Fatalf("expected logged-in client")
|
||||||
|
}
|
||||||
|
if c.secret != "good-secret" {
|
||||||
|
t.Fatalf("secret = %q, want good-secret", c.secret)
|
||||||
|
}
|
||||||
|
if c.cfg.Session.Qobuz.AppID != "new-app" {
|
||||||
|
t.Fatalf("session app id = %q", c.cfg.Session.Qobuz.AppID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoginRetriesAfterRefreshingAppCredentials(t *testing.T) {
|
||||||
|
loginCalls := 0
|
||||||
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/user/login":
|
||||||
|
loginCalls++
|
||||||
|
if r.URL.Query().Get("app_id") != "new-app" {
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"message": "expired app id"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"user_auth_token": "uat-token"})
|
||||||
|
case "/track/getFileUrl":
|
||||||
|
tsValue := r.URL.Query().Get("request_ts")
|
||||||
|
sig := r.URL.Query().Get("request_sig")
|
||||||
|
if r.Header.Get("X-App-Id") == "new-app" && sig == qobuzSecretSig(tsValue, "good-secret") {
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"message": "ok secret"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"message": "bad secret"})
|
||||||
|
default:
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
d := config.DefaultConfigData()
|
||||||
|
d.Qobuz.EmailOrUserID = "user@example.com"
|
||||||
|
d.Qobuz.PasswordOrToken = "hash"
|
||||||
|
d.Qobuz.AppID = "old-app"
|
||||||
|
d.Qobuz.Secrets = []string{"old-secret"}
|
||||||
|
cfg := &config.Config{File: d, Session: d}
|
||||||
|
c := New(cfg)
|
||||||
|
c.baseURL = ts.URL
|
||||||
|
c.fetchCfg = func(context.Context) (string, []string, error) {
|
||||||
|
return "new-app", []string{"good-secret"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.Login(context.Background()); err != nil {
|
||||||
|
t.Fatalf("Login() error = %v", err)
|
||||||
|
}
|
||||||
|
if loginCalls < 2 {
|
||||||
|
t.Fatalf("expected login retry after refresh, calls=%d", loginCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQobuzDownloadExtension(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
resp map[string]any
|
||||||
|
quality int
|
||||||
|
url string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "from url flac", resp: map[string]any{}, quality: 1, url: "https://cdn.example/a.flac?token=1", want: "flac"},
|
||||||
|
{name: "from url mp3", resp: map[string]any{}, quality: 4, url: "https://cdn.example/a.mp3", want: "mp3"},
|
||||||
|
{name: "from mime type", resp: map[string]any{"mime_type": "audio/flac"}, quality: 1, url: "https://cdn.example/stream", want: "flac"},
|
||||||
|
{name: "from format id", resp: map[string]any{"format_id": float64(5)}, quality: 4, url: "https://cdn.example/stream", want: "mp3"},
|
||||||
|
{name: "fallback quality", resp: map[string]any{}, quality: 3, url: "https://cdn.example/stream", want: "flac"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
if got := qobuzDownloadExtension(tt.resp, tt.quality, tt.url); got != tt.want {
|
||||||
|
t.Fatalf("%s: qobuzDownloadExtension()=%q want %q", tt.name, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetDownloadableUsesReturnedURLExtension(t *testing.T) {
|
||||||
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/track/getFileUrl" {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"url": "https://cdn.example/track.mp3?token=abc"})
|
||||||
|
}))
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
c := newTestClient(t)
|
||||||
|
c.loggedIn = true
|
||||||
|
c.secret = "secret"
|
||||||
|
c.baseURL = ts.URL
|
||||||
|
|
||||||
|
d, err := c.GetDownloadable(context.Background(), "19512574", 4)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetDownloadable() error = %v", err)
|
||||||
|
}
|
||||||
|
if d.Extension != "mp3" {
|
||||||
|
t.Fatalf("extension = %q, want mp3", d.Extension)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func qobuzSecretSig(requestTS, secret string) string {
|
||||||
|
raw := "trackgetFileUrlformat_id27intentstreamtrack_id19512574" + requestTS + secret
|
||||||
|
hash := md5.Sum([]byte(raw))
|
||||||
|
return hex.EncodeToString(hash[:])
|
||||||
|
}
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ func (c *Client) searchPlaylists(ctx context.Context, query string, limit int) (
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
re := regexp.MustCompile(`/[A-Za-z0-9_-]+/sets/[A-Za-z0-9_-]+`)
|
re := regexp.MustCompile(`/(?:[A-Za-z0-9._-]+)/sets/(?:[A-Za-z0-9._%~-]+)`)
|
||||||
paths := re.FindAllString(string(body), -1)
|
paths := re.FindAllString(string(body), -1)
|
||||||
if len(paths) == 0 {
|
if len(paths) == 0 {
|
||||||
return []map[string]any{}, nil
|
return []map[string]any{}, nil
|
||||||
@@ -435,10 +435,11 @@ func canonicalSoundcloudURL(info map[string]any) string {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
host := strings.ToLower(strings.TrimPrefix(u.Host, "www."))
|
host := strings.ToLower(strings.TrimPrefix(u.Host, "www."))
|
||||||
if host != "soundcloud.com" {
|
if host != "soundcloud.com" && !strings.HasSuffix(host, ".soundcloud.com") {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
u.Scheme = "https"
|
u.Scheme = "https"
|
||||||
|
u.Host = "soundcloud.com"
|
||||||
u.RawQuery = ""
|
u.RawQuery = ""
|
||||||
u.Fragment = ""
|
u.Fragment = ""
|
||||||
u.Path = strings.TrimSuffix(u.Path, "/")
|
u.Path = strings.TrimSuffix(u.Path, "/")
|
||||||
|
|||||||
@@ -152,6 +152,51 @@ func TestSearchPlaylist(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSearchPlaylistAcceptsDotsInPath(t *testing.T) {
|
||||||
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/search/sets" {
|
||||||
|
_, _ = w.Write([]byte(`<html><body><a href="/artist.name/sets/road.trip">x</a></body></html>`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
}))
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
cfgData := config.DefaultConfigData()
|
||||||
|
c := New(&config.Config{File: cfgData, Session: cfgData})
|
||||||
|
c.loggedIn = true
|
||||||
|
c.http = ts.Client()
|
||||||
|
origBase := soundcloudSearchBaseURL
|
||||||
|
soundcloudSearchBaseURL = ts.URL
|
||||||
|
defer func() { soundcloudSearchBaseURL = origBase }()
|
||||||
|
c.run = func(_ context.Context, _ string, args ...string) ([]byte, error) {
|
||||||
|
joined := strings.Join(args, " ")
|
||||||
|
if strings.Contains(joined, "https://soundcloud.com/artist.name/sets/road.trip") {
|
||||||
|
return []byte(`{"title":"Road Trip","uploader":"User","entries":[{"webpage_url":"https://soundcloud.com/a/t1"}]}`), nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("unexpected args: %v", args)
|
||||||
|
}
|
||||||
|
|
||||||
|
pages, err := c.Search(context.Background(), "playlist", "road trip", 5)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Search() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(pages) != 1 {
|
||||||
|
t.Fatalf("pages len = %d, want 1", len(pages))
|
||||||
|
}
|
||||||
|
items := asAnySlice(pages[0]["items"])
|
||||||
|
if len(items) != 1 {
|
||||||
|
t.Fatalf("items len = %d, want 1", len(items))
|
||||||
|
}
|
||||||
|
item0, ok := items[0].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected first item map")
|
||||||
|
}
|
||||||
|
if stringFromAny(item0["id"]) != "https://soundcloud.com/artist.name/sets/road.trip" {
|
||||||
|
t.Fatalf("playlist search id not canonical: %q", stringFromAny(item0["id"]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLoginShowsYtDlpHint(t *testing.T) {
|
func TestLoginShowsYtDlpHint(t *testing.T) {
|
||||||
cfgData := config.DefaultConfigData()
|
cfgData := config.DefaultConfigData()
|
||||||
c := New(&config.Config{File: cfgData, Session: cfgData})
|
c := New(&config.Config{File: cfgData, Session: cfgData})
|
||||||
@@ -197,3 +242,10 @@ func TestCanonicalSoundcloudURL(t *testing.T) {
|
|||||||
t.Fatalf("canonical url = %q, want %q", got, "https://soundcloud.com/a/b")
|
t.Fatalf("canonical url = %q, want %q", got, "https://soundcloud.com/a/b")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCanonicalSoundcloudURLAcceptsSubdomain(t *testing.T) {
|
||||||
|
got := canonicalSoundcloudURL(map[string]any{"webpage_url": "https://m.soundcloud.com/a/b/?si=x#frag"})
|
||||||
|
if got != "https://soundcloud.com/a/b" {
|
||||||
|
t.Fatalf("canonical url = %q, want %q", got, "https://soundcloud.com/a/b")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user