commit 114b38c841f41a34131fefa9b5d3cdd63d1585f3 Author: Laptop Date: Thu Aug 22 11:11:02 2024 +0300 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7dbc17c --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules +main +package-lock.json +*_templ.go +fly.toml \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..8456e8d --- /dev/null +++ b/README.md @@ -0,0 +1,5 @@ +# soundcloak +wip opensource ui for soundcloud + +# [official public instance](https://sc.maid.zone) +there is no image and audio proxy for now so beware diff --git a/assets/favicon.ico b/assets/favicon.ico new file mode 100644 index 0000000..e69de29 diff --git a/assets/index.html b/assets/index.html new file mode 100644 index 0000000..942d27d --- /dev/null +++ b/assets/index.html @@ -0,0 +1,11 @@ + + + + + + soundcloak + + + + + \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..371527e --- /dev/null +++ b/go.mod @@ -0,0 +1,25 @@ +module github.com/maid-zone/soundcloak + +go 1.21.3 + +require ( + github.com/a-h/templ v0.2.747 + github.com/gofiber/fiber/v2 v2.52.5 +) + +require ( + github.com/andybalholm/brotli v1.1.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/compress v1.17.9 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.15 // indirect + github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/rivo/uniseg v0.2.0 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasthttp v1.55.0 // indirect + github.com/valyala/tcplisten v1.0.0 // indirect + golang.org/x/sys v0.21.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..2dc7c18 --- /dev/null +++ b/go.sum @@ -0,0 +1,43 @@ +github.com/a-h/templ v0.2.747 h1:D0dQ2lxC3W7Dxl6fxQ/1zZHBQslSkTSvl5FxP/CfdKg= +github.com/a-h/templ v0.2.747/go.mod h1:69ObQIbrcuwPCU32ohNaWce3Cb7qM5GMiqN1K+2yop4= +github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= +github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +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= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= +github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= +github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasthttp v1.55.0 h1:Zkefzgt6a7+bVKHnu/YaYSOPfNYNisSVBo/unVCf8k8= +github.com/valyala/fasthttp v1.55.0/go.mod h1:NkY9JtkrpPKmgwV3HTaS2HWaJss9RSIsRVfcxxoHiOM= +github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= +github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= +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.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= +golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= diff --git a/lib/cfg/init.go b/lib/cfg/init.go new file mode 100644 index 0000000..b1febba --- /dev/null +++ b/lib/cfg/init.go @@ -0,0 +1,22 @@ +package cfg + +import ( + "time" + + jsoniter "github.com/json-iterator/go" +) + +// time-to-live for clientid cache +// larger number will improve performance (no need to recheck everytime) but might make soundcloak unusable after soundcloud updates the website +const ClientIDTTL = 5 * time.Minute + +// time-to-live for user profile cache +const UserTTL = 5 * time.Minute + +// time-to-live for track cache +const TrackTTL = 5 * time.Minute + +// default fasthttp one was causing connections to be stuck? todo make it cycle browser useragents or just choose random at startup +const UserAgent = "insomnia/2023.2.0" + +var JSON = jsoniter.ConfigFastest diff --git a/lib/sc/init.go b/lib/sc/init.go new file mode 100644 index 0000000..bc5779e --- /dev/null +++ b/lib/sc/init.go @@ -0,0 +1,286 @@ +package sc + +import ( + "bytes" + "errors" + "fmt" + "net/url" + "regexp" + "strings" + "time" + + "github.com/maid-zone/soundcloak/lib/cfg" + "github.com/valyala/fasthttp" +) + +var clientIdCache struct { + ClientID []byte + ClientIDString string + Version []byte + NextCheck time.Time +} + +type cached[T any] struct { + Value T + Expires time.Time +} + +var usersCache = map[string]cached[User]{} +var tracksCache = map[string]cached[Track]{} + +var verRegex = regexp.MustCompile(`(?m)^$`) +var scriptsRegex = regexp.MustCompile(`(?m)^$`) +var clientIdRegex = regexp.MustCompile(`\("client_id=([A-Za-z0-9]{32})"\)`) +var ErrVersionNotFound = errors.New("version not found") +var ErrScriptNotFound = errors.New("script not found") +var ErrIDNotFound = errors.New("clientid not found") +var ErrKindNotCorrect = errors.New("entity of incorrect kind") +var ErrIncompatibleStream = errors.New("incompatible stream") +var ErrNoURL = errors.New("no url") + +// inspired by github.com/imputnet/cobalt (mostly stolen lol) +func GetClientID() (string, error) { + if clientIdCache.NextCheck.After(time.Now()) { + return clientIdCache.ClientIDString, 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("Accept-Encoding", "gzip, deflate, br, zstd") + + resp := fasthttp.AcquireResponse() + defer fasthttp.ReleaseResponse(resp) + + err := fasthttp.Do(req, resp) + if err != nil { + return "", err + } + + data, err := resp.BodyUncompressed() + if err != nil { + data = resp.Body() + } + + //fmt.Println(string(data), err) + + res := verRegex.FindSubmatch(data) + if len(res) != 2 { + return "", ErrVersionNotFound + } + + if bytes.Equal(res[1], clientIdCache.Version) { + return clientIdCache.ClientIDString, nil + } + + ver := res[1] + + scripts := scriptsRegex.FindAllSubmatch(data, -1) + if len(scripts) == 0 { + return "", ErrScriptNotFound + } + + for _, scr := range scripts { + if len(scr) != 2 { + continue + } + + req.SetRequestURIBytes(scr[1]) + + err = fasthttp.Do(req, resp) + if err != nil { + continue + } + + data, err = resp.BodyUncompressed() + if err != nil { + data = resp.Body() + } + + res = clientIdRegex.FindSubmatch(data) + if len(res) != 2 { + continue + } + + clientIdCache.ClientID = res[1] + clientIdCache.ClientIDString = string(res[1]) + clientIdCache.Version = ver + clientIdCache.NextCheck = time.Now().Add(cfg.ClientIDTTL) + return clientIdCache.ClientIDString, nil + } + + return "", ErrIDNotFound +} + +func Resolve(path string, out any) error { + cid, err := GetClientID() + if err != nil { + return err + } + + req := fasthttp.AcquireRequest() + defer fasthttp.ReleaseRequest(req) + + req.SetRequestURI("https://api-v2.soundcloud.com/resolve?url=https%3A%2F%2Fsoundcloud.com%2F" + url.QueryEscape(path) + "&client_id=" + cid) + req.Header.Set("User-Agent", cfg.UserAgent) + req.Header.Set("Accept-Encoding", "gzip, deflate, br, zstd") + + resp := fasthttp.AcquireResponse() + defer fasthttp.ReleaseResponse(resp) + + err = fasthttp.Do(req, resp) + if err != nil { + return err + } + + if resp.StatusCode() != 200 { + return fmt.Errorf("resolve: got status code %d", resp.StatusCode()) + } + + data, err := resp.BodyUncompressed() + if err != nil { + data = resp.Body() + } + + return cfg.JSON.Unmarshal(data, out) +} + +func GetUser(permalink string) (User, error) { + if cell, ok := usersCache[permalink]; ok && cell.Expires.After(time.Now()) { + return cell.Value, nil + } + + var u User + err := Resolve(permalink, &u) + if err != nil { + return u, err + } + + if u.Kind != "user" { + err = ErrKindNotCorrect + return u, err + } + + u.Avatar = strings.Replace(u.Avatar, "-large.", "-t200x200.", 1) + ls := strings.Split(u.URN, ":") + u.ID = ls[len(ls)-1] + usersCache[permalink] = cached[User]{Value: u, Expires: time.Now().Add(cfg.UserTTL)} + + return u, err +} + +func GetTrack(permalink string) (Track, error) { + if cell, ok := tracksCache[permalink]; ok && cell.Expires.After(time.Now()) { + return cell.Value, nil + } + + var u Track + err := Resolve(permalink, &u) + if err != nil { + return u, err + } + + if u.Kind != "track" { + return u, ErrKindNotCorrect + } + + tracksCache[permalink] = cached[Track]{Value: u, Expires: time.Now().Add(cfg.TrackTTL)} + + return u, nil +} + +func (p *Paginated[T]) Proceed() error { + cid, err := GetClientID() + if err != nil { + return err + } + + req := fasthttp.AcquireRequest() + defer fasthttp.ReleaseRequest(req) + + req.SetRequestURI(p.Next + "&client_id=" + cid) + req.Header.Set("User-Agent", cfg.UserAgent) + req.Header.Set("Accept-Encoding", "gzip, deflate, br, zstd") + + resp := fasthttp.AcquireResponse() + defer fasthttp.ReleaseResponse(resp) + + err = fasthttp.Do(req, resp) + if err != nil { + return err + } + + if resp.StatusCode() != 200 { + return fmt.Errorf("paginated.proceed: got status code %d", resp.StatusCode()) + } + + data, err := resp.BodyUncompressed() + if err != nil { + data = resp.Body() + } + + return cfg.JSON.Unmarshal(data, p) +} + +func (u User) GetTracks(args string) (*Paginated[Track], error) { + p := Paginated[Track]{ + Next: "https://api-v2.soundcloud.com/users/" + u.ID + "/tracks" + args, + } + + err := p.Proceed() + if err != nil { + return nil, err + } + + return &p, nil +} + +func (t Track) GetStream() (string, error) { + cid, err := GetClientID() + if err != nil { + return "", err + } + + tr := t.Media.SelectCompatible() + if tr == nil { + return "", ErrIncompatibleStream + } + + req := fasthttp.AcquireRequest() + defer fasthttp.ReleaseRequest(req) + + req.SetRequestURI(tr.URL + "?client_id=" + cid + "&track_authorization=" + t.Authorization) + req.Header.Set("User-Agent", cfg.UserAgent) + req.Header.Set("Accept-Encoding", "gzip, deflate, br, zstd") + + resp := fasthttp.AcquireResponse() + defer fasthttp.ReleaseResponse(resp) + + err = fasthttp.Do(req, resp) + if err != nil { + return "", err + } + + if resp.StatusCode() != 200 { + return "", fmt.Errorf("resolve: got status code %d", resp.StatusCode()) + } + + data, err := resp.BodyUncompressed() + if err != nil { + data = resp.Body() + } + + var s Stream + err = cfg.JSON.Unmarshal(data, &s) + if err != nil { + return "", err + } + + if s.URL == "" { + return "", ErrNoURL + } + + return s.URL, nil +} diff --git a/lib/sc/structs.go b/lib/sc/structs.go new file mode 100644 index 0000000..3fa3346 --- /dev/null +++ b/lib/sc/structs.go @@ -0,0 +1,81 @@ +package sc + +type User struct { + Avatar string `json:"avatar_url"` + CreatedAt string `json:"created_at"` + Description string `json:"description"` + Followers int64 `json:"followers_count"` + Following int64 `json:"followings_count"` + FullName string `json:"full_name"` + Kind string `json:"kind"` // should always be "user"! + LastModified string `json:"last_modified"` + Liked int `json:"likes_count"` + Permalink string `json:"permalink"` + Playlists int `json:"playlist_count"` + Tracks int64 `json:"track_count"` + URN string `json:"urn"` + Username string `json:"username"` + Verified bool `json:"verified"` + + ID string `json:"-"` +} + +type Protocol string + +const ( + ProtocolHLS Protocol = "hls" + ProtocolProgressive Protocol = "progressive" +) + +type Format struct { + Protocol Protocol `json:"protocol"` + MimeType string `json:"mime_type"` +} + +type Transcoding struct { + URL string `json:"url"` + Preset string `json:"preset"` + Format Format `json:"format"` + Quality string `json:"quality"` +} + +type Media struct { + Transcodings []Transcoding `json:"transcodings"` +} + +func (m Media) SelectCompatible() *Transcoding { + for _, t := range m.Transcodings { + if t.Format.Protocol == "hls" && t.Format.MimeType == "audio/mpeg" { + return &t + } + } + + return nil +} + +type Track struct { + Artwork string `json:"artwork_url"` + Comments int `json:"comment_count"` + CreatedAt string `json:"created_at"` + Duration int `json:"duration"` // there are duration and full_duration fields wtf does that mean + Genre string `json:"genre"` + ID int `json:"id"` + Kind string `json:"kind"` // should always be "track"! + LastModified string `json:"last_modified"` + Likes int `json:"likes_count"` + Permalink string `json:"permalink"` + Played int `json:"playback_count"` + Title string `json:"title"` + Media Media `json:"media"` + Authorization string `json:"track_authorization"` + Author User `json:"user"` +} + +type Paginated[T any] struct { + Collection []T `json:"collection"` + Next string `json:"next_href"` +} + +type Stream struct { + URL string `json:"url"` +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..bf5500e --- /dev/null +++ b/main.go @@ -0,0 +1,61 @@ +package main + +import ( + "context" + _ "embed" + "fmt" + "log" + + "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v2/middleware/compress" + "github.com/gofiber/fiber/v2/middleware/earlydata" + "github.com/gofiber/fiber/v2/middleware/recover" + + "github.com/maid-zone/soundcloak/lib/sc" + "github.com/maid-zone/soundcloak/templates" +) + +func main() { + app := fiber.New() + app.Use(compress.New()) + app.Use(recover.New()) + app.Use(earlydata.New()) + + app.Static("/", "assets", fiber.Static{Compress: true, MaxAge: 3600}) + app.Static("/js/hls.js/", "node_modules/hls.js/dist", fiber.Static{Compress: true, MaxAge: 3600}) + + app.Get("/:user/:track", func(c *fiber.Ctx) error { + track, err := sc.GetTrack(c.Params("user") + "/" + c.Params("track")) + if err != nil { + fmt.Printf("error getting %s from %s: %s\n", c.Params("track"), c.Params("user"), err) + return c.SendStatus(404) + } + + stream, err := track.GetStream() + if err != nil { + fmt.Printf("error getting %s stream from %s: %s\n", c.Params("track"), c.Params("user"), err) + } + + c.Set("Content-Type", "text/html") + return templates.Base(track.Title+" by "+track.Author.Username, templates.Track(track, stream)).Render(context.Background(), c) + }) + + app.Get("/:user", func(c *fiber.Ctx) error { + usr, err := sc.GetUser(c.Params("user")) + if err != nil { + fmt.Printf("error getting %s: %s\n", c.Params("user"), err) + return c.SendStatus(404) + } + + p, err := usr.GetTracks(c.Query("pagination", "?limit=20")) + if err != nil { + fmt.Printf("error getting %s tracks: %s\n", c.Params("user"), err) + return c.SendStatus(404) + } + + c.Set("Content-Type", "text/html") + return templates.Base(usr.Username, templates.User(usr, p)).Render(context.Background(), c) + }) + + log.Fatal(app.Listen(":4664")) +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..8412e96 --- /dev/null +++ b/package.json @@ -0,0 +1,5 @@ +{ + "dependencies": { + "hls.js": "^1.5.15" + } +} diff --git a/templates/base.templ b/templates/base.templ new file mode 100644 index 0000000..1f1792e --- /dev/null +++ b/templates/base.templ @@ -0,0 +1,19 @@ +package templates + +templ Base(title string, content templ.Component) { + + + + + + if title != "" { + { title } • soundcloak + } else { + soundcloak + } + + + @content + + +} \ No newline at end of file diff --git a/templates/track.templ b/templates/track.templ new file mode 100644 index 0000000..bf62598 --- /dev/null +++ b/templates/track.templ @@ -0,0 +1,25 @@ +package templates + +import "github.com/maid-zone/soundcloak/lib/sc" + +templ Track(t sc.Track, stream string) { + + +

{t.Title}

+ + + + +} \ No newline at end of file diff --git a/templates/user.templ b/templates/user.templ new file mode 100644 index 0000000..544a696 --- /dev/null +++ b/templates/user.templ @@ -0,0 +1,41 @@ +package templates + +import ( + "github.com/maid-zone/soundcloak/lib/sc" + "strconv" + "strings" + "net/url" +) + +templ User(u sc.User, p *sc.Paginated[sc.Track]) { +
+ if u.Avatar != "" { + + } + +

{u.Username}

+ if u.FullName != "" { +

{u.FullName}

+ } +
+ +
+

{strconv.FormatInt(u.Followers, 10)} followers

+

{strconv.FormatInt(u.Following, 10)} following

+

{strconv.FormatInt(u.Tracks, 10)} tracks

+
+ + if len(p.Collection) != 0 { +
+ for _, track := range p.Collection { +
+

{track.Title}

+
+ } +
+ + more tracks + } else { +

no mroe tracks

+ } +} \ No newline at end of file