API and many small fixes

This commit is contained in:
Laptop
2025-06-20 21:52:25 +03:00
parent a0551d742b
commit 5e96cee22e
10 changed files with 161 additions and 62 deletions

46
docs/API.md Normal file
View File

@@ -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/<author permalink>/<track permalink>`. 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

View File

@@ -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.

58
lib/api/init.go Normal file
View File

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

View File

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

View File

@@ -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!!!

View File

@@ -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

View File

@@ -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

View File

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

26
main.go
View File

@@ -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)

View File

@@ -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]) {
}
</div>
if p.Next != "" && len(p.Collection) != int(u.Tracks) {
<a class="btn" href={ templ.SafeURL("?pagination=" + url.QueryEscape(strings.Split(p.Next, "/tracks")[1])) } rel="noreferrer">more tracks</a>
<a class="btn" href={ templ.SafeURL("?pagination=" + url.QueryEscape(p.Next[sc.H+len("/users/")+len(u.ID)+len("/tracks"):])) } rel="noreferrer">more tracks</a>
}
} else {
<span>no more tracks</span>
@@ -133,7 +132,7 @@ templ UserPlaylists(prefs cfg.Preferences, u sc.User, p *sc.Paginated[*sc.Playli
}
</div>
if p.Next != "" && len(p.Collection) != int(p.Total) {
<a class="btn" href={ templ.SafeURL("?pagination=" + url.QueryEscape(strings.Split(p.Next, "/playlists_without_albums")[1])) } rel="noreferrer">more playlists</a>
<a class="btn" href={ templ.SafeURL("?pagination=" + url.QueryEscape(p.Next[sc.H+len("/users/")+len(u.ID)+len("/playlists_without_albums"):])) } rel="noreferrer">more playlists</a>
}
} else {
<span>no more playlists</span>
@@ -151,7 +150,7 @@ templ UserAlbums(prefs cfg.Preferences, u sc.User, p *sc.Paginated[*sc.Playlist]
}
</div>
if p.Next != "" && len(p.Collection) != int(p.Total) {
<a class="btn" href={ templ.SafeURL("?pagination=" + url.QueryEscape(strings.Split(p.Next, "/albums")[1])) } rel="noreferrer">more albums</a>
<a class="btn" href={ templ.SafeURL("?pagination=" + url.QueryEscape(p.Next[sc.H+len("/users/")+len(u.ID)+len("/albums"):])) } rel="noreferrer">more albums</a>
}
} else {
<span>no more albums</span>
@@ -173,7 +172,7 @@ templ UserReposts(prefs cfg.Preferences, u sc.User, p *sc.Paginated[*sc.Repost])
}
</div>
if p.Next != "" && len(p.Collection) != int(p.Total) {
<a class="btn" href={ templ.SafeURL("?pagination=" + url.QueryEscape(strings.Split(p.Next, "/reposts")[1])) } rel="noreferrer">more reposts</a>
<a class="btn" href={ templ.SafeURL("?pagination=" + url.QueryEscape(p.Next[sc.H+len("/stream/users/")+len(u.ID)+len("/reposts"):])) } rel="noreferrer">more reposts</a>
}
} else {
<span>no more reposts</span>
@@ -195,7 +194,7 @@ templ UserLikes(prefs cfg.Preferences, u sc.User, p *sc.Paginated[*sc.Like]) {
}
</div>
if p.Next != "" && len(p.Collection) != int(p.Total) {
<a class="btn" href={ templ.SafeURL("?pagination=" + url.QueryEscape(strings.Split(p.Next, "/likes")[1])) } rel="noreferrer">more likes</a>
<a class="btn" href={ templ.SafeURL("?pagination=" + url.QueryEscape(p.Next[sc.H+len("/users/")+len(u.ID)+len("/likes"):])) } rel="noreferrer">more likes</a>
}
} else {
<span>no more likes</span>
@@ -243,7 +242,7 @@ templ UserFollowers(prefs cfg.Preferences, u sc.User, p *sc.Paginated[*sc.User])
}
</div>
if p.Next != "" && len(p.Collection) != int(p.Total) {
<a class="btn" href={ templ.SafeURL("?pagination=" + url.QueryEscape(strings.Split(p.Next, "/followers")[1])) } rel="noreferrer">more users</a>
<a class="btn" href={ templ.SafeURL("?pagination=" + url.QueryEscape(p.Next[sc.H+len("/users/")+len(u.ID)+len("/followers"):])) } rel="noreferrer">more users</a>
}
} else {
<span>no more users</span>
@@ -261,7 +260,7 @@ templ UserFollowing(prefs cfg.Preferences, u sc.User, p *sc.Paginated[*sc.User])
}
</div>
if p.Next != "" && len(p.Collection) != int(p.Total) {
<a class="btn" href={ templ.SafeURL("?pagination=" + url.QueryEscape(strings.Split(p.Next, "/following")[1])) } rel="noreferrer">more users</a>
<a class="btn" href={ templ.SafeURL("?pagination=" + url.QueryEscape(p.Next[sc.H+len("/users/")+len(u.ID)+len("/followings"):])) } rel="noreferrer">more users</a>
}
} else {
<span>no more users</span>
@@ -281,7 +280,7 @@ templ SearchUsers(p *sc.Paginated[*sc.User]) {
@UserItem(user)
}
if p.Next != "" && len(p.Collection) != int(p.Total) {
<a class="btn" href={ templ.SafeURL("?type=users&pagination=" + url.QueryEscape(strings.Split(p.Next, "/users")[1])) } rel="noreferrer">more users</a>
<a class="btn" href={ templ.SafeURL("?type=users&pagination=" + url.QueryEscape(p.Next[sc.H+len("/search/users"):])) } rel="noreferrer">more users</a>
}
}
}