Skip to content

Commit 84986dc

Browse files
committed
feat(download): add fallback to original artwork URL and tests
1 parent 7068e37 commit 84986dc

2 files changed

Lines changed: 175 additions & 3 deletions

File tree

client_test.go

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import (
1111
"os"
1212
"strings"
1313
"testing"
14+
15+
"github.com/bogem/id3v2/v2"
1416
)
1517

1618
// mockTransport allows us to mock HTTP responses
@@ -349,3 +351,168 @@ http://mock/segment.ts
349351
t.Errorf("file content missing decrypted audio. Got size %d", len(content))
350352
}
351353
}
354+
355+
func TestDownload_Artwork(t *testing.T) {
356+
// Setup encryption for mock segment (same as TestDownload)
357+
key := []byte("1234567890123456")
358+
iv := make([]byte, 16) // zero IV
359+
playlistContent := []byte("audio content")
360+
padding := aes.BlockSize - (len(playlistContent) % aes.BlockSize)
361+
padded := append(playlistContent, bytes.Repeat([]byte{byte(padding)}, padding)...)
362+
ciphertext := make([]byte, len(padded))
363+
block, _ := aes.NewCipher(key)
364+
mode := cipher.NewCBCEncrypter(block, iv)
365+
mode.CryptBlocks(ciphertext, padded)
366+
367+
// M3U8 content
368+
m3u8Content := `#EXTM3U
369+
#EXT-X-VERSION:3
370+
#EXT-X-TARGETDURATION:10
371+
#EXT-X-KEY:METHOD=AES-128,URI="http://mock/key",IV=0x00000000000000000000000000000000
372+
#EXTINF:10.0,
373+
http://mock/segment.ts
374+
#EXT-X-ENDLIST`
375+
376+
tests := []struct {
377+
name string
378+
artworkURL string
379+
expectArtwork bool
380+
mockTransportFunc func(req *http.Request) (*http.Response, error)
381+
}{
382+
{
383+
name: "Artwork Success",
384+
artworkURL: "http://mock/artwork-large.jpg",
385+
expectArtwork: true,
386+
mockTransportFunc: func(req *http.Request) (*http.Response, error) {
387+
u := req.URL.String()
388+
if u == "http://mock/artwork-t500x500.jpg" { // Replaced URL
389+
return &http.Response{
390+
StatusCode: 200,
391+
Body: io.NopCloser(bytes.NewReader([]byte("fake image data"))),
392+
}, nil
393+
}
394+
if strings.Contains(u, "artwork") {
395+
return &http.Response{StatusCode: 404, Body: io.NopCloser(strings.NewReader("Not Found"))}, nil
396+
}
397+
// Default handlers
398+
if strings.Contains(u, "/stream/hls") {
399+
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(`{"url": "http://mock/playlist.m3u8"}`))}, nil
400+
}
401+
if u == "http://mock/playlist.m3u8" {
402+
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(m3u8Content))}, nil
403+
}
404+
if u == "http://mock/key" {
405+
return &http.Response{StatusCode: 200, Body: io.NopCloser(bytes.NewReader(key))}, nil
406+
}
407+
if u == "http://mock/segment.ts" {
408+
return &http.Response{StatusCode: 200, Body: io.NopCloser(bytes.NewReader(ciphertext))}, nil
409+
}
410+
return &http.Response{StatusCode: 404, Body: io.NopCloser(strings.NewReader("Not Found: " + u))}, nil
411+
},
412+
},
413+
{
414+
name: "Artwork Failure Silent",
415+
artworkURL: "http://mock/artwork-large.jpg",
416+
expectArtwork: false, // Currently fails silently, so we expect NO artwork
417+
mockTransportFunc: func(req *http.Request) (*http.Response, error) {
418+
u := req.URL.String()
419+
if strings.Contains(u, "artwork") {
420+
return &http.Response{StatusCode: 404, Body: io.NopCloser(strings.NewReader("Not Found"))}, nil
421+
}
422+
// Default handlers
423+
if strings.Contains(u, "/stream/hls") {
424+
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(`{"url": "http://mock/playlist.m3u8"}`))}, nil
425+
}
426+
if u == "http://mock/playlist.m3u8" {
427+
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(m3u8Content))}, nil
428+
}
429+
if u == "http://mock/key" {
430+
return &http.Response{StatusCode: 200, Body: io.NopCloser(bytes.NewReader(key))}, nil
431+
}
432+
if u == "http://mock/segment.ts" {
433+
return &http.Response{StatusCode: 200, Body: io.NopCloser(bytes.NewReader(ciphertext))}, nil
434+
}
435+
return &http.Response{StatusCode: 404, Body: io.NopCloser(strings.NewReader("Not Found: " + u))}, nil
436+
},
437+
},
438+
{
439+
name: "Fallback when Replacement Fails",
440+
artworkURL: "http://mock/artwork-large.jpg",
441+
expectArtwork: true,
442+
mockTransportFunc: func(req *http.Request) (*http.Response, error) {
443+
u := req.URL.String()
444+
// The replaced URL will be tried first
445+
if u == "http://mock/artwork-t500x500.jpg" {
446+
return &http.Response{StatusCode: 404, Body: io.NopCloser(strings.NewReader("Not Found"))}, nil
447+
}
448+
// The original URL SHOULD be tried as fallback and succeed
449+
if u == "http://mock/artwork-large.jpg" {
450+
return &http.Response{
451+
StatusCode: 200,
452+
Body: io.NopCloser(bytes.NewReader([]byte("fake image data"))),
453+
}, nil
454+
}
455+
456+
// Default handlers
457+
if strings.Contains(u, "/stream/hls") {
458+
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(`{"url": "http://mock/playlist.m3u8"}`))}, nil
459+
}
460+
if u == "http://mock/playlist.m3u8" {
461+
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(m3u8Content))}, nil
462+
}
463+
if u == "http://mock/key" {
464+
return &http.Response{StatusCode: 200, Body: io.NopCloser(bytes.NewReader(key))}, nil
465+
}
466+
if u == "http://mock/segment.ts" {
467+
return &http.Response{StatusCode: 200, Body: io.NopCloser(bytes.NewReader(ciphertext))}, nil
468+
}
469+
return &http.Response{StatusCode: 404, Body: io.NopCloser(strings.NewReader("Not Found: " + u))}, nil
470+
},
471+
},
472+
}
473+
474+
for _, tc := range tests {
475+
t.Run(tc.name, func(t *testing.T) {
476+
client := &Client{
477+
clientID: "test-client",
478+
httpClient: &http.Client{
479+
Transport: &mockTransport{RoundTripFunc: tc.mockTransportFunc},
480+
},
481+
}
482+
483+
track := &Track{
484+
ID: 123,
485+
Title: "MySong",
486+
Artist: "MyArtist",
487+
HLSURL: "https://api-v2.soundcloud.com/media/soundcloud:tracks:123/token/stream/hls",
488+
TrackAuthorization: "auth",
489+
ArtworkURL: tc.artworkURL,
490+
}
491+
492+
outDir := t.TempDir()
493+
outPath, err := client.Download(context.Background(), track, outDir, nil)
494+
if err != nil {
495+
t.Fatalf("Download() error = %v", err)
496+
}
497+
498+
// Read tags
499+
tag, err := id3v2.Open(outPath, id3v2.Options{Parse: true})
500+
if err != nil {
501+
t.Fatalf("Error opening tag: %v", err)
502+
}
503+
defer tag.Close()
504+
505+
hasPicture := false
506+
if frames := tag.GetFrames(tag.CommonID("Attached picture")); len(frames) > 0 {
507+
hasPicture = true
508+
}
509+
510+
if tc.expectArtwork && !hasPicture {
511+
t.Error("Expected artwork to be attached, but it was not")
512+
}
513+
if !tc.expectArtwork && hasPicture {
514+
t.Error("Expected no artwork, but it was found")
515+
}
516+
})
517+
}
518+
}

download.go

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ func (c *Client) Download(ctx context.Context, track *Track, outputDir string, p
4747
segments := make([][]byte, count)
4848
var keyCache sync.Map
4949

50-
g, ctx := errgroup.WithContext(ctx)
50+
g, groupCtx := errgroup.WithContext(ctx)
5151
g.SetLimit(maxConcurrentSegments)
5252

5353
var progressMu sync.Mutex
@@ -58,12 +58,12 @@ func (c *Client) Download(ctx context.Context, track *Track, outputDir string, p
5858
globalKey := mpl.Key
5959

6060
g.Go(func() error {
61-
data, err := c.get(ctx, seg.URI)
61+
data, err := c.get(groupCtx, seg.URI)
6262
if err != nil {
6363
return fmt.Errorf("download segment %d: %w", i, err)
6464
}
6565

66-
data, err = c.decryptSegment(ctx, data, seg, globalKey, i, &keyCache)
66+
data, err = c.decryptSegment(groupCtx, data, seg, globalKey, i, &keyCache)
6767
if err != nil {
6868
return fmt.Errorf("decrypt segment %d: %w", i, err)
6969
}
@@ -257,6 +257,11 @@ func (c *Client) embedMetadata(ctx context.Context, filePath string, track *Trac
257257
if track.ArtworkURL != "" {
258258
artworkURL := strings.Replace(track.ArtworkURL, "-large.", "-t500x500.", 1)
259259
image, err := c.get(ctx, artworkURL)
260+
if (err != nil || len(image) == 0) && artworkURL != track.ArtworkURL {
261+
// Fallback to original URL if high-res fails and we changed it
262+
image, err = c.get(ctx, track.ArtworkURL)
263+
}
264+
260265
if err == nil && len(image) > 0 {
261266
tag.AddAttachedPicture(id3v2.PictureFrame{
262267
Encoding: id3v2.EncodingUTF8,

0 commit comments

Comments
 (0)