diff --git a/README.md b/README.md index 076daa7..1ef1796 100644 --- a/README.md +++ b/README.md @@ -188,28 +188,28 @@ Some notes: - When specifying time, specify it in seconds. -| JSON key | Environment variable | Default value | Description | -| :------------------------ | ---------------------------- | ----------------------------------------------------------------------------------------------------------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| None | SOUNDCLOAK_CONFIG | soundcloak.json | File to load soundcloak config from. If set to`FROM_ENV`, soundcloak loads the config from environment variables. | -| DefaultPreferences | DEFAULT_PREFERENCES | {"Player": "hls", "ProxyStreams": false, "FullyPreloadTrack": false, "ProxyImages": false, "ParseDescriptions": true} | see /_/preferences page, default values adapt to your config (Player: "restream" if Restream, else "hls", ProxyStreams and ProxyImages will be same as respective config values) | -| ProxyImages | PROXY_IMAGES | false | Enables proxying of images (user avatars, track covers etc) | -| ImageCacheControl | IMAGE_CACHE_CONTROL | max-age=600, public, immutable | [Cache-Control](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control) header value for proxied images. Cached for 10 minutes by default. | -| ProxyStreams | PROXY_STREAMS | false | Enables proxying of song parts and hls playlist files | -| Restream | RESTREAM | false | Enables Restream Player in settings and the /_/restream/:author/:track endpoint. This player can be used without JavaScript and also can be used for downloading songs. | -| RestreamCacheControl | RESTREAM_CACHE_CONTROL | max-age=3600, public, immutable | [Cache-Control](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control) header value for restreamed songs. Cached for 1 hour by default. | -| ClientIDTTL | CLIENT_ID_TTL | 30 minutes | Time until ClientID cache expires. ClientID is used for authenticating with SoundCloud API | -| UserTTL | USER_TTL | 10 minutes | Time until User profile cache expires | -| UserCacheCleanDelay | USER_CACHE_CLEAN_DELAY | 2.5 minutes | Time between each cleanup of the cache (to remove expired users) | -| TrackTTL | TRACK_TTL | 10 minutes | Time until Track data cache expires | -| TrackCacheCleanDelay | TRACK_CACHE_CLEAN_DELAY | 2.5 minutes | Time between each cleanup of the cache (to remove expired tracks) | -| PlaylistTTL | PLAYLIST_TTL | 10 minutes | Time until Playlist data cache expires | -| PlaylistCacheCleanDelay | PLAYLIST_CACHE_CLEAN_DELAY | 2.5 minutes | Time between each cleanup of the cache (to remove expired playlists) | -| UserAgent | USER_AGENT | Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.3 | User-Agent header used for requests to SoundCloud | -| DNSCacheTTL | DNS_CACHE_TTL | 10 minutes | Time until DNS cache expires | -| Addr | ADDR | :4664 | Address and port for soundcloak to listen on | -| Prefork | PREFORK | false | Run multiple instances of soundcloak locally to be able to handle more requests. Each one will be a separate process, so they will have separate cache. | -| TrustedProxyCheck | TRUSTED_PROXY_CHECK | true | Use X-Forwarded-* headers if IP is in TrustedProxies list. When disabled, those headers will blindly be used. | -| TrustedProxies | TRUSTED_PROXIES | [] | List of IPs or IP ranges of trusted proxies | +| JSON key | Environment variable | Default value | Description | +| :------------------------ | ---------------------------- | ----------------------------------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| None | SOUNDCLOAK_CONFIG | soundcloak.json | File to load soundcloak config from. If set to`FROM_ENV`, soundcloak loads the config from environment variables. | +| DefaultPreferences | DEFAULT_PREFERENCES | {"Player": "hls", "ProxyStreams": false, "FullyPreloadTrack": false, "ProxyImages": false, "ParseDescriptions": true} | see /_/preferences page, default values adapt to your config (Player: "restream" if Restream, else "hls", ProxyStreams and ProxyImages will be same as respective config values) | +| ProxyImages | PROXY_IMAGES | false | Enables proxying of images (user avatars, track covers etc) | +| ImageCacheControl | IMAGE_CACHE_CONTROL | max-age=600, public, immutable | [Cache-Control](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control) header value for proxied images. Cached for 10 minutes by default. | +| ProxyStreams | PROXY_STREAMS | false | Enables proxying of song parts and hls playlist files | +| Restream | RESTREAM | false | Enables Restream Player in settings and the /_/restream/:author/:track endpoint. This player can be used without JavaScript. Restream also enables the button for downloading songs. | +| RestreamCacheControl | RESTREAM_CACHE_CONTROL | max-age=3600, public, immutable | [Cache-Control](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control) header value for restreamed songs. Cached for 1 hour by default. | +| ClientIDTTL | CLIENT_ID_TTL | 30 minutes | Time until ClientID cache expires. ClientID is used for authenticating with SoundCloud API | +| UserTTL | USER_TTL | 10 minutes | Time until User profile cache expires | +| UserCacheCleanDelay | USER_CACHE_CLEAN_DELAY | 2.5 minutes | Time between each cleanup of the cache (to remove expired users) | +| TrackTTL | TRACK_TTL | 10 minutes | Time until Track data cache expires | +| TrackCacheCleanDelay | TRACK_CACHE_CLEAN_DELAY | 2.5 minutes | Time between each cleanup of the cache (to remove expired tracks) | +| PlaylistTTL | PLAYLIST_TTL | 10 minutes | Time until Playlist data cache expires | +| PlaylistCacheCleanDelay | PLAYLIST_CACHE_CLEAN_DELAY | 2.5 minutes | Time between each cleanup of the cache (to remove expired playlists) | +| UserAgent | USER_AGENT | Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.3 | User-Agent header used for requests to SoundCloud | +| DNSCacheTTL | DNS_CACHE_TTL | 10 minutes | Time until DNS cache expires | +| Addr | ADDR | :4664 | Address and port for soundcloak to listen on | +| Prefork | PREFORK | false | Run multiple instances of soundcloak locally to be able to handle more requests. Each one will be a separate process, so they will have separate cache. | +| TrustedProxyCheck | TRUSTED_PROXY_CHECK | true | Use X-Forwarded-* headers if IP is in TrustedProxies list. When disabled, those headers will blindly be used. | +| TrustedProxies | TRUSTED_PROXIES | [] | List of IPs or IP ranges of trusted proxies | diff --git a/go.mod b/go.mod index a2b0389..c378a44 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.21.3 require ( github.com/a-h/templ v0.2.793 + github.com/bogem/id3v2/v2 v2.1.4 github.com/gofiber/fiber/v2 v2.52.5 github.com/segmentio/encoding v0.4.0 github.com/valyala/fasthttp v1.57.0 @@ -21,4 +22,5 @@ require ( github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/tcplisten v1.0.0 // indirect golang.org/x/sys v0.27.0 // indirect + golang.org/x/text v0.19.0 // indirect ) diff --git a/go.sum b/go.sum index 65ad7db..aa6b21d 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ github.com/a-h/templ v0.2.793 h1:Io+/ocnfGWYO4VHdR0zBbf39PQlnzVCVVD+wEEs6/qY= github.com/a-h/templ v0.2.793/go.mod h1:lq48JXoUvuQrU0VThrK31yFwdRjTCnIE5bcPCM9IP1w= github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA= github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA= +github.com/bogem/id3v2/v2 v2.1.4 h1:CEwe+lS2p6dd9UZRlPc1zbFNIha2mb2qzT1cCEoNWoI= +github.com/bogem/id3v2/v2 v2.1.4/go.mod h1:l+gR8MZ6rc9ryPTPkX77smS5Me/36gxkMgDayZ9G1vY= github.com/gofiber/fiber/v2 v2.52.5 h1:tWoP1MJQjGEe4GB5TUGOi7P2E0ZMMRx5ZTG4rT+yGMo= github.com/gofiber/fiber/v2 v2.52.5/go.mod h1:KEOE+cXMhXG0zHc9d8+E38hoX+ZN7bhOtgeF2oT6jrQ= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= @@ -32,7 +34,33 @@ github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVS github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +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/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +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/sys v0.0.0-20190215142949-d0b11bdaac8a/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.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s= golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +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/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.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM= +golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +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/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/lib/cfg/init.go b/lib/cfg/init.go index ccd75d5..8b9500d 100644 --- a/lib/cfg/init.go +++ b/lib/cfg/init.go @@ -467,3 +467,7 @@ func init() { defaultPreferences() } } + +// seems soundcloud has 4 of these (i1, i2, i3, i4) +// they point to the same ip from my observations, and they all serve the same files +const ImageCDN = "i1.sndcdn.com" diff --git a/lib/proxy_images/init.go b/lib/proxy_images/init.go index 52e9771..d2504c1 100644 --- a/lib/proxy_images/init.go +++ b/lib/proxy_images/init.go @@ -11,18 +11,6 @@ import ( var sndcdn = []byte(".sndcdn.com") -// seems soundcloud has 4 of these (i1, i2, i3, i4) -// they point to the same ip from my observations, and they all serve the same files -const cdn = "i1.sndcdn.com" - -var httpc = &fasthttp.HostClient{ - Addr: cdn + ":443", - IsTLS: true, - DialDualStack: true, - Dial: (&fasthttp.TCPDialer{DNSCacheDuration: cfg.DNSCacheTTL}).Dial, - MaxIdleConnDuration: 1<<63 - 1, -} - func Load(r fiber.Router) { r.Get("/_/proxy/images", func(c *fiber.Ctx) error { url := c.Query("url") @@ -42,7 +30,7 @@ func Load(r fiber.Router) { return fiber.ErrBadRequest } - parsed.SetHost(cdn) + parsed.SetHost(cfg.ImageCDN) req := fasthttp.AcquireRequest() defer fasthttp.ReleaseRequest(req) @@ -54,7 +42,7 @@ func Load(r fiber.Router) { resp := fasthttp.AcquireResponse() defer fasthttp.ReleaseResponse(resp) - err = sc.DoWithRetry(httpc, req, resp) + err = sc.DoWithRetry(sc.ImageClient, req, resp) if err != nil { return err } diff --git a/lib/restream/init.go b/lib/restream/init.go index 31db19b..1bb4f18 100644 --- a/lib/restream/init.go +++ b/lib/restream/init.go @@ -4,6 +4,7 @@ import ( "bytes" "io" + "github.com/bogem/id3v2/v2" "github.com/gofiber/fiber/v2" "github.com/maid-zone/soundcloak/lib/cfg" "github.com/maid-zone/soundcloak/lib/sc" @@ -126,9 +127,18 @@ func (r *reader) Read(buf []byte) (n int, err error) { return } +type collector struct { + data []byte +} + +func (c *collector) Write(data []byte) (n int, err error) { + c.data = append(c.data, data...) + return len(data), nil +} + func Load(r fiber.Router) { r.Get("/_/restream/:author/:track", func(c *fiber.Ctx) error { - t, err := sc.GetTrack(stubPrefs, c.Params("author")+"/"+c.Params("track")) + t, err := sc.GetTrack(c.Params("author") + "/" + c.Params("track")) if err != nil { return err } @@ -151,6 +161,30 @@ func Load(r fiber.Router) { return err } + if c.Query("metadata") == "true" { + tag := id3v2.NewEmptyTag() + + tag.SetArtist(t.Author.Username) + if t.Genre != "" { + tag.SetGenre(t.Genre) + } + + tag.SetTitle(t.Title) + + if t.Artwork != "" { + data, mime, err := t.DownloadImage() + if err != nil { + return err + } + + tag.AddAttachedPicture(id3v2.PictureFrame{MimeType: mime, Picture: data, PictureType: id3v2.PTFrontCover, Encoding: id3v2.EncodingUTF8}) + } + + var c collector + tag.WriteTo(&c) + r.leftover = c.data + } + return c.SendStream(&r) }) } diff --git a/lib/sc/featured.go b/lib/sc/featured.go index ec77af6..21d35f6 100644 --- a/lib/sc/featured.go +++ b/lib/sc/featured.go @@ -26,7 +26,8 @@ func GetFeaturedTracks(prefs cfg.Preferences, args string) (*Paginated[*Track], } for _, t := range p.Collection { - t.Fix(prefs, false) + t.Fix(false) + t.Postfix(prefs) } return &p, nil @@ -54,6 +55,7 @@ func GetSelections(prefs cfg.Preferences) (*Paginated[*Selection], error) { func (s *Selection) Fix(prefs cfg.Preferences) { for _, p := range s.Items.Collection { - p.Fix(prefs, false) + p.Fix(false) + p.Postfix(prefs) } } diff --git a/lib/sc/init.go b/lib/sc/init.go index d207468..acdab7f 100644 --- a/lib/sc/init.go +++ b/lib/sc/init.go @@ -15,12 +15,14 @@ import ( "github.com/valyala/fasthttp" ) -var clientIdCache struct { +type clientIdCache struct { ClientID string Version []byte NextCheck time.Time } +var ClientIDCache clientIdCache + const api = "api-v2.soundcloud.com" var httpc = &fasthttp.HostClient{ @@ -28,7 +30,15 @@ var httpc = &fasthttp.HostClient{ IsTLS: true, DialDualStack: true, Dial: (&fasthttp.TCPDialer{DNSCacheDuration: cfg.DNSCacheTTL}).Dial, - MaxIdleConnDuration: 1<<63 - 1, //improves performance but seems to cause some issues, need more testing + MaxIdleConnDuration: 1<<63 - 1, +} + +var ImageClient = &fasthttp.HostClient{ + Addr: cfg.ImageCDN + ":443", + IsTLS: true, + DialDualStack: true, + Dial: (&fasthttp.TCPDialer{DNSCacheDuration: cfg.DNSCacheTTL}).Dial, + MaxIdleConnDuration: 1<<63 - 1, } var verRegex = regexp.MustCompile(`(?m)^$`) @@ -46,15 +56,15 @@ type cached[T any] struct { // inspired by github.com/imputnet/cobalt (mostly stolen lol) func GetClientID() (string, error) { - if clientIdCache.NextCheck.After(time.Now()) { - return clientIdCache.ClientID, nil + if ClientIDCache.NextCheck.After(time.Now()) { + return ClientIDCache.ClientID, nil } req := fasthttp.AcquireRequest() defer fasthttp.ReleaseRequest(req) req.SetRequestURI("https://soundcloud.com/h") // 404 page - req.Header.Set("User-Agent", cfg.UserAgent) // the connection is stuck with fasthttp useragent lol, maybe randomly select from a list of browser useragents in the future? low priority for now + req.Header.Set("User-Agent", cfg.UserAgent) req.Header.Set("Accept-Encoding", "gzip, deflate, br, zstd") resp := fasthttp.AcquireResponse() @@ -75,9 +85,9 @@ func GetClientID() (string, error) { return "", ErrVersionNotFound } - if bytes.Equal(res[1], clientIdCache.Version) { - clientIdCache.NextCheck = time.Now().Add(cfg.ClientIDTTL) - return clientIdCache.ClientID, nil + if bytes.Equal(res[1], ClientIDCache.Version) { + ClientIDCache.NextCheck = time.Now().Add(cfg.ClientIDTTL) + return ClientIDCache.ClientID, nil } ver := res[1] @@ -109,15 +119,16 @@ func GetClientID() (string, error) { continue } - clientIdCache.ClientID = string(res[1]) - clientIdCache.Version = ver - clientIdCache.NextCheck = time.Now().Add(cfg.ClientIDTTL) - return clientIdCache.ClientID, nil + ClientIDCache.ClientID = string(res[1]) + ClientIDCache.Version = ver + ClientIDCache.NextCheck = time.Now().Add(cfg.ClientIDTTL) + return ClientIDCache.ClientID, nil } return "", ErrIDNotFound } +// Since the http client is setup to always keep connections idle (great for speed, no need to open a new one everytime), those connections may be closed by soundcloud after some time of inactivity, this ensures that we retry those requests that fail due to the connection closing/timing out func DoWithRetry(httpc *fasthttp.HostClient, req *fasthttp.Request, resp *fasthttp.Response) (err error) { for i := 0; i < 10; i++ { err = httpc.Do(req, resp) @@ -264,9 +275,9 @@ func init() { for range ticker.C { usersCacheLock.Lock() - for key, val := range usersCache { + for key, val := range UsersCache { if val.Expires.Before(time.Now()) { - delete(usersCache, key) + delete(UsersCache, key) } } @@ -279,9 +290,9 @@ func init() { for range ticker.C { tracksCacheLock.Lock() - for key, val := range tracksCache { + for key, val := range TracksCache { if val.Expires.Before(time.Now()) { - delete(tracksCache, key) + delete(TracksCache, key) } } @@ -294,9 +305,9 @@ func init() { for range ticker.C { playlistsCacheLock.Lock() - for key, val := range playlistsCache { + for key, val := range PlaylistsCache { if val.Expires.Before(time.Now()) { - delete(playlistsCache, key) + delete(PlaylistsCache, key) } } diff --git a/lib/sc/playlist.go b/lib/sc/playlist.go index 5860432..0691a3d 100644 --- a/lib/sc/playlist.go +++ b/lib/sc/playlist.go @@ -10,7 +10,7 @@ import ( "github.com/maid-zone/soundcloak/lib/cfg" ) -var playlistsCache = map[string]cached[Playlist]{} +var PlaylistsCache = map[string]cached[Playlist]{} var playlistsCacheLock = &sync.RWMutex{} // Functions/structures related to playlists @@ -24,20 +24,20 @@ type Playlist struct { Likes int64 `json:"likes_count"` Permalink string `json:"permalink"` //ReleaseDate string `json:"release_date"` - TagList string `json:"tag_list"` - Title string `json:"title"` - Type string `json:"set_type"` - Album bool `json:"is_album"` - Author User `json:"user"` - Tracks []*Track `json:"tracks"` - TrackCount int64 `json:"track_count"` + TagList string `json:"tag_list"` + Title string `json:"title"` + Type string `json:"set_type"` + Album bool `json:"is_album"` + Author User `json:"user"` + Tracks []Track `json:"tracks"` + TrackCount int64 `json:"track_count"` MissingTracks string `json:"-"` } -func GetPlaylist(prefs cfg.Preferences, permalink string) (Playlist, error) { +func GetPlaylist(permalink string) (Playlist, error) { playlistsCacheLock.RLock() - if cell, ok := playlistsCache[permalink]; ok && cell.Expires.After(time.Now()) { + if cell, ok := PlaylistsCache[permalink]; ok && cell.Expires.After(time.Now()) { playlistsCacheLock.RUnlock() return cell.Value, nil } @@ -53,13 +53,13 @@ func GetPlaylist(prefs cfg.Preferences, permalink string) (Playlist, error) { return p, ErrKindNotCorrect } - err = p.Fix(prefs, true) + err = p.Fix(true) if err != nil { return p, err } playlistsCacheLock.Lock() - playlistsCache[permalink] = cached[Playlist]{Value: p, Expires: time.Now().Add(cfg.PlaylistTTL)} + PlaylistsCache[permalink] = cached[Playlist]{Value: p, Expires: time.Now().Add(cfg.PlaylistTTL)} playlistsCacheLock.Unlock() return p, nil @@ -78,19 +78,21 @@ func SearchPlaylists(prefs cfg.Preferences, args string) (*Paginated[*Playlist], } for _, p := range p.Collection { - p.Fix(prefs, false) + p.Fix(false) + p.Postfix(prefs) } return &p, nil } -func (p *Playlist) Fix(prefs cfg.Preferences, cached bool) error { +func (p *Playlist) Fix(cached bool) error { if cached { - for _, t := range p.Tracks { - t.Fix(prefs, false) + for i, t := range p.Tracks { + t.Fix(false) + p.Tracks[i] = t } - err := p.GetMissingTracks(prefs) + err := p.GetMissingTracks() if err != nil { return err } @@ -100,13 +102,23 @@ func (p *Playlist) Fix(prefs cfg.Preferences, cached bool) error { p.Artwork = strings.Replace(p.Artwork, "-large.", "-t200x200.", 1) } + p.Author.Fix(false) + + return nil +} + +func (p *Playlist) Postfix(prefs cfg.Preferences) []Track { if cfg.ProxyImages && *prefs.ProxyImages && p.Artwork != "" { p.Artwork = "/_/proxy/images?url=" + url.QueryEscape(p.Artwork) } - p.Author.Fix(prefs, false) - - return nil + p.Author.Postfix(prefs) + var fixed = make([]Track, len(p.Tracks)) + for i, t := range p.Tracks { + t.Postfix(prefs) + fixed[i] = t + } + return fixed } func (p Playlist) FormatDescription() string { @@ -141,28 +153,28 @@ func JoinMissingTracks(missing []MissingTrack) (st string) { return } -func GetMissingTracks(prefs cfg.Preferences, missing []MissingTrack) (res []*Track, next []MissingTrack, err error) { +func GetMissingTracks(missing []MissingTrack) (res []Track, next []MissingTrack, err error) { if len(missing) > 50 { next = missing[50:] missing = missing[:50] } - res, err = GetTracks(prefs, JoinMissingTracks(missing)) + res, err = GetTracks(JoinMissingTracks(missing)) return } -func GetNextMissingTracks(prefs cfg.Preferences, raw string) (res []*Track, next []string, err error) { +func GetNextMissingTracks(raw string) (res []Track, next []string, err error) { missing := strings.Split(raw, ",") if len(missing) > 50 { next = missing[50:] missing = missing[:50] } - res, err = GetTracks(prefs, strings.Join(missing, ",")) + res, err = GetTracks(strings.Join(missing, ",")) return } -func (p *Playlist) GetMissingTracks(prefs cfg.Preferences) error { +func (p *Playlist) GetMissingTracks() error { missing := []MissingTrack{} for i, track := range p.Tracks { if track.Title == "" { @@ -174,7 +186,7 @@ func (p *Playlist) GetMissingTracks(prefs cfg.Preferences) error { return nil } - res, next, err := GetMissingTracks(prefs, missing) + res, next, err := GetMissingTracks(missing) if err != nil { return err } diff --git a/lib/sc/track.go b/lib/sc/track.go index 13424b3..2d10b28 100644 --- a/lib/sc/track.go +++ b/lib/sc/track.go @@ -19,7 +19,7 @@ import ( var ErrIncompatibleStream = errors.New("incompatible stream") var ErrNoURL = errors.New("no url") -var tracksCache = map[string]cached[Track]{} +var TracksCache = map[string]cached[Track]{} var tracksCacheLock = &sync.RWMutex{} type Track struct { @@ -90,9 +90,9 @@ func (m Media) SelectCompatible() *Transcoding { return nil } -func GetTrack(prefs cfg.Preferences, permalink string) (Track, error) { +func GetTrack(permalink string) (Track, error) { tracksCacheLock.RLock() - if cell, ok := tracksCache[permalink]; ok && cell.Expires.After(time.Now()) { + if cell, ok := TracksCache[permalink]; ok && cell.Expires.After(time.Now()) { tracksCacheLock.RUnlock() return cell.Value, nil } @@ -108,10 +108,10 @@ func GetTrack(prefs cfg.Preferences, permalink string) (Track, error) { return t, ErrKindNotCorrect } - t.Fix(prefs, true) + t.Fix(true) tracksCacheLock.Lock() - tracksCache[permalink] = cached[Track]{Value: t, Expires: time.Now().Add(cfg.TrackTTL)} + TracksCache[permalink] = cached[Track]{Value: t, Expires: time.Now().Add(cfg.TrackTTL)} tracksCacheLock.Unlock() return t, nil @@ -125,12 +125,12 @@ func GetTrack(prefs cfg.Preferences, permalink string) (Track, error) { // plain permalink/id: // - / // - -func GetArbitraryTrack(prefs cfg.Preferences, data string) (Track, error) { +func GetArbitraryTrack(data string) (Track, error) { if len(data) > 8 && (data[:8] == "https://" || data[:7] == "http://") { u, err := url.Parse(data) if err == nil { if (u.Host == "api.soundcloud.com" || u.Host == "api-v2.soundcloud.com") && len(u.Path) > 8 && u.Path[:8] == "/tracks/" { - return GetTrackByID(prefs, u.Path[8:]) + return GetTrackByID(u.Path[8:]) } if u.Host == "soundcloud.com" { @@ -158,7 +158,7 @@ func GetArbitraryTrack(prefs cfg.Preferences, data string) (Track, error) { return Track{}, ErrKindNotCorrect } - return GetTrack(prefs, u.Path) + return GetTrack(u.Path) } } else { return Track{}, err @@ -174,7 +174,7 @@ func GetArbitraryTrack(prefs cfg.Preferences, data string) (Track, error) { } if valid { - return GetTrackByID(prefs, data) + return GetTrackByID(data) } // this part should be at the end since it manipulates data @@ -197,7 +197,7 @@ func GetArbitraryTrack(prefs cfg.Preferences, data string) (Track, error) { } if n == 1 { - return GetTrack(prefs, data) + return GetTrack(data) } // failed to find a data point @@ -217,13 +217,14 @@ func SearchTracks(prefs cfg.Preferences, args string) (*Paginated[*Track], error } for _, t := range p.Collection { - t.Fix(prefs, false) + t.Fix(false) + t.Postfix(prefs) } return &p, nil } -func GetTracks(prefs cfg.Preferences, ids string) ([]*Track, error) { +func GetTracks(ids string) ([]Track, error) { cid, err := GetClientID() if err != nil { return nil, err @@ -249,10 +250,11 @@ func GetTracks(prefs cfg.Preferences, ids string) ([]*Track, error) { data = resp.Body() } - var res []*Track + var res []Track err = json.Unmarshal(data, &res) - for _, t := range res { - t.Fix(prefs, false) + for i, t := range res { + t.Fix(false) + res[i] = t } return res, err } @@ -304,7 +306,7 @@ func (tr Transcoding) GetStream(prefs cfg.Preferences, authorization string) (st return s.URL, nil } -func (t *Track) Fix(prefs cfg.Preferences, large bool) { +func (t *Track) Fix(large bool) { if large { t.Artwork = strings.Replace(t.Artwork, "-large.", "-t500x500.", 1) } else { @@ -318,11 +320,14 @@ func (t *Track) Fix(prefs cfg.Preferences, large bool) { t.ID = ls[len(ls)-1] } + t.Author.Fix(false) +} + +func (t *Track) Postfix(prefs cfg.Preferences) { if cfg.ProxyImages && *prefs.ProxyImages && t.Artwork != "" { t.Artwork = "/_/proxy/images?url=" + url.QueryEscape(t.Artwork) } - - t.Author.Fix(prefs, false) + t.Author.Postfix(prefs) } func (t Track) FormatDescription() string { @@ -344,14 +349,14 @@ func (t Track) FormatDescription() string { return desc } -func GetTrackByID(prefs cfg.Preferences, id string) (Track, error) { +func GetTrackByID(id string) (Track, error) { cid, err := GetClientID() if err != nil { return Track{}, err } tracksCacheLock.RLock() - for _, cell := range tracksCache { + for _, cell := range TracksCache { if cell.Value.ID == id && cell.Expires.After(time.Now()) { tracksCacheLock.RUnlock() return cell.Value, nil @@ -389,11 +394,37 @@ func GetTrackByID(prefs cfg.Preferences, id string) (Track, error) { return t, ErrKindNotCorrect } - t.Fix(prefs, true) + t.Fix(true) tracksCacheLock.Lock() - tracksCache[t.Author.Permalink+"/"+t.Permalink] = cached[Track]{Value: t, Expires: time.Now().Add(cfg.TrackTTL)} + TracksCache[t.Author.Permalink+"/"+t.Permalink] = cached[Track]{Value: t, Expires: time.Now().Add(cfg.TrackTTL)} tracksCacheLock.Unlock() return t, nil } + +func (t Track) DownloadImage() ([]byte, string, error) { + req := fasthttp.AcquireRequest() + defer fasthttp.ReleaseRequest(req) + + req.SetRequestURI(t.Artwork) + req.Header.Set("User-Agent", cfg.UserAgent) + req.Header.Set("Accept-Encoding", "gzip, deflate, br, zstd") + + resp := fasthttp.AcquireResponse() + defer fasthttp.ReleaseResponse(resp) + + err := DoWithRetry(ImageClient, req, resp) + if err != nil { + fmt.Println(t.Artwork) + fmt.Println("hi", err) + return nil, "", err + } + + data, err := resp.BodyUncompressed() + if err != nil { + data = resp.Body() + } + + return data, string(resp.Header.Peek("Content-Type")), nil +} diff --git a/lib/sc/user.go b/lib/sc/user.go index a22347f..117e2fa 100644 --- a/lib/sc/user.go +++ b/lib/sc/user.go @@ -12,7 +12,7 @@ import ( // Functions/structures related to users -var usersCache = map[string]cached[User]{} +var UsersCache = map[string]cached[User]{} var usersCacheLock = &sync.RWMutex{} type User struct { @@ -52,20 +52,22 @@ func (r *Repost) Fix(prefs cfg.Preferences) { switch r.Type { case TrackRepost: if r.Track != nil { - r.Track.Fix(prefs, false) + r.Track.Fix(false) + r.Track.Postfix(prefs) } return case PlaylistRepost: if r.Playlist != nil { - r.Playlist.Fix(prefs, false) // err always nil if cached == false + r.Playlist.Fix(false) // err always nil if cached == false + r.Playlist.Postfix(prefs) } return } } -func GetUser(prefs cfg.Preferences, permalink string) (User, error) { +func GetUser(permalink string) (User, error) { usersCacheLock.RLock() - if cell, ok := usersCache[permalink]; ok && cell.Expires.After(time.Now()) { + if cell, ok := UsersCache[permalink]; ok && cell.Expires.After(time.Now()) { usersCacheLock.RUnlock() return cell.Value, nil } @@ -83,10 +85,10 @@ func GetUser(prefs cfg.Preferences, permalink string) (User, error) { return u, err } - u.Fix(prefs, true) + u.Fix(true) usersCacheLock.Lock() - usersCache[permalink] = cached[User]{Value: u, Expires: time.Now().Add(cfg.UserTTL)} + UsersCache[permalink] = cached[User]{Value: u, Expires: time.Now().Add(cfg.UserTTL)} usersCacheLock.Unlock() return u, err @@ -105,7 +107,8 @@ func SearchUsers(prefs cfg.Preferences, args string) (*Paginated[*User], error) } for _, u := range p.Collection { - u.Fix(prefs, false) + u.Fix(false) + u.Postfix(prefs) } return &p, nil @@ -121,8 +124,9 @@ func (u User) GetTracks(prefs cfg.Preferences, args string) (*Paginated[*Track], return nil, err } - for _, u := range p.Collection { - u.Fix(prefs, false) + for _, t := range p.Collection { + t.Fix(false) + t.Postfix(prefs) } return &p, nil @@ -151,7 +155,7 @@ func (u User) FormatUsername() string { return res } -func (u *User) Fix(prefs cfg.Preferences, large bool) { +func (u *User) Fix(large bool) { if large { u.Avatar = strings.Replace(u.Avatar, "-large.", "-t500x500.", 1) } else { @@ -163,12 +167,14 @@ func (u *User) Fix(prefs cfg.Preferences, large bool) { u.Avatar = "" } + ls := strings.Split(u.ID, ":") + u.ID = ls[len(ls)-1] +} + +func (u *User) Postfix(prefs cfg.Preferences) { if cfg.ProxyImages && *prefs.ProxyImages && u.Avatar != "" { u.Avatar = "/_/proxy/images?url=" + url.QueryEscape(u.Avatar) } - - ls := strings.Split(u.ID, ":") - u.ID = ls[len(ls)-1] } func (u *User) GetPlaylists(prefs cfg.Preferences, args string) (*Paginated[*Playlist], error) { @@ -182,7 +188,8 @@ func (u *User) GetPlaylists(prefs cfg.Preferences, args string) (*Paginated[*Pla } for _, pl := range p.Collection { - pl.Fix(prefs, false) + pl.Fix(false) + pl.Postfix(prefs) } return &p, nil @@ -199,7 +206,8 @@ func (u *User) GetAlbums(prefs cfg.Preferences, args string) (*Paginated[*Playli } for _, pl := range p.Collection { - pl.Fix(prefs, false) + pl.Fix(false) + pl.Postfix(prefs) } return &p, nil diff --git a/main.go b/main.go index c8137a2..a46ccb3 100644 --- a/main.go +++ b/main.go @@ -37,6 +37,26 @@ func main() { app.Static("/", "assets", fiber.Static{Compress: true, MaxAge: 3600}) // 1hour app.Static("/js/hls.js/", "node_modules/hls.js/dist", fiber.Static{Compress: true, MaxAge: 28800}) // 8 hours + // Just for easy inspection of cache in development. Since debug is constant, the compiler will just remove the code below if it's set to false, so this has no runtime overhead. + const debug = false + if debug { + app.Get("/_/cachedump/tracks", func(c *fiber.Ctx) error { + return c.JSON(sc.TracksCache) + }) + + app.Get("/_/cachedump/playlists", func(c *fiber.Ctx) error { + return c.JSON(sc.PlaylistsCache) + }) + + app.Get("/_/cachedump/users", func(c *fiber.Ctx) error { + return c.JSON(sc.UsersCache) + }) + + app.Get("/_/cachedump/clientId", func(c *fiber.Ctx) error { + return c.JSON(sc.ClientIDCache) + }) + } + app.Get("/search", func(c *fiber.Ctx) error { prefs, err := preferences.Get(c) if err != nil { @@ -127,12 +147,13 @@ func main() { return err } - track, err := sc.GetArbitraryTrack(prefs, u) - + track, err := sc.GetArbitraryTrack(u) if err != nil { log.Printf("error getting %s: %s\n", u, err) return err } + track.Postfix(prefs) + displayErr := "" stream := "" @@ -226,11 +247,12 @@ func main() { return err } - user, err := sc.GetUser(prefs, c.Params("user")) + user, err := sc.GetUser(c.Params("user")) if err != nil { log.Printf("error getting %s (playlists): %s\n", c.Params("user"), err) return err } + user.Postfix(prefs) pl, err := user.GetPlaylists(prefs, c.Query("pagination", "?limit=20")) if err != nil { @@ -248,11 +270,12 @@ func main() { return err } - user, err := sc.GetUser(prefs, c.Params("user")) + user, err := sc.GetUser(c.Params("user")) if err != nil { log.Printf("error getting %s (albums): %s\n", c.Params("user"), err) return err } + user.Postfix(prefs) pl, err := user.GetAlbums(prefs, c.Query("pagination", "?limit=20")) if err != nil { @@ -270,11 +293,12 @@ func main() { return err } - user, err := sc.GetUser(prefs, c.Params("user")) + user, err := sc.GetUser(c.Params("user")) if err != nil { log.Printf("error getting %s (reposts): %s\n", c.Params("user"), err) return err } + user.Postfix(prefs) p, err := user.GetReposts(prefs, c.Query("pagination", "?limit=20")) if err != nil { @@ -292,11 +316,13 @@ func main() { return err } - track, err := sc.GetTrack(prefs, c.Params("user")+"/"+c.Params("track")) + track, err := sc.GetTrack(c.Params("user") + "/" + c.Params("track")) if err != nil { log.Printf("error getting %s from %s: %s\n", c.Params("track"), c.Params("user"), err) return err } + track.Postfix(prefs) + displayErr := "" stream := "" @@ -327,11 +353,12 @@ func main() { } //h := time.Now() - usr, err := sc.GetUser(prefs, c.Params("user")) + usr, err := sc.GetUser(c.Params("user")) if err != nil { log.Printf("error getting %s: %s\n", c.Params("user"), err) return err } + usr.Postfix(prefs) //fmt.Println("getuser", time.Since(h)) //h = time.Now() @@ -352,20 +379,27 @@ func main() { return err } - playlist, err := sc.GetPlaylist(prefs, c.Params("user")+"/sets/"+c.Params("playlist")) + playlist, err := sc.GetPlaylist(c.Params("user") + "/sets/" + c.Params("playlist")) if err != nil { log.Printf("error getting %s playlist from %s: %s\n", c.Params("playlist"), c.Params("user"), err) return err } + // Don't ask why + playlist.Tracks = playlist.Postfix(prefs) p := c.Query("pagination") if p != "" { - tracks, next, err := sc.GetNextMissingTracks(prefs, p) + tracks, next, err := sc.GetNextMissingTracks(p) if err != nil { log.Printf("error getting %s playlist tracks from %s: %s\n", c.Params("playlist"), c.Params("user"), err) return err } + for i, track := range tracks { + track.Postfix(prefs) + tracks[i] = track + } + playlist.Tracks = tracks playlist.MissingTracks = strings.Join(next, ",") } diff --git a/templates/playlist.templ b/templates/playlist.templ index c4c977e..33f4a2c 100644 --- a/templates/playlist.templ +++ b/templates/playlist.templ @@ -51,7 +51,7 @@ templ Playlist(prefs cfg.Preferences, p sc.Playlist) {
for _, track := range p.Tracks { - @TrackItem(track, true) + @TrackItem(&track, true) }
if len(p.MissingTracks) != 0 { diff --git a/templates/track.templ b/templates/track.templ index 79b4998..f50c5d8 100644 --- a/templates/track.templ +++ b/templates/track.templ @@ -82,8 +82,11 @@ templ Track(prefs cfg.Preferences, t sc.Track, stream string, displayErr string) } @UserItem(&t.Author) //
-
+
view on soundcloud + if cfg.Restream { + download + }

if t.Description != "" {