mirror of
https://git.maid.zone/stuff/soundcloak.git
synced 2025-12-10 13:49:39 +05:00
init
This commit is contained in:
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules
|
||||||
|
main
|
||||||
|
package-lock.json
|
||||||
|
*_templ.go
|
||||||
|
fly.toml
|
||||||
5
README.md
Normal file
5
README.md
Normal 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
0
assets/favicon.ico
Normal file
11
assets/index.html
Normal file
11
assets/index.html
Normal 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
25
go.mod
Normal 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
43
go.sum
Normal 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
22
lib/cfg/init.go
Normal 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
286
lib/sc/init.go
Normal 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
81
lib/sc/structs.go
Normal 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
61
main.go
Normal 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
5
package.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"dependencies": {
|
||||||
|
"hls.js": "^1.5.15"
|
||||||
|
}
|
||||||
|
}
|
||||||
19
templates/base.templ
Normal file
19
templates/base.templ
Normal 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
25
templates/track.templ
Normal 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
41
templates/user.templ
Normal 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>
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user