From 4254f1d99c9fd53d6073ea75fa321627f5c4f7e4 Mon Sep 17 00:00:00 2001 From: Laptop Date: Wed, 28 Jan 2026 22:22:29 +0200 Subject: [PATCH] it's so simple --- Dockerfile | 2 +- go.mod | 28 ++- go.sum | 58 +++--- lib/api/init.go | 16 +- lib/cfg/init.go | 38 +--- lib/cfg/misc.go | 9 +- lib/misc/init.go | 21 +-- lib/preferences/init.go | 16 +- lib/proxy_images/init.go | 18 +- lib/proxy_streams/init.go | 74 +++++--- lib/restream/init.go | 360 +++++++++++++++++++++++++++++------- lib/restream/injector.go | 60 ++++++ lib/restream/reader.go | 22 ++- lib/sc/featured.go | 62 +------ lib/sc/feeds.go | 59 ++++++ lib/sc/init.go | 358 +++++++++++++++++++++++------------ lib/sc/playlist.go | 7 +- lib/sc/track.go | 83 ++++++--- lib/sc/user.go | 68 +++---- main.go | 267 ++++++++++++++++++++++---- static/assets/index.css | 8 - templates/base.templ | 29 +-- templates/download.templ | 51 +++++ templates/featured.templ | 28 +-- templates/misc.templ | 83 +++++++++ templates/playlist.templ | 8 +- templates/preferences.templ | 63 ++++++- templates/tags.templ | 12 +- templates/track.templ | 73 ++++---- templates/user.templ | 38 ++-- 30 files changed, 1411 insertions(+), 608 deletions(-) create mode 100644 lib/restream/injector.go create mode 100644 lib/sc/feeds.go create mode 100644 templates/download.templ diff --git a/Dockerfile b/Dockerfile index 6456619..669d48e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/go.mod b/go.mod index 620235a..ebe4faf 100644 --- a/go.mod +++ b/go.mod @@ -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 ( diff --git a/go.sum b/go.sum index 46e77d2..8b4eba2 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/lib/api/init.go b/lib/api/init.go index 3a83ec7..08f9530 100644 --- a/lib/api/init.go +++ b/lib/api/init.go @@ -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 } diff --git a/lib/cfg/init.go b/lib/cfg/init.go index c176510..64afaa9 100644 --- a/lib/cfg/init.go +++ b/lib/cfg/init.go @@ -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 diff --git a/lib/cfg/misc.go b/lib/cfg/misc.go index caae805..5e7a412 100644 --- a/lib/cfg/misc.go +++ b/lib/cfg/misc.go @@ -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 diff --git a/lib/misc/init.go b/lib/misc/init.go index fd7d911..f4d0116 100644 --- a/lib/misc/init.go +++ b/lib/misc/init.go @@ -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, } } diff --git a/lib/preferences/init.go b/lib/preferences/init.go index 6985c76..8dc2ad0 100644 --- a/lib/preferences/init.go +++ b/lib/preferences/init.go @@ -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 { diff --git a/lib/proxy_images/init.go b/lib/proxy_images/init.go index 1dd6dac..4afc67d 100644 --- a/lib/proxy_images/init.go +++ b/lib/proxy_images/init.go @@ -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 } diff --git a/lib/proxy_streams/init.go b/lib/proxy_streams/init.go index f3413cb..34a6851 100644 --- a/lib/proxy_streams/init.go +++ b/lib/proxy_streams/init.go @@ -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 }) } diff --git a/lib/restream/init.go b/lib/restream/init.go index d9de321..2651eff 100644 --- a/lib/restream/init.go +++ b/lib/restream/init.go @@ -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 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) diff --git a/lib/restream/injector.go b/lib/restream/injector.go new file mode 100644 index 0000000..2a686f5 --- /dev/null +++ b/lib/restream/injector.go @@ -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 +} diff --git a/lib/restream/reader.go b/lib/restream/reader.go index 80419a6..02564ca 100644 --- a/lib/restream/reader.go +++ b/lib/restream/reader.go @@ -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 +} diff --git a/lib/sc/featured.go b/lib/sc/featured.go index acd3955..6021fd3 100644 --- a/lib/sc/featured.go +++ b/lib/sc/featured.go @@ -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 diff --git a/lib/sc/feeds.go b/lib/sc/feeds.go new file mode 100644 index 0000000..704dd4a --- /dev/null +++ b/lib/sc/feeds.go @@ -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 http://inessential.com/2002/09/01.php#a2 + XMLName xml.Name `xml:"guid"` + Id string `xml:",chardata"` + IsPermaLink string `xml:"isPermaLink,attr,omitempty"` // "true", "false", or an empty string +} diff --git a/lib/sc/init.go b/lib/sc/init.go index 42a48be..4e54604 100644 --- a/lib/sc/init.go +++ b/lib/sc/init.go @@ -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(`$`, 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(`"`)]) - misc.Log("found ver:", ver) - if ClientIDCache.Version != "" && ver == ClientIDCache.Version { - goto verCacheHit - } - } else if bytes.HasPrefix(l, script0) { - scriptUrl = l[len(``)] - 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(`"`) && string(l[:len(sc_version)]) == sc_version { + ver = cfg.B2s(l[len(sc_version) : len(l)-len(`"`)]) + 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(`;`) && string(l[:len(sc_hydration)]) == sc_hydration { + hydration = l[len(sc_hydration) : len(l)-len(`;`)] + 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(`"`)]) - if ver == ClientIDCache.Version { - goto verCacheHit - } - } else if bytes.HasPrefix(l, script) { - scriptUrls = append(scriptUrls, l[len(``)]) - } - } - - 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(`">`) && string(l[:len(script)]) == script { + scriptUrls = append(scriptUrls, l[len(``)]) + } + } + + 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 +} diff --git a/lib/sc/playlist.go b/lib/sc/playlist.go index d8c89cd..4ff716d 100644 --- a/lib/sc/playlist.go +++ b/lib/sc/playlist.go @@ -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 diff --git a/lib/sc/track.go b/lib/sc/track.go index 8c4adf9..4047789 100644 --- a/lib/sc/track.go +++ b/lib/sc/track.go @@ -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 "" +} diff --git a/lib/sc/user.go b/lib/sc/user.go index 2f28180..3b65c23 100644 --- a/lib/sc/user.go +++ b/lib/sc/user.go @@ -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/", diff --git a/main.go b/main.go index fe1c823..34f4849 100644 --- a/main.go +++ b/main.go @@ -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) { diff --git a/static/assets/index.css b/static/assets/index.css index dcd7dea..5ce1ace 100644 --- a/static/assets/index.css +++ b/static/assets/index.css @@ -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; } \ No newline at end of file diff --git a/templates/base.templ b/templates/base.templ index ae4f2ca..97cd33e 100644 --- a/templates/base.templ +++ b/templates/base.templ @@ -11,6 +11,7 @@ templ Base(title string, content templ.Component, head templ.Component) { + 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> } diff --git a/templates/download.templ b/templates/download.templ new file mode 100644 index 0000000..f45f9b1 --- /dev/null +++ b/templates/download.templ @@ -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> +} \ No newline at end of file diff --git a/templates/featured.templ b/templates/featured.templ index ce2dc29..f5d9b9a 100644 --- a/templates/featured.templ +++ b/templates/featured.templ @@ -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) } } diff --git a/templates/misc.templ b/templates/misc.templ index 961375f..756b2d2 100644 --- a/templates/misc.templ +++ b/templates/misc.templ @@ -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> +} \ No newline at end of file diff --git a/templates/playlist.templ b/templates/playlist.templ index c9687b5..2a4dc61 100644 --- a/templates/playlist.templ +++ b/templates/playlist.templ @@ -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> } } } diff --git a/templates/preferences.templ b/templates/preferences.templ index f62e642..ea348dc 100644 --- a/templates/preferences.templ +++ b/templates/preferences.templ @@ -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> } diff --git a/templates/tags.templ b/templates/tags.templ index b8ddc6a..300582a 100644 --- a/templates/tags.templ +++ b/templates/tags.templ @@ -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> } } } diff --git a/templates/track.templ b/templates/track.templ index ae1fc0c..cc0caef 100644 --- a/templates/track.templ +++ b/templates/track.templ @@ -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> } } } diff --git a/templates/user.templ b/templates/user.templ index 3c2b0e3..665e549 100644 --- a/templates/user.templ +++ b/templates/user.templ @@ -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> } } }