diff --git a/internal/audio/convert/convert.go b/internal/audio/convert/convert.go index cc97a6e..89ca70e 100644 --- a/internal/audio/convert/convert.go +++ b/internal/audio/convert/convert.go @@ -72,6 +72,17 @@ func buildFFmpegArgs(inputPath, outputPath string, p profile, cfg config.Convers "-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 { filter := buildLosslessFilter(cfg) if filter != "" { @@ -87,6 +98,15 @@ func buildFFmpegArgs(inputPath, outputPath string, p profile, cfg config.Convers 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 { parts := make([]string, 0, 2) if cfg.SamplingRate > 0 { diff --git a/internal/audio/convert/convert_test.go b/internal/audio/convert/convert_test.go index 474dc32..8d3ffcb 100644 --- a/internal/audio/convert/convert_test.go +++ b/internal/audio/convert/convert_test.go @@ -28,6 +28,12 @@ func TestBuildFFmpegArgsLossless(t *testing.T) { if !strings.Contains(joined, "sample_fmts=s16p|s16") { 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) { @@ -40,4 +46,16 @@ func TestBuildFFmpegArgsLossy(t *testing.T) { if !strings.Contains(joined, "-b:a 320k") { 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) + } } diff --git a/internal/download/downloader.go b/internal/download/downloader.go index a1edf27..82b6f60 100644 --- a/internal/download/downloader.go +++ b/internal/download/downloader.go @@ -81,7 +81,13 @@ func (d *Downloader) FileDeezerEncrypted(ctx context.Context, sourceURL, outputP if err != nil { return err } - defer func() { _ = out.Close() }() + success := false + defer func() { + _ = out.Close() + if !success { + _ = os.Remove(outputPath) + } + }() var bar *mpb.Bar if d.ProgressEnabled() && resp.ContentLength > 0 { @@ -111,6 +117,7 @@ func (d *Downloader) FileDeezerEncrypted(ctx context.Context, sourceURL, outputP buf := make([]byte, deezerBFChunkSize) dec := make([]byte, deezerBFChunkSize) chunkIndex := 0 + totalRead := int64(0) for { n, readErr := io.ReadFull(resp.Body, buf) if readErr == io.EOF { @@ -128,6 +135,7 @@ func (d *Downloader) FileDeezerEncrypted(ctx context.Context, sourceURL, outputP if _, err = out.Write(chunk); err != nil { return err } + totalRead += int64(n) if bar != nil { bar.IncrBy(n) } @@ -136,6 +144,10 @@ func (d *Downloader) FileDeezerEncrypted(ctx context.Context, sourceURL, outputP break } } + if resp.ContentLength > 0 && totalRead != resp.ContentLength { + return io.ErrUnexpectedEOF + } + success = true return nil } @@ -170,7 +182,13 @@ func (d *Downloader) file(ctx context.Context, sourceURL, outputPath string, all if err != nil { return err } - defer func() { _ = out.Close() }() + success := false + defer func() { + _ = out.Close() + if !success { + _ = os.Remove(outputPath) + } + }() if d.ProgressEnabled() && allowProgress && resp.ContentLength > 0 { d.barStarted.Store(1) @@ -191,12 +209,14 @@ func (d *Downloader) file(ctx context.Context, sourceURL, outputPath string, all mpb.BarRemoveOnComplete(), ) buf := make([]byte, 256*1024) + totalWritten := int64(0) for { n, readErr := reader.Read(buf) if n > 0 { if _, writeErr := out.Write(buf[:n]); writeErr != nil { return writeErr } + totalWritten += int64(n) bar.IncrBy(n) } if readErr != nil { @@ -206,12 +226,23 @@ func (d *Downloader) file(ctx context.Context, sourceURL, outputPath string, all return readErr } } + if resp.ContentLength > 0 && totalWritten != resp.ContentLength { + return io.ErrUnexpectedEOF + } } 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 } } + success = true return nil } @@ -268,6 +299,7 @@ func (d *Downloader) streamManifestWithFFmpeg(ctx context.Context, sourceURL, ou cmd := exec.CommandContext(ctx, "ffmpeg", args...) output, err := cmd.CombinedOutput() if err != nil { + _ = os.Remove(outputPath) return fmt.Errorf("ffmpeg stream copy failed: %w: %s", err, string(output)) } return nil diff --git a/internal/download/downloader_test.go b/internal/download/downloader_test.go index 8c62c7c..3e0743e 100644 --- a/internal/download/downloader_test.go +++ b/internal/download/downloader_test.go @@ -3,6 +3,8 @@ package download import ( "context" "crypto/cipher" + "errors" + "io" "net/http" "net/http/httptest" "os" @@ -120,3 +122,39 @@ func TestFileDeezerEncrypted(t *testing.T) { 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) + } +} diff --git a/internal/provider/qobuz/client.go b/internal/provider/qobuz/client.go index a90677c..aabbdf6 100644 --- a/internal/provider/qobuz/client.go +++ b/internal/provider/qobuz/client.go @@ -35,6 +35,7 @@ type Client struct { http *http.Client limiter *ratelimit.Limiter baseURL string + fetchCfg func(ctx context.Context) (string, []string, error) loggedIn bool secret string uat string @@ -42,10 +43,11 @@ type Client struct { func New(cfg *config.Config) *Client { return &Client{ - cfg: cfg, - http: netutil.NewHTTPClient(30*time.Second, cfg.Session.Downloads.VerifySSL), - limiter: ratelimit.New(cfg.Session.Downloads.RequestsPerMinute), - baseURL: baseURL, + cfg: cfg, + http: netutil.NewHTTPClient(30*time.Second, cfg.Session.Downloads.VerifySSL), + limiter: ratelimit.New(cfg.Session.Downloads.RequestsPerMinute), + baseURL: baseURL, + fetchCfg: nil, } } @@ -59,37 +61,44 @@ func (c *Client) LoggedIn() bool { func (c *Client) Login(ctx context.Context) error { q := &c.cfg.Session.Qobuz + q.EmailOrUserID = strings.TrimSpace(q.EmailOrUserID) + q.PasswordOrToken = strings.TrimSpace(q.PasswordOrToken) if q.EmailOrUserID == "" || q.PasswordOrToken == "" { return errMissingCredentials } - if q.AppID == "" || len(q.Secrets) == 0 { - appID, secrets, err := c.fetchAppIDAndSecrets(ctx) - if err != nil { - return err + refreshed := false + if err := c.ensureAppCredentials(ctx, q); err != nil { + return err + } + + loginOnce := func() (map[string]any, int, error) { + headers := map[string]string{"X-App-Id": q.AppID} + params := url.Values{} + params.Set("app_id", q.AppID) + if q.UseAuthToken { + params.Set("user_id", q.EmailOrUserID) + params.Set("user_auth_token", q.PasswordOrToken) + } else { + params.Set("email", q.EmailOrUserID) + params.Set("password", q.PasswordOrToken) } - q.AppID = appID - q.Secrets = secrets - c.cfg.File.Qobuz.AppID = appID - c.cfg.File.Qobuz.Secrets = append([]string(nil), secrets...) - _ = c.cfg.SaveFile() + return c.apiRequest(ctx, "user/login", params, headers) } - headers := map[string]string{"X-App-Id": q.AppID} - params := url.Values{} - params.Set("app_id", q.AppID) - if q.UseAuthToken { - params.Set("user_id", q.EmailOrUserID) - params.Set("user_auth_token", q.PasswordOrToken) - } else { - params.Set("email", q.EmailOrUserID) - params.Set("password", q.PasswordOrToken) - } - - resp, status, err := c.apiRequest(ctx, "user/login", params, headers) + resp, status, err := loginOnce() if err != nil { 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 { 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") } - 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) + 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 { return err } @@ -112,6 +128,31 @@ func (c *Client) Login(ctx context.Context) error { 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) { if !c.loggedIn { return nil, errNotLoggedIn @@ -215,14 +256,12 @@ func (c *Client) GetDownloadable(ctx context.Context, item string, quality int) } streamURL, _ := resp["url"].(string) + streamURL = strings.TrimSpace(streamURL) if streamURL == "" { return nil, fmt.Errorf("track is not streamable") } - ext := "mp3" - if quality > 1 { - ext = "flac" - } + ext := qobuzDownloadExtension(resp, quality, streamURL) return &provider.Downloadable{ URL: streamURL, @@ -231,6 +270,41 @@ func (c *Client) GetDownloadable(ctx context.Context, item string, quality int) }, 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 { return nil } diff --git a/internal/provider/qobuz/client_test.go b/internal/provider/qobuz/client_test.go index 113d7c0..b1a6497 100644 --- a/internal/provider/qobuz/client_test.go +++ b/internal/provider/qobuz/client_test.go @@ -2,6 +2,8 @@ package qobuz import ( "context" + "crypto/md5" + "encoding/hex" "encoding/json" "net/http" "net/http/httptest" @@ -219,3 +221,155 @@ func makeItems(start, end int) []map[string]any { } 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[:]) +} diff --git a/internal/provider/soundcloud/client.go b/internal/provider/soundcloud/client.go index 43c9b00..862177a 100644 --- a/internal/provider/soundcloud/client.go +++ b/internal/provider/soundcloud/client.go @@ -146,7 +146,7 @@ func (c *Client) searchPlaylists(ctx context.Context, query string, limit int) ( 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) if len(paths) == 0 { return []map[string]any{}, nil @@ -435,10 +435,11 @@ func canonicalSoundcloudURL(info map[string]any) string { continue } host := strings.ToLower(strings.TrimPrefix(u.Host, "www.")) - if host != "soundcloud.com" { + if host != "soundcloud.com" && !strings.HasSuffix(host, ".soundcloud.com") { continue } u.Scheme = "https" + u.Host = "soundcloud.com" u.RawQuery = "" u.Fragment = "" u.Path = strings.TrimSuffix(u.Path, "/") diff --git a/internal/provider/soundcloud/client_test.go b/internal/provider/soundcloud/client_test.go index 657f30d..0424e1c 100644 --- a/internal/provider/soundcloud/client_test.go +++ b/internal/provider/soundcloud/client_test.go @@ -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(`x`)) + 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) { cfgData := config.DefaultConfigData() 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") } } + +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") + } +}