This commit is contained in:
Laptop
2024-08-22 11:11:02 +03:00
commit 114b38c841
14 changed files with 629 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
node_modules
main
package-lock.json
*_templ.go
fly.toml

5
README.md Normal file
View File

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

0
assets/favicon.ico Normal file
View File

11
assets/index.html Normal file
View File

@@ -0,0 +1,11 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>soundcloak</title>
</head>
<body>
</body>
</html>

25
go.mod Normal file
View File

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

43
go.sum Normal file
View File

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

22
lib/cfg/init.go Normal file
View File

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

286
lib/sc/init.go Normal file
View File

@@ -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)^<script>window\.__sc_version="([0-9]{10})"</script>$`)
var scriptsRegex = regexp.MustCompile(`(?m)^<script crossorigin src="(https://a-v2\.sndcdn\.com/assets/.+\.js)"></script>$`)
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
}

81
lib/sc/structs.go Normal file
View File

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

61
main.go Normal file
View File

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

5
package.json Normal file
View File

@@ -0,0 +1,5 @@
{
"dependencies": {
"hls.js": "^1.5.15"
}
}

19
templates/base.templ Normal file
View File

@@ -0,0 +1,19 @@
package templates
templ Base(title string, content templ.Component) {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
if title != "" {
<title>{ title } • soundcloak</title>
} else {
<title>soundcloak</title>
}
</head>
<body>
@content
</body>
</html>
}

25
templates/track.templ Normal file
View File

@@ -0,0 +1,25 @@
package templates
import "github.com/maid-zone/soundcloak/lib/sc"
templ Track(t sc.Track, stream string) {
<script src="/js/hls.js/hls.light.js"></script>
<h1>{t.Title}</h1>
<video id="track" data-stream={stream} controls style="width: 50rem; height: 2.5rem;"></video>
<script>
var video = document.getElementById('track');
var videoSrc = video.getAttribute('data-stream');
if (video.canPlayType('application/vnd.apple.mpegurl')) {
video.src = videoSrc;
} else if (Hls.isSupported()) {
var hls = new Hls();
hls.loadSource(videoSrc);
hls.attachMedia(video);
} else {
alert('hls not supported');
}
</script>
}

41
templates/user.templ Normal file
View File

@@ -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]) {
<div>
if u.Avatar != "" {
<img src={u.Avatar}>
}
<h1>{u.Username}</h1>
if u.FullName != "" {
<h2>{u.FullName}</h2>
}
</div>
<div>
<h2>{strconv.FormatInt(u.Followers, 10)} followers</h2>
<h2>{strconv.FormatInt(u.Following, 10)} following</h2>
<h2>{strconv.FormatInt(u.Tracks, 10)} tracks</h2>
</div>
if len(p.Collection) != 0 {
<div>
for _, track := range p.Collection {
<div>
<h1><a href={templ.URL("/" + track.Author.Permalink + "/" + track.Permalink)}>{track.Title}</a></h1>
</div>
}
</div>
<a href={templ.URL("?pagination=" + url.QueryEscape(strings.Split(p.Next, "/tracks")[1]))}>more tracks</a>
} else {
<h1>no mroe tracks</h1>
}
}