Skip to content

Commit 2963d18

Browse files
committed
cleanup
1 parent 3ef9b39 commit 2963d18

42 files changed

Lines changed: 1249 additions & 1354 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/master-workflow.yml

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@ jobs:
1717
uses: actions/setup-go@v2
1818
with:
1919
go-version: 1.21.4
20-
- name: Test SoundCloud Package
21-
run: go test ./pkg/soundcloud/... -v
22-
- name: Test Utils Package
23-
run: go test ./pkg/utils/... -v
20+
- name: Build
21+
run: go build ./...
22+
- name: Vet
23+
run: go vet ./...
24+
- name: Test
25+
run: go test ./... -v

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
.DS_Store
22
dist
3+
/scdl
34

45
.idea/

.goreleaser.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ before:
1313
builds:
1414
- env:
1515
- CGO_ENABLED=0
16-
main: ./cmd/scdl/main.go
16+
main: ./
1717
binary: scdl
1818

1919
# Archive configurations

README.md

Lines changed: 51 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,85 +1,92 @@
11
<p align="center">
2-
<img alt="gopher" src="docs/media/pods.png">
2+
<img alt="gopher" src="docs/media/pods.png">
33
</p>
44
<p align="center">
5-
Scdl is the fastest SoundCloud music downloading CLI tool. Scdl utilizes a go routine pool ensuring multiple thread safe and fast downloads from SoundCloud within seconds. There are extended features such as search (no URL needed) recursively downloading all songs from a given playlist and more!
5+
A fast SoundCloud track downloader written in Go. Give it a track URL, get back an <code>.mp3</code> with embedded cover art.
66
</p>
7-
<br>
87
<p align="center">
98
<a href="https://goreportcard.com/report/github.com/imthaghost/scdl"><img src="https://goreportcard.com/badge/github.com/imthaghost/scdl"></a>
109
</p>
11-
<br>
1210

1311
![Download](/docs/media/v2.gif)
1412

1513
## Table of Contents
1614

17-
- [Installation](#installation)
18-
- [Usage](#usage)
19-
- [Examples](#Examples)
20-
- [Todo](#Todo)
21-
- [License](#license)
15+
- [Installation](#installation)
16+
- [Usage](#usage)
17+
- [How it works](#how-it-works)
18+
- [Roadmap](#roadmap)
19+
- [License](#license)
2220

23-
## 🚀 Installation
21+
## Installation
2422

25-
### Brew
23+
### Homebrew
2624

2725
```bash
28-
# tap
2926
brew tap imthaghost/scdl
30-
# install tool
3127
brew install scdl
3228
```
3329

34-
### Manual
30+
### Go
3531

3632
```bash
37-
# go install :)
38-
go install github.com/imthaghost/scdl/cmd/scdl@latest
33+
go install github.com/imthaghost/scdl@latest
3934
```
4035

41-
### Binary
36+
### Pre-built binary
4237

43-
[Download Here](https://github.com/imthaghost/scdl/releases)
38+
Grab the latest release from the [releases page](https://github.com/imthaghost/scdl/releases).
4439

4540
## Usage
4641

47-
``-h, --help`` - Help screen and usage
42+
```bash
43+
scdl <track-url>
44+
```
4845

49-
``-s, --search`` - Option for searching for songs
46+
Example:
5047

48+
```bash
49+
scdl https://soundcloud.com/polo-g/polo-g-feat-juice-wrld-flex
50+
```
5151

52-
## Examples
52+
The file is written to the current directory as `<track title>.mp3` with the SoundCloud artwork embedded as an ID3v2 front-cover frame.
5353

54-
### Base Command
55-
```bash
56-
# command + SounCloud URL
57-
scdl https://soundcloud.com/polo-g/polo-g-feat-juice-wrld-flex
54+
### Go+ / private tracks (authenticated downloads)
55+
56+
Supply a SoundCloud OAuth token to unlock 256 kbps Go+ transcodings and to download private tracks your account can access. Either pass it on the flag:
57+
58+
```bash
59+
scdl --token "$YOUR_TOKEN" <track-url>
60+
```
61+
62+
…or set it once in your shell:
63+
64+
```bash
65+
export SCDL_TOKEN="your-token-here"
66+
scdl <track-url>
5867
```
5968

69+
To find your token: open soundcloud.com in your logged-in browser, open DevTools → Network, click any request to `api-v2.soundcloud.com`, and copy the value after `OAuth ` in the `Authorization` request header.
6070

71+
Private share links (`?secret_token=s-XXX` or `/s-XXX`) are supported with or without a token.
6172

62-
## Todo
73+
## How it works
6374

64-
### Short term
75+
1. Fetches the track page and parses its `__sc_hydration` JSON.
76+
2. Scrapes a fresh `client_id` from SoundCloud's JS bundles.
77+
3. Builds an authenticated HLS playlist URL using the track's `track_authorization` token.
78+
4. Downloads every segment in parallel, decrypts any AES-128 segments, and assembles them in order.
79+
5. Writes the resulting MP3 and embeds the `og:image` cover art via [`bogem/id3v2`](https://github.com/bogem/id3v2).
6580

66-
- [x] Cobra command line interface
67-
- [x] Download audio file from Soundcloud URL
68-
- [x] Goroutine pool for downloading m3u8 file
69-
- [x] Installation via Brew
70-
- [x] Mp3 file contains image cover
71-
- [x] Download a song through search functionality
72-
- [ ] 80-100% test coverage
73-
- [ ] Update tool for better performance
74-
- [ ] Proxy flag
75-
- [ ] Format flag
76-
### Long term
77-
- [ ] Search results
78-
- [ ] Download all songs from a given playlist
79-
- [ ] Download all songs from a given album
81+
## Roadmap
8082

81-
## 📝 License
83+
- [x] High-quality (256 kbps) downloads via SoundCloud Go+ auth ([#13](https://github.com/imthaghost/scdl/issues/13))
84+
- [x] Private share-link downloads (`?secret_token=` / `/s-XXX`) ([#10](https://github.com/imthaghost/scdl/issues/10))
85+
- [ ] Playlist / set URLs (`/sets/...`)
86+
- [ ] Non-zero exit code on download failure
87+
- [ ] `--output` / `-o` flag for output path
88+
- [ ] Proxy support
8289

83-
By contributing, you agree that your contributions will be licensed under its MIT License.
90+
## License
8491

85-
In short, when you submit code changes, your submissions are understood to be under the same [MIT License](http://choosealicense.com/licenses/mit/) that covers the project. Feel free to contact the maintainers if that's a concern.
92+
[MIT](https://choosealicense.com/licenses/mit/) — see [LICENSE](LICENSE).

client.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package main
2+
3+
import (
4+
"net/http"
5+
"strings"
6+
)
7+
8+
const (
9+
userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3"
10+
apiV2Host = "api-v2.soundcloud.com"
11+
authHeader = "Authorization"
12+
authHeaderValue = "OAuth "
13+
)
14+
15+
// Soundcloud is an HTTP client for scraping and downloading SoundCloud tracks.
16+
//
17+
// Token is an optional SoundCloud OAuth token. When set, it's attached as
18+
// "Authorization: OAuth <token>" on requests to api-v2.soundcloud.com, which
19+
// unlocks Go+ high-quality transcodings and private tracks the account has
20+
// access to.
21+
type Soundcloud struct {
22+
Client *http.Client
23+
UserAgent string
24+
Token string
25+
}
26+
27+
func NewClient(httpClient *http.Client, token string) *Soundcloud {
28+
if httpClient == nil {
29+
httpClient = &http.Client{}
30+
}
31+
return &Soundcloud{Client: httpClient, UserAgent: userAgent, Token: token}
32+
}
33+
34+
// authedGet performs a GET request, attaching the OAuth header only when the
35+
// URL targets api-v2.soundcloud.com and a token is configured. Other hosts
36+
// (the page itself, CDN segments, asset bundles) don't accept the header.
37+
func (s *Soundcloud) authedGet(rawURL string) (*http.Response, error) {
38+
req, err := http.NewRequest("GET", rawURL, nil)
39+
if err != nil {
40+
return nil, err
41+
}
42+
req.Header.Set("User-Agent", s.UserAgent)
43+
if s.Token != "" && strings.Contains(rawURL, apiV2Host) {
44+
req.Header.Set(authHeader, authHeaderValue+s.Token)
45+
}
46+
return s.Client.Do(req)
47+
}

client_test.go

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
package main
2+
3+
import (
4+
"net/http"
5+
"net/http/httptest"
6+
"strings"
7+
"testing"
8+
)
9+
10+
// roundTripFunc lets us intercept requests without spinning up a real server,
11+
// which is the only way to assert the request URL hits api-v2.soundcloud.com.
12+
type roundTripFunc func(*http.Request) (*http.Response, error)
13+
14+
func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
15+
16+
func TestAuthedGetAttachesOAuthHeaderOnlyForAPIV2(t *testing.T) {
17+
cases := []struct {
18+
name string
19+
url string
20+
token string
21+
wantAuth string
22+
wantAgent string
23+
}{
24+
{
25+
name: "api-v2 with token: header attached",
26+
url: "https://api-v2.soundcloud.com/media/foo",
27+
token: "TEST_TOKEN",
28+
wantAuth: "OAuth TEST_TOKEN",
29+
},
30+
{
31+
name: "api-v2 without token: no auth header",
32+
url: "https://api-v2.soundcloud.com/media/foo",
33+
token: "",
34+
wantAuth: "",
35+
},
36+
{
37+
name: "non-api-v2 with token: token NOT leaked to other hosts",
38+
url: "https://cdn.example.com/segment.ts",
39+
token: "TEST_TOKEN",
40+
wantAuth: "",
41+
},
42+
}
43+
44+
for _, tc := range cases {
45+
t.Run(tc.name, func(t *testing.T) {
46+
var seen *http.Request
47+
httpClient := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
48+
seen = req
49+
return &http.Response{StatusCode: 200, Body: http.NoBody, Header: make(http.Header)}, nil
50+
})}
51+
s := NewClient(httpClient, tc.token)
52+
resp, err := s.authedGet(tc.url)
53+
if err != nil {
54+
t.Fatalf("authedGet: %v", err)
55+
}
56+
resp.Body.Close()
57+
58+
if got := seen.Header.Get("Authorization"); got != tc.wantAuth {
59+
t.Errorf("Authorization = %q, want %q", got, tc.wantAuth)
60+
}
61+
if got := seen.Header.Get("User-Agent"); got == "" || !strings.HasPrefix(got, "Mozilla/") {
62+
t.Errorf("User-Agent = %q, want a Mozilla-style UA", got)
63+
}
64+
})
65+
}
66+
}
67+
68+
// TestAuthedGetEndToEnd uses a real httptest server to make sure the request
69+
// actually goes through (no transport oddities) and the body is readable.
70+
func TestAuthedGetEndToEnd(t *testing.T) {
71+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
72+
w.Write([]byte("ok"))
73+
}))
74+
defer srv.Close()
75+
76+
resp, err := NewClient(http.DefaultClient, "").authedGet(srv.URL)
77+
if err != nil {
78+
t.Fatal(err)
79+
}
80+
defer resp.Body.Close()
81+
if resp.StatusCode != 200 {
82+
t.Errorf("status = %d, want 200", resp.StatusCode)
83+
}
84+
}

cmd/base.go

Lines changed: 0 additions & 15 deletions
This file was deleted.

cmd/root.go

Lines changed: 0 additions & 48 deletions
This file was deleted.

cmd/scdl/main.go

Lines changed: 0 additions & 7 deletions
This file was deleted.

0 commit comments

Comments
 (0)