Compare commits

...

33 Commits

Author SHA1 Message Date
Laptop
0cf9f0d53f oops again sorry 2025-08-31 00:57:11 +03:00
Laptop
fd571bc23c oops 2025-08-31 00:54:54 +03:00
Laptop
0474290010 align structs for better memory layout 2025-08-31 00:43:25 +03:00
Laptop
e0bfcaadba support users inside selections 2025-08-31 00:33:27 +03:00
Laptop
39787b6cc9 5 2025-08-28 18:00:48 +03:00
laptopcat
92500f4a41 add api to get playlist tracks and just track 2025-07-08 18:45:29 +03:00
laptopcat
a28968af97 refine api docs, new apis added 2025-07-06 20:33:57 +03:00
Laptop
2ab73d517d add safeguard for commiturl parsing 2025-06-27 13:27:28 +03:00
Laptop
cf369e3f28 fix skill issues w/ restream 2025-06-21 12:34:45 +03:00
Laptop
5e96cee22e API and many small fixes 2025-06-20 21:52:25 +03:00
Laptop
a0551d742b improve unix socket support, always embed commit 2025-06-18 22:34:38 +03:00
Laptop
3d96470a05 small improvements) 2025-06-12 17:31:25 +03:00
Laptop
d2117e5182 some fixes in restream again 2025-04-29 22:06:37 +03:00
Laptop
0ca7a3986a so stupid 2025-04-16 22:33:08 +03:00
Laptop
a2d6d501b3 inject metadata on the fly when downloading, refactoring a bit 2025-04-16 22:28:05 +03:00
Laptop
4f6f6585cb drop "featured tracks", not available anymore 2025-04-16 21:08:20 +03:00
Laptop
14b501d162 forgot to bump it here 2025-04-06 16:25:51 +03:00
Laptop
a61c15bb7d oh shit concurrent map writes hope this fixes it 2025-04-06 16:25:21 +03:00
Laptop
4df1bc4f17 bump deps and update docs 2025-04-06 16:02:23 +03:00
Laptop
8c292de7c7 increase ReadBufferSize 2025-04-06 00:01:03 +03:00
Laptop
5d1168f7d7 more verbose 2025-03-24 21:13:15 +02:00
Laptop
3b63615014 table 2025-03-24 21:09:25 +02:00
Laptop
8fd150ff96 reduce regexp usage for GetClientID 2025-03-21 18:57:01 +02:00
Laptop
280e551230 update deps; improve caching 2025-03-20 22:38:28 +02:00
Laptop
2b84ba8748 patch duration in m4a for restream/downloads 2025-03-11 15:57:20 +02:00
Laptop
7a044a2dec KeepPlayerFocus pref, rework prefs page, always autoplay if you click on the next track 2025-02-26 22:48:26 +02:00
Laptop
3718ef7e66 option to autoplay a related track 2025-02-24 23:27:58 +02:00
Laptop
6daeac4638 less strict clientid regex 2025-02-24 17:18:11 +02:00
Laptop
810661742b bump deps && fix prev commit 2025-02-20 21:54:03 +02:00
Laptop
679731a2cb in restream, download original track cover instead of resized 2025-02-20 21:42:54 +02:00
Laptop
e2b7e9aad6 improve clientid regex && enable back experimental GetClientID 2025-02-18 16:29:10 +02:00
Laptop
12a3d850a5 Disable experimental GetClientID due to issues 2025-02-18 16:13:21 +02:00
Laptop
0ac5523d22 don't release req/resp in restream reader 2025-02-18 16:12:59 +02:00
34 changed files with 1435 additions and 902 deletions

View File

@@ -1,4 +1,4 @@
ARG GO_VERSION=1.24.0
ARG GO_VERSION=1.25.0
FROM --platform=$BUILDPLATFORM golang:${GO_VERSION} AS build
ARG TARGETOS
@@ -6,35 +6,33 @@ ARG TARGETARCH
WORKDIR /build
RUN go env -w GOPROXY=direct
RUN go install github.com/a-h/templ/cmd/templ@latest
RUN go install github.com/dlclark/regexp2cg@main
RUN go env -w GOPROXY=direct && \
mkdir /etc2 && \
mkdir /etc2/ssl && mkdir /etc2/ssl/certs && \
cp /etc/ssl/certs/ca-certificates.crt /etc2/ssl/certs/ca-certificates.crt && \
echo "soundcloak:x:5000:5000:Soundcloak user:/:/sbin/nologin" > /etc2/passwd && \
echo "soundcloak:x:5000:" > /etc2/group
COPY go.* .
RUN go mod download -x && \
go install -v github.com/a-h/templ/cmd/templ@latest && \
go install -v github.com/dlclark/regexp2cg@main
COPY . .
# usually soundcloakctl updates together with soundcloak, so we should redownload it
RUN go install git.maid.zone/stuff/soundcloakctl@master
RUN soundcloakctl js download
RUN templ generate
RUN go generate ./lib/*
RUN soundcloakctl config codegen
RUN soundcloakctl -nozstd -notable precompress
RUN CGO_ENABLED=0 GOARCH=${TARGETARCH} GOOS=${TARGETOS} go build -ldflags "-s -w -extldflags '-static'" -o ./app
RUN echo "soundcloak:x:5000:5000:Soundcloak user:/:/sbin/nologin" > /etc/minimal-passwd && \
echo "soundcloak:x:5000:" > /etc/minimal-group
RUN soundcloakctl postbuild
RUN go install -v git.maid.zone/stuff/soundcloakctl@master && \
soundcloakctl js download && \
templ generate && \
go generate ./lib/* && \
soundcloakctl config codegen && \
soundcloakctl -nozstd precompress && \
CGO_ENABLED=0 GOARCH=${TARGETARCH} GOOS=${TARGETOS} go build -v -ldflags "-s -w -extldflags '-static' -X git.maid.zone/stuff/soundcloak/lib/cfg.Commit=`git rev-parse HEAD | head -c 7` -X git.maid.zone/stuff/soundcloak/lib/cfg.Repo=`git remote get-url origin`" -o ./app && \
soundcloakctl postbuild
FROM scratch
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=build /build/static/assets /static/assets
COPY --from=build /build/static/instance /static/instance
COPY --from=build /build/static/external /static/external
COPY --from=build /build/static/ /static/
COPY --from=build /build/app /app
COPY --from=build /etc/minimal-passwd /etc/passwd
COPY --from=build /etc/minimal-group /etc/group
COPY --from=build /etc2/ /etc/
EXPOSE 4664

View File

@@ -23,5 +23,7 @@ Frontend for SoundCloud
## [Instance Maintainer Guide](docs/INSTANCE_GUIDE.md)
## [Development Guide](docs/DEV_GUIDE.md)
If you have any questions, or just wanna talk about soundcloak, you can join the maid.zone XMPP/Matrix chat: [public@muc.maid.zone](xmpp:public@muc.maid.zone?join) / [#public:maid.zone](https://matrix.to/#/#public:maid.zone)
# Notice
soundcloak is not affiliated with SoundCloud.

2
build
View File

@@ -1,3 +1,3 @@
templ generate
go generate ./lib/*
go build main.go
go build -ldflags "-X git.maid.zone/stuff/soundcloak/lib/cfg.Commit=`git rev-parse HEAD | head -c 7` -X git.maid.zone/stuff/soundcloak/lib/cfg.Repo=`git remote get-url origin`" main.go

82
docs/API.md Normal file
View File

@@ -0,0 +1,82 @@
# Check enabled features
Just go to `/_/info` endpoint
# API
To make use of it, instance must have API enabled of course. All responses are in JSON format if the request is successful. Errors are just returned as plaintext. Currently, there is few functionality present. If you are working on some cool project and wanna have more functionality here, let me know
## Paginated endpoints
Those endpoints return an object with properties:
- `collection`: List of the results on this page
- `total_results`: total results duh, maybe 0 on some endpoints
- `next_href`: Link to next page
To go to next page, take the `next_href`, strip away everything until `?`, and pass that as `pagination` query parameter. Note that not all endpoints may support going to next page.
You can also use `pagination` parameter to pass raw arguments into soundcloud api (for example, more advanced search filters, different initial result limit, etc)
## GET `/_/api/search`
Search for users, tracks or playlists. Query parameters are:
- `q`: the query
- `type`: `users`, `tracks`, or `playlists`. Required
- `pagination`: [Read above](#paginated-endpoints)
For example: `/_/api/search?q=test&type=tracks` to search for `tracks` named `test`
## GET `/_/api/track/:id`
Get track by ID.
For example: `/_/api/track/2014143543` to get track with ID `2014143543`
## GET `/_/api/track/:id/related`
Get related tracks by ID. Pagination is supported here. Initial request returns upto 20 tracks
For example: `/_/api/track/2014143543/related` to get tracks related to track with ID `2014143543`
## GET `/_/api/tracks`
Get tracks by ID in bulk. Pass the IDs comma-separated as `ids` query parameter. You can't request more than 50 tracks at once. The result is a list, which only contains the tracks which were successfully resolved
For example: `/_/api/tracks?ids=2014143543,476907846`. This will only return one track, since 2nd ID is not a track ID
## GET `/_/api/playlistByPermalink/:author/sets/:playlist`
Get playlist by permalinks.
For example: `/_/api/playlistByPermalink/lucybedroque/sets/unmusique` to get `unmusique` playlist from `lucybedroque`
## GET `/_/api/playlistByPermalink/:author/sets/:playlist/tracks`
Get list of track IDs in playlist.
For example: `/_/api/playlistByPermalink/lucybedroque/sets/unmusique/tracks` to get all IDs of the tracks in playlist `unmusique` from `lucybedroque`
# Other automation
Doesn't require API to be enabled
## GET `/_/restream/:author/:track`
Restream must be enabled in the instance. This endpoint can be used to download or stream tracks. Query parameters are:
- `metadata`: `true` or `false`. If `true`, soundcloak will inject metadata (author, track cover, track title, etc) into the audio file, but this may take a little bit more time
- `audio`: `best`, `aac`, `opus`, or `mpeg`. [Read more here](AUDIO_PRESETS.md)
Restream converts the HLS playlist to an audio file on the fly serverside, optionally adding metadata. Please note that when `audio` is `opus` and `metadata` is `true`, it's not done on the fly, as metadata injection is a bit tricky there.
For example: `/_/restream/lucybedroque/speakers?metadata=true&audio=opus` to get the `opus` audio with `metadata` for song `speakers` by author `lucybedroque`
## GET `/_/searchSuggestions`
Pass your query as `q` query parameter
For example: `/_/searchSuggestions?q=hi` to get search suggestions for `hi`
## GET `/_/proxy/images`
ProxyImages must be enabled in the instance. Put image url into `url` query parameter. Of course, this only proxies images from soundcloud cdn

View File

@@ -105,4 +105,6 @@ If you want to add a new feature that's not in [the todo list](https://git.maid.
If you have updated go dependencies or added new ones, please run `go mod tidy` before commiting.
If you update structs, please run [betteralign](https://github.com/dkorunic/betteralign) to make sure memory layout is optimized.
Any security vulnerabilities should first be disclosed privately to the maintainer ([different ways to contact me are listed here](https://laptopc.at))

View File

@@ -60,7 +60,7 @@ git pull
2. Stop the container:
```sh
docker container stop soundcloak
docker compose down
```
3. Build the container with updated source code:
@@ -115,18 +115,35 @@ Some notes:
| TrackCacheCleanDelay | TRACK_CACHE_CLEAN_DELAY | 5 minutes | Time between each cleanup of the cache (to remove expired tracks) |
| PlaylistTTL | PLAYLIST_TTL | 20 minutes | Time until Playlist data cache expires |
| PlaylistCacheCleanDelay | PLAYLIST_CACHE_CLEAN_DELAY | 5 minutes | Time between each cleanup of the cache (to remove expired playlists) |
| UserAgent | USER_AGENT | Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.3 | User-Agent header used for requests to SoundCloud |
| UserAgent | USER_AGENT | Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36 | User-Agent header used for requests to SoundCloud |
| ClientID | CLIENT_ID | (empty) | Authorization token for requests to SoundCloud. It's automatically extracted from the current version of the website, but you can override it if there are issues |
| DNSCacheTTL | DNS_CACHE_TTL | 60 minutes | Time until DNS cache expires |
| EnableAPI | ENABLE_API | false | Should [API](API.md) be enabled? |
| Network | NETWORK | tcp4 | Network to listen on. Can be tcp4, tcp6 or unix |
| Addr | ADDR | :4664 | Address and port for soundcloak to listen on |
| Addr | ADDR | :4664 | Address and port (or socket path) for soundcloak to listen on |
| UnixSocketPerms | UNIX_SOCKET_PERMS | 0775 | Permissions for unix socket (Network must be set to unix) |
| Prefork | PREFORK | false | Run multiple instances of soundcloak locally to be able to handle more requests. Each one will be a separate process, so they will have separate cache. |
| TrustedProxyCheck | TRUSTED_PROXY_CHECK | true | Use X-Forwarded-* headers if IP is in TrustedProxies list. When disabled, those headers will blindly be used. |
| TrustedProxies | TRUSTED_PROXIES | [] | List of IPs or IP ranges of trusted proxies |
| CodegenConfig | CODEGEN_CONFIG | false | Highly recommended to enable. Embeds the config into the binary, which helps reduce size if you aren't using certain features and generally optimize the binary better. Keep in mind that you will have to rebuild the image/binary each time you want to change config. (Note: you need to run `soundcloakctl config codegen` or use docker, as it runs it for you) |
| EmbedFiles | EMBED_FILES | false | Embed files into the binary. Keep in mind that you will have to rebuild the image/binary each time static files are changed (e.g. custom instance files) |
| CodegenConfig | CODEGEN_CONFIG | false | Embeds the config into the binary, which helps reduce size if you aren't using certain features and generally optimize the binary better. Keep in mind that you will have to rebuild the image/binary each time you want to change config. (Note: you need to run `soundcloakctl config codegen` or use docker, as it runs it for you) |
| EmbedFiles | EMBED_FILES | true | Embed files into the binary. Keep in mind that you will have to rebuild the image/binary each time static files are changed (e.g. custom instance files) |
</details>
## Potential issues
### Status code 403/429
Your IP address did too many requests to SoundCloud and got blocked for a bit of time.
### script/version/clientid not found
soundcloak failed to extract the ClientID token from SoundCloud.
This may happen due to your due to changes made by SoundCloud, or IP getting blocked.
In case your IP is not blocked, please report this issue to me on [Codeberg](https://codeberg.org/maid-zone/soundcloak/issues/new) or [GitHub](https://github.com/maid-zone/soundcloak/issues/new) or elsewhere
As a temporary fix, you can go to SoundCloud website in your browser, open the Developer Tools, perform some actions (like loading a profile or searching) and check the Network tab.
There you will see requests to `api-v2.soundcloud.com` with `client_id` parameter. You can use this token with the `ClientID` setting in soundcloak
## Tinkering with the frontend
<details>
@@ -134,6 +151,9 @@ Some notes:
I will mainly talk about the static files here. Maybe about the templates too in the future
Keep in mind that by default soundcloak will embed all static files into the built binary. You will need to rebuild each time you want to change something.
Or you can also set `EmbedFiles` to `false`
The static files are stored in `static/assets` folder
### Overriding files
@@ -169,7 +189,7 @@ To get listed on [the instance list](https://maid.zone/soundcloak/instances.html
Basic rules:
1. Do not collect user information (either yourself, or by including 3rd party tooling which does that)
2. If you are modifying the source code, publish those changes somewhere. Even if it's just static files, it would be best to publish those changes somewhere.
2. If you are modifying the source code, publish those changes somewhere.
Also, keep in mind that the instance list will periodically hit the `/_/info` endpoint on your instance (usually each 10 minutes) in order to display the instance settings. If you do not want this to happen, state it in your discussion/message, and I will exclude your instance from this checking.

View File

@@ -1,21 +1,16 @@
# Preferences
| Name | Key | Default | Possible values | Description |
| :--------------------------------- | --------------------- | ------------------------------------------------------------------------ | ------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Parse descriptions | ParseDescriptions | true | true, false | Turn @mentions, external links (https://example.org) and emails (hello@example.org) inside descriptions into clickable links |
| Show current audio | ShowAudio | false | true, false | Show what [audio preset](AUDIO_PRESETS.md) is being streamed below the audio player |
| Proxy images | ProxyImages | same as ProxyImages in backend config | true, false | Proxy images through the backend. ProxyImages must be enabled on the backend |
| Download audio | DownloadAudio | "mpeg" | "mpeg", "opus", "aac", "best" | What [audio preset](AUDIO_PRESETS.md) should be loaded when downloading audio with metadata. Restream must be enabled on the backend |
| Autoplay next track in playlists | AutoplayNextTrack | false | true, false | Automatically start playlist playback when you open a track from the playlist. Requires JS |
| Default autoplay mode | DefaultAutoplayMode | "normal" | "normal", "random" | Default mode for autoplay. Normal - play songs in order. Random - play random song next |
| Fetch search suggestions | SearchSuggestions | false | true, false | Load search suggestions on main page when you type. Requires JS |
| Dynamically load comments | DynamicLoadComments | false | true, false | Dynamically load track comments, without leaving the page. Requires JS |
| Player | Player | "restream" if Restream is enabled in backend config, otherwise - "hls" | "restream", "hls", "none" | Method used to play the track in the frontend. HLS - requires JavaScript, loads the track in pieces. Restream - works without JavaScript, loads entire track through the backend right away. None - don't play the track |
| Download audio | DownloadAudio | "mpeg" | "mpeg", "opus", "aac", "best" | What [audio preset](AUDIO_PRESETS.md) should be loaded when downloading audio with metadata. Restream must be enabled on the backend |
## Player-specific preferences
# Player preferences
### HLS Player
| Name | Key | Default | Possible values | Description |
| :--------------------------------- | --------------------- | ------------------------------------------------------------------------ | ------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Player | Player | "restream" if Restream is enabled in backend config, otherwise - "hls" | "restream", "hls", "none" | Method used to play the track in the frontend. HLS - requires JavaScript, loads the track in pieces. Restream - works without JavaScript, loads entire track through the backend right away. None - don't play the track |
## HLS Player
| Name | Key | Default | Possible values | Description |
| :-------------------- | ------------------- | ---------------------------------------- | ----------------- | :------------------------------------------------------------------------------------ |
@@ -23,9 +18,30 @@
| Fully preload track | FullyPreloadTrack | false | true, false | Fully load track when the page is loaded (track stream expires in ~5 minutes) |
| Streaming audio | HLSAudio | "mpeg" | "mpeg", "aac" | What [audio preset](AUDIO_PRESETS.md) should be loaded when streaming audio |
### Restream Player
## Restream Player
| Name | Key | Default | Possible values | Description |
| :---------------- | --------------- | --------- | ------------------------------- | :--------------------------------------------------------------------------- |
| Streaming audio | RestreamAudio | "mpeg" | "mpeg", "opus", "aac", "best" | What [audio preset](AUDIO_PRESETS.md) should be loaded when streaming audio |
# Frontend enhancements
| Name | Key | Default | Possible values | Description |
| :--------------------------------- | --------------------- | ------------------------------------------------------------------------ | ------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Proxy images | ProxyImages | same as ProxyImages in backend config | true, false | Proxy images through the backend. ProxyImages must be enabled on the backend |
| Parse descriptions | ParseDescriptions | true | true, false | Turn @mentions, external links (https://example.org) and emails (hello@example.org) inside descriptions into clickable links |
| Show current audio | ShowAudio | false | true, false | Show what [audio preset](AUDIO_PRESETS.md) is being streamed below the audio player |
| Fetch search suggestions | SearchSuggestions | false | true, false | Load search suggestions on main page when you type. Requires JS |
| Dynamically load comments | DynamicLoadComments | false | true, false | Dynamically load track comments, without leaving the page. Requires JS
| Keep player focus | KeepPlayerFocus | false | true, false | Always keep track element in focus, so you can control it with keyboard. Requires JS |
## Autoplay
*Requires JS. You also need to allow autoplay from this domain*
| Name | Key | Default | Possible values | Description |
| :--------------------------------- | --------------------- | ------------------------------------------------------------------------ | ------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Autoplay next track in playlists | AutoplayNextTrack | false | true, false | Automatically start playlist playback when you open a track from the playlist. |
| Default autoplay mode (in playlists) | DefaultAutoplayMode | "normal" | "normal", "random" | Default mode for playlist autoplay. Normal - play songs in order. Random - play random song next |
| Autoplay next related track | AutoplayNextRelatedTrack | false | true, false | Automatically play a related track next.

View File

@@ -22,4 +22,10 @@ Scroll down to the end of the preferences page. There you can see a management t
# Redirecting from SoundCloud to soundcloak
soundcloak tries to keep the URL schemes same to SoundCloud's, so you can just replace `soundcloud.com` with your instance URL. For short links: `https://on.soundcloud.com/boiKDP46fayYDoVK9` -> `<instance>/on/boiKDP46fayYDoVK9`
soundcloak tries to keep the URL schemes same to SoundCloud's, so you can just replace `soundcloud.com` with your instance URL. For short links: `https://on.soundcloud.com/boiKDP46fayYDoVK9` -> `<instance>/on/boiKDP46fayYDoVK9`
To automatically redirect, you can use [LibRedirect](https://libredirect.github.io/) extension. Soundcloak is supported
# Extra notes
If you find music that you like, make sure to download it! Stuff that's on there may be deleted or changed at any moment, without any warning or ability to experience it again, unless you download it for yourself. Download button is available if `Restream` is enabled in backend config. You can configure audio preset for downloading in preferences page. For easily and quickly downloading entire users or playlists, you can use my tool [scrip](https://git.maid.zone/laptop/scrip)

31
go.mod
View File

@@ -1,37 +1,34 @@
module git.maid.zone/stuff/soundcloak
go 1.24.0
go 1.25.0
require (
github.com/a-h/templ v0.3.833
github.com/a-h/templ v0.3.943
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/gofiber/fiber/v3 v3.0.0-beta.4
github.com/valyala/fasthttp v1.58.0
github.com/gofiber/fiber/v3 v3.0.0-rc.1
github.com/valyala/fasthttp v1.65.0
)
require (
github.com/abema/go-mp4 v1.4.1 // indirect
github.com/aler9/writerseeker v1.1.0 // indirect
github.com/andybalholm/brotli v1.1.1 // indirect
github.com/fxamacker/cbor/v2 v2.7.0 // indirect
github.com/gofiber/schema v1.3.0 // indirect
github.com/gofiber/utils/v2 v2.0.0-beta.7 // indirect
github.com/andybalholm/brotli v1.2.0 // indirect
github.com/gofiber/schema v1.6.0 // indirect
github.com/gofiber/utils/v2 v2.0.0-rc.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/klauspost/compress v1.17.11 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/sunfish-shogi/bufseekio v0.1.0 // indirect
github.com/tinylib/msgp v1.2.5 // indirect
github.com/tinylib/msgp v1.4.0 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/tcplisten v1.0.0 // indirect
github.com/x448/float16 v0.8.4 // indirect
golang.org/x/crypto v0.33.0 // indirect
golang.org/x/net v0.35.0 // indirect
golang.org/x/sys v0.30.0 // indirect
golang.org/x/text v0.22.0 // indirect
golang.org/x/crypto v0.41.0 // indirect
golang.org/x/net v0.43.0 // indirect
golang.org/x/sys v0.35.0 // indirect
golang.org/x/text v0.28.0 // indirect
)

64
go.sum
View File

@@ -1,11 +1,11 @@
github.com/a-h/templ v0.3.833 h1:L/KOk/0VvVTBegtE0fp2RJQiBm7/52Zxv5fqlEHiQUU=
github.com/a-h/templ v0.3.833/go.mod h1:cAu4AiZhtJfBjMY0HASlyzvkrtjnHWPeEsyGK2YYmfk=
github.com/a-h/templ v0.3.943 h1:o+mT/4yqhZ33F3ootBiHwaY4HM5EVaOJfIshvd5UNTY=
github.com/a-h/templ v0.3.943/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=
github.com/aler9/writerseeker v1.1.0/go.mod h1:QNCcjSKnLsYoTfMmXkEEfgbz6nNXWxKSaBY+hGJGWDA=
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/bogem/id3v2/v2 v2.1.4 h1:CEwe+lS2p6dd9UZRlPc1zbFNIha2mb2qzT1cCEoNWoI=
github.com/bogem/id3v2/v2 v2.1.4/go.mod h1:l+gR8MZ6rc9ryPTPkX77smS5Me/36gxkMgDayZ9G1vY=
github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY=
@@ -14,27 +14,27 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.11.5-0.20240806004527-5bbbed8ea10b h1:AJKOdc+1fRSJ0/75Jty1npvxUUD0y7hQDg15LMAHhyU=
github.com/dlclark/regexp2 v1.11.5-0.20240806004527-5bbbed8ea10b/go.mod h1:YvCrhrh/qlds8EhFKPtJprdXn5fWBllSw1qo99dZyiQ=
github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E=
github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
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/gofiber/fiber/v3 v3.0.0-beta.4 h1:KzDSavvhG7m81NIsmnu5l3ZDbVS4feCidl4xlIfu6V0=
github.com/gofiber/fiber/v3 v3.0.0-beta.4/go.mod h1:/WFUoHRkZEsGHyy2+fYcdqi109IVOFbVwxv1n1RU+kk=
github.com/gofiber/schema v1.3.0 h1:K3F3wYzAY+aivfCCEHPufCthu5/13r/lzp1nuk6mr3Q=
github.com/gofiber/schema v1.3.0/go.mod h1:YYwj01w3hVfaNjhtJzaqetymL56VW642YS3qZPhuE6c=
github.com/gofiber/utils/v2 v2.0.0-beta.7 h1:NnHFrRHvhrufPABdWajcKZejz9HnCWmT/asoxRsiEbQ=
github.com/gofiber/utils/v2 v2.0.0-beta.7/go.mod h1:J/M03s+HMdZdvhAeyh76xT72IfVqBzuz/OJkrMa7cwU=
github.com/gofiber/fiber/v3 v3.0.0-rc.1 h1:034MxesK6bqGkidP+QR+Ysc1ukOacBWOHCarCKC1xfg=
github.com/gofiber/fiber/v3 v3.0.0-rc.1/go.mod h1:hFdT00oT0XVuQH1/z2i5n1pl/msExHDUie1SsLOkCuM=
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.1 h1:b77K5Rk9+Pjdxz4HlwEBnS7u5nikhx7armQB8xPds4s=
github.com/gofiber/utils/v2 v2.0.0-rc.1/go.mod h1:Y1g08g7gvST49bbjHJ1AVqcsmg93912R/tbKWhn6V3E=
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/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw=
@@ -45,27 +45,27 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/orcaman/writerseeker v0.0.0-20200621085525-1d3f536ff85e h1:s2RNOM/IGdY0Y6qfTeUKhDawdHDpK9RGBdx80qN4Ttw=
github.com/orcaman/writerseeker v0.0.0-20200621085525-1d3f536ff85e/go.mod h1:nBdnFKj15wFbf94Rwfq4m30eAcyY9V/IyKAGQFtqkW0=
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c h1:dAMKvw0MlJT1GshSTtih8C2gDs04w8dReiOGXrGLNoY=
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
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/shamaton/msgpack/v2 v2.3.0 h1:eawIa7lQmwRv0V6rdmL/5Ev9KdJHk07eQH3ceJi3BUw=
github.com/shamaton/msgpack/v2 v2.3.0/go.mod h1:6khjYnkx73f7VQU7wjcFS9DFjs+59naVWJv1TB7qdOI=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.0 h1:ib4sjIrwZKxE5u/Japgo/7SJV3PvgjGiRNAvTVGqQl8=
github.com/stretchr/testify v1.11.0/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
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.2.5 h1:WeQg1whrXRFiZusidTQqzETkRpGjFjcIhW6uqWH09po=
github.com/tinylib/msgp v1.2.5/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0=
github.com/tinylib/msgp v1.4.0 h1:SYOeDRiydzOw9kSiwdYp9UcBgPFtLU2WDHaJXyHruf8=
github.com/tinylib/msgp v1.4.0/go.mod h1:cvjFkb4RiC8qSBOPMGPSzSAx47nAsfhLVTCZZNuHv5o=
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.58.0 h1:GGB2dWxSbEprU9j0iMJHgdKYJVDyjrOwF9RE59PbRuE=
github.com/valyala/fasthttp v1.58.0/go.mod h1:SYXvHHaFp7QZHGKSHmoMipInhrI5StHrhDTYVEjK/Kw=
github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
github.com/valyala/fasthttp v1.65.0 h1:j/u3uzFEGFfRxw79iYzJN+TteTJwbYkru9uDp3d0Yf8=
github.com/valyala/fasthttp v1.65.0/go.mod h1:P/93/YkKPMsKSnATEeELUCkG8a7Y+k99uxNHVbKINr4=
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=
@@ -73,14 +73,14 @@ 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.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
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.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
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/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -90,16 +90,16 @@ 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.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
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.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
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=

117
lib/api/init.go Normal file
View File

@@ -0,0 +1,117 @@
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"
)
func Load(a *fiber.App) {
r := a.Group("/_/api")
prefs := cfg.Preferences{ProxyImages: &cfg.False}
r.Get("/search", func(c fiber.Ctx) error {
q := cfg.B2s(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)
}
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)
return err
}
return c.JSON(p)
case "users":
p, err := sc.SearchUsers("", prefs, args)
if err != nil {
log.Printf("[API] error getting users for %s: %s\n", q, err)
return err
}
return c.JSON(p)
case "playlists":
p, err := sc.SearchPlaylists("", prefs, args)
if err != nil {
log.Printf("[API] error getting playlists for %s: %s\n", q, err)
return err
}
return c.JSON(p)
}
return c.SendStatus(404)
})
r.Get("/track/:id/related", func(c fiber.Ctx) error {
args := cfg.B2s(c.RequestCtx().QueryArgs().Peek("pagination"))
if args == "" {
args = "?limit=20"
}
p, err := (sc.Track{ID: json.Number(c.Params("id"))}).GetRelated("", prefs, args)
if err != nil {
log.Printf("[API] error getting related tracks for %s: %s\n", c.Params("id"), err)
return err
}
return c.JSON(p)
})
r.Get("/playlistByPermalink/:author/sets/:playlist", func(c fiber.Ctx) error {
p, err := sc.GetPlaylist("", c.Params("author")+"/sets/"+c.Params("playlist"))
if err != nil {
log.Printf("[API] error getting %s playlist from %s: %s\n", c.Params("playlist"), c.Params("author"), err)
return err
}
return c.JSON(p)
})
r.Get("/playlistByPermalink/:author/sets/:playlist/tracks", func(c fiber.Ctx) error {
p, err := sc.GetPlaylist("", c.Params("author")+"/sets/"+c.Params("playlist"))
if err != nil {
log.Printf("[API] error getting %s playlist tracks from %s: %s\n", c.Params("playlist"), c.Params("author"), err)
return err
}
tracks := make([]json.Number, len(p.Tracks))
for i, t := range p.Tracks {
tracks[i] = t.ID
}
return c.JSON(tracks)
})
r.Get("/track/:id", func(c fiber.Ctx) error {
t, err := sc.GetTrackByID("", c.Params("id"))
if err != nil {
log.Printf("[API] error getting track %s: %s\n", c.Params("id"), err)
return err
}
return c.JSON(t)
})
r.Get("/tracks", func(c fiber.Ctx) error {
ids := cfg.B2s(c.RequestCtx().QueryArgs().Peek("ids"))
t, err := sc.GetTracks("", ids)
if err != nil {
log.Printf("[API] error getting %s tracks: %s\n", ids, err)
return err
}
return c.JSON(t)
})
}

View File

@@ -1,7 +1,6 @@
package cfg
import (
"fmt"
"log"
"os"
"strconv"
@@ -65,11 +64,17 @@ 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/127.0.0.0 Safari/537.3"
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"
// override the extractor
var ClientID = ""
// time-to-live for dns cache
var DNSCacheTTL = 60 * time.Minute
// enab;e api
var EnableAPI = false
// // // some webserver configuration, put here to make it easier to configure what you need // // //
// more info can be found here: https://docs.gofiber.io/api/fiber#config
@@ -80,6 +85,9 @@ var Network = "tcp4"
// run soundcloak on this address (localhost:4664 by default)
var Addr = ":4664"
// unix socket perms wow
var UnixSocketPerms os.FileMode = 0775
// run multiple instances of soundcloak locally to be able to handle more requests
// each one will be a separate process, so they will have separate cache
var Prefork = false
@@ -95,7 +103,7 @@ var TrustedProxies = []string{}
var CodegenConfig = false
// use static files embedded in binary
var EmbedFiles = false
var EmbedFiles = true
// // end of config // //
@@ -130,6 +138,7 @@ func defaultPreferences() {
DefaultPreferences.ParseDescriptions = &True
DefaultPreferences.AutoplayNextTrack = &False
DefaultPreferences.AutoplayNextRelatedTrack = &False
p2 := AutoplayNormal
DefaultPreferences.DefaultAutoplayMode = &p2
@@ -143,6 +152,7 @@ func defaultPreferences() {
DefaultPreferences.SearchSuggestions = &False
DefaultPreferences.DynamicLoadComments = &False
DefaultPreferences.KeepPlayerFocus = &False
}
func loadDefaultPreferences(loaded Preferences) {
@@ -188,6 +198,12 @@ func loadDefaultPreferences(loaded Preferences) {
DefaultPreferences.AutoplayNextTrack = &False
}
if loaded.AutoplayNextRelatedTrack != nil {
DefaultPreferences.AutoplayNextRelatedTrack = loaded.AutoplayNextRelatedTrack
} else {
DefaultPreferences.AutoplayNextRelatedTrack = &False
}
if loaded.DefaultAutoplayMode != nil {
DefaultPreferences.DefaultAutoplayMode = loaded.DefaultAutoplayMode
} else {
@@ -231,21 +247,18 @@ func loadDefaultPreferences(loaded Preferences) {
} else {
DefaultPreferences.DynamicLoadComments = &False
}
if loaded.KeepPlayerFocus != nil {
DefaultPreferences.KeepPlayerFocus = loaded.KeepPlayerFocus
} else {
DefaultPreferences.KeepPlayerFocus = &False
}
}
func boolean(in string) bool {
return strings.Trim(strings.ToLower(in), " ") == "true"
}
type wrappedError struct {
err error
fault string
}
func (w wrappedError) Error() string {
return fmt.Sprintf("error loading %s: %s", w.fault, w.err)
}
func fromEnv() error {
env := os.Getenv("GET_WEB_PROFILES")
if env != "" {
@@ -257,7 +270,7 @@ func fromEnv() error {
var p Preferences
err := json.Unmarshal(S2b(env), &p)
if err != nil {
return wrappedError{err, "DEFAULT_PREFERENCES"}
return err
}
loadDefaultPreferences(p)
@@ -299,7 +312,7 @@ func fromEnv() error {
if env != "" {
num, err := strconv.ParseInt(env, 10, 64)
if err != nil {
return wrappedError{err, "CLIENT_ID_TTL"}
return err
}
ClientIDTTL = time.Duration(num) * time.Second
@@ -309,7 +322,7 @@ func fromEnv() error {
if env != "" {
num, err := strconv.ParseInt(env, 10, 64)
if err != nil {
return wrappedError{err, "USER_TTL"}
return err
}
UserTTL = time.Duration(num) * time.Second
@@ -319,7 +332,7 @@ func fromEnv() error {
if env != "" {
num, err := strconv.ParseInt(env, 10, 64)
if err != nil {
return wrappedError{err, "USER_CACHE_CLEAN_DELAY"}
return err
}
UserCacheCleanDelay = time.Duration(num) * time.Second
@@ -329,7 +342,7 @@ func fromEnv() error {
if env != "" {
num, err := strconv.ParseInt(env, 10, 64)
if err != nil {
return wrappedError{err, "TRACK_TTL"}
return err
}
TrackTTL = time.Duration(num) * time.Second
@@ -339,7 +352,7 @@ func fromEnv() error {
if env != "" {
num, err := strconv.ParseInt(env, 10, 64)
if err != nil {
return wrappedError{err, "TRACK_CACHE_CLEAN_DELAY"}
return err
}
TrackCacheCleanDelay = time.Duration(num) * time.Second
@@ -349,7 +362,7 @@ func fromEnv() error {
if env != "" {
num, err := strconv.ParseInt(env, 10, 64)
if err != nil {
return wrappedError{err, "PLAYLIST_TTL"}
return err
}
PlaylistTTL = time.Duration(num) * time.Second
@@ -359,7 +372,7 @@ func fromEnv() error {
if env != "" {
num, err := strconv.ParseInt(env, 10, 64)
if err != nil {
return wrappedError{err, "PLAYLIST_CACHE_CLEAN_DELAY"}
return err
}
PlaylistCacheCleanDelay = time.Duration(num) * time.Second
@@ -370,16 +383,26 @@ func fromEnv() error {
UserAgent = env
}
env = os.Getenv("CLIENT_ID")
if env != "" {
ClientID = env
}
env = os.Getenv("DNS_CACHE_TTL")
if env != "" {
num, err := strconv.ParseInt(env, 10, 64)
if err != nil {
return wrappedError{err, "DNS_CACHE_TTL"}
return err
}
DNSCacheTTL = time.Duration(num) * time.Second
}
env = os.Getenv("ENABLE_API")
if env != "" {
EnableAPI = boolean(env)
}
env = os.Getenv("NETWORK")
if env != "" {
Network = env
@@ -390,6 +413,16 @@ func fromEnv() error {
Addr = env
}
env = os.Getenv("UNIX_SOCKET_PERMS")
if env != "" {
p, err := strconv.ParseUint(env, 0, 32)
if err != nil {
return err
}
UnixSocketPerms = os.FileMode(p)
}
env = os.Getenv("PREFORK")
if env != "" {
Prefork = boolean(env)
@@ -405,7 +438,7 @@ func fromEnv() error {
var p []string
err := json.Unmarshal(S2b(env), &p)
if err != nil {
return wrappedError{err, "TRUSTED_PROXIES"}
return err
}
TrustedProxies = p
@@ -429,12 +462,8 @@ func init() {
if env := os.Getenv("SOUNDCLOAK_CONFIG"); env == "FROM_ENV" {
err := fromEnv()
if err != nil {
// So we only set default preferences if it fails to load that in
if err.(wrappedError).fault == "DEFAULT_PREFERENCES" {
defaultPreferences()
}
log.Println("failed to load config from environment:", err)
log.Println("Warning: failed to load config from environment:", err)
defaultPreferences()
}
return
@@ -444,7 +473,7 @@ func init() {
data, err := os.ReadFile(filename)
if err != nil {
log.Printf("failed to load config from %s: %s\n", filename, err)
log.Printf("Warning: failed to load config from %s: %s\n", filename, err)
defaultPreferences()
return
}
@@ -467,8 +496,10 @@ func init() {
PlaylistCacheCleanDelay *time.Duration
UserAgent *string
DNSCacheTTL *time.Duration
EnableAPI *bool
Network *string
Addr *string
UnixSocketPerms *string
Prefork *bool
TrustedProxyCheck *bool
TrustedProxies *[]string
@@ -533,12 +564,23 @@ func init() {
if config.DNSCacheTTL != nil {
DNSCacheTTL = *config.DNSCacheTTL * time.Second
}
if config.EnableAPI != nil {
EnableAPI = *config.EnableAPI
}
if config.Network != nil {
Network = *config.Network
}
if config.Addr != nil {
Addr = *config.Addr
}
if config.UnixSocketPerms != nil {
p, err := strconv.ParseUint(*config.UnixSocketPerms, 0, 32)
if err != nil {
log.Println("failed to parse UnixSocketPerms:", err)
} else {
UnixSocketPerms = os.FileMode(p)
}
}
if config.Prefork != nil {
Prefork = *config.Prefork
}
@@ -563,6 +605,3 @@ func init() {
}
const Debug = false
const Commit = "unknown"
const Repo = "unknown"
const CommitURL = "unknown"

View File

@@ -1,6 +1,8 @@
package cfg
import (
"log"
"strings"
"time"
"unsafe"
)
@@ -17,6 +19,13 @@ const MaxIdleConnDuration = 4 * time.Hour
var True = true
var False = false
// embedded at buildtime
var Commit = "unknown"
var Repo = "unknown"
// generated at runtime
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
RestreamPlayer string = "restream"
@@ -64,6 +73,9 @@ type Preferences struct {
// Automatically play next track in playlists
AutoplayNextTrack *bool
// Automatically play next related track
AutoplayNextRelatedTrack *bool
DefaultAutoplayMode *string // "normal" or "random"
// Check above for more info
@@ -77,6 +89,8 @@ type Preferences struct {
SearchSuggestions *bool // load search suggestions on main page
DynamicLoadComments *bool // dynamic comments loader without leaving track page
KeepPlayerFocus *bool // keep player element in focus
}
func B2s(b []byte) string {
@@ -86,3 +100,26 @@ func B2s(b []byte) string {
func S2b(s string) []byte {
return unsafe.Slice(unsafe.StringData(s), len(s))
}
func init() {
defer func() {
rec := recover()
if rec != nil {
log.Printf("WARNING: failed to parse repo and commit, please report this as an issue!!! repo: %s commit: %s\n", Repo, Commit)
}
}()
if Repo != "unknown" {
if !strings.HasPrefix(Repo, "http") {
s := strings.Split(Repo, "@")
s = strings.Split(s[1], ":")
CommitURL = "https://" + s[0] + "/" + s[1]
} else {
CommitURL = strings.TrimSuffix(Repo, "/")
}
CommitURL = strings.TrimSuffix(CommitURL, ".git")
CommitURL += "/commit/" + Commit
}
}

View File

@@ -1,8 +1,8 @@
package misc
import (
"fmt"
"io"
"log"
"sync"
"git.maid.zone/stuff/soundcloak/lib/cfg"
@@ -41,7 +41,7 @@ func (pr *ProxyReader) Close() error {
func Log(what ...any) {
if cfg.Debug {
fmt.Println(what...)
log.Println(what...)
}
}

View File

@@ -11,6 +11,8 @@ import (
"github.com/gofiber/fiber/v3"
)
const on = "on"
func Defaults(dst *cfg.Preferences) {
if dst.Player == nil {
dst.Player = cfg.DefaultPreferences.Player
@@ -36,6 +38,10 @@ func Defaults(dst *cfg.Preferences) {
dst.AutoplayNextTrack = cfg.DefaultPreferences.AutoplayNextTrack
}
if dst.AutoplayNextRelatedTrack == nil {
dst.AutoplayNextRelatedTrack = cfg.DefaultPreferences.AutoplayNextRelatedTrack
}
if dst.DefaultAutoplayMode == nil {
dst.DefaultAutoplayMode = cfg.DefaultPreferences.DefaultAutoplayMode
}
@@ -63,6 +69,10 @@ func Defaults(dst *cfg.Preferences) {
if dst.DynamicLoadComments == nil {
dst.DynamicLoadComments = cfg.DefaultPreferences.DynamicLoadComments
}
if dst.KeepPlayerFocus == nil {
dst.KeepPlayerFocus = cfg.DefaultPreferences.KeepPlayerFocus
}
}
func Get(c fiber.Ctx) (cfg.Preferences, error) {
@@ -79,19 +89,21 @@ func Get(c fiber.Ctx) (cfg.Preferences, error) {
}
type PrefsForm struct {
ProxyImages string
ParseDescriptions string
Player string
ProxyStreams string
FullyPreloadTrack string
AutoplayNextTrack string
DefaultAutoplayMode string
HLSAudio string
RestreamAudio string
DownloadAudio string
ShowAudio string
SearchSuggestions string
DynamicLoadComments string
ProxyImages string
ParseDescriptions string
Player string
ProxyStreams string
FullyPreloadTrack string
AutoplayNextTrack string
AutoplayNextRelatedTrack string
DefaultAutoplayMode string
HLSAudio string
RestreamAudio string
DownloadAudio string
ShowAudio string
SearchSuggestions string
DynamicLoadComments string
KeepPlayerFocus string
}
type Export struct {
@@ -105,7 +117,7 @@ func Load(r *fiber.App) {
return err
}
c.Set("Content-Type", "text/html")
c.Response().Header.SetContentType("text/html")
return templates.Base("preferences", templates.Preferences(p), nil).Render(context.Background(), c)
})
@@ -125,13 +137,19 @@ func Load(r *fiber.App) {
old.DefaultAutoplayMode = &p.DefaultAutoplayMode
}
if p.AutoplayNextTrack == "on" {
if p.AutoplayNextTrack == on {
old.AutoplayNextTrack = &cfg.True
} else {
old.AutoplayNextTrack = &cfg.False
}
if p.ShowAudio == "on" {
if p.AutoplayNextRelatedTrack == on {
old.AutoplayNextRelatedTrack = &cfg.True
} else {
old.AutoplayNextRelatedTrack = &cfg.False
}
if p.ShowAudio == on {
old.ShowAudio = &cfg.True
} else {
old.ShowAudio = &cfg.False
@@ -139,14 +157,14 @@ func Load(r *fiber.App) {
if *old.Player == cfg.HLSPlayer {
if cfg.ProxyStreams {
if p.ProxyStreams == "on" {
if p.ProxyStreams == on {
old.ProxyStreams = &cfg.True
} else if p.ProxyStreams == "" {
old.ProxyStreams = &cfg.False
}
}
if p.FullyPreloadTrack == "on" {
if p.FullyPreloadTrack == on {
old.FullyPreloadTrack = &cfg.True
} else if p.FullyPreloadTrack == "" {
old.FullyPreloadTrack = &cfg.False
@@ -164,31 +182,37 @@ func Load(r *fiber.App) {
}
if cfg.ProxyImages {
if p.ProxyImages == "on" {
if p.ProxyImages == on {
old.ProxyImages = &cfg.True
} else if p.ProxyImages == "" {
old.ProxyImages = &cfg.False
}
}
if p.ParseDescriptions == "on" {
if p.ParseDescriptions == on {
old.ParseDescriptions = &cfg.True
} else {
old.ParseDescriptions = &cfg.False
}
if p.SearchSuggestions == "on" {
if p.SearchSuggestions == on {
old.SearchSuggestions = &cfg.True
} else {
old.SearchSuggestions = &cfg.False
}
if p.DynamicLoadComments == "on" {
if p.DynamicLoadComments == on {
old.DynamicLoadComments = &cfg.True
} else {
old.DynamicLoadComments = &cfg.False
}
if p.KeepPlayerFocus == on {
old.KeepPlayerFocus = &cfg.True
} else {
old.KeepPlayerFocus = &cfg.False
}
old.Player = &p.Player
data, err := json.Marshal(old)

View File

@@ -11,6 +11,7 @@ import (
)
var al_httpc *fasthttp.HostClient
var sndcdn = []byte(".sndcdn.com")
func Load(r *fiber.App) {
@@ -36,7 +37,7 @@ func Load(r *fiber.App) {
return err
}
if !bytes.HasSuffix(parsed.Host(), []byte(".sndcdn.com")) {
if !bytes.HasSuffix(parsed.Host(), sndcdn) {
return fiber.ErrBadRequest
}
@@ -53,7 +54,6 @@ func Load(r *fiber.App) {
req.SetURI(parsed)
req.Header.SetUserAgent(cfg.UserAgent)
//req.Header.Set("Accept-Encoding", "gzip, deflate, br, zstd") images not big enough to be compressed
resp := fasthttp.AcquireResponse()
//defer fasthttp.ReleaseResponse(resp) moved to proxyreader!!!
@@ -63,12 +63,12 @@ func Load(r *fiber.App) {
return err
}
c.Set("Content-Type", "image/jpeg")
c.Response().Header.SetContentTypeBytes(resp.Header.ContentType())
c.Set("Cache-Control", cfg.ImageCacheControl)
//return c.Send(resp.Body())
pr := misc.AcquireProxyReader()
pr.Reader = resp.BodyStream()
pr.Resp = resp
return c.SendStream(pr)
return c.SendStream(pr, resp.Header.ContentLength())
})
}

View File

@@ -11,6 +11,11 @@ import (
"github.com/valyala/fasthttp"
)
var sndcdn = []byte(".sndcdn.com")
var soundcloudcloud = []byte(".soundcloud.cloud")
var newline = []byte{'\n'}
var extxmap = []byte(`#EXT-X-MAP:URI="`)
func Load(a *fiber.App) {
r := a.Group("/_/proxy/streams")
@@ -28,7 +33,7 @@ func Load(a *fiber.App) {
return err
}
if !bytes.HasSuffix(parsed.Host(), []byte(".sndcdn.com")) {
if !bytes.HasSuffix(parsed.Host(), sndcdn) {
return fiber.ErrBadRequest
}
@@ -37,7 +42,6 @@ func Load(a *fiber.App) {
req.SetURI(parsed)
req.Header.SetUserAgent(cfg.UserAgent)
req.Header.Set("Accept-Encoding", "gzip, deflate, br, zstd")
resp := fasthttp.AcquireResponse()
//defer fasthttp.ReleaseResponse(resp)
@@ -67,7 +71,7 @@ func Load(a *fiber.App) {
return err
}
if !bytes.HasSuffix(parsed.Host(), []byte(".soundcloud.cloud")) {
if !bytes.HasSuffix(parsed.Host(), soundcloudcloud) {
return fiber.ErrBadRequest
}
@@ -76,7 +80,6 @@ func Load(a *fiber.App) {
req.SetURI(parsed)
req.Header.SetUserAgent(cfg.UserAgent)
req.Header.Set("Accept-Encoding", "gzip, deflate, br, zstd")
resp := fasthttp.AcquireResponse()
@@ -105,7 +108,7 @@ func Load(a *fiber.App) {
return err
}
if !bytes.HasSuffix(parsed.Host(), []byte(".sndcdn.com")) {
if !bytes.HasSuffix(parsed.Host(), sndcdn) {
return fiber.ErrBadRequest
}
@@ -114,7 +117,6 @@ func Load(a *fiber.App) {
req.SetURI(parsed)
req.Header.SetUserAgent(cfg.UserAgent)
req.Header.Set("Accept-Encoding", "gzip, deflate, br, zstd")
resp := fasthttp.AcquireResponse()
defer fasthttp.ReleaseResponse(resp)
@@ -129,7 +131,7 @@ func Load(a *fiber.App) {
data = resp.Body()
}
var sp = bytes.Split(data, []byte{'\n'})
var sp = bytes.Split(data, newline)
for i, l := range sp {
if len(l) == 0 || l[0] == '#' {
continue
@@ -139,7 +141,7 @@ func Load(a *fiber.App) {
sp[i] = l
}
return c.Send(bytes.Join(sp, []byte("\n")))
return c.Send(bytes.Join(sp, newline))
})
r.Get("/playlist/aac", func(c fiber.Ctx) error {
@@ -156,7 +158,7 @@ func Load(a *fiber.App) {
return err
}
if !bytes.HasSuffix(parsed.Host(), []byte(".soundcloud.cloud")) {
if !bytes.HasSuffix(parsed.Host(), soundcloudcloud) {
return fiber.ErrBadRequest
}
@@ -165,7 +167,6 @@ func Load(a *fiber.App) {
req.SetURI(parsed)
req.Header.SetUserAgent(cfg.UserAgent)
req.Header.Set("Accept-Encoding", "gzip, deflate, br, zstd")
resp := fasthttp.AcquireResponse()
defer fasthttp.ReleaseResponse(resp)
@@ -175,19 +176,14 @@ func Load(a *fiber.App) {
return err
}
data, err := resp.BodyUncompressed()
if err != nil {
data = resp.Body()
}
var sp = bytes.Split(data, []byte("\n"))
var sp = bytes.Split(resp.Body(), newline)
for i, l := range sp {
if len(l) == 0 {
continue
}
if l[0] == '#' {
if bytes.HasPrefix(l, []byte(`#EXT-X-MAP:URI="`)) {
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
}
@@ -199,6 +195,6 @@ func Load(a *fiber.App) {
sp[i] = l
}
return c.Send(bytes.Join(sp, []byte("\n")))
return c.Send(bytes.Join(sp, newline))
})
}

View File

@@ -2,9 +2,11 @@ package restream
import (
"bytes"
"image/jpeg"
"io"
"sync"
"image"
"strings"
_ "image/jpeg"
_ "image/png"
"git.maid.zone/stuff/soundcloak/lib/cfg"
"git.maid.zone/stuff/soundcloak/lib/misc"
@@ -17,171 +19,6 @@ import (
"github.com/valyala/fasthttp"
)
const defaultPartsCapacity = 24
type reader struct {
parts [][]byte
leftover []byte
index int
req *fasthttp.Request
resp *fasthttp.Response
client *fasthttp.HostClient
}
var readerpool = sync.Pool{
New: func() any {
return &reader{}
},
}
func acquireReader() *reader {
return readerpool.Get().(*reader)
}
func clone(buf []byte) []byte {
out := make([]byte, len(buf))
copy(out, buf)
return out
}
func (r *reader) Setup(url string, aac bool) error {
r.req = fasthttp.AcquireRequest()
r.resp = fasthttp.AcquireResponse()
r.req.SetRequestURI(url)
r.req.Header.SetUserAgent(cfg.UserAgent)
r.req.Header.Set("Accept-Encoding", "gzip, deflate, br, zstd")
if aac {
r.client = misc.HlsAacClient
} else {
r.client = misc.HlsClient
}
err := sc.DoWithRetry(r.client, r.req, r.resp)
if err != nil {
return err
}
data, err := r.resp.BodyUncompressed()
if err != nil {
data = r.resp.Body()
}
if r.parts == nil {
misc.Log("make() r.parts")
r.parts = make([][]byte, 0, defaultPartsCapacity)
} else {
misc.Log(cap(r.parts), len(r.parts))
}
if aac {
// clone needed to mitigate memory skill issues here
for _, s := range bytes.Split(data, []byte{'\n'}) {
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]))
}
continue
}
r.parts = append(r.parts, clone(s))
}
} else {
for _, s := range bytes.Split(data, []byte{'\n'}) {
if len(s) == 0 || s[0] == '#' {
continue
}
r.parts = append(r.parts, s)
}
}
return nil
}
func (r *reader) Close() error {
misc.Log("closed :D")
if r.req != nil {
fasthttp.ReleaseRequest(r.req)
r.req = nil
}
if r.resp != nil {
fasthttp.ReleaseResponse(r.resp)
r.resp = nil
}
r.client = nil
r.leftover = nil
r.index = 0
r.parts = r.parts[:0]
readerpool.Put(r)
return nil
}
// you could prob make this a bit faster by concurrency (make a bunch of workers => make them download the parts => temporarily add them to a map => fully assemble the result => make reader.Read() read out the result as the parts are coming in) but whatever, fine for now
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)
}
n = copy(buf, r.leftover[:h])
if n > len(r.leftover) {
r.leftover = r.leftover[:0]
} else {
r.leftover = r.leftover[n:]
}
if n < len(buf) && r.index == len(r.parts) {
err = io.EOF
}
return
}
if r.index == len(r.parts) {
err = io.EOF
return
}
r.req.SetRequestURIBytes(r.parts[r.index])
err = sc.DoWithRetry(r.client, r.req, r.resp)
if err != nil {
return
}
data, err := r.resp.BodyUncompressed()
if err != nil {
data = r.resp.Body()
}
if len(data) > len(buf) {
n = copy(buf, data[:len(buf)])
} else {
n = copy(buf, data)
}
r.leftover = data[n:]
r.index++
if n < len(buf) && r.index == len(r.parts) {
err = io.EOF
}
return
}
type collector struct {
data []byte
}
@@ -211,14 +48,19 @@ func Load(r *fiber.App) {
}
var isDownload = string(c.RequestCtx().QueryArgs().Peek("metadata")) == "true"
var quality *string
if isDownload {
quality = p.DownloadAudio
var forcedQuality = c.RequestCtx().QueryArgs().Peek("audio")
var quality string
if len(forcedQuality) != 0 {
quality = cfg.B2s(forcedQuality)
} else {
quality = p.RestreamAudio
if isDownload {
quality = *p.DownloadAudio
} else {
quality = *p.RestreamAudio
}
}
tr, audio := t.Media.SelectCompatible(*quality, true)
tr, audio := t.Media.SelectCompatible(quality, true)
if tr == nil {
return fiber.ErrExpectationFailed
}
@@ -228,14 +70,19 @@ func Load(r *fiber.App) {
return err
}
c.Set("Content-Type", tr.Format.MimeType)
c.Response().Header.SetContentType(tr.Format.MimeType)
c.Set("Cache-Control", cfg.RestreamCacheControl)
if isDownload {
if t.Artwork != "" {
t.Artwork = strings.Replace(t.Artwork, "t500x500", "original", 1)
}
switch audio {
case cfg.AudioMP3:
r := acquireReader()
if err := r.Setup(u, false); err != nil {
err := r.Setup(u, false, nil)
if err != nil {
return err
}
@@ -249,156 +96,55 @@ func Load(r *fiber.App) {
tag.SetTitle(t.Title)
if t.Artwork != "" {
data, mime, err := t.DownloadImage()
if err != nil {
return err
}
r.req.SetRequestURI(t.Artwork)
tag.AddAttachedPicture(id3v2.PictureFrame{MimeType: mime, Picture: data, PictureType: id3v2.PTFrontCover, Encoding: id3v2.EncodingUTF8})
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})
}
}
var col collector
tag.WriteTo(&col)
r.leftover = col.data
// id3 is quite flexible and the files streamed by soundcloud don't have it so its easy to restream the stuff like this
return c.SendStream(r)
case cfg.AudioOpus:
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)
req.Header.Set("Accept-Encoding", "gzip, deflate, br, zstd")
resp := fasthttp.AcquireResponse()
defer fasthttp.ReleaseResponse(resp)
err = sc.DoWithRetry(misc.HlsClient, req, resp)
err := sc.DoWithRetry(misc.HlsClient, req, resp)
if err != nil {
return err
}
data, err := resp.BodyUncompressed()
if err != nil {
data = resp.Body()
}
parts := make([][]byte, 0, defaultPartsCapacity)
for _, s := range bytes.Split(data, []byte{'\n'}) {
for _, s := range bytes.Split(resp.Body(), newline) {
if len(s) == 0 || s[0] == '#' {
continue
}
parts = append(parts, s)
}
result := []byte{}
for _, part := range parts {
req.SetRequestURIBytes(part)
err = sc.DoWithRetry(misc.HlsClient, req, resp)
if err != nil {
return err
}
data, err = resp.BodyUncompressed()
if err != nil {
data = resp.Body()
}
result = append(result, data...)
}
tag, err := oggmeta.ReadOGG(bytes.NewReader(result))
if err != nil {
return err
}
tag.SetArtist(t.Author.Username)
if t.Genre != "" {
tag.SetGenre(t.Genre)
}
tag.SetTitle(t.Title)
if t.Artwork != "" {
req.SetRequestURI(t.Artwork)
req.Header.Del("Accept-Encoding")
err := sc.DoWithRetry(misc.ImageClient, req, resp)
if err != nil {
return err
}
defer resp.CloseBodyStream()
parsed, err := jpeg.Decode(resp.BodyStream())
if err != nil {
return err
}
tag.SetCoverArt(&parsed)
}
return tag.Save(c)
case cfg.AudioAAC:
req := fasthttp.AcquireRequest()
defer fasthttp.ReleaseRequest(req)
req.SetRequestURI(u)
req.Header.SetUserAgent(cfg.UserAgent)
req.Header.Set("Accept-Encoding", "gzip, deflate, br, zstd")
resp := fasthttp.AcquireResponse()
defer fasthttp.ReleaseResponse(resp)
err = sc.DoWithRetry(misc.HlsAacClient, req, resp)
if err != nil {
return err
}
data, err := resp.BodyUncompressed()
if err != nil {
data = resp.Body()
}
parts := make([][]byte, 0, defaultPartsCapacity)
// clone needed to mitigate memory skill issues here
for _, s := range bytes.Split(data, []byte{'\n'}) {
if len(s) == 0 {
continue
}
if s[0] == '#' {
if bytes.HasPrefix(s, []byte(`#EXT-X-MAP:URI="`)) {
parts = append(parts, clone(s[16:len(s)-1]))
}
continue
}
parts = append(parts, clone(s))
}
result := []byte{}
for _, part := range parts {
req.SetRequestURIBytes(part)
err = sc.DoWithRetry(misc.HlsAacClient, req, resp)
res := make([]byte, 0, 1024*1024*1)
for _, s := range parts {
req.SetRequestURIBytes(s)
err := sc.DoWithRetry(misc.HlsClient, req, resp)
if err != nil {
return err
}
data, err = resp.BodyUncompressed()
if err != nil {
data = resp.Body()
}
result = append(result, data...)
res = append(res, resp.Body()...)
}
tag, err := mp4meta.ReadMP4(bytes.NewReader(result))
tag, err := oggmeta.ReadOGG(bytes.NewReader(res))
if err != nil {
return err
}
@@ -412,28 +158,73 @@ func Load(r *fiber.App) {
if t.Artwork != "" {
req.SetRequestURI(t.Artwork)
req.Header.Del("Accept-Encoding")
err := sc.DoWithRetry(misc.ImageClient, req, resp)
if err != nil {
return err
if err == nil && resp.StatusCode() == 200 {
parsed, _, err := image.Decode(resp.BodyStream())
if err == nil {
tag.SetCoverArt(&parsed)
}
}
defer resp.CloseBodyStream()
parsed, err := jpeg.Decode(resp.BodyStream())
if err != nil {
return err
}
tag.SetCoverArt(&parsed)
}
return tag.Save(c)
return tag.Save(c.Response().BodyWriter())
case cfg.AudioAAC:
r := acquireReader()
err := r.Setup(u, true, nil)
if err != nil {
return err
}
r.req.SetRequestURIBytes(r.parts[0])
err = sc.DoWithRetry(r.client, r.req, r.resp)
if err != nil {
return err
}
r.index++
tag, err := mp4meta.ReadMP4(bytes.NewReader(r.resp.Body()))
if err != nil {
return err
}
tag.SetArtist(t.Author.Username)
if t.Genre != "" {
tag.SetGenre(t.Genre)
}
tag.SetTitle(t.Title)
if t.Artwork != "" {
r.req.SetRequestURI(t.Artwork)
err := sc.DoWithRetry(misc.ImageClient, r.req, r.resp)
if err == nil && r.resp.StatusCode() == 200 {
parsed, _, err := image.Decode(r.resp.BodyStream())
r.resp.CloseBodyStream()
if err == nil {
tag.SetCoverArt(&parsed)
}
}
}
var col collector
tag.Save(&col)
fixDuration(col.data, &t.Duration)
r.leftover = col.data
return c.SendStream(r)
}
}
r := acquireReader()
if err := r.Setup(u, audio == cfg.AudioAAC); err != nil {
if audio == cfg.AudioAAC {
err = r.Setup(u, true, &t.Duration)
} else {
err = r.Setup(u, false, nil)
}
if err != nil {
return err
}

186
lib/restream/reader.go Normal file
View File

@@ -0,0 +1,186 @@
package restream
import (
"bytes"
"encoding/binary"
"io"
"sync"
"git.maid.zone/stuff/soundcloak/lib/cfg"
"git.maid.zone/stuff/soundcloak/lib/misc"
"git.maid.zone/stuff/soundcloak/lib/sc"
"github.com/valyala/fasthttp"
)
const defaultPartsCapacity = 24
type reader struct {
duration *uint32
req *fasthttp.Request
resp *fasthttp.Response
client *fasthttp.HostClient
parts [][]byte
leftover []byte
index int
}
var readerpool = sync.Pool{
New: func() any {
return &reader{}
},
}
func acquireReader() *reader {
return readerpool.Get().(*reader)
}
func clone(buf []byte) []byte {
out := make([]byte, len(buf))
copy(out, buf)
return out
}
var mvhd = []byte("mvhd")
var newline = []byte{'\n'}
func fixDuration(data []byte, duration *uint32) {
i := bytes.Index(data, mvhd)
if i != -1 {
i += 20
bt := make([]byte, 4)
binary.BigEndian.PutUint32(bt, *duration)
copy(data[i:], bt)
// timescale is already 1000 in the files
}
}
func (r *reader) Setup(url string, aac bool, duration *uint32) error {
if r.req == nil {
r.req = fasthttp.AcquireRequest()
}
if r.resp == nil {
r.resp = fasthttp.AcquireResponse()
}
r.req.SetRequestURI(url)
r.req.Header.SetUserAgent(cfg.UserAgent)
if aac {
r.client = misc.HlsAacClient
r.duration = duration
} else {
r.client = misc.HlsClient
}
err := sc.DoWithRetry(r.client, r.req, r.resp)
if err != nil {
return err
}
if r.parts == nil {
misc.Log("make() r.parts")
r.parts = make([][]byte, 0, defaultPartsCapacity)
} else {
misc.Log(cap(r.parts), len(r.parts))
}
// clone needed to mitigate memory skill issues smh
if aac {
for _, s := range bytes.Split(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]))
}
continue
}
r.parts = append(r.parts, clone(s))
}
} else {
for _, s := range bytes.Split(r.resp.Body(), newline) {
if len(s) == 0 || s[0] == '#' {
continue
}
r.parts = append(r.parts, clone(s))
}
}
return nil
}
func (r *reader) Close() error {
misc.Log("closed :D")
r.req.Reset()
r.resp.Reset()
r.leftover = nil
r.index = 0
r.parts = r.parts[:0]
readerpool.Put(r)
return nil
}
// I have no idea what this truly even does anymore. Maybe a rewrite/refactor would be good?
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)
}
n = copy(buf, r.leftover[:h])
if n > len(r.leftover) {
r.leftover = r.leftover[:0]
} else {
r.leftover = r.leftover[n:]
}
if n < len(buf) && r.index == len(r.parts) {
err = io.EOF
}
return
}
if r.index == len(r.parts) {
err = io.EOF
return
}
r.req.SetRequestURIBytes(r.parts[r.index])
err = sc.DoWithRetry(r.client, r.req, r.resp)
if err != nil {
return
}
data := r.resp.Body()
if r.index == 0 && r.duration != nil {
fixDuration(data, r.duration) // I'm guessing that mvhd will always be in first part
}
if len(data) > len(buf) {
n = copy(buf, data[:len(buf)])
} else {
n = copy(buf, data)
}
r.leftover = data[n:]
r.index++
if n < len(buf) && r.index == len(r.parts) {
err = io.EOF
}
return
}

View File

@@ -1,31 +1,65 @@
package sc
import "git.maid.zone/stuff/soundcloak/lib/cfg"
import (
"net/url"
"strings"
// Functions/structions related to featured/suggested content
"git.maid.zone/stuff/soundcloak/lib/cfg"
)
type Selection struct {
Title string `json:"title"`
Kind string `json:"kind"` // should always be "selection"!
Items Paginated[*Playlist] `json:"items"` // ?? why
// 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 GetFeaturedTracks(cid string, prefs cfg.Preferences, args string) (*Paginated[*Track], error) {
p := Paginated[*Track]{Next: "https://" + api + "/featured_tracks/top/all-music" + args}
// DO NOT UNFOLD
// dangerous
// seems to go in an infinite loop
err := p.Proceed(cid, false)
if err != nil {
return nil, err
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
}
}
for _, t := range p.Collection {
t.Fix(false, false)
t.Postfix(prefs, false)
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)
}
}
}
}
return &p, nil
type Selection struct {
Title string `json:"title"`
Kind string `json:"kind"` // should always be "selection"!
Items Paginated[*PlaylistOrUser] `json:"items"` // ?? why
}
func GetSelections(cid string, prefs cfg.Preferences) (*Paginated[*Selection], error) {
@@ -45,7 +79,6 @@ func GetSelections(cid string, prefs cfg.Preferences) (*Paginated[*Selection], e
func (s *Selection) Fix(prefs cfg.Preferences) {
for _, p := range s.Items.Collection {
p.Fix("", false, false)
p.Postfix(prefs, false, false)
p.Fix(prefs)
}
}

View File

@@ -1,6 +1,7 @@
package sc
import (
"bytes"
"errors"
"fmt"
"net/url"
@@ -17,9 +18,9 @@ import (
)
type clientIdCache struct {
NextCheck time.Time
ClientID string
Version string
NextCheck time.Time
}
var ClientIDCache clientIdCache
@@ -28,6 +29,11 @@ const api = "api-v2.soundcloud.com"
const H = len("https://" + api)
var newline = []byte("\n")
var sc_version = []byte(`<script>window.__sc_version="`)
var script0 = []byte(`<script crossorigin src="https://a-v2.sndcdn.com/assets/0-`)
var script = []byte(`<script crossorigin src="https://a-v2.sndcdn.com/assets/`)
var httpc = &fasthttp.HostClient{
Addr: api + ":443",
IsTLS: true,
@@ -39,11 +45,12 @@ var genericClient = &fasthttp.Client{
Dial: (&fasthttp.TCPDialer{DNSCacheDuration: cfg.DNSCacheTTL}).Dial,
}
// var verRegex = regexp2.MustCompile(`^<script>window\.__sc_version="([0-9]{10})"</script>$`, 2)
// var scriptsRegex = regexp2.MustCompile(`^<script crossorigin src="(https://a-v2\.sndcdn\.com/assets/.+\.js)"></script>$`, 2)
// var scriptRegex = regexp2.MustCompile(`^<script crossorigin src="(https://a-v2\.sndcdn\.com/assets/0-.+\.js)"></script>$`, 2)
//go:generate regexp2cg -package sc -o regexp2_codegen.go
var verRegex = regexp2.MustCompile(`^<script>window\.__sc_version="([0-9]{10})"</script>$`, 2)
var scriptsRegex = regexp2.MustCompile(`^<script crossorigin src="(https://a-v2\.sndcdn\.com/assets/.+\.js)"></script>$`, 2)
var scriptRegex = regexp2.MustCompile(`^<script crossorigin src="(https://a-v2\.sndcdn\.com/assets/0-.+\.js)"></script>$`, 2)
var clientIdRegex = regexp2.MustCompile(`\("client_id=([A-Za-z0-9]{32})"\)`, 0)
var clientIdRegex = regexp2.MustCompile(`client_id:"([A-Za-z0-9]{32})"`, 0) //regexp2.MustCompile(`\("client_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")
@@ -55,12 +62,12 @@ type cached[T any] struct {
}
// don't be spooked by misc.Log, it will be removed during compilation if cfg.Debug == false
func processFile(wg *sync.WaitGroup, ch chan string, uri string, isDone *bool) {
misc.Log(uri)
func processFile(wg *sync.WaitGroup, ch chan string, uri []byte, isDone *bool) {
misc.Log(string(uri))
req := fasthttp.AcquireRequest()
defer fasthttp.ReleaseRequest(req)
req.SetRequestURI(uri)
req.SetRequestURIBytes(uri)
req.Header.SetUserAgent(cfg.UserAgent)
req.Header.Set("Accept-Encoding", "gzip, deflate, br, zstd")
@@ -105,19 +112,23 @@ func processFile(wg *sync.WaitGroup, ch chan string, uri string, isDone *bool) {
}
ch <- g.String()
misc.Log("found in", uri)
misc.Log("found in", string(uri))
return
}
}
misc.Log("not found in", uri)
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 = true
// inspired by github.com/imputnet/cobalt (mostly stolen lol)
// inspired by github.com/imputnet/cobalt
func GetClientID() (string, error) {
if cfg.ClientID != "" {
return cfg.ClientID, nil
}
if ClientIDCache.NextCheck.After(time.Now()) {
misc.Log("clientidcache hit @ 1")
return ClientIDCache.ClientID, nil
@@ -143,65 +154,82 @@ func GetClientID() (string, error) {
data = resp.Body()
}
m, _ := verRegex.FindStringMatch(cfg.B2s(data))
if m == nil {
return "", ErrVersionNotFound
}
g := m.GroupByNumber(1)
if g == nil {
return "", ErrVersionNotFound
}
ver := g.String()
if ver == ClientIDCache.Version {
misc.Log("clientidcache hit @ ver")
ClientIDCache.NextCheck = time.Now().Add(cfg.ClientIDTTL)
return ClientIDCache.ClientID, nil
}
if experimental_GetClientID {
m, _ = scriptRegex.FindStringMatch(cfg.B2s(data))
var ver string
var scriptUrl []byte
for l := range bytes.SplitSeq(data, newline) { // version usually comes earlier, but retest this sometimes !!!
if ver == "" && bytes.HasPrefix(l, sc_version) {
ver = cfg.B2s(l[len(sc_version) : len(l)-len(`"</script>`)])
misc.Log("found ver:", ver)
if ClientIDCache.Version != "" && ver == ClientIDCache.Version {
goto verCacheHit
}
} else if bytes.HasPrefix(l, script0) {
scriptUrl = l[len(`<script crossorigin src="`) : len(l)-len(`"></script>`)]
misc.Log("found scriptUrl:", string(scriptUrl))
break
}
}
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))
if m != nil {
g = m.GroupByNumber(1)
g := m.GroupByNumber(1)
if g != nil {
req.SetRequestURI(g.String())
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))
if m != nil {
g = m.GroupByNumber(1)
if g != nil {
ClientIDCache.ClientID = g.String()
ClientIDCache.Version = ver
ClientIDCache.NextCheck = time.Now().Add(cfg.ClientIDTTL)
misc.Log(ClientIDCache)
return ClientIDCache.ClientID, nil
}
}
ClientIDCache.ClientID = g.String()
ClientIDCache.Version = ver
ClientIDCache.NextCheck = time.Now().Add(cfg.ClientIDTTL)
misc.Log(ClientIDCache)
return ClientIDCache.ClientID, nil
}
}
} else {
ch := make(chan string, 1)
wg := &sync.WaitGroup{}
done := false
m, _ = scriptsRegex.FindStringMatch(cfg.B2s(data))
for m != nil {
g = m.GroupByNumber(1)
if g != nil {
wg.Add(1)
go processFile(wg, ch, g.String(), &done)
}
m, _ = scriptsRegex.FindNextMatch(m)
var ver string
var scriptUrls = make([][]byte, 0, 10) // Usually only 8 chunks, so I went with jsut a bit more to be safe
for l := range bytes.SplitSeq(data, newline) {
if ver == "" && bytes.HasPrefix(l, sc_version) {
ver = cfg.B2s(l[len(sc_version) : len(l)-len(`"</script>`)])
if ver == ClientIDCache.Version {
goto verCacheHit
}
} else if bytes.HasPrefix(l, script) {
scriptUrls = append(scriptUrls, l[len(`<script crossorigin src="`):len(l)-len(`"></script>`)])
}
}
if ver == "" {
return "", ErrVersionNotFound
}
if len(scriptUrls) == 0 {
return "", ErrScriptNotFound
}
for _, s := range scriptUrls {
wg.Add(1)
go processFile(wg, ch, s, &done)
}
go func() {
@@ -234,11 +262,16 @@ func GetClientID() (string, error) {
}
return "", ErrIDNotFound
verCacheHit:
misc.Log("clientidcache hit @ ver")
ClientIDCache.NextCheck = time.Now().Add(cfg.ClientIDTTL)
return ClientIDCache.ClientID, nil
}
// Just retry any kind of errors, why not
func DoWithRetryAll(httpc *fasthttp.Client, req *fasthttp.Request, resp *fasthttp.Response) (err error) {
for i := 0; i < 10; i++ {
for range 10 {
err = httpc.Do(req, resp)
if err == nil {
return nil
@@ -250,7 +283,7 @@ func DoWithRetryAll(httpc *fasthttp.Client, req *fasthttp.Request, resp *fasthtt
// Since the http client is setup to always keep connections idle (great for speed, no need to open a new one everytime), those connections may be closed by soundcloud after some time of inactivity, this ensures that we retry those requests that fail due to the connection closing/timing out
func DoWithRetry(httpc *fasthttp.HostClient, req *fasthttp.Request, resp *fasthttp.Response) (err error) {
for i := 0; i < 10; i++ {
for range 10 {
err = httpc.Do(req, resp)
if err == nil {
return nil
@@ -309,9 +342,9 @@ 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"`
Next string `json:"next_href"`
}
func (p *Paginated[T]) Proceed(cid string, shouldUnfold bool) error {
@@ -426,8 +459,9 @@ func init() {
for range ticker.C {
usersCacheLock.Lock()
now := time.Now()
for key, val := range UsersCache {
if val.Expires.Before(time.Now()) {
if val.Expires.Before(now) {
delete(UsersCache, key)
}
}
@@ -441,8 +475,9 @@ func init() {
for range ticker.C {
tracksCacheLock.Lock()
now := time.Now()
for key, val := range TracksCache {
if val.Expires.Before(time.Now()) {
if val.Expires.Before(now) {
delete(TracksCache, key)
}
}
@@ -456,8 +491,9 @@ func init() {
for range ticker.C {
playlistsCacheLock.Lock()
now := time.Now()
for key, val := range PlaylistsCache {
if val.Expires.Before(time.Now()) {
if val.Expires.Before(now) {
delete(PlaylistsCache, key)
}
}

View File

@@ -16,28 +16,26 @@ var playlistsCacheLock = &sync.RWMutex{}
// Functions/structures related to playlists
type Playlist struct {
Artwork string `json:"artwork_url"`
CreatedAt string `json:"created_at"`
Description string `json:"description"`
Kind string `json:"kind"` // should always be "playlist"! or "system-playlist"
LastModified string `json:"last_modified"`
Likes int64 `json:"likes_count"`
Permalink string `json:"permalink"`
//ReleaseDate string `json:"release_date"`
TagList string `json:"tag_list"`
Title string `json:"title"`
Type string `json:"set_type"`
Album bool `json:"is_album"`
Author User `json:"user"`
Tracks []Track `json:"tracks"`
TrackCount int64 `json:"track_count"`
MissingTracks string `json:"-"`
Artwork string `json:"artwork_url"`
CreatedAt string `json:"created_at"`
Description string `json:"description"`
Kind string `json:"kind"` // should always be "playlist"! or "system-playlist"
LastModified string `json:"last_modified"`
Permalink string `json:"permalink"`
TagList string `json:"tag_list"`
Title string `json:"title"`
Type string `json:"set_type"`
MissingTracks string `json:"-"`
Tracks []Track `json:"tracks"`
Author User `json:"user"`
Likes int64 `json:"likes_count"`
TrackCount int64 `json:"track_count"`
Album bool `json:"is_album"`
}
func GetPlaylist(cid string, permalink string) (Playlist, error) {
playlistsCacheLock.RLock()
if cell, ok := PlaylistsCache[permalink]; ok && cell.Expires.After(time.Now()) {
if cell, ok := PlaylistsCache[permalink]; ok {
playlistsCacheLock.RUnlock()
return cell.Value, nil
}

View File

@@ -24,42 +24,46 @@ var TracksCache = map[string]cached[Track]{}
var tracksCacheLock = &sync.RWMutex{}
type Track struct {
Artwork string `json:"artwork_url"`
Comments int `json:"comment_count"`
CreatedAt string `json:"created_at"`
Description string `json:"description"`
//Duration int `json:"duration"` // there are duration and full_duration fields wtf does that mean
Artwork string `json:"artwork_url"`
CreatedAt string `json:"created_at"`
Description string `json:"description"`
Genre string `json:"genre"`
Kind string `json:"kind"` // should always be "track"!
LastModified string `json:"last_modified"`
License string `json:"license"`
Likes int64 `json:"likes_count"`
Permalink string `json:"permalink"`
Played int64 `json:"playback_count"`
Reposted int64 `json:"reposts_count"`
TagList string `json:"tag_list"`
Title string `json:"title"`
ID json.Number `json:"id"`
Media Media `json:"media"`
Authorization string `json:"track_authorization"`
Author User `json:"user"`
Policy TrackPolicy `json:"policy"`
Station string `json:"station_permalink"`
Media Media `json:"media"`
Author User `json:"user"`
Comments int `json:"comment_count"`
Likes int64 `json:"likes_count"`
Played int64 `json:"playback_count"`
Reposted int64 `json:"reposts_count"`
Duration uint32 `json:"full_duration"`
}
type TrackPolicy string
const (
PolicyBlock TrackPolicy = "BLOCK" // not available (in your country)
PolicySnip TrackPolicy = "SNIP" // 30-second snippet available
PolicyAllow TrackPolicy = "ALLOW" // all good
PolicyMonetize TrackPolicy = "MONETIZE" // seems like only certain countries get this policy? sometimes protected by widevine and fairplay
PolicyBlock TrackPolicy = "BLOCK" // not available (in your country)
PolicySnip TrackPolicy = "SNIP" // 30-second snippet available
PolicyAllow TrackPolicy = "ALLOW" // all good
)
type Protocol string
const (
ProtocolHLS Protocol = "hls"
ProtocolProgressive Protocol = "progressive"
ProtocolHLS Protocol = "hls"
ProtocolProgressive Protocol = "progressive"
ProtocolEncryptedHLS Protocol = "encrypted-hls" // idk, haven't seen in the wild
ProtocolCTREncryptedHLS Protocol = "ctr-encrypted-hls" // google's widevine
ProtocolCBCEncryptedHLS Protocol = "cbc-encrypted-hls" // apple's fairplay
)
type Format struct {
@@ -85,8 +89,8 @@ type Stream struct {
type Comment struct {
Kind string `json:"kind"` // "comment"
Body string `json:"body"`
Timestamp int `json:"timestamp"`
Author User `json:"user"`
Timestamp int `json:"timestamp"`
}
func (m Media) SelectCompatible(mode string, opus bool) (*Transcoding, string) {
@@ -129,7 +133,7 @@ func (m Media) SelectCompatible(mode string, opus bool) (*Transcoding, string) {
func GetTrack(cid string, permalink string) (Track, error) {
tracksCacheLock.RLock()
if cell, ok := TracksCache[permalink]; ok && cell.Expires.After(time.Now()) {
if cell, ok := TracksCache[permalink]; ok {
tracksCacheLock.RUnlock()
return cell.Value, nil
}
@@ -394,7 +398,7 @@ func (t Track) FormatDescription() string {
func GetTrackByID(cid string, id string) (Track, error) {
tracksCacheLock.RLock()
for _, cell := range TracksCache {
if string(cell.Value.ID) == string(id) && cell.Expires.After(time.Now()) {
if string(cell.Value.ID) == string(id) {
tracksCacheLock.RUnlock()
return cell.Value, nil
}
@@ -448,30 +452,6 @@ func GetTrackByID(cid string, id string) (Track, error) {
return t, nil
}
func (t Track) DownloadImage() ([]byte, string, error) {
req := fasthttp.AcquireRequest()
defer fasthttp.ReleaseRequest(req)
req.SetRequestURI(t.Artwork)
req.Header.SetUserAgent(cfg.UserAgent)
//req.Header.Set("Accept-Encoding", "gzip, deflate, br, zstd") images not big enough to be compressed
resp := fasthttp.AcquireResponse()
defer fasthttp.ReleaseResponse(resp)
err := DoWithRetry(misc.ImageClient, req, resp)
if err != nil {
return nil, "", err
}
data, err := resp.BodyUncompressed()
if err != nil {
data = resp.Body()
}
return data, string(resp.Header.Peek("Content-Type")), nil
}
func (t Track) Href() string {
return "/" + t.Author.Permalink + "/" + t.Permalink
}

View File

@@ -23,21 +23,20 @@ type User struct {
Avatar string `json:"avatar_url"`
CreatedAt string `json:"created_at"`
Description string `json:"description"`
Followers int64 `json:"followers_count"`
Following int64 `json:"followings_count"`
FullName string `json:"full_name"`
Kind string `json:"kind"` // should always be "user"!
LastModified string `json:"last_modified"`
Liked int64 `json:"likes_count"`
Permalink string `json:"permalink"`
Playlists int64 `json:"playlist_count"`
Tracks int64 `json:"track_count"`
ID json.Number `json:"id"`
Username string `json:"username"`
Verified bool `json:"verified"`
Station string `json:"station_permalink"`
WebProfiles []Link
WebProfiles []Link `json:",omitempty"`
Followers int64 `json:"followers_count"`
Following int64 `json:"followings_count"`
Liked int64 `json:"likes_count"`
Playlists int64 `json:"playlist_count"`
Tracks int64 `json:"track_count"`
Verified bool `json:"verified"`
}
type Link struct {
@@ -54,10 +53,9 @@ const (
// not worthy of its own file
type Repost struct {
Type RepostType
Track *Track // type == track-report
Playlist *Playlist // type == playlist-repost
Type RepostType
}
func (r Repost) Fix(prefs cfg.Preferences) {
@@ -94,7 +92,7 @@ func (l Like) Fix(prefs cfg.Preferences) {
}
func GetUser(cid string, permalink string) (User, error) {
usersCacheLock.RLock()
if cell, ok := UsersCache[permalink]; ok && cell.Expires.After(time.Now()) {
if cell, ok := UsersCache[permalink]; ok {
usersCacheLock.RUnlock()
return cell.Value, nil
}

453
main.go
View File

@@ -1,15 +1,20 @@
package main
import (
"bytes"
"fmt"
"io"
"io/fs"
"log"
"math/rand"
"net/url"
"os"
"strings"
"sync"
"git.maid.zone/stuff/soundcloak/lib/api"
"git.maid.zone/stuff/soundcloak/lib/misc"
"github.com/a-h/templ"
"github.com/gofiber/fiber/v3"
"github.com/goccy/go-json"
@@ -23,8 +28,9 @@ import (
proxystreams "git.maid.zone/stuff/soundcloak/lib/proxy_streams"
"git.maid.zone/stuff/soundcloak/lib/restream"
"git.maid.zone/stuff/soundcloak/lib/sc"
static_files "git.maid.zone/stuff/soundcloak/static"
"git.maid.zone/stuff/soundcloak/templates"
static_files "git.maid.zone/stuff/soundcloak/static"
)
func boolean(b bool) string {
@@ -34,85 +40,240 @@ func boolean(b bool) string {
return "Disabled"
}
type osfs struct{}
type compressionMap = map[string][][]byte
func (osfs) Open(name string) (fs.File, error) {
misc.Log("osfs:", name)
func parseCompressionMap(path string, filesystem fs.FS) compressionMap {
f, err := filesystem.Open(path + "/.compression")
if err != nil {
return nil
}
defer f.Close()
if strings.HasPrefix(name, "js/") {
return os.Open("static/external/" + name[3:])
data, err := io.ReadAll(f)
if err != nil {
return nil
}
f, err := os.Open("static/instance/" + name)
if err == nil {
return f, nil
}
f, err = os.Open("static/assets/" + name)
return f, err
}
type staticfs struct {
}
func (staticfs) Open(name string) (fs.File, error) {
misc.Log("staticfs:", name)
if strings.HasPrefix(name, "js/") {
return static_files.External.Open("external/" + name[3:])
}
f, err := static_files.Instance.Open("instance/" + name)
if err == nil {
return f, nil
}
f, err = static_files.Assets.Open("assets/" + name)
return f, err
}
// stubby implementation of static middleware
// why? mainly because of the pathrewrite
// i hate seeing it trying to get root directory
func ServeFromFS(r *fiber.App, filesystem fs.FS) {
const path = "/_/static"
const l = len(path)
fs := fasthttp.FS{
FS: filesystem,
PathRewrite: func(ctx *fasthttp.RequestCtx) []byte {
return ctx.Path()[l:]
},
Compress: true,
CompressBrotli: true,
CompressedFileSuffixes: map[string]string{
"gzip": ".gzip",
"br": ".br",
"zstd": ".zstd",
},
}
handler := fs.NewRequestHandler()
r.Use(path, func(c fiber.Ctx) error {
handler(c.RequestCtx())
if c.RequestCtx().Response.StatusCode() == 200 {
c.Set("Cache-Control", "public, max-age=28800")
sp := bytes.Split(data, []byte("\n"))
cm := make(compressionMap, len(sp))
for _, h := range sp {
sp2 := bytes.Split(h, []byte("|"))
if len(sp2) != 2 || string(sp2[0]) == ".gitkeep" {
continue
}
return nil
})
h := bytes.Split(sp2[1], []byte(","))
if len(h) == 0 || len(h[0]) == 0 {
h = nil
}
cm[cfg.B2s(sp2[0])] = h
}
return cm
}
func parseCompressionMaps(filesystem fs.FS) compressionMap {
var (
external = parseCompressionMap("external", filesystem)
assets = parseCompressionMap("assets", filesystem)
instance = parseCompressionMap("instance", filesystem)
)
res := make(compressionMap, len(external)+len(assets)+len(instance))
for k, v := range external {
res["external/"+k] = v
}
for k, v := range assets {
res["assets/"+k] = v
}
for k, v := range instance {
res["instance/"+k] = v
}
return res
}
type pooledReader struct {
handle io.ReadSeeker
p *sync.Pool
}
func (pr *pooledReader) Read(data []byte) (int, error) {
return pr.handle.Read(data)
}
func (pr *pooledReader) Close() error {
pr.handle.Seek(0, io.SeekStart)
pr.p.Put(pr)
return nil
}
type pooledFs struct {
filesystem fs.FS
pools map[string]*sync.Pool
mut sync.RWMutex
}
func (p *pooledFs) Open(name string) (*pooledReader, error) {
p.mut.RLock()
pool := p.pools[name]
p.mut.RUnlock()
if pool == nil {
misc.Log("pool is nil for", name)
pool = &sync.Pool{}
p.mut.Lock()
p.pools[name] = pool
p.mut.Unlock()
goto new
}
if pr := pool.Get(); pr != nil {
return pr.(*pooledReader), nil
}
new:
h, err := p.filesystem.Open(name)
if err != nil {
return nil, err
}
return &pooledReader{h.(io.ReadSeeker), pool}, nil
}
func ServeFS(r *fiber.App, filesystem fs.FS) {
cm := parseCompressionMaps(filesystem)
misc.Log(cm)
pfs := &pooledFs{filesystem, make(map[string]*sync.Pool), sync.RWMutex{}}
const path = "/_/static/"
if len(cm) == 0 {
r.Use(path, func(c fiber.Ctx) error {
// start := time.Now()
// defer func() { fmt.Println("it took", time.Since(start)) }()
fp := cfg.B2s(c.RequestCtx().Path()[len(path):])
if strings.HasSuffix(fp, ".css") {
c.Response().Header.SetContentType("text/css")
} else if strings.HasSuffix(fp, ".js") {
c.Response().Header.SetContentType("text/javascript")
} else if strings.HasSuffix(fp, ".jpg") {
c.Response().Header.SetContentType("image/jpeg")
} else if strings.HasSuffix(fp, ".ttf") {
c.Response().Header.SetContentType("font/ttf")
}
var (
f *pooledReader
err error
)
if !strings.HasPrefix(fp, "external/") {
f, err = pfs.Open("instance/" + fp)
if err != nil {
f, err = pfs.Open("assets/" + fp)
}
} else {
f, err = pfs.Open(fp)
}
if err != nil {
return err
}
c.Set("Cache-Control", "public, max-age=28800")
return c.SendStream(f)
})
} else {
r.Use(path, func(c fiber.Ctx) error {
// start := time.Now()
// defer func() { fmt.Println("it took", time.Since(start)) }()
fp := cfg.B2s(c.RequestCtx().Path()[len(path):])
var (
encs [][]byte
ok bool
)
if strings.HasPrefix(fp, "external/") {
encs, ok = cm[fp]
} else {
encs, ok = cm["instance/"+fp]
if ok {
fp = "instance/" + fp
} else {
fp = "assets/" + fp
encs, ok = cm[fp]
}
}
if !ok {
return fiber.ErrNotFound
}
if strings.HasSuffix(fp, ".css") {
c.Response().Header.SetContentType("text/css")
} else if strings.HasSuffix(fp, ".js") {
c.Response().Header.SetContentType("text/javascript")
} else if strings.HasSuffix(fp, ".jpg") {
c.Response().Header.SetContentType("image/jpeg")
} else if strings.HasSuffix(fp, ".ttf") {
c.Response().Header.SetContentType("font/ttf")
}
if len(encs) != 0 {
ae := c.Request().Header.Peek("Accept-Encoding")
if len(ae) == 1 && ae[0] == '*' {
c.Response().Header.SetContentEncodingBytes(encs[0])
fp += "." + cfg.B2s(encs[0])
} else {
for _, enc := range encs {
if bytes.Contains(ae, enc) {
c.Response().Header.SetContentEncodingBytes(enc)
fp += "." + cfg.B2s(enc)
break
}
}
}
}
f, err := pfs.Open(fp)
if err != nil {
return err
}
c.Set("Cache-Control", "public, max-age=28800")
return c.SendStream(f)
})
}
}
func render(c fiber.Ctx, t templ.Component) error {
c.Response().Header.SetContentType("text/html")
return t.Render(c.RequestCtx(), c.Response().BodyWriter())
}
func r(c fiber.Ctx, title string, content, head templ.Component) error {
return render(c, templates.Base(title, content, head))
}
func main() {
app := fiber.New(fiber.Config{
//Prefork: cfg.Prefork, // moved to ListenConfig in v3
JSONEncoder: json.Marshal,
JSONDecoder: json.Unmarshal,
TrustProxy: cfg.TrustedProxyCheck,
TrustProxyConfig: fiber.TrustProxyConfig{Proxies: cfg.TrustedProxies},
ReadBufferSize: 4096 * 2,
})
if cfg.Debug {
app.Server().Logger = fasthttp.Logger(log.New(os.Stdout, "", log.LstdFlags))
}
if !cfg.Debug { // you wanna catch any possible panics as soon as possible
app.Use(recover.New())
}
@@ -150,28 +311,30 @@ func main() {
return err
}
c.Set("Content-Type", "text/html")
return templates.Base("", templates.MainPage(prefs), templates.MainPageHead()).Render(c.RequestCtx(), c)
return r(c, "", templates.MainPage(prefs), templates.MainPageHead())
}
app.Get("/", mainPageHandler)
app.Get("/index.html", mainPageHandler)
}
const AssetsCacheControl = "public, max-age=28800" // 8hrs
if cfg.EmbedFiles {
misc.Log("using embedded files")
ServeFromFS(app, staticfs{})
ServeFS(app, static_files.All)
} else {
misc.Log("loading files dynamically")
ServeFromFS(app, osfs{})
r, err := os.OpenRoot("static")
if err != nil {
panic(err)
}
ServeFS(app, r.FS())
}
// why? because when you load a page without link rel="icon" the browser will
// try to load favicon from default location,
// and this path loads the user "favicon" by default
app.Get("favicon.ico", func(c fiber.Ctx) error {
return c.Redirect().To("/_/static/favicon.ico")
return c.Redirect().Status(fiber.StatusPermanentRedirect).To("/_/static/favicon.ico")
})
app.Get("robots.txt", func(c fiber.Ctx) error {
@@ -185,38 +348,40 @@ Disallow: /`)
return err
}
q := c.Query("q")
t := c.Query("type")
q := cfg.B2s(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)
}
switch t {
case "tracks":
p, err := sc.SearchTracks("", prefs, c.Query("pagination", "?q="+url.QueryEscape(q)))
p, err := sc.SearchTracks("", prefs, args)
if err != nil {
log.Printf("error getting tracks for %s: %s\n", q, err)
return err
}
c.Set("Content-Type", "text/html")
return templates.Base("tracks: "+q, templates.SearchTracks(p), nil).Render(c.RequestCtx(), c)
return r(c, "tracks: "+q, templates.SearchTracks(p), nil)
case "users":
p, err := sc.SearchUsers("", prefs, c.Query("pagination", "?q="+url.QueryEscape(q)))
p, err := sc.SearchUsers("", prefs, args)
if err != nil {
log.Printf("error getting users for %s: %s\n", q, err)
return err
}
c.Set("Content-Type", "text/html")
return templates.Base("users: "+q, templates.SearchUsers(p), nil).Render(c.RequestCtx(), c)
return r(c, "users: "+q, templates.SearchUsers(p), nil)
case "playlists":
p, err := sc.SearchPlaylists("", prefs, c.Query("pagination", "?q="+url.QueryEscape(q)))
p, err := sc.SearchPlaylists("", prefs, args)
if err != nil {
log.Printf("error getting users for %s: %s\n", q, err)
log.Printf("error getting playlists for %s: %s\n", q, err)
return err
}
c.Set("Content-Type", "text/html")
return templates.Base("playlists: "+q, templates.SearchPlaylists(p), nil).Render(c.RequestCtx(), c)
return r(c, "playlists: "+q, templates.SearchPlaylists(p), nil)
}
return c.SendStatus(404)
@@ -258,8 +423,6 @@ Disallow: /`)
return fiber.ErrNotFound
}
//fmt.Println(c.Hostname(), c.Protocol(), c.IPs())
u, err := url.Parse(cfg.B2s(loc))
if err != nil {
return err
@@ -310,8 +473,7 @@ Disallow: /`)
}
}
c.Set("Content-Type", "text/html")
return templates.TrackEmbed(prefs, track, stream, displayErr).Render(c.RequestCtx(), c)
return render(c, templates.TrackEmbed(prefs, track, stream, displayErr))
})
app.Get("/tags/:tag", func(c fiber.Ctx) error {
@@ -320,20 +482,14 @@ Disallow: /`)
return err
}
cid, err := sc.GetClientID()
if err != nil {
return err
}
tag := c.Params("tag")
p, err := sc.RecentTracks(cid, prefs, c.Query("pagination", tag+"?limit=20"))
p, err := sc.RecentTracks("", prefs, c.Query("pagination", tag+"?limit=20"))
if err != nil {
log.Printf("error getting %s tagged recent-tracks: %s\n", tag, err)
return err
}
c.Set("Content-Type", "text/html")
return templates.Base("Recent tracks tagged "+tag, templates.RecentTracks(tag, p), nil).Render(c.RequestCtx(), c)
return r(c, "Recent tracks tagged "+tag, templates.RecentTracks(tag, p), nil)
})
app.Get("/tags/:tag/popular-tracks", func(c fiber.Ctx) error {
@@ -342,20 +498,14 @@ Disallow: /`)
return err
}
cid, err := sc.GetClientID()
if err != nil {
return err
}
tag := c.Params("tag")
p, err := sc.SearchTracks(cid, prefs, c.Query("pagination", "?q=*&filter.genre_or_tag="+tag+"&sort=popular"))
p, err := sc.SearchTracks("", prefs, c.Query("pagination", "?q=*&filter.genre_or_tag="+tag+"&sort=popular"))
if err != nil {
log.Printf("error getting %s tagged popular-tracks: %s\n", tag, err)
return err
}
c.Set("Content-Type", "text/html")
return templates.Base("Popular tracks tagged "+tag, templates.PopularTracks(tag, p), nil).Render(c.RequestCtx(), c)
return r(c, "Popular tracks tagged "+tag, templates.PopularTracks(tag, p), nil)
})
app.Get("/tags/:tag/playlists", func(c fiber.Ctx) error {
@@ -364,37 +514,15 @@ Disallow: /`)
return err
}
cid, err := sc.GetClientID()
if err != nil {
return err
}
tag := c.Params("tag")
// Using a different method, since /playlists/discovery endpoint seems to be broken :P
p, err := sc.SearchPlaylists(cid, prefs, c.Query("pagination", "?q=*&filter.genre_or_tag="+tag))
p, err := sc.SearchPlaylists("", prefs, c.Query("pagination", "?q=*&filter.genre_or_tag="+tag))
if err != nil {
log.Printf("error getting %s tagged playlists: %s\n", tag, err)
return err
}
c.Set("Content-Type", "text/html")
return templates.Base("Playlists tagged "+tag, templates.TaggedPlaylists(tag, p), nil).Render(c.RequestCtx(), c)
})
app.Get("/_/featured", func(c fiber.Ctx) error {
prefs, err := preferences.Get(c)
if err != nil {
return err
}
tracks, err := sc.GetFeaturedTracks("", prefs, c.Query("pagination", "?limit=20"))
if err != nil {
log.Printf("error getting featured tracks: %s\n", err)
return err
}
c.Set("Content-Type", "text/html")
return templates.Base("Featured Tracks", templates.FeaturedTracks(tracks), nil).Render(c.RequestCtx(), c)
return r(c, "Playlists tagged "+tag, templates.TaggedPlaylists(tag, p), nil)
})
app.Get("/discover", func(c fiber.Ctx) error {
@@ -409,8 +537,7 @@ Disallow: /`)
return err
}
c.Set("Content-Type", "text/html")
return templates.Base("Discover", templates.Discover(selections), nil).Render(c.RequestCtx(), c)
return r(c, "Discover", templates.Discover(selections), nil)
})
if cfg.ProxyImages {
@@ -421,15 +548,20 @@ Disallow: /`)
proxystreams.Load(app)
}
if cfg.EnableAPI {
api.Load(app)
}
if cfg.InstanceInfo {
type info struct {
DefaultPreferences cfg.Preferences
Commit string
Repo string
ProxyImages bool
ProxyStreams bool
Restream bool
GetWebProfiles bool
DefaultPreferences cfg.Preferences
EnableAPI bool
}
inf, err := json.Marshal(info{
@@ -440,13 +572,14 @@ Disallow: /`)
Restream: cfg.Restream,
GetWebProfiles: cfg.GetWebProfiles,
DefaultPreferences: cfg.DefaultPreferences,
EnableAPI: cfg.EnableAPI,
})
if err != nil {
log.Fatalln("failed to marshal info: ", err)
}
app.Get("/_/info", func(c fiber.Ctx) error {
c.Set("Content-Type", "application/json")
c.Response().Header.SetContentType("application/json")
return c.Send(inf)
})
}
@@ -500,8 +633,7 @@ Disallow: /`)
return err
}
c.Set("Content-Type", "text/html")
return templates.Base(user.Username, templates.UserPlaylists(prefs, user, pl), templates.UserHeader(user)).Render(c.RequestCtx(), c)
return r(c, user.Username, templates.UserPlaylists(prefs, user, pl), templates.UserHeader(user))
})
app.Get("/:user/albums", func(c fiber.Ctx) error {
@@ -528,8 +660,7 @@ Disallow: /`)
return err
}
c.Set("Content-Type", "text/html")
return templates.Base(user.Username, templates.UserAlbums(prefs, user, pl), templates.UserHeader(user)).Render(c.RequestCtx(), c)
return r(c, user.Username, templates.UserAlbums(prefs, user, pl), templates.UserHeader(user))
})
app.Get("/:user/reposts", func(c fiber.Ctx) error {
@@ -556,8 +687,7 @@ Disallow: /`)
return err
}
c.Set("Content-Type", "text/html")
return templates.Base(user.Username, templates.UserReposts(prefs, user, p), templates.UserHeader(user)).Render(c.RequestCtx(), c)
return r(c, user.Username, templates.UserReposts(prefs, user, p), templates.UserHeader(user))
})
app.Get("/:user/likes", func(c fiber.Ctx) error {
@@ -584,8 +714,7 @@ Disallow: /`)
return err
}
c.Set("Content-Type", "text/html")
return templates.Base(user.Username, templates.UserLikes(prefs, user, p), templates.UserHeader(user)).Render(c.RequestCtx(), c)
return r(c, user.Username, templates.UserLikes(prefs, user, p), templates.UserHeader(user))
})
app.Get("/:user/popular-tracks", func(c fiber.Ctx) error {
@@ -612,8 +741,7 @@ Disallow: /`)
return err
}
c.Set("Content-Type", "text/html")
return templates.Base(user.Username, templates.UserTopTracks(prefs, user, p), templates.UserHeader(user)).Render(c.RequestCtx(), c)
return r(c, user.Username, templates.UserTopTracks(prefs, user, p), templates.UserHeader(user))
})
app.Get("/:user/followers", func(c fiber.Ctx) error {
@@ -640,8 +768,7 @@ Disallow: /`)
return err
}
c.Set("Content-Type", "text/html")
return templates.Base(user.Username, templates.UserFollowers(prefs, user, p), templates.UserHeader(user)).Render(c.RequestCtx(), c)
return r(c, user.Username, templates.UserFollowers(prefs, user, p), templates.UserHeader(user))
})
app.Get("/:user/following", func(c fiber.Ctx) error {
@@ -668,8 +795,7 @@ Disallow: /`)
return err
}
c.Set("Content-Type", "text/html")
return templates.Base(user.Username, templates.UserFollowing(prefs, user, p), templates.UserHeader(user)).Render(c.RequestCtx(), c)
return r(c, user.Username, templates.UserFollowing(prefs, user, p), templates.UserHeader(user))
})
app.Get("/:user/:track", func(c fiber.Ctx) error {
@@ -762,6 +888,17 @@ Disallow: /`)
}
}
if *prefs.AutoplayNextRelatedTrack && nextTrack == nil && string(c.RequestCtx().QueryArgs().Peek("playRelated")) != "false" {
rel, err := track.GetRelated(cid, prefs, "?limit=4")
if err == nil && len(rel.Collection) != 0 {
prev := c.RequestCtx().QueryArgs().Peek("prev")
nextTrack = &track
for i := len(rel.Collection) - 1; i >= 0 && (string(nextTrack.ID) == string(track.ID) || string(nextTrack.ID) == string(prev)); i-- {
nextTrack = rel.Collection[i]
}
}
}
var comments *sc.Paginated[*sc.Comment]
if q := c.Query("pagination"); q != "" {
comments, err = track.GetComments(cid, prefs, q)
@@ -777,8 +914,7 @@ Disallow: /`)
downloadAudio = &audio
}
c.Set("Content-Type", "text/html")
return templates.Base(track.Title+" by "+track.Author.Username, templates.Track(prefs, track, stream, displayErr, string(c.RequestCtx().QueryArgs().Peek("autoplay")) == "true", playlist, nextTrack, c.Query("volume"), mode, audio, downloadAudio, comments), templates.TrackHeader(prefs, track, true)).Render(c.RequestCtx(), c)
return r(c, track.Title+" by "+track.Author.Username, templates.Track(prefs, track, stream, displayErr, string(c.RequestCtx().QueryArgs().Peek("autoplay")) == "true", playlist, nextTrack, c.Query("volume"), mode, audio, downloadAudio, comments), templates.TrackHeader(prefs, track, true))
})
app.Get("/_/partials/comments/:id", func(c fiber.Ctx) error {
@@ -809,7 +945,7 @@ Disallow: /`)
c.Set("next", "done")
}
return templates.Comments(comm).Render(c.RequestCtx(), c)
return render(c, templates.Comments(comm))
})
app.Get("/:user", func(c fiber.Ctx) error {
@@ -836,8 +972,7 @@ Disallow: /`)
return err
}
c.Set("Content-Type", "text/html")
return templates.Base(usr.Username, templates.User(prefs, usr, p), templates.UserHeader(usr)).Render(c.RequestCtx(), c)
return r(c, usr.Username, templates.User(prefs, usr, p), templates.UserHeader(usr))
})
app.Get("/:user/sets/:playlist", func(c fiber.Ctx) error {
@@ -876,8 +1011,7 @@ Disallow: /`)
playlist.MissingTracks = strings.Join(next, ",")
}
c.Set("Content-Type", "text/html")
return templates.Base(playlist.Title+" by "+playlist.Author.Username, templates.Playlist(prefs, playlist), templates.PlaylistHeader(playlist)).Render(c.RequestCtx(), c)
return r(c, playlist.Title+" by "+playlist.Author.Username, templates.Playlist(prefs, playlist), templates.PlaylistHeader(playlist))
})
app.Get("/:user/_/related", func(c fiber.Ctx) error {
@@ -898,14 +1032,13 @@ Disallow: /`)
}
user.Postfix(prefs)
r, err := user.GetRelated(cid, prefs)
rel, err := user.GetRelated(cid, prefs)
if err != nil {
log.Printf("error getting %s related users: %s\n", c.Params("user"), err)
return err
}
c.Set("Content-Type", "text/html")
return templates.Base(user.Username, templates.UserRelated(prefs, user, r), templates.UserHeader(user)).Render(c.RequestCtx(), c)
return r(c, user.Username, templates.UserRelated(prefs, user, rel), templates.UserHeader(user))
})
// I'd like to make this "related" but keeping it "recommended" to have the same url as soundcloud
@@ -927,14 +1060,13 @@ Disallow: /`)
}
track.Postfix(prefs, true)
r, 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
}
c.Set("Content-Type", "text/html")
return templates.Base(track.Title+" by "+track.Author.Username, templates.RelatedTracks(track, r), templates.TrackHeader(prefs, track, false)).Render(c.RequestCtx(), c)
return r(c, track.Title+" by "+track.Author.Username, templates.RelatedTracks(track, rel), templates.TrackHeader(prefs, track, false))
})
app.Get("/:user/:track/sets", func(c fiber.Ctx) error {
@@ -961,8 +1093,7 @@ Disallow: /`)
return err
}
c.Set("Content-Type", "text/html")
return templates.Base(track.Title+" by "+track.Author.Username, templates.TrackInPlaylists(track, p), templates.TrackHeader(prefs, track, false)).Render(c.RequestCtx(), c)
return r(c, track.Title+" by "+track.Author.Username, templates.TrackInPlaylists(track, p), templates.TrackHeader(prefs, track, false))
})
app.Get("/:user/:track/albums", func(c fiber.Ctx) error {
@@ -989,8 +1120,7 @@ Disallow: /`)
return err
}
c.Set("Content-Type", "text/html")
return templates.Base(track.Title+" by "+track.Author.Username, templates.TrackInAlbums(track, p), templates.TrackHeader(prefs, track, false)).Render(c.RequestCtx(), c)
return r(c, track.Title+" by "+track.Author.Username, templates.TrackInAlbums(track, p), templates.TrackHeader(prefs, track, false))
})
// cute
@@ -1036,5 +1166,6 @@ Disallow: /`)
if cfg.CodegenConfig {
log.Println("Warning: you have CodegenConfig enabled, but the config was loaded dynamically.")
}
log.Fatal(app.Listen(cfg.Addr, fiber.ListenConfig{EnablePrefork: cfg.Prefork, DisableStartupMessage: true, ListenerNetwork: cfg.Network}))
log.Fatal(app.Listen(cfg.Addr, fiber.ListenConfig{EnablePrefork: cfg.Prefork, DisableStartupMessage: true, ListenerNetwork: cfg.Network, UnixSocketFileMode: cfg.UnixSocketPerms}))
}

View File

@@ -46,10 +46,8 @@ function getSuggestions() {
}
input.addEventListener('input', function () {
if (!timeout) {
timeout = setTimeout(getSuggestions, 250);
} else {
if (timeout) {
clearTimeout(timeout);
timeout = setTimeout(getSuggestions, 250);
}
timeout = setTimeout(getSuggestions, 250);
});

View File

@@ -0,0 +1,9 @@
var audio = document.getElementById('track');
audio.onblur = function (e) {
if (e.target != e.relatedTarget) {
setTimeout(function() {
e.target.focus({preventScroll: true, focusVisible: false});
})
}
}
audio.focus({focusVisible: false});

View File

@@ -4,11 +4,5 @@ package static
import "embed"
//go:embed assets/*
var Assets embed.FS
//go:embed instance/*
var Instance embed.FS
//go:embed external/*
var External embed.FS
//go:embed */*
var All embed.FS

View File

@@ -54,15 +54,12 @@ templ MainPage(p cfg.Preferences) {
</form>
<footer>
<div>
<a class="btn" href="/_/featured">Featured Tracks</a>
<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>
if cfg.Commit != "unknown" {
<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={cfg.CommitURL}>{cfg.Commit}</a></p>
</footer>
}

View File

@@ -2,22 +2,34 @@ package templates
import (
"git.maid.zone/stuff/soundcloak/lib/sc"
"net/url"
"strconv"
)
templ FeaturedTracks(p *sc.Paginated[*sc.Track]) {
<h1>Featured Tracks</h1>
if len(p.Collection) == 0 {
<p>no more tracks</p>
} else {
for _, track := range p.Collection {
@TrackItem(track, true, "")
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 loading="lazy" fetchpriority="low" src={ img }/>
} else {
<img loading="lazy" fetchpriority="low" src="/_/static/placeholder.jpg"/>
}
if p.Next != "" {
<a class="btn" href={ templ.SafeURL("/_/featured?pagination=" + url.QueryEscape(p.Next[sc.H+len("/featured_tracks/top/all-music"):])) } rel="noreferrer">more tracks</a>
}
}
<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]) {
@@ -32,8 +44,7 @@ templ Discover(p *sc.Paginated[*sc.Selection]) {
<h2>{selection.Title}</h2>
for _, pl := range selection.Items.Collection {
// We don't need the username
@PlaylistItem(pl, false)
@PlaylistOrUserItem(pl)
}
}

View File

@@ -8,7 +8,7 @@ import (
templ Description(prefs cfg.Preferences, text string, injected templ.Component) {
if text != "" || injected != nil {
<details>
<summary>Toggle description</summary>
<summary>Description</summary>
<p style="white-space: pre-wrap;">
if text != "" {
if *prefs.ParseDescriptions {

View File

@@ -42,50 +42,13 @@ templ sel_audio(name string, selected string, noOpus bool) {
templ Preferences(prefs cfg.Preferences) {
<h1>Preferences</h1>
<form method="post" autocomplete="off">
<label>
Parse descriptions:
@checkbox("ParseDescriptions", *prefs.ParseDescriptions)
</label>
<label>
Show current audio:
@checkbox("ShowAudio", *prefs.ShowAudio)
</label>
if cfg.ProxyImages {
<label>
Proxy images:
@checkbox("ProxyImages", *prefs.ProxyImages)
</label>
}
if cfg.Restream {
<label>
Download audio:
@sel_audio("DownloadAudio", *prefs.DownloadAudio, false)
</label>
}
<label>
Autoplay next track in playlists:
@checkbox("AutoplayNextTrack", *prefs.AutoplayNextTrack)
(requires JS; you need to allow autoplay from this domain!!)
</label>
if *prefs.AutoplayNextTrack {
<label>
Default autoplay mode:
@sel("DefaultAutoplayMode", []option{
{"normal", "Normal (play songs in order)", false},
{"random", "Random (play random song)", false},
}, *prefs.DefaultAutoplayMode)
</label>
}
<label>
Fetch search suggestions:
@checkbox("SearchSuggestions", *prefs.SearchSuggestions)
(requires JS)
</label>
<label>
Dynamically load comments:
@checkbox("DynamicLoadComments", *prefs.DynamicLoadComments)
(requires JS)
</label>
<h1>Player preferences</h1>
<label>
Player:
@sel("Player", []option{
@@ -96,7 +59,6 @@ templ Preferences(prefs cfg.Preferences) {
</label>
switch *prefs.Player {
case cfg.HLSPlayer:
<h1>Player-specific preferences</h1>
if cfg.ProxyStreams {
<label>
Proxy song streams:
@@ -112,12 +74,60 @@ templ Preferences(prefs cfg.Preferences) {
@sel_audio("HLSAudio", *prefs.HLSAudio, true)
</label>
case cfg.RestreamPlayer:
<h1>Player-specific preferences</h1>
<label>
Streaming audio:
@sel_audio("RestreamAudio", *prefs.RestreamAudio, false)
</label>
}
<h1>Frontend enhancements</h1>
if cfg.ProxyImages {
<label>
Proxy images:
@checkbox("ProxyImages", *prefs.ProxyImages)
</label>
}
<label>
Parse descriptions:
@checkbox("ParseDescriptions", *prefs.ParseDescriptions)
</label>
<label>
Show current audio:
@checkbox("ShowAudio", *prefs.ShowAudio)
</label>
<label>
Fetch search suggestions:
@checkbox("SearchSuggestions", *prefs.SearchSuggestions)
(requires JS)
</label>
<label>
Dynamically load comments:
@checkbox("DynamicLoadComments", *prefs.DynamicLoadComments)
(requires JS)
</label>
<label>
Keep player focus:
@checkbox("KeepPlayerFocus", *prefs.KeepPlayerFocus)
(requires JS)
</label>
<h2 style="margin-bottom: .35rem">Autoplay</h2>
<i>Requires JS. You also need to allow autoplay from this domain</i>
<label style="margin-top: 1rem">
Autoplay next track in playlists:
@checkbox("AutoplayNextTrack", *prefs.AutoplayNextTrack)
</label>
if *prefs.AutoplayNextTrack {
<label>
Default autoplay mode (in playlists):
@sel("DefaultAutoplayMode", []option{
{"normal", "Normal (play songs in order)", false},
{"random", "Random (play random song)", false},
}, *prefs.DefaultAutoplayMode)
</label>
}
<label>
Autoplay next related track:
@checkbox("AutoplayNextRelatedTrack", *prefs.AutoplayNextRelatedTrack)
</label>
<input type="submit" value="Update" class="btn" style="margin-top: 1rem;"/>
<p>These preferences get saved in a cookie.</p>
</form>
@@ -132,6 +142,5 @@ 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>
}

View File

@@ -47,22 +47,36 @@ templ TrackHeader(prefs cfg.Preferences, t sc.Track, needPlayer bool) {
<meta name="og:description" content={ t.FormatDescription() }/>
<meta name="og:image" content={ t.Artwork }/>
<link rel="icon" type="image/x-icon" href={ t.Artwork }/>
if needPlayer && *prefs.Player == cfg.HLSPlayer {
<script src="/_/static/js/hls.light.min.js"></script>
if needPlayer {
if *prefs.Player == cfg.HLSPlayer {
<script src="/_/static/external/hls.light.min.js"></script>
}
}
}
func next(t *sc.Track, p *sc.Playlist, autoplay bool, mode string, volume string) string {
r := t.Href() + "?playlist=" + p.Href()[1:]
if autoplay {
r += "&autoplay=true"
}
if mode != "" {
r += "&mode=" + mode
func next(c *sc.Track, t *sc.Track, p *sc.Playlist, mode string, volume string) string {
r := t.Href()
if p != nil {
r += "?playlist=" + p.Href()[1:]
if mode != "" {
r += "&mode=" + mode
}
r += "&"
} else {
r += "?"
if c != nil {
r += "prev=" + string(c.ID) + "&"
}
}
r += "autoplay=true"
if volume != "" {
r += "&volume=" + volume
}
return r
}
@@ -71,19 +85,22 @@ templ TrackPlayer(prefs cfg.Preferences, track sc.Track, stream string, displayE
{{ return }}
}
if displayErr == "" {
{{ var audioPref string }}
{{ var audioPref *string }}
if cfg.Restream && *prefs.Player == cfg.RestreamPlayer {
{{ audioPref = *prefs.RestreamAudio }}
{{ audioPref = prefs.RestreamAudio }}
if nextTrack != nil {
<audio id="track" src={ "/_/restream" + track.Href() } controls autoplay?={ autoplay } data-next={ next(nextTrack, playlist, true, mode, "") } volume={ volume }></audio>
<audio id="track" src={ "/_/restream" + track.Href() } controls autoplay?={ autoplay } data-next={ next(&track, nextTrack, playlist, mode, "") } volume={ volume }></audio>
<script async src="/_/static/restream.js"></script>
} else {
<audio src={ "/_/restream" + track.Href() } controls autoplay?={ autoplay }></audio>
<audio id="track" src={ "/_/restream" + track.Href() } controls autoplay?={ autoplay }></audio>
}
if *prefs.KeepPlayerFocus {
<script async src="/_/static/keepfocus.js"></script>
}
} else if stream != "" {
{{ audioPref = *prefs.HLSAudio }}
{{ audioPref = prefs.HLSAudio }}
if nextTrack != nil {
<audio id="track" src={ stream } controls autoplay?={ autoplay } data-next={ next(nextTrack, playlist, true, mode, "") } volume={ volume }></audio>
<audio id="track" src={ stream } controls autoplay?={ autoplay } data-next={ next(&track, nextTrack, playlist, mode, "") } volume={ volume }></audio>
} else {
<audio id="track" src={ stream } controls autoplay?={ autoplay }></audio>
}
@@ -92,6 +109,9 @@ templ TrackPlayer(prefs cfg.Preferences, track sc.Track, stream string, displayE
} else {
<script async src="/_/static/player.js"></script>
}
if *prefs.KeepPlayerFocus {
<script async src="/_/static/keepfocus.js"></script>
}
<noscript>
<br/>
JavaScript is disabled! Audio playback may not work without it enabled.
@@ -108,7 +128,7 @@ templ TrackPlayer(prefs cfg.Preferences, track sc.Track, stream string, displayE
}
if *prefs.ShowAudio {
<div>
if audioPref == cfg.AudioBest {
if *audioPref == cfg.AudioBest {
<p>Audio: best ({ audio })</p>
} else {
<p>Audio: { audio }</p>
@@ -151,7 +171,7 @@ templ Track(prefs cfg.Preferences, t sc.Track, stream string, displayErr string,
}
<h1>{ t.Title }</h1>
@TrackPlayer(prefs, t, stream, displayErr, autoplay, nextTrack, playlist, volume, mode, audio)
if cfg.Restream {
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>
</div>
@@ -159,19 +179,25 @@ templ Track(prefs cfg.Preferences, t sc.Track, stream string, displayErr string,
if t.Genre != "" {
<a href={ templ.SafeURL("/tags/" + t.Genre) }><p class="tag">{ t.Genre }</p></a>
}
if playlist != nil {
if nextTrack != nil {
<details open style="margin-bottom: 1rem;">
<summary>Playback info</summary>
<h2>In playlist:</h2>
@PlaylistItem(playlist, true)
if playlist != nil {
<h2>In playlist:</h2>
@PlaylistItem(playlist, true)
}
<h2>Next track:</h2>
@TrackItem(nextTrack, true, next(nextTrack, playlist, true, mode, volume))
@TrackItem(nextTrack, true, next(&t, nextTrack, playlist, mode, volume))
<div style="display: flex; gap: 1rem">
<a href={ templ.SafeURL(t.Href()) } class="btn">Stop playlist playback</a>
if mode != cfg.AutoplayRandom {
<a href={ templ.SafeURL(next(&t, playlist, false, cfg.AutoplayRandom, volume)) } class="btn">Switch to random mode</a>
if playlist != nil {
<a href={ templ.SafeURL(t.Href()) } class="btn">Stop playlist playback</a>
if mode != cfg.AutoplayRandom {
<a href={ templ.SafeURL(next(nil, &t, playlist, cfg.AutoplayRandom, volume)) } class="btn">Switch to random mode</a>
} else {
<a href={ templ.SafeURL(next(nil, &t, playlist, cfg.AutoplayNormal, volume)) } class="btn">Switch to normal mode</a>
}
} else {
<a href={ templ.SafeURL(next(&t, playlist, false, cfg.AutoplayNormal, volume)) } class="btn">Switch to normal mode</a>
<a href={ templ.SafeURL(t.Href() + "?playRelated=false") } class="btn">Stop playback</a>
}
</div>
</details>
@@ -194,6 +220,7 @@ templ Track(prefs cfg.Preferences, t sc.Track, stream string, displayErr string,
if t.License != "" {
<p>License: { t.License }</p>
}
<p>Policy: { t.Policy }</p>
if t.TagList != "" {
<p>Tags: { strings.Join(sc.TagListParser(t.TagList), ", ") }</p>
}
@@ -251,7 +278,7 @@ templ TrackEmbed(prefs cfg.Preferences, t sc.Track, stream string, displayErr st
<link rel="stylesheet" href="/_/static/global.css"/>
<title>soundcloak</title>
if *prefs.Player == cfg.HLSPlayer && stream != "" {
<script src="/_/static/js/hls.light.js"></script>
<script src="/_/static/external/hls.light.min.js"></script>
}
</head>
<body>

View File

@@ -5,7 +5,6 @@ import (
"git.maid.zone/stuff/soundcloak/lib/sc"
"net/url"
"strconv"
"strings"
)
templ UserHeader(u sc.User) {
@@ -115,7 +114,7 @@ templ User(prefs cfg.Preferences, u sc.User, p *sc.Paginated[*sc.Track]) {
}
</div>
if p.Next != "" && len(p.Collection) != int(u.Tracks) {
<a class="btn" href={ templ.SafeURL("?pagination=" + url.QueryEscape(strings.Split(p.Next, "/tracks")[1])) } rel="noreferrer">more tracks</a>
<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>
}
} else {
<span>no more tracks</span>
@@ -133,7 +132,7 @@ templ UserPlaylists(prefs cfg.Preferences, u sc.User, p *sc.Paginated[*sc.Playli
}
</div>
if p.Next != "" && len(p.Collection) != int(p.Total) {
<a class="btn" href={ templ.SafeURL("?pagination=" + url.QueryEscape(strings.Split(p.Next, "/playlists_without_albums")[1])) } rel="noreferrer">more playlists</a>
<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>
}
} else {
<span>no more playlists</span>
@@ -151,7 +150,7 @@ templ UserAlbums(prefs cfg.Preferences, u sc.User, p *sc.Paginated[*sc.Playlist]
}
</div>
if p.Next != "" && len(p.Collection) != int(p.Total) {
<a class="btn" href={ templ.SafeURL("?pagination=" + url.QueryEscape(strings.Split(p.Next, "/albums")[1])) } rel="noreferrer">more albums</a>
<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>
}
} else {
<span>no more albums</span>
@@ -173,7 +172,7 @@ 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(strings.Split(p.Next, "/reposts")[1])) } rel="noreferrer">more reposts</a>
<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>
}
} else {
<span>no more reposts</span>
@@ -195,7 +194,7 @@ 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(strings.Split(p.Next, "/likes")[1])) } rel="noreferrer">more likes</a>
<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>
}
} else {
<span>no more likes</span>
@@ -243,7 +242,7 @@ templ UserFollowers(prefs cfg.Preferences, u sc.User, p *sc.Paginated[*sc.User])
}
</div>
if p.Next != "" && len(p.Collection) != int(p.Total) {
<a class="btn" href={ templ.SafeURL("?pagination=" + url.QueryEscape(strings.Split(p.Next, "/followers")[1])) } rel="noreferrer">more users</a>
<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>
}
} else {
<span>no more users</span>
@@ -261,7 +260,7 @@ templ UserFollowing(prefs cfg.Preferences, u sc.User, p *sc.Paginated[*sc.User])
}
</div>
if p.Next != "" && len(p.Collection) != int(p.Total) {
<a class="btn" href={ templ.SafeURL("?pagination=" + url.QueryEscape(strings.Split(p.Next, "/following")[1])) } rel="noreferrer">more users</a>
<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>
}
} else {
<span>no more users</span>
@@ -281,7 +280,7 @@ templ SearchUsers(p *sc.Paginated[*sc.User]) {
@UserItem(user)
}
if p.Next != "" && len(p.Collection) != int(p.Total) {
<a class="btn" href={ templ.SafeURL("?type=users&pagination=" + url.QueryEscape(strings.Split(p.Next, "/users")[1])) } rel="noreferrer">more users</a>
<a class="btn" href={ templ.SafeURL("?type=users&pagination=" + url.QueryEscape(p.Next[sc.H+len("/search/users"):])) } rel="noreferrer">more users</a>
}
}
}