initial Go port of streamrip

This commit is contained in:
2026-04-19 21:11:38 +02:00
commit 97e8b758b3
32 changed files with 7008 additions and 0 deletions

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
View 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 &amp; 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
View 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
View 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

File diff suppressed because it is too large Load Diff

318
internal/app/app_test.go Normal file
View 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
View 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})
}

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

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

View 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 ""
}

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

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

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

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

View 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
View 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"
}

View 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
View 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,
}
}

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

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

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

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

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

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

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

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

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

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