mirror of
https://git.sr.ht/~joren/streamrip-go
synced 2026-06-17 15:05:39 +02:00
initial Go port of streamrip
This commit is contained in:
1016
internal/app/app.go
Normal file
1016
internal/app/app.go
Normal file
File diff suppressed because it is too large
Load Diff
318
internal/app/app_test.go
Normal file
318
internal/app/app_test.go
Normal file
@@ -0,0 +1,318 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"streamrip-go/internal/audio/tag"
|
||||
"streamrip-go/internal/config"
|
||||
"streamrip-go/internal/download"
|
||||
"streamrip-go/internal/provider"
|
||||
"streamrip-go/internal/store"
|
||||
)
|
||||
|
||||
type noopTagger struct{}
|
||||
|
||||
func (n noopTagger) TagFLAC(string, tag.Metadata, string) error { return nil }
|
||||
|
||||
type fakeProvider struct {
|
||||
url string
|
||||
}
|
||||
|
||||
func (f *fakeProvider) Source() string { return "qobuz" }
|
||||
func (f *fakeProvider) Login(context.Context) error { return nil }
|
||||
func (f *fakeProvider) LoggedIn() bool { return true }
|
||||
func (f *fakeProvider) Close() error { return nil }
|
||||
func (f *fakeProvider) Search(context.Context, string, string, int) ([]map[string]any, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeProvider) GetMetadata(context.Context, string, string) (map[string]any, error) {
|
||||
return map[string]any{
|
||||
"title": "Dreams/Live",
|
||||
"track_number": float64(3),
|
||||
"performer": map[string]any{
|
||||
"name": "Fleetwood Mac",
|
||||
},
|
||||
"album": map[string]any{
|
||||
"artist": map[string]any{"name": "Fleetwood Mac"},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
type fakeAlbumProvider struct {
|
||||
url string
|
||||
}
|
||||
|
||||
type fakePlaylistProvider struct {
|
||||
url string
|
||||
}
|
||||
|
||||
func (f *fakeAlbumProvider) Source() string { return "qobuz" }
|
||||
func (f *fakePlaylistProvider) Source() string { return "qobuz" }
|
||||
func (f *fakeAlbumProvider) Login(context.Context) error { return nil }
|
||||
func (f *fakePlaylistProvider) Login(context.Context) error {
|
||||
return nil
|
||||
}
|
||||
func (f *fakeAlbumProvider) LoggedIn() bool { return true }
|
||||
func (f *fakePlaylistProvider) LoggedIn() bool { return true }
|
||||
func (f *fakeAlbumProvider) Close() error { return nil }
|
||||
func (f *fakePlaylistProvider) Close() error { return nil }
|
||||
func (f *fakeAlbumProvider) Search(context.Context, string, string, int) ([]map[string]any, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakePlaylistProvider) Search(context.Context, string, string, int) ([]map[string]any, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (f *fakeAlbumProvider) GetMetadata(_ context.Context, id string, mediaType string) (map[string]any, error) {
|
||||
if mediaType == "album" {
|
||||
return map[string]any{
|
||||
"title": "Rumours",
|
||||
"release_date_original": "1977-02-04",
|
||||
"media_count": float64(2),
|
||||
"maximum_bit_depth": float64(24),
|
||||
"maximum_sampling_rate": float64(96),
|
||||
"artist": map[string]any{"name": "Fleetwood Mac"},
|
||||
"tracks": map[string]any{"items": []any{
|
||||
map[string]any{"id": "t1"},
|
||||
map[string]any{"id": "t2"},
|
||||
}},
|
||||
}, nil
|
||||
}
|
||||
|
||||
tn := float64(1)
|
||||
disc := float64(1)
|
||||
title := "Dreams"
|
||||
if id == "t2" {
|
||||
tn = 2
|
||||
disc = 2
|
||||
title = "Go Your Own Way"
|
||||
}
|
||||
return map[string]any{
|
||||
"title": title,
|
||||
"track_number": tn,
|
||||
"media_number": disc,
|
||||
"performer": map[string]any{
|
||||
"name": "Fleetwood Mac",
|
||||
},
|
||||
"album": map[string]any{
|
||||
"title": "Rumours",
|
||||
"artist": map[string]any{"name": "Fleetwood Mac"},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (f *fakePlaylistProvider) GetMetadata(_ context.Context, id string, mediaType string) (map[string]any, error) {
|
||||
if mediaType == "playlist" {
|
||||
return map[string]any{
|
||||
"name": "Road Trip",
|
||||
"tracks": map[string]any{
|
||||
"items": []any{map[string]any{"id": "p1"}, map[string]any{"id": "p2"}},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
trackNum := float64(7)
|
||||
title := "Track One"
|
||||
if id == "p2" {
|
||||
trackNum = 9
|
||||
title = "Track Two"
|
||||
}
|
||||
return map[string]any{
|
||||
"title": title,
|
||||
"track_number": trackNum,
|
||||
"performer": map[string]any{
|
||||
"name": "Artist",
|
||||
},
|
||||
"album": map[string]any{
|
||||
"title": "Original Album",
|
||||
"artist": map[string]any{"name": "Artist"},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
func (f *fakeProvider) GetDownloadable(context.Context, string, int) (*provider.Downloadable, error) {
|
||||
return &provider.Downloadable{URL: f.url, Extension: "flac", Source: "qobuz"}, nil
|
||||
}
|
||||
func (f *fakeAlbumProvider) GetDownloadable(context.Context, string, int) (*provider.Downloadable, error) {
|
||||
return &provider.Downloadable{URL: f.url, Extension: "flac", Source: "qobuz"}, nil
|
||||
}
|
||||
func (f *fakePlaylistProvider) GetDownloadable(context.Context, string, int) (*provider.Downloadable, error) {
|
||||
return &provider.Downloadable{URL: f.url, Extension: "flac", Source: "qobuz"}, nil
|
||||
}
|
||||
|
||||
func TestTrackRipPipeline(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.New(),
|
||||
Tagger: noopTagger{},
|
||||
Pending: nil,
|
||||
Media: nil,
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
if err = m.Rip(ctx); err != nil {
|
||||
t.Fatalf("Rip() error = %v", err)
|
||||
}
|
||||
|
||||
if _, err = os.Stat(filepath.Join(tmp, "03. Fleetwood Mac - Dreams_Live.flac")); err != nil {
|
||||
t.Fatalf("expected downloaded file: %v", err)
|
||||
}
|
||||
|
||||
ok, err := sqlite.IsDownloaded(ctx, "19512574")
|
||||
if err != nil {
|
||||
t.Fatalf("IsDownloaded() error = %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatalf("expected track marked downloaded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlbumRipPipeline(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
|
||||
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": &fakeAlbumProvider{url: ts.URL},
|
||||
},
|
||||
Store: sqlite,
|
||||
DL: download.New(),
|
||||
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, "Fleetwood Mac - Rumours (1977) [FLAC] [24B-96kHz]")
|
||||
if _, err = os.Stat(filepath.Join(folder, "Disc 1", "01. Fleetwood Mac - Dreams.flac")); err != nil {
|
||||
t.Fatalf("missing first album track: %v", err)
|
||||
}
|
||||
if _, err = os.Stat(filepath.Join(folder, "Disc 2", "02. Fleetwood Mac - Go Your Own Way.flac")); err != nil {
|
||||
t.Fatalf("missing second album track: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaylistRipPipeline(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.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),
|
||||
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, "Road Trip")
|
||||
if _, err = os.Stat(filepath.Join(folder, "01. Artist - Track One.flac")); err != nil {
|
||||
t.Fatalf("missing first playlist track: %v", err)
|
||||
}
|
||||
if _, err = os.Stat(filepath.Join(folder, "02. Artist - Track Two.flac")); err != nil {
|
||||
t.Fatalf("missing second playlist track: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyQobuzArtistFiltersRepeats(t *testing.T) {
|
||||
albums := []collectionAlbum{
|
||||
{ID: "a1", Title: "Album X", BitDepth: 16, Sampling: 44.1, Explicit: false},
|
||||
{ID: "a2", Title: "Album X (Deluxe)", BitDepth: 24, Sampling: 96, Explicit: false},
|
||||
{ID: "b1", Title: "Album B", BitDepth: 16, Sampling: 44.1, Explicit: false},
|
||||
}
|
||||
filtered := applyQobuzArtistFilters("artist", albums, config.QobuzDiscographyFilterConfig{Repeats: true})
|
||||
if len(filtered) != 2 {
|
||||
t.Fatalf("len(filtered)=%d want 2", len(filtered))
|
||||
}
|
||||
ids := map[string]bool{}
|
||||
for _, a := range filtered {
|
||||
ids[a.ID] = true
|
||||
}
|
||||
if !ids["a2"] || !ids["b1"] {
|
||||
t.Fatalf("unexpected winners: %+v", ids)
|
||||
}
|
||||
}
|
||||
186
internal/artwork/artwork.go
Normal file
186
internal/artwork/artwork.go
Normal file
@@ -0,0 +1,186 @@
|
||||
package artwork
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha1"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/jpeg"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"golang.org/x/image/draw"
|
||||
|
||||
"streamrip-go/internal/config"
|
||||
)
|
||||
|
||||
type Downloader interface {
|
||||
File(ctx context.Context, sourceURL, outputPath string) error
|
||||
FileNoProgress(ctx context.Context, sourceURL, outputPath string) error
|
||||
}
|
||||
|
||||
type Result struct {
|
||||
EmbedPath string
|
||||
SavedPath string
|
||||
}
|
||||
|
||||
var (
|
||||
tempDirsMu sync.Mutex
|
||||
tempDirs = map[string]struct{}{}
|
||||
)
|
||||
|
||||
func Prepare(ctx context.Context, dl Downloader, folder string, albumMeta map[string]any, cfg config.ArtworkConfig, forPlaylist bool) (Result, error) {
|
||||
saveArtwork := cfg.SaveArtwork
|
||||
if forPlaylist {
|
||||
saveArtwork = false
|
||||
}
|
||||
if !(cfg.Embed || saveArtwork) {
|
||||
return Result{}, nil
|
||||
}
|
||||
|
||||
imageMap, ok := albumMeta["image"].(map[string]any)
|
||||
if !ok {
|
||||
return Result{}, nil
|
||||
}
|
||||
|
||||
largestURL := pickLargestURL(imageMap)
|
||||
embedURL := pickEmbedURL(imageMap, cfg.EmbedSize)
|
||||
if embedURL == "" {
|
||||
embedURL = largestURL
|
||||
}
|
||||
|
||||
result := Result{}
|
||||
if saveArtwork && largestURL != "" {
|
||||
savedPath := filepath.Join(folder, "cover.jpg")
|
||||
if fileExists(savedPath) {
|
||||
result.SavedPath = savedPath
|
||||
} else if err := dl.FileNoProgress(ctx, largestURL, savedPath); err == nil {
|
||||
if cfg.SavedMaxWidth > 0 {
|
||||
_ = downscaleImage(savedPath, cfg.SavedMaxWidth)
|
||||
}
|
||||
result.SavedPath = savedPath
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.Embed && embedURL != "" {
|
||||
embedDir := filepath.Join(folder, "__artwork")
|
||||
if err := os.MkdirAll(embedDir, 0o755); err == nil {
|
||||
registerTempDir(embedDir)
|
||||
embedPath := filepath.Join(embedDir, embedFilename(embedURL))
|
||||
if fileExists(embedPath) {
|
||||
result.EmbedPath = embedPath
|
||||
} else if err := dl.FileNoProgress(ctx, embedURL, embedPath); err == nil {
|
||||
if cfg.EmbedMaxWidth > 0 {
|
||||
_ = downscaleImage(embedPath, cfg.EmbedMaxWidth)
|
||||
}
|
||||
result.EmbedPath = embedPath
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func CleanupTempDirs() {
|
||||
tempDirsMu.Lock()
|
||||
defer tempDirsMu.Unlock()
|
||||
|
||||
for dir := range tempDirs {
|
||||
_ = os.RemoveAll(dir)
|
||||
delete(tempDirs, dir)
|
||||
}
|
||||
}
|
||||
|
||||
func registerTempDir(path string) {
|
||||
tempDirsMu.Lock()
|
||||
defer tempDirsMu.Unlock()
|
||||
tempDirs[path] = struct{}{}
|
||||
}
|
||||
|
||||
func fileExists(path string) bool {
|
||||
st, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return !st.IsDir()
|
||||
}
|
||||
|
||||
func pickLargestURL(image map[string]any) string {
|
||||
for _, key := range []string{"original", "mega", "extralarge", "large", "small", "thumbnail"} {
|
||||
if v := stringAny(image[key]); v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func pickEmbedURL(image map[string]any, size string) string {
|
||||
size = strings.ToLower(strings.TrimSpace(size))
|
||||
if size == "" {
|
||||
size = "large"
|
||||
}
|
||||
for _, key := range []string{size, "large", "extralarge", "small", "thumbnail", "original"} {
|
||||
if v := stringAny(image[key]); v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func embedFilename(url string) string {
|
||||
s := sha1.Sum([]byte(url))
|
||||
return fmt.Sprintf("cover%x.jpg", s[:8])
|
||||
}
|
||||
|
||||
func stringAny(v any) string {
|
||||
s, _ := v.(string)
|
||||
return s
|
||||
}
|
||||
|
||||
func downscaleImage(path string, maxDimension int) error {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
img, _, err := image.Decode(f)
|
||||
_ = f.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
b := img.Bounds()
|
||||
w, h := b.Dx(), b.Dy()
|
||||
if w <= 0 || h <= 0 || maxDimension <= 0 {
|
||||
return nil
|
||||
}
|
||||
if w <= maxDimension && h <= maxDimension {
|
||||
return nil
|
||||
}
|
||||
|
||||
newW, newH := w, h
|
||||
if w > h {
|
||||
newW = maxDimension
|
||||
newH = int(float64(h) * (float64(maxDimension) / float64(w)))
|
||||
} else {
|
||||
newH = maxDimension
|
||||
newW = int(float64(w) * (float64(maxDimension) / float64(h)))
|
||||
}
|
||||
if newW <= 0 {
|
||||
newW = 1
|
||||
}
|
||||
if newH <= 0 {
|
||||
newH = 1
|
||||
}
|
||||
|
||||
dst := image.NewRGBA(image.Rect(0, 0, newW, newH))
|
||||
draw.CatmullRom.Scale(dst, dst.Bounds(), img, b, draw.Over, nil)
|
||||
|
||||
out, err := os.Create(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = out.Close() }()
|
||||
return jpeg.Encode(out, dst, &jpeg.Options{Quality: 92})
|
||||
}
|
||||
123
internal/audio/convert/convert.go
Normal file
123
internal/audio/convert/convert.go
Normal file
@@ -0,0 +1,123 @@
|
||||
package convert
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"streamrip-go/internal/config"
|
||||
)
|
||||
|
||||
type profile struct {
|
||||
codecLib string
|
||||
ext string
|
||||
lossless bool
|
||||
}
|
||||
|
||||
var profiles = map[string]profile{
|
||||
"FLAC": {codecLib: "flac", ext: "flac", lossless: true},
|
||||
"ALAC": {codecLib: "alac", ext: "m4a", lossless: true},
|
||||
"OPUS": {codecLib: "libopus", ext: "opus", lossless: false},
|
||||
"MP3": {codecLib: "libmp3lame", ext: "mp3", lossless: false},
|
||||
"VORBIS": {codecLib: "libvorbis", ext: "ogg", lossless: false},
|
||||
"AAC": {codecLib: "aac", ext: "m4a", lossless: false},
|
||||
}
|
||||
|
||||
func Convert(path string, cfg config.ConversionConfig) (string, error) {
|
||||
if !cfg.Enabled {
|
||||
return path, nil
|
||||
}
|
||||
if _, err := exec.LookPath("ffmpeg"); err != nil {
|
||||
return path, fmt.Errorf("ffmpeg not found: %w", err)
|
||||
}
|
||||
|
||||
p, ok := profiles[strings.ToUpper(strings.TrimSpace(cfg.Codec))]
|
||||
if !ok {
|
||||
return path, fmt.Errorf("unsupported conversion codec: %s", cfg.Codec)
|
||||
}
|
||||
|
||||
base := strings.TrimSuffix(path, filepath.Ext(path))
|
||||
finalPath := base + "." + p.ext
|
||||
tmpPath := finalPath + ".tmp." + p.ext
|
||||
|
||||
args := buildFFmpegArgs(path, tmpPath, p, cfg)
|
||||
cmd := exec.Command("ffmpeg", args...)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
_ = os.Remove(tmpPath)
|
||||
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 {
|
||||
_ = os.Remove(tmpPath)
|
||||
return path, err
|
||||
}
|
||||
|
||||
return finalPath, nil
|
||||
}
|
||||
|
||||
func buildFFmpegArgs(inputPath, outputPath string, p profile, cfg config.ConversionConfig) []string {
|
||||
args := []string{
|
||||
"-y",
|
||||
"-i", inputPath,
|
||||
"-map", "0:a:0",
|
||||
"-map_metadata", "0",
|
||||
"-c:a", p.codecLib,
|
||||
}
|
||||
|
||||
if p.lossless {
|
||||
filter := buildLosslessFilter(cfg)
|
||||
if filter != "" {
|
||||
args = append(args, "-af", filter)
|
||||
}
|
||||
} else {
|
||||
if cfg.LossyBitrate > 0 {
|
||||
args = append(args, "-b:a", strconv.Itoa(cfg.LossyBitrate)+"k")
|
||||
}
|
||||
}
|
||||
|
||||
args = append(args, outputPath)
|
||||
return args
|
||||
}
|
||||
|
||||
func buildLosslessFilter(cfg config.ConversionConfig) string {
|
||||
parts := make([]string, 0, 2)
|
||||
if cfg.SamplingRate > 0 {
|
||||
rates := allowedSampleRates(cfg.SamplingRate)
|
||||
if len(rates) > 0 {
|
||||
parts = append(parts, "sample_rates="+strings.Join(rates, "|"))
|
||||
}
|
||||
}
|
||||
if cfg.BitDepth == 16 {
|
||||
parts = append(parts, "sample_fmts=s16p|s16")
|
||||
} else if cfg.BitDepth == 24 || cfg.BitDepth == 32 {
|
||||
parts = append(parts, "sample_fmts=s16p|s16|s32p|s32")
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return ""
|
||||
}
|
||||
return "aformat=" + strings.Join(parts, ":")
|
||||
}
|
||||
|
||||
func allowedSampleRates(max int) []string {
|
||||
all := []int{44100, 48000, 88200, 96000, 176400, 192000}
|
||||
out := make([]int, 0, len(all))
|
||||
for _, r := range all {
|
||||
if r <= max {
|
||||
out = append(out, r)
|
||||
}
|
||||
}
|
||||
sort.Ints(out)
|
||||
str := make([]string, 0, len(out))
|
||||
for _, r := range out {
|
||||
str = append(str, strconv.Itoa(r))
|
||||
}
|
||||
return str
|
||||
}
|
||||
43
internal/audio/convert/convert_test.go
Normal file
43
internal/audio/convert/convert_test.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package convert
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"streamrip-go/internal/config"
|
||||
)
|
||||
|
||||
func TestAllowedSampleRates(t *testing.T) {
|
||||
got := allowedSampleRates(96000)
|
||||
want := []string{"44100", "48000", "88200", "96000"}
|
||||
if strings.Join(got, ",") != strings.Join(want, ",") {
|
||||
t.Fatalf("rates=%v want=%v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFFmpegArgsLossless(t *testing.T) {
|
||||
cfg := config.ConversionConfig{Enabled: true, Codec: "FLAC", SamplingRate: 48000, BitDepth: 16}
|
||||
args := buildFFmpegArgs("in.flac", "out.flac", profiles["FLAC"], cfg)
|
||||
joined := strings.Join(args, " ")
|
||||
if !strings.Contains(joined, "-c:a flac") {
|
||||
t.Fatalf("missing flac codec args=%s", joined)
|
||||
}
|
||||
if !strings.Contains(joined, "sample_rates=44100|48000") {
|
||||
t.Fatalf("missing sample rate filter args=%s", joined)
|
||||
}
|
||||
if !strings.Contains(joined, "sample_fmts=s16p|s16") {
|
||||
t.Fatalf("missing bit depth filter args=%s", joined)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFFmpegArgsLossy(t *testing.T) {
|
||||
cfg := config.ConversionConfig{Enabled: true, Codec: "MP3", LossyBitrate: 320}
|
||||
args := buildFFmpegArgs("in.flac", "out.mp3", profiles["MP3"], cfg)
|
||||
joined := strings.Join(args, " ")
|
||||
if !strings.Contains(joined, "-c:a libmp3lame") {
|
||||
t.Fatalf("missing mp3 codec args=%s", joined)
|
||||
}
|
||||
if !strings.Contains(joined, "-b:a 320k") {
|
||||
t.Fatalf("missing bitrate args=%s", joined)
|
||||
}
|
||||
}
|
||||
169
internal/audio/tag/tagger.go
Normal file
169
internal/audio/tag/tagger.go
Normal file
@@ -0,0 +1,169 @@
|
||||
package tag
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Metadata struct {
|
||||
Title string
|
||||
Album string
|
||||
Artist string
|
||||
AlbumArtist string
|
||||
TrackNumber int
|
||||
DiscNumber int
|
||||
TrackTotal int
|
||||
DiscTotal int
|
||||
Date string
|
||||
Genre string
|
||||
Comment string
|
||||
Description string
|
||||
Lyrics string
|
||||
Copyright string
|
||||
ISRC string
|
||||
ReplaygainTrackGain string
|
||||
ReplaygainAlbumGain string
|
||||
SourcePlatform string
|
||||
SourceTrackID string
|
||||
SourceAlbumID string
|
||||
SourceArtistID string
|
||||
}
|
||||
|
||||
type Tagger struct{}
|
||||
|
||||
func New() *Tagger {
|
||||
return &Tagger{}
|
||||
}
|
||||
|
||||
func (t *Tagger) TagFLAC(path string, meta Metadata, coverPath string) error {
|
||||
if _, err := exec.LookPath("ffmpeg"); err != nil {
|
||||
return fmt.Errorf("ffmpeg not found: %w", err)
|
||||
}
|
||||
|
||||
tmpPath := path + ".tmp.flac"
|
||||
args := buildFFmpegArgs(path, tmpPath, meta, coverPath)
|
||||
|
||||
cmd := exec.Command("ffmpeg", args...)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
_ = os.Remove(tmpPath)
|
||||
return fmt.Errorf("ffmpeg tag failed: %w: %s", err, string(output))
|
||||
}
|
||||
|
||||
if err = os.Rename(tmpPath, path); err != nil {
|
||||
_ = os.Remove(tmpPath)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildFFmpegArgs(inputPath, outputPath string, meta Metadata, coverPath string) []string {
|
||||
args := []string{"-y", "-i", inputPath}
|
||||
withCover := coverPath != "" && fileExists(coverPath)
|
||||
if withCover {
|
||||
args = append(args, "-i", coverPath)
|
||||
}
|
||||
|
||||
args = append(args,
|
||||
"-map", "0:a",
|
||||
"-c:a", "copy",
|
||||
)
|
||||
if withCover {
|
||||
args = append(args,
|
||||
"-map", "1:v:0",
|
||||
"-c:v", "mjpeg",
|
||||
"-disposition:v:0", "attached_pic",
|
||||
)
|
||||
}
|
||||
|
||||
for k, v := range toTags(meta) {
|
||||
if strings.TrimSpace(v) == "" {
|
||||
continue
|
||||
}
|
||||
args = append(args, "-metadata", k+"="+v)
|
||||
}
|
||||
|
||||
args = append(args, outputPath)
|
||||
return args
|
||||
}
|
||||
|
||||
func toTags(meta Metadata) map[string]string {
|
||||
tags := map[string]string{
|
||||
"title": meta.Title,
|
||||
"album": meta.Album,
|
||||
"artist": meta.Artist,
|
||||
"album_artist": meta.AlbumArtist,
|
||||
"date": meta.Date,
|
||||
"genre": meta.Genre,
|
||||
"comment": meta.Comment,
|
||||
"description": meta.Description,
|
||||
"lyrics": meta.Lyrics,
|
||||
"copyright": normalizeCopyright(meta.Copyright),
|
||||
"isrc": meta.ISRC,
|
||||
"replaygain_track_gain": meta.ReplaygainTrackGain,
|
||||
"replaygain_album_gain": meta.ReplaygainAlbumGain,
|
||||
"source_platform": strings.ToUpper(strings.TrimSpace(meta.SourcePlatform)),
|
||||
"source_track_id": meta.SourceTrackID,
|
||||
"source_album_id": meta.SourceAlbumID,
|
||||
"source_artist_id": meta.SourceArtistID,
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
if meta.TrackTotal > 0 {
|
||||
tags["tracktotal"] = strconv.Itoa(meta.TrackTotal)
|
||||
}
|
||||
if meta.DiscNumber > 0 {
|
||||
if meta.DiscTotal > 0 {
|
||||
tags["disc"] = fmt.Sprintf("%d/%d", meta.DiscNumber, meta.DiscTotal)
|
||||
} else {
|
||||
tags["disc"] = strconv.Itoa(meta.DiscNumber)
|
||||
}
|
||||
}
|
||||
if meta.DiscTotal > 0 {
|
||||
tags["disctotal"] = strconv.Itoa(meta.DiscTotal)
|
||||
}
|
||||
return tags
|
||||
}
|
||||
|
||||
func normalizeCopyright(in string) string {
|
||||
out := strings.ReplaceAll(in, "(c)", "©")
|
||||
out = strings.ReplaceAll(out, "(C)", "©")
|
||||
out = strings.ReplaceAll(out, "(p)", "℗")
|
||||
out = strings.ReplaceAll(out, "(P)", "℗")
|
||||
return out
|
||||
}
|
||||
|
||||
func fileExists(path string) bool {
|
||||
if path == "" {
|
||||
return false
|
||||
}
|
||||
st, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return !st.IsDir()
|
||||
}
|
||||
|
||||
func CoverPathForTrack(trackPath string, albumFolder string) string {
|
||||
if albumFolder != "" {
|
||||
p := filepath.Join(albumFolder, "cover.jpg")
|
||||
if fileExists(p) {
|
||||
return p
|
||||
}
|
||||
}
|
||||
p := filepath.Join(filepath.Dir(trackPath), "cover.jpg")
|
||||
if fileExists(p) {
|
||||
return p
|
||||
}
|
||||
return ""
|
||||
}
|
||||
73
internal/audio/tag/tagger_test.go
Normal file
73
internal/audio/tag/tagger_test.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package tag
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNormalizeCopyright(t *testing.T) {
|
||||
got := normalizeCopyright("(c) test (P) other")
|
||||
if got != "© test ℗ other" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToTagsTrackDiscFormatting(t *testing.T) {
|
||||
tags := toTags(Metadata{TrackNumber: 3, DiscNumber: 2})
|
||||
if tags["track"] != "03" {
|
||||
t.Fatalf("track tag = %q", tags["track"])
|
||||
}
|
||||
if tags["disc"] != "2" {
|
||||
t.Fatalf("disc tag = %q", tags["disc"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestToTagsTotalsAndSourceFields(t *testing.T) {
|
||||
tags := toTags(Metadata{
|
||||
TrackNumber: 3,
|
||||
TrackTotal: 12,
|
||||
DiscNumber: 1,
|
||||
DiscTotal: 2,
|
||||
ISRC: "USABC1234567",
|
||||
SourcePlatform: "qobuz",
|
||||
SourceTrackID: "t1",
|
||||
})
|
||||
if tags["track"] != "03/12" {
|
||||
t.Fatalf("track tag = %q", tags["track"])
|
||||
}
|
||||
if tags["disc"] != "1/2" {
|
||||
t.Fatalf("disc tag = %q", tags["disc"])
|
||||
}
|
||||
if tags["tracktotal"] != "12" || tags["disctotal"] != "2" {
|
||||
t.Fatalf("totals missing: %+v", tags)
|
||||
}
|
||||
if tags["isrc"] != "USABC1234567" {
|
||||
t.Fatalf("isrc missing: %+v", tags)
|
||||
}
|
||||
if tags["source_platform"] != "QOBUZ" || tags["source_track_id"] != "t1" {
|
||||
t.Fatalf("source tags missing: %+v", tags)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildFFmpegArgsWithCover(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
cover := filepath.Join(tmp, "cover.jpg")
|
||||
if err := os.WriteFile(cover, []byte("x"), 0o644); err != nil {
|
||||
t.Fatalf("write cover: %v", err)
|
||||
}
|
||||
args := buildFFmpegArgs("in.flac", "out.flac", Metadata{Title: "x"}, cover)
|
||||
foundInput2 := false
|
||||
foundAttach := false
|
||||
for i := 0; i < len(args)-1; i++ {
|
||||
if args[i] == "-i" && args[i+1] == cover {
|
||||
foundInput2 = true
|
||||
}
|
||||
if args[i] == "-disposition:v:0" && args[i+1] == "attached_pic" {
|
||||
foundAttach = true
|
||||
}
|
||||
}
|
||||
if !foundInput2 || !foundAttach {
|
||||
t.Fatalf("missing cover args: %v", args)
|
||||
}
|
||||
}
|
||||
331
internal/config/config.go
Normal file
331
internal/config/config.go
Normal file
@@ -0,0 +1,331 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/pelletier/go-toml/v2"
|
||||
)
|
||||
|
||||
const CurrentConfigVersion = "2.2.0"
|
||||
|
||||
var ErrOutdatedConfig = errors.New("config version mismatch")
|
||||
|
||||
type Config struct {
|
||||
Path string
|
||||
File ConfigData
|
||||
Session ConfigData
|
||||
}
|
||||
|
||||
type ConfigData struct {
|
||||
Downloads DownloadsConfig `toml:"downloads"`
|
||||
Qobuz QobuzConfig `toml:"qobuz"`
|
||||
Tidal TidalConfig `toml:"tidal"`
|
||||
Deezer DeezerConfig `toml:"deezer"`
|
||||
Soundcloud SoundcloudConfig `toml:"soundcloud"`
|
||||
Youtube YoutubeConfig `toml:"youtube"`
|
||||
Database DatabaseConfig `toml:"database"`
|
||||
Conversion ConversionConfig `toml:"conversion"`
|
||||
QobuzFilters QobuzDiscographyFilterConfig `toml:"qobuz_filters"`
|
||||
Artwork ArtworkConfig `toml:"artwork"`
|
||||
Metadata MetadataConfig `toml:"metadata"`
|
||||
Filepaths FilepathsConfig `toml:"filepaths"`
|
||||
LastFM LastFMConfig `toml:"lastfm"`
|
||||
CLI CLIConfig `toml:"cli"`
|
||||
Misc MiscConfig `toml:"misc"`
|
||||
}
|
||||
|
||||
type DownloadsConfig struct {
|
||||
Folder string `toml:"folder"`
|
||||
SourceSubdirectories bool `toml:"source_subdirectories"`
|
||||
DiscSubdirectories bool `toml:"disc_subdirectories"`
|
||||
Concurrency bool `toml:"concurrency"`
|
||||
MaxConnections int `toml:"max_connections"`
|
||||
RequestsPerMinute int `toml:"requests_per_minute"`
|
||||
VerifySSL bool `toml:"verify_ssl"`
|
||||
}
|
||||
|
||||
type QobuzConfig struct {
|
||||
Quality int `toml:"quality"`
|
||||
DownloadBooklets bool `toml:"download_booklets"`
|
||||
UseAuthToken bool `toml:"use_auth_token"`
|
||||
EmailOrUserID string `toml:"email_or_userid"`
|
||||
PasswordOrToken string `toml:"password_or_token"`
|
||||
AppID string `toml:"app_id"`
|
||||
Secrets []string `toml:"secrets"`
|
||||
}
|
||||
|
||||
type TidalConfig struct {
|
||||
Quality int `toml:"quality"`
|
||||
DownloadVideos bool `toml:"download_videos"`
|
||||
UserID string `toml:"user_id"`
|
||||
CountryCode string `toml:"country_code"`
|
||||
AccessToken string `toml:"access_token"`
|
||||
RefreshToken string `toml:"refresh_token"`
|
||||
TokenExpiry int64 `toml:"token_expiry"`
|
||||
}
|
||||
|
||||
type DeezerConfig struct {
|
||||
Quality int `toml:"quality"`
|
||||
LowerQualityIfNotAvailable bool `toml:"lower_quality_if_not_available"`
|
||||
ARL string `toml:"arl"`
|
||||
UseDeezloader bool `toml:"use_deezloader"`
|
||||
DeezloaderWarnings bool `toml:"deezloader_warnings"`
|
||||
}
|
||||
|
||||
type SoundcloudConfig struct {
|
||||
Quality int `toml:"quality"`
|
||||
ClientID string `toml:"client_id"`
|
||||
AppVersion string `toml:"app_version"`
|
||||
}
|
||||
|
||||
type YoutubeConfig struct {
|
||||
Quality int `toml:"quality"`
|
||||
DownloadVideos bool `toml:"download_videos"`
|
||||
VideoDownloadsFolder string `toml:"video_downloads_folder"`
|
||||
}
|
||||
|
||||
type DatabaseConfig struct {
|
||||
DownloadsEnabled bool `toml:"downloads_enabled"`
|
||||
DownloadsPath string `toml:"downloads_path"`
|
||||
FailedDownloadsEnabled bool `toml:"failed_downloads_enabled"`
|
||||
FailedDownloadsPath string `toml:"failed_downloads_path"`
|
||||
}
|
||||
|
||||
type ConversionConfig struct {
|
||||
Enabled bool `toml:"enabled"`
|
||||
Codec string `toml:"codec"`
|
||||
SamplingRate int `toml:"sampling_rate"`
|
||||
BitDepth int `toml:"bit_depth"`
|
||||
LossyBitrate int `toml:"lossy_bitrate"`
|
||||
}
|
||||
|
||||
type QobuzDiscographyFilterConfig struct {
|
||||
Extras bool `toml:"extras"`
|
||||
Repeats bool `toml:"repeats"`
|
||||
NonAlbums bool `toml:"non_albums"`
|
||||
Features bool `toml:"features"`
|
||||
NonStudioAlbums bool `toml:"non_studio_albums"`
|
||||
NonRemaster bool `toml:"non_remaster"`
|
||||
}
|
||||
|
||||
type ArtworkConfig struct {
|
||||
Embed bool `toml:"embed"`
|
||||
EmbedSize string `toml:"embed_size"`
|
||||
EmbedMaxWidth int `toml:"embed_max_width"`
|
||||
SaveArtwork bool `toml:"save_artwork"`
|
||||
SavedMaxWidth int `toml:"saved_max_width"`
|
||||
}
|
||||
|
||||
type MetadataConfig struct {
|
||||
SetPlaylistToAlbum bool `toml:"set_playlist_to_album"`
|
||||
RenumberPlaylistTracks bool `toml:"renumber_playlist_tracks"`
|
||||
Exclude []string `toml:"exclude"`
|
||||
}
|
||||
|
||||
type FilepathsConfig struct {
|
||||
AddSinglesToFolder bool `toml:"add_singles_to_folder"`
|
||||
FolderFormat string `toml:"folder_format"`
|
||||
TrackFormat string `toml:"track_format"`
|
||||
RestrictCharacters bool `toml:"restrict_characters"`
|
||||
TruncateTo int `toml:"truncate_to"`
|
||||
}
|
||||
|
||||
type LastFMConfig struct {
|
||||
Source string `toml:"source"`
|
||||
FallbackSource string `toml:"fallback_source"`
|
||||
}
|
||||
|
||||
type CLIConfig struct {
|
||||
TextOutput bool `toml:"text_output"`
|
||||
ProgressBars bool `toml:"progress_bars"`
|
||||
MaxSearchResults int `toml:"max_search_results"`
|
||||
}
|
||||
|
||||
type MiscConfig struct {
|
||||
Version string `toml:"version"`
|
||||
CheckForUpdates bool `toml:"check_for_updates"`
|
||||
}
|
||||
|
||||
func Load(path string) (*Config, error) {
|
||||
resolvedPath, err := resolvePath(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if _, err = os.Stat(resolvedPath); errors.Is(err, os.ErrNotExist) {
|
||||
cfg := DefaultConfigData()
|
||||
if err = saveConfigData(resolvedPath, cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Config{Path: resolvedPath, File: cfg, Session: cloneConfigData(cfg)}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(resolvedPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var data ConfigData
|
||||
if err = toml.Unmarshal(raw, &data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
applyRuntimeDefaults(&data)
|
||||
|
||||
if data.Misc.Version != CurrentConfigVersion {
|
||||
return nil, fmt.Errorf("%w: need to update from %q to %q", ErrOutdatedConfig, data.Misc.Version, CurrentConfigVersion)
|
||||
}
|
||||
|
||||
return &Config{Path: resolvedPath, File: data, Session: cloneConfigData(data)}, nil
|
||||
}
|
||||
|
||||
func (c *Config) SaveFile() error {
|
||||
return saveConfigData(c.Path, c.File)
|
||||
}
|
||||
|
||||
func DefaultConfigData() ConfigData {
|
||||
home, _ := os.UserHomeDir()
|
||||
appDir := defaultAppDir()
|
||||
downloadsFolder := filepath.Join(home, "StreamripDownloads")
|
||||
|
||||
data := ConfigData{
|
||||
Downloads: DownloadsConfig{
|
||||
Folder: downloadsFolder,
|
||||
SourceSubdirectories: false,
|
||||
DiscSubdirectories: true,
|
||||
Concurrency: true,
|
||||
MaxConnections: 6,
|
||||
RequestsPerMinute: 60,
|
||||
VerifySSL: true,
|
||||
},
|
||||
Qobuz: QobuzConfig{
|
||||
Quality: 3,
|
||||
DownloadBooklets: true,
|
||||
UseAuthToken: false,
|
||||
},
|
||||
Tidal: TidalConfig{
|
||||
Quality: 3,
|
||||
DownloadVideos: true,
|
||||
},
|
||||
Deezer: DeezerConfig{
|
||||
Quality: 2,
|
||||
LowerQualityIfNotAvailable: true,
|
||||
UseDeezloader: true,
|
||||
DeezloaderWarnings: true,
|
||||
},
|
||||
Soundcloud: SoundcloudConfig{
|
||||
Quality: 0,
|
||||
},
|
||||
Youtube: YoutubeConfig{
|
||||
Quality: 0,
|
||||
DownloadVideos: false,
|
||||
VideoDownloadsFolder: filepath.Join(downloadsFolder, "YouTubeVideos"),
|
||||
},
|
||||
Database: DatabaseConfig{
|
||||
DownloadsEnabled: true,
|
||||
DownloadsPath: filepath.Join(appDir, "downloads.db"),
|
||||
FailedDownloadsEnabled: true,
|
||||
FailedDownloadsPath: filepath.Join(appDir, "failed_downloads.db"),
|
||||
},
|
||||
Conversion: ConversionConfig{
|
||||
Enabled: false,
|
||||
Codec: "ALAC",
|
||||
SamplingRate: 48000,
|
||||
BitDepth: 24,
|
||||
LossyBitrate: 320,
|
||||
},
|
||||
QobuzFilters: QobuzDiscographyFilterConfig{},
|
||||
Artwork: ArtworkConfig{
|
||||
Embed: true,
|
||||
EmbedSize: "large",
|
||||
EmbedMaxWidth: -1,
|
||||
SaveArtwork: true,
|
||||
SavedMaxWidth: -1,
|
||||
},
|
||||
Metadata: MetadataConfig{
|
||||
SetPlaylistToAlbum: true,
|
||||
RenumberPlaylistTracks: true,
|
||||
Exclude: []string{},
|
||||
},
|
||||
Filepaths: FilepathsConfig{
|
||||
AddSinglesToFolder: false,
|
||||
FolderFormat: "{albumartist} - {title} ({year}) [{container}] [{bit_depth}B-{sampling_rate}kHz]",
|
||||
TrackFormat: "{tracknumber:02}. {artist} - {title}{explicit}",
|
||||
RestrictCharacters: false,
|
||||
TruncateTo: 120,
|
||||
},
|
||||
LastFM: LastFMConfig{
|
||||
Source: "qobuz",
|
||||
},
|
||||
CLI: CLIConfig{
|
||||
TextOutput: true,
|
||||
ProgressBars: true,
|
||||
MaxSearchResults: 100,
|
||||
},
|
||||
Misc: MiscConfig{
|
||||
Version: CurrentConfigVersion,
|
||||
CheckForUpdates: true,
|
||||
},
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
func resolvePath(path string) (string, error) {
|
||||
if path != "" {
|
||||
return path, os.MkdirAll(filepath.Dir(path), 0o755)
|
||||
}
|
||||
appDir := defaultAppDir()
|
||||
if err := os.MkdirAll(appDir, 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(appDir, "config.toml"), nil
|
||||
}
|
||||
|
||||
func defaultAppDir() string {
|
||||
base, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
return "."
|
||||
}
|
||||
return filepath.Join(base, "streamrip")
|
||||
}
|
||||
|
||||
func saveConfigData(path string, data ConfigData) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
b, err := toml.Marshal(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, b, 0o644)
|
||||
}
|
||||
|
||||
func applyRuntimeDefaults(data *ConfigData) {
|
||||
home, _ := os.UserHomeDir()
|
||||
appDir := defaultAppDir()
|
||||
if data.Downloads.Folder == "" {
|
||||
data.Downloads.Folder = filepath.Join(home, "StreamripDownloads")
|
||||
}
|
||||
if data.Database.DownloadsPath == "" {
|
||||
data.Database.DownloadsPath = filepath.Join(appDir, "downloads.db")
|
||||
}
|
||||
if data.Database.FailedDownloadsPath == "" {
|
||||
data.Database.FailedDownloadsPath = filepath.Join(appDir, "failed_downloads.db")
|
||||
}
|
||||
if data.Youtube.VideoDownloadsFolder == "" {
|
||||
data.Youtube.VideoDownloadsFolder = filepath.Join(data.Downloads.Folder, "YouTubeVideos")
|
||||
}
|
||||
}
|
||||
|
||||
func cloneConfigData(in ConfigData) ConfigData {
|
||||
out := in
|
||||
out.Qobuz.Secrets = append([]string(nil), in.Qobuz.Secrets...)
|
||||
out.Metadata.Exclude = append([]string(nil), in.Metadata.Exclude...)
|
||||
return out
|
||||
}
|
||||
81
internal/config/config_test.go
Normal file
81
internal/config/config_test.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDefaultConfigData(t *testing.T) {
|
||||
data := DefaultConfigData()
|
||||
if data.Misc.Version != CurrentConfigVersion {
|
||||
t.Fatalf("version = %q, want %q", data.Misc.Version, CurrentConfigVersion)
|
||||
}
|
||||
if data.Downloads.Folder == "" {
|
||||
t.Fatalf("downloads folder should not be empty")
|
||||
}
|
||||
if data.Database.DownloadsPath == "" || data.Database.FailedDownloadsPath == "" {
|
||||
t.Fatalf("database paths should not be empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadCreatesDefaultConfigWhenMissing(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
path := filepath.Join(tmpDir, "config.toml")
|
||||
|
||||
cfg, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
|
||||
if cfg.Path != path {
|
||||
t.Fatalf("path = %q, want %q", cfg.Path, path)
|
||||
}
|
||||
if _, err = os.Stat(path); err != nil {
|
||||
t.Fatalf("expected created config file: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadOutdatedConfig(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
path := filepath.Join(tmpDir, "config.toml")
|
||||
|
||||
data := DefaultConfigData()
|
||||
data.Misc.Version = "1.0.0"
|
||||
if err := saveConfigData(path, data); err != nil {
|
||||
t.Fatalf("saveConfigData() error = %v", err)
|
||||
}
|
||||
|
||||
_, err := Load(path)
|
||||
if !errors.Is(err, ErrOutdatedConfig) {
|
||||
t.Fatalf("Load() error = %v, want ErrOutdatedConfig", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionCloneDoesNotAliasSlices(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
path := filepath.Join(tmpDir, "config.toml")
|
||||
|
||||
data := DefaultConfigData()
|
||||
data.Metadata.Exclude = []string{"lyrics"}
|
||||
data.Qobuz.Secrets = []string{"s1"}
|
||||
if err := saveConfigData(path, data); err != nil {
|
||||
t.Fatalf("saveConfigData() error = %v", err)
|
||||
}
|
||||
|
||||
cfg, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
|
||||
cfg.Session.Metadata.Exclude[0] = "comment"
|
||||
cfg.Session.Qobuz.Secrets[0] = "s2"
|
||||
|
||||
if cfg.File.Metadata.Exclude[0] != "lyrics" {
|
||||
t.Fatalf("file metadata exclude unexpectedly mutated")
|
||||
}
|
||||
if cfg.File.Qobuz.Secrets[0] != "s1" {
|
||||
t.Fatalf("file qobuz secrets unexpectedly mutated")
|
||||
}
|
||||
}
|
||||
33
internal/domain/media/media.go
Normal file
33
internal/domain/media/media.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package media
|
||||
|
||||
import "context"
|
||||
|
||||
type Media interface {
|
||||
Rip(ctx context.Context) error
|
||||
}
|
||||
|
||||
type Pending interface {
|
||||
Resolve(ctx context.Context) (Media, error)
|
||||
}
|
||||
|
||||
type MediaFunc struct {
|
||||
RipFn func(ctx context.Context) error
|
||||
}
|
||||
|
||||
func (m MediaFunc) Rip(ctx context.Context) error {
|
||||
if m.RipFn == nil {
|
||||
return nil
|
||||
}
|
||||
return m.RipFn(ctx)
|
||||
}
|
||||
|
||||
type PendingFunc struct {
|
||||
ResolveFn func(ctx context.Context) (Media, error)
|
||||
}
|
||||
|
||||
func (p PendingFunc) Resolve(ctx context.Context) (Media, error) {
|
||||
if p.ResolveFn == nil {
|
||||
return MediaFunc{}, nil
|
||||
}
|
||||
return p.ResolveFn(ctx)
|
||||
}
|
||||
200
internal/download/downloader.go
Normal file
200
internal/download/downloader.go
Normal file
@@ -0,0 +1,200 @@
|
||||
package download
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/vbauerster/mpb/v8"
|
||||
"github.com/vbauerster/mpb/v8/decor"
|
||||
"golang.org/x/term"
|
||||
|
||||
"streamrip-go/internal/netutil"
|
||||
)
|
||||
|
||||
type Downloader struct {
|
||||
http *http.Client
|
||||
showProgress bool
|
||||
progress *mpb.Progress
|
||||
barStarted atomic.Int32
|
||||
}
|
||||
|
||||
func New() *Downloader {
|
||||
return NewWithOptions(true, true)
|
||||
}
|
||||
|
||||
func NewWithVerifySSL(verifySSL bool) *Downloader {
|
||||
return NewWithOptions(verifySSL, true)
|
||||
}
|
||||
|
||||
func NewWithOptions(verifySSL bool, showProgress bool) *Downloader {
|
||||
forceProgress := strings.EqualFold(os.Getenv("STREAMRIP_GO_FORCE_PROGRESS"), "1") || strings.EqualFold(os.Getenv("STREAMRIP_GO_FORCE_PROGRESS"), "true")
|
||||
interactive := showProgress && (forceProgress || (term.IsTerminal(int(os.Stderr.Fd())) && strings.ToLower(os.Getenv("TERM")) != "dumb"))
|
||||
d := &Downloader{http: netutil.NewHTTPClient(2*time.Minute, verifySSL), showProgress: interactive}
|
||||
if interactive {
|
||||
d.progress = mpb.New(mpb.WithWidth(40), mpb.WithOutput(os.Stderr))
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func (d *Downloader) File(ctx context.Context, sourceURL, outputPath string) error {
|
||||
return d.file(ctx, sourceURL, outputPath, true)
|
||||
}
|
||||
|
||||
func (d *Downloader) FileNoProgress(ctx context.Context, sourceURL, outputPath string) error {
|
||||
return d.file(ctx, sourceURL, outputPath, false)
|
||||
}
|
||||
|
||||
func (d *Downloader) file(ctx context.Context, sourceURL, outputPath string, allowProgress bool) error {
|
||||
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)
|
||||
}
|
||||
|
||||
reader := bufio.NewReader(resp.Body)
|
||||
peek, _ := reader.Peek(1024)
|
||||
if isManifestResponse(resp.Header.Get("Content-Type"), peek) {
|
||||
_ = resp.Body.Close()
|
||||
return d.streamManifestWithFFmpeg(ctx, sourceURL, outputPath)
|
||||
}
|
||||
|
||||
out, err := os.Create(outputPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = out.Close() }()
|
||||
|
||||
if d.ProgressEnabled() && allowProgress && resp.ContentLength > 0 {
|
||||
d.barStarted.Store(1)
|
||||
desc := shortenName(filepath.Base(outputPath), 54)
|
||||
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(),
|
||||
)
|
||||
buf := make([]byte, 256*1024)
|
||||
for {
|
||||
n, readErr := reader.Read(buf)
|
||||
if n > 0 {
|
||||
if _, writeErr := out.Write(buf[:n]); writeErr != nil {
|
||||
return writeErr
|
||||
}
|
||||
bar.IncrBy(n)
|
||||
}
|
||||
if readErr != nil {
|
||||
if readErr == io.EOF {
|
||||
break
|
||||
}
|
||||
return readErr
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if _, err = io.Copy(out, reader); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Downloader) Close() {
|
||||
if d.progress != nil {
|
||||
d.progress.Wait()
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Downloader) ProgressEnabled() bool {
|
||||
return d.showProgress && d.progress != nil
|
||||
}
|
||||
|
||||
func (d *Downloader) Logf(format string, args ...any) {
|
||||
msg := fmt.Sprintf(format, args...)
|
||||
if d.ProgressEnabled() && d.barStarted.Load() == 1 {
|
||||
_, _ = d.progress.Write([]byte(msg))
|
||||
return
|
||||
}
|
||||
fmt.Print(msg)
|
||||
}
|
||||
|
||||
func shortenName(name string, max int) string {
|
||||
if max <= 0 {
|
||||
return name
|
||||
}
|
||||
r := []rune(name)
|
||||
if len(r) <= max {
|
||||
return name
|
||||
}
|
||||
if max <= 3 {
|
||||
return string(r[:max])
|
||||
}
|
||||
return string(r[:max-3]) + "..."
|
||||
}
|
||||
|
||||
func (d *Downloader) streamManifestWithFFmpeg(ctx context.Context, sourceURL, outputPath string) error {
|
||||
if _, err := exec.LookPath("ffmpeg"); err != nil {
|
||||
return fmt.Errorf("ffmpeg not found for manifest stream: %w", err)
|
||||
}
|
||||
|
||||
args := []string{
|
||||
"-y",
|
||||
"-protocol_whitelist", "file,http,https,tcp,tls,crypto,data",
|
||||
"-i", sourceURL,
|
||||
"-map", "0:a:0",
|
||||
"-c", "copy",
|
||||
outputPath,
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func isManifestResponse(contentType string, peek []byte) bool {
|
||||
ct := strings.ToLower(contentType)
|
||||
if strings.Contains(ct, "dash+xml") || strings.Contains(ct, "mpegurl") || strings.Contains(ct, "vnd.apple.mpegurl") {
|
||||
return true
|
||||
}
|
||||
s := strings.TrimSpace(strings.ToLower(string(peek)))
|
||||
if strings.HasPrefix(s, "<?xml") && strings.Contains(s, "<mpd") {
|
||||
return true
|
||||
}
|
||||
if strings.HasPrefix(s, "#extm3u") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
46
internal/download/downloader_test.go
Normal file
46
internal/download/downloader_test.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package download
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDownloaderFile(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte("abc123"))
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
d := New()
|
||||
out := filepath.Join(t.TempDir(), "x", "a.bin")
|
||||
if err := d.File(context.Background(), ts.URL, out); err != nil {
|
||||
t.Fatalf("File() error = %v", err)
|
||||
}
|
||||
|
||||
b, err := os.ReadFile(out)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile() error = %v", err)
|
||||
}
|
||||
if string(b) != "abc123" {
|
||||
t.Fatalf("contents = %q, want %q", string(b), "abc123")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManifestDetection(t *testing.T) {
|
||||
if !isManifestResponse("application/dash+xml", []byte("x")) {
|
||||
t.Fatalf("expected dash content-type to be manifest")
|
||||
}
|
||||
if !isManifestResponse("application/octet-stream", []byte("<?xml version='1.0'?><MPD></MPD>")) {
|
||||
t.Fatalf("expected MPD XML body to be manifest")
|
||||
}
|
||||
if !isManifestResponse("text/plain", []byte("#EXTM3U\n#EXT-X-VERSION:3")) {
|
||||
t.Fatalf("expected HLS body to be manifest")
|
||||
}
|
||||
if isManifestResponse("audio/flac", []byte("fLaC")) {
|
||||
t.Fatalf("did not expect flac to be manifest")
|
||||
}
|
||||
}
|
||||
74
internal/naming/naming.go
Normal file
74
internal/naming/naming.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package naming
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
RestrictCharacters bool
|
||||
TruncateTo int
|
||||
}
|
||||
|
||||
var tokenRe = regexp.MustCompile(`\{([a-z_]+)(?::0?(\d+))?\}`)
|
||||
var invalidPathRe = regexp.MustCompile(`[<>:"/\\|?*\x00-\x1F]`)
|
||||
|
||||
func FormatTemplate(template string, values map[string]string) string {
|
||||
return tokenRe.ReplaceAllStringFunc(template, func(m string) string {
|
||||
groups := tokenRe.FindStringSubmatch(m)
|
||||
if len(groups) < 2 {
|
||||
return m
|
||||
}
|
||||
key := groups[1]
|
||||
val := values[key]
|
||||
if len(groups) >= 3 && groups[2] != "" {
|
||||
if n, err := strconv.Atoi(val); err == nil {
|
||||
if width, widthErr := strconv.Atoi(groups[2]); widthErr == nil {
|
||||
return fmt.Sprintf("%0*d", width, n)
|
||||
}
|
||||
}
|
||||
}
|
||||
return val
|
||||
})
|
||||
}
|
||||
|
||||
func CleanName(in string, cfg Config) string {
|
||||
s := strings.TrimSpace(in)
|
||||
s = invalidPathRe.ReplaceAllString(s, "_")
|
||||
if cfg.RestrictCharacters {
|
||||
r := make([]rune, 0, len(s))
|
||||
for _, ch := range s {
|
||||
if ch >= 32 && ch <= 126 {
|
||||
r = append(r, ch)
|
||||
}
|
||||
}
|
||||
s = string(r)
|
||||
}
|
||||
if cfg.TruncateTo > 0 {
|
||||
runes := []rune(s)
|
||||
if len(runes) > cfg.TruncateTo {
|
||||
s = string(runes[:cfg.TruncateTo])
|
||||
}
|
||||
}
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return "Unknown"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func YearFromDate(date string) string {
|
||||
if len(date) >= 4 {
|
||||
prefix := date[:4]
|
||||
for _, ch := range prefix {
|
||||
if !unicode.IsDigit(ch) {
|
||||
return "Unknown"
|
||||
}
|
||||
}
|
||||
return prefix
|
||||
}
|
||||
return "Unknown"
|
||||
}
|
||||
22
internal/naming/naming_test.go
Normal file
22
internal/naming/naming_test.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package naming
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestFormatTemplate(t *testing.T) {
|
||||
got := FormatTemplate("{tracknumber:02}. {artist} - {title}{explicit}", map[string]string{
|
||||
"tracknumber": "3",
|
||||
"artist": "Fleetwood Mac",
|
||||
"title": "Dreams",
|
||||
"explicit": "",
|
||||
})
|
||||
if got != "03. Fleetwood Mac - Dreams" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanName(t *testing.T) {
|
||||
got := CleanName(" Dreams/Live ", Config{RestrictCharacters: false, TruncateTo: 120})
|
||||
if got != "Dreams_Live" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
20
internal/netutil/http.go
Normal file
20
internal/netutil/http.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package netutil
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
func NewHTTPClient(timeout time.Duration, verifySSL bool) *http.Client {
|
||||
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
if transport.TLSClientConfig == nil {
|
||||
transport.TLSClientConfig = &tls.Config{}
|
||||
}
|
||||
transport.TLSClientConfig.InsecureSkipVerify = !verifySSL
|
||||
|
||||
return &http.Client{
|
||||
Timeout: timeout,
|
||||
Transport: transport,
|
||||
}
|
||||
}
|
||||
19
internal/provider/provider.go
Normal file
19
internal/provider/provider.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package provider
|
||||
|
||||
import "context"
|
||||
|
||||
type Downloadable struct {
|
||||
URL string
|
||||
Extension string
|
||||
Source string
|
||||
}
|
||||
|
||||
type Client interface {
|
||||
Source() string
|
||||
Login(ctx context.Context) error
|
||||
LoggedIn() bool
|
||||
GetMetadata(ctx context.Context, item, mediaType string) (map[string]any, error)
|
||||
Search(ctx context.Context, mediaType, query string, limit int) ([]map[string]any, error)
|
||||
GetDownloadable(ctx context.Context, item string, quality int) (*Downloadable, error)
|
||||
Close() error
|
||||
}
|
||||
586
internal/provider/qobuz/client.go
Normal file
586
internal/provider/qobuz/client.go
Normal file
@@ -0,0 +1,586 @@
|
||||
package qobuz
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"streamrip-go/internal/config"
|
||||
"streamrip-go/internal/netutil"
|
||||
"streamrip-go/internal/provider"
|
||||
"streamrip-go/internal/ratelimit"
|
||||
)
|
||||
|
||||
const baseURL = "https://www.qobuz.com/api.json/0.2"
|
||||
|
||||
var (
|
||||
errMissingCredentials = errors.New("missing qobuz credentials")
|
||||
errNotLoggedIn = errors.New("qobuz client not logged in")
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
cfg *config.Config
|
||||
http *http.Client
|
||||
limiter *ratelimit.Limiter
|
||||
baseURL string
|
||||
loggedIn bool
|
||||
secret string
|
||||
uat string
|
||||
}
|
||||
|
||||
func New(cfg *config.Config) *Client {
|
||||
return &Client{
|
||||
cfg: cfg,
|
||||
http: netutil.NewHTTPClient(30*time.Second, cfg.Session.Downloads.VerifySSL),
|
||||
limiter: ratelimit.New(cfg.Session.Downloads.RequestsPerMinute),
|
||||
baseURL: baseURL,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) Source() string {
|
||||
return "qobuz"
|
||||
}
|
||||
|
||||
func (c *Client) LoggedIn() bool {
|
||||
return c.loggedIn
|
||||
}
|
||||
|
||||
func (c *Client) Login(ctx context.Context) error {
|
||||
q := &c.cfg.Session.Qobuz
|
||||
if q.EmailOrUserID == "" || q.PasswordOrToken == "" {
|
||||
return errMissingCredentials
|
||||
}
|
||||
|
||||
if q.AppID == "" || len(q.Secrets) == 0 {
|
||||
appID, secrets, err := c.fetchAppIDAndSecrets(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
q.AppID = appID
|
||||
q.Secrets = secrets
|
||||
c.cfg.File.Qobuz.AppID = appID
|
||||
c.cfg.File.Qobuz.Secrets = append([]string(nil), secrets...)
|
||||
_ = c.cfg.SaveFile()
|
||||
}
|
||||
|
||||
headers := map[string]string{"X-App-Id": q.AppID}
|
||||
params := url.Values{}
|
||||
params.Set("app_id", q.AppID)
|
||||
if q.UseAuthToken {
|
||||
params.Set("user_id", q.EmailOrUserID)
|
||||
params.Set("user_auth_token", q.PasswordOrToken)
|
||||
} else {
|
||||
params.Set("email", q.EmailOrUserID)
|
||||
params.Set("password", q.PasswordOrToken)
|
||||
}
|
||||
|
||||
resp, status, err := c.apiRequest(ctx, "user/login", params, headers)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
return fmt.Errorf("qobuz login failed: status=%d body=%v", status, resp)
|
||||
}
|
||||
|
||||
uat, _ := resp["user_auth_token"].(string)
|
||||
if uat == "" {
|
||||
return fmt.Errorf("qobuz login missing user_auth_token")
|
||||
}
|
||||
|
||||
headers["X-User-Auth-Token"] = uat
|
||||
validSecret, err := c.getValidSecret(ctx, q.Secrets, headers)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.secret = validSecret
|
||||
c.uat = uat
|
||||
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
|
||||
}
|
||||
if mediaType == "playlist" {
|
||||
return c.getPlaylist(ctx, item)
|
||||
}
|
||||
if mediaType == "label" {
|
||||
return c.getLabel(ctx, item)
|
||||
}
|
||||
|
||||
params := url.Values{}
|
||||
params.Set("app_id", c.cfg.Session.Qobuz.AppID)
|
||||
params.Set(mediaType+"_id", item)
|
||||
params.Set("limit", "500")
|
||||
params.Set("offset", "0")
|
||||
|
||||
switch mediaType {
|
||||
case "artist":
|
||||
params.Set("extra", "albums")
|
||||
case "playlist":
|
||||
params.Set("extra", "tracks")
|
||||
case "label":
|
||||
params.Set("extra", "albums")
|
||||
}
|
||||
|
||||
resp, status, err := c.apiRequest(ctx, mediaType+"/get", params, c.authHeaders())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
msg, _ := resp["message"].(string)
|
||||
if msg == "" {
|
||||
msg = "non-streamable"
|
||||
}
|
||||
return nil, fmt.Errorf("metadata error: %s", msg)
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (c *Client) GetTrackMetadata(ctx context.Context, id string) (*TrackMetadata, error) {
|
||||
raw, err := c.GetMetadata(ctx, id, "track")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ParseTrackMetadata(raw)
|
||||
}
|
||||
|
||||
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 = 100
|
||||
}
|
||||
|
||||
params := url.Values{}
|
||||
params.Set("query", query)
|
||||
params.Set("limit", strconv.Itoa(limit))
|
||||
|
||||
resp, status, err := c.apiRequest(ctx, mediaType+"/search", params, c.authHeaders())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
return nil, fmt.Errorf("search failed: status=%d", status)
|
||||
}
|
||||
|
||||
return []map[string]any{resp}, nil
|
||||
}
|
||||
|
||||
func (c *Client) GetDownloadable(ctx context.Context, item string, quality int) (*provider.Downloadable, error) {
|
||||
if !c.loggedIn {
|
||||
return nil, errNotLoggedIn
|
||||
}
|
||||
if quality < 1 || quality > 4 {
|
||||
quality = c.cfg.Session.Qobuz.Quality
|
||||
}
|
||||
|
||||
formatID := qualityMap(quality)
|
||||
requestTS := strconv.FormatInt(time.Now().Unix(), 10)
|
||||
sigRaw := "trackgetFileUrlformat_id" + strconv.Itoa(formatID) + "intentstreamtrack_id" + item + requestTS + c.secret
|
||||
hash := md5.Sum([]byte(sigRaw))
|
||||
requestSig := hex.EncodeToString(hash[:])
|
||||
|
||||
params := url.Values{}
|
||||
params.Set("request_ts", requestTS)
|
||||
params.Set("request_sig", requestSig)
|
||||
params.Set("track_id", item)
|
||||
params.Set("format_id", strconv.Itoa(formatID))
|
||||
params.Set("intent", "stream")
|
||||
|
||||
resp, status, err := c.apiRequest(ctx, "track/getFileUrl", params, c.authHeaders())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
return nil, fmt.Errorf("downloadable lookup failed: status=%d body=%v", status, resp)
|
||||
}
|
||||
|
||||
streamURL, _ := resp["url"].(string)
|
||||
if streamURL == "" {
|
||||
return nil, fmt.Errorf("track is not streamable")
|
||||
}
|
||||
|
||||
ext := "mp3"
|
||||
if quality > 1 {
|
||||
ext = "flac"
|
||||
}
|
||||
|
||||
return &provider.Downloadable{
|
||||
URL: streamURL,
|
||||
Extension: ext,
|
||||
Source: "qobuz",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Client) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) getPlaylist(ctx context.Context, playlistID string) (map[string]any, error) {
|
||||
pageLimit := 500
|
||||
params := url.Values{}
|
||||
params.Set("app_id", c.cfg.Session.Qobuz.AppID)
|
||||
params.Set("playlist_id", playlistID)
|
||||
params.Set("limit", strconv.Itoa(pageLimit))
|
||||
params.Set("offset", "0")
|
||||
params.Set("extra", "tracks")
|
||||
|
||||
resp, status, err := c.apiRequest(ctx, "playlist/get", params, c.authHeaders())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
return nil, fmt.Errorf("playlist/get failed: status=%d", status)
|
||||
}
|
||||
|
||||
total, _ := intValue(resp["tracks_count"])
|
||||
if total <= pageLimit {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
tracksObj, ok := mapValue(resp["tracks"])
|
||||
if !ok {
|
||||
return resp, nil
|
||||
}
|
||||
items, ok := tracksObj["items"].([]any)
|
||||
if !ok {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
for offset := pageLimit; offset < total; offset += pageLimit {
|
||||
pageParams := url.Values{}
|
||||
pageParams.Set("app_id", c.cfg.Session.Qobuz.AppID)
|
||||
pageParams.Set("playlist_id", playlistID)
|
||||
pageParams.Set("limit", strconv.Itoa(pageLimit))
|
||||
pageParams.Set("offset", strconv.Itoa(offset))
|
||||
pageParams.Set("extra", "tracks")
|
||||
|
||||
pageResp, pageStatus, pageErr := c.apiRequest(ctx, "playlist/get", pageParams, c.authHeaders())
|
||||
if pageErr != nil {
|
||||
return nil, pageErr
|
||||
}
|
||||
if pageStatus != http.StatusOK {
|
||||
return nil, fmt.Errorf("playlist/get pagination failed: status=%d offset=%d", pageStatus, offset)
|
||||
}
|
||||
pageTracks, ok := mapValue(pageResp["tracks"])
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
pageItems, ok := pageTracks["items"].([]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
items = append(items, pageItems...)
|
||||
}
|
||||
|
||||
tracksObj["items"] = items
|
||||
resp["tracks"] = tracksObj
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (c *Client) getLabel(ctx context.Context, labelID string) (map[string]any, error) {
|
||||
pageLimit := 500
|
||||
params := url.Values{}
|
||||
params.Set("app_id", c.cfg.Session.Qobuz.AppID)
|
||||
params.Set("label_id", labelID)
|
||||
params.Set("limit", strconv.Itoa(pageLimit))
|
||||
params.Set("offset", "0")
|
||||
params.Set("extra", "albums")
|
||||
|
||||
resp, status, err := c.apiRequest(ctx, "label/get", params, c.authHeaders())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
return nil, fmt.Errorf("label/get failed: status=%d", status)
|
||||
}
|
||||
|
||||
total, _ := intValue(resp["albums_count"])
|
||||
if total <= pageLimit {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
albumsObj, ok := mapValue(resp["albums"])
|
||||
if !ok {
|
||||
return resp, nil
|
||||
}
|
||||
items, ok := albumsObj["items"].([]any)
|
||||
if !ok {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
for offset := pageLimit; offset < total; offset += pageLimit {
|
||||
pageParams := url.Values{}
|
||||
pageParams.Set("app_id", c.cfg.Session.Qobuz.AppID)
|
||||
pageParams.Set("label_id", labelID)
|
||||
pageParams.Set("limit", strconv.Itoa(pageLimit))
|
||||
pageParams.Set("offset", strconv.Itoa(offset))
|
||||
pageParams.Set("extra", "albums")
|
||||
|
||||
pageResp, pageStatus, pageErr := c.apiRequest(ctx, "label/get", pageParams, c.authHeaders())
|
||||
if pageErr != nil {
|
||||
return nil, pageErr
|
||||
}
|
||||
if pageStatus != http.StatusOK {
|
||||
return nil, fmt.Errorf("label/get pagination failed: status=%d offset=%d", pageStatus, offset)
|
||||
}
|
||||
pageAlbums, ok := mapValue(pageResp["albums"])
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
pageItems, ok := pageAlbums["items"].([]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
items = append(items, pageItems...)
|
||||
}
|
||||
|
||||
albumsObj["items"] = items
|
||||
resp["albums"] = albumsObj
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (c *Client) authHeaders() map[string]string {
|
||||
headers := map[string]string{"X-App-Id": c.cfg.Session.Qobuz.AppID}
|
||||
if c.uat != "" {
|
||||
headers["X-User-Auth-Token"] = c.uat
|
||||
} else if c.cfg.Session.Qobuz.PasswordOrToken != "" && c.cfg.Session.Qobuz.UseAuthToken {
|
||||
headers["X-User-Auth-Token"] = c.cfg.Session.Qobuz.PasswordOrToken
|
||||
}
|
||||
return headers
|
||||
}
|
||||
|
||||
func (c *Client) getValidSecret(ctx context.Context, secrets []string, headers map[string]string) (string, error) {
|
||||
type candidate struct {
|
||||
secret string
|
||||
valid bool
|
||||
}
|
||||
|
||||
results := make([]candidate, 0, len(secrets))
|
||||
for _, secret := range secrets {
|
||||
ok := c.testSecret(ctx, secret, headers)
|
||||
results = append(results, candidate{secret: secret, valid: ok})
|
||||
}
|
||||
|
||||
for _, result := range results {
|
||||
if result.valid {
|
||||
return result.secret, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("no valid qobuz app secret")
|
||||
}
|
||||
|
||||
func (c *Client) testSecret(ctx context.Context, secret string, headers map[string]string) bool {
|
||||
formatID := qualityMap(4)
|
||||
requestTS := strconv.FormatInt(time.Now().Unix(), 10)
|
||||
sigRaw := "trackgetFileUrlformat_id" + strconv.Itoa(formatID) + "intentstreamtrack_id19512574" + requestTS + secret
|
||||
hash := md5.Sum([]byte(sigRaw))
|
||||
|
||||
params := url.Values{}
|
||||
params.Set("request_ts", requestTS)
|
||||
params.Set("request_sig", hex.EncodeToString(hash[:]))
|
||||
params.Set("track_id", "19512574")
|
||||
params.Set("format_id", strconv.Itoa(formatID))
|
||||
params.Set("intent", "stream")
|
||||
|
||||
_, status, err := c.apiRequest(ctx, "track/getFileUrl", params, headers)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return status == http.StatusOK || status == http.StatusUnauthorized
|
||||
}
|
||||
|
||||
func (c *Client) apiRequest(ctx context.Context, endpoint string, params url.Values, headers map[string]string) (map[string]any, int, error) {
|
||||
if err := c.limiter.Wait(ctx); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
reqURL := baseURL + "/" + endpoint
|
||||
if c.baseURL != "" {
|
||||
reqURL = c.baseURL + "/" + endpoint
|
||||
}
|
||||
if len(params) > 0 {
|
||||
reqURL += "?" + params.Encode()
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
req.Header.Set("User-Agent", "streamrip-go/0.1")
|
||||
|
||||
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 qualityMap(quality int) int {
|
||||
mapVals := []int{5, 6, 7, 27}
|
||||
if quality < 1 || quality > 4 {
|
||||
return mapVals[2]
|
||||
}
|
||||
return mapVals[quality-1]
|
||||
}
|
||||
|
||||
func (c *Client) fetchAppIDAndSecrets(ctx context.Context) (string, []string, error) {
|
||||
loginReq, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://play.qobuz.com/login", nil)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
loginResp, err := c.http.Do(loginReq)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
defer func() { _ = loginResp.Body.Close() }()
|
||||
|
||||
loginBody, err := io.ReadAll(loginResp.Body)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
bundleRe := regexp.MustCompile(`<script src="(/resources/\d+\.\d+\.\d+-[a-z]\d{3}/bundle\.js)"></script>`)
|
||||
bundleMatch := bundleRe.FindStringSubmatch(string(loginBody))
|
||||
if len(bundleMatch) < 2 {
|
||||
return "", nil, fmt.Errorf("could not find qobuz bundle js")
|
||||
}
|
||||
|
||||
bundleURL := "https://play.qobuz.com" + bundleMatch[1]
|
||||
bundleReq, err := http.NewRequestWithContext(ctx, http.MethodGet, bundleURL, nil)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
bundleResp, err := c.http.Do(bundleReq)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
defer func() { _ = bundleResp.Body.Close() }()
|
||||
bundleBody, err := io.ReadAll(bundleResp.Body)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
bundle := string(bundleBody)
|
||||
appIDRe := regexp.MustCompile(`production:{api:{appId:"(?P<app_id>\d{9})",appSecret:"(\w{32})`)
|
||||
appIDMatch := appIDRe.FindStringSubmatch(bundle)
|
||||
if len(appIDMatch) < 2 {
|
||||
return "", nil, fmt.Errorf("could not parse qobuz app id")
|
||||
}
|
||||
appID := appIDMatch[1]
|
||||
|
||||
seedTZRe := regexp.MustCompile(`[a-z]\.initialSeed\("(?P<seed>[\w=]+)",window\.utimezone\.(?P<timezone>[a-z]+)\)`)
|
||||
infoExtrasTemplate := `name:"\w+/(?P<timezone>%s)",info:"(?P<info>[\w=]+)",extras:"(?P<extras>[\w=]+)"`
|
||||
|
||||
type seedParts struct {
|
||||
timezone string
|
||||
parts []string
|
||||
}
|
||||
|
||||
matches := seedTZRe.FindAllStringSubmatch(bundle, -1)
|
||||
idxSeed := seedTZRe.SubexpIndex("seed")
|
||||
idxTZ := seedTZRe.SubexpIndex("timezone")
|
||||
if len(matches) < 2 {
|
||||
return appID, nil, fmt.Errorf("could not parse qobuz secrets seeds")
|
||||
}
|
||||
|
||||
ordered := make([]seedParts, 0, len(matches))
|
||||
seen := map[string]bool{}
|
||||
for _, m := range matches {
|
||||
tz := m[idxTZ]
|
||||
seed := m[idxSeed]
|
||||
if !seen[tz] {
|
||||
ordered = append(ordered, seedParts{timezone: tz, parts: []string{seed}})
|
||||
seen[tz] = true
|
||||
}
|
||||
}
|
||||
if len(ordered) >= 2 {
|
||||
ordered[0], ordered[1] = ordered[1], ordered[0]
|
||||
}
|
||||
|
||||
tzNames := make([]string, 0, len(ordered))
|
||||
for _, o := range ordered {
|
||||
tzNames = append(tzNames, strings.Title(o.timezone))
|
||||
}
|
||||
infoRe := regexp.MustCompile(fmt.Sprintf(infoExtrasTemplate, strings.Join(tzNames, "|")))
|
||||
idxInfo := infoRe.SubexpIndex("info")
|
||||
idxExtras := infoRe.SubexpIndex("extras")
|
||||
idxInfoTZ := infoRe.SubexpIndex("timezone")
|
||||
|
||||
byTZ := map[string][]string{}
|
||||
for _, o := range ordered {
|
||||
byTZ[o.timezone] = append([]string(nil), o.parts...)
|
||||
}
|
||||
|
||||
for _, m := range infoRe.FindAllStringSubmatch(bundle, -1) {
|
||||
tz := strings.ToLower(m[idxInfoTZ])
|
||||
byTZ[tz] = append(byTZ[tz], m[idxInfo], m[idxExtras])
|
||||
}
|
||||
|
||||
final := make([]string, 0, len(byTZ))
|
||||
for _, tz := range sortedKeys(byTZ) {
|
||||
joined := strings.Join(byTZ[tz], "")
|
||||
if len(joined) < 44 {
|
||||
continue
|
||||
}
|
||||
dec, err := base64.StdEncoding.DecodeString(joined[:len(joined)-44])
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
secret := string(dec)
|
||||
if secret != "" {
|
||||
final = append(final, secret)
|
||||
}
|
||||
}
|
||||
|
||||
if len(final) == 0 {
|
||||
return appID, nil, fmt.Errorf("could not decode qobuz secrets")
|
||||
}
|
||||
|
||||
return appID, final, nil
|
||||
}
|
||||
|
||||
func sortedKeys(m map[string][]string) []string {
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
174
internal/provider/qobuz/client_test.go
Normal file
174
internal/provider/qobuz/client_test.go
Normal file
@@ -0,0 +1,174 @@
|
||||
package qobuz
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"streamrip-go/internal/config"
|
||||
)
|
||||
|
||||
func TestQualityMap(t *testing.T) {
|
||||
tests := []struct {
|
||||
in int
|
||||
want int
|
||||
}{
|
||||
{1, 5},
|
||||
{2, 6},
|
||||
{3, 7},
|
||||
{4, 27},
|
||||
{0, 7},
|
||||
{99, 7},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
got := qualityMap(tt.in)
|
||||
if got != tt.want {
|
||||
t.Fatalf("qualityMap(%d)=%d want %d", tt.in, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTrackMetadata(t *testing.T) {
|
||||
resp := map[string]any{
|
||||
"id": "19512574",
|
||||
"title": "Dreams",
|
||||
"version": "Remastered",
|
||||
"track_number": float64(2),
|
||||
"media_number": float64(1),
|
||||
"parental_warning": false,
|
||||
"maximum_bit_depth": float64(24),
|
||||
"maximum_sampling_rate": float64(96),
|
||||
"performer": map[string]any{
|
||||
"name": "Fleetwood Mac",
|
||||
},
|
||||
"album": map[string]any{
|
||||
"title": "Rumours",
|
||||
},
|
||||
}
|
||||
|
||||
m, err := ParseTrackMetadata(resp)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseTrackMetadata() error = %v", err)
|
||||
}
|
||||
if m.ID != "19512574" || m.Title != "Dreams" || m.Album != "Rumours" || m.Artist != "Fleetwood Mac" {
|
||||
t.Fatalf("unexpected metadata: %+v", m)
|
||||
}
|
||||
if m.Quality != 3 {
|
||||
t.Fatalf("quality = %d, want 3", m.Quality)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPlaylistPagination(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{
|
||||
"tracks_count": 1200,
|
||||
"tracks": map[string]any{"items": makeItems(0, 500)},
|
||||
}
|
||||
case "500":
|
||||
resp = map[string]any{"tracks": map[string]any{"items": makeItems(500, 1000)}}
|
||||
case "1000":
|
||||
resp = map[string]any{"tracks": map[string]any{"items": makeItems(1000, 1200)}}
|
||||
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(), "playlist-id", "playlist")
|
||||
if err != nil {
|
||||
t.Fatalf("GetMetadata() error = %v", err)
|
||||
}
|
||||
tracks, ok := mapValue(raw["tracks"])
|
||||
if !ok {
|
||||
t.Fatalf("tracks missing")
|
||||
}
|
||||
items, ok := tracks["items"].([]any)
|
||||
if !ok {
|
||||
t.Fatalf("items missing")
|
||||
}
|
||||
if len(items) != 1200 {
|
||||
t.Fatalf("len(items) = %d, want 1200", len(items))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLabelPagination(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": 700,
|
||||
"albums": map[string]any{"items": makeItems(0, 500)},
|
||||
}
|
||||
case "500":
|
||||
resp = map[string]any{"albums": map[string]any{"items": makeItems(500, 700)}}
|
||||
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(), "label-id", "label")
|
||||
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) != 700 {
|
||||
t.Fatalf("len(items) = %d, want 700", len(items))
|
||||
}
|
||||
}
|
||||
|
||||
func newTestClient(t *testing.T) *Client {
|
||||
t.Helper()
|
||||
d := config.DefaultConfigData()
|
||||
d.Qobuz.AppID = "12345"
|
||||
cfg := &config.Config{File: d, Session: d}
|
||||
return New(cfg)
|
||||
}
|
||||
|
||||
func makeItems(start, end int) []map[string]any {
|
||||
items := make([]map[string]any, 0, end-start)
|
||||
for i := start; i < end; i++ {
|
||||
items = append(items, map[string]any{"id": i})
|
||||
}
|
||||
return items
|
||||
}
|
||||
102
internal/provider/qobuz/model.go
Normal file
102
internal/provider/qobuz/model.go
Normal file
@@ -0,0 +1,102 @@
|
||||
package qobuz
|
||||
|
||||
import "fmt"
|
||||
|
||||
type TrackMetadata struct {
|
||||
ID string
|
||||
Title string
|
||||
Version string
|
||||
Artist string
|
||||
Album string
|
||||
TrackNumber int
|
||||
DiscNumber int
|
||||
Explicit bool
|
||||
BitDepth int
|
||||
SamplingRate float64
|
||||
Quality int
|
||||
}
|
||||
|
||||
func ParseTrackMetadata(resp map[string]any) (*TrackMetadata, error) {
|
||||
id, ok := stringValue(resp["id"])
|
||||
if !ok || id == "" {
|
||||
return nil, fmt.Errorf("missing track id")
|
||||
}
|
||||
|
||||
title, _ := stringValue(resp["title"])
|
||||
version, _ := stringValue(resp["version"])
|
||||
trackNumber, _ := intValue(resp["track_number"])
|
||||
discNumber, _ := intValue(resp["media_number"])
|
||||
explicit, _ := boolValue(resp["parental_warning"])
|
||||
|
||||
performer, _ := mapValue(resp["performer"])
|
||||
artist, _ := stringValue(performer["name"])
|
||||
|
||||
albumObj, _ := mapValue(resp["album"])
|
||||
album, _ := stringValue(albumObj["title"])
|
||||
|
||||
bitDepth, _ := intValue(resp["maximum_bit_depth"])
|
||||
samplingRate, _ := floatValue(resp["maximum_sampling_rate"])
|
||||
quality := qualityFrom(bitDepth, samplingRate)
|
||||
|
||||
return &TrackMetadata{
|
||||
ID: id,
|
||||
Title: title,
|
||||
Version: version,
|
||||
Artist: artist,
|
||||
Album: album,
|
||||
TrackNumber: trackNumber,
|
||||
DiscNumber: discNumber,
|
||||
Explicit: explicit,
|
||||
BitDepth: bitDepth,
|
||||
SamplingRate: samplingRate,
|
||||
Quality: quality,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func qualityFrom(bitDepth int, samplingRate float64) int {
|
||||
if bitDepth >= 24 {
|
||||
if samplingRate > 96 {
|
||||
return 4
|
||||
}
|
||||
return 3
|
||||
}
|
||||
if bitDepth >= 16 {
|
||||
return 2
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
func stringValue(v any) (string, bool) {
|
||||
s, ok := v.(string)
|
||||
return s, ok
|
||||
}
|
||||
|
||||
func mapValue(v any) (map[string]any, bool) {
|
||||
m, ok := v.(map[string]any)
|
||||
return m, ok
|
||||
}
|
||||
|
||||
func intValue(v any) (int, bool) {
|
||||
switch t := v.(type) {
|
||||
case int:
|
||||
return t, true
|
||||
case int32:
|
||||
return int(t), true
|
||||
case int64:
|
||||
return int(t), true
|
||||
case float64:
|
||||
return int(t), true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
func floatValue(v any) (float64, bool) {
|
||||
f, ok := v.(float64)
|
||||
return f, ok
|
||||
}
|
||||
|
||||
func boolValue(v any) (bool, bool) {
|
||||
b, ok := v.(bool)
|
||||
return b, ok
|
||||
}
|
||||
549
internal/provider/tidal/client.go
Normal file
549
internal/provider/tidal/client.go
Normal file
@@ -0,0 +1,549 @@
|
||||
package tidal
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"streamrip-go/internal/config"
|
||||
"streamrip-go/internal/netutil"
|
||||
"streamrip-go/internal/provider"
|
||||
"streamrip-go/internal/ratelimit"
|
||||
)
|
||||
|
||||
const (
|
||||
baseURL = "https://api.tidalhifi.com/v1"
|
||||
openAPIV2 = "https://openapi.tidal.com/v2"
|
||||
authURL = "https://auth.tidal.com/v1/oauth2"
|
||||
clientID = "fX2JxdmntZWK0ixT"
|
||||
clientSec = "1Nm5AfDAjxrgJFJbKNWLeAyKGVGmINuXPPLHVXAvxAg="
|
||||
)
|
||||
|
||||
var qualityMap = map[int]string{
|
||||
0: "LOW",
|
||||
1: "HIGH",
|
||||
2: "LOSSLESS",
|
||||
3: "HI_RES",
|
||||
4: "HI_RES_LOSSLESS",
|
||||
}
|
||||
|
||||
var qualityToFormat = map[int]string{
|
||||
0: "HEAACV1",
|
||||
1: "AACLC",
|
||||
2: "FLAC",
|
||||
3: "FLAC_HIRES",
|
||||
4: "FLAC_HIRES",
|
||||
}
|
||||
|
||||
var ErrMissingTidalToken = errors.New("missing tidal access_token")
|
||||
|
||||
type Client struct {
|
||||
cfg *config.Config
|
||||
http *http.Client
|
||||
limiter *ratelimit.Limiter
|
||||
baseURL string
|
||||
loggedIn bool
|
||||
}
|
||||
|
||||
func New(cfg *config.Config) *Client {
|
||||
return &Client{
|
||||
cfg: cfg,
|
||||
http: netutil.NewHTTPClient(30*time.Second, cfg.Session.Downloads.VerifySSL),
|
||||
limiter: ratelimit.New(cfg.Session.Downloads.RequestsPerMinute),
|
||||
baseURL: baseURL,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) Source() string {
|
||||
return "tidal"
|
||||
}
|
||||
|
||||
func (c *Client) LoggedIn() bool {
|
||||
return c.loggedIn
|
||||
}
|
||||
|
||||
func (c *Client) Login(ctx context.Context) error {
|
||||
if strings.TrimSpace(c.cfg.Session.Tidal.AccessToken) == "" {
|
||||
return ErrMissingTidalToken
|
||||
}
|
||||
if strings.TrimSpace(c.cfg.Session.Tidal.CountryCode) == "" {
|
||||
c.cfg.Session.Tidal.CountryCode = "US"
|
||||
}
|
||||
|
||||
if c.tokenNeedsRefresh() {
|
||||
if err := c.refreshAccessToken(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
resp, status, err := c.apiRequest(ctx, "sessions", url.Values{}, c.baseURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if status == http.StatusUnauthorized && strings.TrimSpace(c.cfg.Session.Tidal.RefreshToken) != "" {
|
||||
if err = c.refreshAccessToken(ctx); err != nil {
|
||||
return fmt.Errorf("tidal login failed and refresh failed: %w", err)
|
||||
}
|
||||
resp, status, err = c.apiRequest(ctx, "sessions", url.Values{}, c.baseURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
return fmt.Errorf("tidal login failed: status=%d body=%v", status, resp)
|
||||
}
|
||||
|
||||
if v := stringify(resp["countryCode"]); v != "" {
|
||||
c.cfg.Session.Tidal.CountryCode = v
|
||||
}
|
||||
if v := stringify(resp["userId"]); v != "" {
|
||||
c.cfg.Session.Tidal.UserID = v
|
||||
}
|
||||
|
||||
c.loggedIn = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) tokenNeedsRefresh() bool {
|
||||
expiry := c.cfg.Session.Tidal.TokenExpiry
|
||||
if expiry <= 0 {
|
||||
return false
|
||||
}
|
||||
return time.Until(time.Unix(expiry, 0)) < 24*time.Hour
|
||||
}
|
||||
|
||||
func (c *Client) refreshAccessToken(ctx context.Context) error {
|
||||
refresh := strings.TrimSpace(c.cfg.Session.Tidal.RefreshToken)
|
||||
if refresh == "" {
|
||||
return errors.New("tidal refresh token missing")
|
||||
}
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("client_id", clientID)
|
||||
form.Set("refresh_token", refresh)
|
||||
form.Set("grant_type", "refresh_token")
|
||||
form.Set("scope", "r_usr+w_usr+w_sub")
|
||||
|
||||
resp, status, err := c.apiPost(ctx, authURL+"/token", form, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
return fmt.Errorf("tidal token refresh failed: status=%d body=%v", status, resp)
|
||||
}
|
||||
|
||||
newToken := stringify(resp["access_token"])
|
||||
if newToken == "" {
|
||||
return errors.New("tidal token refresh missing access_token")
|
||||
}
|
||||
|
||||
newRefresh := stringify(resp["refresh_token"])
|
||||
expiresIn := int64(intFromAny(resp["expires_in"]))
|
||||
if expiresIn <= 0 {
|
||||
expiresIn = 7 * 24 * 3600
|
||||
}
|
||||
|
||||
c.cfg.Session.Tidal.AccessToken = newToken
|
||||
c.cfg.File.Tidal.AccessToken = newToken
|
||||
if newRefresh != "" {
|
||||
c.cfg.Session.Tidal.RefreshToken = newRefresh
|
||||
c.cfg.File.Tidal.RefreshToken = newRefresh
|
||||
}
|
||||
expiry := time.Now().Unix() + expiresIn
|
||||
c.cfg.Session.Tidal.TokenExpiry = expiry
|
||||
c.cfg.File.Tidal.TokenExpiry = expiry
|
||||
_ = c.cfg.SaveFile()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) GetMetadata(ctx context.Context, item, mediaType string) (map[string]any, error) {
|
||||
if !c.loggedIn {
|
||||
return nil, errors.New("tidal client not logged in")
|
||||
}
|
||||
|
||||
path := mediaType + "s/" + item
|
||||
resp, status, err := c.apiRequest(ctx, path, url.Values{}, c.baseURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
return nil, fmt.Errorf("tidal metadata failed: status=%d", status)
|
||||
}
|
||||
|
||||
if mediaType == "album" || mediaType == "playlist" {
|
||||
itemsResp, itemErr := c.fetchAllItems(ctx, path+"/items")
|
||||
if itemErr != nil {
|
||||
return nil, fmt.Errorf("tidal fetch %s items failed: %w", mediaType, itemErr)
|
||||
}
|
||||
resp["tracks"] = map[string]any{"items": itemsResp}
|
||||
}
|
||||
|
||||
if mediaType == "artist" {
|
||||
albums, err := c.fetchArtistAlbums(ctx, item)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp["albums"] = map[string]any{"items": albums}
|
||||
}
|
||||
|
||||
enrichTidalImage(resp)
|
||||
if mediaType == "track" {
|
||||
if album, ok := resp["album"].(map[string]any); ok {
|
||||
enrichTidalImage(album)
|
||||
}
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (c *Client) Search(ctx context.Context, mediaType, query string, limit int) ([]map[string]any, error) {
|
||||
if !c.loggedIn {
|
||||
return nil, errors.New("tidal client not logged in")
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = 25
|
||||
}
|
||||
|
||||
params := url.Values{}
|
||||
params.Set("query", query)
|
||||
params.Set("limit", strconv.Itoa(limit))
|
||||
|
||||
resp, status, err := c.apiRequest(ctx, "search/"+mediaType+"s", params, c.baseURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
return nil, fmt.Errorf("tidal search failed: status=%d", status)
|
||||
}
|
||||
|
||||
items, ok := resp["items"].([]any)
|
||||
if !ok || len(items) == 0 {
|
||||
return []map[string]any{}, nil
|
||||
}
|
||||
return []map[string]any{resp}, nil
|
||||
}
|
||||
|
||||
func (c *Client) GetDownloadable(ctx context.Context, trackID string, quality int) (*provider.Downloadable, error) {
|
||||
if !c.loggedIn {
|
||||
return nil, errors.New("tidal client not logged in")
|
||||
}
|
||||
if quality < 0 || quality > 4 {
|
||||
quality = c.cfg.Session.Tidal.Quality
|
||||
}
|
||||
|
||||
params := url.Values{}
|
||||
params.Set("audioquality", qualityMap[quality])
|
||||
params.Set("playbackmode", "STREAM")
|
||||
params.Set("assetpresentation", "FULL")
|
||||
|
||||
resp, status, err := c.apiRequest(ctx, "tracks/"+trackID+"/playbackinfopostpaywall", params, c.baseURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if status == http.StatusOK {
|
||||
if d := downloadableFromPlaybackManifest(resp); d != nil {
|
||||
return d, nil
|
||||
}
|
||||
}
|
||||
|
||||
return c.getDownloadableFromTrackManifest(ctx, trackID, quality)
|
||||
}
|
||||
|
||||
func (c *Client) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) fetchAllItems(ctx context.Context, path string) ([]map[string]any, error) {
|
||||
offset := 0
|
||||
all := make([]map[string]any, 0)
|
||||
for {
|
||||
params := url.Values{}
|
||||
params.Set("offset", strconv.Itoa(offset))
|
||||
resp, status, err := c.apiRequest(ctx, path, params, c.baseURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
return nil, fmt.Errorf("tidal items failed: status=%d", status)
|
||||
}
|
||||
itemsRaw, ok := resp["items"].([]any)
|
||||
if !ok || len(itemsRaw) == 0 {
|
||||
break
|
||||
}
|
||||
for _, raw := range itemsRaw {
|
||||
itemMap, ok := raw.(map[string]any)
|
||||
if ok {
|
||||
if wrapped, ok := itemMap["item"].(map[string]any); ok {
|
||||
all = append(all, wrapped)
|
||||
} else {
|
||||
all = append(all, itemMap)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(itemsRaw) < 100 {
|
||||
break
|
||||
}
|
||||
offset += 100
|
||||
}
|
||||
return all, nil
|
||||
}
|
||||
|
||||
func (c *Client) fetchArtistAlbums(ctx context.Context, artistID string) ([]map[string]any, error) {
|
||||
paths := []struct {
|
||||
path string
|
||||
params url.Values
|
||||
}{
|
||||
{path: "artists/" + artistID + "/albums", params: url.Values{}},
|
||||
{path: "artists/" + artistID + "/albums", params: url.Values{"filter": []string{"EPSANDSINGLES"}}},
|
||||
}
|
||||
|
||||
out := make([]map[string]any, 0)
|
||||
seen := map[string]struct{}{}
|
||||
for _, p := range paths {
|
||||
resp, status, err := c.apiRequest(ctx, p.path, p.params, c.baseURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
return nil, fmt.Errorf("tidal artist albums failed: status=%d", status)
|
||||
}
|
||||
items, _ := resp["items"].([]any)
|
||||
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 := stringify(itm["id"])
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
if _, dup := seen[id]; dup {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
out = append(out, itm)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *Client) getDownloadableFromTrackManifest(ctx context.Context, trackID string, quality int) (*provider.Downloadable, error) {
|
||||
format := qualityToFormat[quality]
|
||||
params := url.Values{}
|
||||
params.Set("manifestType", "MPEG_DASH")
|
||||
params.Set("formats", format)
|
||||
params.Set("uriScheme", "HTTPS")
|
||||
params.Set("usage", "PLAYBACK")
|
||||
params.Set("adaptive", "false")
|
||||
|
||||
resp, status, err := c.apiRequest(ctx, "trackManifests/"+trackID, params, openAPIV2)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if status != http.StatusOK {
|
||||
return nil, fmt.Errorf("tidal trackManifests failed: status=%d body=%v", status, resp)
|
||||
}
|
||||
|
||||
data, ok := resp["data"].(map[string]any)
|
||||
if !ok {
|
||||
return nil, errors.New("tidal trackManifests missing data")
|
||||
}
|
||||
attrs, ok := data["attributes"].(map[string]any)
|
||||
if !ok {
|
||||
return nil, errors.New("tidal trackManifests missing attributes")
|
||||
}
|
||||
uri := stringify(attrs["uri"])
|
||||
if uri == "" {
|
||||
return nil, errors.New("tidal trackManifests missing uri")
|
||||
}
|
||||
formats, _ := attrs["formats"].([]any)
|
||||
ext := "m4a"
|
||||
for _, f := range formats {
|
||||
if strings.Contains(strings.ToUpper(stringify(f)), "FLAC") {
|
||||
ext = "flac"
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return &provider.Downloadable{URL: uri, Extension: ext, Source: "tidal"}, nil
|
||||
}
|
||||
|
||||
func downloadableFromPlaybackManifest(resp map[string]any) *provider.Downloadable {
|
||||
manifestB64 := stringify(resp["manifest"])
|
||||
if manifestB64 == "" {
|
||||
return nil
|
||||
}
|
||||
b, err := base64.StdEncoding.DecodeString(manifestB64)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
manifest := map[string]any{}
|
||||
if err = json.Unmarshal(b, &manifest); err != nil {
|
||||
return nil
|
||||
}
|
||||
urls, ok := manifest["urls"].([]any)
|
||||
if !ok || len(urls) == 0 {
|
||||
return nil
|
||||
}
|
||||
streamURL := stringify(urls[0])
|
||||
if streamURL == "" {
|
||||
return nil
|
||||
}
|
||||
codec := strings.ToLower(stringify(manifest["codecs"]))
|
||||
ext := "m4a"
|
||||
if strings.Contains(codec, "flac") {
|
||||
ext = "flac"
|
||||
}
|
||||
return &provider.Downloadable{URL: streamURL, Extension: ext, Source: "tidal"}
|
||||
}
|
||||
|
||||
func (c *Client) apiRequest(ctx context.Context, path string, params url.Values, base string) (map[string]any, int, error) {
|
||||
if err := c.limiter.Wait(ctx); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
if params == nil {
|
||||
params = url.Values{}
|
||||
}
|
||||
if params.Get("countryCode") == "" {
|
||||
params.Set("countryCode", c.cfg.Session.Tidal.CountryCode)
|
||||
}
|
||||
if params.Get("limit") == "" {
|
||||
params.Set("limit", "100")
|
||||
}
|
||||
|
||||
reqURL := strings.TrimSuffix(base, "/") + "/" + strings.TrimPrefix(path, "/")
|
||||
if len(params) > 0 {
|
||||
reqURL += "?" + params.Encode()
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.cfg.Session.Tidal.AccessToken)
|
||||
req.Header.Set("User-Agent", "streamrip-go/0.1")
|
||||
|
||||
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) {
|
||||
if err := c.limiter.Wait(ctx); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewBufferString(form.Encode()))
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("User-Agent", "streamrip-go/0.1")
|
||||
if basicAuth {
|
||||
auth := base64.StdEncoding.EncodeToString([]byte(clientID + ":" + clientSec))
|
||||
req.Header.Set("Authorization", "Basic "+auth)
|
||||
}
|
||||
|
||||
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 {
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
return t
|
||||
case int:
|
||||
return strconv.Itoa(t)
|
||||
case int64:
|
||||
return strconv.FormatInt(t, 10)
|
||||
case float64:
|
||||
return strconv.FormatFloat(t, 'f', -1, 64)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func enrichTidalImage(meta map[string]any) {
|
||||
if _, ok := meta["image"].(map[string]any); ok {
|
||||
return
|
||||
}
|
||||
cover := stringify(meta["cover"])
|
||||
if cover == "" {
|
||||
cover = stringify(meta["squareImage"])
|
||||
}
|
||||
if cover == "" {
|
||||
return
|
||||
}
|
||||
meta["image"] = tidalImageMap(cover)
|
||||
}
|
||||
|
||||
func tidalImageMap(cover string) map[string]any {
|
||||
parts := strings.ReplaceAll(cover, "-", "/")
|
||||
base := "https://resources.tidal.com/images/" + parts
|
||||
return map[string]any{
|
||||
"thumbnail": base + "/80x80.jpg",
|
||||
"small": base + "/160x160.jpg",
|
||||
"large": base + "/640x640.jpg",
|
||||
"extralarge": 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
|
||||
}
|
||||
}
|
||||
52
internal/provider/tidal/client_test.go
Normal file
52
internal/provider/tidal/client_test.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package tidal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"streamrip-go/internal/config"
|
||||
)
|
||||
|
||||
func TestLoginMissingToken(t *testing.T) {
|
||||
cfgData := config.DefaultConfigData()
|
||||
cfgData.Tidal.AccessToken = ""
|
||||
c := New(&config.Config{File: cfgData, Session: cfgData})
|
||||
err := c.Login(context.Background())
|
||||
if err == nil {
|
||||
t.Fatalf("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearch(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/search/albums":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"items": []any{map[string]any{"id": 1, "title": "x"}}})
|
||||
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)
|
||||
}
|
||||
pages, err := c.Search(context.Background(), "album", "x", 10)
|
||||
if err != nil {
|
||||
t.Fatalf("search err = %v", err)
|
||||
}
|
||||
if len(pages) != 1 {
|
||||
t.Fatalf("pages = %d", len(pages))
|
||||
}
|
||||
}
|
||||
54
internal/ratelimit/limiter.go
Normal file
54
internal/ratelimit/limiter.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package ratelimit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Limiter struct {
|
||||
interval time.Duration
|
||||
|
||||
mu sync.Mutex
|
||||
next time.Time
|
||||
}
|
||||
|
||||
func New(requestsPerMinute int) *Limiter {
|
||||
if requestsPerMinute <= 0 {
|
||||
return &Limiter{}
|
||||
}
|
||||
return &Limiter{interval: time.Minute / time.Duration(requestsPerMinute)}
|
||||
}
|
||||
|
||||
func (l *Limiter) Wait(ctx context.Context) error {
|
||||
if l.interval <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
l.mu.Lock()
|
||||
now := time.Now()
|
||||
if l.next.IsZero() {
|
||||
l.next = now.Add(l.interval)
|
||||
l.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
wake := l.next
|
||||
if now.After(wake) {
|
||||
l.next = now.Add(l.interval)
|
||||
l.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
l.next = l.next.Add(l.interval)
|
||||
l.mu.Unlock()
|
||||
|
||||
timer := time.NewTimer(time.Until(wake))
|
||||
defer timer.Stop()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-timer.C:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
28
internal/ratelimit/limiter_test.go
Normal file
28
internal/ratelimit/limiter_test.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package ratelimit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLimiterDisabled(t *testing.T) {
|
||||
l := New(-1)
|
||||
if err := l.Wait(context.Background()); err != nil {
|
||||
t.Fatalf("Wait() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLimiterContextCancel(t *testing.T) {
|
||||
l := New(1)
|
||||
if err := l.Wait(context.Background()); err != nil {
|
||||
t.Fatalf("first Wait() error = %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
if err := l.Wait(ctx); err == nil {
|
||||
t.Fatalf("expected context error")
|
||||
}
|
||||
}
|
||||
89
internal/store/sqlite.go
Normal file
89
internal/store/sqlite.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"sync"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
type SQLite struct {
|
||||
db *sql.DB
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func NewSQLite(path string) (*SQLite, error) {
|
||||
db, err := sql.Open("sqlite", path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s := &SQLite{db: db}
|
||||
if err = s.init(); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *SQLite) init() error {
|
||||
queries := []string{
|
||||
`CREATE TABLE IF NOT EXISTS downloads (id TEXT PRIMARY KEY)`,
|
||||
`CREATE TABLE IF NOT EXISTS failed_downloads (
|
||||
source TEXT NOT NULL,
|
||||
media_type TEXT NOT NULL,
|
||||
id TEXT NOT NULL,
|
||||
PRIMARY KEY (source, media_type, id)
|
||||
)`,
|
||||
}
|
||||
|
||||
for _, q := range queries {
|
||||
if _, err := s.db.Exec(q); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SQLite) IsDownloaded(ctx context.Context, id string) (bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
var count int
|
||||
err := s.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM downloads WHERE id = ?`, id).Scan(&count)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
func (s *SQLite) MarkDownloaded(ctx context.Context, id string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
_, err := s.db.ExecContext(ctx, `INSERT OR IGNORE INTO downloads(id) VALUES (?)`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *SQLite) MarkFailed(ctx context.Context, source, mediaType, id string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
_, err := s.db.ExecContext(
|
||||
ctx,
|
||||
`INSERT OR IGNORE INTO failed_downloads(source, media_type, id) VALUES (?, ?, ?)`,
|
||||
source,
|
||||
mediaType,
|
||||
id,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *SQLite) Close() error {
|
||||
if s.db == nil {
|
||||
return nil
|
||||
}
|
||||
return s.db.Close()
|
||||
}
|
||||
42
internal/store/sqlite_test.go
Normal file
42
internal/store/sqlite_test.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSQLiteStore(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
path := filepath.Join(t.TempDir(), "test.db")
|
||||
|
||||
s, err := NewSQLite(path)
|
||||
if err != nil {
|
||||
t.Fatalf("NewSQLite() error = %v", err)
|
||||
}
|
||||
defer func() { _ = s.Close() }()
|
||||
|
||||
ok, err := s.IsDownloaded(ctx, "a")
|
||||
if err != nil {
|
||||
t.Fatalf("IsDownloaded() error = %v", err)
|
||||
}
|
||||
if ok {
|
||||
t.Fatalf("expected not downloaded")
|
||||
}
|
||||
|
||||
if err = s.MarkDownloaded(ctx, "a"); err != nil {
|
||||
t.Fatalf("MarkDownloaded() error = %v", err)
|
||||
}
|
||||
|
||||
ok, err = s.IsDownloaded(ctx, "a")
|
||||
if err != nil {
|
||||
t.Fatalf("IsDownloaded() error = %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatalf("expected downloaded")
|
||||
}
|
||||
|
||||
if err = s.MarkFailed(ctx, "qobuz", "track", "1"); err != nil {
|
||||
t.Fatalf("MarkFailed() error = %v", err)
|
||||
}
|
||||
}
|
||||
32
internal/store/store.go
Normal file
32
internal/store/store.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package store
|
||||
|
||||
import "context"
|
||||
|
||||
type Database interface {
|
||||
IsDownloaded(ctx context.Context, id string) (bool, error)
|
||||
MarkDownloaded(ctx context.Context, id string) error
|
||||
MarkFailed(ctx context.Context, source, mediaType, id string) error
|
||||
Close() error
|
||||
}
|
||||
|
||||
type Dummy struct{}
|
||||
|
||||
func NewDummy() *Dummy {
|
||||
return &Dummy{}
|
||||
}
|
||||
|
||||
func (d *Dummy) IsDownloaded(context.Context, string) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (d *Dummy) MarkDownloaded(context.Context, string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Dummy) MarkFailed(context.Context, string, string, string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Dummy) Close() error {
|
||||
return nil
|
||||
}
|
||||
202
internal/urlparse/parse.go
Normal file
202
internal/urlparse/parse.go
Normal file
@@ -0,0 +1,202 @@
|
||||
package urlparse
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type URLKind string
|
||||
|
||||
const (
|
||||
KindGeneric URLKind = "generic"
|
||||
KindDeezerDynamic URLKind = "deezer_dynamic"
|
||||
KindSoundcloud URLKind = "soundcloud"
|
||||
)
|
||||
|
||||
type ParsedURL struct {
|
||||
OriginalURL string
|
||||
Source string
|
||||
MediaType string
|
||||
ID string
|
||||
Kind URLKind
|
||||
}
|
||||
|
||||
var deezerDynamicRe = regexp.MustCompile(`^https?://dzr\.page\.link/`)
|
||||
|
||||
func Parse(raw string) *ParsedURL {
|
||||
if deezerDynamicRe.MatchString(raw) {
|
||||
return &ParsedURL{
|
||||
OriginalURL: raw,
|
||||
Source: "deezer",
|
||||
Kind: KindDeezerDynamic,
|
||||
}
|
||||
}
|
||||
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u.Host == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
host := normalizeHost(u.Host)
|
||||
path := strings.Trim(u.EscapedPath(), "/")
|
||||
parts := splitParts(path)
|
||||
|
||||
switch {
|
||||
case isQobuzHost(host):
|
||||
return parseQobuz(raw, parts)
|
||||
case isTidalHost(host):
|
||||
return parseTidal(raw, parts)
|
||||
case isDeezerHost(host):
|
||||
return parseDeezer(raw, parts)
|
||||
case host == "soundcloud.com":
|
||||
return parseSoundcloud(raw, parts)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func parseQobuz(raw string, parts []string) *ParsedURL {
|
||||
if len(parts) < 2 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if isLocaleToken(parts[0]) {
|
||||
parts = parts[1:]
|
||||
}
|
||||
if len(parts) < 2 {
|
||||
return nil
|
||||
}
|
||||
|
||||
mediaType := parts[0]
|
||||
if !isSupportedMedia(mediaType) {
|
||||
return nil
|
||||
}
|
||||
id := parts[len(parts)-1]
|
||||
if id == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &ParsedURL{OriginalURL: raw, Source: "qobuz", MediaType: mediaType, ID: id, Kind: KindGeneric}
|
||||
}
|
||||
|
||||
func parseTidal(raw string, parts []string) *ParsedURL {
|
||||
if len(parts) < 2 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if parts[0] == "browse" {
|
||||
parts = parts[1:]
|
||||
}
|
||||
if len(parts) < 2 {
|
||||
return nil
|
||||
}
|
||||
|
||||
mediaType := parts[0]
|
||||
if !isSupportedMedia(mediaType) {
|
||||
return nil
|
||||
}
|
||||
id := parts[1]
|
||||
if id == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &ParsedURL{OriginalURL: raw, Source: "tidal", MediaType: mediaType, ID: id, Kind: KindGeneric}
|
||||
}
|
||||
|
||||
func parseDeezer(raw string, parts []string) *ParsedURL {
|
||||
if len(parts) < 2 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if isLangToken(parts[0]) {
|
||||
parts = parts[1:]
|
||||
}
|
||||
if len(parts) < 2 {
|
||||
return nil
|
||||
}
|
||||
|
||||
mediaType := parts[0]
|
||||
if !isSupportedMedia(mediaType) {
|
||||
return nil
|
||||
}
|
||||
id := parts[1]
|
||||
if id == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &ParsedURL{OriginalURL: raw, Source: "deezer", MediaType: mediaType, ID: id, Kind: KindGeneric}
|
||||
}
|
||||
|
||||
func parseSoundcloud(raw string, parts []string) *ParsedURL {
|
||||
if len(parts) < 2 {
|
||||
return nil
|
||||
}
|
||||
|
||||
mediaType := "track"
|
||||
if len(parts) >= 3 && parts[1] == "sets" {
|
||||
mediaType = "playlist"
|
||||
}
|
||||
|
||||
return &ParsedURL{OriginalURL: raw, Source: "soundcloud", MediaType: mediaType, ID: raw, Kind: KindSoundcloud}
|
||||
}
|
||||
|
||||
func splitParts(path string) []string {
|
||||
if path == "" {
|
||||
return nil
|
||||
}
|
||||
raw := strings.Split(path, "/")
|
||||
parts := make([]string, 0, len(raw))
|
||||
for _, p := range raw {
|
||||
if p != "" {
|
||||
parts = append(parts, p)
|
||||
}
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
func normalizeHost(host string) string {
|
||||
h := strings.ToLower(host)
|
||||
return strings.TrimPrefix(h, "www.")
|
||||
}
|
||||
|
||||
func isQobuzHost(host string) bool {
|
||||
return host == "qobuz.com" || host == "open.qobuz.com" || host == "play.qobuz.com"
|
||||
}
|
||||
|
||||
func isTidalHost(host string) bool {
|
||||
return host == "tidal.com" || host == "open.tidal.com" || host == "listen.tidal.com"
|
||||
}
|
||||
|
||||
func isDeezerHost(host string) bool {
|
||||
return host == "deezer.com"
|
||||
}
|
||||
|
||||
func isSupportedMedia(mediaType string) bool {
|
||||
switch mediaType {
|
||||
case "album", "track", "playlist", "artist", "label":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isLocaleToken(s string) bool {
|
||||
if len(s) != 5 {
|
||||
return false
|
||||
}
|
||||
return s[2] == '-' && isAlpha(s[:2]) && isAlpha(s[3:])
|
||||
}
|
||||
|
||||
func isLangToken(s string) bool {
|
||||
return len(s) == 2 && isAlpha(s)
|
||||
}
|
||||
|
||||
func isAlpha(s string) bool {
|
||||
for _, r := range s {
|
||||
if (r < 'a' || r > 'z') && (r < 'A' || r > 'Z') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
107
internal/urlparse/parse_test.go
Normal file
107
internal/urlparse/parse_test.go
Normal file
@@ -0,0 +1,107 @@
|
||||
package urlparse
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDeezerDynamicURL(t *testing.T) {
|
||||
url := "https://dzr.page.link/SnV6hCyHihkmCCwUA"
|
||||
result := Parse(url)
|
||||
if result == nil {
|
||||
t.Fatalf("expected parsed url")
|
||||
}
|
||||
if result.Source != "deezer" {
|
||||
t.Fatalf("source = %q, want deezer", result.Source)
|
||||
}
|
||||
if result.Kind != KindDeezerDynamic {
|
||||
t.Fatalf("kind = %q, want %q", result.Kind, KindDeezerDynamic)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQobuzAlbumURL(t *testing.T) {
|
||||
url := "https://www.qobuz.com/fr-fr/album/bizarre-ride-ii-the-pharcyde-the-pharcyde/0066991040005"
|
||||
result := Parse(url)
|
||||
if result == nil {
|
||||
t.Fatalf("expected parsed url")
|
||||
}
|
||||
if result.Source != "qobuz" || result.MediaType != "album" || result.ID != "0066991040005" {
|
||||
t.Fatalf("unexpected parse result: %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTidalTrackURL(t *testing.T) {
|
||||
url := "https://tidal.com/browse/track/3083287"
|
||||
result := Parse(url)
|
||||
if result == nil {
|
||||
t.Fatalf("expected parsed url")
|
||||
}
|
||||
if result.Source != "tidal" || result.MediaType != "track" || result.ID != "3083287" {
|
||||
t.Fatalf("unexpected parse result: %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeezerTrackURL(t *testing.T) {
|
||||
url := "https://www.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 TestInvalidURL(t *testing.T) {
|
||||
inputs := []string{
|
||||
"https://example.com",
|
||||
"not a url",
|
||||
"https://spotify.com/track/123456",
|
||||
"https://tidal.com/invalid/3083287",
|
||||
}
|
||||
for _, input := range inputs {
|
||||
if result := Parse(input); result != nil {
|
||||
t.Fatalf("expected nil for %q, got %+v", input, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlternateURLFormats(t *testing.T) {
|
||||
inputs := []string{
|
||||
"https://open.tidal.com/track/3083287",
|
||||
"https://play.qobuz.com/album/0066991040005",
|
||||
"https://listen.tidal.com/track/3083287",
|
||||
}
|
||||
for _, input := range inputs {
|
||||
if result := Parse(input); result == nil {
|
||||
t.Fatalf("expected parse for %q", input)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestURLWithLanguageCode(t *testing.T) {
|
||||
inputs := []string{
|
||||
"https://www.qobuz.com/us-en/album/name/id123456",
|
||||
"https://www.qobuz.com/gb-en/album/name/id123456",
|
||||
"https://www.deezer.com/en/track/4195713",
|
||||
"https://www.deezer.com/fr/track/4195713",
|
||||
}
|
||||
for _, input := range inputs {
|
||||
if result := Parse(input); result == nil {
|
||||
t.Fatalf("expected parse for %q", input)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSoundcloudURL(t *testing.T) {
|
||||
inputs := []string{
|
||||
"https://soundcloud.com/artist-name/track-name",
|
||||
"https://soundcloud.com/artist-name/sets/playlist-name",
|
||||
}
|
||||
for _, input := range inputs {
|
||||
result := Parse(input)
|
||||
if result == nil {
|
||||
t.Fatalf("expected parse for %q", input)
|
||||
}
|
||||
if result.Source != "soundcloud" || result.Kind != KindSoundcloud {
|
||||
t.Fatalf("unexpected parse result: %+v", result)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user