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

@@ -18,7 +18,9 @@ import (
"streamrip-go/internal/download"
"streamrip-go/internal/naming"
"streamrip-go/internal/provider"
deezerprovider "streamrip-go/internal/provider/deezer"
qobuzprovider "streamrip-go/internal/provider/qobuz"
soundcloudprovider "streamrip-go/internal/provider/soundcloud"
tidalprovider "streamrip-go/internal/provider/tidal"
"streamrip-go/internal/store"
)
@@ -66,6 +68,10 @@ type trackTagger interface {
TagFLAC(path string, meta tag.Metadata, coverPath string) error
}
type videoDownloadableProvider interface {
GetVideoDownloadable(ctx context.Context, videoID string) (*provider.Downloadable, error)
}
func New(cfg *config.Config) (*Main, error) {
var db store.Database
if cfg.Session.Database.DownloadsEnabled || cfg.Session.Database.FailedDownloadsEnabled {
@@ -79,8 +85,10 @@ func New(cfg *config.Config) (*Main, error) {
}
providers := map[string]provider.Client{
"qobuz": qobuzprovider.New(cfg),
"tidal": tidalprovider.New(cfg),
"qobuz": qobuzprovider.New(cfg),
"tidal": tidalprovider.New(cfg),
"deezer": deezerprovider.New(cfg),
"soundcloud": soundcloudprovider.New(cfg),
}
return &Main{
@@ -156,8 +164,10 @@ func (m *Main) AddByID(ctx context.Context, source, mediaType, id string) error
return m.ripCollection(ctx, p, source, "Artist", id, meta)
case "label":
return m.ripCollection(ctx, p, source, "Label", id, meta)
case "video":
return m.ripVideo(ctx, p, source, id, meta)
default:
return nil
return fmt.Errorf("unsupported media type %q", mediaType)
}
}}, nil
},
@@ -205,6 +215,37 @@ func (m *Main) ripCollection(ctx context.Context, p provider.Client, source, kin
return nil
}
func (m *Main) ripVideo(ctx context.Context, p provider.Client, source, videoID string, meta map[string]any) error {
alreadyDownloaded, err := m.Store.IsDownloaded(ctx, source, videoID)
if err == nil && alreadyDownloaded && !m.IgnoreDB {
m.logf("skip (already downloaded) id=%s\n", videoID)
return nil
}
vp, ok := p.(videoDownloadableProvider)
if !ok {
return fmt.Errorf("provider %q does not support video downloads", source)
}
d, err := vp.GetVideoDownloadable(ctx, videoID)
if err != nil {
_ = m.Store.MarkFailed(ctx, source, "video", videoID)
return fmt.Errorf("id=%s get_video_downloadable: %w", videoID, err)
}
title := titleFromMetadata(meta, videoID)
outPath := m.videoOutputPath(source, videoID, title, d.Extension)
if err = m.DL.FileVideo(ctx, d.URL, outPath); err != nil {
_ = m.Store.MarkFailed(ctx, source, "video", videoID)
return fmt.Errorf("id=%s title=%q video download: %w", videoID, title, err)
}
if err = m.Store.MarkDownloaded(ctx, source, videoID); err != nil {
return err
}
return nil
}
func buildCollectionAlbum(id string, meta map[string]any) collectionAlbum {
trackCount := intFromAny(meta["tracks_count"])
if trackCount == 0 {
@@ -357,16 +398,21 @@ func extractAlbumIDs(meta map[string]any) []string {
}
func (m *Main) Resolve(ctx context.Context) error {
pendingCount := len(m.Pending)
resolved := make([]media.Media, 0, len(m.Pending))
for _, item := range m.Pending {
med, err := item.Resolve(ctx)
if err != nil {
m.logf("resolve failed: %v\n", err)
continue
}
resolved = append(resolved, med)
}
m.Media = append(m.Media, resolved...)
m.Pending = m.Pending[:0]
if pendingCount > 0 && len(resolved) == 0 {
return fmt.Errorf("resolve failed for all %d pending item(s)", pendingCount)
}
return nil
}
@@ -830,6 +876,24 @@ func (m *Main) trackOutputPath(source, id, title, ext string, trackMeta map[stri
return filepath.Join(base, fileName+"."+ext)
}
func (m *Main) videoOutputPath(source, id, title, ext string) string {
if strings.TrimSpace(ext) == "" {
ext = "mp4"
}
base := m.Config.Session.Downloads.Folder
if m.Config.Session.Downloads.SourceSubdirectories {
base = filepath.Join(base, strings.Title(source))
}
fileName := naming.CleanName(title, naming.Config{
RestrictCharacters: m.Config.Session.Filepaths.RestrictCharacters,
TruncateTo: m.Config.Session.Filepaths.TruncateTo,
})
if fileName == "" {
fileName = id
}
return filepath.Join(base, fileName+"."+ext)
}
func titleFromMetadata(meta map[string]any, fallback string) string {
if title, ok := meta["title"].(string); ok {
title = strings.TrimSpace(title)

View File

@@ -51,22 +51,35 @@ type fakePlaylistProvider struct {
url string
}
type fakeVideoProvider struct {
url string
}
type fakeFailProvider struct{}
func (f *fakeAlbumProvider) Source() string { return "qobuz" }
func (f *fakePlaylistProvider) Source() string { return "qobuz" }
func (f *fakeVideoProvider) Source() string { return "tidal" }
func (f *fakeAlbumProvider) Login(context.Context) error { return nil }
func (f *fakePlaylistProvider) Login(context.Context) error {
return nil
}
func (f *fakeAlbumProvider) LoggedIn() bool { return true }
func (f *fakePlaylistProvider) LoggedIn() bool { return true }
func (f *fakeAlbumProvider) Close() error { return nil }
func (f *fakePlaylistProvider) Close() error { return nil }
func (f *fakeVideoProvider) Login(context.Context) error { return nil }
func (f *fakeAlbumProvider) LoggedIn() bool { return true }
func (f *fakePlaylistProvider) LoggedIn() bool { return true }
func (f *fakeVideoProvider) LoggedIn() bool { return true }
func (f *fakeAlbumProvider) Close() error { return nil }
func (f *fakePlaylistProvider) Close() error { return nil }
func (f *fakeVideoProvider) Close() error { return nil }
func (f *fakeAlbumProvider) Search(context.Context, string, string, int) ([]map[string]any, error) {
return nil, nil
}
func (f *fakePlaylistProvider) Search(context.Context, string, string, int) ([]map[string]any, error) {
return nil, nil
}
func (f *fakeVideoProvider) Search(context.Context, string, string, int) ([]map[string]any, error) {
return nil, nil
}
func (f *fakeAlbumProvider) GetMetadata(_ context.Context, id string, mediaType string) (map[string]any, error) {
if mediaType == "album" {
return map[string]any{
@@ -133,6 +146,12 @@ func (f *fakePlaylistProvider) GetMetadata(_ context.Context, id string, mediaTy
},
}, nil
}
func (f *fakeVideoProvider) GetMetadata(_ context.Context, id string, mediaType string) (map[string]any, error) {
if mediaType == "video" {
return map[string]any{"title": "Live Clip"}, nil
}
return nil, nil
}
func (f *fakeProvider) GetDownloadable(context.Context, string, int) (*provider.Downloadable, error) {
return &provider.Downloadable{URL: f.url, Extension: "flac", Source: "qobuz"}, nil
}
@@ -142,6 +161,25 @@ func (f *fakeAlbumProvider) GetDownloadable(context.Context, string, int) (*prov
func (f *fakePlaylistProvider) GetDownloadable(context.Context, string, int) (*provider.Downloadable, error) {
return &provider.Downloadable{URL: f.url, Extension: "flac", Source: "qobuz"}, nil
}
func (f *fakeVideoProvider) GetDownloadable(context.Context, string, int) (*provider.Downloadable, error) {
return nil, nil
}
func (f *fakeVideoProvider) GetVideoDownloadable(context.Context, string) (*provider.Downloadable, error) {
return &provider.Downloadable{URL: f.url, Extension: "mp4", Source: "tidal"}, nil
}
func (f *fakeFailProvider) Source() string { return "qobuz" }
func (f *fakeFailProvider) Login(context.Context) error { return nil }
func (f *fakeFailProvider) LoggedIn() bool { return true }
func (f *fakeFailProvider) Close() error { return nil }
func (f *fakeFailProvider) Search(context.Context, string, string, int) ([]map[string]any, error) {
return nil, nil
}
func (f *fakeFailProvider) GetMetadata(context.Context, string, string) (map[string]any, error) {
return nil, os.ErrNotExist
}
func (f *fakeFailProvider) GetDownloadable(context.Context, string, int) (*provider.Downloadable, error) {
return nil, os.ErrNotExist
}
func TestTrackRipPipeline(t *testing.T) {
tmp := t.TempDir()
@@ -248,6 +286,88 @@ func TestAlbumRipPipeline(t *testing.T) {
}
}
func TestVideoRipPipeline(t *testing.T) {
tmp := t.TempDir()
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("video-bytes"))
}))
defer ts.Close()
d := config.DefaultConfigData()
d.Downloads.Folder = tmp
d.Downloads.SourceSubdirectories = false
cfg := &config.Config{File: d, Session: d}
sqlite, err := store.NewSQLite(filepath.Join(tmp, "db.sqlite"))
if err != nil {
t.Fatalf("NewSQLite() error = %v", err)
}
defer func() { _ = sqlite.Close() }()
m := &Main{
Config: cfg,
Providers: map[string]provider.Client{
"tidal": &fakeVideoProvider{url: ts.URL},
},
Store: sqlite,
DL: download.New(),
Tagger: noopTagger{},
Pending: nil,
Media: nil,
}
ctx := context.Background()
if err = m.AddByID(ctx, "tidal", "video", "v1"); err != nil {
t.Fatalf("AddByID() error = %v", err)
}
if err = m.Resolve(ctx); err != nil {
t.Fatalf("Resolve() error = %v", err)
}
if err = m.Rip(ctx); err != nil {
t.Fatalf("Rip() error = %v", err)
}
if _, err = os.Stat(filepath.Join(tmp, "Live Clip.mp4")); err != nil {
t.Fatalf("expected downloaded video file: %v", err)
}
ok, err := sqlite.IsDownloaded(ctx, "tidal", "v1")
if err != nil {
t.Fatalf("IsDownloaded() error = %v", err)
}
if !ok {
t.Fatalf("expected video marked downloaded")
}
}
func TestResolveAllFailedReturnsError(t *testing.T) {
tmp := t.TempDir()
d := config.DefaultConfigData()
d.Downloads.Folder = tmp
cfg := &config.Config{File: d, Session: d}
m := &Main{
Config: cfg,
Providers: map[string]provider.Client{
"qobuz": &fakeFailProvider{},
},
Store: store.NewDummy(),
DL: download.New(),
Tagger: noopTagger{},
Pending: nil,
Media: nil,
}
ctx := context.Background()
if err := m.AddByID(ctx, "qobuz", "track", "x"); err != nil {
t.Fatalf("AddByID() error = %v", err)
}
if err := m.Resolve(ctx); err == nil {
t.Fatalf("expected Resolve() to return error when all items fail")
}
}
func TestPlaylistRipPipeline(t *testing.T) {
tmp := t.TempDir()

View File

@@ -65,7 +65,7 @@ func Prepare(ctx context.Context, dl Downloader, folder string, albumMeta map[st
}
if cfg.Embed && embedURL != "" {
embedDir := filepath.Join(folder, "__artwork")
embedDir := sessionEmbedDir(folder)
if err := os.MkdirAll(embedDir, 0o755); err == nil {
registerTempDir(embedDir)
embedPath := filepath.Join(embedDir, embedFilename(embedURL))
@@ -134,6 +134,11 @@ func embedFilename(url string) string {
return fmt.Sprintf("cover%x.jpg", s[:8])
}
func sessionEmbedDir(folder string) string {
key := sha1.Sum([]byte(folder))
return filepath.Join(os.TempDir(), "streamrip-go-artwork", fmt.Sprintf("%x", key[:8]))
}
func stringAny(v any) string {
s, _ := v.(string)
return s

View File

@@ -11,7 +11,6 @@ import (
"path/filepath"
"strings"
"sync/atomic"
"time"
"github.com/vbauerster/mpb/v8"
"github.com/vbauerster/mpb/v8/decor"
@@ -38,7 +37,7 @@ func NewWithVerifySSL(verifySSL bool) *Downloader {
func NewWithOptions(verifySSL bool, showProgress bool) *Downloader {
forceProgress := strings.EqualFold(os.Getenv("STREAMRIP_GO_FORCE_PROGRESS"), "1") || strings.EqualFold(os.Getenv("STREAMRIP_GO_FORCE_PROGRESS"), "true")
interactive := showProgress && (forceProgress || (term.IsTerminal(int(os.Stderr.Fd())) && strings.ToLower(os.Getenv("TERM")) != "dumb"))
d := &Downloader{http: netutil.NewHTTPClient(2*time.Minute, verifySSL), showProgress: interactive}
d := &Downloader{http: netutil.NewHTTPClient(0, verifySSL), showProgress: interactive}
if interactive {
d.progress = mpb.New(mpb.WithWidth(40), mpb.WithOutput(os.Stderr))
}
@@ -46,14 +45,18 @@ func NewWithOptions(verifySSL bool, showProgress bool) *Downloader {
}
func (d *Downloader) File(ctx context.Context, sourceURL, outputPath string) error {
return d.file(ctx, sourceURL, outputPath, true)
return d.file(ctx, sourceURL, outputPath, true, false)
}
func (d *Downloader) FileNoProgress(ctx context.Context, sourceURL, outputPath string) error {
return d.file(ctx, sourceURL, outputPath, false)
return d.file(ctx, sourceURL, outputPath, false, false)
}
func (d *Downloader) file(ctx context.Context, sourceURL, outputPath string, allowProgress bool) error {
func (d *Downloader) FileVideo(ctx context.Context, sourceURL, outputPath string) error {
return d.file(ctx, sourceURL, outputPath, true, true)
}
func (d *Downloader) file(ctx context.Context, sourceURL, outputPath string, allowProgress bool, includeVideo bool) error {
if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil {
return err
}
@@ -77,7 +80,7 @@ func (d *Downloader) file(ctx context.Context, sourceURL, outputPath string, all
peek, _ := reader.Peek(1024)
if isManifestResponse(resp.Header.Get("Content-Type"), peek) {
_ = resp.Body.Close()
return d.streamManifestWithFFmpeg(ctx, sourceURL, outputPath)
return d.streamManifestWithFFmpeg(ctx, sourceURL, outputPath, includeVideo)
}
out, err := os.Create(outputPath)
@@ -162,7 +165,7 @@ func shortenName(name string, max int) string {
return string(r[:max-3]) + "..."
}
func (d *Downloader) streamManifestWithFFmpeg(ctx context.Context, sourceURL, outputPath string) error {
func (d *Downloader) streamManifestWithFFmpeg(ctx context.Context, sourceURL, outputPath string, includeVideo bool) error {
if _, err := exec.LookPath("ffmpeg"); err != nil {
return fmt.Errorf("ffmpeg not found for manifest stream: %w", err)
}
@@ -171,10 +174,13 @@ func (d *Downloader) streamManifestWithFFmpeg(ctx context.Context, sourceURL, ou
"-y",
"-protocol_whitelist", "file,http,https,tcp,tls,crypto,data",
"-i", sourceURL,
"-map", "0:a:0",
"-c", "copy",
outputPath,
}
if includeVideo {
args = append(args, "-map", "0")
} else {
args = append(args, "-map", "0:a:0")
}
args = append(args, "-c", "copy", outputPath)
cmd := exec.CommandContext(ctx, "ffmpeg", args...)
output, err := cmd.CombinedOutput()

View File

@@ -9,6 +9,13 @@ import (
"testing"
)
func TestDownloaderHasNoClientTimeout(t *testing.T) {
d := NewWithOptions(true, false)
if d.http.Timeout != 0 {
t.Fatalf("http timeout = %v, want 0 (no global timeout)", d.http.Timeout)
}
}
func TestDownloaderFile(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("abc123"))

View File

@@ -0,0 +1,451 @@
package deezer
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os/exec"
"strconv"
"strings"
"time"
"streamrip-go/internal/config"
"streamrip-go/internal/netutil"
"streamrip-go/internal/provider"
"streamrip-go/internal/ratelimit"
)
var baseURL = "https://api.deezer.com"
type commandRunner func(ctx context.Context, name string, args ...string) ([]byte, error)
type Client struct {
cfg *config.Config
http *http.Client
limiter *ratelimit.Limiter
loggedIn bool
bin string
run commandRunner
}
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),
bin: "yt-dlp",
run: runCommand,
}
}
func (c *Client) Source() string {
return "deezer"
}
func (c *Client) Login(context.Context) error {
c.loggedIn = true
return nil
}
func (c *Client) LoggedIn() bool {
return c.loggedIn
}
func (c *Client) Close() error {
return nil
}
func (c *Client) Search(ctx context.Context, mediaType, query string, limit int) ([]map[string]any, error) {
if !c.loggedIn {
return nil, errors.New("deezer client not logged in")
}
if limit <= 0 {
limit = 25
}
pathType := mediaType
if mediaType == "playlist" {
pathType = "playlist"
}
params := url.Values{}
params.Set("q", query)
params.Set("limit", strconv.Itoa(limit))
resp, err := c.apiGet(ctx, "/search/"+pathType, params)
if err != nil {
return nil, err
}
data, _ := resp["data"].([]any)
if len(data) == 0 {
return []map[string]any{}, nil
}
bucket := map[string]any{"items": data}
return []map[string]any{{mediaType + "s": bucket}}, nil
}
func (c *Client) GetMetadata(ctx context.Context, item, mediaType string) (map[string]any, error) {
if !c.loggedIn {
return nil, errors.New("deezer client not logged in")
}
switch mediaType {
case "track":
resp, err := c.apiGet(ctx, "/track/"+item, nil)
if err != nil {
return nil, err
}
enrichTrack(resp)
return resp, nil
case "album":
resp, err := c.apiGet(ctx, "/album/"+item, nil)
if err != nil {
return nil, err
}
items := make([]any, 0)
if tracks, ok := resp["tracks"].(map[string]any); ok {
if data, ok := tracks["data"].([]any); ok {
for _, raw := range data {
itm, ok := raw.(map[string]any)
if !ok {
continue
}
enrichTrack(itm)
items = append(items, itm)
}
}
}
resp["tracks"] = map[string]any{"items": items}
enrichAlbumImage(resp)
return resp, nil
case "playlist":
resp, err := c.apiGet(ctx, "/playlist/"+item, nil)
if err != nil {
return nil, err
}
items := make([]any, 0)
if tracks, ok := resp["tracks"].(map[string]any); ok {
if data, ok := tracks["data"].([]any); ok {
for _, raw := range data {
itm, ok := raw.(map[string]any)
if !ok {
continue
}
enrichTrack(itm)
items = append(items, itm)
}
}
}
resp["tracks"] = map[string]any{"items": items}
return resp, nil
case "artist":
resp, err := c.apiGet(ctx, "/artist/"+item+"/albums", nil)
if err != nil {
return nil, err
}
albums := make([]any, 0)
if data, ok := resp["data"].([]any); ok {
for _, raw := range data {
itm, ok := raw.(map[string]any)
if !ok {
continue
}
enrichAlbumImage(itm)
albums = append(albums, itm)
}
}
return map[string]any{"name": "", "albums": map[string]any{"items": albums}}, nil
default:
return nil, fmt.Errorf("unsupported deezer media type: %s", mediaType)
}
}
func (c *Client) GetDownloadable(ctx context.Context, item string, _ int) (*provider.Downloadable, error) {
meta, err := c.GetMetadata(ctx, item, "track")
if err != nil {
return nil, err
}
if c.shouldTryYtDlp() {
d, dlErr := c.getDownloadableViaYtDlp(ctx, item, meta)
if dlErr == nil {
return d, nil
}
if !c.cfg.Session.Deezer.LowerQualityIfNotAvailable {
return nil, dlErr
}
}
preview := strings.TrimSpace(stringFromAny(meta["preview"]))
if preview == "" {
return nil, errors.New("deezer track missing preview url")
}
return &provider.Downloadable{URL: preview, Extension: "mp3", Source: "deezer"}, nil
}
func (c *Client) shouldTryYtDlp() bool {
if c.cfg == nil {
return false
}
if c.cfg.Session.Deezer.UseDeezloader {
return true
}
return strings.TrimSpace(c.cfg.Session.Deezer.ARL) != ""
}
func (c *Client) getDownloadableViaYtDlp(ctx context.Context, trackID string, meta map[string]any) (*provider.Downloadable, error) {
if _, err := exec.LookPath(c.bin); err != nil {
return nil, fmt.Errorf("yt-dlp not found for deezer full-quality mode: %w", err)
}
target := strings.TrimSpace(stringFromAny(meta["link"]))
if target == "" {
target = "https://www.deezer.com/track/" + trackID
}
args := []string{"-J", "--no-playlist", "--skip-download", "--no-warnings"}
if arl := strings.TrimSpace(c.cfg.Session.Deezer.ARL); arl != "" {
args = append(args, "--add-header", "Cookie: arl="+arl)
}
args = append(args, target)
b, err := c.run(ctx, c.bin, args...)
if err != nil {
return nil, err
}
info := map[string]any{}
if err = json.Unmarshal(b, &info); err != nil {
return nil, err
}
f := selectDeezerFormat(info, c.cfg.Session.Deezer.Quality)
if f.url == "" {
return nil, errors.New("yt-dlp output missing downloadable format url")
}
ext := f.ext
if ext == "" {
ext = "mp3"
}
return &provider.Downloadable{URL: f.url, Extension: ext, Source: "deezer"}, nil
}
type deezerFormat struct {
url string
ext string
abr int
}
func selectDeezerFormat(info map[string]any, quality int) deezerFormat {
formats, _ := info["formats"].([]any)
selected := deezerFormat{}
pick := func(candidate deezerFormat, better func(cur, next deezerFormat) bool) {
if candidate.url == "" {
return
}
if selected.url == "" || better(selected, candidate) {
selected = candidate
}
}
for _, raw := range formats {
m, ok := raw.(map[string]any)
if !ok {
continue
}
if strings.TrimSpace(stringFromAny(m["vcodec"])) != "none" {
continue
}
cand := deezerFormat{
url: strings.TrimSpace(stringFromAny(m["url"])),
ext: strings.TrimSpace(stringFromAny(m["ext"])),
abr: intFromAny(m["abr"]),
}
if quality >= 2 {
pick(cand, func(cur, next deezerFormat) bool {
curFlac := strings.EqualFold(cur.ext, "flac")
nextFlac := strings.EqualFold(next.ext, "flac")
if curFlac != nextFlac {
return nextFlac
}
return next.abr > cur.abr
})
continue
}
if quality == 1 {
pick(cand, func(cur, next deezerFormat) bool {
curScore := abrScore(cur.abr, 320)
nextScore := abrScore(next.abr, 320)
if curScore == nextScore {
return next.abr > cur.abr
}
return nextScore > curScore
})
continue
}
pick(cand, func(cur, next deezerFormat) bool {
curScore := abrScore(cur.abr, 128)
nextScore := abrScore(next.abr, 128)
if curScore == nextScore {
if cur.abr == 0 {
return next.abr > 0
}
if next.abr == 0 {
return false
}
return next.abr < cur.abr
}
return nextScore > curScore
})
}
if selected.url != "" {
return selected
}
rootURL := strings.TrimSpace(stringFromAny(info["url"]))
if rootURL == "" {
return deezerFormat{}
}
return deezerFormat{url: rootURL, ext: strings.TrimSpace(stringFromAny(info["ext"])), abr: intFromAny(info["abr"])}
}
func abrScore(abr int, target int) int {
if abr <= 0 {
return -1
}
if abr > target {
return target - (abr-target)*2
}
return abr
}
func (c *Client) apiGet(ctx context.Context, path string, params url.Values) (map[string]any, error) {
if err := c.limiter.Wait(ctx); err != nil {
return nil, err
}
u := strings.TrimSuffix(baseURL, "/") + "/" + strings.TrimPrefix(path, "/")
if len(params) > 0 {
u += "?" + params.Encode()
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "streamrip-go/0.1")
resp, err := c.http.Do(req)
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
out := map[string]any{}
if len(body) > 0 {
if err = json.Unmarshal(body, &out); err != nil {
return nil, err
}
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("deezer api failed: status=%d body=%s", resp.StatusCode, string(body))
}
if e := stringFromAny(out["error"]); e != "" {
return nil, fmt.Errorf("deezer api error: %s", e)
}
return out, nil
}
func enrichTrack(track map[string]any) {
if artist, ok := track["artist"].(map[string]any); ok {
track["performer"] = map[string]any{"name": stringFromAny(artist["name"]), "id": stringFromAny(artist["id"])}
}
if album, ok := track["album"].(map[string]any); ok {
enrichAlbumImage(album)
}
if _, ok := track["track_number"]; !ok {
if p := track["track_position"]; p != nil {
track["track_number"] = p
}
}
if _, ok := track["media_number"]; !ok {
if d := track["disk_number"]; d != nil {
track["media_number"] = d
}
}
if v := stringFromAny(track["explicit_lyrics"]); v == "true" {
track["explicit"] = true
}
}
func enrichAlbumImage(meta map[string]any) {
if _, ok := meta["image"].(map[string]any); ok {
return
}
cover := firstNonEmpty(
stringFromAny(meta["cover_xl"]),
stringFromAny(meta["cover_big"]),
stringFromAny(meta["cover_medium"]),
stringFromAny(meta["cover_small"]),
)
if cover == "" {
return
}
meta["image"] = map[string]any{
"small": cover,
"large": cover,
"extralarge": cover,
"original": cover,
}
}
func stringFromAny(v any) string {
switch t := v.(type) {
case string:
return t
case int:
return strconv.Itoa(t)
case int64:
return strconv.FormatInt(t, 10)
case float64:
return strconv.FormatFloat(t, 'f', -1, 64)
default:
return ""
}
}
func firstNonEmpty(items ...string) string {
for _, item := range items {
if strings.TrimSpace(item) != "" {
return strings.TrimSpace(item)
}
}
return ""
}
func intFromAny(v any) int {
switch t := v.(type) {
case int:
return t
case int64:
return int(t)
case float64:
return int(t)
case string:
i, _ := strconv.Atoi(strings.TrimSpace(t))
return i
default:
return 0
}
}
func runCommand(ctx context.Context, name string, args ...string) ([]byte, error) {
cmd := exec.CommandContext(ctx, name, args...)
b, err := cmd.CombinedOutput()
if err != nil {
return nil, fmt.Errorf("command %s failed: %w: %s", name, err, string(b))
}
return b, nil
}

View File

@@ -0,0 +1,66 @@
package deezer
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"streamrip-go/internal/config"
)
func TestSearchTrack(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/search/track":
_ = json.NewEncoder(w).Encode(map[string]any{"data": []any{map[string]any{"id": 1, "title": "Dreams", "artist": map[string]any{"name": "Fleetwood Mac"}}}})
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer ts.Close()
cfgData := config.DefaultConfigData()
c := New(&config.Config{File: cfgData, Session: cfgData})
c.loggedIn = true
orig := baseURL
baseURL = ts.URL
defer func() { baseURL = orig }()
pages, err := c.Search(context.Background(), "track", "dreams", 5)
if err != nil {
t.Fatalf("Search() error = %v", err)
}
if len(pages) != 1 {
t.Fatalf("pages len = %d, want 1", len(pages))
}
}
func TestGetDownloadableUsesPreview(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/track/42":
_ = json.NewEncoder(w).Encode(map[string]any{"id": 42, "title": "X", "preview": "https://cdn.example/p.mp3"})
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer ts.Close()
cfgData := config.DefaultConfigData()
c := New(&config.Config{File: cfgData, Session: cfgData})
c.loggedIn = true
orig := baseURL
baseURL = ts.URL
defer func() { baseURL = orig }()
d, err := c.GetDownloadable(context.Background(), "42", 0)
if err != nil {
t.Fatalf("GetDownloadable() error = %v", err)
}
if d.URL != "https://cdn.example/p.mp3" || d.Extension != "mp3" {
t.Fatalf("unexpected downloadable: %+v", d)
}
}

View File

@@ -0,0 +1,348 @@
package soundcloud
import (
"context"
"encoding/json"
"errors"
"fmt"
"os/exec"
"strconv"
"strings"
"sync"
"streamrip-go/internal/config"
"streamrip-go/internal/provider"
)
var errUnsupportedMediaType = errors.New("unsupported soundcloud media type")
type commandRunner func(ctx context.Context, name string, args ...string) ([]byte, error)
type Client struct {
cfg *config.Config
loggedIn bool
bin string
run commandRunner
mu sync.Mutex
cache map[string]map[string]any
}
func New(cfg *config.Config) *Client {
return &Client{
cfg: cfg,
bin: "yt-dlp",
run: runCommand,
cache: map[string]map[string]any{},
}
}
func (c *Client) Source() string {
return "soundcloud"
}
func (c *Client) LoggedIn() bool {
return c.loggedIn
}
func (c *Client) Login(context.Context) error {
if _, err := exec.LookPath(c.bin); err != nil {
return fmt.Errorf("yt-dlp is required for soundcloud downloads/search. install it and ensure it is in $PATH (e.g. pipx install yt-dlp): %w", err)
}
c.loggedIn = true
return nil
}
func (c *Client) Search(ctx context.Context, mediaType, query string, limit int) ([]map[string]any, error) {
if !c.loggedIn {
return nil, errors.New("soundcloud client not logged in")
}
if mediaType != "track" {
return nil, fmt.Errorf("%w: %s", errUnsupportedMediaType, mediaType)
}
if limit <= 0 {
limit = 20
}
target := fmt.Sprintf("scsearch%d:%s", limit, query)
b, err := c.run(ctx, c.bin, "-J", "--flat-playlist", "--skip-download", "--no-warnings", target)
if err != nil {
return nil, err
}
root, err := parseJSONMap(b)
if err != nil {
return nil, err
}
entries := asAnySlice(root["entries"])
if len(entries) == 0 {
return []map[string]any{}, nil
}
items := make([]any, 0, len(entries))
for _, e := range entries {
m, ok := e.(map[string]any)
if !ok {
continue
}
id := strings.TrimSpace(stringFromAny(m["webpage_url"]))
if id == "" {
id = strings.TrimSpace(stringFromAny(m["url"]))
}
if id == "" {
continue
}
artist := strings.TrimSpace(stringFromAny(m["uploader"]))
if artist == "" {
artist = strings.TrimSpace(stringFromAny(m["channel"]))
}
item := map[string]any{
"id": id,
"title": stringFromAny(m["title"]),
"artist": map[string]any{
"name": artist,
},
}
items = append(items, item)
}
return []map[string]any{{"items": items}}, nil
}
func (c *Client) GetMetadata(ctx context.Context, item, mediaType string) (map[string]any, error) {
if !c.loggedIn {
return nil, errors.New("soundcloud client not logged in")
}
switch mediaType {
case "track":
info, err := c.trackInfo(ctx, item)
if err != nil {
return nil, err
}
return trackMetadataFromInfo(item, info), nil
case "playlist":
b, err := c.run(ctx, c.bin, "-J", "--skip-download", "--no-warnings", item)
if err != nil {
return nil, err
}
root, err := parseJSONMap(b)
if err != nil {
return nil, err
}
tracks := make([]any, 0)
for _, raw := range asAnySlice(root["entries"]) {
entry, ok := raw.(map[string]any)
if !ok {
continue
}
id := strings.TrimSpace(stringFromAny(entry["webpage_url"]))
if id == "" {
id = strings.TrimSpace(stringFromAny(entry["url"]))
}
if id == "" {
continue
}
tracks = append(tracks, map[string]any{"id": id})
}
name := strings.TrimSpace(stringFromAny(root["title"]))
if name == "" {
name = "SoundCloud Playlist"
}
return map[string]any{
"name": name,
"tracks": map[string]any{"items": tracks},
}, nil
default:
return nil, fmt.Errorf("%w: %s", errUnsupportedMediaType, mediaType)
}
}
func (c *Client) GetDownloadable(ctx context.Context, item string, _ int) (*provider.Downloadable, error) {
if !c.loggedIn {
return nil, errors.New("soundcloud client not logged in")
}
info, err := c.trackInfo(ctx, item)
if err != nil {
return nil, err
}
streamURL := strings.TrimSpace(stringFromAny(info["url"]))
if streamURL == "" {
return nil, errors.New("yt-dlp output missing url")
}
ext := strings.TrimSpace(stringFromAny(info["ext"]))
if ext == "" {
ext = "m4a"
}
return &provider.Downloadable{URL: streamURL, Extension: ext, Source: "soundcloud"}, nil
}
func (c *Client) Close() error {
return nil
}
func (c *Client) trackInfo(ctx context.Context, item string) (map[string]any, error) {
if strings.TrimSpace(item) == "" {
return nil, errors.New("empty soundcloud item")
}
c.mu.Lock()
if cached, ok := c.cache[item]; ok {
copied := cloneMap(cached)
c.mu.Unlock()
return copied, nil
}
c.mu.Unlock()
b, err := c.run(ctx, c.bin, "-J", "--no-playlist", "--skip-download", "--no-warnings", item)
if err != nil {
return nil, err
}
info, err := parseJSONMap(b)
if err != nil {
return nil, err
}
c.mu.Lock()
c.cache[item] = cloneMap(info)
c.mu.Unlock()
return info, nil
}
func trackMetadataFromInfo(id string, info map[string]any) map[string]any {
title := strings.TrimSpace(stringFromAny(info["title"]))
if title == "" {
title = id
}
artistName := strings.TrimSpace(stringFromAny(info["artist"]))
if artistName == "" {
artistName = strings.TrimSpace(stringFromAny(info["uploader"]))
}
if artistName == "" {
artistName = strings.TrimSpace(stringFromAny(info["channel"]))
}
trackNum := intFromAny(info["track_number"])
if trackNum <= 0 {
trackNum = 1
}
meta := map[string]any{
"id": id,
"title": title,
"track_number": trackNum,
"artist": map[string]any{"name": artistName},
"performer": map[string]any{"name": artistName},
"album": map[string]any{
"id": strings.TrimSpace(stringFromAny(info["album"])),
"title": strings.TrimSpace(stringFromAny(info["album"])),
"artist": map[string]any{"name": artistName},
},
"description": strings.TrimSpace(stringFromAny(info["description"])),
"genre": strings.TrimSpace(stringFromAny(info["genre"])),
"release_date": strings.TrimSpace(firstNonEmpty(
stringFromAny(info["release_date"]),
stringFromAny(info["upload_date"]),
)),
}
if meta["release_date"] == "" {
delete(meta, "release_date")
}
if thumb := strings.TrimSpace(stringFromAny(info["thumbnail"])); thumb != "" {
meta["image"] = map[string]any{
"small": thumb,
"large": thumb,
"extralarge": thumb,
"original": thumb,
}
}
if album := strings.TrimSpace(stringFromAny(info["album"])); album == "" {
meta["album"] = map[string]any{
"id": id,
"title": title,
"artist": map[string]any{"name": artistName},
}
}
if durationSec := intFromAny(info["duration"]); durationSec > 0 {
meta["duration"] = durationSec
}
return meta
}
func parseJSONMap(b []byte) (map[string]any, error) {
var out map[string]any
if err := json.Unmarshal(b, &out); err != nil {
return nil, err
}
if out == nil {
return nil, errors.New("empty json payload")
}
return out, nil
}
func cloneMap(in map[string]any) map[string]any {
out := make(map[string]any, len(in))
for k, v := range in {
out[k] = v
}
return out
}
func asAnySlice(v any) []any {
items, ok := v.([]any)
if !ok {
return nil
}
return items
}
func stringFromAny(v any) string {
switch t := v.(type) {
case string:
return t
case int:
return strconv.Itoa(t)
case int64:
return strconv.FormatInt(t, 10)
case float64:
return strconv.FormatFloat(t, 'f', -1, 64)
default:
return ""
}
}
func intFromAny(v any) int {
switch t := v.(type) {
case int:
return t
case int64:
return int(t)
case float64:
return int(t)
case string:
i, _ := strconv.Atoi(strings.TrimSpace(t))
return i
default:
return 0
}
}
func firstNonEmpty(items ...string) string {
for _, item := range items {
if strings.TrimSpace(item) != "" {
return strings.TrimSpace(item)
}
}
return ""
}
func runCommand(ctx context.Context, name string, args ...string) ([]byte, error) {
cmd := exec.CommandContext(ctx, name, args...)
b, err := cmd.CombinedOutput()
if err != nil {
return nil, fmt.Errorf("command %s failed: %w: %s", name, err, string(b))
}
return b, nil
}

View File

@@ -0,0 +1,106 @@
package soundcloud
import (
"context"
"fmt"
"strings"
"testing"
"streamrip-go/internal/config"
)
func TestGetTrackMetadataAndDownloadable(t *testing.T) {
cfgData := config.DefaultConfigData()
c := New(&config.Config{File: cfgData, Session: cfgData})
c.loggedIn = true
c.run = func(_ context.Context, _ string, args ...string) ([]byte, error) {
joined := strings.Join(args, " ")
if strings.Contains(joined, "--no-playlist") {
return []byte(`{"title":"Lean On","uploader":"Major Lazer","url":"https://cdn.example/audio.m4a","ext":"m4a","thumbnail":"https://img.example/cover.jpg"}`), nil
}
return nil, fmt.Errorf("unexpected args: %v", args)
}
meta, err := c.GetMetadata(context.Background(), "https://soundcloud.com/a/b", "track")
if err != nil {
t.Fatalf("GetMetadata() error = %v", err)
}
if stringFromAny(meta["title"]) != "Lean On" {
t.Fatalf("title = %q, want Lean On", stringFromAny(meta["title"]))
}
d, err := c.GetDownloadable(context.Background(), "https://soundcloud.com/a/b", 0)
if err != nil {
t.Fatalf("GetDownloadable() error = %v", err)
}
if d.URL != "https://cdn.example/audio.m4a" || d.Extension != "m4a" {
t.Fatalf("unexpected downloadable: %+v", d)
}
}
func TestGetPlaylistMetadata(t *testing.T) {
cfgData := config.DefaultConfigData()
c := New(&config.Config{File: cfgData, Session: cfgData})
c.loggedIn = true
c.run = func(_ context.Context, _ string, args ...string) ([]byte, error) {
joined := strings.Join(args, " ")
if strings.Contains(joined, "--skip-download") && !strings.Contains(joined, "--no-playlist") {
return []byte(`{"title":"Road Trip","entries":[{"webpage_url":"https://soundcloud.com/a/t1"},{"url":"https://soundcloud.com/a/t2"}]}`), nil
}
return nil, fmt.Errorf("unexpected args: %v", args)
}
meta, err := c.GetMetadata(context.Background(), "https://soundcloud.com/a/sets/road-trip", "playlist")
if err != nil {
t.Fatalf("GetMetadata() error = %v", err)
}
if stringFromAny(meta["name"]) != "Road Trip" {
t.Fatalf("name = %q, want Road Trip", stringFromAny(meta["name"]))
}
tracksMap, ok := meta["tracks"].(map[string]any)
if !ok {
t.Fatalf("tracks missing")
}
items := asAnySlice(tracksMap["items"])
if len(items) != 2 {
t.Fatalf("playlist items len = %d, want 2", len(items))
}
}
func TestSearchTrack(t *testing.T) {
cfgData := config.DefaultConfigData()
c := New(&config.Config{File: cfgData, Session: cfgData})
c.loggedIn = true
c.run = func(_ context.Context, _ string, args ...string) ([]byte, error) {
joined := strings.Join(args, " ")
if strings.Contains(joined, "scsearch2:lean on") {
return []byte(`{"entries":[{"title":"Lean On","uploader":"Major Lazer","webpage_url":"https://soundcloud.com/a/b"}]}`), nil
}
return nil, fmt.Errorf("unexpected args: %v", args)
}
pages, err := c.Search(context.Background(), "track", "lean on", 2)
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))
}
}
func TestLoginShowsYtDlpHint(t *testing.T) {
cfgData := config.DefaultConfigData()
c := New(&config.Config{File: cfgData, Session: cfgData})
c.bin = "definitely-not-a-real-yt-dlp-bin"
err := c.Login(context.Background())
if err == nil {
t.Fatalf("expected login error")
}
if !strings.Contains(strings.ToLower(err.Error()), "yt-dlp is required") {
t.Fatalf("expected yt-dlp hint in error, got: %v", err)
}
}

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)
}
}

View File

@@ -174,7 +174,7 @@ func isDeezerHost(host string) bool {
func isSupportedMedia(mediaType string) bool {
switch mediaType {
case "album", "track", "playlist", "artist", "label":
case "album", "track", "playlist", "artist", "label", "video":
return true
default:
return false

View File

@@ -38,6 +38,17 @@ func TestTidalTrackURL(t *testing.T) {
}
}
func TestTidalVideoURL(t *testing.T) {
url := "https://tidal.com/browse/video/59727844"
result := Parse(url)
if result == nil {
t.Fatalf("expected parsed url")
}
if result.Source != "tidal" || result.MediaType != "video" || result.ID != "59727844" {
t.Fatalf("unexpected parse result: %+v", result)
}
}
func TestDeezerTrackURL(t *testing.T) {
url := "https://www.deezer.com/track/4195713"
result := Parse(url)