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:
1967
cmd/rip/main.go
Normal file
1967
cmd/rip/main.go
Normal file
File diff suppressed because it is too large
Load Diff
117
cmd/rip/main_test.go
Normal file
117
cmd/rip/main_test.go
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestParseFileInputJSONItems(t *testing.T) {
|
||||||
|
content := []byte(`[
|
||||||
|
{"source":"qobuz","media_type":"album","id":"0066991040005"},
|
||||||
|
{"source":"tidal","media_type":"track","id":3083287}
|
||||||
|
]`)
|
||||||
|
|
||||||
|
items, urls, repeated, jsonInput, err := parseFileInput(content)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseFileInput() error = %v", err)
|
||||||
|
}
|
||||||
|
if !jsonInput {
|
||||||
|
t.Fatalf("jsonInput = false, want true")
|
||||||
|
}
|
||||||
|
if len(urls) != 0 {
|
||||||
|
t.Fatalf("urls len = %d, want 0", len(urls))
|
||||||
|
}
|
||||||
|
if repeated != 0 {
|
||||||
|
t.Fatalf("repeated = %d, want 0", repeated)
|
||||||
|
}
|
||||||
|
if len(items) != 2 {
|
||||||
|
t.Fatalf("items len = %d, want 2", len(items))
|
||||||
|
}
|
||||||
|
if items[0].Source != "qobuz" || items[0].MediaType != "album" || items[0].ID != "0066991040005" {
|
||||||
|
t.Fatalf("unexpected first item: %+v", items[0])
|
||||||
|
}
|
||||||
|
if items[1].Source != "tidal" || items[1].MediaType != "track" || items[1].ID != "3083287" {
|
||||||
|
t.Fatalf("unexpected second item: %+v", items[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseFileInputTextURLsDedupes(t *testing.T) {
|
||||||
|
content := []byte("https://tidal.com/browse/track/3083287\nhttps://tidal.com/browse/track/3083287\nhttps://www.qobuz.com/fr-fr/album/example/0066991040005\n")
|
||||||
|
|
||||||
|
items, urls, repeated, jsonInput, err := parseFileInput(content)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseFileInput() error = %v", err)
|
||||||
|
}
|
||||||
|
if jsonInput {
|
||||||
|
t.Fatalf("jsonInput = true, want false")
|
||||||
|
}
|
||||||
|
if len(items) != 0 {
|
||||||
|
t.Fatalf("items len = %d, want 0", len(items))
|
||||||
|
}
|
||||||
|
if repeated != 1 {
|
||||||
|
t.Fatalf("repeated = %d, want 1", repeated)
|
||||||
|
}
|
||||||
|
if len(urls) != 2 {
|
||||||
|
t.Fatalf("urls len = %d, want 2", len(urls))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseFileInputRejectsInvalidJSONShape(t *testing.T) {
|
||||||
|
content := []byte(`{"source":"qobuz","media_type":"track","id":"1"}`)
|
||||||
|
|
||||||
|
_, _, _, jsonInput, err := parseFileInput(content)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected error for non-array json")
|
||||||
|
}
|
||||||
|
if !jsonInput {
|
||||||
|
t.Fatalf("jsonInput = false, want true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseLastFMArgsDefaults(t *testing.T) {
|
||||||
|
opts, err := parseLastFMArgs([]string{"https://www.last.fm/user/x/playlists/123"}, "qobuz", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseLastFMArgs() error = %v", err)
|
||||||
|
}
|
||||||
|
if opts.Source != "qobuz" {
|
||||||
|
t.Fatalf("source = %q, want qobuz", opts.Source)
|
||||||
|
}
|
||||||
|
if opts.PlaylistURL == "" {
|
||||||
|
t.Fatalf("playlist url should not be empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseLastFMArgsOptions(t *testing.T) {
|
||||||
|
opts, err := parseLastFMArgs([]string{"--source", "tidal", "--fallback-source", "qobuz", "https://www.last.fm/user/x/playlists/123"}, "qobuz", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseLastFMArgs() error = %v", err)
|
||||||
|
}
|
||||||
|
if opts.Source != "tidal" || opts.FallbackSource != "qobuz" {
|
||||||
|
t.Fatalf("unexpected options: %+v", opts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractLastFMPlaylistInfoAndPairs(t *testing.T) {
|
||||||
|
html := `<h1 class="playlisting-playlist-header-title">Road & Rain</h1>
|
||||||
|
<div data-playlisting-entry-count="2"></div>
|
||||||
|
<a href="/music/a" title="Dreams"></a>
|
||||||
|
<a href="/music/b" title="Fleetwood Mac"></a>
|
||||||
|
<a href="/music/c" title="Go Your Own Way"></a>
|
||||||
|
<a href="/music/d" title="Fleetwood Mac"></a>`
|
||||||
|
|
||||||
|
title, total, err := extractLastFMPlaylistInfo(html)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("extractLastFMPlaylistInfo() error = %v", err)
|
||||||
|
}
|
||||||
|
if title != "Road & Rain" {
|
||||||
|
t.Fatalf("title = %q, want %q", title, "Road & Rain")
|
||||||
|
}
|
||||||
|
if total != 2 {
|
||||||
|
t.Fatalf("total = %d, want 2", total)
|
||||||
|
}
|
||||||
|
|
||||||
|
pairs := extractLastFMTitleArtistPairs(html)
|
||||||
|
if len(pairs) != 2 {
|
||||||
|
t.Fatalf("pairs len = %d, want 2", len(pairs))
|
||||||
|
}
|
||||||
|
if pairs[0].Title != "Dreams" || pairs[0].Artist != "Fleetwood Mac" {
|
||||||
|
t.Fatalf("unexpected first pair: %+v", pairs[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
33
go.mod
Normal file
33
go.mod
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
module streamrip-go
|
||||||
|
|
||||||
|
go 1.25.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/AlecAivazis/survey/v2 v2.3.7
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.3
|
||||||
|
github.com/vbauerster/mpb/v8 v8.12.0
|
||||||
|
golang.org/x/image v0.39.0
|
||||||
|
golang.org/x/term v0.42.0
|
||||||
|
modernc.org/sqlite v1.39.1
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/VividCortex/ewma v1.2.0 // indirect
|
||||||
|
github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d // indirect
|
||||||
|
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
|
||||||
|
github.com/mattn/go-colorable v0.1.2 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/mattn/go-runewidth v0.0.20 // indirect
|
||||||
|
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b // indirect
|
||||||
|
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
|
||||||
|
golang.org/x/sys v0.43.0 // indirect
|
||||||
|
golang.org/x/text v0.36.0 // indirect
|
||||||
|
modernc.org/libc v1.66.10 // indirect
|
||||||
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
|
modernc.org/memory v1.11.0 // indirect
|
||||||
|
)
|
||||||
120
go.sum
Normal file
120
go.sum
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ=
|
||||||
|
github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo=
|
||||||
|
github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63nhn5WAunQHLTznkw5W8b1Xc0dNjp83s=
|
||||||
|
github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w=
|
||||||
|
github.com/VividCortex/ewma v1.2.0 h1:f58SaIzcDXrSy3kWaHNvuJgJ3Nmz59Zji6XoJR/q1ow=
|
||||||
|
github.com/VividCortex/ewma v1.2.0/go.mod h1:nz4BbCtbLyFDeC9SUHbtcT5644juEuWfUAUnGx7j5l4=
|
||||||
|
github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d h1:licZJFw2RwpHMqeKTCYkitsPqHNxTmd4SNR5r94FGM8=
|
||||||
|
github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d/go.mod h1:asat636LX7Bqt5lYEZ27JNDcqxfjdBQuJ/MM4CN/Lzo=
|
||||||
|
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
||||||
|
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||||
|
github.com/creack/pty v1.1.17 h1:QeVUsEDNrLBW4tMgZHvxy18sKtr6VI492kBhUfhDJNI=
|
||||||
|
github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||||
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog=
|
||||||
|
github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68=
|
||||||
|
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
|
||||||
|
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
|
||||||
|
github.com/mattn/go-colorable v0.1.2 h1:/bC9yWikZXAL9uJdulbSfyVNIR3n3trXl+v8+1sx8mU=
|
||||||
|
github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
|
||||||
|
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ=
|
||||||
|
github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||||
|
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4=
|
||||||
|
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
|
||||||
|
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||||
|
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||||
|
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
github.com/vbauerster/mpb/v8 v8.12.0 h1:+gneY3ifzc88tKDzOtfG8k8gfngCx615S2ZmFM4liWg=
|
||||||
|
github.com/vbauerster/mpb/v8 v8.12.0/go.mod h1:V02YIuMVo301Y1VE9VtZlD8s84OMsk+EKN6mwvf/588=
|
||||||
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
|
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
|
||||||
|
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
|
||||||
|
golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww=
|
||||||
|
golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA=
|
||||||
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
|
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
|
||||||
|
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
|
||||||
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||||
|
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||||
|
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
|
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
|
||||||
|
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
|
||||||
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
|
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
|
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||||
|
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||||
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
|
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
|
||||||
|
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4=
|
||||||
|
modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||||
|
modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A=
|
||||||
|
modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q=
|
||||||
|
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
|
||||||
|
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
||||||
|
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||||
|
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||||
|
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||||
|
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||||
|
modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A=
|
||||||
|
modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I=
|
||||||
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
|
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||||
|
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||||
|
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||||
|
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||||
|
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||||
|
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||||
|
modernc.org/sqlite v1.39.1 h1:H+/wGFzuSCIEVCvXYVHX5RQglwhMOvtHSv+VtidL2r4=
|
||||||
|
modernc.org/sqlite v1.39.1/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE=
|
||||||
|
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||||
|
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||||
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||||
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