From 5e96cee22ee90271481a6546f2421975392e25e3 Mon Sep 17 00:00:00 2001 From: Laptop Date: Fri, 20 Jun 2025 21:52:25 +0300 Subject: [PATCH] API and many small fixes --- docs/API.md | 46 +++++++++++++++++++++++++++++++ docs/INSTANCE_GUIDE.md | 5 ++-- lib/api/init.go | 58 +++++++++++++++++++++++++++++++++++++++ lib/cfg/init.go | 12 ++++++++ lib/proxy_images/init.go | 1 - lib/proxy_streams/init.go | 11 +------- lib/restream/init.go | 31 ++++++++------------- lib/restream/reader.go | 16 ++--------- main.go | 26 ++++++++++++------ templates/user.templ | 17 ++++++------ 10 files changed, 161 insertions(+), 62 deletions(-) create mode 100644 docs/API.md create mode 100644 lib/api/init.go diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..80df7a3 --- /dev/null +++ b/docs/API.md @@ -0,0 +1,46 @@ +# Check enabled features + +Just go to `/_/info` endpoint + +# API + +To make use of it, instance must have API enabled of course. Currently, there is only one functionality present. If you are working on some cool project and wanna have more functionality here, let me know + +## Searching + +You can search with endpoint `/_/api/search`. Query parameters are: +- `q`: the query +- `type`: `users`, `tracks`, or `playlists`. Required +- `pagination`: raw parameters to pass into soundcloud's api + +The response is in JSON format. To go to next page, take the `next_href` from result, strip away everything until `?`, and pass that as `pagination` parameter. + +For example: `/_/api/search?q=test&type=tracks` to search for `tracks` named `test` + +# Other automation + +Doesn't require API to be enabled + +## Download songs + +Restream must be enabled in the instance. The endpoint is `/_/restream//`. Query parameters are: +- `metadata`: `true` or `false`. If `true`, soundcloak will inject metadata (author, track cover, track title, etc) into the audio file, but this may take a little bit more time +- `audio`: `best`, `aac`, `opus`, or `mpeg`. [Read more here](AUDIO_PRESETS.md) + +For example: `/_/restream/lucybedroque/speakers?metadata=true&audio=opus` to get the `opus` audio with `metadata` for song `speakers` by author `lucybedroque` + +## Get search suggestions + +The endpoint is `/_/searchSuggestions`. Query parameters are: +- `q`: the query + +The response is a list of search suggestions as strings in JSON format. + +For example: `/_/searchSuggestions?q=hi` to get search suggestions for `hi` + +## Proxy images + +ProxyImages must be enabled in the instance. + +Endpoint for images: `/_/proxy/images`. Put image url into `url` query parameter. Of course, this only proxies images from soundcloud cdn + diff --git a/docs/INSTANCE_GUIDE.md b/docs/INSTANCE_GUIDE.md index aec9f88..94c4cd2 100644 --- a/docs/INSTANCE_GUIDE.md +++ b/docs/INSTANCE_GUIDE.md @@ -117,9 +117,10 @@ Some notes: | PlaylistCacheCleanDelay | PLAYLIST_CACHE_CLEAN_DELAY | 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 | 60 minutes | Time until DNS cache expires | +| EnableAPI | ENABLE_API | false | Should [API](API.md) be enabled? | | Network | NETWORK | tcp4 | Network to listen on. Can be tcp4, tcp6 or unix | | Addr | ADDR | :4664 | Address and port (or socket path) for soundcloak to listen on | -| UnixSocketPerms | UNIX_SOCKET_PERMS | 0775 | Permissions for unix socket (Network must be set to unix) on | +| UnixSocketPerms | UNIX_SOCKET_PERMS | 0775 | Permissions for unix socket (Network must be set to unix) | | 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 | @@ -170,7 +171,7 @@ To get listed on [the instance list](https://maid.zone/soundcloak/instances.html Basic rules: 1. Do not collect user information (either yourself, or by including 3rd party tooling which does that) -2. If you are modifying the source code, publish those changes somewhere. Even if it's just static files, it would be best to publish those changes somewhere. +2. If you are modifying the source code, publish those changes somewhere. Also, keep in mind that the instance list will periodically hit the `/_/info` endpoint on your instance (usually each 10 minutes) in order to display the instance settings. If you do not want this to happen, state it in your discussion/message, and I will exclude your instance from this checking. diff --git a/lib/api/init.go b/lib/api/init.go new file mode 100644 index 0000000..0bebe70 --- /dev/null +++ b/lib/api/init.go @@ -0,0 +1,58 @@ +package api + +import ( + "log" + "net/url" + + "git.maid.zone/stuff/soundcloak/lib/cfg" + "git.maid.zone/stuff/soundcloak/lib/preferences" + "git.maid.zone/stuff/soundcloak/lib/sc" + "github.com/gofiber/fiber/v3" +) + +func Load(r *fiber.App) { + r.Get("/_/api/search", func(c fiber.Ctx) error { + prefs, err := preferences.Get(c) + if err != nil { + return err + } + + q := cfg.B2s(c.RequestCtx().QueryArgs().Peek("q")) + t := cfg.B2s(c.RequestCtx().QueryArgs().Peek("type")) + args := cfg.B2s(c.RequestCtx().QueryArgs().Peek("pagination")) + if args == "" { + args = "?q=" + url.QueryEscape(q) + } + + switch t { + case "tracks": + p, err := sc.SearchTracks("", prefs, args) + if err != nil { + log.Printf("[API] error getting tracks for %s: %s\n", q, err) + return err + } + + return c.JSON(p) + + case "users": + p, err := sc.SearchUsers("", prefs, args) + if err != nil { + log.Printf("[API] error getting users for %s: %s\n", q, err) + return err + } + + return c.JSON(p) + + case "playlists": + p, err := sc.SearchPlaylists("", prefs, args) + if err != nil { + log.Printf("[API] error getting playlists for %s: %s\n", q, err) + return err + } + + return c.JSON(p) + } + + return c.SendStatus(404) + }) +} diff --git a/lib/cfg/init.go b/lib/cfg/init.go index 5bbbe11..0697daa 100644 --- a/lib/cfg/init.go +++ b/lib/cfg/init.go @@ -69,6 +69,9 @@ var UserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (K // time-to-live for dns cache var DNSCacheTTL = 60 * time.Minute +// enab;e api +var EnableAPI = false + // // // some webserver configuration, put here to make it easier to configure what you need // // // // more info can be found here: https://docs.gofiber.io/api/fiber#config @@ -387,6 +390,11 @@ func fromEnv() error { DNSCacheTTL = time.Duration(num) * time.Second } + env = os.Getenv("ENABLE_API") + if env != "" { + EnableAPI = boolean(env) + } + env = os.Getenv("NETWORK") if env != "" { Network = env @@ -480,6 +488,7 @@ func init() { PlaylistCacheCleanDelay *time.Duration UserAgent *string DNSCacheTTL *time.Duration + EnableAPI *bool Network *string Addr *string UnixSocketPerms *string @@ -547,6 +556,9 @@ func init() { if config.DNSCacheTTL != nil { DNSCacheTTL = *config.DNSCacheTTL * time.Second } + if config.EnableAPI != nil { + EnableAPI = *config.EnableAPI + } if config.Network != nil { Network = *config.Network } diff --git a/lib/proxy_images/init.go b/lib/proxy_images/init.go index ef6d810..dd50cbd 100644 --- a/lib/proxy_images/init.go +++ b/lib/proxy_images/init.go @@ -54,7 +54,6 @@ func Load(r *fiber.App) { req.SetURI(parsed) req.Header.SetUserAgent(cfg.UserAgent) - //req.Header.Set("Accept-Encoding", "gzip, deflate, br, zstd") images not big enough to be compressed resp := fasthttp.AcquireResponse() //defer fasthttp.ReleaseResponse(resp) moved to proxyreader!!! diff --git a/lib/proxy_streams/init.go b/lib/proxy_streams/init.go index 1387282..f3413cb 100644 --- a/lib/proxy_streams/init.go +++ b/lib/proxy_streams/init.go @@ -42,7 +42,6 @@ func Load(a *fiber.App) { req.SetURI(parsed) req.Header.SetUserAgent(cfg.UserAgent) - req.Header.Set("Accept-Encoding", "gzip, deflate, br, zstd") resp := fasthttp.AcquireResponse() //defer fasthttp.ReleaseResponse(resp) @@ -81,7 +80,6 @@ func Load(a *fiber.App) { req.SetURI(parsed) req.Header.SetUserAgent(cfg.UserAgent) - req.Header.Set("Accept-Encoding", "gzip, deflate, br, zstd") resp := fasthttp.AcquireResponse() @@ -119,7 +117,6 @@ func Load(a *fiber.App) { req.SetURI(parsed) req.Header.SetUserAgent(cfg.UserAgent) - req.Header.Set("Accept-Encoding", "gzip, deflate, br, zstd") resp := fasthttp.AcquireResponse() defer fasthttp.ReleaseResponse(resp) @@ -170,7 +167,6 @@ func Load(a *fiber.App) { req.SetURI(parsed) req.Header.SetUserAgent(cfg.UserAgent) - req.Header.Set("Accept-Encoding", "gzip, deflate, br, zstd") resp := fasthttp.AcquireResponse() defer fasthttp.ReleaseResponse(resp) @@ -180,12 +176,7 @@ func Load(a *fiber.App) { return err } - data, err := resp.BodyUncompressed() - if err != nil { - data = resp.Body() - } - - var sp = bytes.Split(data, newline) + var sp = bytes.Split(resp.Body(), newline) for i, l := range sp { if len(l) == 0 { continue diff --git a/lib/restream/init.go b/lib/restream/init.go index 1064cc2..9ab4012 100644 --- a/lib/restream/init.go +++ b/lib/restream/init.go @@ -48,14 +48,19 @@ func Load(r *fiber.App) { } var isDownload = string(c.RequestCtx().QueryArgs().Peek("metadata")) == "true" - var quality *string - if isDownload { - quality = p.DownloadAudio + var forcedQuality = c.RequestCtx().QueryArgs().Peek("audio") + var quality string + if len(forcedQuality) != 0 { + quality = cfg.B2s(forcedQuality) } else { - quality = p.RestreamAudio + if isDownload { + quality = *p.DownloadAudio + } else { + quality = *p.RestreamAudio + } } - tr, audio := t.Media.SelectCompatible(*quality, true) + tr, audio := t.Media.SelectCompatible(quality, true) if tr == nil { return fiber.ErrExpectationFailed } @@ -92,7 +97,6 @@ func Load(r *fiber.App) { if t.Artwork != "" { r.req.SetRequestURI(t.Artwork) - r.req.Header.Del("Accept-Encoding") err := sc.DoWithRetry(misc.ImageClient, r.req, r.resp) if err != nil { @@ -100,7 +104,6 @@ func Load(r *fiber.App) { } tag.AddAttachedPicture(id3v2.PictureFrame{MimeType: cfg.B2s(r.req.Header.ContentType()), Picture: r.req.Body(), PictureType: id3v2.PTFrontCover, Encoding: id3v2.EncodingUTF8}) - r.req.Header.Set("Accept-Encoding", "gzip, deflate, br, zstd") } var col collector @@ -115,17 +118,13 @@ func Load(r *fiber.App) { req.SetRequestURI(u) req.Header.SetUserAgent(cfg.UserAgent) - req.Header.Set("Accept-Encoding", "gzip, deflate, br, zstd") err := sc.DoWithRetry(misc.HlsClient, req, resp) if err != nil { return err } - data, err := resp.BodyUncompressed() - if err != nil { - data = resp.Body() - } + data := resp.Body() res := make([]byte, 0, 1024*1024*1) for _, s := range bytes.Split(data, []byte{'\n'}) { @@ -139,10 +138,7 @@ func Load(r *fiber.App) { return err } - data, err = resp.BodyUncompressed() - if err != nil { - data = resp.Body() - } + data = resp.Body() res = append(res, data...) } @@ -161,7 +157,6 @@ func Load(r *fiber.App) { if t.Artwork != "" { req.SetRequestURI(t.Artwork) - req.Header.Del("Accept-Encoding") err := sc.DoWithRetry(misc.ImageClient, req, resp) if err != nil { @@ -207,7 +202,6 @@ func Load(r *fiber.App) { if t.Artwork != "" { r.req.SetRequestURI(t.Artwork) - r.req.Header.Del("Accept-Encoding") err := sc.DoWithRetry(misc.ImageClient, r.req, r.resp) if err != nil { @@ -221,7 +215,6 @@ func Load(r *fiber.App) { } tag.SetCoverArt(&parsed) - r.req.Header.Set("Accept-Encoding", "gzip, deflate, br, zstd") } var col collector diff --git a/lib/restream/reader.go b/lib/restream/reader.go index 51171ef..f3d8a93 100644 --- a/lib/restream/reader.go +++ b/lib/restream/reader.go @@ -66,7 +66,6 @@ func (r *reader) Setup(url string, aac bool, duration *uint32) error { r.req.SetRequestURI(url) r.req.Header.SetUserAgent(cfg.UserAgent) - r.req.Header.Set("Accept-Encoding", "gzip, deflate, br, zstd") if aac { r.client = misc.HlsAacClient @@ -80,11 +79,6 @@ func (r *reader) Setup(url string, aac bool, duration *uint32) error { return err } - data, err := r.resp.BodyUncompressed() - if err != nil { - data = r.resp.Body() - } - if r.parts == nil { misc.Log("make() r.parts") r.parts = make([][]byte, 0, defaultPartsCapacity) @@ -93,7 +87,7 @@ func (r *reader) Setup(url string, aac bool, duration *uint32) error { } if aac { // clone needed to mitigate memory skill issues here - for _, s := range bytes.Split(data, []byte{'\n'}) { + for _, s := range bytes.Split(r.resp.Body(), []byte{'\n'}) { if len(s) == 0 { continue } @@ -108,7 +102,7 @@ func (r *reader) Setup(url string, aac bool, duration *uint32) error { r.parts = append(r.parts, clone(s)) } } else { - for _, s := range bytes.Split(data, []byte{'\n'}) { + for _, s := range bytes.Split(r.resp.Body(), []byte{'\n'}) { if len(s) == 0 || s[0] == '#' { continue } @@ -169,11 +163,7 @@ func (r *reader) Read(buf []byte) (n int, err error) { return } - data, err := r.resp.BodyUncompressed() - if err != nil { - data = r.resp.Body() - } - + data := r.resp.Body() if r.index == 0 && r.duration != nil { fixDuration(data, r.duration) // I'm guessing that mvhd will always be in first part } diff --git a/main.go b/main.go index b62146b..7259a64 100644 --- a/main.go +++ b/main.go @@ -12,6 +12,7 @@ import ( "strings" "sync" + "git.maid.zone/stuff/soundcloak/lib/api" "git.maid.zone/stuff/soundcloak/lib/misc" "github.com/a-h/templ" "github.com/gofiber/fiber/v3" @@ -347,11 +348,16 @@ Disallow: /`) return err } - q := c.Query("q") - t := c.Query("type") + q := cfg.B2s(c.RequestCtx().QueryArgs().Peek("q")) + t := cfg.B2s(c.RequestCtx().QueryArgs().Peek("type")) + args := cfg.B2s(c.RequestCtx().QueryArgs().Peek("pagination")) + if args == "" { + args = "?q=" + url.QueryEscape(q) + } + switch t { case "tracks": - p, err := sc.SearchTracks("", prefs, c.Query("pagination", "?q="+url.QueryEscape(q))) + p, err := sc.SearchTracks("", prefs, args) if err != nil { log.Printf("error getting tracks for %s: %s\n", q, err) return err @@ -360,7 +366,7 @@ Disallow: /`) return r(c, "tracks: "+q, templates.SearchTracks(p), nil) case "users": - p, err := sc.SearchUsers("", prefs, c.Query("pagination", "?q="+url.QueryEscape(q))) + p, err := sc.SearchUsers("", prefs, args) if err != nil { log.Printf("error getting users for %s: %s\n", q, err) return err @@ -369,9 +375,9 @@ Disallow: /`) return r(c, "users: "+q, templates.SearchUsers(p), nil) case "playlists": - p, err := sc.SearchPlaylists("", prefs, c.Query("pagination", "?q="+url.QueryEscape(q))) + p, err := sc.SearchPlaylists("", prefs, args) if err != nil { - log.Printf("error getting users for %s: %s\n", q, err) + log.Printf("error getting playlists for %s: %s\n", q, err) return err } @@ -417,8 +423,6 @@ Disallow: /`) return fiber.ErrNotFound } - //fmt.Println(c.Hostname(), c.Protocol(), c.IPs()) - u, err := url.Parse(cfg.B2s(loc)) if err != nil { return err @@ -544,6 +548,10 @@ Disallow: /`) proxystreams.Load(app) } + if cfg.EnableAPI { + api.Load(app) + } + if cfg.InstanceInfo { type info struct { Commit string @@ -553,6 +561,7 @@ Disallow: /`) Restream bool GetWebProfiles bool DefaultPreferences cfg.Preferences + EnableAPI bool } inf, err := json.Marshal(info{ @@ -563,6 +572,7 @@ Disallow: /`) Restream: cfg.Restream, GetWebProfiles: cfg.GetWebProfiles, DefaultPreferences: cfg.DefaultPreferences, + EnableAPI: cfg.EnableAPI, }) if err != nil { log.Fatalln("failed to marshal info: ", err) diff --git a/templates/user.templ b/templates/user.templ index 4f69841..5f7da94 100644 --- a/templates/user.templ +++ b/templates/user.templ @@ -5,7 +5,6 @@ import ( "git.maid.zone/stuff/soundcloak/lib/sc" "net/url" "strconv" - "strings" ) templ UserHeader(u sc.User) { @@ -115,7 +114,7 @@ templ User(prefs cfg.Preferences, u sc.User, p *sc.Paginated[*sc.Track]) { } if p.Next != "" && len(p.Collection) != int(u.Tracks) { - more tracks + more tracks } } else { no more tracks @@ -133,7 +132,7 @@ templ UserPlaylists(prefs cfg.Preferences, u sc.User, p *sc.Paginated[*sc.Playli } if p.Next != "" && len(p.Collection) != int(p.Total) { - more playlists + more playlists } } else { no more playlists @@ -151,7 +150,7 @@ templ UserAlbums(prefs cfg.Preferences, u sc.User, p *sc.Paginated[*sc.Playlist] } if p.Next != "" && len(p.Collection) != int(p.Total) { - more albums + more albums } } else { no more albums @@ -173,7 +172,7 @@ templ UserReposts(prefs cfg.Preferences, u sc.User, p *sc.Paginated[*sc.Repost]) } if p.Next != "" && len(p.Collection) != int(p.Total) { - more reposts + more reposts } } else { no more reposts @@ -195,7 +194,7 @@ templ UserLikes(prefs cfg.Preferences, u sc.User, p *sc.Paginated[*sc.Like]) { } if p.Next != "" && len(p.Collection) != int(p.Total) { - more likes + more likes } } else { no more likes @@ -243,7 +242,7 @@ templ UserFollowers(prefs cfg.Preferences, u sc.User, p *sc.Paginated[*sc.User]) } if p.Next != "" && len(p.Collection) != int(p.Total) { - more users + more users } } else { no more users @@ -261,7 +260,7 @@ templ UserFollowing(prefs cfg.Preferences, u sc.User, p *sc.Paginated[*sc.User]) } if p.Next != "" && len(p.Collection) != int(p.Total) { - more users + more users } } else { no more users @@ -281,7 +280,7 @@ templ SearchUsers(p *sc.Paginated[*sc.User]) { @UserItem(user) } if p.Next != "" && len(p.Collection) != int(p.Total) { - more users + more users } } }