mirror of
https://git.maid.zone/stuff/soundcloak.git
synced 2025-12-10 05:39:38 +05:00
Shuffle mode for autoplay; fix proxying with autoplay
This commit is contained in:
48
README.md
48
README.md
@@ -38,7 +38,7 @@ Available features:
|
||||
- - Proxy streams: Retrieve song pieces through the instance, instead of going to soundcloud's servers for them
|
||||
- - Fully preload track: Fully loads the track when you load the page instead of buffering a small part of it
|
||||
- - Autoplay next track in playlists: self-explanatory
|
||||
|
||||
- - Default autoplay mode: Default mode for autoplaying. Can be normal (play songs in order) or random (play random song)
|
||||
# Contributing
|
||||
|
||||
Contributions are appreciated! This includes feedback, feature requests, issues, pull requests and etc.
|
||||
@@ -189,29 +189,29 @@ Some notes:
|
||||
- When specifying time, specify it in seconds.
|
||||
|
||||
|
||||
| JSON key | Environment variable | Default value | Description |
|
||||
| :------------------------ | ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| None | SOUNDCLOAK_CONFIG | soundcloak.json | File to load soundcloak config from. If set to`FROM_ENV`, soundcloak loads the config from environment variables. |
|
||||
| GetWebProfiles | GET_WEB_PROFILES | true | Retrieve links users set in their profile (social media, website, etc) |
|
||||
| DefaultPreferences | DEFAULT_PREFERENCES | {"Player": "hls", "ProxyStreams": false, "FullyPreloadTrack": false, "ProxyImages": false, "ParseDescriptions": true, "AutoplayNextTrack": false} | see /_/preferences page, default values adapt to your config (Player: "restream" if Restream, else "hls", ProxyStreams and ProxyImages will be same as respective config values) |
|
||||
| ProxyImages | PROXY_IMAGES | false | Enables proxying of images (user avatars, track covers etc) |
|
||||
| ImageCacheControl | IMAGE_CACHE_CONTROL | max-age=600, public, immutable | [Cache-Control](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control) header value for proxied images. Cached for 10 minutes by default. |
|
||||
| ProxyStreams | PROXY_STREAMS | false | Enables proxying of song parts and hls playlist files |
|
||||
| Restream | RESTREAM | false | Enables Restream Player in settings and the /_/restream/:author/:track endpoint. This player can be used without JavaScript. Restream also enables the button for downloading songs. |
|
||||
| RestreamCacheControl | RESTREAM_CACHE_CONTROL | max-age=3600, public, immutable | [Cache-Control](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control) header value for restreamed songs. Cached for 1 hour by default. |
|
||||
| ClientIDTTL | CLIENT_ID_TTL | 4 hours | Time until ClientID cache expires. ClientID is used for authenticating with SoundCloud API |
|
||||
| UserTTL | USER_TTL | 20 minutes | Time until User profile cache expires |
|
||||
| UserCacheCleanDelay | USER_CACHE_CLEAN_DELAY | 5 minutes | Time between each cleanup of the cache (to remove expired users) |
|
||||
| TrackTTL | TRACK_TTL | 20 minutes | Time until Track data cache expires |
|
||||
| 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 |
|
||||
| DNSCacheTTL | DNS_CACHE_TTL | 60 minutes | Time until DNS cache expires |
|
||||
| Addr | ADDR | :4664 | Address and port for soundcloak to listen on |
|
||||
| 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 |
|
||||
| JSON key | Environment variable | Default value | Description |
|
||||
| :------------------------ | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| None | SOUNDCLOAK_CONFIG | soundcloak.json | File to load soundcloak config from. If set to`FROM_ENV`, soundcloak loads the config from environment variables. |
|
||||
| GetWebProfiles | GET_WEB_PROFILES | true | Retrieve links users set in their profile (social media, website, etc) |
|
||||
| DefaultPreferences | DEFAULT_PREFERENCES | {"Player": "hls", "ProxyStreams": false, "FullyPreloadTrack": false, "ProxyImages": false, "ParseDescriptions": true, "AutoplayNextTrack": false, "DefaultAutoplayMode": "normal"} | see /_/preferences page, default values adapt to your config (Player: "restream" if Restream, else "hls", ProxyStreams and ProxyImages will be same as respective config values) |
|
||||
| ProxyImages | PROXY_IMAGES | false | Enables proxying of images (user avatars, track covers etc) |
|
||||
| ImageCacheControl | IMAGE_CACHE_CONTROL | max-age=600, public, immutable | [Cache-Control](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control) header value for proxied images. Cached for 10 minutes by default. |
|
||||
| ProxyStreams | PROXY_STREAMS | false | Enables proxying of song parts and hls playlist files |
|
||||
| Restream | RESTREAM | false | Enables Restream Player in settings and the /_/restream/:author/:track endpoint. This player can be used without JavaScript. Restream also enables the button for downloading songs. |
|
||||
| RestreamCacheControl | RESTREAM_CACHE_CONTROL | max-age=3600, public, immutable | [Cache-Control](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control) header value for restreamed songs. Cached for 1 hour by default. |
|
||||
| ClientIDTTL | CLIENT_ID_TTL | 4 hours | Time until ClientID cache expires. ClientID is used for authenticating with SoundCloud API |
|
||||
| UserTTL | USER_TTL | 20 minutes | Time until User profile cache expires |
|
||||
| UserCacheCleanDelay | USER_CACHE_CLEAN_DELAY | 5 minutes | Time between each cleanup of the cache (to remove expired users) |
|
||||
| TrackTTL | TRACK_TTL | 20 minutes | Time until Track data cache expires |
|
||||
| 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 |
|
||||
| DNSCacheTTL | DNS_CACHE_TTL | 60 minutes | Time until DNS cache expires |
|
||||
| Addr | ADDR | :4664 | Address and port for soundcloak to listen on |
|
||||
| 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 |
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
@@ -25,6 +25,13 @@ const (
|
||||
NonePlayer string = "none"
|
||||
)
|
||||
|
||||
const (
|
||||
// Just plays every song in order, one after another
|
||||
AutoplayNormal string = "normal"
|
||||
// Randomly selects a song to play from the playlist
|
||||
AutoplayRandom string = "random"
|
||||
)
|
||||
|
||||
type Preferences struct {
|
||||
Player *string
|
||||
ProxyStreams *bool
|
||||
@@ -41,6 +48,8 @@ type Preferences struct {
|
||||
|
||||
// Automatically play next track in playlists
|
||||
AutoplayNextTrack *bool
|
||||
|
||||
DefaultAutoplayMode *string
|
||||
}
|
||||
|
||||
// // config // //
|
||||
@@ -123,6 +132,7 @@ var TrustedProxies = []string{}
|
||||
// ProxyImages: same as ProxyImages in your config (false by default)
|
||||
// ParseDescriptions: true
|
||||
// AutoplayNextTrack: false
|
||||
// DefaultAutoplayMode: AutoplayNormal
|
||||
func defaultPreferences() {
|
||||
var p string
|
||||
if Restream {
|
||||
@@ -134,19 +144,18 @@ func defaultPreferences() {
|
||||
|
||||
DefaultPreferences.ProxyStreams = &ProxyStreams
|
||||
|
||||
var f bool
|
||||
var t = true
|
||||
DefaultPreferences.FullyPreloadTrack = &f
|
||||
DefaultPreferences.FullyPreloadTrack = &False
|
||||
|
||||
DefaultPreferences.ProxyImages = &ProxyImages
|
||||
|
||||
DefaultPreferences.ParseDescriptions = &t
|
||||
DefaultPreferences.AutoplayNextTrack = &f
|
||||
DefaultPreferences.ParseDescriptions = &True
|
||||
DefaultPreferences.AutoplayNextTrack = &False
|
||||
|
||||
p2 := AutoplayNormal
|
||||
DefaultPreferences.DefaultAutoplayMode = &p2
|
||||
}
|
||||
|
||||
func loadDefaultPreferences(loaded Preferences) {
|
||||
var f bool
|
||||
var t = true
|
||||
if loaded.Player != nil {
|
||||
DefaultPreferences.Player = loaded.Player
|
||||
} else {
|
||||
@@ -168,7 +177,7 @@ func loadDefaultPreferences(loaded Preferences) {
|
||||
if loaded.FullyPreloadTrack != nil {
|
||||
DefaultPreferences.FullyPreloadTrack = loaded.FullyPreloadTrack
|
||||
} else {
|
||||
DefaultPreferences.FullyPreloadTrack = &f
|
||||
DefaultPreferences.FullyPreloadTrack = &False
|
||||
}
|
||||
|
||||
if loaded.ProxyImages != nil {
|
||||
@@ -180,13 +189,20 @@ func loadDefaultPreferences(loaded Preferences) {
|
||||
if loaded.ParseDescriptions != nil {
|
||||
DefaultPreferences.ParseDescriptions = loaded.ParseDescriptions
|
||||
} else {
|
||||
DefaultPreferences.ParseDescriptions = &t
|
||||
DefaultPreferences.ParseDescriptions = &True
|
||||
}
|
||||
|
||||
if loaded.AutoplayNextTrack != nil {
|
||||
DefaultPreferences.AutoplayNextTrack = loaded.AutoplayNextTrack
|
||||
} else {
|
||||
DefaultPreferences.AutoplayNextTrack = &f
|
||||
DefaultPreferences.AutoplayNextTrack = &False
|
||||
}
|
||||
|
||||
if loaded.DefaultAutoplayMode != nil {
|
||||
DefaultPreferences.DefaultAutoplayMode = loaded.DefaultAutoplayMode
|
||||
} else {
|
||||
p := AutoplayNormal
|
||||
DefaultPreferences.DefaultAutoplayMode = &p
|
||||
}
|
||||
}
|
||||
|
||||
@@ -495,3 +511,6 @@ func init() {
|
||||
// seems soundcloud has 4 of these (i1, i2, i3, i4)
|
||||
// they point to the same ip from my observations, and they all serve the same files
|
||||
const ImageCDN = "i1.sndcdn.com"
|
||||
|
||||
var True = true
|
||||
var False = false
|
||||
|
||||
@@ -34,6 +34,10 @@ func Defaults(dst *cfg.Preferences) {
|
||||
if dst.AutoplayNextTrack == nil {
|
||||
dst.AutoplayNextTrack = cfg.DefaultPreferences.AutoplayNextTrack
|
||||
}
|
||||
|
||||
if dst.DefaultAutoplayMode == nil {
|
||||
dst.DefaultAutoplayMode = cfg.DefaultPreferences.DefaultAutoplayMode
|
||||
}
|
||||
}
|
||||
|
||||
func Get(c *fiber.Ctx) (cfg.Preferences, error) {
|
||||
@@ -50,12 +54,13 @@ func Get(c *fiber.Ctx) (cfg.Preferences, error) {
|
||||
}
|
||||
|
||||
type PrefsForm struct {
|
||||
ProxyImages string
|
||||
ParseDescriptions string
|
||||
Player string
|
||||
ProxyStreams string
|
||||
FullyPreloadTrack string
|
||||
AutoplayNextTrack string
|
||||
ProxyImages string
|
||||
ParseDescriptions string
|
||||
Player string
|
||||
ProxyStreams string
|
||||
FullyPreloadTrack string
|
||||
AutoplayNextTrack string
|
||||
DefaultAutoplayMode string
|
||||
}
|
||||
|
||||
func Load(r fiber.Router) {
|
||||
@@ -81,27 +86,25 @@ func Load(r fiber.Router) {
|
||||
return err
|
||||
}
|
||||
|
||||
var f bool
|
||||
var t bool = true
|
||||
if *old.Player == "hls" {
|
||||
if cfg.ProxyStreams {
|
||||
if p.ProxyStreams == "on" {
|
||||
old.ProxyStreams = &cfg.ProxyStreams // true!
|
||||
} else if p.ProxyStreams == "" {
|
||||
old.ProxyStreams = &f
|
||||
old.ProxyStreams = &cfg.False
|
||||
}
|
||||
|
||||
if p.AutoplayNextTrack == "on" {
|
||||
old.AutoplayNextTrack = &t
|
||||
old.AutoplayNextTrack = &cfg.True
|
||||
} else {
|
||||
old.AutoplayNextTrack = &f
|
||||
old.AutoplayNextTrack = &cfg.False
|
||||
}
|
||||
}
|
||||
|
||||
if p.FullyPreloadTrack == "on" {
|
||||
old.FullyPreloadTrack = &t
|
||||
old.FullyPreloadTrack = &cfg.True
|
||||
} else if p.FullyPreloadTrack == "" {
|
||||
old.FullyPreloadTrack = &f
|
||||
old.FullyPreloadTrack = &cfg.False
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,14 +112,18 @@ func Load(r fiber.Router) {
|
||||
if p.ProxyImages == "on" {
|
||||
old.ProxyImages = &cfg.ProxyImages // true!
|
||||
} else if p.ProxyImages == "" {
|
||||
old.ProxyImages = &f
|
||||
old.ProxyImages = &cfg.False
|
||||
}
|
||||
}
|
||||
|
||||
if p.ParseDescriptions == "on" {
|
||||
old.ParseDescriptions = &t
|
||||
old.ParseDescriptions = &cfg.True
|
||||
} else {
|
||||
old.ParseDescriptions = &f
|
||||
old.ParseDescriptions = &cfg.False
|
||||
}
|
||||
|
||||
if *old.AutoplayNextTrack {
|
||||
old.DefaultAutoplayMode = &p.DefaultAutoplayMode
|
||||
}
|
||||
old.Player = &p.Player
|
||||
|
||||
|
||||
25
main.go
25
main.go
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"math/rand"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
@@ -369,33 +370,39 @@ func main() {
|
||||
|
||||
var playlist *sc.Playlist
|
||||
var nextTrack *sc.Track
|
||||
mode := c.Query("mode", *prefs.DefaultAutoplayMode)
|
||||
if pl := c.Query("playlist"); pl != "" {
|
||||
p, err := sc.GetPlaylist(pl)
|
||||
|
||||
if err != nil {
|
||||
log.Printf("error getting %s playlist (track): %s\n", pl, err)
|
||||
return err
|
||||
}
|
||||
|
||||
p.Tracks = p.Postfix(prefs, true, false)
|
||||
|
||||
nextIndex := -1
|
||||
for i, t := range p.Tracks {
|
||||
if t.ID == track.ID {
|
||||
nextIndex = i + 1
|
||||
if mode == cfg.AutoplayRandom {
|
||||
nextIndex = rand.Intn(len(p.Tracks))
|
||||
} else {
|
||||
for i, t := range p.Tracks {
|
||||
if t.ID == track.ID {
|
||||
nextIndex = i + 1
|
||||
}
|
||||
}
|
||||
|
||||
if nextIndex == len(p.Tracks) {
|
||||
nextIndex = 0
|
||||
}
|
||||
}
|
||||
|
||||
if nextIndex != -1 {
|
||||
if nextIndex == len(p.Tracks) {
|
||||
nextIndex = 0
|
||||
}
|
||||
|
||||
nextTrack = &p.Tracks[nextIndex]
|
||||
playlist = &p
|
||||
}
|
||||
}
|
||||
|
||||
c.Set("Content-Type", "text/html")
|
||||
return templates.Base(track.Title+" by "+track.Author.Username, templates.Track(prefs, track, stream, displayErr, c.Query("autoplay") == "true", playlist, nextTrack, c.Query("volume")), templates.TrackHeader(prefs, track)).Render(context.Background(), c)
|
||||
return templates.Base(track.Title+" by "+track.Author.Username, templates.Track(prefs, track, stream, displayErr, c.Query("autoplay") == "true", playlist, nextTrack, c.Query("volume"), mode), templates.TrackHeader(prefs, track)).Render(context.Background(), c)
|
||||
})
|
||||
|
||||
app.Get("/:user", func(c *fiber.Ctx) error {
|
||||
|
||||
@@ -72,6 +72,16 @@ templ Preferences(prefs cfg.Preferences) {
|
||||
Autoplay next track in playlists:
|
||||
@checkbox("AutoplayNextTrack", *prefs.AutoplayNextTrack)
|
||||
</label>
|
||||
if *prefs.AutoplayNextTrack {
|
||||
<br/>
|
||||
<label>
|
||||
Default autoplay mode:
|
||||
@sel("DefaultAutoplayMode", []option{
|
||||
{"normal", "Normal (play songs in order)", false},
|
||||
{"random", "Random (play random song)", false},
|
||||
}, *prefs.DefaultAutoplayMode)
|
||||
</label>
|
||||
}
|
||||
<br/>
|
||||
}
|
||||
<input type="submit" value="Update" class="btn" style="margin-top: 1rem;"/>
|
||||
|
||||
@@ -19,11 +19,21 @@ templ TrackHeader(prefs cfg.Preferences, t sc.Track) {
|
||||
}
|
||||
}
|
||||
|
||||
func next(t *sc.Track, p *sc.Playlist) string {
|
||||
return t.Href() + "?autoplay=true&playlist=" + p.Href()[1:]
|
||||
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
|
||||
}
|
||||
if volume != "" {
|
||||
r += "&volume=" + volume
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
templ TrackPlayer(prefs cfg.Preferences, track sc.Track, stream string, displayErr string, autoplay bool, nextTrack *sc.Track, playlist *sc.Playlist, volume string) {
|
||||
templ TrackPlayer(prefs cfg.Preferences, track sc.Track, stream string, displayErr string, autoplay bool, nextTrack *sc.Track, playlist *sc.Playlist, volume string, mode string) {
|
||||
if *prefs.Player == cfg.NonePlayer {
|
||||
{{ return }}
|
||||
}
|
||||
@@ -32,7 +42,7 @@ templ TrackPlayer(prefs cfg.Preferences, track sc.Track, stream string, displayE
|
||||
<audio src={ "/_/restream" + track.Href() } controls autoplay?={ autoplay }></audio>
|
||||
} else if stream != "" {
|
||||
if nextTrack != nil {
|
||||
<audio id="track" src={ stream } controls autoplay?={ autoplay } data-next={next(nextTrack, playlist)} volume={volume}></audio>
|
||||
<audio id="track" src={ stream } controls autoplay?={ autoplay } data-next={next(nextTrack, playlist, true, mode, "")} volume={volume}></audio>
|
||||
} else {
|
||||
<audio id="track" src={ stream } controls autoplay?={ autoplay }></audio>
|
||||
}
|
||||
@@ -78,12 +88,12 @@ templ TrackItem(track *sc.Track, showUsername bool, overrideHref string) {
|
||||
}
|
||||
}
|
||||
|
||||
templ Track(prefs cfg.Preferences, t sc.Track, stream string, displayErr string, autoplay bool, playlist *sc.Playlist, nextTrack *sc.Track, volume string) {
|
||||
templ Track(prefs cfg.Preferences, t sc.Track, stream string, displayErr string, autoplay bool, playlist *sc.Playlist, nextTrack *sc.Track, volume string, mode string) {
|
||||
if t.Artwork != "" {
|
||||
<img src={ t.Artwork } width="300px"/>
|
||||
}
|
||||
<h1>{ t.Title }</h1>
|
||||
@TrackPlayer(prefs, t, stream, displayErr, autoplay, nextTrack, playlist, volume)
|
||||
@TrackPlayer(prefs, t, stream, displayErr, autoplay, nextTrack, playlist, volume, mode)
|
||||
if t.Genre != "" {
|
||||
<p class="tag">{ t.Genre }</p>
|
||||
} else {
|
||||
@@ -91,16 +101,22 @@ templ Track(prefs cfg.Preferences, t sc.Track, stream string, displayErr string,
|
||||
<br/>
|
||||
}
|
||||
if playlist != nil {
|
||||
<details style="margin-bottom: 1rem;">
|
||||
<details open style="margin-bottom: 1rem;">
|
||||
<summary>Playback info</summary>
|
||||
|
||||
<h2>In playlist:</h2>
|
||||
@PlaylistItem(playlist, true)
|
||||
|
||||
<h2>Next track:</h2>
|
||||
@TrackItem(nextTrack, true, next(nextTrack, playlist) + "&volume=" + volume)
|
||||
@TrackItem(nextTrack, true, next(nextTrack, playlist, true, mode, volume))
|
||||
|
||||
<a href={templ.URL(t.Href())} class="link">Stop playlist playback</a>
|
||||
<br>
|
||||
if mode != cfg.AutoplayRandom {
|
||||
<a href={templ.URL(next(&t, playlist, false, cfg.AutoplayRandom, volume))} class="link">Switch to random mode</a>
|
||||
} else {
|
||||
<a href={templ.URL(next(&t, playlist, false, cfg.AutoplayNormal, volume))} class="link">Switch to normal mode</a>
|
||||
}
|
||||
</details>
|
||||
}
|
||||
@UserItem(&t.Author)
|
||||
@@ -142,7 +158,7 @@ templ TrackEmbed(prefs cfg.Preferences, t sc.Track, stream string, displayErr st
|
||||
<img src={ t.Artwork } width="300px"/>
|
||||
}
|
||||
<h1>{ t.Title }</h1>
|
||||
@TrackPlayer(prefs, t, stream, displayErr, false, nil, nil, "")
|
||||
@TrackPlayer(prefs, t, stream, displayErr, false, nil, nil, "", "")
|
||||
@UserItem(&t.Author)
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user