it's so simple

This commit is contained in:
Laptop
2026-01-28 22:22:29 +02:00
parent 848bbe7c1a
commit 4254f1d99c
30 changed files with 1411 additions and 608 deletions

View File

@@ -1,4 +1,4 @@
ARG GO_VERSION=1.25.5
ARG GO_VERSION=1.25.6
FROM --platform=$BUILDPLATFORM golang:${GO_VERSION} AS build
ARG TARGETOS

28
go.mod
View File

@@ -1,18 +1,16 @@
module git.maid.zone/stuff/soundcloak
go 1.25.5
go 1.25.6
require (
github.com/a-h/templ v0.3.960
github.com/a-h/templ v0.3.977
github.com/bogem/id3v2/v2 v2.1.4
github.com/dlclark/regexp2 v1.11.5-0.20240806004527-5bbbed8ea10b
github.com/gcottom/mp4meta v0.0.5
github.com/gcottom/oggmeta v0.0.8
github.com/goccy/go-json v0.10.5
github.com/goccy/go-json v0.10.6-0.20251028001429-e4877d51d546
github.com/gofiber/fiber/v3 v3.0.0-rc.3
github.com/gorilla/feeds v1.2.0
github.com/valyala/fasthttp v1.68.0
golang.org/x/net v0.48.0
github.com/valyala/fasthttp v1.69.0
golang.org/x/net v0.49.0
)
require (
@@ -27,23 +25,23 @@ require (
github.com/fatih/color v1.16.0 // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/gofiber/schema v1.6.0 // indirect
github.com/gofiber/utils/v2 v2.0.0-rc.4 // indirect
github.com/gofiber/utils/v2 v2.0.0-rc.6 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/klauspost/compress v1.18.2 // indirect
github.com/klauspost/compress v1.18.3 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/natefinch/atomic v1.0.1 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/sunfish-shogi/bufseekio v0.1.0 // indirect
github.com/tinylib/msgp v1.6.1 // indirect
github.com/tinylib/msgp v1.6.3 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
golang.org/x/crypto v0.46.0 // indirect
golang.org/x/mod v0.30.0 // indirect
golang.org/x/crypto v0.47.0 // indirect
golang.org/x/mod v0.31.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect
golang.org/x/tools v0.39.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/text v0.33.0 // indirect
golang.org/x/tools v0.40.0 // indirect
)
tool (

58
go.sum
View File

@@ -2,8 +2,8 @@ git.maid.zone/stuff/soundcloakctl v0.0.0-20251212193418-f85f457a0df1 h1:Ksv7MuRY
git.maid.zone/stuff/soundcloakctl v0.0.0-20251212193418-f85f457a0df1/go.mod h1:teXAWdTDQ3CXlXy8Co+U3Jai2LuxFLgjPy7LnguYoiY=
github.com/a-h/parse v0.0.0-20250122154542-74294addb73e h1:HjVbSQHy+dnlS6C3XajZ69NYAb5jbGNfHanvm1+iYlo=
github.com/a-h/parse v0.0.0-20250122154542-74294addb73e/go.mod h1:3mnrkvGpurZ4ZrTDbYU84xhwXW2TjTKShSwjRi2ihfQ=
github.com/a-h/templ v0.3.960 h1:trshEpGa8clF5cdI39iY4ZrZG8Z/QixyzEyUnA7feTM=
github.com/a-h/templ v0.3.960/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo=
github.com/a-h/templ v0.3.977 h1:kiKAPXTZE2Iaf8JbtM21r54A8bCNsncrfnokZZSrSDg=
github.com/a-h/templ v0.3.977/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo=
github.com/abema/go-mp4 v1.4.1 h1:YoS4VRqd+pAmddRPLFf8vMk74kuGl6ULSjzhsIqwr6M=
github.com/abema/go-mp4 v1.4.1/go.mod h1:vPl9t5ZK7K0x68jh12/+ECWBCXoWuIDtNgPtU2f04ws=
github.com/aler9/writerseeker v1.1.0 h1:t+Sm3tjp8scNlqyoa8obpeqwciMNOvdvsxjxEb3Sx3g=
@@ -32,33 +32,25 @@ github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sa
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/gcottom/mp4meta v0.0.5 h1:pZZAMwRMisx7RaewO7MvjuD3t+tHCYRujpJkQI2yVHU=
github.com/gcottom/mp4meta v0.0.5/go.mod h1:Dxt8rM1fJDl9sOJCfsnQuprE3gWtmE/oXTJHA/g5WHY=
github.com/gcottom/oggmeta v0.0.8 h1:cai8PX7k4/6coKaYCeBZI5GD2f+bgzrCHbcFM5l5Vms=
github.com/gcottom/oggmeta v0.0.8/go.mod h1:as5q4K3n3GHJIuWKoJPjiroxPEtqDCNB52+sYzBSaGQ=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-json v0.10.6-0.20251028001429-e4877d51d546 h1:FcZnXbn+88+xAIR8RLuIeIdT3Vnn5WSpQk+gzdRNlg8=
github.com/goccy/go-json v0.10.6-0.20251028001429-e4877d51d546/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/gofiber/fiber/v3 v3.0.0-rc.3 h1:h0KXuRHbivSslIpoHD1R/XjUsjcGwt+2vK0avFiYonA=
github.com/gofiber/fiber/v3 v3.0.0-rc.3/go.mod h1:LNBPuS/rGoUFlOyy03fXsWAeWfdGoT1QytwjRVNSVWo=
github.com/gofiber/schema v1.6.0 h1:rAgVDFwhndtC+hgV7Vu5ItQCn7eC2mBA4Eu1/ZTiEYY=
github.com/gofiber/schema v1.6.0/go.mod h1:WNZWpQx8LlPSK7ZaX0OqOh+nQo/eW2OevsXs1VZfs/s=
github.com/gofiber/utils/v2 v2.0.0-rc.4 h1:CDjwPwtwwj1OTIf6v3iRk+D2wcdjUzwk91Ghu2TMNbE=
github.com/gofiber/utils/v2 v2.0.0-rc.4/go.mod h1:gXins5o7up+BQFiubmO8aUJc/+Mhd7EKXIiAK5GBomI=
github.com/gofiber/utils/v2 v2.0.0-rc.6 h1:pBAbppiFMR+BpdEwjnZDMpnH0rBreDUPWjolUVe6BVY=
github.com/gofiber/utils/v2 v2.0.0-rc.6/go.mod h1:8PuWXERC3IoTmoD2Fp/X7amJntq928Fa2yTHI5Orj2M=
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/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
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/gorilla/feeds v1.2.0 h1:O6pBiXJ5JHhPvqy53NsjKOThq+dNFm8+DFrxBEdzSCc=
github.com/gorilla/feeds v1.2.0/go.mod h1:WMib8uJP3BbY+X8Szd1rA5Pzhdfh+HCCAYT2z7Fza6Y=
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw=
github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
@@ -73,8 +65,6 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/shamaton/msgpack/v2 v2.4.0 h1:O5Z08MRmbo0lA9o2xnQ4TXx6teJbPqEurqcCOQ8Oi/4=
github.com/shamaton/msgpack/v2 v2.4.0/go.mod h1:6khjYnkx73f7VQU7wjcFS9DFjs+59naVWJv1TB7qdOI=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
@@ -86,12 +76,12 @@ github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD
github.com/sunfish-shogi/bufseekio v0.0.0-20210207115823-a4185644b365/go.mod h1:dEzdXgvImkQ3WLI+0KQpmEx8T/C/ma9KeS3AfmU899I=
github.com/sunfish-shogi/bufseekio v0.1.0 h1:zu38kFbv0KuuiwZQeuYeS02U9AM14j0pVA9xkHOCJ2A=
github.com/sunfish-shogi/bufseekio v0.1.0/go.mod h1:dEzdXgvImkQ3WLI+0KQpmEx8T/C/ma9KeS3AfmU899I=
github.com/tinylib/msgp v1.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY=
github.com/tinylib/msgp v1.6.1/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
github.com/tinylib/msgp v1.6.3 h1:bCSxiTz386UTgyT1i0MSCvdbWjVW+8sG3PjkGsZQt4s=
github.com/tinylib/msgp v1.6.3/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
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.68.0 h1:v12Nx16iepr8r9ySOwqI+5RBJ/DqTxhOy1HrHoDFnok=
github.com/valyala/fasthttp v1.68.0/go.mod h1:5EXiRfYQAoiO/khu4oU9VISC/eVY6JqmSpPJoHCKsz4=
github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZyVI=
github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
@@ -99,16 +89,16 @@ github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3i
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
@@ -120,21 +110,21 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

@@ -2,12 +2,12 @@ package api
import (
"log"
"net/url"
"git.maid.zone/stuff/soundcloak/lib/cfg"
"git.maid.zone/stuff/soundcloak/lib/sc"
json "github.com/goccy/go-json"
"github.com/gofiber/fiber/v3"
"github.com/valyala/fasthttp"
)
func Load(a *fiber.App) {
@@ -15,18 +15,18 @@ func Load(a *fiber.App) {
prefs := cfg.Preferences{ProxyImages: &cfg.False}
r.Get("/search", func(c fiber.Ctx) error {
q := cfg.B2s(c.RequestCtx().QueryArgs().Peek("q"))
q := 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)
args := c.RequestCtx().QueryArgs().Peek("pagination")
if len(args) == 0 {
args = fasthttp.AppendQuotedArg([]byte("q="), 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)
log.Printf("[API] error getting tracks for %s: %s\n", cfg.B2s(q), err)
return err
}
@@ -35,7 +35,7 @@ func Load(a *fiber.App) {
case "users":
p, err := sc.SearchUsers("", prefs, args)
if err != nil {
log.Printf("[API] error getting users for %s: %s\n", q, err)
log.Printf("[API] error getting users for %s: %s\n", cfg.B2s(q), err)
return err
}
@@ -44,7 +44,7 @@ func Load(a *fiber.App) {
case "playlists":
p, err := sc.SearchPlaylists("", prefs, args)
if err != nil {
log.Printf("[API] error getting playlists for %s: %s\n", q, err)
log.Printf("[API] error getting playlists for %s: %s\n", cfg.B2s(q), err)
return err
}

View File

@@ -64,7 +64,7 @@ var PlaylistTTL = 20 * time.Minute
var PlaylistCacheCleanDelay = PlaylistTTL / 4
// default fasthttp one was causing connections to be stuck? todo make it cycle browser useragents or just choose random at startup
var UserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36"
var UserAgent = "Mozilla/5.0 (X11; Linux x86_64; rv:146.0) Gecko/20100101 Firefox/146.0"
// override the extractor
var ClientID = ""
@@ -72,41 +72,7 @@ var ClientID = ""
// enab;e api
var EnableAPI = false
// Dear soundcloud workers who are for sure reading this:
// please keep this image in your mind
// . ,+@#@=--__, __+##@@@#==,
// . @* @#m, __==#@ #
// . @ @#m_, __+#@ %
// . @ #@*@#* %
// . @ __--##@@@#---__ @
// . #, m#@@* *%@, ,#
// . @ ___,@ *@+---___ @
// . %#__==#@ ,* %@, *==%,
// . ,=@ @ @ *@=,
// . ,#@ ,% ,_ @, %,
// ,@* @* ,m#@ ,@ @, *# @,
// # __---@ ,@ @ .@ @, *@---__ #
// @ .@ .@ --____@ .%_____-- @ @. @
// .@ # @ @, @ @. @ #
// . % :@ @ @, @ # @: ,@
// . @, @ ,* @@@****----- *@-----****@@@ % @: ,%
// . *_, @ @ @ @ @ @ @ @ @ @ ,@
// . *+# @ @** @ @** @ @ @+*^
// . @, @ # # # @ @ .#
// . # #: *@__@* *@__#* ,# @
// . %*, %. @ ,#@
// . @ @, @_ m m _* @ @
// . @ @ *-___# ^+___+^ #___+#* #
// . #__+* @ @ %, , @ %
// . @ # @_, ,_@* # # @
// . *, @ *@##--___--##@* ,* @ *-__#
// . ^--=* @ @ @ @ @ ,#
// . :@ ,+@@@+, @: #==-
// . ==@ @ @ @==
// . ,# @ @ @,
// . @ % % %
// . *==* @==#*
// cirno day everyday
// yeah i doubt they will be reading this lolol
var SoundcloudApiProxy = ""
var DialDualStack = false

View File

@@ -27,10 +27,14 @@ var Repo = "unknown"
var CommitURL = "unknown"
const (
// Downloads the HLS stream on the backend, and restreams it to frontend as a file. Requires no JS, but less stable client-side
// Downloads the HLS stream on the backend, and restreams it to frontend as a file.
// If requested MP3 preset, it uses Progressive protocol (so just proxying a file, maybe adding metadata if you need it)
// Requires no JS, but less stable client-side (browser likes to randomly unload the audio if you listen to shit on repeat xd)
RestreamPlayer string = "restream"
// Downloads the HLS stream on the frontend (proxying can be enabled). Requires JS, more stable client-side
HLSPlayer string = "hls"
// Just proxies the file given from Progressive stream, only available when restream is not, also just MP3 preset
ProgressivePlayer string = "progressive"
// Disables the song player
NonePlayer string = "none"
)
@@ -56,6 +60,9 @@ const (
AudioMP3 string = "mpeg"
)
// for taking ptrs :)
var MP3 = AudioMP3
type Preferences struct {
Player *string
ProxyStreams *bool

View File

@@ -45,27 +45,16 @@ func Log(what ...any) {
}
}
var ImageClient *fasthttp.HostClient
var HlsClient *fasthttp.HostClient
var HlsStreamingOnlyClient *fasthttp.HostClient
var HlsAacClient *fasthttp.HostClient
func init() {
if cfg.Restream || cfg.ProxyImages {
ImageClient = &fasthttp.HostClient{
Addr: cfg.ImageCDN + ":443",
IsTLS: true,
MaxIdleConnDuration: cfg.MaxIdleConnDuration,
StreamResponseBody: true,
DialDualStack: cfg.DialDualStack,
}
}
if cfg.Restream || cfg.ProxyStreams {
HlsClient = &fasthttp.HostClient{
Addr: cfg.HLSCDN + ":443",
IsTLS: true,
MaxIdleConnDuration: cfg.MaxIdleConnDuration,
StreamResponseBody: true,
DialDualStack: cfg.DialDualStack,
}
@@ -73,7 +62,15 @@ func init() {
Addr: cfg.HLSAACCDN + ":443",
IsTLS: true,
MaxIdleConnDuration: cfg.MaxIdleConnDuration,
DialDualStack: cfg.DialDualStack,
}
HlsStreamingOnlyClient = &fasthttp.HostClient{
Addr: cfg.HLSCDN + ":443",
IsTLS: true,
MaxIdleConnDuration: cfg.MaxIdleConnDuration,
StreamResponseBody: true,
MaxResponseBodySize: 1,
DialDualStack: cfg.DialDualStack,
}
}

View File

@@ -4,6 +4,8 @@ import (
"context"
"time"
encodingjson "encoding/json"
"github.com/goccy/go-json"
"github.com/valyala/fasthttp"
@@ -175,7 +177,7 @@ func Load(r *fiber.App) {
old.ShowAudio = &cfg.False
}
if *old.Player == cfg.HLSPlayer {
if *old.Player == cfg.HLSPlayer || *old.Player == cfg.ProgressivePlayer {
if cfg.ProxyStreams {
switch p.ProxyStreams {
case on:
@@ -184,7 +186,9 @@ func Load(r *fiber.App) {
old.ProxyStreams = &cfg.False
}
}
}
if *old.Player == cfg.HLSPlayer {
switch p.FullyPreloadTrack {
case on:
old.FullyPreloadTrack = &cfg.True
@@ -265,7 +269,15 @@ func Load(r *fiber.App) {
return err
}
return c.JSON(Export{Preferences: &p})
data, err := encodingjson.Marshal(Export{Preferences: &p})
if err != nil {
return err
}
c.Response().Header.SetContentType("application/json")
return c.Send(data)
// go-json seems to crash for this one :p
})
r.Post("/_/preferences/import", func(c fiber.Ctx) error {

View File

@@ -1,8 +1,6 @@
package proxyimages
import (
"bytes"
"git.maid.zone/stuff/soundcloak/lib/cfg"
"git.maid.zone/stuff/soundcloak/lib/misc"
"git.maid.zone/stuff/soundcloak/lib/sc"
@@ -11,7 +9,7 @@ import (
)
var al_httpc *fasthttp.HostClient
var sndcdn = []byte(".sndcdn.com")
var streaming_httpc *fasthttp.HostClient
func Load(r *fiber.App) {
@@ -20,6 +18,15 @@ func Load(r *fiber.App) {
IsTLS: true,
MaxIdleConnDuration: cfg.MaxIdleConnDuration,
StreamResponseBody: true,
MaxResponseBodySize: 1,
}
streaming_httpc = &fasthttp.HostClient{
Addr: cfg.ImageCDN + ":443",
IsTLS: true,
MaxIdleConnDuration: cfg.MaxIdleConnDuration,
StreamResponseBody: true,
MaxResponseBodySize: 1,
DialDualStack: cfg.DialDualStack,
}
@@ -37,14 +44,15 @@ func Load(r *fiber.App) {
return err
}
if !bytes.HasSuffix(parsed.Host(), sndcdn) {
const x = ".sndcdn.com"
if h := parsed.Host(); len(h) > len(x) && string(h[len(h)-len(x):]) != x {
return fiber.ErrBadRequest
}
var cl *fasthttp.HostClient
if parsed.Host()[0] == 'i' {
parsed.SetHost(cfg.ImageCDN)
cl = misc.ImageClient
cl = streaming_httpc
} else if string(parsed.Host()[:2]) == "al" {
cl = al_httpc
}

View File

@@ -2,7 +2,6 @@ package proxystreams
import (
"bytes"
"net/url"
"git.maid.zone/stuff/soundcloak/lib/cfg"
"git.maid.zone/stuff/soundcloak/lib/misc"
@@ -11,12 +10,23 @@ import (
"github.com/valyala/fasthttp"
)
var sndcdn = []byte(".sndcdn.com")
var soundcloudcloud = []byte(".soundcloud.cloud")
const sndcdn = ".sndcdn.com"
const soundcloudcloud = "soundcloud.cloud"
var newline = []byte{'\n'}
var extxmap = []byte(`#EXT-X-MAP:URI="`)
var hls_aac_streaming_httpc *fasthttp.HostClient
func Load(a *fiber.App) {
hls_aac_streaming_httpc = &fasthttp.HostClient{
Addr: cfg.HLSAACCDN + ":443",
IsTLS: true,
MaxIdleConnDuration: cfg.MaxIdleConnDuration,
StreamResponseBody: true,
MaxResponseBodySize: 1,
DialDualStack: cfg.DialDualStack,
}
r := a.Group("/_/proxy/streams")
r.Get("/", func(c fiber.Ctx) error {
@@ -33,7 +43,7 @@ func Load(a *fiber.App) {
return err
}
if !bytes.HasSuffix(parsed.Host(), sndcdn) {
if h := parsed.Host(); len(h) > len(sndcdn) && string(h[len(h)-len(sndcdn):]) != sndcdn {
return fiber.ErrBadRequest
}
@@ -46,7 +56,7 @@ func Load(a *fiber.App) {
resp := fasthttp.AcquireResponse()
//defer fasthttp.ReleaseResponse(resp)
err = sc.DoWithRetry(misc.HlsClient, req, resp)
err = sc.DoWithRetry(misc.HlsStreamingOnlyClient, req, resp)
if err != nil {
return err
}
@@ -71,7 +81,7 @@ func Load(a *fiber.App) {
return err
}
if !bytes.HasSuffix(parsed.Host(), soundcloudcloud) {
if h := parsed.Host(); len(h) > len(soundcloudcloud) && string(h[len(h)-len(soundcloudcloud):]) != soundcloudcloud {
return fiber.ErrBadRequest
}
@@ -83,7 +93,7 @@ func Load(a *fiber.App) {
resp := fasthttp.AcquireResponse()
err = sc.DoWithRetry(misc.HlsAacClient, req, resp)
err = sc.DoWithRetry(hls_aac_streaming_httpc, req, resp)
if err != nil {
return err
}
@@ -108,7 +118,8 @@ func Load(a *fiber.App) {
return err
}
if !bytes.HasSuffix(parsed.Host(), sndcdn) {
const x = ".sndcdn.com"
if h := parsed.Host(); len(h) > len(x) && string(h[len(h)-len(x):]) != x {
return fiber.ErrBadRequest
}
@@ -126,22 +137,19 @@ func Load(a *fiber.App) {
return err
}
data, err := resp.BodyUncompressed()
if err != nil {
data = resp.Body()
}
var sp = bytes.Split(data, newline)
for i, l := range sp {
for l := range bytes.SplitSeq(resp.Body(), newline) {
if len(l) == 0 || l[0] == '#' {
c.Response().AppendBody(l)
c.Response().AppendBody(newline)
continue
}
l = []byte("/_/proxy/streams?url=" + url.QueryEscape(cfg.B2s(l)))
sp[i] = l
c.Response().AppendBodyString("/_/proxy/streams?url=")
c.Response().AppendBody(fasthttp.AppendQuotedArg(nil, l))
c.Response().AppendBody(newline)
}
return c.Send(bytes.Join(sp, newline))
return nil
})
r.Get("/playlist/aac", func(c fiber.Ctx) error {
@@ -158,7 +166,8 @@ func Load(a *fiber.App) {
return err
}
if !bytes.HasSuffix(parsed.Host(), soundcloudcloud) {
const x = ".soundcloud.cloud"
if h := parsed.Host(); len(h) > len(x) && string(h[len(h)-len(x):]) != x {
return fiber.ErrBadRequest
}
@@ -176,25 +185,32 @@ func Load(a *fiber.App) {
return err
}
var sp = bytes.Split(resp.Body(), newline)
for i, l := range sp {
for l := range bytes.SplitSeq(resp.Body(), newline) {
if len(l) == 0 {
c.Response().AppendBody(l)
c.Response().AppendBody(newline)
continue
}
if l[0] == '#' {
if bytes.HasPrefix(l, extxmap) {
l = []byte(`#EXT-X-MAP:URI="/_/proxy/streams/aac?url=` + url.QueryEscape(cfg.B2s(l[16:len(l)-1])) + `"`)
sp[i] = l
// #EXT-X-MAP:URI="..."
const x = `#EXT-X-MAP:URI="`
if len(l) > len(x) && string(l[:len(x)]) == x {
c.Response().AppendBodyString(`#EXT-X-MAP:URI="/_/proxy/streams/aac?url=`)
c.Response().AppendBody(fasthttp.AppendQuotedArg(nil, l[16:len(l)-1]))
c.Response().AppendBodyString(`"`)
} else {
c.Response().AppendBody(l)
}
c.Response().AppendBody(newline)
continue
}
l = []byte("/_/proxy/streams/aac?url=" + url.QueryEscape(cfg.B2s(l)))
sp[i] = l
c.Response().AppendBodyString("/_/proxy/streams/aac?url=")
c.Response().AppendBody(fasthttp.AppendQuotedArg(nil, l))
c.Response().AppendBody(newline)
}
return c.Send(bytes.Join(sp, newline))
return nil
})
}

View File

@@ -2,6 +2,8 @@ package restream
import (
"bytes"
"encoding/base64"
"encoding/binary"
"image"
"strings"
@@ -14,21 +16,33 @@ import (
"git.maid.zone/stuff/soundcloak/lib/sc"
"github.com/bogem/id3v2/v2"
"github.com/gcottom/mp4meta"
"github.com/gcottom/oggmeta"
"github.com/gofiber/fiber/v3"
"github.com/valyala/fasthttp"
)
type collector struct {
data []byte
}
func (c *collector) Write(data []byte) (n int, err error) {
c.data = append(c.data, data...)
return len(data), nil
}
var image_httpc *fasthttp.HostClient
var crcTable [256]uint32
func Load(r *fiber.App) {
for i := range crcTable {
r := uint32(i) << 24
for j := 0; j < 8; j++ {
if r&0x80000000 != 0 {
r = (r << 1) ^ 0x04c11db7
} else {
r <<= 1
}
}
crcTable[i] = r
}
image_httpc = &fasthttp.HostClient{
Addr: cfg.ImageCDN + ":443",
IsTLS: true,
MaxIdleConnDuration: cfg.MaxIdleConnDuration,
DialDualStack: cfg.DialDualStack,
}
r.Get("/_/restream/:author/:track", func(c fiber.Ctx) error {
p, err := preferences.Get(c)
if err != nil {
@@ -60,7 +74,20 @@ func Load(r *fiber.App) {
}
}
tr, audio := t.Media.SelectCompatible(quality, true)
if isDownload {
var s []byte
if s = c.RequestCtx().QueryArgs().Peek("title"); len(s) > 0 {
t.Title = cfg.B2s(s)
}
if s = c.RequestCtx().QueryArgs().Peek("genre"); len(s) > 0 {
t.Genre = cfg.B2s(s)
}
if s = c.RequestCtx().QueryArgs().Peek("author"); len(s) > 0 {
t.Author.Username = cfg.B2s(s)
}
}
tr, audio := t.Media.SelectCompatible(quality, true, true)
if tr == nil {
return fiber.ErrExpectationFailed
}
@@ -72,6 +99,7 @@ func Load(r *fiber.App) {
c.Response().Header.SetContentType(tr.Format.MimeType)
c.Set("Cache-Control", cfg.RestreamCacheControl)
c.Set("Content-Disposition", `attachment; filename="`+t.Permalink+"."+sc.ToExt(audio)+`"`)
if isDownload {
if t.Artwork != "" {
@@ -80,11 +108,10 @@ func Load(r *fiber.App) {
switch audio {
case cfg.AudioMP3:
r := acquireReader()
err := r.Setup(u, false, nil)
if err != nil {
return err
}
req := fasthttp.AcquireRequest()
resp := fasthttp.AcquireResponse()
req.Header.SetUserAgent(cfg.UserAgent)
tag := id3v2.NewEmptyTag()
@@ -96,79 +123,253 @@ func Load(r *fiber.App) {
tag.SetTitle(t.Title)
if t.Artwork != "" {
r.req.SetRequestURI(t.Artwork)
req.SetRequestURI(t.Artwork)
err := sc.DoWithRetry(misc.ImageClient, r.req, r.resp)
if err == nil && r.resp.StatusCode() == 200 {
tag.AddAttachedPicture(id3v2.PictureFrame{MimeType: cfg.B2s(r.req.Header.ContentType()), Picture: r.req.Body(), PictureType: id3v2.PTFrontCover, Encoding: id3v2.EncodingUTF8})
err := sc.DoWithRetry(image_httpc, req, resp)
if err == nil && resp.StatusCode() == 200 {
//fmt.Println(string(resp.Header.ContentType()), string(resp.Header.Peek("Content-Encoding")), len(resp.Body()))
tag.AddAttachedPicture(id3v2.PictureFrame{MimeType: cfg.B2s(resp.Header.ContentType()), Picture: resp.Body(), PictureType: id3v2.PTFrontCover, Encoding: id3v2.EncodingUTF8})
}
}
var col collector
tag.WriteTo(&col)
r.leftover = col.data
if tr.Format.Protocol == sc.ProtocolProgressive {
r := acquireInjector()
tag.WriteTo(r) // write out tag first because the buffers will be overwritten if you reuse the req/resp
return c.SendStream(r)
case cfg.AudioOpus: // might try to fuck around with metadata injection. Dynamically injecting metadata for opus wasn't really good idea as it breaks some things :P
req := fasthttp.AcquireRequest()
resp := fasthttp.AcquireResponse()
defer fasthttp.ReleaseRequest(req)
defer fasthttp.ReleaseResponse(resp)
req.SetRequestURI(u)
req.Header.SetUserAgent(cfg.UserAgent)
err := sc.DoWithRetry(misc.HlsClient, req, resp)
if err != nil {
return err
}
parts := make([][]byte, 0, defaultPartsCapacity)
for _, s := range bytes.Split(resp.Body(), newline) {
if len(s) == 0 || s[0] == '#' {
continue
}
parts = append(parts, clone(s))
}
res := make([]byte, 0, 1024*1024*1)
for _, s := range parts {
req.SetRequestURIBytes(s)
err := sc.DoWithRetry(misc.HlsClient, req, resp)
req.SetRequestURI(u)
// enforce streaming here!!
err := sc.DoWithRetry(misc.HlsStreamingOnlyClient, req, resp)
if err != nil {
return err
}
res = append(res, resp.Body()...)
r.reader = resp.BodyStream()
r.resp = resp
return c.SendStream(r)
}
tag, err := oggmeta.ReadOGG(bytes.NewReader(res))
r := acquireReader()
tag.WriteTo(r)
r.req = req
r.resp = resp
err := r.Setup(u, false, nil)
if err != nil {
return err
}
tag.SetArtist(t.Author.Username)
if t.Genre != "" {
tag.SetGenre(t.Genre)
return c.SendStream(r)
case cfg.AudioOpus:
r := acquireReader()
err := r.Setup(u, false, nil)
if err != nil {
return err
}
tag.SetTitle(t.Title)
r.req.SetRequestURIBytes(r.parts[0])
err = sc.DoWithRetry(r.client, r.req, r.resp)
if err != nil {
return err
}
r.index++
res := r.resp.Body()
// purpose is to inject OpusTags metadata into ogg files
const until_hdr = len("OggS") + 1 /* ver */ + 0
const until_seq = until_hdr + 1 /* hdr */ + 8 /* granule */ + 4 /* bitstream */ + 0
const until_checksum = until_seq + 4 /* seq */ + 0
const until_segments = until_checksum + 4 /* checksum */ + 1 /* segments num */ + 0
// this expects first page to only have 1 segment
second_page := until_segments + 1 + int(res[until_segments])
r.leftover = append(r.leftover, res[:second_page]...)
const opustags_prelude = "OpusTags\x04\x00\x00\x00maid" // "OpusTags", uint32 for length, then <length> bytes for vendor string
const artist = "ARTIST="
const title = "TITLE="
const genre = "GENRE="
// we need to put actual content somewhere else to segment it properly
var leftover []byte
{
ln := len(opustags_prelude) + // opustags hdr
4 + // number of fields
4 + len(artist) + len(t.Author.Username) + // ARTIST=...
4 + len(title) + len(t.Title) // TITLE=...
if t.Genre != "" {
ln += 4 + len(genre) + len(t.Genre)
}
leftover = make([]byte, 0, ln)
}
// here come the metadata
leftover = append(leftover, opustags_prelude...)
// number of fields
const default_num = 2
num := uint32(default_num)
leftover = append(leftover, default_num, 0, 0, 0)
// each field in the format of SOME_KEY=SOME_VALUE
// same approach here, first the field length, then the field itself
leftover = binary.LittleEndian.AppendUint32(leftover, uint32(len(artist)+len(t.Author.Username)))
leftover = append(leftover, artist...)
leftover = append(leftover, t.Author.Username...)
leftover = binary.LittleEndian.AppendUint32(leftover, uint32(len(title)+len(t.Title)))
leftover = append(leftover, title...)
leftover = append(leftover, t.Title...)
if t.Genre != "" {
leftover = binary.LittleEndian.AppendUint32(leftover, uint32(len(genre)+len(t.Genre)))
leftover = append(leftover, genre...)
leftover = append(leftover, t.Genre...)
num++
}
if t.Artwork != "" {
req.SetRequestURI(t.Artwork)
r.req.SetRequestURI(t.Artwork)
err := sc.DoWithRetry(misc.ImageClient, req, resp)
if err == nil && resp.StatusCode() == 200 {
parsed, _, err := image.Decode(resp.BodyStream())
if err == nil {
tag.SetCoverArt(&parsed)
err := sc.DoWithRetry(image_httpc, r.req, r.resp)
if err == nil && r.resp.StatusCode() == 200 {
// METADATA_BLOCK_PICTURE comes from the FLAC format (https://www.rfc-editor.org/rfc/rfc9639.html#section-8.8), but base64 encoded
const picture = "METADATA_BLOCK_PICTURE="
const emptyshits2 = "\x00\x00\x00\x00" + // desc length
"\x00\x00\x00\x00" + // width
"\x00\x00\x00\x00" + // height
"\x00\x00\x00\x00" + // color depth
"\x00\x00\x00\x00" // indexed color count
pic := make([]byte, 0, 4+ // picture type
4+ // mime len
len(r.resp.Header.ContentType())+ // mime
len(emptyshits2)+ // blah blah look above
4+ // body len
len(r.resp.Body()))
pic = append(pic, 0, 0, 0, 3) // picture type (3, Front cover)
pic = binary.BigEndian.AppendUint32(pic, uint32(len(r.resp.Header.ContentType())))
pic = append(pic, r.resp.Header.ContentType()...)
pic = pic[:len(pic)+len(emptyshits2)]
pic = binary.BigEndian.AppendUint32(pic, uint32(len(r.resp.Body())))
pic = append(pic, r.resp.Body()...)
newLen := base64.StdEncoding.EncodedLen(len(pic))
// alloc for the picture
if n := 4 + len(picture) + newLen - (cap(leftover) - len(leftover)); n > 0 {
leftover = append(leftover[:cap(leftover)], make([]byte, n)...)[:len(leftover)]
}
leftover = binary.LittleEndian.AppendUint32(leftover, uint32(len(picture)+newLen))
leftover = append(leftover, picture...)
base64.StdEncoding.Encode(leftover[len(leftover):len(leftover)+newLen], pic)
leftover = leftover[:len(leftover)+newLen]
num++
}
}
return tag.Save(c.Response().BodyWriter())
if num != default_num {
binary.LittleEndian.PutUint32(leftover[len(opustags_prelude):], num)
}
const max_possible_page = 255 * 255 // 255 segments, each can have 255 bytes
if len(leftover) <= max_possible_page { // happy path :) it all fits in one page
// allocate hdr
if n := until_segments - (cap(r.leftover) - len(r.leftover)); n > 0 {
r.leftover = append(r.leftover[:cap(r.leftover)], make([]byte, n)...)[:len(r.leftover)]
}
ptr := len(r.leftover)
r.leftover = append(r.leftover, res[second_page:second_page+until_checksum]...)
r.leftover = append(r.leftover, 0, 0, 0, 0) // checksum, to be filled in
// lets segment it using ceil division
segments_num := (len(leftover) + 254) / 255
r.leftover = append(r.leftover, byte(segments_num))
// grow the slice before we add allat
if n := segments_num - (cap(r.leftover) - len(r.leftover)); n > 0 {
r.leftover = append(r.leftover[:cap(r.leftover)], make([]byte, n)...)[:len(r.leftover)+segments_num]
}
for i := range segments_num - 1 {
r.leftover[len(r.leftover)-i-2] = 255
}
if n := byte(len(leftover) % 255); n != 0 {
r.leftover[len(r.leftover)-1] = n
} else {
r.leftover[len(r.leftover)-1] = 255
}
r.leftover = append(r.leftover, leftover...)
// checksum is calculated for entire page including header
var crc uint32
for _, b := range r.leftover[ptr:] {
crc = (crc << 8) ^ crcTable[(crc>>24)^uint32(b)]
}
binary.LittleEndian.PutUint32(r.leftover[ptr+until_checksum:], crc)
} else { // sad path :(
pages_num := (len(leftover) + max_possible_page - 1) / max_possible_page
// allocate exactly as much as we need
if n :=
pages_num*until_segments + // headers
((len(leftover) + 254) / 255) + // needed segments
len(leftover) - // data itself
(cap(r.leftover) - len(r.leftover)); n > 0 {
r.leftover = append(r.leftover[:cap(r.leftover)], make([]byte, n)...)[:len(r.leftover)]
}
for i := range pages_num {
ptr := len(r.leftover)
r.leftover = append(r.leftover, res[second_page:second_page+until_checksum]...)
binary.LittleEndian.PutUint32(r.leftover[ptr+until_seq:], uint32(i))
r.leftover = append(r.leftover, 0, 0, 0, 0) // checksum, to be filled in
const (
Continuation byte = 0x01
BOS byte = 0x02
EOS byte = 0x04
)
var segments_num int
var sl []byte
if i+1 == pages_num {
r.leftover[ptr+until_hdr] = EOS
sl = leftover[i*max_possible_page:]
segments_num = (len(sl) + 254) / 255
} else {
if i != 0 {
r.leftover[ptr+until_hdr] = Continuation
} else {
r.leftover[ptr+until_hdr] = BOS
}
segments_num = 255
sl = leftover[i*max_possible_page : (i+1)*max_possible_page]
}
r.leftover = append(r.leftover, byte(segments_num))
r.leftover = r.leftover[:len(r.leftover)+segments_num]
for i := range segments_num - 1 {
r.leftover[len(r.leftover)-i-2] = 255
}
if n := byte(len(sl) % 255); n != 0 {
r.leftover[len(r.leftover)-1] = n
} else {
r.leftover[len(r.leftover)-1] = 255
}
r.leftover = append(r.leftover, sl...)
// checksum is calculated for entire page including header
var crc uint32
for _, b := range r.leftover[ptr:] {
crc = (crc << 8) ^ crcTable[(crc>>24)^uint32(b)]
}
binary.LittleEndian.PutUint32(r.leftover[ptr+until_checksum:], crc)
}
}
// dump the rest after original 2nd page
r.leftover = append(r.leftover, res[second_page+until_segments+int(res[second_page+until_segments])])
return c.SendStream(r)
case cfg.AudioAAC:
r := acquireReader()
err := r.Setup(u, true, nil)
@@ -198,7 +399,7 @@ func Load(r *fiber.App) {
if t.Artwork != "" {
r.req.SetRequestURI(t.Artwork)
err := sc.DoWithRetry(misc.ImageClient, r.req, r.resp)
err := sc.DoWithRetry(image_httpc, r.req, r.resp)
if err == nil && r.resp.StatusCode() == 200 {
parsed, _, err := image.Decode(r.resp.BodyStream())
r.resp.CloseBodyStream()
@@ -208,15 +409,36 @@ func Load(r *fiber.App) {
}
}
var col collector
tag.Save(&col)
fixDuration(col.data, &t.Duration)
r.leftover = col.data
tag.Save(r)
fixDuration(r.leftover, &t.Duration)
return c.SendStream(r)
}
}
// just the audio file itself, means less processing overhead for us :)
if tr.Format.Protocol == sc.ProtocolProgressive {
misc.Log("use progressive")
req := fasthttp.AcquireRequest()
defer fasthttp.ReleaseRequest(req)
resp := fasthttp.AcquireResponse()
req.SetRequestURI(u)
req.Header.SetUserAgent(cfg.UserAgent)
// enforce streaming here!!
err := sc.DoWithRetry(misc.HlsStreamingOnlyClient, req, resp)
if err != nil {
return err
}
r := misc.AcquireProxyReader()
r.Reader = resp.BodyStream()
r.Resp = resp
return c.SendStream(r)
}
r := acquireReader()
if audio == cfg.AudioAAC {
err = r.Setup(u, true, &t.Duration)

60
lib/restream/injector.go Normal file
View File

@@ -0,0 +1,60 @@
package restream
import (
"io"
"sync"
"github.com/valyala/fasthttp"
)
// inject some bytes before the real reader
type injector struct {
leftover []byte
reader io.Reader
resp *fasthttp.Response
}
var injectorpool = sync.Pool{
New: func() any {
return &injector{}
},
}
func acquireInjector() *injector {
return injectorpool.Get().(*injector)
}
func (r *injector) Read(buf []byte) (n int, err error) {
if len(r.leftover) != 0 {
h := min(len(buf), len(r.leftover))
n = copy(buf, r.leftover[:h])
if n > len(r.leftover) {
r.leftover = nil
} else {
r.leftover = r.leftover[n:]
}
return
}
return r.reader.Read(buf)
}
func (r *injector) Close() error {
r.resp.CloseBodyStream()
fasthttp.ReleaseResponse(r.resp)
r.reader = nil
r.resp = nil
r.leftover = r.leftover[:0]
injectorpool.Put(r)
return nil
}
func (c *injector) Write(data []byte) (n int, err error) {
c.leftover = append(c.leftover, data...)
return len(data), nil
}

View File

@@ -88,13 +88,15 @@ func (r *reader) Setup(url string, aac bool, duration *uint32) error {
}
// clone needed to mitigate memory skill issues smh
if aac {
for _, s := range bytes.Split(r.resp.Body(), newline) {
for s := range bytes.SplitSeq(r.resp.Body(), newline) {
if len(s) == 0 {
continue
}
if s[0] == '#' {
if bytes.HasPrefix(s, []byte(`#EXT-X-MAP:URI="`)) {
r.parts = append(r.parts, clone(s[16:len(s)-1]))
// #EXT-X-MAP:URI="..."
const x = `#EXT-X-MAP:URI="`
if len(s) > len(x) && string(s[:len(x)]) == x {
r.parts = append(r.parts, clone(s[len(x):len(s)-1]))
}
continue
@@ -103,7 +105,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(r.resp.Body(), newline) {
for s := range bytes.SplitSeq(r.resp.Body(), newline) {
if len(s) == 0 || s[0] == '#' {
continue
}
@@ -120,7 +122,7 @@ func (r *reader) Close() error {
r.req.Reset()
r.resp.Reset()
r.leftover = nil
r.leftover = r.leftover[:0]
r.index = 0
r.parts = r.parts[:0]
@@ -132,10 +134,7 @@ func (r *reader) Close() error {
func (r *reader) Read(buf []byte) (n int, err error) {
misc.Log("we read")
if len(r.leftover) != 0 {
h := len(buf)
if h > len(r.leftover) {
h = len(r.leftover)
}
h := min(len(buf), len(r.leftover))
n = copy(buf, r.leftover[:h])
@@ -184,3 +183,8 @@ func (r *reader) Read(buf []byte) (n int, err error) {
return
}
func (c *reader) Write(data []byte) (n int, err error) {
c.leftover = append(c.leftover, data...)
return len(data), nil
}

View File

@@ -1,70 +1,24 @@
package sc
import (
"net/url"
"strings"
"git.maid.zone/stuff/soundcloak/lib/cfg"
)
// Functions/structures related to featured/suggested content
type PlaylistOrUser struct {
Kind string `json:"kind"` // "playlist" or "system-playlist" or "user"
Permalink string `json:"permalink"`
// User-specific
Avatar string `json:"avatar_url"`
Username string `json:"username"`
FullName string `json:"full_name"`
// Playlist-specific
Title string `json:"title"`
Author struct {
Permalink string `string:"permalink"`
} `json:"user"`
Artwork string `json:"artwork_url"`
TrackCount int64 `json:"track_count"`
}
func (p PlaylistOrUser) Href() string {
switch p.Kind {
case "system-playlist":
return "/discover/sets/" + p.Permalink
case "playlist":
return "/" + p.Author.Permalink + "/sets/" + p.Permalink
default:
return "/" + p.Permalink
}
}
func (p *PlaylistOrUser) Fix(prefs cfg.Preferences) {
switch p.Kind {
case "user":
if p.Avatar == "https://a1.sndcdn.com/images/default_avatar_large.png" {
p.Avatar = ""
} else {
p.Avatar = strings.Replace(p.Avatar, "-large.", "-t200x200.", 1)
}
default:
if p.Artwork != "" {
p.Artwork = strings.Replace(p.Artwork, "-large.", "-t200x200.", 1)
if cfg.ProxyImages && *prefs.ProxyImages {
p.Artwork = "/_/proxy/images?url=" + url.QueryEscape(p.Artwork)
}
}
}
}
type Selection struct {
Title string `json:"title"`
Kind string `json:"kind"` // should always be "selection"!
Items Paginated[*PlaylistOrUser] `json:"items"` // ?? why
Title string `json:"title"`
Kind string `json:"kind"` // should always be "selection"!
Items Paginated[*UserPlaylistTrack] `json:"items"` // ?? why
}
func GetSelections(cid string, prefs cfg.Preferences) (*Paginated[*Selection], error) {
uri := baseUri()
uri.SetPath("/mixed-selections")
uri.QueryArgs().Set("limit", "20")
// There is no pagination
p := Paginated[*Selection]{Next: "https://" + api + "/mixed-selections?limit=20"}
p := Paginated[*Selection]{Next: uri}
err := p.Proceed(cid, false)
if err != nil {
return nil, err

59
lib/sc/feeds.go Normal file
View File

@@ -0,0 +1,59 @@
package sc
import "encoding/xml"
// vendored type definitions from github.com/gorilla/feeds
// the only thing used from that module so why not drop some dead weight :)
type RssFeedXml struct {
XMLName xml.Name `xml:"rss"`
Version string `xml:"version,attr"`
ContentNamespace string `xml:"xmlns:content,attr"`
Channel *RssFeed
}
type RssFeed struct {
XMLName xml.Name `xml:"channel"`
Title string `xml:"title"` // required
Link string `xml:"link"` // required
Description string `xml:"description"` // required
//Language string `xml:"language,omitempty"`
//Copyright string `xml:"copyright,omitempty"`
ManagingEditor string `xml:"managingEditor,omitempty"` // Author used
//WebMaster string `xml:"webMaster,omitempty"`
PubDate string `xml:"pubDate,omitempty"` // created or updated
LastBuildDate string `xml:"lastBuildDate,omitempty"` // updated used
Category string `xml:"category,omitempty"`
Generator string `xml:"generator,omitempty"`
//Docs string `xml:"docs,omitempty"`
//Cloud string `xml:"cloud,omitempty"`
Ttl int `xml:"ttl,omitempty"`
//Rating string `xml:"rating,omitempty"`
//SkipHours string `xml:"skipHours,omitempty"`
//SkipDays string `xml:"skipDays,omitempty"`
//Image *RssImage
//TextInput *RssTextInput
Items []*RssItem `xml:"item"`
}
type RssItem struct {
XMLName xml.Name `xml:"item"`
Title string `xml:"title"` // required
Link string `xml:"link"` // required
Description string `xml:"description"` // required
//Content *RssContent
//Author string `xml:"author,omitempty"`
Category string `xml:"category,omitempty"`
//Comments string `xml:"comments,omitempty"`
//Enclosure *RssEnclosure
Guid *RssGuid // Id used
PubDate string `xml:"pubDate,omitempty"` // created or updated
//Source string `xml:"source,omitempty"`
}
type RssGuid struct {
//RSS 2.0 <guid isPermaLink="true">http://inessential.com/2002/09/01.php#a2</guid>
XMLName xml.Name `xml:"guid"`
Id string `xml:",chardata"`
IsPermaLink string `xml:"isPermaLink,attr,omitempty"` // "true", "false", or an empty string
}

View File

@@ -2,6 +2,7 @@ package sc
import (
"bytes"
"crypto/tls"
"errors"
"fmt"
"log"
@@ -22,12 +23,21 @@ import (
)
var ProxyErr = errors.New("could not connect to proxy")
var parsedproxy string
// don't jus leak the proxy like that lol
func scrub(err error) error {
if cfg.SoundcloudApiProxy != "" && err != nil {
if parsedproxy == "" {
u, err := url.Parse(cfg.SoundcloudApiProxy)
if err == nil {
parsedproxy = u.Host
} else {
parsedproxy = cfg.SoundcloudApiProxy
}
}
s := err.Error()
if strings.HasPrefix(s, "could not connect to proxyAddr") || strings.HasPrefix(s, "socks connect") {
if strings.HasPrefix(s, "could not connect to proxyAddr") || strings.HasPrefix(s, "socks connect") || strings.Contains(s, parsedproxy) {
return ProxyErr
}
}
@@ -49,18 +59,44 @@ const H = len("https://" + api)
var newline = []byte("\n")
var sc_version = []byte(`<script>window.__sc_version="`)
var script0 = []byte(`<script crossorigin src="https://a-v2.sndcdn.com/assets/0-`)
var script = []byte(`<script crossorigin src="https://a-v2.sndcdn.com/assets/`)
const sc_version = `<script>window.__sc_version="`
const sc_hydration = `<script>window.__sc_hydration = `
const script0 = `<script crossorigin src="https://a-v2.sndcdn.com/assets/0-`
const script = `<script crossorigin src="https://a-v2.sndcdn.com/assets/`
var tlsConfig = &tls.Config{
CipherSuites: []uint16{
tls.TLS_AES_128_GCM_SHA256,
tls.TLS_CHACHA20_POLY1305_SHA256,
tls.TLS_AES_256_GCM_SHA384,
tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256,
tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256,
tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
tls.TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA,
tls.TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA,
tls.TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA,
tls.TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA,
tls.TLS_RSA_WITH_AES_128_GCM_SHA256,
tls.TLS_RSA_WITH_AES_256_GCM_SHA384,
tls.TLS_RSA_WITH_AES_128_CBC_SHA,
tls.TLS_RSA_WITH_AES_256_CBC_SHA,
},
}
var httpc = &fasthttp.HostClient{
Addr: api + ":443",
IsTLS: true,
MaxIdleConnDuration: cfg.MaxIdleConnDuration,
DialDualStack: cfg.DialDualStack,
TLSConfig: tlsConfig,
}
var genericClient = &fasthttp.Client{
DialDualStack: cfg.DialDualStack,
TLSConfig: tlsConfig,
}
// var verRegex = regexp2.MustCompile(`^<script>window\.__sc_version="([0-9]{10})"</script>$`, 2)
@@ -69,6 +105,7 @@ var genericClient = &fasthttp.Client{
//go:generate go tool regexp2cg -package sc -o regexp2_codegen.go
var clientIdRegex = regexp2.MustCompile(`client_id:"([A-Za-z0-9]{32})"`, 0) //regexp2.MustCompile(`\("client_id=([A-Za-z0-9]{32})"\)`, 0)
var hydrationClientIdRegex = regexp2.MustCompile(`{"hydratable":"apiClient","data":{"id":"([A-Za-z0-9]{32})"`, 0)
var ErrVersionNotFound = errors.New("version not found")
var ErrScriptNotFound = errors.New("script not found")
var ErrIDNotFound = errors.New("clientid not found")
@@ -120,7 +157,7 @@ func processFile(wg *sync.WaitGroup, ch chan string, uri []byte, isDone *bool) {
return
}
m2, _ := clientIdRegex.FindStringMatch(string(data))
m2, _ := clientIdRegex.FindStringMatch(cfg.B2s(data))
if m2 != nil {
g := m2.GroupByNumber(1)
if g != nil {
@@ -138,10 +175,7 @@ func processFile(wg *sync.WaitGroup, ch chan string, uri []byte, isDone *bool) {
misc.Log("not found in", string(uri))
}
// Experimental method, which asserts that the clientId is inside the file that starts with "0-"
const experimental_GetClientID = false
// inspired by github.com/imputnet/cobalt
// now faster and more error-prone (?)
func GetClientID() (string, error) {
if cfg.ClientID != "" {
return cfg.ClientID, nil
@@ -155,7 +189,8 @@ func GetClientID() (string, error) {
req := fasthttp.AcquireRequest()
defer fasthttp.ReleaseRequest(req)
req.SetRequestURI("https://soundcloud.com")
req.URI().SetScheme("https")
req.URI().SetHost("soundcloud.com")
req.Header.SetUserAgent(cfg.UserAgent)
req.Header.Set("Accept-Encoding", "gzip, deflate, br, zstd")
@@ -172,46 +207,35 @@ func GetClientID() (string, error) {
data = resp.Body()
}
if experimental_GetClientID {
var ver string
var scriptUrl []byte
for l := range bytes.SplitSeq(data, newline) { // version usually comes earlier, but retest this sometimes !!!
if ver == "" && bytes.HasPrefix(l, sc_version) {
ver = cfg.B2s(l[len(sc_version) : len(l)-len(`"</script>`)])
misc.Log("found ver:", ver)
if ClientIDCache.Version != "" && ver == ClientIDCache.Version {
goto verCacheHit
}
} else if bytes.HasPrefix(l, script0) {
scriptUrl = l[len(`<script crossorigin src="`) : len(l)-len(`"></script>`)]
misc.Log("found scriptUrl:", string(scriptUrl))
break
var ver string
var hydration []byte
for l := range bytes.SplitSeq(data, newline) { // version usually comes earlier, but retest this sometimes !!!
if ver == "" && len(l) > len(sc_version)+len(`"</script>`) && string(l[:len(sc_version)]) == sc_version {
ver = cfg.B2s(l[len(sc_version) : len(l)-len(`"</script>`)])
misc.Log("found ver:", ver)
if ClientIDCache.Version != "" && ver == ClientIDCache.Version {
misc.Log("clientidcache hit @ ver")
ClientIDCache.NextCheck = time.Now().Add(cfg.ClientIDTTL)
return ClientIDCache.ClientID, nil
}
} else if len(l) > len(sc_hydration)+len(`;</script>`) && string(l[:len(sc_hydration)]) == sc_hydration {
hydration = l[len(sc_hydration) : len(l)-len(`;</script>`)]
misc.Log("found hydration:", cfg.B2s(hydration))
break
}
}
if ver == "" {
return "", ErrVersionNotFound
}
if ver == "" {
return "", ErrVersionNotFound
}
if scriptUrl == nil {
return "", ErrScriptNotFound
}
req.SetRequestURIBytes(scriptUrl)
err = DoWithRetryAll(genericClient, req, resp)
if err != nil {
return "", err
}
data, err = resp.BodyUncompressed()
if err != nil {
data = resp.Body()
}
m, _ := clientIdRegex.FindStringMatch(cfg.B2s(data))
// inspired a bit by 4get
if hydration != nil {
m, _ := hydrationClientIdRegex.FindStringMatch(cfg.B2s(hydration))
if m != nil {
g := m.GroupByNumber(1)
if g != nil {
misc.Log("found using sc_hydration")
ClientIDCache.ClientID = g.String()
ClientIDCache.Version = ver
ClientIDCache.NextCheck = time.Now().Add(cfg.ClientIDTTL)
@@ -219,72 +243,60 @@ func GetClientID() (string, error) {
return ClientIDCache.ClientID, nil
}
}
} else {
ch := make(chan string, 1)
wg := &sync.WaitGroup{}
done := false
var ver string
var scriptUrls = make([][]byte, 0, 10) // Usually only 8 chunks, so I went with jsut a bit more to be safe
for l := range bytes.SplitSeq(data, newline) {
if ver == "" && bytes.HasPrefix(l, sc_version) {
ver = cfg.B2s(l[len(sc_version) : len(l)-len(`"</script>`)])
if ver == ClientIDCache.Version {
goto verCacheHit
}
} else if bytes.HasPrefix(l, script) {
scriptUrls = append(scriptUrls, l[len(`<script crossorigin src="`):len(l)-len(`"></script>`)])
}
}
if ver == "" {
return "", ErrVersionNotFound
}
if len(scriptUrls) == 0 {
return "", ErrScriptNotFound
}
for _, s := range scriptUrls {
wg.Add(1)
go processFile(wg, ch, s, &done)
}
go func() {
defer func() {
err := recover()
misc.Log("-- GetClientID recovered:", err)
}()
wg.Wait()
//time.Sleep(time.Millisecond) // maybe race?
if !done {
ch <- ""
}
}()
res := <-ch
done = true
close(ch)
if res == "" {
err = ErrIDNotFound
} else {
ClientIDCache.ClientID = res
ClientIDCache.Version = ver
ClientIDCache.NextCheck = time.Now().Add(cfg.ClientIDTTL)
misc.Log(ClientIDCache)
}
return res, err
}
return "", ErrIDNotFound
// fallback to searching inside JS scripts, inspired by cobalt
ch := make(chan string, 1)
wg := &sync.WaitGroup{}
done := false
verCacheHit:
misc.Log("clientidcache hit @ ver")
ClientIDCache.NextCheck = time.Now().Add(cfg.ClientIDTTL)
return ClientIDCache.ClientID, nil
var scriptUrls = make([][]byte, 0, 9)
for l := range bytes.SplitSeq(data, newline) {
if len(l) > len(script)+len(`"></script>`) && string(l[:len(script)]) == script {
scriptUrls = append(scriptUrls, l[len(`<script crossorigin src="`):len(l)-len(`"></script>`)])
}
}
if ver == "" {
return "", ErrVersionNotFound
}
if len(scriptUrls) == 0 {
return "", ErrScriptNotFound
}
for _, s := range scriptUrls {
wg.Add(1)
go processFile(wg, ch, s, &done)
}
go func() {
defer func() {
err := recover()
misc.Log("-- GetClientID recovered:", err)
}()
wg.Wait()
//time.Sleep(time.Millisecond) // maybe race?
if !done {
ch <- ""
}
}()
res := <-ch
done = true
close(ch)
if res == "" {
err = ErrIDNotFound
} else {
ClientIDCache.ClientID = res
ClientIDCache.Version = ver
ClientIDCache.NextCheck = time.Now().Add(cfg.ClientIDTTL)
misc.Log(ClientIDCache)
}
return res, err
}
// Just retry any kind of errors, why not
@@ -340,7 +352,11 @@ func Resolve(cid string, path string, out any) error {
req := fasthttp.AcquireRequest()
defer fasthttp.ReleaseRequest(req)
req.SetRequestURI("https://" + api + "/resolve?url=https%3A%2F%2Fsoundcloud.com%2F" + url.QueryEscape(path) + "&client_id=" + cid)
req.URI().SetScheme("https")
req.URI().SetHost(api)
req.URI().SetPath("/resolve")
req.URI().QueryArgs().Set("url", "https://soundcloud.com/"+path)
req.URI().QueryArgs().Set("client_id", cid)
req.Header.SetUserAgent(cfg.UserAgent)
req.Header.Set("Accept-Encoding", "gzip, deflate, br, zstd")
@@ -365,9 +381,10 @@ func Resolve(cid string, path string, out any) error {
}
type Paginated[T any] struct {
Next string `json:"next_href"`
Collection []T `json:"collection"`
Total int64 `json:"total_results"`
NextHref string `json:"next_href"`
Next *fasthttp.URI `json:"-"`
Collection []T `json:"collection"`
Total int64 `json:"total_results"`
}
func (p *Paginated[T]) Proceed(cid string, shouldUnfold bool) error {
@@ -382,12 +399,17 @@ func (p *Paginated[T]) Proceed(cid string, shouldUnfold bool) error {
req := fasthttp.AcquireRequest()
defer fasthttp.ReleaseRequest(req)
oldNext := p.Next
req.SetRequestURI(p.Next + "&client_id=" + cid)
oldNext := p.NextHref
if p.NextHref == "" {
oldNext = p.Next.String()
req.SetURI(p.Next)
} else {
req.SetRequestURI(p.NextHref)
}
req.URI().QueryArgs().Set("client_id", cid)
req.Header.SetUserAgent(cfg.UserAgent)
req.Header.Set("Accept-Encoding", "gzip, deflate, br, zstd")
req.Header.Set("Accept-Language", "en-US,en;q=0.5") // you get captcha without it :)
resp := fasthttp.AcquireResponse()
defer fasthttp.ReleaseResponse(resp)
@@ -410,8 +432,8 @@ func (p *Paginated[T]) Proceed(cid string, shouldUnfold bool) error {
return err
}
if p.Next == oldNext { // prevent loops of nothingness
p.Next = ""
if p.NextHref == oldNext { // prevent loops of nothingness
p.NextHref = ""
}
// in soundcloud api, pagination may not immediately return you something!
@@ -419,7 +441,7 @@ func (p *Paginated[T]) Proceed(cid string, shouldUnfold bool) error {
// maybe there could be a way to cache the last useless layer of pagination so soundcloak can start loading from there? might be a bit complicated, but would be great
// another note: in featured tracks it seems to just be forever stuck after 2-3~ pages so i added a way to disable this behaviour
if shouldUnfold && len(p.Collection) == 0 && p.Next != "" {
if shouldUnfold && len(p.Collection) == 0 && p.NextHref != "" {
// this will make sure that we actually proceed to something useful and not emptiness
return p.Proceed(cid, true)
}
@@ -429,8 +451,8 @@ func (p *Paginated[T]) Proceed(cid string, shouldUnfold bool) error {
func TagListParser(taglist string) (res []string) {
inString := false
cur := []rune{}
for i, c := range taglist {
cur := []byte{}
for i, c := range cfg.S2b(taglist) {
if c == '"' {
if i == len(taglist)-1 {
res = append(res, string(cur))
@@ -443,7 +465,7 @@ func TagListParser(taglist string) (res []string) {
if !inString && c == ' ' {
res = append(res, string(cur))
cur = []rune{}
cur = cur[:0]
continue
}
@@ -462,7 +484,12 @@ type SearchSuggestion struct {
}
func GetSearchSuggestions(cid string, query string) ([]string, error) {
p := Paginated[SearchSuggestion]{Next: "https://" + api + "/search/queries?limit=10&q=" + url.QueryEscape(query)}
uri := baseUri()
uri.SetPath("/search/queries")
uri.QueryArgs().Set("limit", "10")
uri.QueryArgs().Set("q", query)
p := Paginated[SearchSuggestion]{Next: uri}
err := p.Proceed(cid, false)
if err != nil {
return nil, err
@@ -476,6 +503,89 @@ func GetSearchSuggestions(cid string, query string) ([]string, error) {
return l, nil
}
// polyglot type struct lol
type UserPlaylistTrack struct {
Kind string `json:"kind"` // "playlist" or "system-playlist" or "user" or "track"
Permalink string `json:"permalink"`
// User
Avatar string `json:"avatar_url"`
Username string `json:"username"`
FullName string `json:"full_name"`
// Playlist/track
Title string `json:"title"`
Author struct {
Permalink string `string:"permalink"`
Username string `json:"username"`
} `json:"user"`
Artwork string `json:"artwork_url"`
// Playlist
Tracks []struct{} `json:"tracks"` // stub
TrackCount int64 `json:"track_count"`
}
func (p UserPlaylistTrack) Href() string {
switch p.Kind {
case "system-playlist":
return "/discover/sets/" + p.Permalink
case "playlist":
return "/" + p.Author.Permalink + "/sets/" + p.Permalink
case "track":
return "/" + p.Author.Permalink + "/" + p.Permalink
default:
return "/" + p.Permalink
}
}
func (p *UserPlaylistTrack) Fix(prefs cfg.Preferences) {
switch p.Kind {
case "user":
if p.Avatar == "https://a1.sndcdn.com/images/default_avatar_large.png" {
p.Avatar = ""
} else {
p.Avatar = strings.Replace(p.Avatar, "-large.", "-t200x200.", 1)
}
if p.Avatar != "" && cfg.ProxyImages && *prefs.ProxyImages {
p.Avatar = "/_/proxy/images?url=" + url.QueryEscape(p.Avatar)
}
default:
if p.Artwork != "" {
p.Artwork = strings.Replace(p.Artwork, "-large.", "-t200x200.", 1)
if cfg.ProxyImages && *prefs.ProxyImages {
p.Artwork = "/_/proxy/images?url=" + url.QueryEscape(p.Artwork)
}
}
}
}
func (p UserPlaylistTrack) TracksCount() int64 {
if p.TrackCount != 0 {
return p.TrackCount
}
return int64(len(p.Tracks))
}
func Search(cid string, prefs cfg.Preferences, args []byte) (*Paginated[*UserPlaylistTrack], error) {
uri := baseUri()
uri.SetPath("/search")
uri.SetQueryStringBytes(args)
p := Paginated[*UserPlaylistTrack]{Next: uri}
err := p.Proceed(cid, true)
if err != nil {
return nil, err
}
for _, t := range p.Collection {
t.Fix(prefs)
}
return &p, nil
}
// could probably make a generic function, whatever
func init() {
if cfg.SoundcloudApiProxy != "" {
@@ -536,3 +646,11 @@ func init() {
}
}()
}
func baseUri() *fasthttp.URI {
uri := fasthttp.AcquireURI()
uri.SetScheme("https")
uri.SetHost(api)
return uri
}

View File

@@ -71,8 +71,11 @@ func GetPlaylist(cid string, permalink string) (Playlist, error) {
return p, nil
}
func SearchPlaylists(cid string, prefs cfg.Preferences, args string) (*Paginated[*Playlist], error) {
p := Paginated[*Playlist]{Next: "https://" + api + "/search/playlists" + args}
func SearchPlaylists(cid string, prefs cfg.Preferences, args []byte) (*Paginated[*Playlist], error) {
uri := baseUri()
uri.SetPath("/search/playlists")
uri.SetQueryStringBytes(args)
p := Paginated[*Playlist]{Next: uri}
err := p.Proceed(cid, true)
if err != nil {
return nil, err

View File

@@ -93,7 +93,7 @@ type Comment struct {
Timestamp int `json:"timestamp"`
}
func (m Media) SelectCompatible(mode string, opus bool) (*Transcoding, string) {
func (m Media) SelectCompatible(mode string, opus bool, restream bool) (*Transcoding, string) {
switch mode {
case cfg.AudioBest:
for _, t := range m.Transcodings {
@@ -123,6 +123,13 @@ func (m Media) SelectCompatible(mode string, opus bool) (*Transcoding, string) {
}
}
if restream {
for _, t := range m.Transcodings {
if t.Format.Protocol == ProtocolProgressive && t.Format.MimeType == "audio/mpeg" {
return &t, cfg.AudioMP3
}
}
}
for _, t := range m.Transcodings {
if t.Format.Protocol == ProtocolHLS && t.Format.MimeType == "audio/mpeg" {
return &t, cfg.AudioMP3
@@ -131,6 +138,15 @@ func (m Media) SelectCompatible(mode string, opus bool) (*Transcoding, string) {
return nil, ""
}
func (m Media) SelectCompatibleProgressive() *Transcoding {
for _, t := range m.Transcodings {
if t.Format.Protocol == ProtocolProgressive && t.Format.MimeType == "audio/mpeg" {
return &t
}
}
return nil
}
func GetTrack(cid string, permalink string) (Track, error) {
tracksCacheLock.RLock()
if cell, ok := TracksCache[permalink]; ok {
@@ -245,8 +261,11 @@ func GetArbitraryTrack(cid string, data string) (Track, error) {
return Track{}, ErrKindNotCorrect
}
func SearchTracks(cid string, prefs cfg.Preferences, args string) (*Paginated[*Track], error) {
p := Paginated[*Track]{Next: "https://" + api + "/search/tracks" + args}
func SearchTracks(cid string, prefs cfg.Preferences, args []byte) (*Paginated[*Track], error) {
uri := baseUri()
uri.SetPath("/search/tracks")
uri.SetQueryStringBytes(args)
p := Paginated[*Track]{Next: uri}
err := p.Proceed(cid, true)
if err != nil {
return nil, err
@@ -343,12 +362,17 @@ func (tr Transcoding) GetStream(cid string, prefs cfg.Preferences, authorization
return "", ErrNoURL
}
if cfg.ProxyStreams && *prefs.ProxyStreams && *prefs.Player == cfg.HLSPlayer {
if tr.Preset == "aac_160k" {
return "/_/proxy/streams/playlist/aac?url=" + url.QueryEscape(s.URL), nil
}
if cfg.ProxyStreams && *prefs.ProxyStreams {
switch *prefs.Player {
case cfg.HLSPlayer:
if tr.Preset == "aac_160k" {
return "/_/proxy/streams/playlist/aac?url=" + url.QueryEscape(s.URL), nil
}
return "/_/proxy/streams/playlist?url=" + url.QueryEscape(s.URL), nil
return "/_/proxy/streams/playlist?url=" + url.QueryEscape(s.URL), nil
case cfg.ProgressivePlayer:
return "/_/proxy/streams?url=" + url.QueryEscape(s.URL), nil
}
}
return s.URL, nil
@@ -456,8 +480,11 @@ func (t Track) Href() string {
return "/" + t.Author.Permalink + "/" + t.Permalink
}
func RecentTracks(cid string, prefs cfg.Preferences, args string) (*Paginated[*Track], error) {
p := Paginated[*Track]{Next: "https://" + api + "/recent-tracks/" + args}
func RecentTracks(cid string, prefs cfg.Preferences, tag, args string) (*Paginated[*Track], error) {
uri := baseUri()
uri.SetPath("/recent-tracks/" + tag)
uri.SetQueryString(args)
p := Paginated[*Track]{Next: uri}
err := p.Proceed(cid, true)
if err != nil {
return nil, err
@@ -471,10 +498,15 @@ func RecentTracks(cid string, prefs cfg.Preferences, args string) (*Paginated[*T
return &p, nil
}
func (t Track) baseUri(subpath, args string) *fasthttp.URI {
uri := baseUri()
uri.SetPath("/tracks/" + string(t.ID) + "/" + subpath)
uri.SetQueryString(args)
return uri
}
func (t Track) GetRelated(cid string, prefs cfg.Preferences, args string) (*Paginated[*Track], error) {
p := Paginated[*Track]{
Next: "https://" + api + "/tracks/" + string(t.ID) + "/related" + args,
}
p := Paginated[*Track]{Next: t.baseUri("related", args)}
err := p.Proceed(cid, true)
if err != nil {
@@ -490,9 +522,7 @@ func (t Track) GetRelated(cid string, prefs cfg.Preferences, args string) (*Pagi
}
func (t Track) GetPlaylists(cid string, prefs cfg.Preferences, args string) (*Paginated[*Playlist], error) {
p := Paginated[*Playlist]{
Next: "https://" + api + "/tracks/" + string(t.ID) + "/playlists_without_albums" + args,
}
p := Paginated[*Playlist]{Next: t.baseUri("playlists_without_albums", args)}
err := p.Proceed(cid, true)
if err != nil {
@@ -508,9 +538,7 @@ func (t Track) GetPlaylists(cid string, prefs cfg.Preferences, args string) (*Pa
}
func (t Track) GetAlbums(cid string, prefs cfg.Preferences, args string) (*Paginated[*Playlist], error) {
p := Paginated[*Playlist]{
Next: "https://" + api + "/tracks/" + string(t.ID) + "/albums" + args,
}
p := Paginated[*Playlist]{Next: t.baseUri("albums", args)}
err := p.Proceed(cid, true)
if err != nil {
@@ -526,9 +554,7 @@ func (t Track) GetAlbums(cid string, prefs cfg.Preferences, args string) (*Pagin
}
func (t Track) GetComments(cid string, prefs cfg.Preferences, args string) (*Paginated[*Comment], error) {
p := Paginated[*Comment]{
Next: "https://" + api + "/tracks/" + string(t.ID) + "/comments" + args,
}
p := Paginated[*Comment]{Next: t.baseUri("comments", args)}
err := p.Proceed(cid, true)
if err != nil {
@@ -542,3 +568,16 @@ func (t Track) GetComments(cid string, prefs cfg.Preferences, args string) (*Pag
return &p, nil
}
func ToExt(audio string) string {
switch audio {
case cfg.AudioAAC:
return "m4a"
case cfg.AudioOpus:
return "ogg"
case cfg.AudioMP3:
return "mp3"
}
return ""
}

View File

@@ -14,7 +14,6 @@ import (
"git.maid.zone/stuff/soundcloak/lib/cfg"
"git.maid.zone/stuff/soundcloak/lib/textparsing"
"github.com/goccy/go-json"
"github.com/gorilla/feeds"
"github.com/valyala/fasthttp"
)
@@ -137,8 +136,11 @@ func GetUser(cid string, permalink string) (User, error) {
return u, err
}
func SearchUsers(cid string, prefs cfg.Preferences, args string) (*Paginated[*User], error) {
p := Paginated[*User]{Next: "https://" + api + "/search/users" + args}
func SearchUsers(cid string, prefs cfg.Preferences, args []byte) (*Paginated[*User], error) {
uri := baseUri()
uri.SetPath("/search/users")
uri.SetQueryStringBytes(args)
p := Paginated[*User]{Next: uri}
err := p.Proceed(cid, true)
if err != nil {
return nil, err
@@ -152,10 +154,15 @@ func SearchUsers(cid string, prefs cfg.Preferences, args string) (*Paginated[*Us
return &p, nil
}
func (u User) baseUri(subpath, args string) *fasthttp.URI {
uri := baseUri()
uri.SetPath("/users/" + string(u.ID) + "/" + subpath)
uri.SetQueryString(args)
return uri
}
func (u User) GetTracks(cid string, prefs cfg.Preferences, args string) (*Paginated[*Track], error) {
p := Paginated[*Track]{
Next: "https://" + api + "/users/" + string(u.ID) + "/tracks" + args,
}
p := Paginated[*Track]{Next: u.baseUri("tracks", args)}
err := p.Proceed(cid, true)
if err != nil {
@@ -232,9 +239,7 @@ func (u *User) Postfix(prefs cfg.Preferences) {
}
func (u User) GetPlaylists(cid string, prefs cfg.Preferences, args string) (*Paginated[*Playlist], error) {
p := Paginated[*Playlist]{
Next: "https://" + api + "/users/" + string(u.ID) + "/playlists_without_albums" + args,
}
p := Paginated[*Playlist]{Next: u.baseUri("playlists_without_albums", args)}
err := p.Proceed(cid, true)
if err != nil {
@@ -250,9 +255,7 @@ func (u User) GetPlaylists(cid string, prefs cfg.Preferences, args string) (*Pag
}
func (u User) GetAlbums(cid string, prefs cfg.Preferences, args string) (*Paginated[*Playlist], error) {
p := Paginated[*Playlist]{
Next: "https://" + api + "/users/" + string(u.ID) + "/albums" + args,
}
p := Paginated[*Playlist]{Next: u.baseUri("albums", args)}
err := p.Proceed(cid, true)
if err != nil {
@@ -268,9 +271,10 @@ func (u User) GetAlbums(cid string, prefs cfg.Preferences, args string) (*Pagina
}
func (u User) GetReposts(cid string, prefs cfg.Preferences, args string) (*Paginated[*Repost], error) {
p := Paginated[*Repost]{
Next: "https://" + api + "/stream/users/" + string(u.ID) + "/reposts" + args,
}
uri := baseUri()
uri.SetPath("/stream/users/" + string(u.ID) + "/reposts")
uri.SetQueryString(args)
p := Paginated[*Repost]{Next: uri}
err := p.Proceed(cid, true)
if err != nil {
@@ -285,9 +289,7 @@ func (u User) GetReposts(cid string, prefs cfg.Preferences, args string) (*Pagin
}
func (u User) GetLikes(cid string, prefs cfg.Preferences, args string) (*Paginated[*Like], error) {
p := Paginated[*Like]{
Next: "https://" + api + "/users/" + string(u.ID) + "/likes" + args,
}
p := Paginated[*Like]{Next: u.baseUri("likes", args)}
err := p.Proceed(cid, true)
if err != nil {
@@ -338,8 +340,11 @@ func (u *User) GetWebProfiles(cid string) error {
}
func (u User) GetRelated(cid string, prefs cfg.Preferences) ([]*User, error) {
uri := baseUri()
uri.SetPath("/users/" + string(u.ID) + "/relatedartists")
uri.QueryArgs().Set("page_size", "20")
p := Paginated[*User]{
Next: "https://" + api + "/users/" + string(u.ID) + "/relatedartists?page_size=20",
Next: uri,
}
err := p.Proceed(cid, true)
@@ -356,9 +361,10 @@ func (u User) GetRelated(cid string, prefs cfg.Preferences) ([]*User, error) {
}
func (u User) GetTopTracks(cid string, prefs cfg.Preferences) ([]*Track, error) {
p := Paginated[*Track]{
Next: "https://" + api + "/users/" + string(u.ID) + "/toptracks?limit=10",
}
uri := baseUri()
uri.SetPath("/users/" + string(u.ID) + "/toptracks")
uri.QueryArgs().Set("limit", "10")
p := Paginated[*Track]{Next: uri}
err := p.Proceed(cid, true)
if err != nil {
@@ -374,9 +380,7 @@ func (u User) GetTopTracks(cid string, prefs cfg.Preferences) ([]*Track, error)
}
func (u User) GetFollowers(cid string, prefs cfg.Preferences, args string) (*Paginated[*User], error) {
p := Paginated[*User]{
Next: "https://" + api + "/users/" + string(u.ID) + "/followers" + args,
}
p := Paginated[*User]{Next: u.baseUri("followers", args)}
err := p.Proceed(cid, true)
if err != nil {
@@ -392,9 +396,7 @@ func (u User) GetFollowers(cid string, prefs cfg.Preferences, args string) (*Pag
}
func (u User) GetFollowing(cid string, prefs cfg.Preferences, args string) (*Paginated[*User], error) {
p := Paginated[*User]{
Next: "https://" + api + "/users/" + string(u.ID) + "/followings" + args,
}
p := Paginated[*User]{Next: u.baseUri("followings", args)}
err := p.Proceed(cid, true)
if err != nil {
@@ -420,12 +422,12 @@ func t(s string) string {
// TODO: maybe add option for caching generated feeds? could benefit when many people follow same artists
func (u *User) GenerateFeed(ctx context.Context, cid string, prefs cfg.Preferences, base string) ([]byte, error) {
tracks, err := u.GetTracks(cid, prefs, "?limit=20")
tracks, err := u.GetTracks(cid, prefs, "limit=20")
if err != nil {
return nil, err
}
f := feeds.RssFeed{
f := RssFeed{
Title: "Tracks from " + u.Username,
Link: base + "/" + u.Permalink,
ManagingEditor: u.Username + " (@" + u.Permalink + ")",
@@ -439,12 +441,12 @@ func (u *User) GenerateFeed(ctx context.Context, cid string, prefs cfg.Preferenc
if len(tracks.Collection) != 0 {
f.LastBuildDate = t(tracks.Collection[0].LastModified)
for _, track := range tracks.Collection {
item := feeds.RssItem{
item := RssItem{
Title: track.Title,
Link: base + "/" + u.Permalink + "/" + track.Permalink,
Category: track.Genre,
Guid: &feeds.RssGuid{Id: string(track.ID), IsPermaLink: "false"},
Guid: &RssGuid{Id: string(track.ID), IsPermaLink: "false"},
PubDate: t(track.LastModified),
}
@@ -469,7 +471,7 @@ func (u *User) GenerateFeed(ctx context.Context, cid string, prefs cfg.Preferenc
}
f.PubDate = f.LastBuildDate
return xml.Marshal(feeds.RssFeedXml{
return xml.Marshal(RssFeedXml{
Version: "2.0",
Channel: &f,
ContentNamespace: "http://purl.org/rss/1.0/modules/content/",

267
main.go
View File

@@ -2,6 +2,7 @@ package main
import (
"bytes"
"encoding/xml"
"fmt"
"io"
"io/fs"
@@ -319,7 +320,7 @@ func main() {
return err
}
return r(c, "", templates.MainPage(prefs), templates.MainPageHead())
return r(c, "", templates.MainPage(prefs), templates.MainPageHead(prefs))
}
app.Get("/", mainPageHandler)
@@ -345,6 +346,10 @@ func main() {
return c.Redirect().Status(fiber.StatusPermanentRedirect).To("/_/static/favicon.ico")
})
app.Get("apple-touch-icon.png", func(c fiber.Ctx) error {
return c.Redirect().Status(fiber.StatusPermanentRedirect).To("/_/static/favicon.ico")
})
app.Get("robots.txt", func(c fiber.Ctx) error {
return c.SendString(`User-agent: *
Disallow: /`)
@@ -356,40 +361,84 @@ Disallow: /`)
return err
}
q := cfg.B2s(c.RequestCtx().QueryArgs().Peek("q"))
q := 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)
args := c.RequestCtx().QueryArgs().Peek("pagination")
if len(args) == 0 {
args = fasthttp.AppendQuotedArg([]byte("q="), q)
}
switch t {
case "any":
q := cfg.B2s(q)
p, err := sc.Search("", prefs, args)
if err != nil {
log.Printf("error getting any for %s: %s\n", q, err)
return err
}
if q == "" {
q = p.NextHref[sc.H+len("/search?"):]
i := strings.LastIndexByte(q, '&')
if i != -1 {
q, _ = url.QueryUnescape(q[i+len("&q="):])
}
}
return r(c, q, templates.Search(p, prefs, q), templates.MainPageHead(prefs))
case "tracks":
q := cfg.B2s(q)
p, err := sc.SearchTracks("", prefs, args)
if err != nil {
log.Printf("error getting tracks for %s: %s\n", q, err)
return err
}
return r(c, "tracks: "+q, templates.SearchTracks(p), nil)
if q == "" {
q = p.NextHref[sc.H+len("/search/tracks?"):]
i := strings.LastIndexByte(q, '&')
if i != -1 {
q, _ = url.QueryUnescape(q[i+len("&q="):])
}
}
return r(c, "tracks: "+q, templates.SearchTracks(p, prefs, q), templates.MainPageHead(prefs))
case "users":
q := cfg.B2s(q)
p, err := sc.SearchUsers("", prefs, args)
if err != nil {
log.Printf("error getting users for %s: %s\n", q, err)
return err
}
return r(c, "users: "+q, templates.SearchUsers(p), nil)
if q == "" {
q = p.NextHref[sc.H+len("/search/users?"):]
i := strings.LastIndexByte(q, '&')
if i != -1 {
q, _ = url.QueryUnescape(q[i+len("&q="):])
}
}
return r(c, "users: "+q, templates.SearchUsers(p, prefs, q), templates.MainPageHead(prefs))
case "playlists":
q := cfg.B2s(q)
p, err := sc.SearchPlaylists("", prefs, args)
if err != nil {
log.Printf("error getting playlists for %s: %s\n", q, err)
return err
}
return r(c, "playlists: "+q, templates.SearchPlaylists(p), nil)
if q == "" {
q = p.NextHref[sc.H+len("/search/playlists?"):]
i := strings.LastIndexByte(q, '&')
if i != -1 {
q, _ = url.QueryUnescape(q[i+len("&q="):])
}
}
return r(c, "playlists: "+q, templates.SearchPlaylists(p, prefs, q), templates.MainPageHead(prefs))
}
return c.SendStatus(404)
@@ -465,19 +514,33 @@ Disallow: /`)
displayErr := ""
stream := ""
if *prefs.Player != cfg.NonePlayer {
tr, _ := track.Media.SelectCompatible(*prefs.HLSAudio, false)
if *prefs.Player == cfg.HLSPlayer {
var tr *sc.Transcoding
tr, _ = track.Media.SelectCompatible(*prefs.HLSAudio, false, false)
if tr == nil {
err = sc.ErrIncompatibleStream
} else if *prefs.Player == cfg.HLSPlayer {
} else {
stream, err = tr.GetStream(cid, prefs, track.Authorization)
}
} else if *prefs.Player == cfg.RestreamPlayer {
_, audio := track.Media.SelectCompatible(*prefs.RestreamAudio, true, true)
if audio == "" {
err = sc.ErrIncompatibleStream
}
} else {
var tr *sc.Transcoding
tr = track.Media.SelectCompatibleProgressive()
if tr == nil {
err = sc.ErrIncompatibleStream
} else {
stream, err = tr.GetStream(cid, prefs, track.Authorization)
}
}
if err != nil {
displayErr = "Failed to get track stream: " + err.Error()
if track.Policy == sc.PolicyBlock {
displayErr += "\nThis track may be blocked in the country where this instance is hosted."
}
if err != nil {
displayErr = "Failed to get track stream: " + err.Error()
if track.Policy == sc.PolicyBlock {
displayErr += "\nThis track may be blocked in the country where this instance is hosted."
}
}
@@ -491,7 +554,7 @@ Disallow: /`)
}
tag := c.Params("tag")
p, err := sc.RecentTracks("", prefs, c.Query("pagination", tag+"?limit=20"))
p, err := sc.RecentTracks("", prefs, tag, c.Query("pagination", "limit=20"))
if err != nil {
log.Printf("error getting %s tagged recent-tracks: %s\n", tag, err)
return err
@@ -507,7 +570,12 @@ Disallow: /`)
}
tag := c.Params("tag")
p, err := sc.SearchTracks("", prefs, c.Query("pagination", "?q=*&filter.genre_or_tag="+tag+"&sort=popular"))
args := c.RequestCtx().QueryArgs().Peek("pagination")
if len(args) == 0 {
args = []byte("q=*&filter.genre_or_tag=" + tag + "&sort=popular")
}
p, err := sc.SearchTracks("", prefs, args)
if err != nil {
log.Printf("error getting %s tagged popular-tracks: %s\n", tag, err)
return err
@@ -524,7 +592,12 @@ Disallow: /`)
tag := c.Params("tag")
// Using a different method, since /playlists/discovery endpoint seems to be broken :P
p, err := sc.SearchPlaylists("", prefs, c.Query("pagination", "?q=*&filter.genre_or_tag="+tag))
args := c.RequestCtx().QueryArgs().Peek("pagination")
if len(args) == 0 {
args = []byte("q=*&filter.genre_or_tag=" + tag)
}
p, err := sc.SearchPlaylists("", prefs, args)
if err != nil {
log.Printf("error getting %s tagged playlists: %s\n", tag, err)
return err
@@ -594,6 +667,64 @@ Disallow: /`)
if cfg.Restream {
restream.Load(app)
app.Get("/_/download/:author/:track", func(c fiber.Ctx) error {
p, err := preferences.Get(c)
if err != nil {
return err
}
p.ProxyImages = &cfg.False
p.ProxyStreams = &cfg.False
cid, err := sc.GetClientID()
if err != nil {
return err
}
t, err := sc.GetTrack(cid, c.Params("author")+"/"+c.Params("track"))
if err != nil {
return err
}
disabled_formats := map[string]bool{
cfg.AudioBest: false,
cfg.AudioAAC: true,
cfg.AudioOpus: true,
cfg.AudioMP3: true,
}
fmt.Println(t.Media.Transcodings)
for _, tr := range t.Media.Transcodings {
switch tr.Format.Protocol {
case sc.ProtocolHLS:
if tr.Preset == "aac_160k" {
disabled_formats[cfg.AudioAAC] = false
} else if tr.Format.MimeType == "audio/mpeg" {
disabled_formats[cfg.AudioMP3] = false
} else if strings.HasPrefix(tr.Preset, "opus_") {
disabled_formats[cfg.AudioOpus] = false
}
case sc.ProtocolProgressive:
if tr.Format.MimeType == "audio/mpeg" {
disabled_formats[cfg.AudioMP3] = false
}
}
}
if disabled_formats[cfg.AudioAAC] && disabled_formats[cfg.AudioOpus] && disabled_formats[cfg.AudioMP3] {
disabled_formats[cfg.AudioBest] = true
}
if disabled_formats[*p.DownloadAudio] {
pr := cfg.AudioMP3
p.DownloadAudio = &pr
}
return r(c, "Download "+t.Title+" by "+t.Author.Username, templates.DownloadTrack(p, t, disabled_formats), nil)
})
app.Post("/_/download/:author/:track", func(c fiber.Ctx) error {
return c.Redirect().To("/_/restream/" + c.Params("author") + "/" + c.Params("track") + "?metadata=true&" + strings.ReplaceAll(cfg.B2s(c.Body()), "+", "%20"))
})
}
preferences.Load(app)
@@ -609,9 +740,64 @@ Disallow: /`)
return err
}
if string(c.RequestCtx().QueryArgs().Peek("format")) == "opensearch" {
return c.JSON([]any{q, s})
}
return c.JSON(s)
})
{
type URL struct {
XMLName xml.Name `xml:"Url"`
Method string `xml:"method,attr"`
Template string `xml:"template,attr"`
Type string `xml:"type,attr"`
Rel string `xml:"rel,attr,omitempty"`
}
type Description struct {
XMLName xml.Name `xml:"http://a9.com/-/spec/opensearch/1.1/ OpenSearchDescription"`
Description string
LongName string
ShortName string
Image string
URLs []URL
}
app.Get("/_/opensearch.xml", func(c fiber.Ctx) error {
base := c.BaseURL()
d := Description{
ShortName: "soundcloak",
LongName: "soundcloak",
Description: "Frontend for SoundCloud",
Image: base + "/_/static/favicon.ico",
URLs: []URL{
{
Method: "get",
Template: base + "/search?q={searchTerms}&type=any",
Type: "text/html",
Rel: "results",
},
{
Method: "get",
Template: base + "/_/searchSuggestions?q={searchTerms}&format=opensearch",
Type: "application/x-suggestions+json",
},
{
Method: "get",
Template: base + "/_/opensearch.xml",
Type: "application/opensearchdescription+xml",
Rel: "self",
},
},
}
return c.XML(d)
})
}
// Currently, /:user is the tracks page
app.Get("/:user/tracks", func(c fiber.Ctx) error {
return c.Redirect().To("/" + c.Params("user"))
@@ -635,7 +821,7 @@ Disallow: /`)
}
user.Postfix(prefs)
pl, err := user.GetPlaylists(cid, prefs, c.Query("pagination", "?limit=20"))
pl, err := user.GetPlaylists(cid, prefs, c.Query("pagination", "limit=20"))
if err != nil {
log.Printf("error getting %s playlists: %s\n", c.Params("user"), err)
return err
@@ -662,7 +848,7 @@ Disallow: /`)
}
user.Postfix(prefs)
pl, err := user.GetAlbums(cid, prefs, c.Query("pagination", "?limit=20"))
pl, err := user.GetAlbums(cid, prefs, c.Query("pagination", "limit=20"))
if err != nil {
log.Printf("error getting %s albums: %s\n", c.Params("user"), err)
return err
@@ -689,7 +875,7 @@ Disallow: /`)
}
user.Postfix(prefs)
p, err := user.GetReposts(cid, prefs, c.Query("pagination", "?limit=20"))
p, err := user.GetReposts(cid, prefs, c.Query("pagination", "limit=20"))
if err != nil {
log.Printf("error getting %s reposts: %s\n", c.Params("user"), err)
return err
@@ -716,7 +902,7 @@ Disallow: /`)
}
user.Postfix(prefs)
p, err := user.GetLikes(cid, prefs, c.Query("pagination", "?limit=20"))
p, err := user.GetLikes(cid, prefs, c.Query("pagination", "limit=20"))
if err != nil {
log.Printf("error getting %s likes: %s\n", c.Params("user"), err)
return err
@@ -770,7 +956,7 @@ Disallow: /`)
}
user.Postfix(prefs)
p, err := user.GetFollowers(cid, prefs, c.Query("pagination", "?limit=20"))
p, err := user.GetFollowers(cid, prefs, c.Query("pagination", "limit=20"))
if err != nil {
log.Printf("error getting %s followers: %s\n", c.Params("user"), err)
return err
@@ -797,7 +983,7 @@ Disallow: /`)
}
user.Postfix(prefs)
p, err := user.GetFollowing(cid, prefs, c.Query("pagination", "?limit=20"))
p, err := user.GetFollowing(cid, prefs, c.Query("pagination", "limit=20"))
if err != nil {
log.Printf("error getting %s following: %s\n", c.Params("user"), err)
return err
@@ -831,17 +1017,26 @@ Disallow: /`)
if *prefs.Player != cfg.NonePlayer {
if *prefs.Player == cfg.HLSPlayer {
var tr *sc.Transcoding
tr, audio = track.Media.SelectCompatible(*prefs.HLSAudio, false)
tr, audio = track.Media.SelectCompatible(*prefs.HLSAudio, false, false)
if tr == nil {
err = sc.ErrIncompatibleStream
} else {
stream, err = tr.GetStream(cid, prefs, track.Authorization)
}
} else {
_, audio = track.Media.SelectCompatible(*prefs.RestreamAudio, true)
} else if *prefs.Player == cfg.RestreamPlayer {
_, audio = track.Media.SelectCompatible(*prefs.RestreamAudio, true, true)
if audio == "" {
err = sc.ErrIncompatibleStream
}
} else {
audio = cfg.AudioMP3
var tr *sc.Transcoding
tr = track.Media.SelectCompatibleProgressive()
if tr == nil {
err = sc.ErrIncompatibleStream
} else {
stream, err = tr.GetStream(cid, prefs, track.Authorization)
}
}
if err != nil {
@@ -918,7 +1113,7 @@ Disallow: /`)
var downloadAudio *string
if cfg.Restream {
_, audio := track.Media.SelectCompatible(*prefs.DownloadAudio, true)
_, audio := track.Media.SelectCompatible(*prefs.DownloadAudio, true, true)
downloadAudio = &audio
}
@@ -947,8 +1142,9 @@ Disallow: /`)
return err
}
if comm.Next != "" {
c.Set("next", "?pagination="+url.QueryEscape(strings.Split(comm.Next, "/comments")[1]))
if comm.NextHref != "" {
misc.Log(comm.NextHref)
c.Set("next", "?pagination="+url.QueryEscape(strings.Split(comm.NextHref, "/comments?")[1]))
} else {
c.Set("next", "done")
}
@@ -1008,7 +1204,7 @@ Disallow: /`)
}
usr.Postfix(prefs)
p, err := usr.GetTracks(cid, prefs, c.Query("pagination", "?limit=20"))
p, err := usr.GetTracks(cid, prefs, c.Query("pagination", "limit=20"))
if err != nil {
log.Printf("error getting %s tracks: %s\n", c.Params("user"), err)
return err
@@ -1102,7 +1298,7 @@ Disallow: /`)
}
track.Postfix(prefs, true)
rel, err := track.GetRelated(cid, prefs, c.Query("pagination", "?limit=20"))
rel, err := track.GetRelated(cid, prefs, c.Query("pagination", "limit=20"))
if err != nil {
log.Printf("error getting %s from %s related tracks: %s\n", c.Params("track"), c.Params("user"), err)
return err
@@ -1129,7 +1325,7 @@ Disallow: /`)
}
track.Postfix(prefs, true)
p, err := track.GetPlaylists(cid, prefs, c.Query("pagination", "?limit=20"))
p, err := track.GetPlaylists(cid, prefs, c.Query("pagination", "limit=20"))
if err != nil {
log.Printf("error getting %s from %s sets: %s\n", c.Params("track"), c.Params("user"), err)
return err
@@ -1156,7 +1352,7 @@ Disallow: /`)
}
track.Postfix(prefs, true)
p, err := track.GetAlbums(cid, prefs, c.Query("pagination", "?limit=20"))
p, err := track.GetAlbums(cid, prefs, c.Query("pagination", "limit=20"))
if err != nil {
log.Printf("error getting %s from %s albums: %s\n", c.Params("track"), c.Params("user"), err)
return err
@@ -1190,6 +1386,7 @@ Disallow: /`)
if cfg.Addr[0] == ':' {
table["Listening on"] = "127.0.0.1" + cfg.Addr
}
table["Listening on"] = "http://" + table["Listening on"]
longest := ""
for key := range table {
if len(key) > len(longest) {

View File

@@ -16,12 +16,4 @@
#search-suggestions > li:hover {
cursor: pointer;
}
footer > div {
margin-top: 5rem;
gap: 1rem;
display: grid;
grid-template: auto / auto auto auto;
justify-content: center;
}

View File

@@ -11,6 +11,7 @@ templ Base(title string, content templ.Component, head templ.Component) {
<link rel="stylesheet" href="/_/static/global.css"/>
<link rel="stylesheet" href="/_/static/instance.css"/>
<link rel="icon" href="/_/static/favicon.ico"/>
<link rel="search" type="application/opensearchdescription+xml" title="soundcloak" href="/_/opensearch.xml"/>
<title>
if title != "" {
{ title } ~
@@ -31,36 +32,22 @@ templ Base(title string, content templ.Component, head templ.Component) {
</html>
}
templ MainPageHead() {
<link rel="stylesheet" href="/_/static/index.css"/>
templ MainPageHead(p cfg.Preferences) {
if *p.SearchSuggestions {
<link rel="stylesheet" href="/_/static/index.css"/>
}
}
templ MainPage(p cfg.Preferences) {
<form action="/search">
<div style="position: relative">
<div style="display: flex; gap: .5rem;">
<input id="q" name="q" type="text" autocomplete="off" autofill="off" style="padding: .5rem .6rem; flex-grow: 1;"/>
<select name="type">
<option value="tracks">Tracks</option>
<option value="users">Users</option>
<option value="playlists">Playlists</option>
</select>
</div>
if *p.SearchSuggestions {
<ul id="search-suggestions" style="display: none;"></ul>
<script async src="/_/static/index.js"></script>
}
</div>
<input type="submit" value="Search" class="btn" style="width: 100%; margin-top: .5rem;"/>
</form>
@searchbar(p, "", "")
<footer>
<div>
<div style="margin-top:5rem;gap:1rem;display:grid;grid-template:auto/auto auto auto;justify-content:center">
<a class="btn" href="/discover">Discover Playlists</a>
<a class="btn" href="/_/preferences">Preferences</a>
<a class="btn" href="https://git.maid.zone/stuff/soundcloak">Source code</a>
<a class="btn" href="/_/static/notice.txt">Legal notice</a>
</div>
<p style="text-align: center;">Build <a class="link" href={cfg.CommitURL}>{cfg.Commit}</a></p>
<p style="text-align:center">Build <a class="link" href={templ.SafeURL(cfg.CommitURL)}>{cfg.Commit}</a></p>
</footer>
}

51
templates/download.templ Normal file
View File

@@ -0,0 +1,51 @@
package templates
import "git.maid.zone/stuff/soundcloak/lib/sc"
import "git.maid.zone/stuff/soundcloak/lib/cfg"
templ sel_audio2(name string, selected string, f map[string]bool) {
@sel(name, []option{
{cfg.AudioBest, "Best", f[cfg.AudioBest]},
{cfg.AudioAAC, "M4A AAC 160kb/s", f[cfg.AudioAAC]},
{cfg.AudioOpus, "OGG Opus 72kb/s", f[cfg.AudioOpus]},
{cfg.AudioMP3, "MP3 128kb/s", f[cfg.AudioMP3]},
}, selected)
}
templ text(name string, value string) {
<input name={ name } type="text" autocomplete="off" value={value}/>
}
templ DownloadTrack(prefs cfg.Preferences, t sc.Track, disabled_formats map[string]bool) {
if t.Artwork != "" {
<img src={ t.Artwork } width="300px"/>
}
<h1><a href={templ.SafeURL(t.Href())} id="title">{ t.Title }</a></h1>
if t.Policy == sc.PolicySnip {
<h2>Full track not available, only a 30-second snippet.</h2>
}
// just nice script to redirect you back to track page :)
<form method="post" autocomplete="off" onsubmit="setTimeout(function(){location='/'+location.pathname.split('/').slice(3).join('/')},7500)">
@sel_audio2("audio", *prefs.RestreamAudio, disabled_formats)
<details style="margin-top: 1rem; margin-bottom: 1rem">
<summary>File metadata</summary>
<label>
Title:
@text("title", t.Title)
</label>
<label>
Author:
@text("author", t.Author.Username)
</label>
<label>
Genre:
@text("genre", t.Genre)
</label>
</details>
<input type="submit" value="Download" class="btn" style="margin-top: 1rem;"/>
</form>
<style>label{display:flex;gap:.5rem;align-items:center;margin-bottom:.35rem}</style>
}

View File

@@ -5,32 +5,6 @@ import (
"strconv"
)
templ PlaylistOrUserItem(pl *sc.PlaylistOrUser) {
<a class="listing" href={ templ.SafeURL(pl.Href()) }>
{{
img := pl.Artwork
if pl.Kind == "user" {
img = pl.Avatar
}
if img == "" {
img = "/_/static/placeholder.jpg"
}
}}
<img loading="lazy" fetchpriority="low" src={ img }/>
<div class="meta">
if pl.Kind == "user" {
<h3>{ pl.Username }</h3>
if pl.FullName != "" {
<span>{ pl.FullName }</span>
}
} else {
<h3>{ pl.Title }</h3>
<p>{ strconv.FormatInt(pl.TrackCount, 10) } tracks</p>
}
</div>
</a>
}
templ Discover(p *sc.Paginated[*sc.Selection]) {
<h1>Discover Playlists</h1> // also tracks apparently? haven't seen any
<span>Got { strconv.FormatInt(int64(len(p.Collection)), 10) } selections</span>
@@ -43,7 +17,7 @@ templ Discover(p *sc.Paginated[*sc.Selection]) {
<h2>{selection.Title}</h2>
for _, pl := range selection.Items.Collection {
@PlaylistOrUserItem(pl)
@UserPlaylistTrackItem(pl)
}
}

View File

@@ -2,7 +2,13 @@ package templates
import (
"git.maid.zone/stuff/soundcloak/lib/cfg"
"git.maid.zone/stuff/soundcloak/lib/sc"
"git.maid.zone/stuff/soundcloak/lib/textparsing"
"github.com/valyala/fasthttp"
"context"
"io"
"strconv"
"net/url"
)
templ Description(prefs cfg.Preferences, text string, injected templ.Component) {
@@ -24,3 +30,80 @@ templ Description(prefs cfg.Preferences, text string, injected templ.Component)
</details>
}
}
func Bytes(b []byte) templ.Component {
return templ.ComponentFunc(func(ctx context.Context, w io.Writer) error {
_, err := w.Write(fasthttp.AppendHTMLEscapeBytes(nil, b))
return err
})
}
templ UserPlaylistTrackItem(pl *sc.UserPlaylistTrack) {
<a class="listing" href={ templ.SafeURL(pl.Href()) }>
{{
img := pl.Artwork
if pl.Kind == "user" {
img = pl.Avatar
}
if img == "" {
img = "/_/static/placeholder.jpg"
}
}}
<img loading="lazy" fetchpriority="low" src={ img }/>
<div class="meta">
if pl.Kind == "user" {
<h3>{ pl.Username }</h3>
if pl.FullName != "" {
<p>{ pl.FullName }</p>
}
} else {
<h3>{ pl.Title }</h3>
if pl.Kind == "track" {
<span>{ pl.Author.Username }</span>
} else {
<p>{ strconv.FormatInt(pl.TracksCount(), 10) } tracks</p>
}
}
</div>
</a>
}
templ Search(p *sc.Paginated[*sc.UserPlaylistTrack], prefs cfg.Preferences, content string) {
@searchbar(prefs, content, "any")
<br>
<span>Found { strconv.FormatInt(p.Total, 10) } results</span>
<br/>
<br/>
if len(p.Collection) == 0 && p.Total != 0 {
<p>no more results</p>
} else {
for _, u := range p.Collection {
@UserPlaylistTrackItem(u)
}
if p.NextHref != "" && len(p.Collection) != int(p.Total) {
<a class="btn" href={ templ.SafeURL("?type=any&pagination=" + url.QueryEscape(p.NextHref[sc.H+len("/search?"):])) } rel="noreferrer">more results</a>
}
}
}
templ searchbar(p cfg.Preferences, content string, typ string) {
<form action="/search">
<div style="position:relative">
<div style="display:flex;gap:.5rem">
<input id="q" name="q" type="text" autocomplete="off" autofill="off" value={content} style="padding:.5rem.6rem;flex-grow:1"/>
@sel("type", []option{
{"any", "Anything", false},
{"tracks", "Tracks", false},
{"users", "Users", false},
{"playlists", "Playlists", false},
}, typ)
</div>
if *p.SearchSuggestions {
<ul id="search-suggestions" style="display:none"></ul>
<script async src="/_/static/index.js"></script>
}
</div>
<input type="submit" value="Search" class="btn" style="width:100%;margin-top:.5rem"/>
</form>
}

View File

@@ -78,7 +78,9 @@ templ Playlist(prefs cfg.Preferences, p sc.Playlist) {
</div>
}
templ SearchPlaylists(p *sc.Paginated[*sc.Playlist]) {
templ SearchPlaylists(p *sc.Paginated[*sc.Playlist], prefs cfg.Preferences, content string) {
@searchbar(prefs, content, "playlist")
<br>
<span>Found { strconv.FormatInt(p.Total, 10) } playlists</span>
<br/>
<br/>
@@ -90,8 +92,8 @@ templ SearchPlaylists(p *sc.Paginated[*sc.Playlist]) {
for _, playlist := range p.Collection {
@PlaylistItem(playlist, true)
}
if p.Next != "" && len(p.Collection) != int(p.Total) {
<a class="btn" href={ templ.SafeURL("?type=playlists&pagination=" + url.QueryEscape(p.Next[sc.H+len("/search/playlists"):])) } rel="noreferrer">more playlists</a>
if p.NextHref != "" && len(p.Collection) != int(p.Total) {
<a class="btn" href={ templ.SafeURL("?type=playlists&pagination=" + url.QueryEscape(p.NextHref[sc.H+len("/search/playlists?"):])) } rel="noreferrer">more playlists</a>
}
}
}

View File

@@ -17,7 +17,7 @@ type option struct {
templ sel(name string, options []option, selected string) {
<select name={ name } autocomplete="off">
for _, opt := range options {
<option
<option
value={ opt.value }
selected?={ opt.value==selected }
disabled?={ opt.disabled }
@@ -35,6 +35,10 @@ templ sel_audio(name string, selected string, noOpus bool) {
}, selected)
}
templ js() {
<span class="js">(requires JS)</span>
}
templ Preferences(prefs cfg.Preferences) {
<h1>Preferences</h1>
<form method="post" autocomplete="off">
@@ -49,11 +53,54 @@ templ Preferences(prefs cfg.Preferences) {
Player:
@sel("Player", []option{
{cfg.RestreamPlayer, "Restream Player", !cfg.Restream},
{cfg.HLSPlayer, "HLS Player (requires JS)", false},
{cfg.HLSPlayer, "HLS Player", false},
{cfg.ProgressivePlayer, "Progressive Player", cfg.Restream},
{cfg.NonePlayer, "None", false},
}, *prefs.Player)
</label>
<details style="margin-bottom:1rem">
<summary>Explanation of each player</summary>
Each player option uses a different way to deliver/playback the music for you.
<ul>
<li>
Restream Player
<p>
It's most suitable for listening to music, because audio must be loaded from start to finish.
Streaming protocols are handled on server-side, so works without JavaScript enabled.
You can stream all the available audio presets with it.
</p>
</li>
<li>
HLS Player
<p>
It's recommended if you listen to long audios. Since streaming protocol (HLS) is handled on your browser,
you can skip parts of an audio without needing to load them.
<span class="js">It also requires enabled JavaScript to function.</span>
ogg/opus audio preset cannot be streamed with it.
</p>
</li>
<li>
Progressive Player
<p>
Similar to Restream Player, but available in case restream is disabled. You can only stream MP3 at 128kb/s using it.
</p>
</li>
<li>
None
<p>
Don't stream audio at all :)
</p>
</li>
</ul>
</details>
switch *prefs.Player {
case cfg.ProgressivePlayer:
if cfg.ProxyStreams {
<label>
Proxy song streams:
@checkbox("ProxyStreams", *prefs.ProxyStreams)
</label>
}
case cfg.HLSPlayer:
if cfg.ProxyStreams {
<label>
@@ -93,20 +140,20 @@ templ Preferences(prefs cfg.Preferences) {
<label>
Fetch search suggestions:
@checkbox("SearchSuggestions", *prefs.SearchSuggestions)
(requires JS)
@js()
</label>
<label>
Dynamically load comments:
@checkbox("DynamicLoadComments", *prefs.DynamicLoadComments)
(requires JS)
@js()
</label>
<label>
Keep player focus:
@checkbox("KeepPlayerFocus", *prefs.KeepPlayerFocus)
(requires JS)
@js()
</label>
<h2 style="margin-bottom: .35rem">Autoplay</h2>
<i>Requires JS. You also need to allow autoplay from this domain</i>
<i><span class="js">Requires JS. </span>You also need to allow autoplay from this domain</i>
<label style="margin-top: 1rem">
Autoplay next track in playlists:
@checkbox("AutoplayNextTrack", *prefs.AutoplayNextTrack)
@@ -138,5 +185,7 @@ templ Preferences(prefs cfg.Preferences) {
<input class="btn" type="file" autocomplete="off" name="prefs"/>
<input type="submit" value="Import" class="btn"/>
</form>
<style>label{display:flex;gap:.5rem;align-items:center;margin-bottom:.35rem}</style>
// oh mah gah
<style>label{display:flex;gap:.5rem;align-items:center;margin-bottom:.35rem}li>p{margin-top:0}</style>
<script>var y=document.getElementsByClassName('js');for(x=0;x<y.length;x++)y[x].style='display:none'</script>
}

View File

@@ -31,8 +31,8 @@ templ RecentTracks(tag string, p *sc.Paginated[*sc.Track]) {
for _, track := range p.Collection {
@TrackItem(track, true, "")
}
if p.Next != "" {
<a class="btn" href={ templ.SafeURL("?pagination=" + url.QueryEscape(p.Next[sc.H+len("/recent-tracks/"):])) } rel="noreferrer">more tracks</a>
if p.NextHref != "" {
<a class="btn" href={ templ.SafeURL("?pagination=" + url.QueryEscape(p.NextHref[sc.H+len("/recent-tracks/")+len(tag)+1:])) } rel="noreferrer">more tracks</a>
}
}
}
@@ -49,8 +49,8 @@ templ PopularTracks(tag string, p *sc.Paginated[*sc.Track]) {
for _, track := range p.Collection {
@TrackItem(track, true, "")
}
if p.Next != "" {
<a class="btn" href={ templ.SafeURL("?pagination=" + url.QueryEscape(p.Next[sc.H+len("/search/tracks"):])) } rel="noreferrer">more tracks</a>
if p.NextHref != "" {
<a class="btn" href={ templ.SafeURL("?pagination=" + url.QueryEscape(p.NextHref[sc.H+len("/search/tracks?"):])) } rel="noreferrer">more tracks</a>
}
}
}
@@ -69,8 +69,8 @@ templ TaggedPlaylists(tag string, p *sc.Paginated[*sc.Playlist]) {
for _, playlist := range p.Collection {
@PlaylistItem(playlist, true)
}
if p.Next != "" && len(p.Collection) != int(p.Total) {
<a class="btn" href={ templ.SafeURL("?pagination=" + url.QueryEscape(p.Next[sc.H+len("/search/playlists"):])) } rel="noreferrer">more playlists</a>
if p.NextHref != "" && len(p.Collection) != int(p.Total) {
<a class="btn" href={ templ.SafeURL("?pagination=" + url.QueryEscape(p.NextHref[sc.H+len("/search/playlists?"):])) } rel="noreferrer">more playlists</a>
}
}
}

View File

@@ -8,19 +8,6 @@ import (
"strings"
)
func toExt(audio string) string {
switch audio {
case cfg.AudioAAC:
return "m4a"
case cfg.AudioOpus:
return "ogg"
case cfg.AudioMP3:
return "mp3"
}
return ""
}
templ TrackButtons(current string, track sc.Track) {
<div class="btns">
for _, b := range [...]btn{{"related tracks", "/recommended", false, false},{"in albums", "/albums", false, false},{"in playlists", "/sets", false, false},{"track station", "/discover/sets/"+track.Station, true, false},{"view on soundcloud", "https://soundcloud.com"+track.Href(), true, true}} {
@@ -50,7 +37,7 @@ templ TrackHeader(prefs cfg.Preferences, t sc.Track, needPlayer bool) {
<meta name="og:title" content={ t.Title }/>
<meta name="og:description" content={ t.FormatDescription() }/>
<meta name="og:image" content={ t.Artwork }/>
<link rel="icon" type="image/x-icon" href={ t.Artwork }/>
<link rel="icon" href={ t.Artwork }/>
if needPlayer && *prefs.Player == cfg.HLSPlayer {
<script src="/_/static/external/hls.light.min.js"></script>
}
@@ -106,6 +93,24 @@ templ TrackPlayer(prefs cfg.Preferences, track sc.Track, stream string, displayE
if *prefs.KeepPlayerFocus {
<script async src="/_/static/keepfocus.js"></script>
}
} else if *prefs.Player == cfg.RestreamPlayer || *prefs.Player == cfg.ProgressivePlayer {
{{ audioPref = &cfg.MP3 }}
<audio
id="track"
src={ stream }
controls
autoplay?={ autoplay }
if nextTrack != nil {
data-next={ next(&track, nextTrack, playlist, mode, "") }
volume={ volume }
}
></audio>
if nextTrack != nil {
<script async src="/_/static/restream.js"></script>
}
if *prefs.KeepPlayerFocus {
<script async src="/_/static/keepfocus.js"></script>
}
} else if stream != "" {
{{ audioPref = prefs.HLSAudio }}
<audio
@@ -132,10 +137,14 @@ templ TrackPlayer(prefs cfg.Preferences, track sc.Track, stream string, displayE
<noscript>
<br/>
JavaScript is disabled! Audio playback may not work without it enabled.
if cfg.Restream {
<br/>
<a class="link" href="/_/preferences">You can enable Restream player in the preferences. It works without JavaScript.</a>
<br/>
<a class="link" href="/_/preferences">You can enable
if cfg.Restream {
Restream
} else {
Progressive
}
player in the preferences. It works without JavaScript.</a>
</noscript>
}
if track.Policy == sc.PolicySnip {
@@ -194,7 +203,7 @@ templ Track(prefs cfg.Preferences, t sc.Track, stream string, displayErr string,
@TrackPlayer(prefs, t, stream, displayErr, autoplay, nextTrack, playlist, volume, mode, audio)
if displayErr == "" && cfg.Restream {
<div style="display: flex; margin-bottom: 1rem;">
<a class="btn" href={ templ.SafeURL("/_/restream" + t.Href() + "?metadata=true") } download={ t.Permalink + "." + toExt(*downloadAudio) }>download</a>
<a class="btn" href={ templ.SafeURL("/_/download" + t.Href()) }>download</a>
</div>
}
if t.Genre != "" {
@@ -252,8 +261,8 @@ templ Track(prefs cfg.Preferences, t sc.Track, stream string, displayErr string,
@Comments(comments)
</div>
<script async src="/_/static/comments.js"></script>
if comments.Next != "" {
<a class="btn" href={ templ.SafeURL("?pagination=" + url.QueryEscape(comments.Next[sc.H+len("/tracks/")+len(string(t.ID))+len("/comments"):])) } rel="noreferrer" onclick="event.preventDefault(); comments(this)" data-id={ string(t.ID) }>more comments</a>
if comments.NextHref != "" {
<a class="btn" href={ templ.SafeURL("?pagination=" + url.QueryEscape(comments.NextHref[sc.H+len("/tracks/")+len(string(t.ID))+len("/comments?"):])) } rel="noreferrer" onclick="event.preventDefault(); comments(this)" data-id={ string(t.ID) }>more comments</a>
}
} else {
<div id="comments"></div>
@@ -265,8 +274,8 @@ templ Track(prefs cfg.Preferences, t sc.Track, stream string, displayErr string,
<div>
@Comments(comments)
</div>
if comments.Next != "" {
<a class="btn" href={ templ.SafeURL("?pagination=" + url.QueryEscape(comments.Next[sc.H+len("/tracks/")+len(string(t.ID))+len("/comments"):])) } rel="noreferrer">more comments</a>
if comments.NextHref != "" {
<a class="btn" href={ templ.SafeURL("?pagination=" + url.QueryEscape(comments.NextHref[sc.H+len("/tracks/")+len(string(t.ID))+len("/comments?"):])) } rel="noreferrer">more comments</a>
}
} else {
<a class="btn" href="?pagination=%3Flimit%3D20%26threaded%3D1">load comments</a>
@@ -315,7 +324,9 @@ templ TrackEmbed(prefs cfg.Preferences, t sc.Track, stream string, displayErr st
</html>
}
templ SearchTracks(p *sc.Paginated[*sc.Track]) {
templ SearchTracks(p *sc.Paginated[*sc.Track], prefs cfg.Preferences, content string) {
@searchbar(prefs, content, "tracks")
<br>
<span>Found { strconv.FormatInt(p.Total, 10) } tracks</span>
<br/>
<br/>
@@ -325,8 +336,8 @@ templ SearchTracks(p *sc.Paginated[*sc.Track]) {
for _, track := range p.Collection {
@TrackItem(track, true, "")
}
if p.Next != "" && len(p.Collection) != int(p.Total) {
<a class="btn" href={ templ.SafeURL("?type=tracks&pagination=" + url.QueryEscape(p.Next[sc.H+len("/search/tracks"):])) } rel="noreferrer">more tracks</a>
if p.NextHref != "" && len(p.Collection) != int(p.Total) {
<a class="btn" href={ templ.SafeURL("?type=tracks&pagination=" + url.QueryEscape(p.NextHref[sc.H+len("/search/tracks?"):])) } rel="noreferrer">more tracks</a>
}
}
}
@@ -344,8 +355,8 @@ templ RelatedTracks(t sc.Track, p *sc.Paginated[*sc.Track]) {
for _, track := range p.Collection {
@TrackItem(track, true, "")
}
if p.Next != "" {
<a class="btn" href={ templ.SafeURL("?pagination=" + url.QueryEscape(p.Next[sc.H+len("/tracks/")+len(string(t.ID))+len("/related"):])) } rel="noreferrer">more tracks</a>
if p.NextHref != "" {
<a class="btn" href={ templ.SafeURL("?pagination=" + url.QueryEscape(p.NextHref[sc.H+len("/tracks/")+len(string(t.ID))+len("/related?"):])) } rel="noreferrer">more tracks</a>
}
}
}
@@ -363,8 +374,8 @@ templ TrackInAlbums(t sc.Track, p *sc.Paginated[*sc.Playlist]) {
for _, playlist := range p.Collection {
@PlaylistItem(playlist, true)
}
if p.Next != "" {
<a class="btn" href={ templ.SafeURL("?pagination=" + url.QueryEscape(p.Next[sc.H+len("/tracks/")+len(string(t.ID))+len("/albums"):])) } rel="noreferrer">more albums</a>
if p.NextHref != "" {
<a class="btn" href={ templ.SafeURL("?pagination=" + url.QueryEscape(p.NextHref[sc.H+len("/tracks/")+len(string(t.ID))+len("/albums?"):])) } rel="noreferrer">more albums</a>
}
}
}
@@ -382,8 +393,8 @@ templ TrackInPlaylists(t sc.Track, p *sc.Paginated[*sc.Playlist]) {
for _, playlist := range p.Collection {
@PlaylistItem(playlist, true)
}
if p.Next != "" {
<a class="btn" href={ templ.SafeURL("?pagination=" + url.QueryEscape(p.Next[sc.H+len("/tracks/")+len(string(t.ID))+len("/playlists_without_albums"):])) } rel="noreferrer">more playlists</a>
if p.NextHref != "" {
<a class="btn" href={ templ.SafeURL("?pagination=" + url.QueryEscape(p.NextHref[sc.H+len("/tracks/")+len(string(t.ID))+len("/playlists_without_albums?"):])) } rel="noreferrer">more playlists</a>
}
}
}

View File

@@ -12,7 +12,7 @@ templ UserHeader(u sc.User) {
<meta name="og:title" content={ u.FormatUsername() }/>
<meta name="og:description" content={ u.FormatDescription() }/>
<meta name="og:image" content={ u.Avatar }/>
<link rel="icon" type="image/x-icon" href={ u.Avatar }/>
<link rel="icon" href={ u.Avatar }/>
}
templ UserItem(user *sc.User) {
@@ -127,8 +127,8 @@ templ User(prefs cfg.Preferences, u sc.User, p *sc.Paginated[*sc.Track]) {
@TrackItem(track, false, "")
}
</div>
if p.Next != "" && len(p.Collection) != int(u.Tracks) {
<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>
if p.NextHref != "" && len(p.Collection) != int(u.Tracks) {
<a class="btn" href={ templ.SafeURL("?pagination=" + url.QueryEscape(p.NextHref[sc.H+len("/users/")+len(u.ID)+len("/tracks?"):])) } rel="noreferrer">more tracks</a>
}
} else {
<span>no more tracks</span>
@@ -145,8 +145,8 @@ templ UserPlaylists(prefs cfg.Preferences, u sc.User, p *sc.Paginated[*sc.Playli
@PlaylistItem(playlist, false)
}
</div>
if p.Next != "" && len(p.Collection) != int(p.Total) {
<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>
if p.NextHref != "" && len(p.Collection) != int(p.Total) {
<a class="btn" href={ templ.SafeURL("?pagination=" + url.QueryEscape(p.NextHref[sc.H+len("/users/")+len(u.ID)+len("/playlists_without_albums?"):])) } rel="noreferrer">more playlists</a>
}
} else {
<span>no more playlists</span>
@@ -163,8 +163,8 @@ templ UserAlbums(prefs cfg.Preferences, u sc.User, p *sc.Paginated[*sc.Playlist]
@PlaylistItem(playlist, false)
}
</div>
if p.Next != "" && len(p.Collection) != int(p.Total) {
<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>
if p.NextHref != "" && len(p.Collection) != int(p.Total) {
<a class="btn" href={ templ.SafeURL("?pagination=" + url.QueryEscape(p.NextHref[sc.H+len("/users/")+len(u.ID)+len("/albums?"):])) } rel="noreferrer">more albums</a>
}
} else {
<span>no more albums</span>
@@ -185,8 +185,8 @@ 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(p.Next[sc.H+len("/stream/users/")+len(u.ID)+len("/reposts"):])) } rel="noreferrer">more reposts</a>
if p.NextHref != "" && len(p.Collection) != int(p.Total) {
<a class="btn" href={ templ.SafeURL("?pagination=" + url.QueryEscape(p.NextHref[sc.H+len("/stream/users/")+len(u.ID)+len("/reposts?"):])) } rel="noreferrer">more reposts</a>
}
} else {
<span>no more reposts</span>
@@ -207,8 +207,8 @@ 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(p.Next[sc.H+len("/users/")+len(u.ID)+len("/likes"):])) } rel="noreferrer">more likes</a>
if p.NextHref != "" && len(p.Collection) != int(p.Total) {
<a class="btn" href={ templ.SafeURL("?pagination=" + url.QueryEscape(p.NextHref[sc.H+len("/users/")+len(u.ID)+len("/likes?"):])) } rel="noreferrer">more likes</a>
}
} else {
<span>no more likes</span>
@@ -255,8 +255,8 @@ templ UserFollowers(prefs cfg.Preferences, u sc.User, p *sc.Paginated[*sc.User])
@UserItem(user)
}
</div>
if p.Next != "" && len(p.Collection) != int(p.Total) {
<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>
if p.NextHref != "" && len(p.Collection) != int(p.Total) {
<a class="btn" href={ templ.SafeURL("?pagination=" + url.QueryEscape(p.NextHref[sc.H+len("/users/")+len(u.ID)+len("/followers?"):])) } rel="noreferrer">more users</a>
}
} else {
<span>no more users</span>
@@ -273,15 +273,17 @@ templ UserFollowing(prefs cfg.Preferences, u sc.User, p *sc.Paginated[*sc.User])
@UserItem(user)
}
</div>
if p.Next != "" && len(p.Collection) != int(p.Total) {
<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>
if p.NextHref != "" && len(p.Collection) != int(p.Total) {
<a class="btn" href={ templ.SafeURL("?pagination=" + url.QueryEscape(p.NextHref[sc.H+len("/users/")+len(u.ID)+len("/followings?"):])) } rel="noreferrer">more users</a>
}
} else {
<span>no more users</span>
}
}
templ SearchUsers(p *sc.Paginated[*sc.User]) {
templ SearchUsers(p *sc.Paginated[*sc.User], prefs cfg.Preferences, content string) {
@searchbar(prefs,content, "users")
<br>
<span>Found { strconv.FormatInt(p.Total, 10) } users</span>
<br/>
<br/>
@@ -293,8 +295,8 @@ templ SearchUsers(p *sc.Paginated[*sc.User]) {
for _, user := range p.Collection {
@UserItem(user)
}
if p.Next != "" && len(p.Collection) != int(p.Total) {
<a class="btn" href={ templ.SafeURL("?type=users&pagination=" + url.QueryEscape(p.Next[sc.H+len("/search/users"):])) } rel="noreferrer">more users</a>
if p.NextHref != "" && len(p.Collection) != int(p.Total) {
<a class="btn" href={ templ.SafeURL("?type=users&pagination=" + url.QueryEscape(p.NextHref[sc.H+len("/search/users?"):])) } rel="noreferrer">more users</a>
}
}
}