Author SHA1 Message Date
Joren 80bfbe0ecb feat: infer beatport release artist 2026-07-11 16:40:04 +02:00
Joren 3413de1daa feat: format multi-artist credits 2026-07-11 04:44:54 +02:00
Joren d2fa098d69 feat: add configurable artist separator 2026-07-11 01:21:40 +02:00
Joren 537959b6ec feat: add beatport provider 2026-07-10 19:21:14 +02:00
Joren 2a7d259e9f Merge branch 'feat/yandex-music'
# Conflicts:
#	internal/audio/tag/tagger.go
#	internal/audio/tag/tagger_test.go
#	internal/urlparse/parse_test.go
2026-07-10 18:18:04 +02:00
Joren b65edb4cce yes 2026-06-15 17:18:47 +02:00
Joren 0ae8c7e008 feat yandex desktop downloads 2026-06-10 12:58:04 +02:00
Joren db26a40415 fix qobuz interpreter artist urls 2026-06-10 12:34:37 +02:00
Joren fa39582849 fix tidal playlist metadata and retries 2026-05-21 23:03:27 +02:00
lb-a 3bc965db77 Merge branch 'feat/verbose' 2026-05-07 23:56:42 +02:00
lb-a 3909ba5113 feat: implement second level of verbosity 2026-05-07 18:45:36 +02:00
lb-a 04cc56040b feat: add log hooks 2026-05-07 18:45:05 +02:00
lb-a ef741434cb feat: ad verbose package 2026-05-07 18:42:24 +02:00
Joren ef72aad14e Merge branch 'fix/qobuz-mobile-fallback-eof' 2026-05-05 21:51:19 +02:00
Joren 59b476034e fix qobuz EOF downloads with mobile fallback flow 2026-05-01 17:23:52 +02:00
lb-a 9618108f2a performance: exclude lyrics if they are not enabled 2026-04-30 12:00:58 +02:00
lb-a 7a27845e75 performance: change I/O buffer size 2026-04-29 23:56:05 +02:00
lb-a 945695cea7 performance: tune shared HTTP transport for concurrent CDN downloads 2026-04-29 23:53:51 +02:00
Joren 9e27ba842f fix deezer track pagination and decryption track id resolution 2026-04-26 22:23:23 +02:00
Joren 63e1f20e04 fix tidal lyrics fetch host and synced lyric tagging 2026-04-25 01:24:11 +02:00
Joren 5a6d7926ff Merge remote-tracking branch 'origin/master' into fix/resolved-audio-folder-naming 2026-04-24 18:37:30 +02:00
Joren a06e3fa996 clarify provider quality ladders in config example 2026-04-24 18:36:01 +02:00
Joren b63889f559 refactor deezer session and pipe graphql flow 2026-04-24 15:19:47 +02:00
Joren c29f5415ac fix deezer mobile token and launch parity 2026-04-24 15:11:17 +02:00
Joren 6f6cca19b3 adjust atmos folder bit depth to match delivered stream 2026-04-24 00:45:53 +02:00
Joren 232901f3eb unify folder naming with resolved audio profiles across providers 2026-04-24 00:35:38 +02:00
Joren d5b336ca4e fix tidal lossless quality negotiation and atmos format fallback 2026-04-23 23:53:41 +02:00
Joren c1e89f5876 render manifest ffmpeg progress like standard download bars 2026-04-22 00:40:11 +02:00
Joren 50ca5f564b improve search menu preview and artist release details 2026-04-22 00:16:59 +02:00
Joren 6bc4b3b319 Refactor: comprehensive cleanup and modularization
- Extracted common JSON parsing helpers into internal/jsonutil
- Removed duplicated helper functions from provider packages
- Removed dead code in internal/app/app.go and downloader.go
- Replaced deprecated strings.Title with jsonutil.TitleCase
- Added graceful shutdown with signal handling in main.go
- Split monolithic cmd/rip/main.go into args.go, helpers.go, lastfm.go, search.go
2026-04-21 23:38:41 +02:00
Joren d65dc182f8 harden ffmpeg pipeline failure handling and stream mapping 2026-04-21 23:07:48 +02:00
Joren beb6ce6cbb improve ffmpeg manifest progress rendering 2026-04-21 22:28:37 +02:00
Joren 96348a6a34 harden manifest detection and downloader failure coverage 2026-04-21 20:55:14 +02:00
Joren dfa8095e1d fix lastfm extraction regression and honor no-db 2026-04-21 20:40:28 +02:00
Joren 4c7e6f5792 tighten lastfm parsing and locale url handling 2026-04-21 19:21:32 +02:00
Joren ba97fe85fe harden lastfm playlist parsing and url validation 2026-04-21 19:10:50 +02:00
Joren c67be72869 harden search parsing and qobuz refresh validation 2026-04-21 19:04:33 +02:00
Joren de4e561377 harden qobuz and downloader reliability edge cases 2026-04-21 18:54:10 +02:00
Joren 0161c01a4c add optional tidal atmos preference with immersive fallback 2026-04-21 18:31:58 +02:00
Joren 6dbf6a222d paginate tidal artist album fetches across all pages 2026-04-21 17:33:25 +02:00
Joren 654bed17e2 harden deezer auth errors and mixed playlist preflight 2026-04-21 12:19:23 +02:00
Joren 1246a24749 map deezer gain to replaygain track metadata 2026-04-21 11:51:17 +02:00
Joren 4f86751ff4 paginate artist album fetching for deezer and qobuz 2026-04-21 11:39:04 +02:00
Joren 9ebddc8316 harden deezer auth and lyrics tagging behavior 2026-04-21 11:14:57 +02:00
Joren 26c9d50fac implement native Deezer download/decrypt pipeline
Replace Deezer yt-dlp usage with native ARL session + media.get_url resolution, add BF_CBC_STRIPE decryption in downloader, and wire cipher-aware Deezer downloads through the main rip pipeline. Includes validation hardening and metadata/source-id improvements used by tagging flows.
2026-04-21 00:48:07 +02:00
Joren 0ba8faa943 improve CLI error semantics and soundcloud canonicalization
Auto-upgrade outdated configs on startup, add actionable SSL verification hints in rip error paths, and harden SoundCloud search/metadata with canonical URL handling and richer source IDs.
2026-04-20 15:16:59 +02:00
38 changed files with 11916 additions and 2139 deletions
+217
View File
@@ -0,0 +1,217 @@
package main
import (
"fmt"
"strconv"
"strings"
"streamrip-go/internal/config"
)
type smokeOptions struct {
qualitySet bool
quality int
ignoreDB bool
}
type globalOptions struct {
configPath string
folder string
noDB bool
qualitySet bool
quality int
codecSet bool
codec string
noProgress bool
noSSLVerify bool
verbose int
command string
commandArgs []string
}
func parseGlobalArgs(args []string) (globalOptions, error) {
opts := globalOptions{}
for i := 0; i < len(args); i++ {
arg := args[i]
if arg == "" {
continue
}
if !strings.HasPrefix(arg, "-") {
opts.command = arg
if i+1 < len(args) {
opts.commandArgs = append([]string(nil), args[i+1:]...)
}
return opts, nil
}
switch {
case arg == "-ndb" || arg == "--no-db":
opts.noDB = true
case arg == "--no-progress":
opts.noProgress = true
case arg == "--no-ssl-verify":
opts.noSSLVerify = true
case arg == "-v" || arg == "--verbose":
if opts.verbose < 2 {
opts.verbose++
}
case arg == "-vv":
if opts.verbose < 2 {
opts.verbose = 2
}
case arg == "-f" || arg == "--folder":
if i+1 >= len(args) {
return globalOptions{}, fmt.Errorf("%s requires a value", arg)
}
opts.folder = strings.TrimSpace(args[i+1])
i++
case strings.HasPrefix(arg, "--folder="):
opts.folder = strings.TrimSpace(strings.TrimPrefix(arg, "--folder="))
case arg == "--config-path":
if i+1 >= len(args) {
return globalOptions{}, fmt.Errorf("--config-path requires a value")
}
opts.configPath = strings.TrimSpace(args[i+1])
i++
case strings.HasPrefix(arg, "--config-path="):
opts.configPath = strings.TrimSpace(strings.TrimPrefix(arg, "--config-path="))
case arg == "-q" || arg == "--quality":
if i+1 >= len(args) {
return globalOptions{}, fmt.Errorf("%s requires a value", arg)
}
q, err := strconv.Atoi(args[i+1])
if err != nil || q < 0 || q > 4 {
return globalOptions{}, fmt.Errorf("invalid quality %q (expected 0-4)", args[i+1])
}
opts.qualitySet = true
opts.quality = q
i++
case strings.HasPrefix(arg, "--quality="):
qRaw := strings.TrimSpace(strings.TrimPrefix(arg, "--quality="))
q, err := strconv.Atoi(qRaw)
if err != nil || q < 0 || q > 4 {
return globalOptions{}, fmt.Errorf("invalid quality %q (expected 0-4)", qRaw)
}
opts.qualitySet = true
opts.quality = q
case arg == "-c" || arg == "--codec":
if i+1 >= len(args) {
return globalOptions{}, fmt.Errorf("%s requires a value", arg)
}
codec, err := normalizeCodec(args[i+1])
if err != nil {
return globalOptions{}, err
}
opts.codecSet = true
opts.codec = codec
i++
case strings.HasPrefix(arg, "--codec="):
codecRaw := strings.TrimSpace(strings.TrimPrefix(arg, "--codec="))
codec, err := normalizeCodec(codecRaw)
if err != nil {
return globalOptions{}, err
}
opts.codecSet = true
opts.codec = codec
default:
return globalOptions{}, fmt.Errorf("unknown global option %q", arg)
}
}
return opts, nil
}
func normalizeCodec(raw string) (string, error) {
codec := strings.ToUpper(strings.TrimSpace(raw))
switch codec {
case "ALAC", "FLAC", "MP3", "AAC", "VORBIS":
return codec, nil
case "OGG":
return "VORBIS", nil
default:
return "", fmt.Errorf("unsupported codec %q (expected ALAC, FLAC, OGG, MP3, AAC)", raw)
}
}
func applyGlobalConfigOverrides(cfg *config.Config, opts globalOptions) {
if opts.folder != "" {
cfg.Session.Downloads.Folder = opts.folder
}
if opts.noDB {
cfg.Session.Database.DownloadsEnabled = false
}
if opts.qualitySet {
cfg.Session.Qobuz.Quality = opts.quality
cfg.Session.Tidal.Quality = opts.quality
cfg.Session.Deezer.Quality = opts.quality
cfg.Session.Soundcloud.Quality = opts.quality
}
if opts.codecSet {
cfg.Session.Conversion.Enabled = true
cfg.Session.Conversion.Codec = opts.codec
}
if opts.noProgress {
cfg.Session.CLI.ProgressBars = false
}
if opts.noSSLVerify {
cfg.Session.Downloads.VerifySSL = false
}
}
func errorWithActionableHint(err error, opts globalOptions) string {
if err == nil {
return ""
}
msg := err.Error()
if opts.noSSLVerify {
return msg
}
lower := strings.ToLower(msg)
if strings.Contains(lower, "x509") || strings.Contains(lower, "certificate") || strings.Contains(lower, "tls") || strings.Contains(lower, "ssl") {
return msg + " (hint: try again with --no-ssl-verify)"
}
return msg
}
func parseSmokeOptions(args []string, minQuality int, maxQuality int) (smokeOptions, error) {
opts := smokeOptions{}
for _, arg := range args {
switch arg {
case "--force", "--ignore-db":
opts.ignoreDB = true
default:
q, err := parseQuality(arg, minQuality, maxQuality)
if err != nil {
return smokeOptions{}, fmt.Errorf("unknown option %q", arg)
}
opts.quality = q
opts.qualitySet = true
}
}
return opts, nil
}
func parseQuality(raw string, min int, max int) (int, error) {
q, err := strconv.Atoi(raw)
if err != nil {
return 0, err
}
if q < min || q > max {
return 0, fmt.Errorf("quality must be %d-%d, got %d", min, max, q)
}
return q, nil
}
func asString(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 ""
}
}
+234
View File
@@ -0,0 +1,234 @@
package main
import (
"bufio"
"context"
"database/sql"
"encoding/json"
"fmt"
"os"
"os/exec"
"regexp"
"runtime"
"strings"
"streamrip-go/internal/app"
"streamrip-go/internal/urlparse"
)
type fileIDItem struct {
Source string
MediaType string
ID string
}
type failedRow struct {
Source string
MediaType string
ID string
}
type lastFMOptions struct {
Source string
FallbackSource string
PlaylistURL string
}
type lastFMTrack struct {
Title string
Artist string
}
type resolvedLastFMTrack struct {
Source string
ID string
Query string
}
var (
lastFMTitleTagsRe = regexp.MustCompile(`<a\b[^>]*\btitle=(?:"([^"]+)"|'([^']+)')`)
lastFMDataTrackArtistRe = regexp.MustCompile(`data-track-name=(?:"([^"]+)"|'([^']+)')[^>]*data-artist-name=(?:"([^"]+)"|'([^']+)')`)
lastFMTotalTracksRe = regexp.MustCompile(`data-playlisting-entry-count="(\d+)"`)
lastFMPlaylistTitleRe = regexp.MustCompile(`<h1[^>]*class="[^"]*playlisting-playlist-header-title[^"]*"[^>]*>([^<]+)</h1>`)
lastFMMirrorTitleRe = regexp.MustCompile(`^Title:\s*(.+?)\s+\|`)
lastFMMirrorLinkTextRe = regexp.MustCompile(`\[([^\]]+)\]\(`)
errLastFMInvalidSource = "unsupported source"
)
func addURLToQueue(ctx context.Context, mainApp *app.Main, raw string) bool {
parsed := urlparse.Parse(raw)
if parsed == nil {
fmt.Printf("invalid: %s\n", raw)
return false
}
if parsed.Kind != urlparse.KindGeneric && parsed.Kind != urlparse.KindSoundcloud {
fmt.Printf("not yet supported: %s (kind=%s)\n", raw, parsed.Kind)
return false
}
if parsed.Source != "qobuz" && parsed.Source != "tidal" && parsed.Source != "deezer" && parsed.Source != "yandex" && parsed.Source != "beatport" && parsed.Source != "soundcloud" {
fmt.Printf("provider not yet implemented: source=%s url=%s\n", parsed.Source, raw)
return false
}
if err := mainApp.AddByID(ctx, parsed.Source, parsed.MediaType, parsed.ID); err != nil {
fmt.Printf("add failed: source=%s type=%s id=%s err=%v\n", parsed.Source, parsed.MediaType, parsed.ID, err)
return false
}
return true
}
func parseFileInput(content []byte) ([]fileIDItem, []string, int, bool, error) {
trimmed := strings.TrimSpace(string(content))
if trimmed == "" {
return nil, nil, 0, false, nil
}
var parsed any
if err := json.Unmarshal([]byte(trimmed), &parsed); err == nil {
arr, ok := parsed.([]any)
if !ok {
return nil, nil, 0, true, fmt.Errorf("json input must be an array of objects")
}
items := make([]fileIDItem, 0, len(arr))
for i, raw := range arr {
entry, ok := raw.(map[string]any)
if !ok {
return nil, nil, 0, true, fmt.Errorf("json item %d must be an object", i+1)
}
source := strings.ToLower(strings.TrimSpace(asString(entry["source"])))
mediaType := strings.ToLower(strings.TrimSpace(asString(entry["media_type"])))
if mediaType == "" {
mediaType = strings.ToLower(strings.TrimSpace(asString(entry["mediaType"])))
}
id := strings.TrimSpace(asString(entry["id"]))
if source == "" || mediaType == "" || id == "" {
return nil, nil, 0, true, fmt.Errorf("json item %d missing source/media_type/id", i+1)
}
items = append(items, fileIDItem{Source: source, MediaType: mediaType, ID: id})
}
return items, nil, 0, true, nil
}
parts := strings.Fields(trimmed)
if len(parts) == 0 {
return nil, nil, 0, false, nil
}
seen := make(map[string]struct{}, len(parts))
urls := make([]string, 0, len(parts))
repeated := 0
for _, raw := range parts {
if _, ok := seen[raw]; ok {
repeated++
continue
}
seen[raw] = struct{}{}
urls = append(urls, raw)
}
return nil, urls, repeated, false, nil
}
func promptYesNo(prompt string) (bool, error) {
reader := bufio.NewReader(os.Stdin)
fmt.Print(prompt)
line, err := reader.ReadString('\n')
if err != nil {
return false, err
}
line = strings.ToLower(strings.TrimSpace(line))
return line == "y" || line == "yes", nil
}
func openConfigInEditor(path string, vim bool) error {
launch := func(name string, args ...string) error {
cmd := exec.Command(name, args...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
if vim {
if p, err := exec.LookPath("nvim"); err == nil {
return launch(p, path)
}
if p, err := exec.LookPath("vim"); err == nil {
return launch(p, path)
}
}
if editor := strings.TrimSpace(os.Getenv("EDITOR")); editor != "" {
parts := strings.Fields(editor)
if len(parts) > 0 {
return launch(parts[0], append(parts[1:], path)...)
}
}
switch runtime.GOOS {
case "darwin":
return launch("open", path)
case "windows":
return launch("cmd", "/c", "start", "", path)
default:
if p, err := exec.LookPath("xdg-open"); err == nil {
return launch(p, path)
}
return fmt.Errorf("could not find an editor (set $EDITOR or install xdg-open)")
}
}
func listDownloadsRows(path string) ([]string, error) {
db, err := sql.Open("sqlite", path)
if err != nil {
return nil, err
}
defer func() { _ = db.Close() }()
rows, err := db.Query(`SELECT id FROM downloads ORDER BY rowid`)
if err != nil {
if isNoSuchTableErr(err) {
return []string{}, nil
}
return nil, err
}
defer func() { _ = rows.Close() }()
out := []string{}
for rows.Next() {
var id string
if err = rows.Scan(&id); err != nil {
return nil, err
}
out = append(out, id)
}
return out, rows.Err()
}
func listFailedRows(path string) ([]failedRow, error) {
db, err := sql.Open("sqlite", path)
if err != nil {
return nil, err
}
defer func() { _ = db.Close() }()
rows, err := db.Query(`SELECT source, media_type, id FROM failed_downloads ORDER BY rowid`)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
out := []failedRow{}
for rows.Next() {
var r failedRow
if err = rows.Scan(&r.Source, &r.MediaType, &r.ID); err != nil {
return nil, err
}
out = append(out, r)
}
return out, rows.Err()
}
func isNoSuchTableErr(err error) bool {
if err == nil {
return false
}
return strings.Contains(strings.ToLower(err.Error()), "no such table")
}
+430
View File
@@ -0,0 +1,430 @@
package main
import (
"context"
"encoding/json"
"fmt"
"html"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"streamrip-go/internal/app"
"streamrip-go/internal/netutil"
"streamrip-go/internal/provider"
)
func parseLastFMArgs(args []string, defaultSource, defaultFallback string) (lastFMOptions, error) {
opts := lastFMOptions{Source: strings.ToLower(strings.TrimSpace(defaultSource)), FallbackSource: strings.ToLower(strings.TrimSpace(defaultFallback))}
for i := 0; i < len(args); i++ {
switch args[i] {
case "-s", "--source":
if i+1 >= len(args) {
return lastFMOptions{}, fmt.Errorf("--source requires a value")
}
opts.Source = strings.ToLower(strings.TrimSpace(args[i+1]))
i++
case "-fs", "--fallback-source":
if i+1 >= len(args) {
return lastFMOptions{}, fmt.Errorf("--fallback-source requires a value")
}
opts.FallbackSource = strings.ToLower(strings.TrimSpace(args[i+1]))
i++
default:
if strings.HasPrefix(args[i], "-") {
return lastFMOptions{}, fmt.Errorf("unknown option %q", args[i])
}
if opts.PlaylistURL != "" {
return lastFMOptions{}, fmt.Errorf("unexpected extra argument %q", args[i])
}
opts.PlaylistURL = strings.TrimSpace(args[i])
}
}
if opts.Source == "" {
opts.Source = "qobuz"
}
if opts.PlaylistURL == "" {
return lastFMOptions{}, fmt.Errorf("missing playlist url")
}
if !isValidLastFMPlaylistURL(opts.PlaylistURL) {
return lastFMOptions{}, fmt.Errorf("playlist url must be a last.fm url")
}
if !isAllowedSearchSource(opts.Source) {
return lastFMOptions{}, fmt.Errorf("%s %q", errLastFMInvalidSource, opts.Source)
}
if opts.FallbackSource != "" && !isAllowedSearchSource(opts.FallbackSource) {
return lastFMOptions{}, fmt.Errorf("%s %q", errLastFMInvalidSource, opts.FallbackSource)
}
return opts, nil
}
func isValidLastFMPlaylistURL(raw string) bool {
u, err := url.Parse(strings.TrimSpace(raw))
if err != nil || u == nil || u.Host == "" {
return false
}
s := strings.ToLower(strings.TrimSpace(u.Scheme))
if s != "http" && s != "https" {
return false
}
h := strings.ToLower(strings.TrimPrefix(strings.TrimSpace(u.Host), "www."))
if h != "last.fm" && !strings.HasSuffix(h, ".last.fm") {
return false
}
p := strings.ToLower(strings.TrimSpace(u.Path))
return strings.Contains(p, "/playlists/")
}
func fetchLastFMPlaylist(ctx context.Context, verifySSL bool, playlistURL string) (string, []lastFMTrack, error) {
parsed, err := url.Parse(playlistURL)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return "", nil, fmt.Errorf("invalid playlist url")
}
if !isValidLastFMPlaylistURL(playlistURL) {
return "", nil, fmt.Errorf("invalid playlist url")
}
client := netutil.NewHTTPClient(30*time.Second, verifySSL, 0)
page1, err := fetchLastFMPlaylistPage(ctx, client, parsed, 1)
if err != nil {
return fetchLastFMPlaylistViaMirror(ctx, verifySSL, playlistURL)
}
title, total, err := extractLastFMPlaylistInfo(page1)
if err != nil {
return fetchLastFMPlaylistViaMirror(ctx, verifySSL, playlistURL)
}
tracks := extractLastFMTitleArtistPairs(page1)
if total <= len(tracks) || total <= 50 {
if len(tracks) > total && total > 0 {
tracks = tracks[:total]
}
return title, tracks, nil
}
remaining := total - 50
lastPage := 1 + remaining/50
if remaining%50 != 0 {
lastPage++
}
for page := 2; page <= lastPage; page++ {
body, fetchErr := fetchLastFMPlaylistPage(ctx, client, parsed, page)
if fetchErr != nil {
return "", nil, fetchErr
}
tracks = append(tracks, extractLastFMTitleArtistPairs(body)...)
}
if len(tracks) > total {
tracks = tracks[:total]
}
return title, tracks, nil
}
func fetchLastFMPlaylistViaMirror(ctx context.Context, verifySSL bool, playlistURL string) (string, []lastFMTrack, error) {
client := netutil.NewHTTPClient(30*time.Second, verifySSL, 0)
all := make([]lastFMTrack, 0, 200)
title := ""
for page := 1; page <= 50; page++ {
body, err := fetchLastFMPlaylistMirrorPage(ctx, client, playlistURL, page)
if err != nil {
if page == 1 {
return "", nil, err
}
break
}
pageTitle, tracks := extractLastFMTracksFromMirrorMarkdown(body)
if title == "" && strings.TrimSpace(pageTitle) != "" {
title = pageTitle
}
if len(tracks) == 0 {
break
}
all = append(all, tracks...)
if !strings.Contains(strings.ToLower(body), "show more") {
break
}
}
if len(all) == 0 {
return "", nil, fmt.Errorf("could not parse playlist tracks from last.fm")
}
if strings.TrimSpace(title) == "" {
title = "Last.fm Playlist"
}
return title, all, nil
}
func fetchLastFMPlaylistMirrorPage(ctx context.Context, client *http.Client, playlistURL string, page int) (string, error) {
u, err := url.Parse(playlistURL)
if err != nil {
return "", err
}
if page > 1 {
q := u.Query()
q.Set("page", strconv.Itoa(page))
u.RawQuery = q.Encode()
}
raw := u.String()
raw = strings.TrimPrefix(raw, "https://")
raw = strings.TrimPrefix(raw, "http://")
mirrorURL := "https://r.jina.ai/http://" + raw
req, err := http.NewRequestWithContext(ctx, http.MethodGet, mirrorURL, nil)
if err != nil {
return "", err
}
req.Header.Set("User-Agent", "streamrip-go/0")
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return "", fmt.Errorf("lastfm mirror request failed: status %d", resp.StatusCode)
}
b, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
return string(b), nil
}
func fetchLastFMPlaylistPage(ctx context.Context, client *http.Client, parsed *url.URL, page int) (string, error) {
u := *parsed
if page > 1 {
q := u.Query()
q.Set("page", strconv.Itoa(page))
u.RawQuery = q.Encode()
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
if err != nil {
return "", err
}
req.Header.Set("User-Agent", "streamrip-go/0")
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return "", fmt.Errorf("lastfm request failed: status %d", resp.StatusCode)
}
b, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
return string(b), nil
}
func extractLastFMPlaylistInfo(page string) (string, int, error) {
titleMatch := lastFMPlaylistTitleRe.FindStringSubmatch(page)
if len(titleMatch) < 2 {
return "", 0, fmt.Errorf("could not parse playlist title")
}
totalMatch := lastFMTotalTracksRe.FindStringSubmatch(page)
if len(totalMatch) < 2 {
return "", 0, fmt.Errorf("could not parse total track count")
}
total, err := strconv.Atoi(totalMatch[1])
if err != nil {
return "", 0, fmt.Errorf("invalid total track count")
}
return html.UnescapeString(strings.TrimSpace(titleMatch[1])), total, nil
}
func extractLastFMTitleArtistPairs(page string) []lastFMTrack {
dataPairs := lastFMDataTrackArtistRe.FindAllStringSubmatch(page, -1)
if len(dataPairs) > 0 {
out := make([]lastFMTrack, 0, len(dataPairs))
for _, m := range dataPairs {
title := html.UnescapeString(strings.TrimSpace(firstNonEmpty(m[1], m[2])))
artist := html.UnescapeString(strings.TrimSpace(firstNonEmpty(m[3], m[4])))
if title == "" || artist == "" {
continue
}
out = append(out, lastFMTrack{Title: title, Artist: artist})
}
if len(out) > 0 {
return out
}
}
titles := lastFMTitleTagsRe.FindAllStringSubmatch(page, -1)
out := make([]lastFMTrack, 0, len(titles)/2)
for i := 0; i+1 < len(titles); i += 2 {
titleRaw := strings.TrimSpace(firstNonEmpty(titles[i][1], titles[i][2]))
artistRaw := strings.TrimSpace(firstNonEmpty(titles[i+1][1], titles[i+1][2]))
if strings.EqualFold(titleRaw, "Play on YouTube") || strings.EqualFold(artistRaw, "Play on YouTube") {
continue
}
title := html.UnescapeString(titleRaw)
artist := html.UnescapeString(artistRaw)
if title == "" || artist == "" {
continue
}
out = append(out, lastFMTrack{Title: title, Artist: artist})
}
return out
}
func firstNonEmpty(items ...string) string {
for _, item := range items {
if strings.TrimSpace(item) != "" {
return strings.TrimSpace(item)
}
}
return ""
}
func extractLastFMTracksFromMirrorMarkdown(md string) (string, []lastFMTrack) {
lines := strings.Split(strings.ReplaceAll(md, "\r\n", "\n"), "\n")
title := ""
tracks := make([]lastFMTrack, 0, 100)
for _, line := range lines {
line = strings.TrimSpace(line)
if title == "" {
if m := lastFMMirrorTitleRe.FindStringSubmatch(line); len(m) >= 2 {
title = strings.TrimSpace(html.UnescapeString(m[1]))
}
}
if !strings.HasPrefix(line, "|") || !strings.Contains(strings.ToLower(line), "play track") {
continue
}
cols := splitMarkdownTableRow(line)
if len(cols) < 6 {
continue
}
trackName := markdownLinkText(cols[3])
artist := markdownLinkText(cols[4])
if strings.TrimSpace(trackName) == "" || strings.TrimSpace(artist) == "" {
continue
}
tracks = append(tracks, lastFMTrack{Title: html.UnescapeString(strings.TrimSpace(trackName)), Artist: html.UnescapeString(strings.TrimSpace(artist))})
}
return title, tracks
}
func splitMarkdownTableRow(line string) []string {
trimmed := strings.TrimSpace(line)
trimmed = strings.TrimPrefix(trimmed, "|")
trimmed = strings.TrimSuffix(trimmed, "|")
parts := strings.Split(trimmed, "|")
out := make([]string, 0, len(parts))
for _, p := range parts {
out = append(out, strings.TrimSpace(p))
}
return out
}
func markdownLinkText(cell string) string {
m := lastFMMirrorLinkTextRe.FindStringSubmatch(cell)
if len(m) >= 2 {
return m[1]
}
return strings.TrimSpace(cell)
}
func resolveLastFMTracks(ctx context.Context, mainApp *app.Main, opts lastFMOptions, tracks []lastFMTrack) ([]resolvedLastFMTrack, error) {
primary, err := mainApp.GetLoggedInProvider(ctx, opts.Source)
if err != nil {
return nil, fmt.Errorf("%s login error: %w", opts.Source, err)
}
var fallback provider.Client
if opts.FallbackSource != "" && opts.FallbackSource != opts.Source {
fallback, err = mainApp.GetLoggedInProvider(ctx, opts.FallbackSource)
if err != nil {
return nil, fmt.Errorf("%s login error: %w", opts.FallbackSource, err)
}
}
found := 0
failed := 0
resolved := make([]resolvedLastFMTrack, 0, len(tracks))
for i, tr := range tracks {
query := strings.TrimSpace(tr.Title + " " + tr.Artist)
id, source, searchErr := searchLastFMTrack(ctx, opts, primary, fallback, query)
if searchErr != nil {
failed++
fmt.Printf("[%d/%d] search failed: %s (%v)\n", i+1, len(tracks), query, searchErr)
continue
}
if id == "" {
failed++
fmt.Printf("[%d/%d] no result: %s\n", i+1, len(tracks), query)
continue
}
resolved = append(resolved, resolvedLastFMTrack{Source: source, ID: id, Query: query})
found++
fmt.Printf("[%d/%d] found: %s (%s)\n", i+1, len(tracks), query, source)
}
fmt.Printf("lastfm resolve complete: %d found, %d failed\n", found, failed)
return resolved, nil
}
func fetchSoundcloudOEmbed(ctx context.Context, verifySSL bool, trackURL string) (map[string]any, error) {
parsed, err := url.Parse(trackURL)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return nil, fmt.Errorf("invalid soundcloud url")
}
q := url.Values{}
q.Set("format", "json")
q.Set("url", trackURL)
endpoint := "https://soundcloud.com/oembed?" + q.Encode()
client := netutil.NewHTTPClient(20*time.Second, verifySSL, 0)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "streamrip-go/0.1")
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("soundcloud oembed failed: status %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
out := map[string]any{}
if err = json.Unmarshal(body, &out); err != nil {
return nil, err
}
return out, nil
}
func searchLastFMTrack(ctx context.Context, opts lastFMOptions, primary provider.Client, fallback provider.Client, query string) (string, string, error) {
pages, err := primary.Search(ctx, "track", query, 1)
if err == nil {
results := normalizeSearchResults(opts.Source, "track", pages)
if len(results) > 0 {
return results[0].ID, opts.Source, nil
}
}
if fallback != nil {
pages, fbErr := fallback.Search(ctx, "track", query, 1)
if fbErr != nil {
if err != nil {
return "", "", fmt.Errorf("primary=%v fallback=%v", err, fbErr)
}
return "", "", fbErr
}
results := normalizeSearchResults(opts.FallbackSource, "track", pages)
if len(results) > 0 {
return results[0].ID, opts.FallbackSource, nil
}
}
if err != nil {
return "", "", err
}
return "", "", nil
}
+80 -1322
View File
File diff suppressed because it is too large Load Diff
+165 -22
View File
@@ -1,6 +1,12 @@
package main package main
import "testing" import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
)
func TestParseFileInputJSONItems(t *testing.T) { func TestParseFileInputJSONItems(t *testing.T) {
content := []byte(`[ content := []byte(`[
@@ -88,6 +94,31 @@ func TestParseLastFMArgsOptions(t *testing.T) {
} }
} }
func TestParseLastFMArgsRejectsNonLastFMURL(t *testing.T) {
_, err := parseLastFMArgs([]string{"https://example.com/user/x/playlists/123"}, "qobuz", "")
if err == nil || !strings.Contains(strings.ToLower(err.Error()), "last.fm") {
t.Fatalf("expected last.fm url validation error, got %v", err)
}
}
func TestIsValidLastFMPlaylistURL(t *testing.T) {
if !isValidLastFMPlaylistURL("https://www.last.fm/user/x/playlists/123") {
t.Fatalf("expected canonical last.fm playlist url to be valid")
}
if !isValidLastFMPlaylistURL("http://last.fm/user/x/playlists/123") {
t.Fatalf("expected http last.fm playlist url to be valid")
}
if isValidLastFMPlaylistURL("ftp://last.fm/user/x/playlists/123") {
t.Fatalf("expected non-http scheme to be invalid")
}
if isValidLastFMPlaylistURL("https://example.com/user/x/playlists/123") {
t.Fatalf("expected non-last.fm host to be invalid")
}
if isValidLastFMPlaylistURL("https://www.last.fm/user/x/library") {
t.Fatalf("expected non-playlist last.fm url to be invalid")
}
}
func TestExtractLastFMPlaylistInfoAndPairs(t *testing.T) { func TestExtractLastFMPlaylistInfoAndPairs(t *testing.T) {
html := `<h1 class="playlisting-playlist-header-title">Road &amp; Rain</h1> html := `<h1 class="playlisting-playlist-header-title">Road &amp; Rain</h1>
<div data-playlisting-entry-count="2"></div> <div data-playlisting-entry-count="2"></div>
@@ -116,6 +147,49 @@ func TestExtractLastFMPlaylistInfoAndPairs(t *testing.T) {
} }
} }
func TestExtractLastFMPlaylistInfoFlexibleClass(t *testing.T) {
html := `<h1 id="x" class="foo playlisting-playlist-header-title bar">Road &amp; Rain</h1>
<div data-playlisting-entry-count="1"></div>`
title, total, err := extractLastFMPlaylistInfo(html)
if err != nil {
t.Fatalf("extractLastFMPlaylistInfo() error = %v", err)
}
if title != "Road & Rain" || total != 1 {
t.Fatalf("unexpected parsed values: title=%q total=%d", title, total)
}
}
func TestExtractLastFMTitleArtistPairsSingleQuotes(t *testing.T) {
html := `<a href='/music/a' title='Dreams'></a>
<a href='/music/b' title='Fleetwood Mac'></a>`
pairs := extractLastFMTitleArtistPairs(html)
if len(pairs) != 1 {
t.Fatalf("pairs len = %d, want 1", len(pairs))
}
if pairs[0].Title != "Dreams" || pairs[0].Artist != "Fleetwood Mac" {
t.Fatalf("unexpected pair: %+v", pairs[0])
}
}
func TestExtractLastFMTitleArtistPairsSkipsPlayOnYouTubeNoise(t *testing.T) {
html := `<a href="https://www.youtube.com/watch?v=1" data-track-name="Won&#39;t Forget You" data-artist-name="Shouse" title="Play on YouTube">Play track</a>
<a href="/music/Shouse/_/Won%27t+Forget+You" title="Won&#39;t Forget You"></a>
<a href="/music/Shouse" title="Shouse"></a>
<a href="https://www.youtube.com/watch?v=2" data-track-name="EYES" data-artist-name="The Blaze" title="Play on YouTube">Play track</a>
<a href="/music/The+Blaze/_/EYES" title="EYES"></a>
<a href="/music/The+Blaze" title="The Blaze"></a>`
pairs := extractLastFMTitleArtistPairs(html)
if len(pairs) != 2 {
t.Fatalf("pairs len = %d, want 2", len(pairs))
}
if pairs[0].Title != "Won't Forget You" || pairs[0].Artist != "Shouse" {
t.Fatalf("unexpected first pair: %+v", pairs[0])
}
if pairs[1].Title != "EYES" || pairs[1].Artist != "The Blaze" {
t.Fatalf("unexpected second pair: %+v", pairs[1])
}
}
func TestParseGlobalArgsNoDBBeforeCommand(t *testing.T) { func TestParseGlobalArgsNoDBBeforeCommand(t *testing.T) {
opts, err := parseGlobalArgs([]string{"-ndb", "url", "https://play.qobuz.com/album/0004228000522"}) opts, err := parseGlobalArgs([]string{"-ndb", "url", "https://play.qobuz.com/album/0004228000522"})
if err != nil { if err != nil {
@@ -153,7 +227,7 @@ func TestParseGlobalArgsAllOfficialFlags(t *testing.T) {
if !opts.noDB || !opts.qualitySet || opts.quality != 3 || !opts.codecSet || opts.codec != "VORBIS" { if !opts.noDB || !opts.qualitySet || opts.quality != 3 || !opts.codecSet || opts.codec != "VORBIS" {
t.Fatalf("unexpected quality/codec/db opts: %+v", opts) t.Fatalf("unexpected quality/codec/db opts: %+v", opts)
} }
if !opts.noProgress || !opts.noSSLVerify || !opts.verbose { if !opts.noProgress || !opts.noSSLVerify || opts.verbose != 1 {
t.Fatalf("unexpected boolean opts: %+v", opts) t.Fatalf("unexpected boolean opts: %+v", opts)
} }
if opts.command != "search" { if opts.command != "search" {
@@ -167,26 +241,6 @@ func TestNormalizeCodecRejectsUnknown(t *testing.T) {
} }
} }
func TestGroupLastFMResolvedTracksBySourcePreservesOrderAndDuplicates(t *testing.T) {
resolved := []resolvedLastFMTrack{
{Source: "tidal", ID: "1"},
{Source: "tidal", ID: "1"},
{Source: "qobuz", ID: "2"},
{Source: "tidal", ID: "3"},
{Source: "", ID: "4"},
}
groups := groupLastFMResolvedTracksBySource(resolved)
if len(groups["tidal"]) != 3 {
t.Fatalf("tidal ids len = %d, want 3", len(groups["tidal"]))
}
if len(groups["qobuz"]) != 1 {
t.Fatalf("qobuz ids len = %d, want 1", len(groups["qobuz"]))
}
if groups["tidal"][0] != "1" || groups["tidal"][1] != "1" || groups["tidal"][2] != "3" {
t.Fatalf("unexpected tidal ordering: %+v", groups["tidal"])
}
}
func TestExtractLastFMTracksFromMirrorMarkdown(t *testing.T) { func TestExtractLastFMTracksFromMirrorMarkdown(t *testing.T) {
md := `Title: My Playlist | user playlists | Last.fm md := `Title: My Playlist | user playlists | Last.fm
| Play | Image | Loved | Name | Artist name | Buy | Options | Duration | | Play | Image | Loved | Name | Artist name | Buy | Options | Duration |
@@ -204,3 +258,92 @@ func TestExtractLastFMTracksFromMirrorMarkdown(t *testing.T) {
t.Fatalf("unexpected first track: %+v", tracks[0]) t.Fatalf("unexpected first track: %+v", tracks[0])
} }
} }
func TestExtractLastFMTracksFromMirrorMarkdownLowercasePlayTrack(t *testing.T) {
md := `Title: My Playlist | user playlists | Last.fm
| Play | Image | Loved | Name | Artist name | Buy | Options | Duration |
| --- | --- | --- | --- | --- | --- | --- | --- |
| [play track](https://x) | [img](https://i) | x | [Song A](https://a) | [Artist A](https://aa) | | | 3:00 |`
_, tracks := extractLastFMTracksFromMirrorMarkdown(md)
if len(tracks) != 1 {
t.Fatalf("tracks len = %d, want 1", len(tracks))
}
}
func TestParseSearchArgsAllowsFirstAndOutputFileButCallerCanReject(t *testing.T) {
opts, err := parseSearchArgs([]string{"q", "--first", "--output-file", "/tmp/out.json"}, 20)
if err != nil {
t.Fatalf("parseSearchArgs() error = %v", err)
}
if !opts.first || opts.outputFile == "" {
t.Fatalf("expected first=true and output file set, got %+v", opts)
}
}
func TestParseSearchArgsRejectsUnknownOption(t *testing.T) {
_, err := parseSearchArgs([]string{"query", "--bogus"}, 20)
if err == nil || !strings.Contains(err.Error(), "unknown option") {
t.Fatalf("expected unknown option error, got %v", err)
}
}
func TestParseSearchArgsSupportsDoubleDashTerminator(t *testing.T) {
opts, err := parseSearchArgs([]string{"--limit", "10", "--", "--weird", "track"}, 20)
if err != nil {
t.Fatalf("parseSearchArgs() error = %v", err)
}
if opts.limit != 10 {
t.Fatalf("limit = %d, want 10", opts.limit)
}
if opts.query != "--weird track" {
t.Fatalf("query = %q, want %q", opts.query, "--weird track")
}
}
func TestWriteSearchResultsToFileCreatesParentDirectory(t *testing.T) {
tmp := t.TempDir()
out := filepath.Join(tmp, "nested", "search", "results.json")
results := []searchResult{{ID: "1", Title: "Dreams"}}
if err := writeSearchResultsToFile("qobuz", "track", results, out); err != nil {
t.Fatalf("writeSearchResultsToFile() error = %v", err)
}
if _, err := os.Stat(out); err != nil {
t.Fatalf("expected output file, stat error = %v", err)
}
}
func TestNormalizeSearchResultsDedupesByID(t *testing.T) {
pages := []map[string]any{
{"tracks": map[string]any{"items": []any{
map[string]any{"id": "1", "title": "Dreams", "artist": map[string]any{"name": "Fleetwood Mac"}},
map[string]any{"id": "1", "title": "Dreams", "artist": map[string]any{"name": "Fleetwood Mac"}},
}}},
{"tracks": map[string]any{"items": []any{
map[string]any{"id": "2", "title": "Go Your Own Way", "artist": map[string]any{"name": "Fleetwood Mac"}},
map[string]any{"id": "1", "title": "Dreams", "artist": map[string]any{"name": "Fleetwood Mac"}},
}}},
}
results := normalizeSearchResults("qobuz", "track", pages)
if len(results) != 2 {
t.Fatalf("len(results)=%d want 2", len(results))
}
if results[0].ID != "1" || results[1].ID != "2" {
t.Fatalf("unexpected IDs order: %+v", results)
}
}
func TestErrorWithActionableHintForSSL(t *testing.T) {
err := errors.New("x509: certificate signed by unknown authority")
msg := errorWithActionableHint(err, globalOptions{})
if msg == err.Error() {
t.Fatalf("expected ssl hint in message")
}
}
func TestErrorWithActionableHintNoHintWhenDisabled(t *testing.T) {
err := errors.New("tls handshake failure")
msg := errorWithActionableHint(err, globalOptions{noSSLVerify: true})
if msg != err.Error() {
t.Fatalf("unexpected hint when noSSLVerify set")
}
}
+741
View File
@@ -0,0 +1,741 @@
package main
import (
"bufio"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/AlecAivazis/survey/v2"
"streamrip-go/internal/jsonutil"
)
type searchResult struct {
ID string
Title string
Artist string
Album string
Date string
Releases int
TrackCount int
Explicit bool
}
type searchOptions struct {
query string
limit int
ignoreDB bool
noDownload bool
first bool
outputFile string
}
func parseSearchArgs(args []string, defaultLimit int) (searchOptions, error) {
if defaultLimit <= 0 {
defaultLimit = 20
}
limit := defaultLimit
parts := make([]string, 0, len(args))
ignoreDB := false
noDownload := false
first := false
outputFile := ""
for i := 0; i < len(args); i++ {
if args[i] == "--" {
if i+1 < len(args) {
parts = append(parts, args[i+1:]...)
}
break
}
switch args[i] {
case "--force", "--ignore-db":
ignoreDB = true
continue
case "--no-download":
noDownload = true
continue
case "--first":
first = true
continue
case "--output-file":
if i+1 >= len(args) {
return searchOptions{}, fmt.Errorf("--output-file requires a path")
}
outputFile = strings.TrimSpace(args[i+1])
if outputFile == "" {
return searchOptions{}, fmt.Errorf("--output-file requires a non-empty path")
}
i++
continue
case "--num-results":
if i+1 >= len(args) {
return searchOptions{}, fmt.Errorf("--num-results requires a value")
}
v, err := strconv.Atoi(args[i+1])
if err != nil || v <= 0 {
return searchOptions{}, fmt.Errorf("invalid --num-results value %q", args[i+1])
}
limit = v
i++
continue
}
if args[i] == "--limit" {
if i+1 >= len(args) {
return searchOptions{}, fmt.Errorf("--limit requires a value")
}
v, err := strconv.Atoi(args[i+1])
if err != nil || v <= 0 {
return searchOptions{}, fmt.Errorf("invalid --limit value %q", args[i+1])
}
limit = v
i++
continue
}
if strings.HasPrefix(args[i], "-") {
return searchOptions{}, fmt.Errorf("unknown option %q", args[i])
}
parts = append(parts, args[i])
}
return searchOptions{
query: strings.TrimSpace(strings.Join(parts, " ")),
limit: limit,
ignoreDB: ignoreDB,
noDownload: noDownload,
first: first,
outputFile: outputFile,
}, nil
}
func promptSearchSelection(results []searchResult) ([]int, error) {
reader := bufio.NewReader(os.Stdin)
for {
fmt.Print("Select results to download (e.g. 1,3-5; a=all; q=cancel): ")
line, err := reader.ReadString('\n')
if err != nil {
return nil, err
}
line = strings.TrimSpace(line)
if line == "" || strings.EqualFold(line, "q") || strings.EqualFold(line, "quit") {
return nil, nil
}
if strings.EqualFold(line, "a") || strings.EqualFold(line, "all") {
out := make([]int, 0, len(results))
for i := range results {
out = append(out, i)
}
return out, nil
}
selected := map[int]struct{}{}
chunks := strings.Split(line, ",")
ok := true
for _, raw := range chunks {
part := strings.TrimSpace(raw)
if part == "" {
continue
}
if strings.Contains(part, "-") {
bounds := strings.SplitN(part, "-", 2)
if len(bounds) != 2 {
ok = false
break
}
start, err1 := strconv.Atoi(strings.TrimSpace(bounds[0]))
end, err2 := strconv.Atoi(strings.TrimSpace(bounds[1]))
if err1 != nil || err2 != nil || start <= 0 || end <= 0 || start > end {
ok = false
break
}
for i := start; i <= end; i++ {
if i > len(results) {
ok = false
break
}
selected[i-1] = struct{}{}
}
if !ok {
break
}
continue
}
idx, err := strconv.Atoi(part)
if err != nil || idx <= 0 || idx > len(results) {
ok = false
break
}
selected[idx-1] = struct{}{}
}
if !ok || len(selected) == 0 {
fmt.Println("Invalid selection, try again.")
continue
}
out := make([]int, 0, len(selected))
for idx := range selected {
out = append(out, idx)
}
for i := 1; i < len(out); i++ {
for j := i; j > 0 && out[j] < out[j-1]; j-- {
out[j], out[j-1] = out[j-1], out[j]
}
}
return out, nil
}
}
func promptSearchSelectionMenu(source, mediaType, query string, results []searchResult) ([]int, error) {
if len(results) == 0 {
return nil, nil
}
prevTemplate := survey.MultiSelectQuestionTemplate
survey.MultiSelectQuestionTemplate = streamripLikeSearchTemplate
defer func() {
survey.MultiSelectQuestionTemplate = prevTemplate
}()
labels := make([]string, 0, len(results))
labelToIndex := map[string]int{}
for i, r := range results {
label := formatSearchOptionLabel(i+1, mediaType, r)
labels = append(labels, label)
labelToIndex[label] = i
}
pageSize := len(labels)
if pageSize < 15 {
pageSize = 15
}
if pageSize > 30 {
pageSize = 30
}
selected := []string{}
prompt := &survey.MultiSelect{
Message: fmt.Sprintf("Results for %s '%s' from %s", mediaType, query, jsonutil.TitleCase(source)),
Help: "SPACE - select, ENTER - download, ESC - exit",
Options: labels,
Description: func(value string, index int) string {
resultIndex, ok := labelToIndex[value]
if !ok || resultIndex < 0 || resultIndex >= len(results) {
return ""
}
return formatSearchDetails(mediaType, results[resultIndex])
},
PageSize: pageSize,
}
if err := survey.AskOne(prompt, &selected); err != nil {
if strings.Contains(strings.ToLower(err.Error()), "interrupt") {
return nil, nil
}
return nil, err
}
if len(selected) == 0 {
return nil, nil
}
out := make([]int, 0, len(selected))
for _, label := range selected {
if idx, ok := labelToIndex[label]; ok {
out = append(out, idx)
}
}
for i := 1; i < len(out); i++ {
for j := i; j > 0 && out[j] < out[j-1]; j-- {
out[j], out[j-1] = out[j-1], out[j]
}
}
return out, nil
}
const streamripLikeSearchTemplate = `
{{ color "default+hb"}}{{ .Message }}{{color "reset"}}
{{- if .Help }}
{{ .Help }}
{{- end }}
{{- range $ix, $option := .PageEntries }}
{{ if eq $.SelectedIndex $ix }}>{{ else }} {{ end }} [{{ if index $.Checked $option.Index }}x{{ else }} {{ end }}] {{ $option.Value }}
{{- end }}
preview
{{- if gt (len .PageEntries) 0 }}
{{ $current := index .PageEntries .SelectedIndex }}
{{ $.GetDescription $current }}
{{- end }}
`
func writeSearchResultsToFile(source, mediaType string, results []searchResult, path string) error {
type outItem struct {
Source string `json:"source"`
MediaType string `json:"media_type"`
ID string `json:"id"`
Title string `json:"title"`
}
out := make([]outItem, 0, len(results))
for _, r := range results {
out = append(out, outItem{Source: source, MediaType: mediaType, ID: r.ID, Title: r.Title})
}
b, err := json.MarshalIndent(out, "", " ")
if err != nil {
return err
}
dir := filepath.Dir(path)
if dir != "" && dir != "." {
if err = os.MkdirAll(dir, 0o755); err != nil {
return err
}
}
return os.WriteFile(path, b, 0o644)
}
func isAllowedSearchSource(source string) bool {
return source == "qobuz" || source == "tidal" || source == "deezer" || source == "yandex" || source == "beatport" || source == "soundcloud"
}
func isAllowedMediaType(mediaType string) bool {
switch mediaType {
case "track", "album", "playlist", "artist", "label", "video", "chart":
return true
default:
return false
}
}
func promptSearchInteractive(defaultLimit int) (string, string, searchOptions, error) {
reader := bufio.NewReader(os.Stdin)
read := func(prompt string) (string, error) {
fmt.Print(prompt)
line, err := reader.ReadString('\n')
if err != nil {
return "", err
}
return strings.TrimSpace(line), nil
}
for {
source, err := read("Source [qobuz/tidal/deezer/yandex/beatport/soundcloud]: ")
if err != nil {
return "", "", searchOptions{}, err
}
source = strings.ToLower(source)
if !isAllowedSearchSource(source) {
fmt.Println("Invalid source.")
continue
}
mediaType, err := read("Type [track/album/playlist/artist/label/video]: ")
if err != nil {
return "", "", searchOptions{}, err
}
mediaType = strings.ToLower(mediaType)
if !isAllowedMediaType(mediaType) {
fmt.Println("Invalid media type.")
continue
}
if source == "soundcloud" && mediaType != "track" && mediaType != "playlist" {
fmt.Println("SoundCloud search supports track and playlist only.")
continue
}
if source == "yandex" && mediaType != "track" && mediaType != "album" && mediaType != "playlist" && mediaType != "artist" {
fmt.Println("Yandex search supports track, album, playlist, and artist only.")
continue
}
if source == "beatport" && mediaType != "track" && mediaType != "album" && mediaType != "label" {
fmt.Println("Beatport search supports track, album, and label only.")
continue
}
query, err := read("Query: ")
if err != nil {
return "", "", searchOptions{}, err
}
if strings.TrimSpace(query) == "" {
fmt.Println("Query cannot be empty.")
continue
}
limitRaw, err := read(fmt.Sprintf("Limit [%d]: ", defaultLimit))
if err != nil {
return "", "", searchOptions{}, err
}
limit := defaultLimit
if strings.TrimSpace(limitRaw) != "" {
v, convErr := strconv.Atoi(limitRaw)
if convErr != nil || v <= 0 {
fmt.Println("Invalid limit.")
continue
}
limit = v
}
return source, mediaType, searchOptions{query: query, limit: limit}, nil
}
}
func normalizeSearchResults(source, mediaType string, pages []map[string]any) []searchResult {
results := make([]searchResult, 0)
seen := map[string]struct{}{}
appendUnique := func(r searchResult) {
if strings.TrimSpace(r.ID) == "" || strings.TrimSpace(r.Title) == "" {
return
}
key := r.ID
if _, ok := seen[key]; ok {
return
}
seen[key] = struct{}{}
results = append(results, r)
}
for _, page := range pages {
switch source {
case "qobuz":
key := mediaType + "s"
bucket, ok := page[key].(map[string]any)
if !ok {
continue
}
items, ok := bucket["items"].([]any)
if !ok {
continue
}
for _, raw := range items {
itm, ok := raw.(map[string]any)
if !ok {
continue
}
id := asString(itm["id"])
title := asString(itm["title"])
if title == "" {
title = asString(itm["name"])
}
if version := asString(itm["version"]); version != "" {
title += " (" + version + ")"
}
artist := nestedSearchString(itm, "artist", "name")
if artist == "" {
artist = nestedSearchString(itm, "performer", "name")
}
album := nestedSearchString(itm, "album", "title")
trackCount := searchInt(itm["tracks_count"])
if trackCount == 0 {
trackCount = searchInt(itm["track_count"])
}
explicit := searchBool(itm["parental_warning"])
date := firstNonEmpty(
asString(itm["release_date_original"]),
asString(itm["release_date"]),
asString(itm["releaseDate"]),
asString(itm["streamStartDate"]),
nestedSearchString(itm, "album", "release_date_original"),
nestedSearchString(itm, "album", "release_date"),
)
releases := 0
if mediaType == "artist" {
releases = firstPositiveInt(
searchInt(itm["albums_count"]),
searchInt(itm["albumsCount"]),
nestedSearchInt(itm, "albums", "total"),
)
}
appendUnique(searchResult{ID: id, Title: title, Artist: artist, Album: album, Date: date, Releases: releases, TrackCount: trackCount, Explicit: explicit})
}
case "tidal":
items, ok := page["items"].([]any)
if !ok {
continue
}
for _, raw := range items {
itm, ok := raw.(map[string]any)
if !ok {
continue
}
if wrapped, ok := itm["item"].(map[string]any); ok {
itm = wrapped
}
id := asString(itm["id"])
title := asString(itm["title"])
if title == "" {
title = asString(itm["name"])
}
artist := nestedSearchString(itm, "artist", "name")
if artist == "" {
if artists, ok := itm["artists"].([]any); ok && len(artists) > 0 {
if a0, ok := artists[0].(map[string]any); ok {
artist = asString(a0["name"])
}
}
}
album := nestedSearchString(itm, "album", "title")
trackCount := searchInt(itm["numberOfTracks"])
if trackCount == 0 {
trackCount = searchInt(itm["tracks_count"])
}
explicit := searchBool(itm["explicit"])
date := firstNonEmpty(
asString(itm["releaseDate"]),
asString(itm["streamStartDate"]),
asString(itm["release_date"]),
nestedSearchString(itm, "album", "releaseDate"),
nestedSearchString(itm, "album", "release_date"),
)
releases := 0
if mediaType == "artist" {
releases = firstPositiveInt(
searchInt(itm["numberOfAlbums"]),
searchInt(itm["albums_count"]),
searchInt(itm["albumsCount"]),
nestedSearchInt(itm, "albums", "total"),
)
}
appendUnique(searchResult{ID: id, Title: title, Artist: artist, Album: album, Date: date, Releases: releases, TrackCount: trackCount, Explicit: explicit})
}
case "deezer":
key := mediaType + "s"
bucket, ok := page[key].(map[string]any)
if !ok {
continue
}
items, ok := bucket["items"].([]any)
if !ok {
continue
}
for _, raw := range items {
itm, ok := raw.(map[string]any)
if !ok {
continue
}
id := asString(itm["id"])
title := asString(itm["title"])
if title == "" {
title = asString(itm["name"])
}
artist := nestedSearchString(itm, "artist", "name")
album := nestedSearchString(itm, "album", "title")
trackCount := searchInt(itm["nb_tracks"])
explicit := searchBool(itm["explicit_lyrics"])
date := firstNonEmpty(
asString(itm["release_date"]),
nestedSearchString(itm, "album", "release_date"),
)
releases := 0
if mediaType == "artist" {
releases = firstPositiveInt(
searchInt(itm["nb_album"]),
searchInt(itm["albums_count"]),
searchInt(itm["numberOfAlbums"]),
)
}
appendUnique(searchResult{ID: id, Title: title, Artist: artist, Album: album, Date: date, Releases: releases, TrackCount: trackCount, Explicit: explicit})
}
case "soundcloud":
items, ok := page["items"].([]any)
if !ok {
continue
}
for _, raw := range items {
itm, ok := raw.(map[string]any)
if !ok {
continue
}
id := asString(itm["id"])
title := asString(itm["title"])
artist := nestedSearchString(itm, "artist", "name")
trackCount := searchInt(itm["tracks_count"])
date := firstNonEmpty(
asString(itm["release_date"]),
asString(itm["display_date"]),
)
appendUnique(searchResult{ID: id, Title: title, Artist: artist, Date: date, TrackCount: trackCount})
}
case "yandex":
items, ok := page["items"].([]any)
if !ok {
continue
}
for _, raw := range items {
itm, ok := raw.(map[string]any)
if !ok {
continue
}
id := asString(itm["id"])
title := asString(itm["title"])
if title == "" {
title = asString(itm["name"])
}
artist := nestedSearchString(itm, "artist", "name")
if artist == "" {
artist = nestedSearchString(itm, "performer", "name")
}
album := nestedSearchString(itm, "album", "title")
trackCount := firstPositiveInt(
searchInt(itm["trackCount"]),
searchInt(itm["track_count"]),
searchInt(itm["tracks_count"]),
)
explicit := searchBool(itm["explicit"])
date := firstNonEmpty(
asString(itm["release_date"]),
asString(itm["releaseDate"]),
nestedSearchString(itm, "album", "release_date"),
nestedSearchString(itm, "album", "releaseDate"),
)
releases := 0
if mediaType == "artist" {
releases = firstPositiveInt(
searchInt(itm["albums_count"]),
searchInt(itm["numberOfAlbums"]),
nestedSearchInt(itm, "albums", "total"),
)
}
appendUnique(searchResult{ID: id, Title: title, Artist: artist, Album: album, Date: date, Releases: releases, TrackCount: trackCount, Explicit: explicit})
}
case "beatport":
items, ok := page["items"].([]any)
if !ok {
continue
}
for _, raw := range items {
itm, ok := raw.(map[string]any)
if !ok {
continue
}
id := asString(itm["id"])
title := asString(itm["title"])
if title == "" {
title = asString(itm["name"])
}
if version := asString(itm["version"]); version != "" {
title += " (" + version + ")"
}
artist := nestedSearchString(itm, "artist", "name")
album := nestedSearchString(itm, "album", "title")
trackCount := firstPositiveInt(searchInt(itm["tracks_count"]), searchInt(itm["track_count"]))
date := firstNonEmpty(asString(itm["release_date_original"]), asString(itm["release_date"]), nestedSearchString(itm, "album", "release_date_original"))
appendUnique(searchResult{ID: id, Title: title, Artist: artist, Album: album, Date: date, TrackCount: trackCount})
}
}
}
return results
}
func formatSearchDetails(mediaType string, r searchResult) string {
date := strings.TrimSpace(r.Date)
if date == "" {
date = "Unknown"
}
lines := []string{}
switch mediaType {
case "album":
lines = append(lines, "Date released:", date)
if r.TrackCount > 0 {
lines = append(lines, "", fmt.Sprintf("%d Tracks", r.TrackCount))
}
case "artist":
if strings.TrimSpace(r.Title) != "" {
lines = append(lines, r.Title)
}
if r.Releases > 0 {
lines = append(lines, "", fmt.Sprintf("%d Releases", r.Releases))
}
case "track":
lines = append(lines, "Released on:", date)
case "playlist":
if r.TrackCount > 0 {
lines = append(lines, fmt.Sprintf("%d Tracks", r.TrackCount))
}
default:
if strings.TrimSpace(r.Title) != "" {
lines = append(lines, r.Title)
}
if strings.TrimSpace(r.Artist) != "" {
lines = append(lines, strings.TrimSpace(r.Artist))
}
if strings.TrimSpace(r.Album) != "" {
lines = append(lines, strings.TrimSpace(r.Album))
}
if r.TrackCount > 0 {
lines = append(lines, fmt.Sprintf("%d Tracks", r.TrackCount))
}
if r.Explicit {
lines = append(lines, "Explicit")
}
}
lines = append(lines, "", "ID: "+r.ID)
return strings.Join(lines, "\n")
}
func formatSearchOptionLabel(index int, mediaType string, r searchResult) string {
title := strings.TrimSpace(r.Title)
if title == "" {
title = "Unknown"
}
artist := strings.TrimSpace(r.Artist)
switch mediaType {
case "artist", "label":
return fmt.Sprintf("%d. %s", index, title)
default:
if artist == "" {
return fmt.Sprintf("%d. %s", index, title)
}
return fmt.Sprintf("%d. %s by %s", index, title, artist)
}
}
func nestedSearchString(v map[string]any, keys ...string) string {
cur := any(v)
for _, key := range keys {
m, ok := cur.(map[string]any)
if !ok {
return ""
}
cur = m[key]
}
return asString(cur)
}
func nestedSearchInt(v map[string]any, keys ...string) int {
cur := any(v)
for _, key := range keys {
m, ok := cur.(map[string]any)
if !ok {
return 0
}
cur = m[key]
}
return searchInt(cur)
}
func firstPositiveInt(values ...int) int {
for _, v := range values {
if v > 0 {
return v
}
}
return 0
}
func searchInt(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(t)
return i
default:
return 0
}
}
func searchBool(v any) bool {
b, ok := v.(bool)
return ok && b
}
+87
View File
@@ -0,0 +1,87 @@
package main
import "testing"
func TestFormatSearchDetailsAlbum(t *testing.T) {
r := searchResult{ID: "9399700017915", Date: "1994-02-01", TrackCount: 18}
got := formatSearchDetails("album", r)
want := "Date released:\n1994-02-01\n\n18 Tracks\n\nID: 9399700017915"
if got != want {
t.Fatalf("unexpected album details:\n%s", got)
}
}
func TestFormatSearchDetailsTrackDefaultsDate(t *testing.T) {
r := searchResult{ID: "3083287"}
got := formatSearchDetails("track", r)
want := "Released on:\nUnknown\n\nID: 3083287"
if got != want {
t.Fatalf("unexpected track details:\n%s", got)
}
}
func TestFormatSearchDetailsArtistWithReleases(t *testing.T) {
r := searchResult{ID: "1863897", Title: "Lost Frequencies", Releases: 23}
got := formatSearchDetails("artist", r)
want := "Lost Frequencies\n\n23 Releases\n\nID: 1863897"
if got != want {
t.Fatalf("unexpected artist details:\n%s", got)
}
}
func TestNormalizeSearchResultsQobuzExtractsDate(t *testing.T) {
pages := []map[string]any{
{"albums": map[string]any{"items": []any{
map[string]any{
"id": "1",
"title": "Master of Reality",
"release_date_original": "1971-07-21",
"artist": map[string]any{"name": "Black Sabbath"},
},
}}},
}
results := normalizeSearchResults("qobuz", "album", pages)
if len(results) != 1 {
t.Fatalf("len(results)=%d want 1", len(results))
}
if results[0].Date != "1971-07-21" {
t.Fatalf("date=%q want 1971-07-21", results[0].Date)
}
}
func TestFormatSearchOptionLabelArtistNoUnknownArtistSuffix(t *testing.T) {
r := searchResult{Title: "Lost Frequencies"}
got := formatSearchOptionLabel(1, "artist", r)
want := "1. Lost Frequencies"
if got != want {
t.Fatalf("label=%q want %q", got, want)
}
}
func TestFormatSearchOptionLabelTrackWithArtist(t *testing.T) {
r := searchResult{Title: "Dreams", Artist: "Fleetwood Mac"}
got := formatSearchOptionLabel(2, "track", r)
want := "2. Dreams by Fleetwood Mac"
if got != want {
t.Fatalf("label=%q want %q", got, want)
}
}
func TestNormalizeSearchResultsQobuzArtistExtractsReleases(t *testing.T) {
pages := []map[string]any{
{"artists": map[string]any{"items": []any{
map[string]any{
"id": "1863897",
"name": "Lost Frequencies",
"albums_count": 23,
},
}}},
}
results := normalizeSearchResults("qobuz", "artist", pages)
if len(results) != 1 {
t.Fatalf("len(results)=%d want 1", len(results))
}
if results[0].Releases != 23 {
t.Fatalf("releases=%d want 23", results[0].Releases)
}
}
+193
View File
@@ -0,0 +1,193 @@
[downloads]
# Folder where tracks are downloaded to
folder = "/path/to/StreamripDownloads"
# Put Qobuz albums in a 'Qobuz' folder, Tidal albums in 'Tidal', etc.
source_subdirectories = false
# Put tracks in albums with 2+ discs into subfolders named `Disc N`
disc_subdirectories = true
# Download (and convert) tracks concurrently instead of sequentially
concurrency = true
# The maximum number of tracks to download at once
# Set to -1 for no limit
max_connections = 6
# Max number of API requests per source per minute
# Set to -1 for no limit
requests_per_minute = 60
# Verify SSL certificates for API connections
# Set to false only if certificate verification fails (not recommended)
verify_ssl = true
[qobuz]
# Quality ladder (global scale is 0-4, Qobuz supports 1-4):
# 1 = MP3 320, 2 = FLAC 16/44.1, 3 = FLAC 24-bit <=96kHz, 4 = FLAC 24-bit <=192kHz
quality = 3
# Download booklet PDFs when available
download_booklets = true
# Authenticate using Qobuz auth token instead of email/password hash
use_auth_token = false
# If use_auth_token=true, set your user id. Otherwise set your email.
email_or_userid = ""
# If use_auth_token=true, set your auth token. Otherwise set md5(password).
password_or_token = ""
# Managed automatically by streamrip-go
app_id = ""
# Managed automatically by streamrip-go
secrets = []
[tidal]
# Quality ladder:
# 0 = LOW (HEAACV1), 1 = HIGH (AACLC), 2 = LOSSLESS (FLAC), 3/4 = HI_RES_LOSSLESS when available
# If a tier is unavailable, Tidal may fall back to the best available stream for that track.
quality = 3
# Prefer Dolby Atmos/immersive stream variants when available
# Disabled by default because stereo FLAC is usually preferred
prefer_atmos = false
# Download videos included in supported Tidal media
download_videos = true
# Session values are managed automatically. Do not modify manually.
user_id = ""
country_code = ""
access_token = ""
refresh_token = ""
# Unix timestamp when access_token expires
token_expiry = 0
[deezer]
# Quality ladder:
# 0 = MP3_128, 1 = MP3_320, 2/3/4 = FLAC
quality = 2
# If target quality is unavailable, fallback down quality ladder
lower_quality_if_not_available = true
# Deezer ARL cookie (recommended auth method)
arl = ""
# Optional login alternative when ARL is not provided
email = ""
password = ""
# Optional cached Deezer refresh token. Managed automatically when available.
refresh_token = ""
[yandex]
# Quality ladder:
# 0 = LOW (HE-AAC/AAC when available), 1 = HIGH (AAC/MP3 192), 2/3/4 = LOSSLESS when available
quality = 2
# OAuth access token for api.music.yandex.net
access_token = ""
# Cached current account uid. Managed automatically when available.
user_id = ""
[beatport]
# Quality ladder:
# 1 = medium (AAC 128), 2 = high (AAC 256), 3/4 = lossless (FLAC 16/44.1)
# medium-hls is intentionally not supported.
quality = 3
# If a release lists more artists than this, use "Various Artists" as album artist.
# Set to -1 to disable this collapse.
various_artists_threshold = 3
# Beatport Streaming account credentials
username = ""
password = ""
# Session values are managed automatically. Do not modify manually.
access_token = ""
refresh_token = ""
# Unix timestamp when access_token expires
token_expiry = 0
[soundcloud]
# Quality is currently provider-defined (keep 0)
quality = 0
# Managed automatically when available
client_id = ""
app_version = ""
[youtube]
# Only 0 is currently supported
quality = 0
# Download video streams together with audio when supported
download_videos = false
# Folder used for video outputs
video_downloads_folder = "/path/to/StreamripDownloads/YouTubeVideos"
[database]
# Track IDs already downloaded are stored here and skipped next time
downloads_enabled = true
downloads_path = "/path/to/.config/streamrip/downloads.db"
# Failed item IDs are stored here for retry/repair workflows
failed_downloads_enabled = true
failed_downloads_path = "/path/to/.config/streamrip/failed_downloads.db"
[conversion]
# Convert tracks after download
enabled = false
# ALAC, FLAC, OGG, MP3, or AAC
codec = "ALAC"
# In Hz. Audio is downsampled when above this rate.
sampling_rate = 48000
# Applied only when source bit depth is higher than this value
bit_depth = 24
# Used for lossy codecs
lossy_bitrate = 320
[qobuz_filters]
# Filter a Qobuz artist discography (best-effort for other sources)
extras = false
repeats = false
non_albums = false
features = false
non_studio_albums = false
non_remaster = false
[artwork]
# Embed artwork in the audio file
embed = true
# thumbnail, small, large, or original
embed_size = "large"
# If > 0, embedded image max(width, height) in pixels
embed_max_width = -1
# Save artwork as separate jpg file
save_artwork = true
# If > 0, saved image max(width, height) in pixels
saved_max_width = -1
[metadata]
# Set ALBUM metadata to playlist name for playlist items
set_playlist_to_album = true
# Use playlist position as tracknumber for playlist items
renumber_playlist_tracks = true
# Separator used when a provider exposes multiple artists as separate values
artist_separator = "; "
# Metadata fields to exclude from tagging
exclude = []
[filepaths]
# Create folders for single tracks using folder_format template
add_singles_to_folder = false
# Available keys: albumartist, title, year, bit_depth, sampling_rate, id, container, codec, quality, bitrate, albumcomposer
folder_format = "{albumartist} - {title} ({year}) [{container}] [{bit_depth}B-{sampling_rate}kHz]"
# Available keys: id, tracknumber, artist, albumartist, composer, title, albumcomposer, explicit
track_format = "{tracknumber:02}. {artist} - {title}{explicit}"
# Restrict filenames to printable ASCII
restrict_characters = false
# Truncate filenames longer than this value
truncate_to = 120
[lastfm]
# Primary source used to resolve Last.fm playlist tracks
source = "qobuz"
# Fallback source when primary lookup fails
fallback_source = ""
[cli]
# Print informational output like "Downloading <album>"
text_output = true
# Show resolve and download progress bars
progress_bars = true
# Max interactive search results displayed
max_search_results = 100
[misc]
# Metadata used for config compatibility checks
version = "2.2.0"
# Notify when a new version is available
check_for_updates = true
+1
View File
@@ -24,6 +24,7 @@ require (
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b // indirect github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/crypto v0.50.0 // indirect
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
golang.org/x/sys v0.43.0 // indirect golang.org/x/sys v0.43.0 // indirect
golang.org/x/text v0.36.0 // indirect golang.org/x/text v0.36.0 // indirect
+2
View File
@@ -49,6 +49,8 @@ github.com/vbauerster/mpb/v8 v8.12.0/go.mod h1:V02YIuMVo301Y1VE9VtZlD8s84OMsk+EK
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww= golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww=
+596 -182
View File
File diff suppressed because it is too large Load Diff
+380 -3
View File
@@ -2,9 +2,11 @@ package app
import ( import (
"context" "context"
"fmt"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os" "os"
"os/exec"
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "testing"
@@ -20,6 +22,12 @@ type noopTagger struct{}
func (n noopTagger) TagFLAC(string, tag.Metadata, string) error { return nil } func (n noopTagger) TagFLAC(string, tag.Metadata, string) error { return nil }
type failingTagger struct {
err error
}
func (f failingTagger) TagFLAC(string, tag.Metadata, string) error { return f.err }
type fakeProvider struct { type fakeProvider struct {
url string url string
} }
@@ -56,22 +64,34 @@ type fakeVideoProvider struct {
url string url string
} }
type fakeResolvedAlbumProvider struct {
url string
downloadable provider.Downloadable
downloadableHits int
}
type fakeFailProvider struct{} type fakeFailProvider struct{}
func (f *fakeAlbumProvider) Source() string { return "qobuz" } func (f *fakeAlbumProvider) Source() string { return "qobuz" }
func (f *fakePlaylistProvider) Source() string { return "qobuz" } func (f *fakePlaylistProvider) Source() string { return "qobuz" }
func (f *fakeVideoProvider) Source() string { return "tidal" } func (f *fakeVideoProvider) Source() string { return "tidal" }
func (f *fakeResolvedAlbumProvider) Source() string { return "qobuz" }
func (f *fakeAlbumProvider) Login(context.Context) error { return nil } func (f *fakeAlbumProvider) Login(context.Context) error { return nil }
func (f *fakePlaylistProvider) Login(context.Context) error { func (f *fakePlaylistProvider) Login(context.Context) error {
return nil return nil
} }
func (f *fakeVideoProvider) Login(context.Context) error { return nil } func (f *fakeVideoProvider) Login(context.Context) error { return nil }
func (f *fakeResolvedAlbumProvider) Login(context.Context) error {
return nil
}
func (f *fakeAlbumProvider) LoggedIn() bool { return true } func (f *fakeAlbumProvider) LoggedIn() bool { return true }
func (f *fakePlaylistProvider) LoggedIn() bool { return true } func (f *fakePlaylistProvider) LoggedIn() bool { return true }
func (f *fakeVideoProvider) LoggedIn() bool { return true } func (f *fakeVideoProvider) LoggedIn() bool { return true }
func (f *fakeResolvedAlbumProvider) LoggedIn() bool { return true }
func (f *fakeAlbumProvider) Close() error { return nil } func (f *fakeAlbumProvider) Close() error { return nil }
func (f *fakePlaylistProvider) Close() error { return nil } func (f *fakePlaylistProvider) Close() error { return nil }
func (f *fakeVideoProvider) Close() error { return nil } func (f *fakeVideoProvider) Close() error { return nil }
func (f *fakeResolvedAlbumProvider) Close() error { return nil }
func (f *fakeAlbumProvider) Search(context.Context, string, string, int) ([]map[string]any, error) { func (f *fakeAlbumProvider) Search(context.Context, string, string, int) ([]map[string]any, error) {
return nil, nil return nil, nil
} }
@@ -81,6 +101,9 @@ func (f *fakePlaylistProvider) Search(context.Context, string, string, int) ([]m
func (f *fakeVideoProvider) Search(context.Context, string, string, int) ([]map[string]any, error) { func (f *fakeVideoProvider) Search(context.Context, string, string, int) ([]map[string]any, error) {
return nil, nil return nil, nil
} }
func (f *fakeResolvedAlbumProvider) 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) { func (f *fakeAlbumProvider) GetMetadata(_ context.Context, id string, mediaType string) (map[string]any, error) {
if mediaType == "album" { if mediaType == "album" {
return map[string]any{ return map[string]any{
@@ -153,6 +176,22 @@ func (f *fakeVideoProvider) GetMetadata(_ context.Context, id string, mediaType
} }
return nil, nil return nil, nil
} }
func (f *fakeResolvedAlbumProvider) GetMetadata(_ context.Context, id string, mediaType string) (map[string]any, error) {
if mediaType == "album" {
return map[string]any{
"title": "Fallback Album",
"release_date_original": "2020-01-01",
"artist": map[string]any{"name": "Fallback Artist"},
"tracks": map[string]any{"items": []any{map[string]any{"id": "t1"}}},
}, nil
}
return map[string]any{
"title": "Fallback Song",
"track_number": float64(1),
"performer": map[string]any{"name": "Fallback Artist"},
"album": map[string]any{"title": "Fallback Album", "artist": map[string]any{"name": "Fallback Artist"}},
}, nil
}
func (f *fakeProvider) GetDownloadable(context.Context, string, int) (*provider.Downloadable, error) { func (f *fakeProvider) GetDownloadable(context.Context, string, int) (*provider.Downloadable, error) {
return &provider.Downloadable{URL: f.url, Extension: "flac", Source: "qobuz"}, nil return &provider.Downloadable{URL: f.url, Extension: "flac", Source: "qobuz"}, nil
} }
@@ -165,6 +204,14 @@ func (f *fakePlaylistProvider) GetDownloadable(context.Context, string, int) (*p
func (f *fakeVideoProvider) GetDownloadable(context.Context, string, int) (*provider.Downloadable, error) { func (f *fakeVideoProvider) GetDownloadable(context.Context, string, int) (*provider.Downloadable, error) {
return nil, nil return nil, nil
} }
func (f *fakeResolvedAlbumProvider) GetDownloadable(context.Context, string, int) (*provider.Downloadable, error) {
f.downloadableHits++
d := f.downloadable
if d.URL == "" {
d.URL = f.url
}
return &d, nil
}
func (f *fakeVideoProvider) GetVideoDownloadable(context.Context, string) (*provider.Downloadable, error) { func (f *fakeVideoProvider) GetVideoDownloadable(context.Context, string) (*provider.Downloadable, error) {
return &provider.Downloadable{URL: f.url, Extension: "mp4", Source: "tidal"}, nil return &provider.Downloadable{URL: f.url, Extension: "mp4", Source: "tidal"}, nil
} }
@@ -237,6 +284,56 @@ func TestTrackRipPipeline(t *testing.T) {
} }
} }
func TestTrackRipFailsWhenTaggerReportsMissingFFmpeg(t *testing.T) {
tmp := t.TempDir()
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("audio-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{
"qobuz": &fakeProvider{url: ts.URL},
},
Store: sqlite,
DL: download.NewWithOptions(true, false, 0),
Tagger: failingTagger{err: fmt.Errorf("ffmpeg not found: %w", exec.ErrNotFound)},
}
ctx := context.Background()
if err = m.AddByID(ctx, "qobuz", "track", "19512574"); err != nil {
t.Fatalf("AddByID() error = %v", err)
}
if err = m.Resolve(ctx); err != nil {
t.Fatalf("Resolve() error = %v", err)
}
err = m.Rip(ctx)
if err == nil {
t.Fatalf("expected rip failure")
}
ok, err := sqlite.IsDownloaded(ctx, "qobuz", "19512574")
if err != nil {
t.Fatalf("IsDownloaded() error = %v", err)
}
if ok {
t.Fatalf("expected track not marked downloaded")
}
}
func TestAlbumRipPipeline(t *testing.T) { func TestAlbumRipPipeline(t *testing.T) {
tmp := t.TempDir() tmp := t.TempDir()
@@ -393,6 +490,46 @@ func TestBuildTagMetadataUsesAlbumArtistOverride(t *testing.T) {
} }
} }
func TestBuildTagMetadataPlaylistOmitsDiscTags(t *testing.T) {
meta := map[string]any{
"title": "One Step Too Far",
"track_number": float64(15),
"media_number": float64(2),
"numberOfVolumes": float64(2),
"numberOfTracks": float64(18),
"performer": map[string]any{"name": "Faithless"},
"artist": map[string]any{"name": "Faithless"},
"release_date": "2005-01-01",
"release_date_original": "2005-01-01",
"album": map[string]any{
"id": "23324600",
"title": "Greatest Hits (Deluxe)",
"artist": map[string]any{"name": "Faithless"},
},
}
playlistCfg := config.DefaultConfigData().Metadata
applyPlaylistMetadataOverrides(meta, playlistCfg, "Road Trip", 3)
tags := buildTagMetadata(meta, "One Step Too Far", "tidal", "23324615", ripTrackOptions{forPlaylist: true, playlistName: "Road Trip", playlistPos: 3, total: 20})
if tags.Album != "Road Trip" {
t.Fatalf("album = %q, want Road Trip", tags.Album)
}
if tags.TrackNumber != 3 {
t.Fatalf("track number = %d, want 3", tags.TrackNumber)
}
if tags.TrackTotal != 20 {
t.Fatalf("track total = %d, want 20", tags.TrackTotal)
}
if tags.DiscNumber != 0 {
t.Fatalf("disc number = %d, want 0", tags.DiscNumber)
}
if tags.DiscTotal != 0 {
t.Fatalf("disc total = %d, want 0", tags.DiscTotal)
}
if !tags.OmitDiscTags {
t.Fatalf("omit disc tags = %v, want true", tags.OmitDiscTags)
}
}
func TestTrackOutputPathFallsBackToDisc1(t *testing.T) { func TestTrackOutputPathFallsBackToDisc1(t *testing.T) {
tmp := t.TempDir() tmp := t.TempDir()
d := config.DefaultConfigData() d := config.DefaultConfigData()
@@ -408,7 +545,7 @@ func TestTrackOutputPathFallsBackToDisc1(t *testing.T) {
"performer": map[string]any{"name": "Dido"}, "performer": map[string]any{"name": "Dido"},
"album": map[string]any{"id": "a", "title": "Greatest Hits", "artist": map[string]any{"name": "Dido"}}, "album": map[string]any{"id": "a", "title": "Greatest Hits", "artist": map[string]any{"name": "Dido"}},
} }
path := m.trackOutputPath("tidal", "1", "Song", "flac", meta, filepath.Join(tmp, "Album"), 2) path := m.trackOutputPath("tidal", "1", "Song", "flac", nil, meta, filepath.Join(tmp, "Album"), 2)
if !strings.Contains(path, string(filepath.Separator)+"Disc 1"+string(filepath.Separator)) { if !strings.Contains(path, string(filepath.Separator)+"Disc 1"+string(filepath.Separator)) {
t.Fatalf("expected Disc 1 subdir in path, got %q", path) t.Fatalf("expected Disc 1 subdir in path, got %q", path)
} }
@@ -440,7 +577,7 @@ func TestPlaylistRipPipeline(t *testing.T) {
"qobuz": &fakePlaylistProvider{url: ts.URL}, "qobuz": &fakePlaylistProvider{url: ts.URL},
}, },
Store: sqlite, Store: sqlite,
DL: download.NewWithOptions(true, false), DL: download.NewWithOptions(true, false, 0),
Tagger: noopTagger{}, Tagger: noopTagger{},
} }
@@ -464,6 +601,87 @@ func TestPlaylistRipPipeline(t *testing.T) {
} }
} }
func TestPlaylistRipUsesSourceSubdirectory(t *testing.T) {
tmp := t.TempDir()
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("audio-bytes"))
}))
defer ts.Close()
d := config.DefaultConfigData()
d.Downloads.Folder = tmp
d.Downloads.Concurrency = false
d.Downloads.SourceSubdirectories = true
d.Filepaths.RestrictCharacters = 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{
"qobuz": &fakePlaylistProvider{url: ts.URL},
},
Store: sqlite,
DL: download.NewWithOptions(true, false, 0),
Tagger: noopTagger{},
}
ctx := context.Background()
if err = m.AddByID(ctx, "qobuz", "playlist", "pl1"); 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)
}
folder := filepath.Join(tmp, "Qobuz", "Road Trip")
if _, err = os.Stat(filepath.Join(folder, "01. Artist - Track One.flac")); err != nil {
t.Fatalf("missing first playlist track in source subdir: %v", err)
}
}
func TestRipPlaylistRequiresDeezerARL(t *testing.T) {
d := config.DefaultConfigData()
m := &Main{Config: &config.Config{File: d, Session: d}}
err := m.ripPlaylist(context.Background(), nil, "deezer", "pl1", map[string]any{
"name": "Road Trip",
"tracks": map[string]any{"items": []any{map[string]any{"id": "p1"}}},
})
if err == nil || !strings.Contains(err.Error(), "deezer") {
t.Fatalf("expected deezer arl error, got %v", err)
}
}
func TestRipAlbumRequiresDeezerARL(t *testing.T) {
d := config.DefaultConfigData()
m := &Main{Config: &config.Config{File: d, Session: d}}
err := m.ripAlbum(context.Background(), nil, "deezer", "alb1", map[string]any{})
if err == nil || !strings.Contains(err.Error(), "deezer") {
t.Fatalf("expected deezer arl error, got %v", err)
}
}
func TestRipPlaylistMixedRequiresDeezerAuth(t *testing.T) {
d := config.DefaultConfigData()
m := &Main{Config: &config.Config{File: d, Session: d}}
err := m.ripPlaylistMixed(context.Background(), "mix1", "Mix", []PlaylistTrackRef{{Source: "deezer", ID: "1"}})
if err == nil || !strings.Contains(err.Error(), "deezer") {
t.Fatalf("expected deezer auth error, got %v", err)
}
}
func TestApplyQobuzArtistFiltersRepeats(t *testing.T) { func TestApplyQobuzArtistFiltersRepeats(t *testing.T) {
albums := []collectionAlbum{ albums := []collectionAlbum{
{ID: "a1", Title: "Album X", BitDepth: 16, Sampling: 44.1, Explicit: false}, {ID: "a1", Title: "Album X", BitDepth: 16, Sampling: 44.1, Explicit: false},
@@ -518,12 +736,107 @@ func TestTrackOutputPathSinglesUsesAlbumID(t *testing.T) {
"performer": map[string]any{"name": "Artist"}, "performer": map[string]any{"name": "Artist"},
} }
out := m.trackOutputPath("qobuz", "track-999", "Song", "flac", meta, "", 0) out := m.trackOutputPath("qobuz", "track-999", "Song", "flac", nil, meta, "", 0)
if got, want := filepath.Dir(out), filepath.Join(tmp, "album-123"); got != want { if got, want := filepath.Dir(out), filepath.Join(tmp, "album-123"); got != want {
t.Fatalf("trackOutputPath() dir=%q want %q", got, want) t.Fatalf("trackOutputPath() dir=%q want %q", got, want)
} }
} }
func TestTrackOutputPathSinglesUsesResolvedAudioProfile(t *testing.T) {
tmp := t.TempDir()
d := config.DefaultConfigData()
d.Downloads.Folder = tmp
d.Downloads.SourceSubdirectories = false
d.Filepaths.AddSinglesToFolder = true
d.Filepaths.FolderFormat = "{container}-{bit_depth}-{sampling_rate}"
d.Filepaths.TrackFormat = "{title}"
d.Filepaths.RestrictCharacters = false
m := &Main{Config: &config.Config{File: d, Session: d}}
meta := map[string]any{
"album": map[string]any{
"id": "album-123",
"title": "Album",
"artist": map[string]any{"name": "Artist"},
},
"performer": map[string]any{"name": "Artist"},
}
dl := &provider.Downloadable{Extension: "mp3", Audio: provider.AudioProfile{Container: "MP3", Codec: "MP3", Quality: "HIGH", BitDepth: 16, SamplingRate: "44.1", BitrateKbps: 320}}
out := m.trackOutputPath("qobuz", "track-999", "Song", "mp3", dl, meta, "", 0)
if got, want := filepath.Dir(out), filepath.Join(tmp, "MP3-16-44.1"); got != want {
t.Fatalf("trackOutputPath() dir=%q want %q", got, want)
}
}
func TestRipAlbumUsesResolvedAudioProfileForFolderName(t *testing.T) {
tmp := t.TempDir()
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("audio-bytes"))
}))
defer ts.Close()
d := config.DefaultConfigData()
d.Downloads.Folder = tmp
d.Downloads.SourceSubdirectories = false
d.Downloads.Concurrency = false
d.Filepaths.RestrictCharacters = 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() }()
fake := &fakeResolvedAlbumProvider{
url: ts.URL,
downloadable: provider.Downloadable{
URL: ts.URL,
Extension: "mp3",
Source: "qobuz",
Audio: provider.AudioProfile{
Container: "MP3",
Codec: "MP3",
Quality: "HIGH",
BitDepth: 16,
SamplingRate: "44.1",
BitrateKbps: 320,
},
},
}
m := &Main{
Config: cfg,
Providers: map[string]provider.Client{
"qobuz": fake,
},
Store: sqlite,
DL: download.NewWithOptions(true, false, 0),
Tagger: noopTagger{},
}
ctx := context.Background()
if err = m.AddByID(ctx, "qobuz", "album", "a1"); 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)
}
folder := filepath.Join(tmp, "Fallback Artist - Fallback Album (2020) [MP3] [16B-44.1kHz]")
if _, err = os.Stat(filepath.Join(folder, "01. Fallback Artist - Fallback Song.mp3")); err != nil {
t.Fatalf("missing track in resolved-quality folder: %v", err)
}
if fake.downloadableHits != 1 {
t.Fatalf("GetDownloadable() calls=%d, want 1 (prefetched reuse)", fake.downloadableHits)
}
}
func TestBuildTagMetadataReplayGainFallbacks(t *testing.T) { func TestBuildTagMetadataReplayGainFallbacks(t *testing.T) {
meta := map[string]any{ meta := map[string]any{
"replayGain": float64(-7.25), "replayGain": float64(-7.25),
@@ -550,3 +863,67 @@ func TestBuildTagMetadataReplayGainFallbacks(t *testing.T) {
t.Fatalf("album replaygain peak=%q", tags.ReplaygainAlbumPeak) t.Fatalf("album replaygain peak=%q", tags.ReplaygainAlbumPeak)
} }
} }
func TestBuildTagMetadataInitialKey(t *testing.T) {
meta := map[string]any{
"key": "E Minor",
"album": map[string]any{"title": "Album"},
"performer": map[string]any{"name": "Artist"},
}
tags := buildTagMetadata(meta, "Song", "beatport", "42", ripTrackOptions{})
if tags.InitialKey != "Em" {
t.Fatalf("InitialKey=%q", tags.InitialKey)
}
}
func TestBuildTagMetadataArtistList(t *testing.T) {
meta := map[string]any{
"artist_names": []string{"Lost Frequencies", "Calum Scott", "Kungs"},
"performer": map[string]any{"name": "Lost Frequencies, Calum Scott & Kungs"},
"album": map[string]any{"title": "Album"},
}
tags := buildTagMetadata(meta, "Song", "beatport", "42", ripTrackOptions{})
if tags.Artist != "Lost Frequencies, Calum Scott & Kungs" {
t.Fatalf("artist=%q", tags.Artist)
}
if got := strings.Join(tags.Artists, ";"); got != "Lost Frequencies;Calum Scott;Kungs" {
t.Fatalf("artists=%q", got)
}
}
func TestNormalizeInitialKey(t *testing.T) {
tests := map[string]string{
"C Major": "C",
"Db Major": "Db",
"F# Major": "F#",
"E Minor": "Em",
"C# Minor": "C#m",
"Bb Minor": "Bbm",
"g# minor": "G#m",
"F#m": "F#m",
"Ab": "Ab",
"Not A Key": "Not A Key",
"C# Major": "C# Major",
"Db Minor": "Db Minor",
}
for input, want := range tests {
if got := normalizeInitialKey(input); got != want {
t.Fatalf("normalizeInitialKey(%q)=%q want %q", input, got, want)
}
}
}
func TestBuildTagMetadataReplayGainFallsBackToDeezerGain(t *testing.T) {
meta := map[string]any{
"gain": float64(-10),
"performer": map[string]any{"name": "Artist"},
"album": map[string]any{"title": "Album"},
}
tags := buildTagMetadata(meta, "Song", "deezer", "2675762392", ripTrackOptions{})
if tags.ReplaygainTrackGain != "-10 dB" {
t.Fatalf("track replaygain gain=%q", tags.ReplaygainTrackGain)
}
}
+23 -3
View File
@@ -52,13 +52,13 @@ func Convert(path string, cfg config.ConversionConfig) (string, error) {
return path, fmt.Errorf("conversion failed: %w: %s", err, string(output)) return path, fmt.Errorf("conversion failed: %w: %s", err, string(output))
} }
if path != finalPath {
_ = os.Remove(path)
}
if err = os.Rename(tmpPath, finalPath); err != nil { if err = os.Rename(tmpPath, finalPath); err != nil {
_ = os.Remove(tmpPath) _ = os.Remove(tmpPath)
return path, err return path, err
} }
if path != finalPath {
_ = os.Remove(path)
}
return finalPath, nil return finalPath, nil
} }
@@ -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 {
+60
View File
@@ -1,6 +1,8 @@
package convert package convert
import ( import (
"os"
"path/filepath"
"strings" "strings"
"testing" "testing"
@@ -28,6 +30,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 +48,56 @@ 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)
}
}
func TestConvertKeepsSourceWhenRenameFails(t *testing.T) {
tmp := t.TempDir()
in := filepath.Join(tmp, "song.flac")
if err := os.WriteFile(in, []byte("src"), 0o644); err != nil {
t.Fatalf("write input: %v", err)
}
fakeBin := filepath.Join(tmp, "bin")
if err := os.MkdirAll(fakeBin, 0o755); err != nil {
t.Fatalf("mkdir bin: %v", err)
}
fakeFFmpeg := filepath.Join(fakeBin, "ffmpeg")
script := "#!/bin/sh\nout=\"\"\nfor arg in \"$@\"; do out=\"$arg\"; done\n: > \"$out\"\n"
if err := os.WriteFile(fakeFFmpeg, []byte(script), 0o755); err != nil {
t.Fatalf("write fake ffmpeg: %v", err)
}
t.Setenv("PATH", fakeBin)
finalPath := strings.TrimSuffix(in, filepath.Ext(in)) + ".m4a"
if err := os.Mkdir(finalPath, 0o755); err != nil {
t.Fatalf("mkdir final path: %v", err)
}
cfg := config.ConversionConfig{Enabled: true, Codec: "ALAC"}
out, err := Convert(in, cfg)
if err == nil {
t.Fatalf("expected rename failure")
}
if out != in {
t.Fatalf("returned path = %q, want %q", out, in)
}
if _, statErr := os.Stat(in); statErr != nil {
t.Fatalf("expected source to remain, stat err=%v", statErr)
}
tmpPath := finalPath + ".tmp.m4a"
if _, statErr := os.Stat(tmpPath); !os.IsNotExist(statErr) {
t.Fatalf("expected temp output cleanup, stat err=%v", statErr)
}
} }
+182 -6
View File
@@ -1,6 +1,7 @@
package tag package tag
import ( import (
"encoding/binary"
"fmt" "fmt"
"os" "os"
"os/exec" "os/exec"
@@ -13,13 +14,16 @@ type Metadata struct {
Title string Title string
Album string Album string
Artist string Artist string
Artists []string
AlbumArtist string AlbumArtist string
OmitDiscTags bool
TrackNumber int TrackNumber int
DiscNumber int DiscNumber int
TrackTotal int TrackTotal int
DiscTotal int DiscTotal int
Date string Date string
Genre string Genre string
InitialKey string
Comment string Comment string
Description string Description string
Lyrics string Lyrics string
@@ -47,9 +51,10 @@ func (t *Tagger) TagFLAC(path string, meta Metadata, coverPath string) error {
} }
ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(path)), ".") ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(path)), ".")
forceMP4Muxer := shouldForceMP4Muxer(path, ext)
tmpPath := taggedTempPath(path) tmpPath := taggedTempPath(path)
runTag := func(cover string) ([]byte, error) { runTag := func(cover string) ([]byte, error) {
args := buildFFmpegArgs(path, tmpPath, meta, cover, ext) args := buildFFmpegArgs(path, tmpPath, meta, cover, ext, forceMP4Muxer)
cmd := exec.Command("ffmpeg", args...) cmd := exec.Command("ffmpeg", args...)
return cmd.CombinedOutput() return cmd.CombinedOutput()
} }
@@ -68,11 +73,14 @@ func (t *Tagger) TagFLAC(path string, meta Metadata, coverPath string) error {
_ = os.Remove(tmpPath) _ = os.Remove(tmpPath)
return err return err
} }
if err = applyMultiValueFLACTags(path, meta); err != nil {
return err
}
return nil return nil
} }
func buildFFmpegArgs(inputPath, outputPath string, meta Metadata, coverPath, ext string) []string { func buildFFmpegArgs(inputPath, outputPath string, meta Metadata, coverPath, ext string, forceMP4Muxer bool) []string {
args := []string{"-y", "-i", inputPath} args := []string{"-y", "-i", inputPath}
withCover := coverPath != "" && fileExists(coverPath) && supportsAttachedPicture(ext) withCover := coverPath != "" && fileExists(coverPath) && supportsAttachedPicture(ext)
if withCover { if withCover {
@@ -101,11 +109,38 @@ func buildFFmpegArgs(inputPath, outputPath string, meta Metadata, coverPath, ext
} }
args = append(args, "-metadata", k+"="+v) args = append(args, "-metadata", k+"="+v)
} }
if meta.OmitDiscTags {
args = append(args,
"-metadata", "disc=",
"-metadata", "disk=",
"-metadata", "disctotal=",
"-metadata", "totaldiscs=",
)
}
if forceMP4Muxer {
args = append(args, "-f", "mp4")
}
args = append(args, outputPath) args = append(args, outputPath)
return args return args
} }
func shouldForceMP4Muxer(path, ext string) bool {
switch strings.TrimPrefix(strings.ToLower(ext), ".") {
case "m4a", "mp4":
default:
return false
}
if _, err := exec.LookPath("ffprobe"); err != nil {
return false
}
out, err := exec.Command("ffprobe", "-v", "error", "-select_streams", "a:0", "-show_entries", "stream=codec_name", "-of", "default=nokey=1:noprint_wrappers=1", path).Output()
if err != nil {
return false
}
return strings.EqualFold(strings.TrimSpace(string(out)), "flac")
}
func taggedTempPath(path string) string { func taggedTempPath(path string) string {
ext := filepath.Ext(path) ext := filepath.Ext(path)
if ext == "" { if ext == "" {
@@ -131,6 +166,7 @@ func toTags(meta Metadata) map[string]string {
"album_artist": meta.AlbumArtist, "album_artist": meta.AlbumArtist,
"date": meta.Date, "date": meta.Date,
"genre": meta.Genre, "genre": meta.Genre,
"INITIALKEY": meta.InitialKey,
"comment": meta.Comment, "comment": meta.Comment,
"description": meta.Description, "description": meta.Description,
"lyrics": meta.Lyrics, "lyrics": meta.Lyrics,
@@ -146,12 +182,8 @@ func toTags(meta Metadata) map[string]string {
"source_artist_id": meta.SourceArtistID, "source_artist_id": meta.SourceArtistID,
} }
if meta.TrackNumber > 0 { if meta.TrackNumber > 0 {
if meta.TrackTotal > 0 {
tags["track"] = fmt.Sprintf("%02d/%02d", meta.TrackNumber, meta.TrackTotal)
} else {
tags["track"] = fmt.Sprintf("%02d", meta.TrackNumber) tags["track"] = fmt.Sprintf("%02d", meta.TrackNumber)
} }
}
if meta.TrackTotal > 0 { if meta.TrackTotal > 0 {
tags["tracktotal"] = strconv.Itoa(meta.TrackTotal) tags["tracktotal"] = strconv.Itoa(meta.TrackTotal)
} }
@@ -168,6 +200,150 @@ func toTags(meta Metadata) map[string]string {
return tags return tags
} }
func applyMultiValueFLACTags(path string, meta Metadata) error {
if strings.ToLower(strings.TrimPrefix(filepath.Ext(path), ".")) != "flac" || len(meta.Artists) == 0 {
return nil
}
st, err := os.Stat(path)
if err != nil {
return err
}
data, err := os.ReadFile(path)
if err != nil {
return err
}
updated, err := replaceFLACVorbisComments(data, "ARTISTS", meta.Artists)
if err != nil {
return err
}
if updated == nil {
return nil
}
return os.WriteFile(path, updated, st.Mode())
}
type flacMetadataBlock struct {
isLast bool
blockType byte
data []byte
}
func replaceFLACVorbisComments(data []byte, key string, values []string) ([]byte, error) {
if len(data) < 4 || string(data[:4]) != "fLaC" {
return nil, fmt.Errorf("not a FLAC file")
}
blocks := []flacMetadataBlock{}
pos := 4
vorbisIndex := -1
for {
if pos+4 > len(data) {
return nil, fmt.Errorf("truncated FLAC metadata header")
}
header := data[pos]
blockType := header & 0x7f
length := int(data[pos+1])<<16 | int(data[pos+2])<<8 | int(data[pos+3])
pos += 4
if pos+length > len(data) {
return nil, fmt.Errorf("truncated FLAC metadata block")
}
if blockType == 4 {
vorbisIndex = len(blocks)
}
blocks = append(blocks, flacMetadataBlock{isLast: header&0x80 != 0, blockType: blockType, data: data[pos : pos+length]})
pos += length
if header&0x80 != 0 {
break
}
}
if vorbisIndex < 0 {
return nil, nil
}
commentBlock, err := replaceVorbisCommentValues(blocks[vorbisIndex].data, key, values)
if err != nil {
return nil, err
}
blocks[vorbisIndex].data = commentBlock
out := make([]byte, 0, len(data)+len(commentBlock)-len(blocks[vorbisIndex].data))
out = append(out, data[:4]...)
for _, block := range blocks {
if len(block.data) > 0xffffff {
return nil, fmt.Errorf("FLAC metadata block too large")
}
header := block.blockType
if block.isLast {
header |= 0x80
}
out = append(out, header, byte(len(block.data)>>16), byte(len(block.data)>>8), byte(len(block.data)))
out = append(out, block.data...)
}
out = append(out, data[pos:]...)
return out, nil
}
func replaceVorbisCommentValues(data []byte, key string, values []string) ([]byte, error) {
if len(data) < 8 {
return nil, fmt.Errorf("truncated Vorbis comment block")
}
pos := 0
vendorLength := int(binary.LittleEndian.Uint32(data[pos:]))
pos += 4
if pos+vendorLength+4 > len(data) {
return nil, fmt.Errorf("truncated Vorbis vendor string")
}
vendor := data[pos : pos+vendorLength]
pos += vendorLength
commentCount := int(binary.LittleEndian.Uint32(data[pos:]))
pos += 4
comments := make([][]byte, 0, commentCount+len(values))
for i := 0; i < commentCount; i++ {
if pos+4 > len(data) {
return nil, fmt.Errorf("truncated Vorbis comment length")
}
commentLength := int(binary.LittleEndian.Uint32(data[pos:]))
pos += 4
if pos+commentLength > len(data) {
return nil, fmt.Errorf("truncated Vorbis comment")
}
comment := data[pos : pos+commentLength]
pos += commentLength
if !vorbisCommentKeyEqual(comment, key) {
comments = append(comments, comment)
}
}
for _, value := range values {
value = strings.TrimSpace(value)
if value != "" {
comments = append(comments, []byte(key+"="+value))
}
}
out := make([]byte, 0, len(data))
out = appendUint32LE(out, uint32(len(vendor)))
out = append(out, vendor...)
out = appendUint32LE(out, uint32(len(comments)))
for _, comment := range comments {
out = appendUint32LE(out, uint32(len(comment)))
out = append(out, comment...)
}
return out, nil
}
func vorbisCommentKeyEqual(comment []byte, key string) bool {
idx := strings.IndexByte(string(comment), '=')
if idx < 0 {
return false
}
return strings.EqualFold(string(comment[:idx]), key)
}
func appendUint32LE(out []byte, v uint32) []byte {
return append(out, byte(v), byte(v>>8), byte(v>>16), byte(v>>24))
}
func normalizeCopyright(in string) string { func normalizeCopyright(in string) string {
out := strings.ReplaceAll(in, "(c)", "©") out := strings.ReplaceAll(in, "(c)", "©")
out = strings.ReplaceAll(out, "(C)", "©") out = strings.ReplaceAll(out, "(C)", "©")
+136 -3
View File
@@ -1,6 +1,7 @@
package tag package tag
import ( import (
"encoding/binary"
"os" "os"
"path/filepath" "path/filepath"
"testing" "testing"
@@ -30,6 +31,7 @@ func TestToTagsTotalsAndSourceFields(t *testing.T) {
DiscNumber: 1, DiscNumber: 1,
DiscTotal: 2, DiscTotal: 2,
ISRC: "USABC1234567", ISRC: "USABC1234567",
InitialKey: "Em",
ReplaygainTrackGain: "-7.25 dB", ReplaygainTrackGain: "-7.25 dB",
ReplaygainAlbumGain: "-8.1 dB", ReplaygainAlbumGain: "-8.1 dB",
ReplaygainTrackPeak: "0.989", ReplaygainTrackPeak: "0.989",
@@ -37,7 +39,7 @@ func TestToTagsTotalsAndSourceFields(t *testing.T) {
SourcePlatform: "qobuz", SourcePlatform: "qobuz",
SourceTrackID: "t1", SourceTrackID: "t1",
}) })
if tags["track"] != "03/12" { if tags["track"] != "03" {
t.Fatalf("track tag = %q", tags["track"]) t.Fatalf("track tag = %q", tags["track"])
} }
if tags["disc"] != "1/2" { if tags["disc"] != "1/2" {
@@ -49,6 +51,9 @@ func TestToTagsTotalsAndSourceFields(t *testing.T) {
if tags["isrc"] != "USABC1234567" { if tags["isrc"] != "USABC1234567" {
t.Fatalf("isrc missing: %+v", tags) t.Fatalf("isrc missing: %+v", tags)
} }
if tags["INITIALKEY"] != "Em" {
t.Fatalf("INITIALKEY missing: %+v", tags)
}
if tags["source_platform"] != "QOBUZ" || tags["source_track_id"] != "t1" { if tags["source_platform"] != "QOBUZ" || tags["source_track_id"] != "t1" {
t.Fatalf("source tags missing: %+v", tags) t.Fatalf("source tags missing: %+v", tags)
} }
@@ -60,13 +65,109 @@ func TestToTagsTotalsAndSourceFields(t *testing.T) {
} }
} }
func TestToTagsArtistList(t *testing.T) {
tags := toTags(Metadata{Artist: "A, B & C", Artists: []string{"A", "B", "C"}})
if tags["artist"] != "A, B & C" {
t.Fatalf("artist tags = %+v", tags)
}
if _, ok := tags["ARTISTS"]; ok {
t.Fatalf("ARTISTS should be written as repeated FLAC comments, got %+v", tags)
}
}
func TestReplaceVorbisCommentValuesWritesRepeatedTags(t *testing.T) {
block := vorbisCommentBlock("vendor", []string{
"ARTIST=A, B & C",
"ARTISTS=A;B;C",
"TITLE=Song",
})
updated, err := replaceVorbisCommentValues(block, "ARTISTS", []string{"A", "B", "C"})
if err != nil {
t.Fatalf("replaceVorbisCommentValues() error = %v", err)
}
comments := readVorbisComments(t, updated)
want := []string{"ARTIST=A, B & C", "TITLE=Song", "ARTISTS=A", "ARTISTS=B", "ARTISTS=C"}
if len(comments) != len(want) {
t.Fatalf("comments=%#v want %#v", comments, want)
}
for i := range want {
if comments[i] != want[i] {
t.Fatalf("comments=%#v want %#v", comments, want)
}
}
}
func TestReplaceFLACVorbisComments(t *testing.T) {
vorbis := vorbisCommentBlock("vendor", []string{"ARTISTS=A;B", "TITLE=Song"})
flac := append([]byte("fLaC"), flacBlockHeader(true, 4, len(vorbis))...)
flac = append(flac, vorbis...)
flac = append(flac, []byte("audio")...)
updated, err := replaceFLACVorbisComments(flac, "ARTISTS", []string{"A", "B"})
if err != nil {
t.Fatalf("replaceFLACVorbisComments() error = %v", err)
}
if string(updated[len(updated)-5:]) != "audio" {
t.Fatalf("audio payload not preserved")
}
length := int(updated[5])<<16 | int(updated[6])<<8 | int(updated[7])
comments := readVorbisComments(t, updated[8:8+length])
want := []string{"TITLE=Song", "ARTISTS=A", "ARTISTS=B"}
if len(comments) != len(want) {
t.Fatalf("comments=%#v want %#v", comments, want)
}
for i := range want {
if comments[i] != want[i] {
t.Fatalf("comments=%#v want %#v", comments, want)
}
}
}
func vorbisCommentBlock(vendor string, comments []string) []byte {
out := []byte{}
out = appendUint32LE(out, uint32(len(vendor)))
out = append(out, vendor...)
out = appendUint32LE(out, uint32(len(comments)))
for _, comment := range comments {
out = appendUint32LE(out, uint32(len(comment)))
out = append(out, comment...)
}
return out
}
func flacBlockHeader(last bool, blockType byte, length int) []byte {
header := blockType
if last {
header |= 0x80
}
return []byte{header, byte(length >> 16), byte(length >> 8), byte(length)}
}
func readVorbisComments(t *testing.T, block []byte) []string {
t.Helper()
pos := 0
vendorLength := int(binary.LittleEndian.Uint32(block[pos:]))
pos += 4 + vendorLength
count := int(binary.LittleEndian.Uint32(block[pos:]))
pos += 4
comments := make([]string, 0, count)
for i := 0; i < count; i++ {
length := int(binary.LittleEndian.Uint32(block[pos:]))
pos += 4
comments = append(comments, string(block[pos:pos+length]))
pos += length
}
return comments
}
func TestBuildFFmpegArgsWithCover(t *testing.T) { func TestBuildFFmpegArgsWithCover(t *testing.T) {
tmp := t.TempDir() tmp := t.TempDir()
cover := filepath.Join(tmp, "cover.jpg") cover := filepath.Join(tmp, "cover.jpg")
if err := os.WriteFile(cover, []byte("x"), 0o644); err != nil { if err := os.WriteFile(cover, []byte("x"), 0o644); err != nil {
t.Fatalf("write cover: %v", err) t.Fatalf("write cover: %v", err)
} }
args := buildFFmpegArgs("in.flac", "out.flac", Metadata{Title: "x"}, cover, "flac") args := buildFFmpegArgs("in.flac", "out.flac", Metadata{Title: "x"}, cover, "flac", false)
foundInput2 := false foundInput2 := false
foundAttach := false foundAttach := false
for i := 0; i < len(args)-1; i++ { for i := 0; i < len(args)-1; i++ {
@@ -88,7 +189,7 @@ func TestBuildFFmpegArgsSkipsCoverForUnsupportedContainer(t *testing.T) {
if err := os.WriteFile(cover, []byte("x"), 0o644); err != nil { if err := os.WriteFile(cover, []byte("x"), 0o644); err != nil {
t.Fatalf("write cover: %v", err) t.Fatalf("write cover: %v", err)
} }
args := buildFFmpegArgs("in.opus", "out.opus", Metadata{Title: "x"}, cover, "opus") args := buildFFmpegArgs("in.opus", "out.opus", Metadata{Title: "x"}, cover, "opus", false)
for i := 0; i < len(args)-1; i++ { for i := 0; i < len(args)-1; i++ {
if args[i] == "-i" && args[i+1] == cover { if args[i] == "-i" && args[i+1] == cover {
t.Fatalf("unexpected cover input for opus: %v", args) t.Fatalf("unexpected cover input for opus: %v", args)
@@ -96,6 +197,38 @@ func TestBuildFFmpegArgsSkipsCoverForUnsupportedContainer(t *testing.T) {
} }
} }
func TestBuildFFmpegArgsClearsDiscTagsWhenRequested(t *testing.T) {
args := buildFFmpegArgs("in.flac", "out.flac", Metadata{Title: "x", OmitDiscTags: true}, "", "flac", false)
want := map[string]bool{
"disc=": false,
"disk=": false,
"disctotal=": false,
"totaldiscs=": false,
}
for i := 0; i < len(args)-1; i++ {
if args[i] == "-metadata" {
if _, ok := want[args[i+1]]; ok {
want[args[i+1]] = true
}
}
}
for tag, found := range want {
if !found {
t.Fatalf("missing clear tag %q in args: %v", tag, args)
}
}
}
func TestBuildFFmpegArgsForcesMP4Muxer(t *testing.T) {
args := buildFFmpegArgs("in.m4a", "out.m4a", Metadata{Title: "x"}, "", "m4a", true)
for i := 0; i < len(args)-1; i++ {
if args[i] == "-f" && args[i+1] == "mp4" {
return
}
}
t.Fatalf("missing forced mp4 muxer args: %v", args)
}
func TestTaggedTempPathPreservesExtension(t *testing.T) { func TestTaggedTempPathPreservesExtension(t *testing.T) {
if got := taggedTempPath("/tmp/song.flac"); got != "/tmp/song.tmp.flac" { if got := taggedTempPath("/tmp/song.flac"); got != "/tmp/song.tmp.flac" {
t.Fatalf("taggedTempPath(flac)=%q", got) t.Fatalf("taggedTempPath(flac)=%q", got)
+54 -5
View File
@@ -24,6 +24,8 @@ type ConfigData struct {
Qobuz QobuzConfig `toml:"qobuz"` Qobuz QobuzConfig `toml:"qobuz"`
Tidal TidalConfig `toml:"tidal"` Tidal TidalConfig `toml:"tidal"`
Deezer DeezerConfig `toml:"deezer"` Deezer DeezerConfig `toml:"deezer"`
Yandex YandexConfig `toml:"yandex"`
Beatport BeatportConfig `toml:"beatport"`
Soundcloud SoundcloudConfig `toml:"soundcloud"` Soundcloud SoundcloudConfig `toml:"soundcloud"`
Youtube YoutubeConfig `toml:"youtube"` Youtube YoutubeConfig `toml:"youtube"`
Database DatabaseConfig `toml:"database"` Database DatabaseConfig `toml:"database"`
@@ -60,6 +62,7 @@ type QobuzConfig struct {
type TidalConfig struct { type TidalConfig struct {
Quality int `toml:"quality"` Quality int `toml:"quality"`
DownloadVideos bool `toml:"download_videos"` DownloadVideos bool `toml:"download_videos"`
PreferAtmos bool `toml:"prefer_atmos"`
UserID string `toml:"user_id"` UserID string `toml:"user_id"`
CountryCode string `toml:"country_code"` CountryCode string `toml:"country_code"`
AccessToken string `toml:"access_token"` AccessToken string `toml:"access_token"`
@@ -71,8 +74,25 @@ type DeezerConfig struct {
Quality int `toml:"quality"` Quality int `toml:"quality"`
LowerQualityIfNotAvailable bool `toml:"lower_quality_if_not_available"` LowerQualityIfNotAvailable bool `toml:"lower_quality_if_not_available"`
ARL string `toml:"arl"` ARL string `toml:"arl"`
UseDeezloader bool `toml:"use_deezloader"` Email string `toml:"email"`
DeezloaderWarnings bool `toml:"deezloader_warnings"` Password string `toml:"password"`
RefreshToken string `toml:"refresh_token"`
}
type YandexConfig struct {
Quality int `toml:"quality"`
AccessToken string `toml:"access_token"`
UserID string `toml:"user_id"`
}
type BeatportConfig struct {
Quality int `toml:"quality"`
VariousArtistsThreshold int `toml:"various_artists_threshold"`
Username string `toml:"username"`
Password string `toml:"password"`
AccessToken string `toml:"access_token"`
RefreshToken string `toml:"refresh_token"`
TokenExpiry int64 `toml:"token_expiry"`
} }
type SoundcloudConfig struct { type SoundcloudConfig struct {
@@ -122,6 +142,7 @@ type ArtworkConfig struct {
type MetadataConfig struct { type MetadataConfig struct {
SetPlaylistToAlbum bool `toml:"set_playlist_to_album"` SetPlaylistToAlbum bool `toml:"set_playlist_to_album"`
RenumberPlaylistTracks bool `toml:"renumber_playlist_tracks"` RenumberPlaylistTracks bool `toml:"renumber_playlist_tracks"`
ArtistSeparator string `toml:"artist_separator"`
Exclude []string `toml:"exclude"` Exclude []string `toml:"exclude"`
} }
@@ -171,7 +192,7 @@ func Load(path string) (*Config, error) {
return nil, err return nil, err
} }
var data ConfigData data := DefaultConfigData()
if err = toml.Unmarshal(raw, &data); err != nil { if err = toml.Unmarshal(raw, &data); err != nil {
return nil, err return nil, err
} }
@@ -184,6 +205,27 @@ func Load(path string) (*Config, error) {
return &Config{Path: resolvedPath, File: data, Session: cloneConfigData(data)}, nil return &Config{Path: resolvedPath, File: data, Session: cloneConfigData(data)}, nil
} }
func UpgradeOutdated(path string) (string, error) {
resolvedPath, err := resolvePath(path)
if err != nil {
return "", err
}
raw, err := os.ReadFile(resolvedPath)
if err != nil {
return "", err
}
data := DefaultConfigData()
if err = toml.Unmarshal(raw, &data); err != nil {
return "", err
}
applyRuntimeDefaults(&data)
data.Misc.Version = CurrentConfigVersion
if err = saveConfigData(resolvedPath, data); err != nil {
return "", err
}
return resolvedPath, nil
}
func (c *Config) SaveFile() error { func (c *Config) SaveFile() error {
return saveConfigData(c.Path, c.File) return saveConfigData(c.Path, c.File)
} }
@@ -211,12 +253,18 @@ func DefaultConfigData() ConfigData {
Tidal: TidalConfig{ Tidal: TidalConfig{
Quality: 3, Quality: 3,
DownloadVideos: true, DownloadVideos: true,
PreferAtmos: false,
}, },
Deezer: DeezerConfig{ Deezer: DeezerConfig{
Quality: 2, Quality: 2,
LowerQualityIfNotAvailable: true, LowerQualityIfNotAvailable: true,
UseDeezloader: true, },
DeezloaderWarnings: true, Yandex: YandexConfig{
Quality: 2,
},
Beatport: BeatportConfig{
Quality: 3,
VariousArtistsThreshold: 3,
}, },
Soundcloud: SoundcloudConfig{ Soundcloud: SoundcloudConfig{
Quality: 0, Quality: 0,
@@ -250,6 +298,7 @@ func DefaultConfigData() ConfigData {
Metadata: MetadataConfig{ Metadata: MetadataConfig{
SetPlaylistToAlbum: true, SetPlaylistToAlbum: true,
RenumberPlaylistTracks: true, RenumberPlaylistTracks: true,
ArtistSeparator: "; ",
Exclude: []string{}, Exclude: []string{},
}, },
Filepaths: FilepathsConfig{ Filepaths: FilepathsConfig{
+31
View File
@@ -53,6 +53,37 @@ func TestLoadOutdatedConfig(t *testing.T) {
} }
} }
func TestUpgradeOutdatedConfig(t *testing.T) {
tmpDir := t.TempDir()
path := filepath.Join(tmpDir, "config.toml")
data := DefaultConfigData()
data.Misc.Version = "1.0.0"
data.Downloads.Folder = filepath.Join(tmpDir, "Music")
if err := saveConfigData(path, data); err != nil {
t.Fatalf("saveConfigData() error = %v", err)
}
resolved, err := UpgradeOutdated(path)
if err != nil {
t.Fatalf("UpgradeOutdated() error = %v", err)
}
if resolved != path {
t.Fatalf("resolved path = %q, want %q", resolved, path)
}
cfg, err := Load(path)
if err != nil {
t.Fatalf("Load() after upgrade error = %v", err)
}
if cfg.File.Misc.Version != CurrentConfigVersion {
t.Fatalf("version = %q, want %q", cfg.File.Misc.Version, CurrentConfigVersion)
}
if cfg.File.Downloads.Folder != data.Downloads.Folder {
t.Fatalf("downloads folder changed unexpectedly")
}
}
func TestSessionCloneDoesNotAliasSlices(t *testing.T) { func TestSessionCloneDoesNotAliasSlices(t *testing.T) {
tmpDir := t.TempDir() tmpDir := t.TempDir()
path := filepath.Join(tmpDir, "config.toml") path := filepath.Join(tmpDir, "config.toml")
+659 -18
View File
@@ -3,12 +3,17 @@ package download
import ( import (
"bufio" "bufio"
"context" "context"
"crypto/aes"
"crypto/cipher"
"crypto/md5"
"encoding/hex"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"strconv"
"strings" "strings"
"sync/atomic" "sync/atomic"
@@ -17,6 +22,9 @@ import (
"golang.org/x/term" "golang.org/x/term"
"streamrip-go/internal/netutil" "streamrip-go/internal/netutil"
"streamrip-go/internal/verbose"
"golang.org/x/crypto/blowfish"
) )
type Downloader struct { type Downloader struct {
@@ -26,18 +34,23 @@ type Downloader struct {
barStarted atomic.Int32 barStarted atomic.Int32
} }
// downloadBufferSize sizes HTTP-to-disk copies. 1 MiB cuts read/write syscalls
// ~32x vs Go's default 32 KiB io.Copy buffer, which matters for multi-MB FLAC
// streams off CDNs that can sustain high per-connection throughput.
const downloadBufferSize = 1 << 20
func New() *Downloader { func New() *Downloader {
return NewWithOptions(true, true) return NewWithOptions(true, true, 0)
} }
func NewWithVerifySSL(verifySSL bool) *Downloader { func NewWithVerifySSL(verifySSL bool) *Downloader {
return NewWithOptions(verifySSL, true) return NewWithOptions(verifySSL, true, 0)
} }
func NewWithOptions(verifySSL bool, showProgress bool) *Downloader { func NewWithOptions(verifySSL bool, showProgress bool, maxConnsPerHost int) *Downloader {
forceProgress := strings.EqualFold(os.Getenv("STREAMRIP_GO_FORCE_PROGRESS"), "1") || strings.EqualFold(os.Getenv("STREAMRIP_GO_FORCE_PROGRESS"), "true") 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")) interactive := showProgress && (forceProgress || (term.IsTerminal(int(os.Stderr.Fd())) && strings.ToLower(os.Getenv("TERM")) != "dumb"))
d := &Downloader{http: netutil.NewHTTPClient(0, verifySSL), showProgress: interactive} d := &Downloader{http: netutil.NewHTTPClient(0, verifySSL, maxConnsPerHost), showProgress: interactive}
if interactive { if interactive {
d.progress = mpb.New(mpb.WithWidth(40), mpb.WithOutput(os.Stderr)) d.progress = mpb.New(mpb.WithWidth(40), mpb.WithOutput(os.Stderr))
} }
@@ -56,7 +69,237 @@ func (d *Downloader) FileVideo(ctx context.Context, sourceURL, outputPath string
return d.file(ctx, sourceURL, outputPath, true, true) return d.file(ctx, sourceURL, outputPath, true, true)
} }
func (d *Downloader) FileDeezerEncrypted(ctx context.Context, sourceURL, outputPath, trackID string) error {
logDownloadStart(sourceURL, outputPath)
if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, sourceURL, nil)
if err != nil {
return err
}
resp, err := d.http.Do(req)
if err != nil {
return err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("download failed: status=%d", resp.StatusCode)
}
out, err := os.Create(outputPath)
if err != nil {
return err
}
success := false
defer func() {
_ = out.Close()
if !success {
_ = os.Remove(outputPath)
}
}()
var bar *mpb.Bar
if d.ProgressEnabled() {
d.barStarted.Store(1)
desc := shortenName(filepath.Base(outputPath), 54)
if resp.ContentLength > 0 {
bar = d.progress.AddBar(
resp.ContentLength,
mpb.PrependDecorators(
decor.Name(desc+" ", decor.WC{W: 56, C: decor.DSyncWidth | decor.DindentRight}),
decor.Percentage(decor.WCSyncWidthR),
),
mpb.AppendDecorators(
decor.CountersKibiByte("% .1f / % .1f", decor.WCSyncWidthR),
decor.Name(" | ", decor.WCSyncWidth),
decor.AverageSpeed(decor.SizeB1024(0), "% .1f", decor.WCSyncWidthR),
decor.Name(" | ETA ", decor.WCSyncWidth),
decor.AverageETA(decor.ET_STYLE_GO, decor.WCSyncWidthR),
),
mpb.BarRemoveOnComplete(),
)
} else {
bar = d.progress.AddSpinner(
0,
mpb.PrependDecorators(
decor.Name(desc+" ", decor.WC{W: 56, C: decor.DSyncWidth | decor.DindentRight}),
),
mpb.AppendDecorators(
decor.CurrentKibiByte("% .1f", decor.WCSyncWidthR),
decor.Name(" | ", decor.WCSyncWidth),
decor.Elapsed(decor.ET_STYLE_GO, decor.WCSyncWidthR),
),
mpb.BarRemoveOnComplete(),
)
defer bar.SetTotal(-1, true)
}
defer func() {
if !success && bar != nil {
bar.Abort(true)
}
}()
}
block, err := blowfish.NewCipher(deriveDeezerBlowfishKey(trackID))
if err != nil {
return err
}
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 {
break
}
if readErr != nil && readErr != io.ErrUnexpectedEOF {
return readErr
}
chunk := buf[:n]
if chunkIndex%3 == 0 && n == deezerBFChunkSize {
mode := cipher.NewCBCDecrypter(block, deezerBFIV)
mode.CryptBlocks(dec[:n], chunk)
chunk = dec[:n]
}
if _, err = out.Write(chunk); err != nil {
return err
}
totalRead += int64(n)
if bar != nil {
bar.IncrBy(n)
}
chunkIndex++
if readErr == io.ErrUnexpectedEOF {
break
}
}
if resp.ContentLength > 0 && totalRead != resp.ContentLength {
return io.ErrUnexpectedEOF
}
if err = out.Sync(); err != nil {
return err
}
success = true
return nil
}
func (d *Downloader) FileYandexEncrypted(ctx context.Context, sourceURL, outputPath, key string) error {
logDownloadStart(sourceURL, outputPath)
if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil {
return err
}
keyBytes, err := hex.DecodeString(strings.TrimSpace(key))
if err != nil {
return fmt.Errorf("invalid yandex key: %w", err)
}
if len(keyBytes) != 16 {
return fmt.Errorf("invalid yandex key length: %d", len(keyBytes))
}
block, err := aes.NewCipher(keyBytes)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, sourceURL, nil)
if err != nil {
return err
}
resp, err := d.http.Do(req)
if err != nil {
return err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("download failed: status=%d", resp.StatusCode)
}
out, err := os.Create(outputPath)
if err != nil {
return err
}
success := false
defer func() {
_ = out.Close()
if !success {
_ = os.Remove(outputPath)
}
}()
var bar *mpb.Bar
if d.ProgressEnabled() {
d.barStarted.Store(1)
desc := shortenName(filepath.Base(outputPath), 54)
if resp.ContentLength > 0 {
bar = d.progress.AddBar(
resp.ContentLength,
mpb.PrependDecorators(
decor.Name(desc+" ", decor.WC{W: 56, C: decor.DSyncWidth | decor.DindentRight}),
decor.Percentage(decor.WCSyncWidthR),
),
mpb.AppendDecorators(
decor.CountersKibiByte("% .1f / % .1f", decor.WCSyncWidthR),
decor.Name(" | ", decor.WCSyncWidth),
decor.AverageSpeed(decor.SizeB1024(0), "% .1f", decor.WCSyncWidthR),
decor.Name(" | ETA ", decor.WCSyncWidth),
decor.AverageETA(decor.ET_STYLE_GO, decor.WCSyncWidthR),
),
mpb.BarRemoveOnComplete(),
)
} else {
bar = d.progress.AddSpinner(
0,
mpb.PrependDecorators(
decor.Name(desc+" ", decor.WC{W: 56, C: decor.DSyncWidth | decor.DindentRight}),
),
mpb.AppendDecorators(
decor.CurrentKibiByte("% .1f", decor.WCSyncWidthR),
decor.Name(" | ", decor.WCSyncWidth),
decor.Elapsed(decor.ET_STYLE_GO, decor.WCSyncWidthR),
),
mpb.BarRemoveOnComplete(),
)
defer bar.SetTotal(-1, true)
}
defer func() {
if !success && bar != nil {
bar.Abort(true)
}
}()
}
stream := cipher.NewCTR(block, make([]byte, aes.BlockSize))
reader := &cipher.StreamReader{S: stream, R: resp.Body}
buf := make([]byte, downloadBufferSize)
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)
if bar != nil {
bar.IncrBy(n)
}
}
if readErr != nil {
if readErr == io.EOF {
break
}
return readErr
}
}
if resp.ContentLength > 0 && totalWritten != resp.ContentLength {
return io.ErrUnexpectedEOF
}
if err = out.Sync(); err != nil {
return err
}
success = true
return nil
}
func (d *Downloader) file(ctx context.Context, sourceURL, outputPath string, allowProgress bool, includeVideo bool) error { func (d *Downloader) file(ctx context.Context, sourceURL, outputPath string, allowProgress bool, includeVideo bool) error {
logDownloadStart(sourceURL, outputPath)
if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil { if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil {
return err return err
} }
@@ -87,12 +330,20 @@ 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 {
d.barStarted.Store(1) d.barStarted.Store(1)
desc := shortenName(filepath.Base(outputPath), 54) desc := shortenName(filepath.Base(outputPath), 54)
bar := d.progress.AddBar( var bar *mpb.Bar
if resp.ContentLength > 0 {
bar = d.progress.AddBar(
resp.ContentLength, resp.ContentLength,
mpb.PrependDecorators( mpb.PrependDecorators(
decor.Name(desc+" ", decor.WC{W: 56, C: decor.DSyncWidth | decor.DindentRight}), decor.Name(desc+" ", decor.WC{W: 56, C: decor.DSyncWidth | decor.DindentRight}),
@@ -107,13 +358,35 @@ func (d *Downloader) file(ctx context.Context, sourceURL, outputPath string, all
), ),
mpb.BarRemoveOnComplete(), mpb.BarRemoveOnComplete(),
) )
buf := make([]byte, 256*1024) } else {
bar = d.progress.AddSpinner(
0,
mpb.PrependDecorators(
decor.Name(desc+" ", decor.WC{W: 56, C: decor.DSyncWidth | decor.DindentRight}),
),
mpb.AppendDecorators(
decor.CurrentKibiByte("% .1f", decor.WCSyncWidthR),
decor.Name(" | ", decor.WCSyncWidth),
decor.Elapsed(decor.ET_STYLE_GO, decor.WCSyncWidthR),
),
mpb.BarRemoveOnComplete(),
)
defer bar.SetTotal(-1, true)
}
defer func() {
if !success && bar != nil {
bar.Abort(true)
}
}()
buf := make([]byte, downloadBufferSize)
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 {
@@ -123,12 +396,26 @@ 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
}
if err = out.Sync(); err != nil {
return err
}
} else { } else {
if _, err = io.Copy(out, reader); err != nil { written, copyErr := io.CopyBuffer(out, reader, make([]byte, downloadBufferSize))
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
} }
@@ -151,6 +438,16 @@ func (d *Downloader) Logf(format string, args ...any) {
fmt.Print(msg) fmt.Print(msg)
} }
// logDownloadStart emits the source URL and destination filename when the
// user passed -v or higher. The transport-level logger covers the same
// requests at -vv, but this line gives a friendlier per-track summary.
func logDownloadStart(sourceURL, outputPath string) {
if !verbose.Enabled(verbose.V) {
return
}
verbose.Printf(verbose.V, "download %s -> %s\n", sourceURL, filepath.Base(outputPath))
}
func shortenName(name string, max int) string { func shortenName(name string, max int) string {
if max <= 0 { if max <= 0 {
return name return name
@@ -170,24 +467,179 @@ func (d *Downloader) streamManifestWithFFmpeg(ctx context.Context, sourceURL, ou
return fmt.Errorf("ffmpeg not found for manifest stream: %w", err) return fmt.Errorf("ffmpeg not found for manifest stream: %w", err)
} }
args := buildFFmpegStreamArgs(sourceURL, outputPath, includeVideo)
if !d.ProgressEnabled() {
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
}
d.barStarted.Store(1)
desc := shortenName(filepath.Base(outputPath), 54)
spinner := d.progress.AddSpinner(
0,
mpb.PrependDecorators(
decor.Name(desc+" ", decor.WC{W: 56, C: decor.DSyncWidth | decor.DindentRight}),
),
mpb.AppendDecorators(
decor.CurrentKibiByte("% .1f", decor.WCSyncWidthR),
decor.Name(" | ", decor.WCSyncWidth),
decor.Elapsed(decor.ET_STYLE_GO, decor.WCSyncWidthR),
),
mpb.BarRemoveOnComplete(),
)
cmd := exec.CommandContext(ctx, "ffmpeg", args...)
stderr, err := cmd.StderrPipe()
if err != nil {
spinner.SetTotal(-1, true)
return fmt.Errorf("ffmpeg stream setup failed: %w", err)
}
if err = cmd.Start(); err != nil {
spinner.SetTotal(-1, true)
return fmt.Errorf("ffmpeg stream setup failed: %w", err)
}
var ffOut strings.Builder
scanDone := make(chan scanState, 1)
go func() {
state := scanState{}
shownBytes := int64(0)
barTotalBytes := int64(0)
newEstimatedBar := func(totalBytes int64) *mpb.Bar {
return d.progress.AddBar(
totalBytes,
mpb.PrependDecorators(
decor.Name(desc+" ", decor.WC{W: 56, C: decor.DSyncWidth | decor.DindentRight}),
decor.Percentage(decor.WCSyncWidthR),
),
mpb.AppendDecorators(
decor.CountersKibiByte("% .1f / % .1f", decor.WCSyncWidthR),
decor.Name(" | ", decor.WCSyncWidth),
decor.AverageSpeed(decor.SizeB1024(0), "% .1f", decor.WCSyncWidthR),
decor.Name(" | ETA ", decor.WCSyncWidth),
decor.AverageETA(decor.ET_STYLE_GO, decor.WCSyncWidthR),
),
mpb.BarRemoveOnComplete(),
)
}
var bar *mpb.Bar
scanner := bufio.NewScanner(stderr)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for scanner.Scan() {
line := scanner.Text()
if ffOut.Len() < 256*1024 {
ffOut.WriteString(line)
ffOut.WriteByte('\n')
}
if totalMS, ok := parseFFmpegDurationLine(line); ok && totalMS > 0 {
state.totalMS = totalMS
}
if bitrateBPS, ok := parseFFmpegDurationBitrateBPS(line); ok && bitrateBPS > 0 {
state.bitrateBPS = bitrateBPS
}
if bitrateBPS, ok := parseFFmpegProgressBitrateBPS(line); ok && bitrateBPS > 0 {
state.bitrateBPS = bitrateBPS
}
if totalSize, ok := parseFFmpegTotalSize(line); ok && totalSize > state.currentBytes {
state.currentBytes = totalSize
}
if currentMS, ok := parseFFmpegOutTime(line); ok {
if currentMS > state.currentMS {
state.currentMS = currentMS
}
}
if bar == nil {
estimatedTotal := int64(0)
if state.totalMS > 0 && state.bitrateBPS > 0 {
estimatedTotal = estimateTotalBytesFromBitrate(state.totalMS, state.bitrateBPS)
}
if estimatedTotal == 0 {
estimatedTotal = estimateTotalBytesFromProgress(state.totalMS, state.currentMS, state.currentBytes)
}
if estimatedTotal > 0 {
if estimatedTotal < state.currentBytes {
estimatedTotal = state.currentBytes
}
barTotalBytes = estimatedTotal
spinner.SetTotal(-1, true)
bar = newEstimatedBar(barTotalBytes)
}
}
if state.currentBytes > shownBytes {
delta := state.currentBytes - shownBytes
if bar != nil {
bar.IncrBy(int(delta))
if state.currentBytes > barTotalBytes {
barTotalBytes = state.currentBytes
bar.SetTotal(barTotalBytes, false)
}
} else {
spinner.IncrBy(int(delta))
}
shownBytes = state.currentBytes
}
}
if bar != nil {
if shownBytes < barTotalBytes {
bar.IncrBy(int(barTotalBytes - shownBytes))
}
bar.SetTotal(barTotalBytes, true)
} else {
spinner.SetTotal(-1, true)
}
state.currentBytes = shownBytes
state.scanErr = scanner.Err()
scanDone <- state
}()
waitErr := cmd.Wait()
state := <-scanDone
if waitErr != nil {
_ = os.Remove(outputPath)
if out := strings.TrimSpace(ffOut.String()); out != "" {
return fmt.Errorf("ffmpeg stream copy failed: %w: %s", waitErr, out)
}
return fmt.Errorf("ffmpeg stream copy failed: %w", waitErr)
}
if state.scanErr != nil {
return fmt.Errorf("ffmpeg stream output read failed: %w", state.scanErr)
}
return nil
}
func buildFFmpegStreamArgs(sourceURL, outputPath string, includeVideo bool) []string {
args := []string{ args := []string{
"-y", "-y",
"-protocol_whitelist", "file,http,https,tcp,tls,crypto,data", "-protocol_whitelist", "file,http,https,tcp,tls,crypto,data",
"-i", sourceURL, "-i", sourceURL,
} }
if includeVideo { if includeVideo {
args = append(args, "-map", "0") args = append(args,
"-map", "0:v:0?",
"-map", "0:a:0?",
)
} else { } else {
args = append(args, "-map", "0:a:0") args = append(args, "-map", "0:a:0")
} }
args = append(args, "-c", "copy", outputPath) args = append(args, "-c", "copy", "-hide_banner", "-nostats", "-progress", "pipe:2", outputPath)
return args
cmd := exec.CommandContext(ctx, "ffmpeg", args...)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("ffmpeg stream copy failed: %w: %s", err, string(output))
} }
return nil
type scanState struct {
totalMS int64
currentMS int64
bitrateBPS int64
currentBytes int64
scanErr error
} }
func isManifestResponse(contentType string, peek []byte) bool { func isManifestResponse(contentType string, peek []byte) bool {
@@ -196,7 +648,7 @@ func isManifestResponse(contentType string, peek []byte) bool {
return true return true
} }
s := strings.TrimSpace(strings.ToLower(string(peek))) s := strings.TrimSpace(strings.ToLower(string(peek)))
if strings.HasPrefix(s, "<?xml") && strings.Contains(s, "<mpd") { if (strings.HasPrefix(s, "<?xml") && strings.Contains(s, "<mpd")) || strings.HasPrefix(s, "<mpd") {
return true return true
} }
if strings.HasPrefix(s, "#extm3u") { if strings.HasPrefix(s, "#extm3u") {
@@ -204,3 +656,192 @@ func isManifestResponse(contentType string, peek []byte) bool {
} }
return false return false
} }
func parseFFmpegDurationLine(line string) (int64, bool) {
idx := strings.Index(line, "Duration:")
if idx < 0 {
return 0, false
}
raw := strings.TrimSpace(line[idx+len("Duration:"):])
if raw == "" {
return 0, false
}
if comma := strings.Index(raw, ","); comma >= 0 {
raw = raw[:comma]
}
return parseClockDurationMS(raw)
}
func parseFFmpegOutTime(line string) (int64, bool) {
if !strings.HasPrefix(line, "out_time=") {
return 0, false
}
raw := strings.TrimSpace(strings.TrimPrefix(line, "out_time="))
if raw == "" || strings.EqualFold(raw, "N/A") {
return 0, false
}
return parseClockDurationMS(raw)
}
func parseFFmpegTotalSize(line string) (int64, bool) {
if !strings.HasPrefix(line, "total_size=") {
return 0, false
}
raw := strings.TrimSpace(strings.TrimPrefix(line, "total_size="))
if raw == "" || strings.EqualFold(raw, "N/A") {
return 0, false
}
v, err := strconv.ParseInt(raw, 10, 64)
if err != nil || v < 0 {
return 0, false
}
return v, true
}
func parseFFmpegDurationBitrateBPS(line string) (int64, bool) {
idx := strings.Index(strings.ToLower(line), "bitrate:")
if idx < 0 {
return 0, false
}
raw := strings.TrimSpace(line[idx+len("bitrate:"):])
if raw == "" {
return 0, false
}
return parseFFmpegBitrateBPS(raw)
}
func parseFFmpegProgressBitrateBPS(line string) (int64, bool) {
if !strings.HasPrefix(line, "bitrate=") {
return 0, false
}
raw := strings.TrimSpace(strings.TrimPrefix(line, "bitrate="))
if raw == "" || strings.EqualFold(raw, "N/A") {
return 0, false
}
return parseFFmpegBitrateBPS(raw)
}
func parseFFmpegBitrateBPS(raw string) (int64, bool) {
raw = strings.TrimSpace(raw)
if raw == "" || strings.EqualFold(raw, "N/A") {
return 0, false
}
parts := strings.Fields(strings.ToLower(raw))
if len(parts) == 0 {
return 0, false
}
numStr := parts[0]
unit := ""
if len(parts) > 1 {
unit = parts[1]
} else {
i := 0
for i < len(numStr) {
c := numStr[i]
if (c >= '0' && c <= '9') || c == '.' {
i++
continue
}
break
}
if i < len(numStr) {
unit = numStr[i:]
numStr = numStr[:i]
}
}
num, err := strconv.ParseFloat(strings.TrimSpace(numStr), 64)
if err != nil || num <= 0 {
return 0, false
}
unit = strings.TrimSpace(strings.Trim(unit, ","))
mult := float64(1)
switch unit {
case "kb/s", "kbits/s", "kbit/s", "kbps":
mult = 1000
case "mb/s", "mbits/s", "mbit/s", "mbps":
mult = 1000 * 1000
case "gb/s", "gbits/s", "gbit/s", "gbps":
mult = 1000 * 1000 * 1000
case "b/s", "bit/s", "bits/s", "":
mult = 1
default:
return 0, false
}
bps := int64(num * mult)
if bps <= 0 {
return 0, false
}
return bps, true
}
func estimateTotalBytesFromBitrate(totalMS, bitrateBPS int64) int64 {
if totalMS <= 0 || bitrateBPS <= 0 {
return 0
}
return (totalMS * bitrateBPS) / 8000
}
func estimateTotalBytesFromProgress(totalMS, currentMS, currentBytes int64) int64 {
if totalMS <= 0 || currentMS <= 0 || currentBytes <= 0 {
return 0
}
if currentMS > totalMS {
currentMS = totalMS
}
est := (currentBytes * totalMS) / currentMS
if est < currentBytes {
est = currentBytes
}
return est
}
func parseClockDurationMS(raw string) (int64, bool) {
parts := strings.Split(strings.TrimSpace(raw), ":")
if len(parts) != 3 {
return 0, false
}
hours, err := strconv.ParseInt(parts[0], 10, 64)
if err != nil || hours < 0 {
return 0, false
}
minutes, err := strconv.ParseInt(parts[1], 10, 64)
if err != nil || minutes < 0 {
return 0, false
}
secPart := parts[2]
secRaw, fracRaw, hasFrac := strings.Cut(secPart, ".")
seconds, err := strconv.ParseInt(secRaw, 10, 64)
if err != nil || seconds < 0 {
return 0, false
}
ms := ((hours*60+minutes)*60 + seconds) * 1000
if hasFrac {
if len(fracRaw) > 3 {
fracRaw = fracRaw[:3]
}
for len(fracRaw) < 3 {
fracRaw += "0"
}
fracMS, fracErr := strconv.ParseInt(fracRaw, 10, 64)
if fracErr != nil || fracMS < 0 {
return 0, false
}
ms += fracMS
}
return ms, true
}
const deezerBFChunkSize = 2048
var deezerBFIV = []byte{0, 1, 2, 3, 4, 5, 6, 7}
func deriveDeezerBlowfishKey(trackID string) []byte {
sum := md5.Sum([]byte(trackID))
md5Hex := fmt.Sprintf("%x", sum)
secret := "g4el58wc0zvf9na1"
key := make([]byte, 16)
for i := 0; i < 16; i++ {
key[i] = md5Hex[i] ^ md5Hex[i+16] ^ secret[i]
}
return key
}
+317 -1
View File
@@ -2,15 +2,25 @@ package download
import ( import (
"context" "context"
"crypto/aes"
"crypto/cipher"
"errors"
"encoding/hex"
"io"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os" "os"
"os/exec"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
"time"
"golang.org/x/crypto/blowfish"
) )
func TestDownloaderHasNoClientTimeout(t *testing.T) { func TestDownloaderHasNoClientTimeout(t *testing.T) {
d := NewWithOptions(true, false) d := NewWithOptions(true, false, 0)
if d.http.Timeout != 0 { if d.http.Timeout != 0 {
t.Fatalf("http timeout = %v, want 0 (no global timeout)", d.http.Timeout) t.Fatalf("http timeout = %v, want 0 (no global timeout)", d.http.Timeout)
} }
@@ -41,9 +51,15 @@ func TestManifestDetection(t *testing.T) {
if !isManifestResponse("application/dash+xml", []byte("x")) { if !isManifestResponse("application/dash+xml", []byte("x")) {
t.Fatalf("expected dash content-type to be manifest") t.Fatalf("expected dash content-type to be manifest")
} }
if !isManifestResponse("application/vnd.apple.mpegurl", []byte("x")) {
t.Fatalf("expected mpegurl content-type to be manifest")
}
if !isManifestResponse("application/octet-stream", []byte("<?xml version='1.0'?><MPD></MPD>")) { if !isManifestResponse("application/octet-stream", []byte("<?xml version='1.0'?><MPD></MPD>")) {
t.Fatalf("expected MPD XML body to be manifest") t.Fatalf("expected MPD XML body to be manifest")
} }
if !isManifestResponse("application/octet-stream", []byte("<MPD type='static'></MPD>")) {
t.Fatalf("expected MPD body without xml prolog to be manifest")
}
if !isManifestResponse("text/plain", []byte("#EXTM3U\n#EXT-X-VERSION:3")) { if !isManifestResponse("text/plain", []byte("#EXTM3U\n#EXT-X-VERSION:3")) {
t.Fatalf("expected HLS body to be manifest") t.Fatalf("expected HLS body to be manifest")
} }
@@ -51,3 +67,303 @@ func TestManifestDetection(t *testing.T) {
t.Fatalf("did not expect flac to be manifest") t.Fatalf("did not expect flac to be manifest")
} }
} }
func TestDeezerBlowfishKeyDerivation(t *testing.T) {
trackID := "3135556"
key := deriveDeezerBlowfishKey(trackID)
if len(key) != 16 {
t.Fatalf("blowfish key len = %d, want 16", len(key))
}
}
func TestFileDeezerEncrypted(t *testing.T) {
trackID := "3135556"
plain := make([]byte, deezerBFChunkSize+777)
for i := range plain {
plain[i] = byte((i * 7) % 251)
}
enc := make([]byte, len(plain))
copy(enc, plain)
block, err := blowfish.NewCipher(deriveDeezerBlowfishKey(trackID))
if err != nil {
t.Fatalf("cipher error: %v", err)
}
cbc := cipher.NewCBCEncrypter(block, deezerBFIV)
cbc.CryptBlocks(enc[:deezerBFChunkSize], enc[:deezerBFChunkSize])
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write(enc)
}))
defer ts.Close()
d := NewWithOptions(true, false, 0)
out := filepath.Join(t.TempDir(), "x", "a.flac")
if err = d.FileDeezerEncrypted(context.Background(), ts.URL, out, trackID); err != nil {
t.Fatalf("FileDeezerEncrypted() error = %v", err)
}
got, err := os.ReadFile(out)
if err != nil {
t.Fatalf("ReadFile() error = %v", err)
}
if string(got) != string(plain) {
t.Fatalf("decrypted file mismatch")
}
}
func TestFileYandexEncrypted(t *testing.T) {
plain := make([]byte, 8192+333)
for i := range plain {
plain[i] = byte((i * 11) % 251)
}
keyHex := "00112233445566778899aabbccddeeff"
key, err := hex.DecodeString(keyHex)
if err != nil {
t.Fatalf("DecodeString() error = %v", err)
}
block, err := aes.NewCipher(key)
if err != nil {
t.Fatalf("NewCipher() error = %v", err)
}
enc := make([]byte, len(plain))
copy(enc, plain)
stream := cipher.NewCTR(block, make([]byte, aes.BlockSize))
stream.XORKeyStream(enc, enc)
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write(enc)
}))
defer ts.Close()
d := NewWithOptions(true, false, 0)
out := filepath.Join(t.TempDir(), "x", "a.m4a")
if err = d.FileYandexEncrypted(context.Background(), ts.URL, out, keyHex); err != nil {
t.Fatalf("FileYandexEncrypted() error = %v", err)
}
got, err := os.ReadFile(out)
if err != nil {
t.Fatalf("ReadFile() error = %v", err)
}
if string(got) != string(plain) {
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, 0)
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, 0)
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)
}
}
func TestFileDeezerEncryptedBadStatus(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusForbidden)
}))
defer ts.Close()
d := NewWithOptions(true, false, 0)
out := filepath.Join(t.TempDir(), "x", "a.flac")
err := d.FileDeezerEncrypted(context.Background(), ts.URL, out, "3135556")
if err == nil || !strings.Contains(err.Error(), "status=403") {
t.Fatalf("expected status error, got %v", err)
}
}
func TestFileYandexEncryptedBadKey(t *testing.T) {
d := NewWithOptions(true, false, 0)
out := filepath.Join(t.TempDir(), "x", "a.m4a")
err := d.FileYandexEncrypted(context.Background(), "https://example.com/file", out, "abcd")
if err == nil || !strings.Contains(err.Error(), "invalid yandex key length") {
t.Fatalf("expected key length error, got %v", err)
}
}
func TestDownloaderFileContextCancellationRemovesPartialFile(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/octet-stream")
_, _ = w.Write([]byte("partial-data"))
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
<-r.Context().Done()
}))
defer ts.Close()
d := NewWithOptions(true, false, 0)
out := filepath.Join(t.TempDir(), "x", "cancel.bin")
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Millisecond)
defer cancel()
err := d.File(ctx, ts.URL, out)
if err == nil {
t.Fatalf("expected context cancellation error")
}
if _, statErr := os.Stat(out); !errors.Is(statErr, os.ErrNotExist) {
t.Fatalf("expected no partial file, stat err=%v", statErr)
}
}
func TestStreamManifestWithFFmpegMissing(t *testing.T) {
d := NewWithOptions(true, false, 0)
t.Setenv("PATH", "")
err := d.streamManifestWithFFmpeg(context.Background(), "https://example.com/live.m3u8", filepath.Join(t.TempDir(), "out.m4a"), false)
if err == nil || !strings.Contains(strings.ToLower(err.Error()), "ffmpeg not found") {
t.Fatalf("expected ffmpeg missing error, got %v", err)
}
}
func TestStreamManifestWithFFmpegFailureRemovesPartialFile(t *testing.T) {
if _, err := exec.LookPath("ffmpeg"); err != nil {
t.Skip("ffmpeg not installed")
}
d := NewWithOptions(true, false, 0)
out := filepath.Join(t.TempDir(), "out.m4a")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
err := d.streamManifestWithFFmpeg(ctx, "https://127.0.0.1:1/unreachable.m3u8", out, false)
if err == nil || !strings.Contains(err.Error(), "ffmpeg stream copy failed") {
t.Fatalf("expected ffmpeg failure error, got %v", err)
}
if _, statErr := os.Stat(out); !errors.Is(statErr, os.ErrNotExist) {
t.Fatalf("expected no partial file after ffmpeg failure, stat err=%v", statErr)
}
}
func TestParseFFmpegDurationLine(t *testing.T) {
totalMS, ok := parseFFmpegDurationLine(" Duration: 00:04:52.57, start: 0.000000, bitrate: 975 kb/s")
if !ok {
t.Fatalf("expected duration parse to succeed")
}
if want := int64(292570); totalMS != want {
t.Fatalf("unexpected duration ms: got=%d want=%d", totalMS, want)
}
}
func TestParseFFmpegDurationBitrateBPS(t *testing.T) {
bps, ok := parseFFmpegDurationBitrateBPS(" Duration: 00:04:52.57, start: 0.000000, bitrate: 975 kb/s")
if !ok {
t.Fatalf("expected bitrate parse to succeed")
}
if want := int64(975000); bps != want {
t.Fatalf("unexpected bitrate: got=%d want=%d", bps, want)
}
}
func TestParseFFmpegProgressBitrateBPS(t *testing.T) {
bps, ok := parseFFmpegProgressBitrateBPS("bitrate=1706.8kbits/s")
if !ok {
t.Fatalf("expected progress bitrate parse to succeed")
}
if want := int64(1706800); bps != want {
t.Fatalf("unexpected bitrate: got=%d want=%d", bps, want)
}
}
func TestParseFFmpegTotalSize(t *testing.T) {
size, ok := parseFFmpegTotalSize("total_size=1234567")
if !ok {
t.Fatalf("expected total_size parse to succeed")
}
if want := int64(1234567); size != want {
t.Fatalf("unexpected total_size: got=%d want=%d", size, want)
}
}
func TestParseFFmpegOutTime(t *testing.T) {
currentMS, ok := parseFFmpegOutTime("out_time=00:01:02.340000")
if !ok {
t.Fatalf("expected out_time parse to succeed")
}
if want := int64(62340); currentMS != want {
t.Fatalf("unexpected out_time ms: got=%d want=%d", currentMS, want)
}
}
func TestParseClockDurationMSInvalid(t *testing.T) {
if _, ok := parseClockDurationMS("bad"); ok {
t.Fatalf("expected invalid duration to fail")
}
if _, ok := parseClockDurationMS("00:12"); ok {
t.Fatalf("expected short duration to fail")
}
}
func TestEstimateTotalBytesFromBitrate(t *testing.T) {
total := estimateTotalBytesFromBitrate(10000, 1600000)
if want := int64(2000000); total != want {
t.Fatalf("unexpected estimate from bitrate: got=%d want=%d", total, want)
}
}
func TestEstimateTotalBytesFromProgress(t *testing.T) {
total := estimateTotalBytesFromProgress(10000, 2500, 500000)
if want := int64(2000000); total != want {
t.Fatalf("unexpected estimate from progress: got=%d want=%d", total, want)
}
}
func TestBuildFFmpegStreamArgsAudioOnly(t *testing.T) {
args := buildFFmpegStreamArgs("https://example.com/master.m3u8", "/tmp/out.m4a", false)
if !containsArgPair(args, "-map", "0:a:0") {
t.Fatalf("expected audio map in args: %v", args)
}
if containsArgPair(args, "-map", "0:v:0?") {
t.Fatalf("did not expect video map in audio-only args: %v", args)
}
if containsArgPair(args, "-map", "0") {
t.Fatalf("did not expect broad map=0 in args: %v", args)
}
}
func TestBuildFFmpegStreamArgsIncludeVideo(t *testing.T) {
args := buildFFmpegStreamArgs("https://example.com/master.m3u8", "/tmp/out.mp4", true)
if !containsArgPair(args, "-map", "0:v:0?") {
t.Fatalf("expected video map in args: %v", args)
}
if !containsArgPair(args, "-map", "0:a:0?") {
t.Fatalf("expected audio map in args: %v", args)
}
if containsArgPair(args, "-map", "0") {
t.Fatalf("did not expect broad map=0 in args: %v", args)
}
}
func containsArgPair(args []string, key, value string) bool {
for i := 0; i+1 < len(args); i++ {
if args[i] == key && args[i+1] == value {
return true
}
}
return false
}
+131
View File
@@ -0,0 +1,131 @@
// Package jsonutil provides shared helpers for working with untyped JSON
// values (map[string]any) that come from API responses across all providers.
package jsonutil
import (
"strconv"
"strings"
)
// StringFromAny converts a dynamic JSON value to a string.
// Numeric types are formatted without trailing zeroes.
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 ""
}
}
// IntFromAny converts a dynamic JSON value to an int.
// Handles int, int64, float64, and string types.
func IntFromAny(v any) int {
switch t := v.(type) {
case int:
return t
case int32:
return int(t)
case int64:
return int(t)
case float64:
return int(t)
case string:
i, _ := strconv.Atoi(strings.TrimSpace(t))
return i
default:
return 0
}
}
// FloatFromAny converts a dynamic JSON value to a float64.
func FloatFromAny(v any) float64 {
switch t := v.(type) {
case float64:
return t
case int:
return float64(t)
case int64:
return float64(t)
default:
return 0
}
}
// BoolFromAny converts a dynamic JSON value to a bool.
// Supports bool, string ("true"/"1"/"yes"), and numeric types.
func BoolFromAny(v any) bool {
switch t := v.(type) {
case bool:
return t
case string:
l := strings.ToLower(strings.TrimSpace(t))
return l == "1" || l == "true" || l == "yes"
case int:
return t != 0
case int64:
return t != 0
case float64:
return t != 0
default:
return false
}
}
// FirstNonEmpty returns the first string in items that is non-empty after trimming.
func FirstNonEmpty(items ...string) string {
for _, item := range items {
if strings.TrimSpace(item) != "" {
return strings.TrimSpace(item)
}
}
return ""
}
// NestedMap returns the value at m[key] as a map[string]any.
// Returns an empty map if the key is missing or the value is not a map.
func NestedMap(m map[string]any, key string) map[string]any {
v, ok := m[key].(map[string]any)
if !ok {
return map[string]any{}
}
return v
}
// NestedAny traverses a chain of map keys and returns the final value.
func NestedAny(v map[string]any, keys ...string) any {
cur := any(v)
for _, key := range keys {
m, ok := cur.(map[string]any)
if !ok {
return nil
}
cur = m[key]
}
return cur
}
// NestedString traverses a chain of map keys and returns the final value as a string.
func NestedString(v map[string]any, keys ...string) string {
return StringFromAny(NestedAny(v, keys...))
}
// TitleCase capitalises the first rune of s. This is a simple ASCII replacement
// for the deprecated strings.Title function, suitable for source names like
// "qobuz" → "Qobuz".
func TitleCase(s string) string {
if s == "" {
return s
}
r := []rune(s)
if r[0] >= 'a' && r[0] <= 'z' {
r[0] -= 'a' - 'A'
}
return string(r)
}
+87 -2
View File
@@ -3,18 +3,103 @@ package netutil
import ( import (
"crypto/tls" "crypto/tls"
"net/http" "net/http"
"net/url"
"time" "time"
"streamrip-go/internal/verbose"
) )
func NewHTTPClient(timeout time.Duration, verifySSL bool) *http.Client { const defaultMaxConnsPerHost = 16
// NewHTTPClient builds an *http.Client whose transport is tuned for the
// concurrent download workloads this app issues against single CDN hosts.
//
// maxConnsPerHost caps idle keep-alive sockets per host; pass <= 0 to use a
// sensible default. The downloader and provider clients should pass the
// configured concurrency so keep-alive sockets aren't evicted between workers.
func NewHTTPClient(timeout time.Duration, verifySSL bool, maxConnsPerHost int) *http.Client {
if maxConnsPerHost <= 0 {
maxConnsPerHost = defaultMaxConnsPerHost
}
transport := http.DefaultTransport.(*http.Transport).Clone() transport := http.DefaultTransport.(*http.Transport).Clone()
if transport.TLSClientConfig == nil { if transport.TLSClientConfig == nil {
transport.TLSClientConfig = &tls.Config{} transport.TLSClientConfig = &tls.Config{}
} }
transport.TLSClientConfig.InsecureSkipVerify = !verifySSL transport.TLSClientConfig.InsecureSkipVerify = !verifySSL
transport.MaxIdleConnsPerHost = maxConnsPerHost
if maxIdle := maxConnsPerHost * 4; maxIdle > transport.MaxIdleConns {
transport.MaxIdleConns = maxIdle
}
if transport.MaxIdleConns < 100 {
transport.MaxIdleConns = 100
}
transport.MaxConnsPerHost = 0
transport.IdleConnTimeout = 90 * time.Second
transport.WriteBufferSize = 64 * 1024
transport.ReadBufferSize = 64 * 1024
transport.ForceAttemptHTTP2 = true
return &http.Client{ return &http.Client{
Timeout: timeout, Timeout: timeout,
Transport: transport, Transport: &loggingTransport{base: transport},
} }
} }
// loggingTransport emits one verbose line per HTTP request when verbose
// level >= VV. The check is per-call so toggling the level at runtime
// affects subsequent requests without rebuilding clients.
type loggingTransport struct {
base http.RoundTripper
}
func (t *loggingTransport) RoundTrip(req *http.Request) (*http.Response, error) {
if !verbose.Enabled(verbose.VV) {
return t.base.RoundTrip(req)
}
start := time.Now()
resp, err := t.base.RoundTrip(req)
elapsed := time.Since(start).Round(time.Millisecond)
target := redactURL(req.URL)
if err != nil {
verbose.Printf(verbose.VV, "http %s %s -> error %v (%s)\n", req.Method, target, err, elapsed)
return resp, err
}
verbose.Printf(verbose.VV, "http %s %s -> %d (%s)\n", req.Method, target, resp.StatusCode, elapsed)
return resp, err
}
// redactURL hides values for query parameters that commonly carry
// credentials so -vv output is safe to paste in an issue.
func redactURL(u *url.URL) string {
if u == nil {
return ""
}
if u.RawQuery == "" {
return u.String()
}
q := u.Query()
redacted := false
for k := range q {
if isSensitiveParam(k) {
q.Set(k, "REDACTED")
redacted = true
}
}
if !redacted {
return u.String()
}
cp := *u
cp.RawQuery = q.Encode()
return cp.String()
}
func isSensitiveParam(name string) bool {
switch name {
case "user_auth_token", "api_token", "access_token", "refresh_token",
"request_sig", "signature", "password", "secret", "token", "code", "auth", "key":
return true
}
return false
}
+825
View File
@@ -0,0 +1,825 @@
package beatport
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"streamrip-go/internal/config"
"streamrip-go/internal/jsonutil"
"streamrip-go/internal/netutil"
"streamrip-go/internal/provider"
"streamrip-go/internal/ratelimit"
)
const (
baseURL = "https://api.beatport.com/v4"
clientID = "ryZ8LuyQVPqbK2mBX2Hwt4qSMtnWuTYSqBPO92yQ"
tokenEndpoint = "/auth/o/token/"
authEndpoint = "/auth/o/authorize/"
loginEndpoint = "/auth/login/"
)
var (
errMissingCredentials = errors.New("missing beatport credentials")
errNotLoggedIn = errors.New("beatport client not logged in")
)
type Client struct {
cfg *config.Config
http *http.Client
limiter *ratelimit.Limiter
baseURL string
loggedIn bool
sessionID string
}
func New(cfg *config.Config) *Client {
h := netutil.NewHTTPClient(40*time.Second, cfg.Session.Downloads.VerifySSL, cfg.Session.Downloads.MaxConnections)
h.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
return &Client{
cfg: cfg,
http: h,
limiter: ratelimit.New(cfg.Session.Downloads.RequestsPerMinute),
baseURL: baseURL,
}
}
func (c *Client) Source() string { return "beatport" }
func (c *Client) LoggedIn() bool { return c.loggedIn }
func (c *Client) Login(ctx context.Context) error {
b := &c.cfg.Session.Beatport
b.Username = strings.TrimSpace(b.Username)
b.Password = strings.TrimSpace(b.Password)
if b.Username == "" || b.Password == "" {
return errMissingCredentials
}
if strings.TrimSpace(b.AccessToken) != "" && time.Now().Unix()+300 < b.TokenExpiry {
c.loggedIn = true
return nil
}
if strings.TrimSpace(b.RefreshToken) != "" {
if err := c.refresh(ctx); err == nil {
c.loggedIn = true
return nil
}
}
if err := c.loginPasswordFlow(ctx); err != nil {
return err
}
c.loggedIn = true
return nil
}
func (c *Client) GetMetadata(ctx context.Context, item, mediaType string) (map[string]any, error) {
if !c.loggedIn {
return nil, errNotLoggedIn
}
switch mediaType {
case "track":
track, err := c.getMap(ctx, "/catalog/tracks/"+url.PathEscape(strings.TrimSpace(item))+"/", nil)
if err != nil {
return nil, err
}
if relID := jsonutil.NestedString(track, "release", "id"); relID != "" {
if rel, relErr := c.getRelease(ctx, relID); relErr == nil {
track["release"] = rel
}
}
return c.normalizeTrack(track), nil
case "album":
return c.getAlbumMetadata(ctx, item)
case "playlist":
return c.getPlaylistMetadata(ctx, item)
case "chart":
return c.getChartMetadata(ctx, strings.TrimPrefix(strings.TrimSpace(item), "chart:"))
case "artist":
artist, err := c.getMap(ctx, "/catalog/artists/"+url.PathEscape(strings.TrimSpace(item))+"/", nil)
if err != nil {
return nil, err
}
releases, err := c.getPaginated(ctx, "/catalog/releases/", url.Values{"artist_id": []string{strings.TrimSpace(item)}})
if err != nil {
return nil, err
}
return c.releaseCollectionMetadata(artist, releases), nil
case "label":
label, err := c.getMap(ctx, "/catalog/labels/"+url.PathEscape(strings.TrimSpace(item))+"/", nil)
if err != nil {
return nil, err
}
tracks, err := c.getTrackCollection(ctx, url.Values{"label_id": []string{strings.TrimSpace(item)}})
if err != nil {
return nil, err
}
return c.collectionMetadata(label, tracks), nil
default:
return nil, fmt.Errorf("unsupported beatport media type %q", mediaType)
}
}
func (c *Client) Search(ctx context.Context, mediaType, query string, limit int) ([]map[string]any, error) {
if !c.loggedIn {
return nil, errNotLoggedIn
}
if limit <= 0 {
limit = 25
}
var key string
switch mediaType {
case "track":
key = "tracks"
case "album":
key = "releases"
case "label":
key = "labels"
default:
return nil, fmt.Errorf("unsupported beatport search media type %q", mediaType)
}
params := url.Values{}
params.Set("q", query)
params.Set("order_by", "-publish_date")
params.Set("is_available_for_streaming", "true")
resp, err := c.getMap(ctx, "/catalog/search/", params)
if err != nil {
return nil, err
}
items := sliceAny(resp[key])
if limit < len(items) {
items = items[:limit]
}
return []map[string]any{{"items": c.normalizeSearchItems(mediaType, items)}}, nil
}
func (c *Client) GetDownloadable(ctx context.Context, item string, quality int) (*provider.Downloadable, error) {
if !c.loggedIn {
return nil, errNotLoggedIn
}
q := beatportQuality(quality)
params := url.Values{"quality": []string{q}}
resp, err := c.getMap(ctx, "/catalog/tracks/"+url.PathEscape(strings.TrimSpace(item))+"/download/", params)
if err != nil {
return nil, err
}
location := strings.TrimSpace(jsonutil.StringFromAny(resp["location"]))
if location == "" {
return nil, errors.New("beatport download response missing location")
}
streamQuality := strings.TrimSpace(jsonutil.StringFromAny(resp["stream_quality"]))
profile, ext := audioProfile(q, streamQuality)
return &provider.Downloadable{URL: location, Extension: ext, Source: "beatport", TrackID: strings.TrimSpace(item), Audio: profile}, nil
}
func (c *Client) Close() error { return nil }
func (c *Client) getAlbumMetadata(ctx context.Context, id string) (map[string]any, error) {
release, err := c.getRelease(ctx, id)
if err != nil {
return nil, err
}
tracks, err := c.getReleaseTracks(ctx, id)
if err != nil {
return nil, err
}
return c.normalizeRelease(release, tracks), nil
}
func (c *Client) getPlaylistMetadata(ctx context.Context, id string) (map[string]any, error) {
if strings.HasPrefix(strings.TrimSpace(id), "chart:") {
return c.getChartMetadata(ctx, strings.TrimPrefix(strings.TrimSpace(id), "chart:"))
}
playlist, err := c.getMap(ctx, "/catalog/playlists/"+url.PathEscape(strings.TrimSpace(id))+"/", nil)
if err != nil {
return nil, err
}
items, err := c.getPaginated(ctx, "/catalog/playlists/"+url.PathEscape(strings.TrimSpace(id))+"/tracks/", nil)
if err != nil {
return nil, err
}
tracks := make([]any, 0, len(items))
for _, raw := range items {
m, ok := raw.(map[string]any)
if !ok {
continue
}
if track, ok := m["track"].(map[string]any); ok {
tracks = append(tracks, c.normalizeTrackListItem(track))
}
}
return playlistMetadata(playlist, tracks), nil
}
func (c *Client) getChartMetadata(ctx context.Context, id string) (map[string]any, error) {
chart, err := c.getMap(ctx, "/catalog/charts/"+url.PathEscape(strings.TrimSpace(id))+"/", nil)
if err != nil {
return nil, err
}
tracks, err := c.getPaginated(ctx, "/catalog/charts/"+url.PathEscape(strings.TrimSpace(id))+"/tracks/", nil)
if err != nil {
return nil, err
}
normalized := make([]any, 0, len(tracks))
for _, raw := range tracks {
if track, ok := raw.(map[string]any); ok {
normalized = append(normalized, c.normalizeTrackListItem(track))
}
}
return playlistMetadata(chart, normalized), nil
}
func (c *Client) getRelease(ctx context.Context, id string) (map[string]any, error) {
return c.getMap(ctx, "/catalog/releases/"+url.PathEscape(strings.TrimSpace(id))+"/", nil)
}
func (c *Client) getReleaseTracks(ctx context.Context, id string) ([]any, error) {
items, err := c.getPaginated(ctx, "/catalog/releases/"+url.PathEscape(strings.TrimSpace(id))+"/tracks/", nil)
if err != nil {
return nil, err
}
for _, raw := range items {
if track, ok := raw.(map[string]any); ok {
track["release"] = map[string]any{"id": id}
}
}
return items, nil
}
func (c *Client) getTrackCollection(ctx context.Context, params url.Values) ([]any, error) {
return c.getPaginated(ctx, "/catalog/tracks/", params)
}
func (c *Client) getPaginated(ctx context.Context, endpoint string, params url.Values) ([]any, error) {
if params == nil {
params = url.Values{}
} else {
params = cloneValues(params)
}
out := make([]any, 0)
for page := 1; ; page++ {
params.Set("page", strconv.Itoa(page))
resp, err := c.getMap(ctx, endpoint, params)
if err != nil {
return nil, err
}
out = append(out, sliceAny(resp["results"])...)
if strings.TrimSpace(jsonutil.StringFromAny(resp["next"])) == "" {
break
}
}
return out, nil
}
func (c *Client) getMap(ctx context.Context, endpoint string, params url.Values) (map[string]any, error) {
resp, err := c.apiRequest(ctx, http.MethodGet, endpoint, params, nil, "")
if err != nil {
return nil, err
}
return resp, nil
}
func (c *Client) apiRequest(ctx context.Context, method, endpoint string, params url.Values, payload any, contentType string) (map[string]any, error) {
if err := c.ensureToken(ctx); err != nil {
return nil, err
}
resp, status, err := c.rawRequest(ctx, method, endpoint, params, payload, contentType, true)
if err != nil {
return nil, err
}
if status == http.StatusUnauthorized {
c.cfg.Session.Beatport.TokenExpiry = 0
if err = c.refresh(ctx); err != nil {
if loginErr := c.loginPasswordFlow(ctx); loginErr != nil {
return nil, err
}
}
resp, status, err = c.rawRequest(ctx, method, endpoint, params, payload, contentType, true)
if err != nil {
return nil, err
}
}
if status < 200 || status >= 300 {
return nil, fmt.Errorf("beatport request failed: status=%d body=%v", status, resp)
}
return resp, nil
}
func (c *Client) ensureToken(ctx context.Context) error {
if strings.TrimSpace(c.cfg.Session.Beatport.AccessToken) != "" && time.Now().Unix()+300 < c.cfg.Session.Beatport.TokenExpiry {
return nil
}
if strings.TrimSpace(c.cfg.Session.Beatport.RefreshToken) != "" {
if err := c.refresh(ctx); err == nil {
return nil
}
}
return c.loginPasswordFlow(ctx)
}
func (c *Client) loginPasswordFlow(ctx context.Context) error {
b := &c.cfg.Session.Beatport
if strings.TrimSpace(b.Username) == "" || strings.TrimSpace(b.Password) == "" {
return errMissingCredentials
}
loginResp, status, err := c.rawRequest(ctx, http.MethodPost, loginEndpoint, nil, map[string]string{"username": b.Username, "password": b.Password}, "application/json", false)
if err != nil {
return err
}
if status < 200 || status >= 300 {
return fmt.Errorf("beatport login failed: status=%d body=%v", status, loginResp)
}
if c.sessionID == "" {
return errors.New("beatport login response missing sessionid cookie")
}
code, err := c.authorize(ctx)
if err != nil {
return err
}
return c.issueToken(ctx, map[string]string{"client_id": clientID, "grant_type": "authorization_code", "code": code})
}
func (c *Client) authorize(ctx context.Context) (string, error) {
params := url.Values{"client_id": []string{clientID}, "response_type": []string{"code"}}
resp, status, err := c.rawRequest(ctx, http.MethodGet, authEndpoint, params, nil, "", false)
if err != nil {
return "", err
}
if status != http.StatusFound {
return "", fmt.Errorf("beatport authorize failed: status=%d body=%v", status, resp)
}
code := strings.TrimSpace(jsonutil.StringFromAny(resp["code"]))
if code == "" {
return "", errors.New("beatport authorize redirect missing code")
}
return code, nil
}
func (c *Client) refresh(ctx context.Context) error {
b := &c.cfg.Session.Beatport
return c.issueToken(ctx, map[string]string{"client_id": clientID, "grant_type": "refresh_token", "refresh_token": b.RefreshToken})
}
func (c *Client) issueToken(ctx context.Context, payload map[string]string) error {
resp, status, err := c.rawRequest(ctx, http.MethodPost, tokenEndpoint, nil, payload, "application/x-www-form-urlencoded", false)
if err != nil {
return err
}
if status < 200 || status >= 300 {
return fmt.Errorf("beatport token request failed: status=%d body=%v", status, resp)
}
access := strings.TrimSpace(jsonutil.StringFromAny(resp["access_token"]))
refresh := strings.TrimSpace(jsonutil.StringFromAny(resp["refresh_token"]))
if access == "" {
return errors.New("beatport token response missing access_token")
}
expiresIn := int64(jsonutil.IntFromAny(resp["expires_in"]))
if expiresIn <= 0 {
expiresIn = 3600
}
c.cfg.Session.Beatport.AccessToken = access
if refresh != "" {
c.cfg.Session.Beatport.RefreshToken = refresh
}
c.cfg.Session.Beatport.TokenExpiry = time.Now().Unix() + expiresIn
c.cfg.File.Beatport.AccessToken = c.cfg.Session.Beatport.AccessToken
c.cfg.File.Beatport.RefreshToken = c.cfg.Session.Beatport.RefreshToken
c.cfg.File.Beatport.TokenExpiry = c.cfg.Session.Beatport.TokenExpiry
_ = c.cfg.SaveFile()
return nil
}
func (c *Client) rawRequest(ctx context.Context, method, endpoint string, params url.Values, payload any, contentType string, auth bool) (map[string]any, int, error) {
if c.limiter != nil {
if err := c.limiter.Wait(ctx); err != nil {
return nil, 0, err
}
}
var body io.Reader
if payload != nil {
switch contentType {
case "application/json":
b, err := json.Marshal(payload)
if err != nil {
return nil, 0, err
}
body = bytes.NewReader(b)
case "application/x-www-form-urlencoded":
vals := url.Values{}
for k, v := range payload.(map[string]string) {
vals.Set(k, v)
}
body = strings.NewReader(vals.Encode())
default:
return nil, 0, fmt.Errorf("unsupported beatport content type %q", contentType)
}
}
u, err := url.Parse(strings.TrimRight(c.baseURL, "/") + endpoint)
if err != nil {
return nil, 0, err
}
if len(params) > 0 {
u.RawQuery = params.Encode()
}
req, err := http.NewRequestWithContext(ctx, method, u.String(), body)
if err != nil {
return nil, 0, err
}
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36")
if contentType != "" {
req.Header.Set("Content-Type", contentType)
}
if auth && strings.TrimSpace(c.cfg.Session.Beatport.AccessToken) != "" {
req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(c.cfg.Session.Beatport.AccessToken))
}
if endpoint == authEndpoint && c.sessionID != "" {
req.Header.Set("Cookie", "sessionid="+c.sessionID)
}
resp, err := c.http.Do(req)
if err != nil {
return nil, 0, err
}
defer func() { _ = resp.Body.Close() }()
if endpoint == loginEndpoint {
for _, cookie := range resp.Cookies() {
if cookie.Name == "sessionid" {
c.sessionID = cookie.Value
break
}
}
}
if endpoint == authEndpoint && resp.StatusCode == http.StatusFound {
loc := resp.Header.Get("Location")
parsed, parseErr := url.Parse(loc)
if parseErr == nil && strings.TrimSpace(parsed.Query().Get("code")) != "" {
return map[string]any{"code": parsed.Query().Get("code")}, resp.StatusCode, nil
}
return map[string]any{}, resp.StatusCode, nil
}
var out map[string]any
if err = json.NewDecoder(resp.Body).Decode(&out); err != nil {
if errors.Is(err, io.EOF) {
return map[string]any{}, resp.StatusCode, nil
}
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return nil, resp.StatusCode, err
}
return map[string]any{}, resp.StatusCode, nil
}
return out, resp.StatusCode, nil
}
func (c *Client) normalizeRelease(raw map[string]any, tracks []any) map[string]any {
id := jsonutil.StringFromAny(raw["id"])
artist := c.releaseArtistNameForTracks(raw["artists"], tracks)
trackRelease := cloneMap(raw)
trackRelease["album_artist_name"] = artist
items := make([]any, 0, len(tracks))
for _, entry := range tracks {
track, ok := entry.(map[string]any)
if !ok {
continue
}
track["release"] = trackRelease
items = append(items, c.normalizeTrack(track))
}
return map[string]any{
"id": id,
"title": jsonutil.StringFromAny(raw["name"]),
"artist": map[string]any{"name": artist},
"release_date_original": jsonutil.StringFromAny(raw["new_release_date"]),
"tracks_count": firstPositiveInt(jsonutil.IntFromAny(raw["track_count"]), len(items)),
"maximum_bit_depth": 16,
"maximum_sampling_rate": "44.1",
"image": imageMap(raw["image"]),
"tracks": map[string]any{"items": items},
"upc": jsonutil.StringFromAny(raw["upc"]),
"label": raw["label"],
}
}
func (c *Client) normalizeTrack(raw map[string]any) map[string]any {
release := mapAny(raw["release"])
trackArtistNames := artistNames(raw["artists"])
artistName := displayArtistNames(trackArtistNames)
artistID := firstArtistID(raw["artists"])
albumArtist := strings.TrimSpace(jsonutil.StringFromAny(release["album_artist_name"]))
if albumArtist == "" {
albumArtist = c.releaseArtistName(release["artists"])
}
if albumArtist == "" {
albumArtist = artistName
}
genreName := jsonutil.NestedString(raw, "genre", "name")
if sub := jsonutil.NestedString(raw, "sub_genre", "name"); sub != "" {
genreName = sub
}
date := jsonutil.FirstNonEmpty(jsonutil.StringFromAny(release["new_release_date"]), jsonutil.StringFromAny(raw["publish_date"]))
track := map[string]any{
"id": jsonutil.StringFromAny(raw["id"]),
"title": jsonutil.StringFromAny(raw["name"]),
"version": jsonutil.StringFromAny(raw["mix_name"]),
"artist": map[string]any{"id": artistID, "name": artistName},
"performer": map[string]any{"id": artistID, "name": artistName},
"artist_names": trackArtistNames,
"album": map[string]any{"id": jsonutil.StringFromAny(release["id"]), "title": jsonutil.StringFromAny(release["name"]), "artist": map[string]any{"name": albumArtist}, "image": imageMap(release["image"]), "release_date_original": date},
"track_number": jsonutil.IntFromAny(raw["number"]),
"tracks_count": jsonutil.IntFromAny(release["track_count"]),
"release_date_original": date,
"genre": map[string]any{"name": genreName},
"isrc": jsonutil.StringFromAny(raw["isrc"]),
"bpm": jsonutil.IntFromAny(raw["bpm"]),
"source_track_id": jsonutil.StringFromAny(raw["id"]),
"source_album_id": jsonutil.StringFromAny(release["id"]),
"source_artist_id": artistID,
"maximum_bit_depth": 16,
"maximum_sampling_rate": "44.1",
}
if keyName := jsonutil.NestedString(raw, "key", "name"); keyName != "" {
track["key"] = keyName
}
return track
}
func (c *Client) normalizeTrackListItem(raw map[string]any) map[string]any {
names := artistNames(raw["artists"])
return map[string]any{
"id": jsonutil.StringFromAny(raw["id"]),
"title": jsonutil.StringFromAny(raw["name"]),
"version": jsonutil.StringFromAny(raw["mix_name"]),
"artist": map[string]any{"name": displayArtistNames(names)},
"album": map[string]any{"id": jsonutil.NestedString(raw, "release", "id"), "title": jsonutil.NestedString(raw, "release", "name")},
"track_number": jsonutil.IntFromAny(raw["number"]),
}
}
func (c *Client) collectionMetadata(raw map[string]any, tracks []any) map[string]any {
items := make([]any, 0, len(tracks))
for _, entry := range tracks {
track, ok := entry.(map[string]any)
if !ok {
continue
}
items = append(items, c.normalizeTrackListItem(track))
}
return map[string]any{
"id": jsonutil.StringFromAny(raw["id"]),
"name": jsonutil.StringFromAny(raw["name"]),
"title": jsonutil.StringFromAny(raw["name"]),
"tracks_count": len(items),
"tracks": map[string]any{"items": items},
}
}
func (c *Client) releaseCollectionMetadata(raw map[string]any, releases []any) map[string]any {
items := make([]any, 0, len(releases))
for _, entry := range releases {
release, ok := entry.(map[string]any)
if !ok {
continue
}
id := strings.TrimSpace(jsonutil.StringFromAny(release["id"]))
if id == "" {
continue
}
items = append(items, map[string]any{
"id": id,
"title": jsonutil.StringFromAny(release["name"]),
"artist": map[string]any{"name": c.releaseArtistName(release["artists"])},
"tracks_count": jsonutil.IntFromAny(release["track_count"]),
"release_date_original": jsonutil.StringFromAny(release["new_release_date"]),
})
}
return map[string]any{
"id": jsonutil.StringFromAny(raw["id"]),
"name": jsonutil.StringFromAny(raw["name"]),
"title": jsonutil.StringFromAny(raw["name"]),
"albums": map[string]any{"items": items},
}
}
func playlistMetadata(raw map[string]any, tracks []any) map[string]any {
name := jsonutil.StringFromAny(raw["name"])
return map[string]any{
"id": jsonutil.StringFromAny(raw["id"]),
"name": name,
"title": name,
"tracks_count": firstPositiveInt(jsonutil.IntFromAny(raw["track_count"]), len(tracks)),
"image": imageMap(raw["image"]),
"tracks": map[string]any{"items": tracks},
}
}
func (c *Client) normalizeSearchItems(mediaType string, items []any) []any {
out := make([]any, 0, len(items))
for _, raw := range items {
m, ok := raw.(map[string]any)
if !ok {
continue
}
switch mediaType {
case "track":
out = append(out, c.normalizeTrackListItem(m))
case "album":
out = append(out, map[string]any{"id": jsonutil.StringFromAny(m["id"]), "title": jsonutil.StringFromAny(m["name"]), "artist": map[string]any{"name": c.releaseArtistName(m["artists"])}, "tracks_count": jsonutil.IntFromAny(m["track_count"]), "release_date_original": jsonutil.StringFromAny(m["new_release_date"])})
case "label":
out = append(out, map[string]any{"id": jsonutil.StringFromAny(m["id"]), "title": jsonutil.StringFromAny(m["name"]), "name": jsonutil.StringFromAny(m["name"])})
}
}
return out
}
func beatportQuality(q int) string {
switch q {
case 1:
return "medium"
case 2:
return "high"
default:
return "lossless"
}
}
func audioProfile(quality, streamQuality string) (provider.AudioProfile, string) {
s := strings.ToLower(strings.TrimSpace(streamQuality))
switch {
case strings.Contains(s, "flac") || quality == "lossless":
return provider.AudioProfile{Container: "FLAC", Codec: "FLAC", Quality: "LOSSLESS", BitDepth: 16, SamplingRate: "44.1"}, "flac"
case strings.Contains(s, "256") || quality == "high":
return provider.AudioProfile{Container: "M4A", Codec: "AACLC", Quality: "HIGH", BitDepth: 16, SamplingRate: "44.1", BitrateKbps: 256}, "m4a"
default:
return provider.AudioProfile{Container: "M4A", Codec: "AACLC", Quality: "LOW", BitDepth: 16, SamplingRate: "44.1", BitrateKbps: 128}, "m4a"
}
}
func artistNames(v any) []string {
items := sliceAny(v)
names := make([]string, 0, len(items))
for _, raw := range items {
m, ok := raw.(map[string]any)
if !ok {
continue
}
if name := strings.TrimSpace(jsonutil.StringFromAny(m["name"])); name != "" {
names = append(names, name)
}
}
return names
}
func displayArtistNames(names []string) string {
switch len(names) {
case 0:
return ""
case 1:
return names[0]
case 2:
return names[0] + " & " + names[1]
default:
return strings.Join(names[:len(names)-1], ", ") + " & " + names[len(names)-1]
}
}
func (c *Client) releaseArtistName(v any) string {
items := sliceAny(v)
threshold := c.cfg.Session.Beatport.VariousArtistsThreshold
if threshold >= 0 && len(items) > threshold {
return "Various Artists"
}
return displayArtistNames(artistNames(v))
}
func (c *Client) releaseArtistNameForTracks(releaseArtists any, tracks []any) string {
common := commonReleaseArtists(releaseArtists, tracks)
if len(common) > 0 {
return displayArtistNames(artistNames(common))
}
return c.releaseArtistName(releaseArtists)
}
func commonReleaseArtists(releaseArtists any, tracks []any) []any {
items := sliceAny(releaseArtists)
if len(items) == 0 || len(tracks) == 0 {
return nil
}
counts := map[string]int{}
trackCount := 0
for _, rawTrack := range tracks {
track, ok := rawTrack.(map[string]any)
if !ok {
continue
}
trackCount++
seen := map[string]bool{}
for _, rawArtist := range sliceAny(track["artists"]) {
if key := artistIdentity(rawArtist); key != "" {
seen[key] = true
}
}
for key := range seen {
counts[key]++
}
}
if trackCount == 0 {
return nil
}
common := make([]any, 0)
for _, rawArtist := range items {
if key := artistIdentity(rawArtist); key != "" && counts[key] == trackCount {
common = append(common, rawArtist)
}
}
return common
}
func artistIdentity(raw any) string {
m, ok := raw.(map[string]any)
if !ok {
return ""
}
if id := strings.TrimSpace(jsonutil.StringFromAny(m["id"])); id != "" {
return "id:" + id
}
if name := strings.TrimSpace(jsonutil.StringFromAny(m["name"])); name != "" {
return "name:" + strings.ToLower(name)
}
return ""
}
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 firstArtistID(v any) string {
items := sliceAny(v)
if len(items) == 0 {
return ""
}
m, _ := items[0].(map[string]any)
return jsonutil.StringFromAny(m["id"])
}
func imageMap(v any) map[string]any {
m := mapAny(v)
dynamic := strings.TrimSpace(jsonutil.StringFromAny(m["dynamic_uri"]))
uri := strings.TrimSpace(jsonutil.StringFromAny(m["uri"]))
if dynamic != "" {
return map[string]any{
"original": strings.ReplaceAll(dynamic, "{w}x{h}", "1400x1400"),
"extralarge": strings.ReplaceAll(dynamic, "{w}x{h}", "1000x1000"),
"large": strings.ReplaceAll(dynamic, "{w}x{h}", "500x500"),
"small": strings.ReplaceAll(dynamic, "{w}x{h}", "250x250"),
"thumbnail": strings.ReplaceAll(dynamic, "{w}x{h}", "100x100"),
}
}
if uri != "" {
return map[string]any{"original": uri, "large": uri}
}
return nil
}
func mapAny(v any) map[string]any {
m, _ := v.(map[string]any)
if m == nil {
return map[string]any{}
}
return m
}
func sliceAny(v any) []any {
s, ok := v.([]any)
if !ok {
return nil
}
return s
}
func firstPositiveInt(vals ...int) int {
for _, v := range vals {
if v > 0 {
return v
}
}
return 0
}
func cloneValues(in url.Values) url.Values {
out := url.Values{}
for k, vals := range in {
out[k] = append([]string(nil), vals...)
}
return out
}
+267
View File
@@ -0,0 +1,267 @@
package beatport
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"streamrip-go/internal/config"
)
func newTestClient(t *testing.T, handler http.Handler) (*Client, func()) {
t.Helper()
ts := httptest.NewServer(handler)
cfg := &config.Config{Session: config.DefaultConfigData(), File: config.DefaultConfigData()}
cfg.Session.Beatport.Username = "user"
cfg.Session.Beatport.Password = "pass"
cfg.Session.Beatport.AccessToken = "token"
cfg.Session.Beatport.TokenExpiry = time.Now().Add(time.Hour).Unix()
c := New(cfg)
c.baseURL = ts.URL
c.http = ts.Client()
c.loggedIn = true
return c, ts.Close
}
func writeJSON(t *testing.T, w http.ResponseWriter, v any) {
t.Helper()
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(v); err != nil {
t.Fatalf("encode json: %v", err)
}
}
func TestGetDownloadableMapsQuality(t *testing.T) {
c, closeServer := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/catalog/tracks/42/download/" {
t.Fatalf("path = %q", r.URL.Path)
}
if got := r.URL.Query().Get("quality"); got != "high" {
t.Fatalf("quality = %q, want high", got)
}
writeJSON(t, w, map[string]any{"location": "https://cdn.example/42.m4a", "stream_quality": ".256k.aac.mp4"})
}))
defer closeServer()
d, err := c.GetDownloadable(context.Background(), "42", 2)
if err != nil {
t.Fatalf("GetDownloadable() error = %v", err)
}
if d.URL != "https://cdn.example/42.m4a" || d.Extension != "m4a" || d.Audio.BitrateKbps != 256 || d.Audio.Quality != "HIGH" {
t.Fatalf("unexpected downloadable: %+v", d)
}
}
func TestAlbumMetadataNormalizesReleaseTracks(t *testing.T) {
c, closeServer := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/catalog/releases/7/":
writeJSON(t, w, map[string]any{
"id": 7,
"name": "Release Name",
"new_release_date": "2024-01-02",
"track_count": 1,
"artists": []any{map[string]any{"id": 10, "name": "Album Artist"}},
"image": map[string]any{"dynamic_uri": "https://img.example/{w}x{h}.jpg"},
})
case "/catalog/releases/7/tracks/":
writeJSON(t, w, map[string]any{
"next": nil,
"results": []any{map[string]any{
"id": 42,
"name": "Track Name",
"mix_name": "Original Mix",
"number": 1,
"artists": []any{map[string]any{"id": 11, "name": "Track Artist"}},
"genre": map[string]any{"name": "House"},
"isrc": "USABC1234567",
}},
})
default:
t.Fatalf("unexpected path %q", r.URL.Path)
}
}))
defer closeServer()
meta, err := c.GetMetadata(context.Background(), "7", "album")
if err != nil {
t.Fatalf("GetMetadata() error = %v", err)
}
if meta["title"] != "Release Name" || meta["release_date_original"] != "2024-01-02" {
t.Fatalf("unexpected album meta: %+v", meta)
}
tracks := meta["tracks"].(map[string]any)["items"].([]any)
if len(tracks) != 1 {
t.Fatalf("tracks len = %d", len(tracks))
}
track := tracks[0].(map[string]any)
if track["id"] != "42" || track["title"] != "Track Name" || track["version"] != "Original Mix" {
t.Fatalf("unexpected track meta: %+v", track)
}
album := track["album"].(map[string]any)
if album["title"] != "Release Name" {
t.Fatalf("unexpected track album: %+v", album)
}
}
func TestAlbumMetadataCollapsesManyReleaseArtists(t *testing.T) {
c, closeServer := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/catalog/releases/7/":
writeJSON(t, w, map[string]any{
"id": 7,
"name": "Compilation",
"track_count": 2,
"artists": []any{
map[string]any{"id": 1, "name": "A"},
map[string]any{"id": 2, "name": "B"},
map[string]any{"id": 3, "name": "C"},
map[string]any{"id": 4, "name": "D"},
},
})
case "/catalog/releases/7/tracks/":
writeJSON(t, w, map[string]any{
"next": nil,
"results": []any{
map[string]any{"id": 42, "name": "Track A", "number": 1, "artists": []any{map[string]any{"id": 1, "name": "A"}}},
map[string]any{"id": 43, "name": "Track B", "number": 2, "artists": []any{map[string]any{"id": 2, "name": "B"}}},
},
})
default:
t.Fatalf("unexpected path %q", r.URL.Path)
}
}))
defer closeServer()
meta, err := c.GetMetadata(context.Background(), "7", "album")
if err != nil {
t.Fatalf("GetMetadata() error = %v", err)
}
artist := meta["artist"].(map[string]any)
if artist["name"] != "Various Artists" {
t.Fatalf("album artist = %q, want Various Artists", artist["name"])
}
track := meta["tracks"].(map[string]any)["items"].([]any)[0].(map[string]any)
album := track["album"].(map[string]any)
albumArtist := album["artist"].(map[string]any)
if albumArtist["name"] != "Various Artists" {
t.Fatalf("track album artist = %q, want Various Artists", albumArtist["name"])
}
}
func TestAlbumMetadataUsesArtistPresentOnEveryTrack(t *testing.T) {
c, closeServer := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/catalog/releases/7/":
writeJSON(t, w, map[string]any{
"id": 7,
"name": "Artist Album",
"track_count": 3,
"artists": []any{
map[string]any{"id": 1, "name": "Main"},
map[string]any{"id": 2, "name": "Guest A"},
map[string]any{"id": 3, "name": "Guest B"},
map[string]any{"id": 4, "name": "Guest C"},
},
})
case "/catalog/releases/7/tracks/":
writeJSON(t, w, map[string]any{
"next": nil,
"results": []any{
map[string]any{"id": 42, "name": "Track A", "number": 1, "artists": []any{map[string]any{"id": 1, "name": "Main"}, map[string]any{"id": 2, "name": "Guest A"}}},
map[string]any{"id": 43, "name": "Track B", "number": 2, "artists": []any{map[string]any{"id": 1, "name": "Main"}, map[string]any{"id": 3, "name": "Guest B"}}},
map[string]any{"id": 44, "name": "Track C", "number": 3, "artists": []any{map[string]any{"id": 1, "name": "Main"}, map[string]any{"id": 4, "name": "Guest C"}}},
},
})
default:
t.Fatalf("unexpected path %q", r.URL.Path)
}
}))
defer closeServer()
meta, err := c.GetMetadata(context.Background(), "7", "album")
if err != nil {
t.Fatalf("GetMetadata() error = %v", err)
}
artist := meta["artist"].(map[string]any)
if artist["name"] != "Main" {
t.Fatalf("album artist = %q, want Main", artist["name"])
}
track := meta["tracks"].(map[string]any)["items"].([]any)[0].(map[string]any)
album := track["album"].(map[string]any)
albumArtist := album["artist"].(map[string]any)
if albumArtist["name"] != "Main" {
t.Fatalf("track album artist = %q, want Main", albumArtist["name"])
}
}
func TestReleaseArtistCollapseCanBeDisabled(t *testing.T) {
cfg := &config.Config{Session: config.DefaultConfigData(), File: config.DefaultConfigData()}
cfg.Session.Beatport.VariousArtistsThreshold = -1
c := New(cfg)
got := c.releaseArtistName([]any{
map[string]any{"name": "A"},
map[string]any{"name": "B"},
map[string]any{"name": "C"},
map[string]any{"name": "D"},
})
if got != "A, B, C & D" {
t.Fatalf("releaseArtistName() = %q", got)
}
}
func TestDisplayArtistNamesUsesCreditPunctuation(t *testing.T) {
got := displayArtistNames(artistNames([]any{
map[string]any{"name": "A"},
map[string]any{"name": "B"},
map[string]any{"name": "C"},
}))
if got != "A, B & C" {
t.Fatalf("displayArtistNames() = %q", got)
}
}
func TestArtistMetadataUsesReleases(t *testing.T) {
c, closeServer := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/catalog/artists/908468/":
writeJSON(t, w, map[string]any{"id": 908468, "name": "D'ort"})
case "/catalog/releases/":
if got := r.URL.Query().Get("artist_id"); got != "908468" {
t.Fatalf("artist_id = %q", got)
}
writeJSON(t, w, map[string]any{
"next": nil,
"results": []any{map[string]any{
"id": 123,
"name": "An Artisan's Exhibition",
"new_release_date": "2026-01-01",
"track_count": 10,
"artists": []any{map[string]any{"id": 908468, "name": "D'ort"}},
}},
})
default:
t.Fatalf("unexpected path %q", r.URL.Path)
}
}))
defer closeServer()
meta, err := c.GetMetadata(context.Background(), "908468", "artist")
if err != nil {
t.Fatalf("GetMetadata() error = %v", err)
}
if meta["name"] != "D'ort" {
t.Fatalf("artist name = %q", meta["name"])
}
albums := meta["albums"].(map[string]any)["items"].([]any)
if len(albums) != 1 {
t.Fatalf("albums len = %d", len(albums))
}
album := albums[0].(map[string]any)
if album["id"] != "123" || album["title"] != "An Artisan's Exhibition" || album["tracks_count"] != 10 {
t.Fatalf("unexpected album: %+v", album)
}
}
File diff suppressed because it is too large Load Diff
+769 -56
View File
@@ -2,24 +2,25 @@ package deezer
import ( import (
"context" "context"
"encoding/hex"
"encoding/json" "encoding/json"
"fmt"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings" "strings"
"testing" "testing"
"streamrip-go/internal/jsonutil"
"streamrip-go/internal/config" "streamrip-go/internal/config"
) )
func TestSearchTrack(t *testing.T) { func TestSearchTrack(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path { if r.URL.Path == "/search/track" {
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"}}}}) _ = json.NewEncoder(w).Encode(map[string]any{"data": []any{map[string]any{"id": 1, "title": "Dreams", "artist": map[string]any{"name": "Fleetwood Mac"}}}})
default: return
w.WriteHeader(http.StatusNotFound)
} }
w.WriteHeader(http.StatusNotFound)
})) }))
defer ts.Close() defer ts.Close()
@@ -27,9 +28,9 @@ func TestSearchTrack(t *testing.T) {
c := New(&config.Config{File: cfgData, Session: cfgData}) c := New(&config.Config{File: cfgData, Session: cfgData})
c.loggedIn = true c.loggedIn = true
orig := baseURL origBase := baseURL
baseURL = ts.URL baseURL = ts.URL
defer func() { baseURL = orig }() defer func() { baseURL = origBase }()
pages, err := c.Search(context.Background(), "track", "dreams", 5) pages, err := c.Search(context.Background(), "track", "dreams", 5)
if err != nil { if err != nil {
@@ -40,11 +41,33 @@ func TestSearchTrack(t *testing.T) {
} }
} }
func TestGetDownloadableUsesPreview(t *testing.T) { func TestGetMetadataArtistPaginatesAlbums(t *testing.T) {
callCount := 0
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path { switch r.URL.Path {
case "/track/42": case "/artist/9":
_ = json.NewEncoder(w).Encode(map[string]any{"id": 42, "title": "X", "preview": "https://cdn.example/p.mp3"}) _ = json.NewEncoder(w).Encode(map[string]any{"id": 9, "name": "Lost Frequencies"})
case "/artist/9/albums":
callCount++
index := r.URL.Query().Get("index")
limit := r.URL.Query().Get("limit")
if limit != "100" {
w.WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(w).Encode(map[string]any{"error": map[string]any{"message": "bad limit"}})
return
}
switch index {
case "0":
items := make([]any, 0, 100)
for i := 0; i < 100; i++ {
items = append(items, map[string]any{"id": i + 1, "title": "Album"})
}
_ = json.NewEncoder(w).Encode(map[string]any{"data": items, "total": 101})
case "100":
_ = json.NewEncoder(w).Encode(map[string]any{"data": []any{map[string]any{"id": 101, "title": "Album 101"}}, "total": 101})
default:
w.WriteHeader(http.StatusBadRequest)
}
default: default:
w.WriteHeader(http.StatusNotFound) w.WriteHeader(http.StatusNotFound)
} }
@@ -54,29 +77,464 @@ func TestGetDownloadableUsesPreview(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})
c.loggedIn = true c.loggedIn = true
orig := baseURL
baseURL = ts.URL
defer func() { baseURL = orig }()
d, err := c.GetDownloadable(context.Background(), "42", 0) origBase := baseURL
baseURL = ts.URL
defer func() { baseURL = origBase }()
meta, err := c.GetMetadata(context.Background(), "9", "artist")
if err != nil {
t.Fatalf("GetMetadata() error = %v", err)
}
albumsObj, _ := meta["albums"].(map[string]any)
items, _ := albumsObj["items"].([]any)
if len(items) != 101 {
t.Fatalf("albums len = %d, want 101", len(items))
}
if got := strings.TrimSpace(jsonutil.StringFromAny(meta["name"])); got != "Lost Frequencies" {
t.Fatalf("artist name = %q, want Lost Frequencies", got)
}
if callCount != 2 {
t.Fatalf("call count = %d, want 2", callCount)
}
}
func TestGetMetadataAlbumPaginatesTracks(t *testing.T) {
callCount := 0
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/album/46514392":
_ = json.NewEncoder(w).Encode(map[string]any{"id": 46514392, "title": "Clouseau30", "tracks": map[string]any{"data": []any{}}})
case "/album/46514392/tracks":
callCount++
index := r.URL.Query().Get("index")
limit := r.URL.Query().Get("limit")
if limit != "100" {
w.WriteHeader(http.StatusBadRequest)
return
}
switch index {
case "0":
items := make([]any, 0, 100)
for i := 0; i < 100; i++ {
items = append(items, map[string]any{"id": i + 1, "title": "T"})
}
_ = json.NewEncoder(w).Encode(map[string]any{"data": items, "total": 105})
case "100":
items := make([]any, 0, 5)
for i := 0; i < 5; i++ {
items = append(items, map[string]any{"id": 101 + i, "title": "T"})
}
_ = json.NewEncoder(w).Encode(map[string]any{"data": items, "total": 105})
default:
w.WriteHeader(http.StatusBadRequest)
}
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer ts.Close()
cfgData := config.DefaultConfigData()
c := New(&config.Config{File: cfgData, Session: cfgData})
c.loggedIn = true
origBase := baseURL
baseURL = ts.URL
defer func() { baseURL = origBase }()
meta, err := c.GetMetadata(context.Background(), "46514392", "album")
if err != nil {
t.Fatalf("GetMetadata() error = %v", err)
}
tracksObj, _ := meta["tracks"].(map[string]any)
items, _ := tracksObj["items"].([]any)
if len(items) != 105 {
t.Fatalf("tracks len = %d, want 105", len(items))
}
if callCount != 2 {
t.Fatalf("track page call count = %d, want 2", callCount)
}
}
func TestGetMetadataPlaylistPaginatesTracks(t *testing.T) {
callCount := 0
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/playlist/123":
_ = json.NewEncoder(w).Encode(map[string]any{"id": 123, "title": "Mix", "tracks": map[string]any{"data": []any{}}})
case "/playlist/123/tracks":
callCount++
index := r.URL.Query().Get("index")
limit := r.URL.Query().Get("limit")
if limit != "100" {
w.WriteHeader(http.StatusBadRequest)
return
}
switch index {
case "0":
items := make([]any, 0, 100)
for i := 0; i < 100; i++ {
items = append(items, map[string]any{"id": i + 1, "title": "T"})
}
_ = json.NewEncoder(w).Encode(map[string]any{"data": items, "total": 101})
case "100":
_ = json.NewEncoder(w).Encode(map[string]any{"data": []any{map[string]any{"id": 101, "title": "T"}}, "total": 101})
default:
w.WriteHeader(http.StatusBadRequest)
}
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer ts.Close()
cfgData := config.DefaultConfigData()
c := New(&config.Config{File: cfgData, Session: cfgData})
c.loggedIn = true
origBase := baseURL
baseURL = ts.URL
defer func() { baseURL = origBase }()
meta, err := c.GetMetadata(context.Background(), "123", "playlist")
if err != nil {
t.Fatalf("GetMetadata() error = %v", err)
}
tracksObj, _ := meta["tracks"].(map[string]any)
items, _ := tracksObj["items"].([]any)
if len(items) != 101 {
t.Fatalf("tracks len = %d, want 101", len(items))
}
if callCount != 2 {
t.Fatalf("track page call count = %d, want 2", callCount)
}
}
func TestGetDownloadableNativeCipher(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", "track_token": "tt"})
case "/media":
if got := strings.TrimSpace(r.Header.Get("Accept-Charset")); got != "UTF-8" {
t.Fatalf("accept-charset = %q, want UTF-8", got)
}
var payload map[string]any
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
media, _ := payload["media"].([]any)
if len(media) != 1 {
t.Fatalf("media length = %d, want 1", len(media))
}
entry, _ := media[0].(map[string]any)
formats, _ := entry["formats"].([]any)
if len(formats) != 6 {
t.Fatalf("formats length = %d, want 6", len(formats))
}
_ = json.NewEncoder(w).Encode(map[string]any{"data": []any{map[string]any{"errors": []any{}, "media": []any{map[string]any{"cipher": map[string]any{"type": "BF_CBC_STRIPE"}, "format": "FLAC", "sources": []any{map[string]any{"url": "https://cdn.example/file"}}}}}}})
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer ts.Close()
cfgData := config.DefaultConfigData()
cfgData.Deezer.ARL = "arl"
c := New(&config.Config{File: cfgData, Session: cfgData})
c.loggedIn = true
c.arl = "arl"
c.license = "license"
c.jwt = "jwt"
origBase := baseURL
origMedia := mediaURL
origPipe := pipeURL
baseURL = ts.URL
mediaURL = ts.URL + "/media"
pipeURL = ts.URL + "/pipe"
defer func() {
baseURL = origBase
mediaURL = origMedia
pipeURL = origPipe
}()
d, err := c.GetDownloadable(context.Background(), "42", 2)
if err != nil { if err != nil {
t.Fatalf("GetDownloadable() error = %v", err) t.Fatalf("GetDownloadable() error = %v", err)
} }
if d.URL != "https://cdn.example/p.mp3" || d.Extension != "mp3" { if d.Cipher != "BF_CBC_STRIPE" || d.Extension != "flac" || d.TrackID != "42" {
t.Fatalf("unexpected downloadable: %+v", d)
}
if d.Audio.Container != "FLAC" || d.Audio.Quality != "LOSSLESS" {
t.Fatalf("unexpected audio profile: %+v", d.Audio)
}
}
func TestGetDownloadablePrefersNoneCipherWhenAvailable(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", "track_token": "tt"})
case "/media":
_ = json.NewEncoder(w).Encode(map[string]any{"data": []any{map[string]any{"errors": []any{}, "media": []any{
map[string]any{"cipher": map[string]any{"type": "BF_CBC_STRIPE"}, "format": "FLAC", "sources": []any{map[string]any{"url": "https://cdn.example/bf"}}},
map[string]any{"cipher": map[string]any{"type": "NONE"}, "format": "FLAC", "sources": []any{map[string]any{"url": "https://cdn.example/plain"}}},
}}}})
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer ts.Close()
cfgData := config.DefaultConfigData()
cfgData.Deezer.ARL = "arl"
c := New(&config.Config{File: cfgData, Session: cfgData})
c.loggedIn = true
c.arl = "arl"
c.license = "license"
c.jwt = "jwt"
origBase := baseURL
origMedia := mediaURL
origPipe := pipeURL
baseURL = ts.URL
mediaURL = ts.URL + "/media"
pipeURL = ts.URL + "/pipe"
defer func() {
baseURL = origBase
mediaURL = origMedia
pipeURL = origPipe
}()
d, err := c.GetDownloadable(context.Background(), "42", 2)
if err != nil {
t.Fatalf("GetDownloadable() error = %v", err)
}
if d.Cipher != "NONE" || d.URL != "https://cdn.example/plain" {
t.Fatalf("expected NONE cipher source, got %+v", d)
}
}
func TestExtractTrackIDFromMediaURL(t *testing.T) {
url := "https://f-cdnt-stream.dzcdn.net/media/1/9/6/4/8/2552667002/64821d6a2007e90768fa0300b508fcf4.flac?hdnea=x"
if got := extractTrackIDFromMediaURL(url); got != "2552667002" {
t.Fatalf("extractTrackIDFromMediaURL() = %q, want 2552667002", got)
}
}
func TestLoginPrefersARLFlowOverRefreshShortcut(t *testing.T) {
mobileToken := testMobileToken(t)
refreshCalled := false
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/web":
_ = json.NewEncoder(w).Encode(map[string]any{"results": map[string]any{"USER_ID": "42", "JWT": "jwt-from-web", "refresh_token": "refresh-from-web", "license_token": "license-from-web"}})
case "/gateway":
switch r.URL.Query().Get("method") {
case "mobile_auth":
_ = json.NewEncoder(w).Encode(map[string]any{"results": map[string]any{"TOKEN": mobileToken}})
case "api_checkToken":
_ = json.NewEncoder(w).Encode(map[string]any{"results": "sid123"})
case "mobile_userAutolog":
_ = json.NewEncoder(w).Encode(map[string]any{"results": map[string]any{"JWT": "jwt-from-autolog", "license_token": "license-from-autolog", "refresh_token": "refresh-from-autolog"}})
default:
w.WriteHeader(http.StatusNotFound)
}
case "/pipe":
_ = json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{"tokens": map[string]any{"mediaServiceLicenseToken": map[string]any{"token": "license-from-pipe"}}}})
case "/renew":
refreshCalled = true
w.WriteHeader(http.StatusInternalServerError)
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer ts.Close()
cfgData := config.DefaultConfigData()
cfgData.Deezer.ARL = "arl"
cfgData.Deezer.RefreshToken = "refresh-token"
c := New(&config.Config{File: cfgData, Session: cfgData})
origGateway := gatewayURL
origWeb := webGWLight
origPipe := pipeURL
origAuth := authURL
gatewayURL = ts.URL + "/gateway"
webGWLight = ts.URL + "/web"
pipeURL = ts.URL + "/pipe"
authURL = ts.URL + "/renew"
defer func() {
gatewayURL = origGateway
webGWLight = origWeb
pipeURL = origPipe
authURL = origAuth
}()
if err := c.Login(context.Background()); err != nil {
t.Fatalf("Login() error = %v", err)
}
if refreshCalled {
t.Fatalf("expected ARL launch flow without refresh shortcut")
}
if c.license != "license-from-pipe" {
t.Fatalf("license = %q, want license-from-pipe", c.license)
}
}
func TestGetDownloadableRequiresARL(t *testing.T) {
cfgData := config.DefaultConfigData()
cfgData.Deezer.ARL = ""
c := New(&config.Config{File: cfgData, Session: cfgData})
c.loggedIn = true
_, err := c.GetDownloadable(context.Background(), "42", 2)
if err == nil || !strings.Contains(strings.ToLower(err.Error()), "arl") {
t.Fatalf("expected arl requirement error, got %v", err)
}
}
func TestGetDownloadableDRMError(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", "track_token": "tt"})
case "/media":
_ = json.NewEncoder(w).Encode(map[string]any{"data": []any{map[string]any{"errors": []any{map[string]any{"code": 403, "message": "DRM required"}}, "media": []any{}}}})
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer ts.Close()
cfgData := config.DefaultConfigData()
cfgData.Deezer.ARL = "arl"
c := New(&config.Config{File: cfgData, Session: cfgData})
c.loggedIn = true
c.arl = "arl"
c.license = "license"
c.jwt = "jwt"
origBase := baseURL
origMedia := mediaURL
origPipe := pipeURL
baseURL = ts.URL
mediaURL = ts.URL + "/media"
pipeURL = ts.URL + "/pipe"
defer func() {
baseURL = origBase
mediaURL = origMedia
pipeURL = origPipe
}()
_, err := c.GetDownloadable(context.Background(), "42", 2)
if err == nil || !strings.Contains(strings.ToLower(err.Error()), "drm") {
t.Fatalf("expected drm error, got %v", err)
}
}
func TestGetTrackTokenPrefersPipeToken(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/pipe":
_ = json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{"track": map[string]any{"media": map[string]any{"token": map[string]any{"payload": "pipe-track-token"}}}}})
case "/track/42":
_ = json.NewEncoder(w).Encode(map[string]any{"id": 42, "track_token": "api-track-token"})
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer ts.Close()
cfgData := config.DefaultConfigData()
c := New(&config.Config{File: cfgData, Session: cfgData})
c.loggedIn = true
c.jwt = "jwt-token"
origBase := baseURL
origPipe := pipeURL
baseURL = ts.URL
pipeURL = ts.URL + "/pipe"
defer func() {
baseURL = origBase
pipeURL = origPipe
}()
token, mediaID, err := c.getTrackToken(context.Background(), "42")
if err != nil {
t.Fatalf("getTrackToken() error = %v", err)
}
if token != "pipe-track-token" {
t.Fatalf("token = %q, want pipe-track-token", token)
}
if mediaID != "" {
t.Fatalf("mediaID = %q, want empty when pipe media id missing", mediaID)
}
}
func TestGetDownloadableUsesPipeTrackToken(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", "track_token": "api-track-token"})
case "/pipe":
_ = json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{"track": map[string]any{"media": map[string]any{"token": map[string]any{"payload": "pipe-track-token"}}}}})
case "/media":
var payload map[string]any
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
tokens, _ := payload["track_tokens"].([]any)
if len(tokens) == 0 || strings.TrimSpace(jsonutil.StringFromAny(tokens[0])) != "pipe-track-token" {
_ = json.NewEncoder(w).Encode(map[string]any{"data": []any{map[string]any{"errors": []any{map[string]any{"code": 2004, "message": "The track country differs from the license."}}, "media": []any{}}}})
return
}
_ = json.NewEncoder(w).Encode(map[string]any{"data": []any{map[string]any{"errors": []any{}, "media": []any{map[string]any{"cipher": map[string]any{"type": "BF_CBC_STRIPE"}, "format": "FLAC", "sources": []any{map[string]any{"url": "https://cdn.example/file"}}}}}}})
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer ts.Close()
cfgData := config.DefaultConfigData()
cfgData.Deezer.ARL = "arl"
c := New(&config.Config{File: cfgData, Session: cfgData})
c.loggedIn = true
c.arl = "arl"
c.license = "license"
c.jwt = "jwt"
origBase := baseURL
origMedia := mediaURL
origPipe := pipeURL
baseURL = ts.URL
mediaURL = ts.URL + "/media"
pipeURL = ts.URL + "/pipe"
defer func() {
baseURL = origBase
mediaURL = origMedia
pipeURL = origPipe
}()
d, err := c.GetDownloadable(context.Background(), "42", 2)
if err != nil {
t.Fatalf("GetDownloadable() error = %v", err)
}
if d.URL != "https://cdn.example/file" || d.Extension != "flac" {
t.Fatalf("unexpected downloadable: %+v", d) t.Fatalf("unexpected downloadable: %+v", d)
} }
} }
func TestGetMetadataSetsExplicitFromBool(t *testing.T) { func TestGetMetadataAddsLyricsFromPipe(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path { switch r.URL.Path {
case "/track/9": case "/track/1141668":
_ = json.NewEncoder(w).Encode(map[string]any{ _ = json.NewEncoder(w).Encode(map[string]any{"id": 1141668, "title": "In Da Club", "artist": map[string]any{"name": "50 Cent"}})
"id": 9, case "/pipe":
"title": "X", _ = json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{"track": map[string]any{"lyrics": map[string]any{"text": "Go, go, go\nGo shawty", "synchronizedLines": []any{map[string]any{"line": "Go, go, go", "milliseconds": 0}, map[string]any{"line": "Go shawty", "milliseconds": 4280}}}}}})
"explicit_lyrics": true,
"artist": map[string]any{"name": "Artist"},
})
default: default:
w.WriteHeader(http.StatusNotFound) w.WriteHeader(http.StatusNotFound)
} }
@@ -86,67 +544,322 @@ func TestGetMetadataSetsExplicitFromBool(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})
c.loggedIn = true c.loggedIn = true
orig := baseURL c.jwt = "jwt"
baseURL = ts.URL
defer func() { baseURL = orig }()
meta, err := c.GetMetadata(context.Background(), "9", "track") origBase := baseURL
origPipe := pipeURL
baseURL = ts.URL
pipeURL = ts.URL + "/pipe"
defer func() {
baseURL = origBase
pipeURL = origPipe
}()
meta, err := c.GetMetadata(context.Background(), "1141668", "track")
if err != nil { if err != nil {
t.Fatalf("GetMetadata() error = %v", err) t.Fatalf("GetMetadata() error = %v", err)
} }
if explicit, _ := meta["explicit"].(bool); !explicit { if !strings.Contains(jsonutil.StringFromAny(meta["lyrics"]), "Go shawty") {
t.Fatalf("expected explicit=true, got %#v", meta["explicit"]) t.Fatalf("expected lyrics text, got %q", jsonutil.StringFromAny(meta["lyrics"]))
}
if !strings.Contains(jsonutil.StringFromAny(meta["lyrics_synced"]), "[00:00.00]Go, go, go") {
t.Fatalf("expected synced lyrics, got %q", jsonutil.StringFromAny(meta["lyrics_synced"]))
} }
} }
func TestSearchReturnsStructuredAPIError(t *testing.T) { func TestLoginWithCredentials(t *testing.T) {
mobileToken := testMobileToken(t)
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/search/track" { if r.URL.Path != "/gateway" {
_ = json.NewEncoder(w).Encode(map[string]any{"error": map[string]any{"message": "invalid query"}}) w.WriteHeader(http.StatusNotFound)
return return
} }
switch r.URL.Query().Get("method") {
case "mobile_auth":
_ = json.NewEncoder(w).Encode(map[string]any{"results": map[string]any{"TOKEN": mobileToken}})
case "api_checkToken":
_ = json.NewEncoder(w).Encode(map[string]any{"results": "sid123"})
case "mobile_userAuth":
var payload map[string]any
_ = json.NewDecoder(r.Body).Decode(&payload)
if strings.TrimSpace(jsonutil.StringFromAny(payload["mail"])) == "" || strings.TrimSpace(jsonutil.StringFromAny(payload["password"])) == "" {
w.WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(w).Encode(map[string]any{"error": map[string]any{"message": "missing creds"}})
return
}
_ = json.NewEncoder(w).Encode(map[string]any{"results": map[string]any{"ARL": "arl-token", "JWT": "jwt-token", "refresh_token": "refresh-token", "license_token": "license-token", "USER_ID": "42"}})
default:
w.WriteHeader(http.StatusNotFound) w.WriteHeader(http.StatusNotFound)
}
}))
defer ts.Close()
cfgData := config.DefaultConfigData()
cfgData.Deezer.Email = "tidal1@alpin.sbs"
cfgData.Deezer.Password = "tidal1@alpin.sbs"
c := New(&config.Config{File: cfgData, Session: cfgData})
origGateway := gatewayURL
gatewayURL = ts.URL + "/gateway"
defer func() { gatewayURL = origGateway }()
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.arl != "arl-token" {
t.Fatalf("arl = %q, want arl-token", c.arl)
}
if c.jwt != "jwt-token" {
t.Fatalf("jwt = %q, want jwt-token", c.jwt)
}
if c.refresh != "refresh-token" {
t.Fatalf("refresh = %q, want refresh-token", c.refresh)
}
if c.license != "license-token" {
t.Fatalf("license = %q, want license-token", c.license)
}
if c.cfg.Session.Deezer.RefreshToken != "refresh-token" {
t.Fatalf("session refresh token = %q", c.cfg.Session.Deezer.RefreshToken)
}
if c.cfg.File.Deezer.RefreshToken != "refresh-token" {
t.Fatalf("file refresh token = %q", c.cfg.File.Deezer.RefreshToken)
}
}
func TestMobileAuthIncludesAppContextParams(t *testing.T) {
mobileToken := testMobileToken(t)
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/gateway" {
w.WriteHeader(http.StatusNotFound)
return
}
if r.URL.Query().Get("method") != "mobile_auth" {
w.WriteHeader(http.StatusBadRequest)
return
}
if got := strings.TrimSpace(r.URL.Query().Get("version")); got != deezerAppVersion {
t.Fatalf("version = %q, want %q", got, deezerAppVersion)
}
if got := strings.TrimSpace(r.URL.Query().Get("lang")); got != deezerAppLang {
t.Fatalf("lang = %q, want %q", got, deezerAppLang)
}
if got := strings.TrimSpace(r.URL.Query().Get("buildId")); got != deezerBuildID {
t.Fatalf("buildId = %q, want %q", got, deezerBuildID)
}
if got := strings.TrimSpace(r.URL.Query().Get("screenWidth")); got != deezerScreenW {
t.Fatalf("screenWidth = %q, want %q", got, deezerScreenW)
}
if got := strings.TrimSpace(r.URL.Query().Get("screenHeight")); got != deezerScreenH {
t.Fatalf("screenHeight = %q, want %q", got, deezerScreenH)
}
if got := strings.TrimSpace(r.URL.Query().Get("uniq_id")); got == "" {
t.Fatalf("uniq_id is empty")
}
_ = json.NewEncoder(w).Encode(map[string]any{"results": map[string]any{"TOKEN": mobileToken}})
})) }))
defer ts.Close() defer ts.Close()
cfgData := config.DefaultConfigData() cfgData := config.DefaultConfigData()
c := New(&config.Config{File: cfgData, Session: cfgData}) c := New(&config.Config{File: cfgData, Session: cfgData})
c.loggedIn = true
orig := baseURL
baseURL = ts.URL
defer func() { baseURL = orig }()
_, err := c.Search(context.Background(), "track", "", 5) origGateway := gatewayURL
if err == nil || !strings.Contains(err.Error(), "invalid query") { gatewayURL = ts.URL + "/gateway"
t.Fatalf("expected structured deezer error, got %v", err) defer func() { gatewayURL = origGateway }()
token, err := c.mobileAuth(context.Background())
if err != nil {
t.Fatalf("mobileAuth() error = %v", err)
}
if token != mobileToken {
t.Fatalf("token = %q, want %q", token, mobileToken)
} }
} }
func TestGetDownloadableErrorsWhenFullQualityFailsAndFallbackDisabled(t *testing.T) { func TestMobileUserAutologIncludesRefreshToken(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/track/42" { if r.URL.Path != "/gateway" {
_ = json.NewEncoder(w).Encode(map[string]any{"id": 42, "title": "X", "preview": "https://cdn.example/p.mp3"}) w.WriteHeader(http.StatusNotFound)
return return
} }
w.WriteHeader(http.StatusNotFound) if method := r.URL.Query().Get("method"); method != "mobile_userAutolog" {
w.WriteHeader(http.StatusBadRequest)
return
}
if got := strings.TrimSpace(r.URL.Query().Get("arl")); got != "" {
t.Fatalf("unexpected arl query parameter: %q", got)
}
var payload map[string]any
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
if got := strings.TrimSpace(jsonutil.StringFromAny(payload["refresh_token"])); got != "refresh-token" {
t.Fatalf("refresh_token payload = %q, want refresh-token", got)
}
_ = json.NewEncoder(w).Encode(map[string]any{"results": map[string]any{"JWT": "jwt-token", "license_token": "license-token"}})
})) }))
defer ts.Close() defer ts.Close()
cfgData := config.DefaultConfigData() cfgData := config.DefaultConfigData()
cfgData.Deezer.UseDeezloader = true
cfgData.Deezer.LowerQualityIfNotAvailable = false
c := New(&config.Config{File: cfgData, Session: cfgData}) c := New(&config.Config{File: cfgData, Session: cfgData})
c.loggedIn = true c.sid = "sid123"
c.bin = "definitely-not-a-real-yt-dlp-bin" c.userID = "42"
c.run = func(context.Context, string, ...string) ([]byte, error) { c.arl = "arl-token"
return nil, fmt.Errorf("unexpected run call") c.refresh = "refresh-token"
}
orig := baseURL
baseURL = ts.URL
defer func() { baseURL = orig }()
_, err := c.GetDownloadable(context.Background(), "42", 2) origGateway := gatewayURL
if err == nil || !strings.Contains(err.Error(), "full-quality mode failed") { gatewayURL = ts.URL + "/gateway"
t.Fatalf("expected full-quality failure error, got %v", err) defer func() { gatewayURL = origGateway }()
if err := c.mobileUserAutolog(context.Background()); err != nil {
t.Fatalf("mobileUserAutolog() error = %v", err)
}
if c.jwt != "jwt-token" {
t.Fatalf("jwt = %q, want jwt-token", c.jwt)
}
if c.license != "license-token" {
t.Fatalf("license = %q, want license-token", c.license)
}
}
func testMobileToken(t *testing.T) string {
t.Helper()
plain := []byte(strings.Repeat("A", 64) + strings.Repeat("B", 16) + strings.Repeat("C", 16))
enc, err := aesECBEncrypt([]byte(gatewayDec), plain)
if err != nil {
t.Fatalf("aesECBEncrypt() error = %v", err)
}
return hex.EncodeToString(enc)
}
func TestLoginWithRefreshToken(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/renew":
_ = json.NewEncoder(w).Encode(map[string]any{"jwt": "jwt-token", "refresh_token": "refresh-token-2"})
case "/pipe":
_ = json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{"tokens": map[string]any{"mediaServiceLicenseToken": map[string]any{"token": "license-token"}}}})
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer ts.Close()
cfgData := config.DefaultConfigData()
cfgData.Deezer.RefreshToken = "refresh-token"
c := New(&config.Config{File: cfgData, Session: cfgData})
origAuth := authURL
origPipe := pipeURL
authURL = ts.URL + "/renew"
pipeURL = ts.URL + "/pipe"
defer func() {
authURL = origAuth
pipeURL = origPipe
}()
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.jwt != "jwt-token" || c.license != "license-token" {
t.Fatalf("unexpected jwt/license: jwt=%q license=%q", c.jwt, c.license)
}
if c.cfg.Session.Deezer.RefreshToken != "refresh-token-2" {
t.Fatalf("session refresh token = %q", c.cfg.Session.Deezer.RefreshToken)
}
}
func TestRefreshJWTHTTPError(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
_ = json.NewEncoder(w).Encode(map[string]any{"error": map[string]any{"message": "bad refresh"}})
}))
defer ts.Close()
cfgData := config.DefaultConfigData()
c := New(&config.Config{File: cfgData, Session: cfgData})
c.refresh = "refresh-token"
origAuth := authURL
authURL = ts.URL
defer func() { authURL = origAuth }()
err := c.refreshJWT(context.Background())
if err == nil || !strings.Contains(strings.ToLower(err.Error()), "status=401") {
t.Fatalf("expected http status error, got %v", err)
}
}
func TestRefreshLicenseFromPipeGraphQLError(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{"errors": []any{map[string]any{"message": "token expired"}}})
}))
defer ts.Close()
cfgData := config.DefaultConfigData()
c := New(&config.Config{File: cfgData, Session: cfgData})
c.jwt = "jwt-token"
origPipe := pipeURL
pipeURL = ts.URL
defer func() { pipeURL = origPipe }()
err := c.refreshLicenseFromPipe(context.Background())
if err == nil || !strings.Contains(strings.ToLower(err.Error()), "token expired") {
t.Fatalf("expected graphql error, got %v", err)
}
}
func TestRefreshLicenseFromPipeUsesMobileGraphQLShape(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := strings.TrimSpace(r.Header.Get("Authorization")); got != "Bearer jwt-token" {
t.Fatalf("authorization = %q, want Bearer jwt-token", got)
}
if got := r.Header.Get("Accept"); !strings.Contains(got, "application/graphql-response+json") {
t.Fatalf("accept header = %q", got)
}
if got := strings.TrimSpace(r.Header.Get("Accept-Language")); got != "en-US" {
t.Fatalf("accept-language = %q, want en-US", got)
}
var payload map[string]any
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
op := strings.TrimSpace(jsonutil.StringFromAny(payload["operationName"]))
if op != "KmpMpMediaServiceLicenseToken" {
t.Fatalf("operationName = %q, want KmpMpMediaServiceLicenseToken", op)
}
ext, _ := payload["extensions"].(map[string]any)
clientLib, _ := ext["clientLibrary"].(map[string]any)
if got := strings.TrimSpace(jsonutil.StringFromAny(clientLib["name"])); got != "apollo-kotlin" {
t.Fatalf("clientLibrary.name = %q, want apollo-kotlin", got)
}
if got := strings.TrimSpace(jsonutil.StringFromAny(clientLib["version"])); got != "4.4.2" {
t.Fatalf("clientLibrary.version = %q, want 4.4.2", got)
}
_ = json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{"tokens": map[string]any{"mediaServiceLicenseToken": map[string]any{"token": "license-token"}}}})
}))
defer ts.Close()
cfgData := config.DefaultConfigData()
c := New(&config.Config{File: cfgData, Session: cfgData})
c.jwt = "jwt-token"
origPipe := pipeURL
pipeURL = ts.URL
defer func() { pipeURL = origPipe }()
if err := c.refreshLicenseFromPipe(context.Background()); err != nil {
t.Fatalf("refreshLicenseFromPipe() error = %v", err)
}
if c.license != "license-token" {
t.Fatalf("license = %q, want license-token", c.license)
} }
} }
+13
View File
@@ -6,6 +6,19 @@ type Downloadable struct {
URL string URL string
Extension string Extension string
Source string Source string
Cipher string
Key string
TrackID string
Audio AudioProfile
}
type AudioProfile struct {
Container string
Codec string
Quality string
BitDepth int
SamplingRate string
BitrateKbps int
} }
type Client interface { type Client interface {
File diff suppressed because it is too large Load Diff
+216
View File
@@ -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"
@@ -157,6 +159,53 @@ func TestGetLabelPagination(t *testing.T) {
} }
} }
func TestGetArtistPagination(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
offset := r.URL.Query().Get("offset")
if offset == "" {
offset = "0"
}
resp := map[string]any{}
switch offset {
case "0":
resp = map[string]any{
"albums_count": 620,
"albums": map[string]any{"items": makeItems(0, 500)},
}
case "500":
resp = map[string]any{"albums": map[string]any{"items": makeItems(500, 620)}}
default:
w.WriteHeader(http.StatusNotFound)
_ = json.NewEncoder(w).Encode(map[string]any{"message": "not found"})
return
}
_ = json.NewEncoder(w).Encode(resp)
}))
defer ts.Close()
c := newTestClient(t)
c.loggedIn = true
c.baseURL = ts.URL
raw, err := c.GetMetadata(context.Background(), "artist-id", "artist")
if err != nil {
t.Fatalf("GetMetadata() error = %v", err)
}
albums, ok := mapValue(raw["albums"])
if !ok {
t.Fatalf("albums missing")
}
items, ok := albums["items"].([]any)
if !ok {
t.Fatalf("items missing")
}
if len(items) != 620 {
t.Fatalf("len(items) = %d, want 620", len(items))
}
}
func newTestClient(t *testing.T) *Client { func newTestClient(t *testing.T) *Client {
t.Helper() t.Helper()
d := config.DefaultConfigData() d := config.DefaultConfigData()
@@ -172,3 +221,170 @@ 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)
}
if d.Audio.Container != "MP3" || d.Audio.Codec != "MP3" {
t.Fatalf("unexpected audio profile: %+v", d.Audio)
}
}
func qobuzSecretSig(requestTS, secret string) string {
raw := "trackgetFileUrlformat_id27intentstreamtrack_id19512574" + requestTS + secret
hash := md5.Sum([]byte(raw))
return hex.EncodeToString(hash[:])
}
func TestRefreshAppCredentialsRejectsEmptyData(t *testing.T) {
d := config.DefaultConfigData()
c := New(&config.Config{File: d, Session: d})
c.fetchCfg = func(context.Context) (string, []string, error) {
return "", []string{" "}, nil
}
err := c.refreshAppCredentials(context.Background(), &c.cfg.Session.Qobuz)
if err == nil {
t.Fatalf("expected error for empty refreshed app credentials")
}
}
+271 -97
View File
@@ -5,17 +5,24 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"io"
"net/http"
"net/url"
"os/exec" "os/exec"
"strconv" "regexp"
"strings" "strings"
"sync" "sync"
"time"
"streamrip-go/internal/config" "streamrip-go/internal/config"
"streamrip-go/internal/jsonutil"
"streamrip-go/internal/provider" "streamrip-go/internal/provider"
) )
var errUnsupportedMediaType = errors.New("unsupported soundcloud media type") var errUnsupportedMediaType = errors.New("unsupported soundcloud media type")
var soundcloudSearchBaseURL = "https://soundcloud.com"
type commandRunner func(ctx context.Context, name string, args ...string) ([]byte, error) type commandRunner func(ctx context.Context, name string, args ...string) ([]byte, error)
type Client struct { type Client struct {
@@ -23,6 +30,7 @@ type Client struct {
loggedIn bool loggedIn bool
bin string bin string
run commandRunner run commandRunner
http *http.Client
mu sync.Mutex mu sync.Mutex
cache map[string]map[string]any cache map[string]map[string]any
} }
@@ -32,6 +40,7 @@ func New(cfg *config.Config) *Client {
cfg: cfg, cfg: cfg,
bin: "yt-dlp", bin: "yt-dlp",
run: runCommand, run: runCommand,
http: &http.Client{Timeout: 20 * time.Second},
cache: map[string]map[string]any{}, cache: map[string]map[string]any{},
} }
} }
@@ -56,12 +65,19 @@ func (c *Client) Search(ctx context.Context, mediaType, query string, limit int)
if !c.loggedIn { if !c.loggedIn {
return nil, errors.New("soundcloud client not logged in") return nil, errors.New("soundcloud client not logged in")
} }
if mediaType != "track" {
return nil, fmt.Errorf("%w: %s", errUnsupportedMediaType, mediaType)
}
if limit <= 0 { if limit <= 0 {
limit = 20 limit = 20
} }
if mediaType == "track" {
return c.searchTracks(ctx, query, limit)
}
if mediaType == "playlist" {
return c.searchPlaylists(ctx, query, limit)
}
return nil, fmt.Errorf("%w: %s", errUnsupportedMediaType, mediaType)
}
func (c *Client) searchTracks(ctx context.Context, query string, limit int) ([]map[string]any, error) {
target := fmt.Sprintf("scsearch%d:%s", limit, query) target := fmt.Sprintf("scsearch%d:%s", limit, query)
b, err := c.run(ctx, c.bin, "-J", "--flat-playlist", "--skip-download", "--no-warnings", target) b, err := c.run(ctx, c.bin, "-J", "--flat-playlist", "--skip-download", "--no-warnings", target)
@@ -82,29 +98,108 @@ func (c *Client) Search(ctx context.Context, mediaType, query string, limit int)
if !ok { if !ok {
continue continue
} }
id := strings.TrimSpace(stringFromAny(m["webpage_url"])) id := canonicalSoundcloudURL(m)
if id == "" {
id = strings.TrimSpace(stringFromAny(m["url"]))
}
if id == "" { if id == "" {
continue continue
} }
artist := strings.TrimSpace(stringFromAny(m["uploader"])) artist := strings.TrimSpace(jsonutil.StringFromAny(m["uploader"]))
if artist == "" { if artist == "" {
artist = strings.TrimSpace(stringFromAny(m["channel"])) artist = strings.TrimSpace(jsonutil.StringFromAny(m["channel"]))
} }
artistID := strings.TrimSpace(jsonutil.FirstNonEmpty(jsonutil.StringFromAny(m["uploader_id"]), jsonutil.StringFromAny(m["channel_id"])))
item := map[string]any{ item := map[string]any{
"id": id, "id": id,
"title": stringFromAny(m["title"]), "title": jsonutil.StringFromAny(m["title"]),
"artist": map[string]any{ "artist": map[string]any{
"name": artist, "name": artist,
}, },
} }
if artistID != "" {
item["artist"] = map[string]any{"name": artist, "id": artistID}
}
if trackID := strings.TrimSpace(jsonutil.StringFromAny(m["id"])); trackID != "" {
item["source_track_id"] = trackID
}
items = append(items, item) items = append(items, item)
} }
return []map[string]any{{"items": items}}, nil return []map[string]any{{"items": items}}, nil
} }
func (c *Client) searchPlaylists(ctx context.Context, query string, limit int) ([]map[string]any, error) {
searchURL := strings.TrimSuffix(soundcloudSearchBaseURL, "/") + "/search/sets?q=" + url.QueryEscape(query)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, searchURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "Mozilla/5.0")
resp, err := c.http.Do(req)
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("soundcloud playlist search failed: status=%d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
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
}
seen := map[string]struct{}{}
items := make([]any, 0, limit)
for _, path := range paths {
if _, ok := seen[path]; ok {
continue
}
seen[path] = struct{}{}
playlistURL := "https://soundcloud.com" + path
info, infoErr := c.playlistInfo(ctx, playlistURL)
if infoErr != nil {
continue
}
title := strings.TrimSpace(jsonutil.StringFromAny(info["title"]))
if title == "" {
title = strings.Trim(strings.ReplaceAll(path, "/", " "), " ")
}
artist := strings.TrimSpace(jsonutil.FirstNonEmpty(jsonutil.StringFromAny(info["uploader"]), jsonutil.StringFromAny(info["channel"])))
artistID := strings.TrimSpace(jsonutil.FirstNonEmpty(jsonutil.StringFromAny(info["uploader_id"]), jsonutil.StringFromAny(info["channel_id"])))
trackCount := 0
if entries := asAnySlice(info["entries"]); len(entries) > 0 {
trackCount = len(entries)
}
canonical := jsonutil.FirstNonEmpty(canonicalSoundcloudURL(info), playlistURL)
item := map[string]any{
"id": canonical,
"title": title,
"tracks_count": trackCount,
"artist": map[string]any{"name": artist},
}
if artistID != "" {
item["artist"] = map[string]any{"name": artist, "id": artistID}
}
if pid := strings.TrimSpace(jsonutil.StringFromAny(info["id"])); pid != "" {
item["source_playlist_id"] = pid
}
if thumb := strings.TrimSpace(jsonutil.StringFromAny(info["thumbnail"])); thumb != "" {
item["image"] = soundcloudImageMap(thumb)
}
items = append(items, item)
if len(items) >= limit {
break
}
}
if len(items) == 0 {
return []map[string]any{}, nil
}
return []map[string]any{{"items": items}}, 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, errors.New("soundcloud client not logged in") return nil, errors.New("soundcloud client not logged in")
@@ -118,37 +213,60 @@ func (c *Client) GetMetadata(ctx context.Context, item, mediaType string) (map[s
} }
return trackMetadataFromInfo(item, info), nil return trackMetadataFromInfo(item, info), nil
case "playlist": case "playlist":
b, err := c.run(ctx, c.bin, "-J", "--skip-download", "--no-warnings", item) root, err := c.playlistInfo(ctx, item)
if err != nil {
return nil, err
}
root, err := parseJSONMap(b)
if err != nil { if err != nil {
return nil, err return nil, err
} }
tracks := make([]any, 0) tracks := make([]any, 0)
for _, raw := range asAnySlice(root["entries"]) { for i, raw := range asAnySlice(root["entries"]) {
entry, ok := raw.(map[string]any) entry, ok := raw.(map[string]any)
if !ok { if !ok {
continue continue
} }
id := strings.TrimSpace(stringFromAny(entry["webpage_url"])) id := canonicalSoundcloudURL(entry)
if id == "" {
id = strings.TrimSpace(stringFromAny(entry["url"]))
}
if id == "" { if id == "" {
continue continue
} }
tracks = append(tracks, map[string]any{"id": id}) track := map[string]any{"id": id}
if trackID := strings.TrimSpace(jsonutil.StringFromAny(entry["id"])); trackID != "" {
track["source_track_id"] = trackID
} }
name := strings.TrimSpace(stringFromAny(root["title"])) if title := strings.TrimSpace(jsonutil.StringFromAny(entry["title"])); title != "" {
track["title"] = title
}
if artist := strings.TrimSpace(jsonutil.FirstNonEmpty(jsonutil.StringFromAny(entry["uploader"]), jsonutil.StringFromAny(entry["channel"]))); artist != "" {
artistMap := map[string]any{"name": artist}
if artistID := strings.TrimSpace(jsonutil.FirstNonEmpty(jsonutil.StringFromAny(entry["uploader_id"]), jsonutil.StringFromAny(entry["channel_id"]))); artistID != "" {
artistMap["id"] = artistID
}
track["artist"] = artistMap
}
track["track_number"] = i + 1
tracks = append(tracks, track)
}
name := strings.TrimSpace(jsonutil.StringFromAny(root["title"]))
if name == "" { if name == "" {
name = "SoundCloud Playlist" name = "SoundCloud Playlist"
} }
return map[string]any{ meta := map[string]any{
"id": jsonutil.FirstNonEmpty(canonicalSoundcloudURL(root), item),
"name": name, "name": name,
"description": strings.TrimSpace(jsonutil.StringFromAny(root["description"])),
"tracks": map[string]any{"items": tracks}, "tracks": map[string]any{"items": tracks},
}, nil }
if pid := strings.TrimSpace(jsonutil.StringFromAny(root["id"])); pid != "" {
meta["source_playlist_id"] = pid
}
if artist := strings.TrimSpace(jsonutil.FirstNonEmpty(jsonutil.StringFromAny(root["uploader"]), jsonutil.StringFromAny(root["channel"]))); artist != "" {
meta["artist"] = map[string]any{"name": artist}
}
if thumb := strings.TrimSpace(jsonutil.StringFromAny(root["thumbnail"])); thumb != "" {
meta["image"] = soundcloudImageMap(thumb)
}
if entries := asAnySlice(root["entries"]); len(entries) > 0 {
meta["tracks_count"] = len(entries)
}
return meta, nil
default: default:
return nil, fmt.Errorf("%w: %s", errUnsupportedMediaType, mediaType) return nil, fmt.Errorf("%w: %s", errUnsupportedMediaType, mediaType)
} }
@@ -162,21 +280,38 @@ func (c *Client) GetDownloadable(ctx context.Context, item string, _ int) (*prov
if err != nil { if err != nil {
return nil, err return nil, err
} }
streamURL := strings.TrimSpace(stringFromAny(info["url"])) streamURL := strings.TrimSpace(jsonutil.StringFromAny(info["url"]))
if streamURL == "" { if streamURL == "" {
return nil, errors.New("yt-dlp output missing url") return nil, errors.New("yt-dlp output missing url (track may be unavailable or region-restricted)")
} }
ext := strings.TrimSpace(stringFromAny(info["ext"])) ext := strings.TrimSpace(jsonutil.StringFromAny(info["ext"]))
if ext == "" { if ext == "" {
ext = "m4a" ext = "m4a"
} }
return &provider.Downloadable{URL: streamURL, Extension: ext, Source: "soundcloud"}, nil return &provider.Downloadable{URL: streamURL, Extension: ext, Source: "soundcloud", Audio: soundcloudAudioProfile(ext)}, nil
} }
func (c *Client) Close() error { func (c *Client) Close() error {
return nil return nil
} }
func soundcloudAudioProfile(ext string) provider.AudioProfile {
switch strings.ToLower(strings.TrimSpace(ext)) {
case "mp3":
return provider.AudioProfile{Container: "MP3", Codec: "MP3", Quality: "LOSSY", BitDepth: 16, SamplingRate: "44.1"}
case "flac":
return provider.AudioProfile{Container: "FLAC", Codec: "FLAC", Quality: "LOSSLESS", BitDepth: 16, SamplingRate: "44.1"}
case "m4a", "aac":
return provider.AudioProfile{Container: "M4A", Codec: "AAC", Quality: "LOSSY", BitDepth: 16, SamplingRate: "44.1"}
default:
container := strings.ToUpper(strings.TrimSpace(ext))
if container == "" {
container = "M4A"
}
return provider.AudioProfile{Container: container, Codec: container, Quality: "LOSSY", BitDepth: 16, SamplingRate: "44.1"}
}
}
func (c *Client) trackInfo(ctx context.Context, item string) (map[string]any, error) { func (c *Client) trackInfo(ctx context.Context, item string) (map[string]any, error) {
if strings.TrimSpace(item) == "" { if strings.TrimSpace(item) == "" {
return nil, errors.New("empty soundcloud item") return nil, errors.New("empty soundcloud item")
@@ -198,79 +333,141 @@ func (c *Client) trackInfo(ctx context.Context, item string) (map[string]any, er
if err != nil { if err != nil {
return nil, err return nil, err
} }
canonical := canonicalSoundcloudURL(info)
c.mu.Lock() c.mu.Lock()
c.cache[item] = cloneMap(info) c.cache[item] = cloneMap(info)
if canonical != "" {
c.cache[canonical] = cloneMap(info)
}
c.mu.Unlock() c.mu.Unlock()
return info, nil return info, nil
} }
func trackMetadataFromInfo(id string, info map[string]any) map[string]any { func (c *Client) playlistInfo(ctx context.Context, item string) (map[string]any, error) {
title := strings.TrimSpace(stringFromAny(info["title"])) b, err := c.run(ctx, c.bin, "-J", "--flat-playlist", "--skip-download", "--no-warnings", item)
if title == "" { if err != nil {
title = id return nil, err
} }
artistName := strings.TrimSpace(stringFromAny(info["artist"])) return parseJSONMap(b)
if artistName == "" {
artistName = strings.TrimSpace(stringFromAny(info["uploader"]))
}
if artistName == "" {
artistName = strings.TrimSpace(stringFromAny(info["channel"]))
} }
trackNum := intFromAny(info["track_number"]) func trackMetadataFromInfo(id string, info map[string]any) map[string]any {
canonicalID := jsonutil.FirstNonEmpty(canonicalSoundcloudURL(info), id)
publisher := jsonutil.NestedMap(info, "publisher_metadata")
title := strings.TrimSpace(jsonutil.StringFromAny(info["title"]))
if title == "" {
title = canonicalID
}
albumTitle := strings.TrimSpace(jsonutil.StringFromAny(publisher["album_title"]))
if albumTitle == "" {
albumTitle = strings.TrimSpace(jsonutil.StringFromAny(info["album"]))
}
if albumTitle == "" {
albumTitle = title
}
artistName := strings.TrimSpace(jsonutil.StringFromAny(info["artist"]))
if artistName == "" {
artistName = strings.TrimSpace(jsonutil.StringFromAny(publisher["artist"]))
}
if artistName == "" {
artistName = strings.TrimSpace(jsonutil.StringFromAny(info["uploader"]))
}
if artistName == "" {
artistName = strings.TrimSpace(jsonutil.StringFromAny(info["channel"]))
}
artistID := strings.TrimSpace(jsonutil.FirstNonEmpty(
jsonutil.StringFromAny(info["uploader_id"]),
jsonutil.StringFromAny(info["channel_id"]),
jsonutil.StringFromAny(jsonutil.NestedMap(info, "user")["id"]),
))
trackNum := jsonutil.IntFromAny(info["track_number"])
if trackNum <= 0 { if trackNum <= 0 {
trackNum = 1 trackNum = 1
} }
meta := map[string]any{ meta := map[string]any{
"id": id, "id": canonicalID,
"title": title, "title": title,
"track_number": trackNum, "track_number": trackNum,
"artist": map[string]any{"name": artistName}, "artist": map[string]any{"name": artistName, "id": artistID},
"performer": map[string]any{"name": artistName}, "performer": map[string]any{"name": artistName, "id": artistID},
"album": map[string]any{ "album": map[string]any{
"id": strings.TrimSpace(stringFromAny(info["album"])), "id": jsonutil.FirstNonEmpty(strings.TrimSpace(jsonutil.StringFromAny(info["album"])), canonicalID),
"title": strings.TrimSpace(stringFromAny(info["album"])), "title": albumTitle,
"artist": map[string]any{"name": artistName}, "artist": map[string]any{"name": artistName, "id": artistID},
}, },
"description": strings.TrimSpace(stringFromAny(info["description"])), "description": strings.TrimSpace(jsonutil.StringFromAny(info["description"])),
"genre": strings.TrimSpace(stringFromAny(info["genre"])), "genre": strings.TrimSpace(jsonutil.StringFromAny(info["genre"])),
"release_date": strings.TrimSpace(firstNonEmpty( "isrc": strings.TrimSpace(jsonutil.StringFromAny(info["isrc"])),
stringFromAny(info["release_date"]), "label": strings.TrimSpace(jsonutil.FirstNonEmpty(jsonutil.StringFromAny(info["label"]), jsonutil.StringFromAny(info["label_name"]))),
stringFromAny(info["upload_date"]), "copyright": strings.TrimSpace(jsonutil.StringFromAny(publisher["p_line"])),
"release_date": strings.TrimSpace(jsonutil.FirstNonEmpty(
jsonutil.StringFromAny(info["created_at"]),
jsonutil.StringFromAny(info["release_date"]),
jsonutil.StringFromAny(info["upload_date"]),
)), )),
} }
if trackID := strings.TrimSpace(jsonutil.StringFromAny(info["id"])); trackID != "" {
meta["source_track_id"] = trackID
}
if jsonutil.BoolFromAny(publisher["explicit"]) || jsonutil.IntFromAny(info["age_limit"]) >= 18 {
meta["explicit"] = true
}
if meta["release_date"] == "" { if meta["release_date"] == "" {
delete(meta, "release_date") delete(meta, "release_date")
} }
if thumb := strings.TrimSpace(stringFromAny(info["thumbnail"])); thumb != "" { if thumb := strings.TrimSpace(jsonutil.StringFromAny(info["thumbnail"])); thumb != "" {
meta["image"] = map[string]any{ meta["image"] = soundcloudImageMap(thumb)
"small": thumb,
"large": thumb,
"extralarge": thumb,
"original": thumb,
}
} }
if album := strings.TrimSpace(stringFromAny(info["album"])); album == "" { if strings.TrimSpace(jsonutil.StringFromAny(info["album"])) == "" && strings.TrimSpace(jsonutil.StringFromAny(publisher["album_title"])) == "" {
meta["album"] = map[string]any{ meta["album"] = map[string]any{
"id": id, "id": canonicalID,
"title": title, "title": title,
"artist": map[string]any{"name": artistName}, "artist": map[string]any{"name": artistName, "id": artistID},
} }
} }
if durationSec := intFromAny(info["duration"]); durationSec > 0 { if durationSec := jsonutil.IntFromAny(info["duration"]); durationSec > 0 {
meta["duration"] = durationSec meta["duration"] = durationSec
} }
return meta return meta
} }
func canonicalSoundcloudURL(info map[string]any) string {
for _, key := range []string{"webpage_url", "original_url", "url"} {
raw := strings.TrimSpace(jsonutil.StringFromAny(info[key]))
if raw == "" {
continue
}
u, err := url.Parse(raw)
if err != nil {
continue
}
host := strings.ToLower(strings.TrimPrefix(u.Host, "www."))
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, "/")
if strings.TrimSpace(u.Path) == "" {
continue
}
return u.String()
}
return ""
}
func parseJSONMap(b []byte) (map[string]any, error) { func parseJSONMap(b []byte) (map[string]any, error) {
var out map[string]any var out map[string]any
if err := json.Unmarshal(b, &out); err != nil { if err := json.Unmarshal(b, &out); err != nil {
@@ -298,46 +495,23 @@ func asAnySlice(v any) []any {
return items return items
} }
func stringFromAny(v any) string { func soundcloudImageMap(raw string) map[string]any {
switch t := v.(type) { base := strings.TrimSpace(raw)
case string: if base == "" {
return t return map[string]any{}
case int:
return strconv.Itoa(t)
case int64:
return strconv.FormatInt(t, 10)
case float64:
return strconv.FormatFloat(t, 'f', -1, 64)
default:
return ""
} }
large := strings.Replace(base, "-large.", "-t500x500.", 1)
if large == base {
large = strings.Replace(base, "large", "t500x500", 1)
} }
return map[string]any{
func intFromAny(v any) int { "small": base,
switch t := v.(type) { "large": large,
case int: "extralarge": large,
return t "original": large,
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) { func runCommand(ctx context.Context, name string, args ...string) ([]byte, error) {
cmd := exec.CommandContext(ctx, name, args...) cmd := exec.CommandContext(ctx, name, args...)
b, err := cmd.CombinedOutput() b, err := cmd.CombinedOutput()
+154 -4
View File
@@ -3,9 +3,13 @@ package soundcloud
import ( import (
"context" "context"
"fmt" "fmt"
"net/http"
"net/http/httptest"
"strings" "strings"
"testing" "testing"
"streamrip-go/internal/jsonutil"
"streamrip-go/internal/config" "streamrip-go/internal/config"
) )
@@ -25,8 +29,11 @@ func TestGetTrackMetadataAndDownloadable(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("GetMetadata() error = %v", err) t.Fatalf("GetMetadata() error = %v", err)
} }
if stringFromAny(meta["title"]) != "Lean On" { if jsonutil.StringFromAny(meta["title"]) != "Lean On" {
t.Fatalf("title = %q, want Lean On", stringFromAny(meta["title"])) t.Fatalf("title = %q, want Lean On", jsonutil.StringFromAny(meta["title"]))
}
if jsonutil.StringFromAny(meta["id"]) != "https://soundcloud.com/a/b" {
t.Fatalf("id = %q, want canonical soundcloud url", jsonutil.StringFromAny(meta["id"]))
} }
d, err := c.GetDownloadable(context.Background(), "https://soundcloud.com/a/b", 0) d, err := c.GetDownloadable(context.Background(), "https://soundcloud.com/a/b", 0)
@@ -36,6 +43,9 @@ func TestGetTrackMetadataAndDownloadable(t *testing.T) {
if d.URL != "https://cdn.example/audio.m4a" || d.Extension != "m4a" { if d.URL != "https://cdn.example/audio.m4a" || d.Extension != "m4a" {
t.Fatalf("unexpected downloadable: %+v", d) t.Fatalf("unexpected downloadable: %+v", d)
} }
if d.Audio.Container != "M4A" || d.Audio.Codec != "AAC" {
t.Fatalf("unexpected audio profile: %+v", d.Audio)
}
} }
func TestGetPlaylistMetadata(t *testing.T) { func TestGetPlaylistMetadata(t *testing.T) {
@@ -54,8 +64,8 @@ func TestGetPlaylistMetadata(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("GetMetadata() error = %v", err) t.Fatalf("GetMetadata() error = %v", err)
} }
if stringFromAny(meta["name"]) != "Road Trip" { if jsonutil.StringFromAny(meta["name"]) != "Road Trip" {
t.Fatalf("name = %q, want Road Trip", stringFromAny(meta["name"])) t.Fatalf("name = %q, want Road Trip", jsonutil.StringFromAny(meta["name"]))
} }
tracksMap, ok := meta["tracks"].(map[string]any) tracksMap, ok := meta["tracks"].(map[string]any)
if !ok { if !ok {
@@ -65,6 +75,9 @@ func TestGetPlaylistMetadata(t *testing.T) {
if len(items) != 2 { if len(items) != 2 {
t.Fatalf("playlist items len = %d, want 2", len(items)) t.Fatalf("playlist items len = %d, want 2", len(items))
} }
if jsonutil.StringFromAny(meta["id"]) != "https://soundcloud.com/a/sets/road-trip" {
t.Fatalf("playlist id not canonical: %q", jsonutil.StringFromAny(meta["id"]))
}
} }
func TestSearchTrack(t *testing.T) { func TestSearchTrack(t *testing.T) {
@@ -90,6 +103,103 @@ func TestSearchTrack(t *testing.T) {
if len(items) != 1 { if len(items) != 1 {
t.Fatalf("items len = %d, want 1", len(items)) 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 jsonutil.StringFromAny(item0["id"]) != "https://soundcloud.com/a/b" {
t.Fatalf("track search id not canonical: %q", jsonutil.StringFromAny(item0["id"]))
}
}
func TestSearchPlaylist(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="/a/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/a/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 jsonutil.StringFromAny(item0["id"]) != "https://soundcloud.com/a/sets/road-trip" {
t.Fatalf("playlist search id not canonical: %q", jsonutil.StringFromAny(item0["id"]))
}
}
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 jsonutil.StringFromAny(item0["id"]) != "https://soundcloud.com/artist.name/sets/road.trip" {
t.Fatalf("playlist search id not canonical: %q", jsonutil.StringFromAny(item0["id"]))
}
} }
func TestLoginShowsYtDlpHint(t *testing.T) { func TestLoginShowsYtDlpHint(t *testing.T) {
@@ -104,3 +214,43 @@ func TestLoginShowsYtDlpHint(t *testing.T) {
t.Fatalf("expected yt-dlp hint in error, got: %v", err) t.Fatalf("expected yt-dlp hint in error, got: %v", err)
} }
} }
func TestTrackMetadataIncludesExplicitAndISRC(t *testing.T) {
meta := trackMetadataFromInfo("https://soundcloud.com/a/b", map[string]any{
"title": "T",
"uploader": "U",
"isrc": "US123",
"id": "9876",
"webpage_url": "https://soundcloud.com/a/b?si=abc",
"age_limit": float64(18),
"thumbnail": "https://img",
"upload_date": "20240101",
})
if jsonutil.StringFromAny(meta["isrc"]) != "US123" {
t.Fatalf("isrc = %q, want US123", jsonutil.StringFromAny(meta["isrc"]))
}
explicit, _ := meta["explicit"].(bool)
if !explicit {
t.Fatalf("expected explicit=true")
}
if jsonutil.StringFromAny(meta["source_track_id"]) != "9876" {
t.Fatalf("source_track_id = %q, want 9876", jsonutil.StringFromAny(meta["source_track_id"]))
}
if jsonutil.StringFromAny(jsonutil.NestedMap(meta, "album")["title"]) != "T" {
t.Fatalf("album title mismatch: %#v", jsonutil.NestedMap(meta, "album"))
}
}
func TestCanonicalSoundcloudURL(t *testing.T) {
got := canonicalSoundcloudURL(map[string]any{"webpage_url": "https://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")
}
}
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")
}
}
+462 -77
View File
@@ -15,6 +15,7 @@ import (
"time" "time"
"streamrip-go/internal/config" "streamrip-go/internal/config"
"streamrip-go/internal/jsonutil"
"streamrip-go/internal/netutil" "streamrip-go/internal/netutil"
"streamrip-go/internal/provider" "streamrip-go/internal/provider"
"streamrip-go/internal/ratelimit" "streamrip-go/internal/ratelimit"
@@ -22,10 +23,12 @@ import (
const ( const (
baseURL = "https://api.tidalhifi.com/v1" baseURL = "https://api.tidalhifi.com/v1"
lyricsAPIv1 = "https://api.tidal.com/v1"
openAPIV2 = "https://openapi.tidal.com/v2" openAPIV2 = "https://openapi.tidal.com/v2"
authURL = "https://auth.tidal.com/v1/oauth2" authURL = "https://auth.tidal.com/v1/oauth2"
clientID = "fX2JxdmntZWK0ixT" clientID = "fX2JxdmntZWK0ixT"
clientSec = "1Nm5AfDAjxrgJFJbKNWLeAyKGVGmINuXPPLHVXAvxAg=" clientSec = "1Nm5AfDAjxrgJFJbKNWLeAyKGVGmINuXPPLHVXAvxAg="
tidalRequestAttempts = 3
) )
var qualityMap = map[int]string{ var qualityMap = map[int]string{
@@ -36,14 +39,16 @@ var qualityMap = map[int]string{
4: "HI_RES_LOSSLESS", 4: "HI_RES_LOSSLESS",
} }
var qualityToFormat = map[int]string{ var qualityToFormats = map[int][]string{
0: "HEAACV1", 0: {"HEAACV1"},
1: "AACLC", 1: {"HEAACV1", "AACLC"},
2: "FLAC", 2: {"HEAACV1", "AACLC", "FLAC"},
3: "FLAC_HIRES", 3: {"HEAACV1", "AACLC", "FLAC", "FLAC_HIRES"},
4: "FLAC_HIRES", 4: {"HEAACV1", "AACLC", "FLAC", "FLAC_HIRES"},
} }
var atmosAudioQualities = []string{"HI_RES_LOSSLESS", "HI_RES", "LOSSLESS", "HIGH"}
var ErrMissingTidalToken = errors.New("missing tidal access_token") var ErrMissingTidalToken = errors.New("missing tidal access_token")
type Client struct { type Client struct {
@@ -51,15 +56,19 @@ type Client struct {
http *http.Client http *http.Client
limiter *ratelimit.Limiter limiter *ratelimit.Limiter
baseURL string baseURL string
lyricsAPI string
openAPI string
loggedIn bool loggedIn bool
} }
func New(cfg *config.Config) *Client { func New(cfg *config.Config) *Client {
return &Client{ return &Client{
cfg: cfg, cfg: cfg,
http: netutil.NewHTTPClient(30*time.Second, cfg.Session.Downloads.VerifySSL), http: netutil.NewHTTPClient(30*time.Second, cfg.Session.Downloads.VerifySSL, cfg.Session.Downloads.MaxConnections),
limiter: ratelimit.New(cfg.Session.Downloads.RequestsPerMinute), limiter: ratelimit.New(cfg.Session.Downloads.RequestsPerMinute),
baseURL: baseURL, baseURL: baseURL,
lyricsAPI: lyricsAPIv1,
openAPI: openAPIV2,
} }
} }
@@ -147,7 +156,7 @@ func (c *Client) refreshAccessToken(ctx context.Context) error {
} }
newRefresh := stringify(resp["refresh_token"]) newRefresh := stringify(resp["refresh_token"])
expiresIn := int64(intFromAny(resp["expires_in"])) expiresIn := int64(jsonutil.IntFromAny(resp["expires_in"]))
if expiresIn <= 0 { if expiresIn <= 0 {
expiresIn = 7 * 24 * 3600 expiresIn = 7 * 24 * 3600
} }
@@ -201,11 +210,52 @@ func (c *Client) GetMetadata(ctx context.Context, item, mediaType string) (map[s
if album, ok := resp["album"].(map[string]any); ok { if album, ok := resp["album"].(map[string]any); ok {
enrichTidalImage(album) enrichTidalImage(album)
} }
// Lyrics live on a separate endpoint, so fetching them costs an extra
// rate-limited roundtrip per track. Users who don't embed lyrics can
// opt out via metadata.exclude = ["lyrics"].
if !c.lyricsExcluded() {
if lyrics, lrc := c.fetchTrackLyrics(ctx, item); lyrics != "" || lrc != "" {
if lyrics != "" {
resp["lyrics"] = lyrics
}
if lrc != "" {
resp["lyrics_synced"] = lrc
}
}
}
} }
return resp, nil return resp, nil
} }
func (c *Client) lyricsExcluded() bool {
for _, k := range c.cfg.Session.Metadata.Exclude {
if strings.EqualFold(strings.TrimSpace(k), "lyrics") {
return true
}
}
return false
}
func (c *Client) fetchTrackLyrics(ctx context.Context, trackID string) (string, string) {
params := url.Values{}
params.Set("deviceType", "PHONE")
params.Set("locale", "en_US")
params.Set("platform", "ANDROID")
resp, status, err := c.apiRequest(ctx, "tracks/"+url.PathEscape(strings.TrimSpace(trackID))+"/lyrics", params, c.lyricsAPI)
if err != nil {
return "", ""
}
if status != http.StatusOK {
return "", ""
}
lyrics := strings.TrimSpace(stringify(resp["lyrics"]))
lrc := strings.TrimSpace(stringify(resp["subtitles"]))
return lyrics, lrc
}
func (c *Client) Search(ctx context.Context, mediaType, query string, limit int) ([]map[string]any, error) { func (c *Client) Search(ctx context.Context, mediaType, query string, limit int) ([]map[string]any, error) {
if !c.loggedIn { if !c.loggedIn {
return nil, errors.New("tidal client not logged in") return nil, errors.New("tidal client not logged in")
@@ -241,6 +291,15 @@ func (c *Client) GetDownloadable(ctx context.Context, trackID string, quality in
quality = c.cfg.Session.Tidal.Quality quality = c.cfg.Session.Tidal.Quality
} }
if c.cfg.Session.Tidal.PreferAtmos {
// No tracks/{id} pre-check: getAtmosDownloadable already validates
// each candidate response via playbackLooksAtmos and falls back
// through the format-specific trackManifests paths.
if d, _ := c.getAtmosDownloadable(ctx, trackID); d != nil {
return d, nil
}
}
params := url.Values{} params := url.Values{}
params.Set("audioquality", qualityMap[quality]) params.Set("audioquality", qualityMap[quality])
params.Set("playbackmode", "STREAM") params.Set("playbackmode", "STREAM")
@@ -252,6 +311,15 @@ func (c *Client) GetDownloadable(ctx context.Context, trackID string, quality in
} }
if status == http.StatusOK { if status == http.StatusOK {
if d := downloadableFromPlaybackManifest(resp); d != nil { if d := downloadableFromPlaybackManifest(resp); d != nil {
// Tidal's playbackinfo sometimes returns an m4a (HIGH/AAC)
// stream even when LOSSLESS+ was requested. There is no
// lossless m4a tier, so retry via the openAPI v2 manifest
// before settling for the downgrade.
if quality >= 2 && d.Extension == "m4a" {
if strict, strictErr := c.getDownloadableFromTrackManifest(ctx, trackID, quality); strictErr == nil && strict != nil {
return strict, nil
}
}
return d, nil return d, nil
} }
} }
@@ -259,6 +327,111 @@ func (c *Client) GetDownloadable(ctx context.Context, trackID string, quality in
return c.getDownloadableFromTrackManifest(ctx, trackID, quality) return c.getDownloadableFromTrackManifest(ctx, trackID, quality)
} }
func (c *Client) trackSupportsAtmos(ctx context.Context, trackID string) bool {
resp, status, err := c.apiRequest(ctx, "tracks/"+trackID, url.Values{}, c.baseURL)
if err != nil || status != http.StatusOK {
return false
}
if modes, ok := resp["audioModes"].([]any); ok {
for _, mode := range modes {
if strings.Contains(strings.ToUpper(stringify(mode)), "ATMOS") {
return true
}
}
}
if mm, ok := resp["mediaMetadata"].(map[string]any); ok {
if tags, ok := mm["tags"].([]any); ok {
for _, tag := range tags {
if strings.Contains(strings.ToUpper(stringify(tag)), "ATMOS") {
return true
}
}
}
}
return false
}
func (c *Client) getAtmosDownloadable(ctx context.Context, trackID string) (*provider.Downloadable, error) {
var lastErr error
for _, aq := range atmosAudioQualities {
params := url.Values{}
params.Set("audioquality", aq)
params.Set("playbackmode", "STREAM")
params.Set("assetpresentation", "FULL")
params.Set("immersiveaudio", "true")
resp, status, err := c.apiRequest(ctx, "tracks/"+trackID+"/playbackinfopostpaywall", params, c.baseURL)
if err != nil {
lastErr = err
continue
}
if status != http.StatusOK {
lastErr = fmt.Errorf("tidal atmos playbackinfo failed: status=%d", status)
continue
}
if !playbackLooksAtmos(resp) {
continue
}
if d := downloadableFromPlaybackManifest(resp); d != nil {
return d, nil
}
}
if d, err := c.getDownloadableFromTrackManifestForFormat(ctx, trackID, "EAC3_JOC"); err == nil {
return d, nil
} else if err != nil {
lastErr = err
}
if d, err := c.getDownloadableFromTrackManifestForFormat(ctx, trackID, "DOLBY_ATMOS"); err == nil {
return d, nil
} else if err != nil {
lastErr = err
}
if d, err := c.getDownloadableFromTrackManifestForFormat(ctx, trackID, "SONY_360RA"); err == nil {
return d, nil
} else if err != nil {
lastErr = err
}
return nil, lastErr
}
func playbackLooksAtmos(resp map[string]any) bool {
if strings.Contains(strings.ToUpper(stringify(resp["audioMode"])), "ATMOS") {
return true
}
if modes, ok := resp["audioModes"].([]any); ok {
for _, raw := range modes {
if strings.Contains(strings.ToUpper(stringify(raw)), "ATMOS") {
return true
}
}
}
manifestB64 := stringify(resp["manifest"])
if manifestB64 == "" {
return false
}
b, err := base64.StdEncoding.DecodeString(manifestB64)
if err != nil {
return false
}
manifest := map[string]any{}
if err = json.Unmarshal(b, &manifest); err != nil {
return false
}
if strings.Contains(strings.ToUpper(stringify(manifest["audioMode"])), "ATMOS") {
return true
}
if modes, ok := manifest["audioModes"].([]any); ok {
for _, raw := range modes {
if strings.Contains(strings.ToUpper(stringify(raw)), "ATMOS") {
return true
}
}
}
codec := strings.ToLower(stringify(manifest["codecs"]))
return strings.Contains(codec, "ec-3") || strings.Contains(codec, "eac3") || strings.Contains(codec, "joc") || strings.Contains(codec, "atmos")
}
func (c *Client) Close() error { func (c *Client) Close() error {
return nil return nil
} }
@@ -310,14 +483,27 @@ func (c *Client) fetchArtistAlbums(ctx context.Context, artistID string) ([]map[
out := make([]map[string]any, 0) out := make([]map[string]any, 0)
seen := map[string]struct{}{} seen := map[string]struct{}{}
for _, p := range paths { for _, p := range paths {
resp, status, err := c.apiRequest(ctx, p.path, p.params, c.baseURL) offset := 0
for {
params := url.Values{}
for k, values := range p.params {
for _, v := range values {
params.Add(k, v)
}
}
params.Set("offset", strconv.Itoa(offset))
resp, status, err := c.apiRequest(ctx, p.path, params, c.baseURL)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if status != http.StatusOK { if status != http.StatusOK {
return nil, fmt.Errorf("tidal artist albums failed: status=%d", status) return nil, fmt.Errorf("tidal artist albums failed: status=%d offset=%d", status, offset)
} }
items, _ := resp["items"].([]any) items, _ := resp["items"].([]any)
if len(items) == 0 {
break
}
for _, raw := range items { for _, raw := range items {
itm, ok := raw.(map[string]any) itm, ok := raw.(map[string]any)
if !ok { if !ok {
@@ -336,20 +522,33 @@ func (c *Client) fetchArtistAlbums(ctx context.Context, artistID string) ([]map[
seen[id] = struct{}{} seen[id] = struct{}{}
out = append(out, itm) out = append(out, itm)
} }
if len(items) < 100 {
break
}
offset += 100
}
} }
return out, nil return out, nil
} }
func (c *Client) getDownloadableFromTrackManifest(ctx context.Context, trackID string, quality int) (*provider.Downloadable, error) { func (c *Client) getDownloadableFromTrackManifest(ctx context.Context, trackID string, quality int) (*provider.Downloadable, error) {
format := qualityToFormat[quality] formats := formatsForQuality(quality, c.cfg.Session.Tidal.PreferAtmos)
return c.getDownloadableFromTrackManifestForFormats(ctx, trackID, formats)
}
func (c *Client) getDownloadableFromTrackManifestForFormat(ctx context.Context, trackID, format string) (*provider.Downloadable, error) {
return c.getDownloadableFromTrackManifestForFormats(ctx, trackID, []string{format})
}
func (c *Client) getDownloadableFromTrackManifestForFormats(ctx context.Context, trackID string, formats []string) (*provider.Downloadable, error) {
params := url.Values{} params := url.Values{}
params.Set("manifestType", "MPEG_DASH") params.Set("manifestType", "MPEG_DASH")
params.Set("formats", format) params.Set("formats", strings.Join(formats, ","))
params.Set("uriScheme", "HTTPS") params.Set("uriScheme", "HTTPS")
params.Set("usage", "PLAYBACK") params.Set("usage", "PLAYBACK")
params.Set("adaptive", "false") params.Set("adaptive", "false")
resp, status, err := c.apiRequest(ctx, "trackManifests/"+trackID, params, openAPIV2) resp, status, err := c.apiRequest(ctx, "trackManifests/"+trackID, params, c.openAPI)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -369,16 +568,36 @@ func (c *Client) getDownloadableFromTrackManifest(ctx context.Context, trackID s
if uri == "" { if uri == "" {
return nil, errors.New("tidal trackManifests missing uri") return nil, errors.New("tidal trackManifests missing uri")
} }
formats, _ := attrs["formats"].([]any) attrFormats, _ := attrs["formats"].([]any)
ext := "m4a" ext := "m4a"
for _, f := range formats { for _, f := range attrFormats {
if strings.Contains(strings.ToUpper(stringify(f)), "FLAC") { fv := strings.ToUpper(stringify(f))
if strings.Contains(fv, "FLAC") {
ext = "flac" ext = "flac"
break break
} }
if strings.Contains(fv, "EAC3") || strings.Contains(fv, "ATMOS") || strings.Contains(fv, "JOC") {
ext = "mka"
}
} }
return &provider.Downloadable{URL: uri, Extension: ext, Source: "tidal"}, nil profile := tidalAudioProfileFromFormats(attrFormats)
if profile.Container == "" {
profile = tidalAudioProfileFromExtension(ext)
}
return &provider.Downloadable{URL: uri, Extension: ext, Source: "tidal", Audio: profile}, nil
}
func formatsForQuality(quality int, preferAtmos bool) []string {
base, ok := qualityToFormats[quality]
if !ok {
base = qualityToFormats[0]
}
out := append([]string(nil), base...)
if preferAtmos {
out = append(out, "EAC3_JOC")
}
return out
} }
func (c *Client) GetVideoDownloadable(ctx context.Context, videoID string) (*provider.Downloadable, error) { func (c *Client) GetVideoDownloadable(ctx context.Context, videoID string) (*provider.Downloadable, error) {
@@ -470,8 +689,124 @@ func downloadableFromPlaybackManifest(resp map[string]any) *provider.Downloadabl
ext := "m4a" ext := "m4a"
if strings.Contains(codec, "flac") { if strings.Contains(codec, "flac") {
ext = "flac" ext = "flac"
} else if strings.Contains(codec, "ec-3") || strings.Contains(codec, "eac3") || strings.Contains(codec, "joc") || strings.Contains(codec, "atmos") {
ext = "mka"
} }
return &provider.Downloadable{URL: streamURL, Extension: ext, Source: "tidal"} profile := tidalAudioProfileFromCodec(codec)
if profile.Container == "" {
profile = tidalAudioProfileFromExtension(ext)
}
audioQuality := strings.ToUpper(strings.TrimSpace(stringify(resp["audioQuality"])))
if audioQuality == "" {
audioQuality = strings.ToUpper(strings.TrimSpace(stringify(manifest["audioQuality"])))
}
if audioQuality != "" {
profile = applyTidalAudioQuality(profile, audioQuality)
}
if strings.Contains(strings.ToUpper(stringify(resp["audioMode"])), "ATMOS") {
profile = tidalAtmosAudioProfile()
}
return &provider.Downloadable{URL: streamURL, Extension: ext, Source: "tidal", Audio: profile}
}
func tidalAudioProfileFromFormats(formats []any) provider.AudioProfile {
best := provider.AudioProfile{}
for _, raw := range formats {
f := strings.ToUpper(strings.TrimSpace(stringify(raw)))
switch {
case strings.Contains(f, "EAC3") || strings.Contains(f, "JOC") || strings.Contains(f, "ATMOS"):
return tidalAtmosAudioProfile()
case strings.Contains(f, "FLAC_HIRES"):
best = provider.AudioProfile{Container: "FLAC", Codec: "FLAC", Quality: "HI_RES_LOSSLESS", BitDepth: 24}
case strings.Contains(f, "FLAC"):
if best.Container == "" {
best = provider.AudioProfile{Container: "FLAC", Codec: "FLAC", Quality: "LOSSLESS", BitDepth: 16, SamplingRate: "44.1"}
}
case strings.Contains(f, "AACLC"):
if best.Container == "" {
best = provider.AudioProfile{Container: "M4A", Codec: "AACLC", Quality: "HIGH", BitDepth: 16, SamplingRate: "44.1", BitrateKbps: 320}
}
case strings.Contains(f, "HEAAC"):
if best.Container == "" {
best = provider.AudioProfile{Container: "M4A", Codec: "HEAACV1", Quality: "LOW", BitDepth: 16, SamplingRate: "44.1", BitrateKbps: 96}
}
}
}
return best
}
func tidalAudioProfileFromCodec(codec string) provider.AudioProfile {
c := strings.ToLower(strings.TrimSpace(codec))
switch {
case strings.Contains(c, "ec-3") || strings.Contains(c, "eac3") || strings.Contains(c, "joc") || strings.Contains(c, "atmos"):
return tidalAtmosAudioProfile()
case strings.Contains(c, "flac"):
return provider.AudioProfile{Container: "FLAC", Codec: "FLAC", Quality: "LOSSLESS", BitDepth: 16, SamplingRate: "44.1"}
case strings.Contains(c, "mp4a.40.5") || strings.Contains(c, "mp4a.40.29"):
return provider.AudioProfile{Container: "M4A", Codec: "HEAACV1", Quality: "LOW", BitDepth: 16, SamplingRate: "44.1", BitrateKbps: 96}
case strings.Contains(c, "mp4a") || strings.Contains(c, "aac"):
return provider.AudioProfile{Container: "M4A", Codec: "AACLC", Quality: "HIGH", BitDepth: 16, SamplingRate: "44.1", BitrateKbps: 320}
default:
return provider.AudioProfile{}
}
}
func tidalAtmosAudioProfile() provider.AudioProfile {
return provider.AudioProfile{Container: "MKA", Codec: "EAC3_JOC", Quality: "ATMOS", BitDepth: 32, SamplingRate: "48"}
}
func tidalAudioProfileFromExtension(ext string) provider.AudioProfile {
switch strings.ToLower(strings.TrimSpace(ext)) {
case "flac":
return provider.AudioProfile{Container: "FLAC", Codec: "FLAC", Quality: "LOSSLESS", BitDepth: 16, SamplingRate: "44.1"}
case "mka":
return tidalAtmosAudioProfile()
case "m4a":
return provider.AudioProfile{Container: "M4A", Codec: "AACLC", Quality: "HIGH", BitDepth: 16, SamplingRate: "44.1", BitrateKbps: 320}
default:
container := strings.ToUpper(strings.TrimSpace(ext))
if container == "" {
container = "M4A"
}
return provider.AudioProfile{Container: container, Codec: container}
}
}
func applyTidalAudioQuality(profile provider.AudioProfile, audioQuality string) provider.AudioProfile {
aq := strings.ToUpper(strings.TrimSpace(audioQuality))
if aq == "" {
return profile
}
profile.Quality = aq
switch aq {
case "HI_RES", "HI_RES_LOSSLESS":
if strings.EqualFold(profile.Container, "FLAC") {
if profile.BitDepth < 24 {
profile.BitDepth = 24
}
}
case "LOSSLESS":
if strings.EqualFold(profile.Container, "FLAC") {
if profile.BitDepth == 0 {
profile.BitDepth = 16
}
if profile.SamplingRate == "" {
profile.SamplingRate = "44.1"
}
}
case "HIGH":
if strings.EqualFold(profile.Container, "M4A") && profile.BitrateKbps == 0 {
profile.BitrateKbps = 320
}
case "LOW":
if strings.EqualFold(profile.Container, "M4A") {
profile.Codec = "HEAACV1"
if profile.BitrateKbps == 0 {
profile.BitrateKbps = 96
}
}
}
return profile
} }
func bestHLSVariantURL(masterURL, playlist string) string { func bestHLSVariantURL(masterURL, playlist string) string {
@@ -509,11 +844,111 @@ func resolvePlaylistURL(baseRaw, refRaw string) string {
return baseURL.ResolveReference(refURL).String() return baseURL.ResolveReference(refURL).String()
} }
func (c *Client) apiRequest(ctx context.Context, path string, params url.Values, base string) (map[string]any, int, error) { func shouldRetryStatus(status int) bool {
return status == http.StatusTooManyRequests || status >= http.StatusInternalServerError
}
func retryDelay(retryAfter string, attempt int) time.Duration {
retryAfter = strings.TrimSpace(retryAfter)
if retryAfter != "" {
if seconds, err := strconv.Atoi(retryAfter); err == nil && seconds >= 0 {
return time.Duration(seconds) * time.Second
}
if when, err := http.ParseTime(retryAfter); err == nil {
if delay := time.Until(when); delay > 0 {
return delay
}
return 0
}
}
return time.Duration(attempt+1) * 500 * time.Millisecond
}
func waitRetry(ctx context.Context, delay time.Duration) error {
if delay <= 0 {
return nil
}
timer := time.NewTimer(delay)
defer timer.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
return nil
}
}
func parseAPIResponseBody(body []byte, status int) (map[string]any, error) {
out := map[string]any{}
if len(body) == 0 {
return out, nil
}
if err := json.Unmarshal(body, &out); err != nil {
if status < http.StatusOK || status >= http.StatusMultipleChoices {
if raw := strings.TrimSpace(string(body)); raw != "" {
out["raw"] = raw
}
return out, nil
}
return nil, err
}
return out, nil
}
func readAPIResponse(resp *http.Response) (map[string]any, int, string, error) {
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, resp.StatusCode, resp.Header.Get("Retry-After"), err
}
parsed, err := parseAPIResponseBody(body, resp.StatusCode)
return parsed, resp.StatusCode, resp.Header.Get("Retry-After"), err
}
func (c *Client) doJSONWithRetry(ctx context.Context, newRequest func() (*http.Request, error)) (map[string]any, int, error) {
var lastStatus int
for attempt := 0; attempt < tidalRequestAttempts; attempt++ {
if err := c.limiter.Wait(ctx); err != nil { if err := c.limiter.Wait(ctx); err != nil {
return nil, 0, err return nil, 0, err
} }
req, err := newRequest()
if err != nil {
return nil, 0, err
}
resp, err := c.http.Do(req)
if err != nil {
if attempt+1 < tidalRequestAttempts {
if waitErr := waitRetry(ctx, retryDelay("", attempt)); waitErr != nil {
return nil, 0, waitErr
}
continue
}
return nil, 0, err
}
parsed, status, retryAfter, err := readAPIResponse(resp)
lastStatus = status
if err != nil {
if attempt+1 < tidalRequestAttempts {
if waitErr := waitRetry(ctx, retryDelay(retryAfter, attempt)); waitErr != nil {
return nil, 0, waitErr
}
continue
}
return nil, status, err
}
if shouldRetryStatus(status) && attempt+1 < tidalRequestAttempts {
if waitErr := waitRetry(ctx, retryDelay(retryAfter, attempt)); waitErr != nil {
return nil, 0, waitErr
}
continue
}
return parsed, status, nil
}
return map[string]any{}, lastStatus, nil
}
func (c *Client) apiRequest(ctx context.Context, path string, params url.Values, base string) (map[string]any, int, error) {
if params == nil { if params == nil {
params = url.Values{} params = url.Values{}
} }
@@ -529,41 +964,22 @@ func (c *Client) apiRequest(ctx context.Context, path string, params url.Values,
reqURL += "?" + params.Encode() reqURL += "?" + params.Encode()
} }
return c.doJSONWithRetry(ctx, func() (*http.Request, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil) req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
if err != nil { if err != nil {
return nil, 0, err return nil, err
} }
req.Header.Set("Authorization", "Bearer "+c.cfg.Session.Tidal.AccessToken) req.Header.Set("Authorization", "Bearer "+c.cfg.Session.Tidal.AccessToken)
req.Header.Set("User-Agent", "streamrip-go/0.1") req.Header.Set("User-Agent", "streamrip-go/0.1")
return req, nil
resp, err := c.http.Do(req) })
if err != nil {
return nil, 0, err
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, resp.StatusCode, err
}
parsed := map[string]any{}
if len(body) > 0 {
if err = json.Unmarshal(body, &parsed); err != nil {
return nil, resp.StatusCode, err
}
}
return parsed, resp.StatusCode, nil
} }
func (c *Client) apiPost(ctx context.Context, endpoint string, form url.Values, basicAuth bool) (map[string]any, int, error) { func (c *Client) apiPost(ctx context.Context, endpoint string, form url.Values, basicAuth bool) (map[string]any, int, error) {
if err := c.limiter.Wait(ctx); err != nil { return c.doJSONWithRetry(ctx, func() (*http.Request, error) {
return nil, 0, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewBufferString(form.Encode())) req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewBufferString(form.Encode()))
if err != nil { if err != nil {
return nil, 0, err return nil, err
} }
req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("User-Agent", "streamrip-go/0.1") req.Header.Set("User-Agent", "streamrip-go/0.1")
@@ -571,23 +987,8 @@ func (c *Client) apiPost(ctx context.Context, endpoint string, form url.Values,
auth := base64.StdEncoding.EncodeToString([]byte(clientID + ":" + clientSec)) auth := base64.StdEncoding.EncodeToString([]byte(clientID + ":" + clientSec))
req.Header.Set("Authorization", "Basic "+auth) req.Header.Set("Authorization", "Basic "+auth)
} }
return req, nil
resp, err := c.http.Do(req) })
if err != nil {
return nil, 0, err
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, resp.StatusCode, err
}
out := map[string]any{}
if len(body) > 0 {
if err = json.Unmarshal(body, &out); err != nil {
return nil, resp.StatusCode, err
}
}
return out, resp.StatusCode, nil
} }
func stringify(v any) string { func stringify(v any) string {
@@ -630,19 +1031,3 @@ func tidalImageMap(cover string) map[string]any {
"original": base + "/1280x1280.jpg", "original": base + "/1280x1280.jpg",
} }
} }
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(t)
return i
default:
return 0
}
}
+375
View File
@@ -6,6 +6,9 @@ import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url"
"reflect"
"strconv"
"testing" "testing"
"streamrip-go/internal/config" "streamrip-go/internal/config"
@@ -98,3 +101,375 @@ func TestBestHLSVariantURLFallsBackToMaster(t *testing.T) {
t.Fatalf("url = %q, want %q", got, master) t.Fatalf("url = %q, want %q", got, master)
} }
} }
func TestGetMetadataArtistPaginatesAlbums(t *testing.T) {
ts := 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/artists/9":
_ = json.NewEncoder(w).Encode(map[string]any{"id": 9, "name": "Artist X"})
case "/v1/artists/9/albums":
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
filter := r.URL.Query().Get("filter")
if filter == "" {
if offset == 0 {
items := make([]any, 0, 100)
for i := 0; i < 100; i++ {
items = append(items, map[string]any{"id": i + 1})
}
_ = json.NewEncoder(w).Encode(map[string]any{"items": items})
return
}
if offset == 100 {
_ = json.NewEncoder(w).Encode(map[string]any{"items": []any{map[string]any{"id": 101}}})
return
}
_ = json.NewEncoder(w).Encode(map[string]any{"items": []any{}})
return
}
if filter == "EPSANDSINGLES" {
if offset == 0 {
_ = json.NewEncoder(w).Encode(map[string]any{"items": []any{map[string]any{"id": 101}, map[string]any{"id": 102}}})
return
}
_ = json.NewEncoder(w).Encode(map[string]any{"items": []any{}})
return
}
w.WriteHeader(http.StatusBadRequest)
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer ts.Close()
cfgData := config.DefaultConfigData()
cfgData.Tidal.AccessToken = "token"
cfgData.Tidal.CountryCode = "US"
c := New(&config.Config{File: cfgData, Session: cfgData})
c.baseURL = ts.URL + "/v1"
if err := c.Login(context.Background()); err != nil {
t.Fatalf("login err = %v", err)
}
meta, err := c.GetMetadata(context.Background(), "9", "artist")
if err != nil {
t.Fatalf("GetMetadata() err = %v", err)
}
albumsObj, _ := meta["albums"].(map[string]any)
items, _ := albumsObj["items"].([]map[string]any)
if len(items) != 102 {
t.Fatalf("albums len = %d, want 102", len(items))
}
}
func TestGetMetadataTrackAddsLyricsAndSyncedLyrics(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/v1/tracks/42":
_ = json.NewEncoder(w).Encode(map[string]any{"id": 42, "title": "Song", "album": map[string]any{"id": 10, "title": "Album"}})
case "/v1/tracks/42/lyrics":
q := r.URL.Query()
if q.Get("deviceType") != "PHONE" || q.Get("locale") != "en_US" || q.Get("platform") != "ANDROID" || q.Get("countryCode") != "MY" {
w.WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(w).Encode(map[string]any{"error": "bad query"})
return
}
_ = json.NewEncoder(w).Encode(map[string]any{
"lyrics": "plain lyrics line",
"subtitles": "[00:00.00]plain lyrics line",
})
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer ts.Close()
cfgData := config.DefaultConfigData()
cfgData.Tidal.AccessToken = "token"
cfgData.Tidal.CountryCode = "MY"
c := New(&config.Config{File: cfgData, Session: cfgData})
c.loggedIn = true
c.baseURL = ts.URL + "/v1"
c.lyricsAPI = ts.URL + "/v1"
meta, err := c.GetMetadata(context.Background(), "42", "track")
if err != nil {
t.Fatalf("GetMetadata() err = %v", err)
}
if got := stringify(meta["lyrics"]); got != "plain lyrics line" {
t.Fatalf("lyrics = %q, want plain lyrics line", got)
}
if got := stringify(meta["lyrics_synced"]); got != "[00:00.00]plain lyrics line" {
t.Fatalf("lyrics_synced = %q, want synced lrc", got)
}
}
func TestGetMetadataTrackIgnoresLyricsEndpointFailure(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/v1/tracks/42":
_ = json.NewEncoder(w).Encode(map[string]any{"id": 42, "title": "Song"})
case "/v1/tracks/42/lyrics":
w.WriteHeader(http.StatusNotFound)
_ = json.NewEncoder(w).Encode(map[string]any{"error": "not found"})
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer ts.Close()
cfgData := config.DefaultConfigData()
cfgData.Tidal.AccessToken = "token"
cfgData.Tidal.CountryCode = "US"
c := New(&config.Config{File: cfgData, Session: cfgData})
c.loggedIn = true
c.baseURL = ts.URL + "/v1"
c.lyricsAPI = ts.URL + "/v1"
meta, err := c.GetMetadata(context.Background(), "42", "track")
if err != nil {
t.Fatalf("GetMetadata() err = %v", err)
}
if _, ok := meta["lyrics"]; ok {
t.Fatalf("did not expect lyrics when endpoint fails")
}
if _, ok := meta["lyrics_synced"]; ok {
t.Fatalf("did not expect lyrics_synced when endpoint fails")
}
}
func TestAPIRequestRetriesTooManyRequests(t *testing.T) {
calls := 0
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/tracks/42" {
w.WriteHeader(http.StatusNotFound)
return
}
calls++
if calls == 1 {
w.Header().Set("Retry-After", "0")
w.WriteHeader(http.StatusTooManyRequests)
_, _ = w.Write([]byte("slow down"))
return
}
_ = json.NewEncoder(w).Encode(map[string]any{"id": 42, "title": "Song"})
}))
defer ts.Close()
cfgData := config.DefaultConfigData()
cfgData.Downloads.RequestsPerMinute = 0
cfgData.Tidal.AccessToken = "token"
cfgData.Tidal.CountryCode = "US"
c := New(&config.Config{File: cfgData, Session: cfgData})
c.baseURL = ts.URL + "/v1"
resp, status, err := c.apiRequest(context.Background(), "tracks/42", nil, c.baseURL)
if err != nil {
t.Fatalf("apiRequest() err = %v", err)
}
if status != http.StatusOK {
t.Fatalf("status = %d, want %d", status, http.StatusOK)
}
if calls != 2 {
t.Fatalf("calls = %d, want 2", calls)
}
if stringify(resp["title"]) != "Song" {
t.Fatalf("title = %q, want Song", stringify(resp["title"]))
}
}
func TestAPIPostRetriesTooManyRequests(t *testing.T) {
calls := 0
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/token" {
w.WriteHeader(http.StatusNotFound)
return
}
calls++
if calls == 1 {
w.Header().Set("Retry-After", "0")
w.WriteHeader(http.StatusTooManyRequests)
_, _ = w.Write([]byte("slow down"))
return
}
_ = json.NewEncoder(w).Encode(map[string]any{"access_token": "fresh-token"})
}))
defer ts.Close()
cfgData := config.DefaultConfigData()
cfgData.Downloads.RequestsPerMinute = 0
c := New(&config.Config{File: cfgData, Session: cfgData})
resp, status, err := c.apiPost(context.Background(), ts.URL+"/token", url.Values{"grant_type": []string{"refresh_token"}}, false)
if err != nil {
t.Fatalf("apiPost() err = %v", err)
}
if status != http.StatusOK {
t.Fatalf("status = %d, want %d", status, http.StatusOK)
}
if calls != 2 {
t.Fatalf("calls = %d, want 2", calls)
}
if stringify(resp["access_token"]) != "fresh-token" {
t.Fatalf("access_token = %q, want fresh-token", stringify(resp["access_token"]))
}
}
func TestGetDownloadablePrefersAtmosWhenEnabled(t *testing.T) {
var calls []string
allImmersive := true
var ts *httptest.Server
ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/v1/tracks/42":
_ = json.NewEncoder(w).Encode(map[string]any{"id": 42, "audioModes": []any{"DOLBY_ATMOS", "STEREO"}, "mediaMetadata": map[string]any{"tags": []any{"LOSSLESS", "DOLBY_ATMOS"}}})
case "/v1/tracks/42/playbackinfopostpaywall":
if r.URL.Query().Get("immersiveaudio") != "true" {
allImmersive = false
}
aq := r.URL.Query().Get("audioquality")
calls = append(calls, aq)
manifest := map[string]any{"urls": []string{ts.URL + "/stereo.m3u8"}, "codecs": "flac"}
if aq == "HI_RES" {
manifest = map[string]any{"urls": []string{ts.URL + "/atmos.m3u8"}, "codecs": "ec-3", "audioMode": "DOLBY_ATMOS"}
}
b, _ := json.Marshal(manifest)
_ = json.NewEncoder(w).Encode(map[string]any{"manifest": base64.StdEncoding.EncodeToString(b), "audioMode": manifest["audioMode"]})
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer ts.Close()
cfgData := config.DefaultConfigData()
cfgData.Tidal.AccessToken = "token"
cfgData.Tidal.CountryCode = "US"
cfgData.Tidal.PreferAtmos = true
c := New(&config.Config{File: cfgData, Session: cfgData})
c.loggedIn = true
c.baseURL = ts.URL + "/v1"
d, err := c.GetDownloadable(context.Background(), "42", 3)
if err != nil {
t.Fatalf("GetDownloadable() err = %v", err)
}
if d.URL != ts.URL+"/atmos.m3u8" {
t.Fatalf("url = %q, want %q", d.URL, ts.URL+"/atmos.m3u8")
}
if d.Extension != "mka" {
t.Fatalf("extension = %q, want mka", d.Extension)
}
if len(calls) < 2 || calls[0] != "HI_RES_LOSSLESS" || calls[1] != "HI_RES" {
t.Fatalf("unexpected audioquality call order: %+v", calls)
}
if !allImmersive {
t.Fatalf("expected immersiveaudio=true on Atmos probing calls")
}
}
func TestPlaybackLooksAtmosFromManifest(t *testing.T) {
manifest := map[string]any{"urls": []string{"https://cdn.example/stream.m3u8"}, "codecs": "ec-3"}
b, _ := json.Marshal(manifest)
resp := map[string]any{"manifest": base64.StdEncoding.EncodeToString(b)}
if !playbackLooksAtmos(resp) {
t.Fatalf("expected atmos detection from manifest codec")
}
}
func TestTrackSupportsAtmosFromTags(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/tracks/42" {
w.WriteHeader(http.StatusNotFound)
return
}
_ = json.NewEncoder(w).Encode(map[string]any{"id": 42, "audioModes": []any{"STEREO"}, "mediaMetadata": map[string]any{"tags": []any{"LOSSLESS", "DOLBY_ATMOS"}}})
}))
defer ts.Close()
cfgData := config.DefaultConfigData()
cfgData.Tidal.AccessToken = "token"
cfgData.Tidal.CountryCode = "US"
c := New(&config.Config{File: cfgData, Session: cfgData})
c.loggedIn = true
c.baseURL = ts.URL + "/v1"
if !c.trackSupportsAtmos(context.Background(), "42") {
t.Fatalf("expected atmos support from mediaMetadata tags")
}
}
func TestFormatsForQuality(t *testing.T) {
tests := []struct {
name string
q int
atmos bool
wants []string
}{
{name: "low", q: 0, wants: []string{"HEAACV1"}},
{name: "high", q: 1, wants: []string{"HEAACV1", "AACLC"}},
{name: "lossless", q: 2, wants: []string{"HEAACV1", "AACLC", "FLAC"}},
{name: "hires", q: 4, wants: []string{"HEAACV1", "AACLC", "FLAC", "FLAC_HIRES"}},
{name: "atmos adds eac3", q: 2, atmos: true, wants: []string{"HEAACV1", "AACLC", "FLAC", "EAC3_JOC"}},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := formatsForQuality(tc.q, tc.atmos)
if !reflect.DeepEqual(got, tc.wants) {
t.Fatalf("formatsForQuality(%d, atmos=%v) = %#v, want %#v", tc.q, tc.atmos, got, tc.wants)
}
})
}
}
func TestGetDownloadableLosslessUsesTrackManifestWhenPlaybackIsAAC(t *testing.T) {
var gotFormats string
var ts *httptest.Server
ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/v1/tracks/42/playbackinfopostpaywall":
manifest := map[string]any{"urls": []string{ts.URL + "/aac.m3u8"}, "codecs": "mp4a.40.2"}
b, _ := json.Marshal(manifest)
_ = json.NewEncoder(w).Encode(map[string]any{"manifest": base64.StdEncoding.EncodeToString(b)})
case "/v2/trackManifests/42":
gotFormats = r.URL.Query().Get("formats")
_ = json.NewEncoder(w).Encode(map[string]any{
"data": map[string]any{
"attributes": map[string]any{
"uri": ts.URL + "/song.flac",
"formats": []any{"FLAC"},
},
},
})
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer ts.Close()
cfgData := config.DefaultConfigData()
cfgData.Tidal.AccessToken = "token"
cfgData.Tidal.CountryCode = "US"
c := New(&config.Config{File: cfgData, Session: cfgData})
c.loggedIn = true
c.baseURL = ts.URL + "/v1"
c.openAPI = ts.URL + "/v2"
d, err := c.GetDownloadable(context.Background(), "42", 2)
if err != nil {
t.Fatalf("GetDownloadable() err = %v", err)
}
if gotFormats != "HEAACV1,AACLC,FLAC" {
t.Fatalf("formats query = %q, want %q", gotFormats, "HEAACV1,AACLC,FLAC")
}
if d.URL != ts.URL+"/song.flac" {
t.Fatalf("url = %q, want %q", d.URL, ts.URL+"/song.flac")
}
if d.Extension != "flac" {
t.Fatalf("extension = %q, want flac", d.Extension)
}
if d.Audio.Container != "FLAC" || d.Audio.Quality != "LOSSLESS" {
t.Fatalf("unexpected audio profile: %+v", d.Audio)
}
}
File diff suppressed because it is too large Load Diff
+191
View File
@@ -0,0 +1,191 @@
package yandex
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"streamrip-go/internal/config"
"streamrip-go/internal/jsonutil"
)
func TestYandexDownloadSignMatchesCapturedFormat(t *testing.T) {
sign, ts := yandexDownloadSign("32038184", "lossless", []string{"flac", "aac", "he-aac", "mp3", "flac-mp4", "aac-mp4", "he-aac-mp4"}, "raw")
if ts <= 0 {
t.Fatalf("timestamp = %d", ts)
}
if strings.TrimSpace(sign) == "" {
t.Fatalf("decoded sign is empty")
}
if strings.Contains(sign, "=") {
t.Fatalf("sign unexpectedly contains base64 padding: %q", sign)
}
if strings.Contains(sign, " ") {
t.Fatalf("sign unexpectedly contains space: %q", sign)
}
}
func TestGetDownloadableUsesModernGetFileInfo(t *testing.T) {
var gotPath string
var gotQuery url.Values
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
gotQuery = r.URL.Query()
if r.URL.Path == "/account/about" {
_ = json.NewEncoder(w).Encode(map[string]any{"result": map[string]any{"uid": "123"}})
return
}
if r.URL.Path != "/get-file-info" {
w.WriteHeader(http.StatusNotFound)
return
}
_ = json.NewEncoder(w).Encode(map[string]any{
"result": map[string]any{
"downloadInfo": map[string]any{
"trackId": "32038184",
"quality": "lossless",
"codec": "flac-mp4",
"transport": "encraw",
"key": "00112233445566778899aabbccddeeff",
"bitrate": 0,
"url": "https://strm.example/music-v2/crypt/x/flac-mp4",
},
},
})
}))
defer ts.Close()
d := config.DefaultConfigData()
d.Downloads.RequestsPerMinute = 0
d.Yandex.AccessToken = "token"
c := New(&config.Config{File: d, Session: d})
c.baseURL = ts.URL
c.loggedIn = true
dl, err := c.GetDownloadable(context.Background(), "32038184:1683700", 2)
if err != nil {
t.Fatalf("GetDownloadable() error = %v", err)
}
if gotPath != "/get-file-info" {
t.Fatalf("path = %q, want /get-file-info", gotPath)
}
if gotQuery.Get("trackId") != "32038184" {
t.Fatalf("trackId = %q, want 32038184", gotQuery.Get("trackId"))
}
if gotQuery.Get("quality") != "lossless" {
t.Fatalf("quality = %q, want lossless", gotQuery.Get("quality"))
}
if gotQuery.Get("transports") != "encraw" {
t.Fatalf("transports = %q, want encraw", gotQuery.Get("transports"))
}
if dl.Extension != "m4a" {
t.Fatalf("extension = %q, want m4a", dl.Extension)
}
if dl.Audio.Codec != "FLAC" || dl.Audio.Quality != "LOSSLESS" {
t.Fatalf("unexpected audio profile: %+v", dl.Audio)
}
if dl.TrackID != "32038184" {
t.Fatalf("track id = %q, want 32038184", dl.TrackID)
}
if dl.Cipher != "AES_CTR" || dl.Key == "" {
t.Fatalf("expected yandex cipher metadata, got cipher=%q key=%q", dl.Cipher, dl.Key)
}
}
func TestGetMetadataTrackUsesModernTracksEndpoint(t *testing.T) {
var gotMethod string
var gotPath string
var gotBody string
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotMethod = r.Method
gotPath = r.URL.Path
if r.URL.Path != "/tracks" {
w.WriteHeader(http.StatusNotFound)
return
}
body, _ := io.ReadAll(r.Body)
gotBody = string(body)
_ = json.NewEncoder(w).Encode(map[string]any{
"result": []map[string]any{{
"id": "9442712",
"realId": "9442712",
"title": "Nightcall",
"artists": []map[string]any{{"id": "1433871", "name": "Kavinsky"}, {"id": "42", "name": "Lovefoxxx"}},
"albums": []map[string]any{{
"id": "1000856",
"title": "OutRun",
"releaseDate": "2013-02-25T00:00:00+04:00",
"trackCount": 13,
"artists": []map[string]any{{"id": "1433871", "name": "Kavinsky"}, {"id": "42", "name": "Lovefoxxx"}},
"trackPosition": map[string]any{
"index": 0,
"volume": 1,
},
}},
}},
})
}))
defer ts.Close()
d := config.DefaultConfigData()
d.Downloads.RequestsPerMinute = 0
d.Yandex.AccessToken = "token"
c := New(&config.Config{File: d, Session: d})
c.baseURL = ts.URL
c.loggedIn = true
meta, err := c.GetMetadata(context.Background(), "9442712:1000856", "track")
if err != nil {
t.Fatalf("GetMetadata() error = %v", err)
}
if gotMethod != http.MethodPost || gotPath != "/tracks" {
t.Fatalf("unexpected request: %s %s", gotMethod, gotPath)
}
if !strings.Contains(gotBody, "trackIds=9442712%3A1000856") {
t.Fatalf("body = %q", gotBody)
}
if meta["id"] != "9442712:1000856" {
t.Fatalf("id = %v", meta["id"])
}
if album, _ := meta["album"].(map[string]any); jsonutil.StringFromAny(album["title"]) != "OutRun" {
t.Fatalf("unexpected album: %+v", album)
}
if artist := jsonutil.NestedString(meta, "artist", "name"); artist != "Kavinsky & Lovefoxxx" {
t.Fatalf("artist = %q", artist)
}
if albumArtist := jsonutil.NestedString(meta, "album", "artist", "name"); albumArtist != "Kavinsky & Lovefoxxx" {
t.Fatalf("album artist = %q", albumArtist)
}
artists, _ := meta["artist_names"].([]string)
if strings.Join(artists, ";") != "Kavinsky;Lovefoxxx" {
t.Fatalf("artist_names = %#v", artists)
}
}
func TestDisplayArtistNamesUsesCreditPunctuation(t *testing.T) {
got := displayArtistNames([]string{"A", "B", "C"})
if got != "A, B & C" {
t.Fatalf("displayArtistNames() = %q", got)
}
}
func TestLegacyDirectURLBuildsPlayableMP3URL(t *testing.T) {
url, err := legacyDirectURL(&legacyDownloadInfoXML{
Host: "example.test",
Path: "/abc123",
TS: "1234567890",
S: "tailxyz",
})
if err != nil {
t.Fatalf("legacyDirectURL() error = %v", err)
}
want := "https://example.test/get-mp3/248c1c6ff5daf481560d3bd9f24e8058/1234567890/abc123"
if url != want {
t.Fatalf("legacyDirectURL() = %q, want %q", url, want)
}
}
+128 -6
View File
@@ -45,17 +45,75 @@ func Parse(raw string) *ParsedURL {
switch { switch {
case isQobuzHost(host): case isQobuzHost(host):
return parseQobuz(raw, parts) return parseQobuz(raw, parts)
case isYandexHost(host):
return parseYandex(raw, parts)
case isBeatportHost(host):
return parseBeatport(raw, parts)
case isTidalHost(host): case isTidalHost(host):
return parseTidal(raw, parts) return parseTidal(raw, parts)
case isDeezerHost(host): case isDeezerHost(host):
return parseDeezer(raw, parts) return parseDeezer(raw, parts)
case host == "soundcloud.com": case isSoundcloudHost(host):
return parseSoundcloud(raw, parts) return parseSoundcloud(raw, host, parts)
default: default:
return nil return nil
} }
} }
func parseBeatport(raw string, parts []string) *ParsedURL {
if len(parts) < 2 {
return nil
}
if len(parts[0]) == 2 {
parts = parts[1:]
}
if len(parts) > 0 && parts[0] == "catalog" {
parts = parts[1:]
}
if len(parts) < 2 {
return nil
}
mediaType := ""
idIndex := 1
switch parts[0] {
case "track", "tracks":
mediaType = "track"
if parts[0] == "track" {
idIndex = 2
}
case "release", "releases":
mediaType = "album"
if parts[0] == "release" {
idIndex = 2
}
case "library":
if len(parts) < 3 || (parts[1] != "playlists" && parts[1] != "playlist") {
return nil
}
mediaType = "playlist"
idIndex = 2
case "playlists":
mediaType = "playlist"
idIndex = 2
case "chart", "playlist":
mediaType = "chart"
idIndex = 2
case "artist":
mediaType = "artist"
idIndex = 2
case "label":
mediaType = "label"
idIndex = 2
default:
return nil
}
if idIndex >= len(parts) || strings.TrimSpace(parts[idIndex]) == "" {
return nil
}
return &ParsedURL{OriginalURL: raw, Source: "beatport", MediaType: mediaType, ID: parts[idIndex], Kind: KindGeneric}
}
func parseQobuz(raw string, parts []string) *ParsedURL { func parseQobuz(raw string, parts []string) *ParsedURL {
if len(parts) < 2 { if len(parts) < 2 {
return nil return nil
@@ -69,6 +127,9 @@ func parseQobuz(raw string, parts []string) *ParsedURL {
} }
mediaType := parts[0] mediaType := parts[0]
if mediaType == "interpreter" {
mediaType = "artist"
}
if !isSupportedMedia(mediaType) { if !isSupportedMedia(mediaType) {
return nil return nil
} }
@@ -80,11 +141,54 @@ func parseQobuz(raw string, parts []string) *ParsedURL {
return &ParsedURL{OriginalURL: raw, Source: "qobuz", MediaType: mediaType, ID: id, Kind: KindGeneric} return &ParsedURL{OriginalURL: raw, Source: "qobuz", MediaType: mediaType, ID: id, Kind: KindGeneric}
} }
func parseYandex(raw string, parts []string) *ParsedURL {
if len(parts) < 2 {
return nil
}
switch parts[0] {
case "track":
if len(parts) != 2 || strings.TrimSpace(parts[1]) == "" {
return nil
}
return &ParsedURL{OriginalURL: raw, Source: "yandex", MediaType: "track", ID: parts[1], Kind: KindGeneric}
case "album":
if len(parts) == 2 && strings.TrimSpace(parts[1]) != "" {
return &ParsedURL{OriginalURL: raw, Source: "yandex", MediaType: "album", ID: parts[1], Kind: KindGeneric}
}
if len(parts) == 4 && parts[2] == "track" && strings.TrimSpace(parts[1]) != "" && strings.TrimSpace(parts[3]) != "" {
return &ParsedURL{OriginalURL: raw, Source: "yandex", MediaType: "track", ID: parts[3] + ":" + parts[1], Kind: KindGeneric}
}
case "artist":
if len(parts) != 2 || strings.TrimSpace(parts[1]) == "" {
return nil
}
return &ParsedURL{OriginalURL: raw, Source: "yandex", MediaType: "artist", ID: parts[1], Kind: KindGeneric}
case "users":
if len(parts) == 4 && parts[2] == "playlists" && strings.TrimSpace(parts[1]) != "" && strings.TrimSpace(parts[3]) != "" {
return &ParsedURL{OriginalURL: raw, Source: "yandex", MediaType: "playlist", ID: parts[1] + ":" + parts[3], Kind: KindGeneric}
}
case "playlists":
if len(parts) == 2 && strings.TrimSpace(parts[1]) != "" {
return &ParsedURL{OriginalURL: raw, Source: "yandex", MediaType: "playlist", ID: parts[1], Kind: KindGeneric}
}
}
return nil
}
func parseTidal(raw string, parts []string) *ParsedURL { func parseTidal(raw string, parts []string) *ParsedURL {
if len(parts) < 2 { if len(parts) < 2 {
return nil return nil
} }
if isLangToken(parts[0]) {
parts = parts[1:]
}
if len(parts) < 2 {
return nil
}
if parts[0] == "browse" { if parts[0] == "browse" {
parts = parts[1:] parts = parts[1:]
} }
@@ -128,14 +232,20 @@ func parseDeezer(raw string, parts []string) *ParsedURL {
return &ParsedURL{OriginalURL: raw, Source: "deezer", MediaType: mediaType, ID: id, Kind: KindGeneric} return &ParsedURL{OriginalURL: raw, Source: "deezer", MediaType: mediaType, ID: id, Kind: KindGeneric}
} }
func parseSoundcloud(raw string, parts []string) *ParsedURL { func parseSoundcloud(raw, host string, parts []string) *ParsedURL {
if len(parts) < 2 { if len(parts) < 1 {
return nil return nil
} }
if host == "on.soundcloud.com" {
return &ParsedURL{OriginalURL: raw, Source: "soundcloud", MediaType: "track", ID: raw, Kind: KindSoundcloud}
}
mediaType := "track" mediaType := "track"
if len(parts) >= 3 && parts[1] == "sets" { if len(parts) >= 3 && parts[1] == "sets" {
mediaType = "playlist" mediaType = "playlist"
} else if len(parts) < 2 || parts[1] == "sets" {
return nil
} }
return &ParsedURL{OriginalURL: raw, Source: "soundcloud", MediaType: mediaType, ID: raw, Kind: KindSoundcloud} return &ParsedURL{OriginalURL: raw, Source: "soundcloud", MediaType: mediaType, ID: raw, Kind: KindSoundcloud}
@@ -164,17 +274,29 @@ func isQobuzHost(host string) bool {
return host == "qobuz.com" || host == "open.qobuz.com" || host == "play.qobuz.com" return host == "qobuz.com" || host == "open.qobuz.com" || host == "play.qobuz.com"
} }
func isYandexHost(host string) bool {
return host == "music.yandex.ru" || host == "music.yandex.com" || host == "music.yandex.kz" || host == "music.yandex.by"
}
func isBeatportHost(host string) bool {
return host == "beatport.com" || host == "api.beatport.com"
}
func isTidalHost(host string) bool { func isTidalHost(host string) bool {
return host == "tidal.com" || host == "open.tidal.com" || host == "listen.tidal.com" return host == "tidal.com" || host == "open.tidal.com" || host == "listen.tidal.com"
} }
func isDeezerHost(host string) bool { func isDeezerHost(host string) bool {
return host == "deezer.com" return host == "deezer.com" || strings.HasSuffix(host, ".deezer.com")
}
func isSoundcloudHost(host string) bool {
return host == "soundcloud.com" || strings.HasSuffix(host, ".soundcloud.com") || host == "on.soundcloud.com"
} }
func isSupportedMedia(mediaType string) bool { func isSupportedMedia(mediaType string) bool {
switch mediaType { switch mediaType {
case "album", "track", "playlist", "artist", "label", "video": case "album", "track", "playlist", "artist", "label", "video", "chart":
return true return true
default: default:
return false return false
+100 -3
View File
@@ -27,14 +27,85 @@ func TestQobuzAlbumURL(t *testing.T) {
} }
} }
func TestQobuzInterpreterURLParsesAsArtist(t *testing.T) {
inputs := []string{
"https://www.qobuz.com/us-en/interpreter/odezenne/739874",
"https://play.qobuz.com/artist/739874",
}
for _, input := range inputs {
result := Parse(input)
if result == nil {
t.Fatalf("expected parsed url for %q", input)
}
if result.Source != "qobuz" || result.MediaType != "artist" || result.ID != "739874" {
t.Fatalf("unexpected parse result for %q: %+v", input, result)
}
}
}
func TestYandexURLs(t *testing.T) {
tests := []struct {
url string
mediaType string
id string
}{
{url: "https://music.yandex.ru/track/9442712", mediaType: "track", id: "9442712"},
{url: "https://music.yandex.ru/album/1000856", mediaType: "album", id: "1000856"},
{url: "https://music.yandex.ru/album/1000856/track/9442712", mediaType: "track", id: "9442712:1000856"},
{url: "https://music.yandex.ru/artist/1433871", mediaType: "artist", id: "1433871"},
{url: "https://music.yandex.ru/users/yandexmusic/playlists/1635", mediaType: "playlist", id: "yandexmusic:1635"},
{url: "https://music.yandex.ru/playlists/4ae45ac1-0972-734f-8537-769490399170", mediaType: "playlist", id: "4ae45ac1-0972-734f-8537-769490399170"},
}
for _, tc := range tests {
result := Parse(tc.url)
if result == nil {
t.Fatalf("expected parse for %q", tc.url)
}
if result.Source != "yandex" || result.MediaType != tc.mediaType || result.ID != tc.id {
t.Fatalf("unexpected parse result for %q: %+v", tc.url, result)
}
}
}
func TestBeatportURLs(t *testing.T) {
tests := []struct {
url string
mediaType string
id string
}{
{url: "https://www.beatport.com/track/strobe/1696999", mediaType: "track", id: "1696999"},
{url: "https://www.beatport.com/release/random-album/12345", mediaType: "album", id: "12345"},
{url: "https://www.beatport.com/library/playlists/67890", mediaType: "playlist", id: "67890"},
{url: "https://www.beatport.com/chart/some-chart/111", mediaType: "chart", id: "111"},
{url: "https://www.beatport.com/artist/deadmau5/24078", mediaType: "artist", id: "24078"},
{url: "https://www.beatport.com/label/mau5trap/1234", mediaType: "label", id: "1234"},
{url: "https://api.beatport.com/v4/catalog/tracks/1696999/", mediaType: "track", id: "1696999"},
}
for _, tc := range tests {
result := Parse(tc.url)
if result == nil {
t.Fatalf("expected parse for %q", tc.url)
}
if result.Source != "beatport" || result.MediaType != tc.mediaType || result.ID != tc.id {
t.Fatalf("unexpected parse result for %q: %+v", tc.url, result)
}
}
}
func TestTidalTrackURL(t *testing.T) { func TestTidalTrackURL(t *testing.T) {
url := "https://tidal.com/browse/track/3083287" inputs := []string{
"https://tidal.com/browse/track/3083287",
"https://tidal.com/us/browse/track/3083287",
"https://tidal.com/us/track/3083287",
}
for _, url := range inputs {
result := Parse(url) result := Parse(url)
if result == nil { if result == nil {
t.Fatalf("expected parsed url") t.Fatalf("expected parsed url for %q", url)
} }
if result.Source != "tidal" || result.MediaType != "track" || result.ID != "3083287" { if result.Source != "tidal" || result.MediaType != "track" || result.ID != "3083287" {
t.Fatalf("unexpected parse result: %+v", result) t.Fatalf("unexpected parse result for %q: %+v", url, result)
}
} }
} }
@@ -93,6 +164,7 @@ func TestURLWithLanguageCode(t *testing.T) {
"https://www.qobuz.com/gb-en/album/name/id123456", "https://www.qobuz.com/gb-en/album/name/id123456",
"https://www.deezer.com/en/track/4195713", "https://www.deezer.com/en/track/4195713",
"https://www.deezer.com/fr/track/4195713", "https://www.deezer.com/fr/track/4195713",
"https://m.deezer.com/en/track/4195713",
} }
for _, input := range inputs { for _, input := range inputs {
if result := Parse(input); result == nil { if result := Parse(input); result == nil {
@@ -101,10 +173,23 @@ func TestURLWithLanguageCode(t *testing.T) {
} }
} }
func TestDeezerMobileHostURL(t *testing.T) {
url := "https://m.deezer.com/track/4195713"
result := Parse(url)
if result == nil {
t.Fatalf("expected parsed url")
}
if result.Source != "deezer" || result.MediaType != "track" || result.ID != "4195713" {
t.Fatalf("unexpected parse result: %+v", result)
}
}
func TestSoundcloudURL(t *testing.T) { func TestSoundcloudURL(t *testing.T) {
inputs := []string{ inputs := []string{
"https://soundcloud.com/artist-name/track-name", "https://soundcloud.com/artist-name/track-name",
"https://soundcloud.com/artist-name/sets/playlist-name", "https://soundcloud.com/artist-name/sets/playlist-name",
"https://m.soundcloud.com/artist-name/track-name",
"https://on.soundcloud.com/abcdef",
} }
for _, input := range inputs { for _, input := range inputs {
result := Parse(input) result := Parse(input)
@@ -116,3 +201,15 @@ func TestSoundcloudURL(t *testing.T) {
} }
} }
} }
func TestSoundcloudProfileURLIsNotTrack(t *testing.T) {
if result := Parse("https://soundcloud.com/artist-name"); result != nil {
t.Fatalf("expected nil for profile url, got %+v", result)
}
}
func TestSoundcloudSetsRootWithoutPlaylistSlugInvalid(t *testing.T) {
if result := Parse("https://soundcloud.com/artist-name/sets"); result != nil {
t.Fatalf("expected nil for sets root url, got %+v", result)
}
}
+68
View File
@@ -0,0 +1,68 @@
// Package verbose provides a process-wide verbosity level and a pluggable
// log sink so verbose output integrates with the downloader's progress bars.
//
// Level meaning:
//
// 0 (Off) - no extra output
// 1 (V) - log per-track CDN URLs from the downloader
// 2 (VV) - additionally log every outbound HTTP request via the
// netutil-wrapped transport (covers all provider API calls
// and downloads)
package verbose
import (
"fmt"
"os"
"sync/atomic"
)
const (
Off byte = 0
V byte = 1
VV byte = 2
)
var (
level atomic.Uint32
sink atomic.Pointer[func(string)]
)
// SetLevel clamps and stores the verbosity level. Pass 0 to disable.
func SetLevel(l int) {
if l < 0 {
l = 0
}
if l > int(VV) {
l = int(VV)
}
level.Store(uint32(l))
}
func Level() byte { return byte(level.Load()) }
func Enabled(l byte) bool { return Level() >= l }
// SetSink installs a writer for verbose output. Use this to route logs
// through the downloader so they don't tear progress bars. Pass nil to
// fall back to stderr.
func SetSink(fn func(string)) {
if fn == nil {
sink.Store(nil)
return
}
sink.Store(&fn)
}
// Printf emits a line at the given level if verbosity is enabled. The
// caller is responsible for including a trailing newline.
func Printf(l byte, format string, args ...any) {
if !Enabled(l) {
return
}
msg := fmt.Sprintf(format, args...)
if p := sink.Load(); p != nil {
(*p)(msg)
return
}
_, _ = fmt.Fprint(os.Stderr, msg)
}