Autoplay tracks in playlists; increase caching time; some fixes

This commit is contained in:
Laptop
2024-12-01 23:33:01 +02:00
parent 79da855e9e
commit 96ac9c8b36
13 changed files with 173 additions and 55 deletions

View File

@@ -37,6 +37,7 @@ Available features:
- Player-specific settings: They will only show up if you have selected HLS player currently.
- - 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
# Contributing
@@ -188,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} | 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 | 30 minutes | Time until ClientID cache expires. ClientID is used for authenticating with SoundCloud API |
| UserTTL | USER_TTL | 10 minutes | Time until User profile cache expires |
| UserCacheCleanDelay | USER_CACHE_CLEAN_DELAY | 2.5 minutes | Time between each cleanup of the cache (to remove expired users) |
| TrackTTL | TRACK_TTL | 10 minutes | Time until Track data cache expires |
| TrackCacheCleanDelay | TRACK_CACHE_CLEAN_DELAY | 2.5 minutes | Time between each cleanup of the cache (to remove expired tracks) |
| PlaylistTTL | PLAYLIST_TTL | 10 minutes | Time until Playlist data cache expires |
| PlaylistCacheCleanDelay | PLAYLIST_CACHE_CLEAN_DELAY | 2.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 | 10 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} | 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>

View File

@@ -3,6 +3,18 @@ if (Hls.isSupported()) {
var hls = new Hls();
hls.loadSource(audio.src);
hls.attachMedia(audio);
var volume = audio.getAttribute('volume');
if (volume) {
audio.volume = parseFloat(volume);
}
} else if (!audio.canPlayType('application/vnd.apple.mpegurl')) {
alert('HLS is not supported! Audio playback will not work.');
}
var next = audio.getAttribute('data-next');
if (next) {
audio.addEventListener('ended', function() {
location = next + '&volume=' + audio.volume;
});
}

View File

@@ -3,6 +3,18 @@ if (Hls.isSupported()) {
var hls = new Hls({ maxBufferLength: Infinity });
hls.loadSource(audio.src);
hls.attachMedia(audio);
var volume = audio.getAttribute('volume');
if (volume) {
audio.volume = parseFloat(volume);
}
} else if (!audio.canPlayType('application/vnd.apple.mpegurl')) {
alert('HLS is not supported! Audio playback will not work.');
}
var next = audio.getAttribute('data-next');
if (next) {
audio.addEventListener('ended', function() {
location = next + '&volume=' + audio.volume;
});
}

View File

@@ -38,6 +38,9 @@ type Preferences struct {
// Highlight @username, https://example.com and email@example.com in text as clickable links
ParseDescriptions *bool
// Automatically play next track in playlists
AutoplayNextTrack *bool
}
// // config // //
@@ -67,22 +70,23 @@ var InstanceInfo = true
// time-to-live for clientid cache
// larger number will improve performance (no need to recheck everytime) but might make soundcloak briefly unusable for a larger amount of time if the client id is invalidated
var ClientIDTTL = 30 * time.Minute
// I went with 4 hours, since those clientids still remain active for quite some time, even after soundcloud updates it
var ClientIDTTL = 4 * time.Hour
// time-to-live for user profile cache
var UserTTL = 10 * time.Minute
var UserTTL = 20 * time.Minute
// delay between cleanup of user cache
var UserCacheCleanDelay = UserTTL / 4
// time-to-live for track cache
var TrackTTL = 10 * time.Minute
var TrackTTL = 20 * time.Minute
// delay between cleanup of track cache
var TrackCacheCleanDelay = TrackTTL / 4
// time-to-live for playlist cache
var PlaylistTTL = 10 * time.Minute
var PlaylistTTL = 20 * time.Minute
// delay between cleanup of playlist cache
var PlaylistCacheCleanDelay = PlaylistTTL / 4
@@ -91,7 +95,7 @@ var PlaylistCacheCleanDelay = PlaylistTTL / 4
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"
// time-to-live for dns cache
var DNSCacheTTL = 10 * time.Minute
var DNSCacheTTL = 60 * time.Minute
// // // 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
@@ -118,6 +122,7 @@ var TrustedProxies = []string{}
// FullyPreloadTrack: false
// ProxyImages: same as ProxyImages in your config (false by default)
// ParseDescriptions: true
// AutoplayNextTrack: false
func defaultPreferences() {
var p string
if Restream {
@@ -136,6 +141,7 @@ func defaultPreferences() {
DefaultPreferences.ProxyImages = &ProxyImages
DefaultPreferences.ParseDescriptions = &t
DefaultPreferences.AutoplayNextTrack = &f
}
func loadDefaultPreferences(loaded Preferences) {
@@ -176,6 +182,12 @@ func loadDefaultPreferences(loaded Preferences) {
} else {
DefaultPreferences.ParseDescriptions = &t
}
if loaded.AutoplayNextTrack != nil {
DefaultPreferences.AutoplayNextTrack = loaded.AutoplayNextTrack
} else {
DefaultPreferences.AutoplayNextTrack = &f
}
}
func boolean(in string) bool {

View File

@@ -30,6 +30,10 @@ func Defaults(dst *cfg.Preferences) {
if dst.ParseDescriptions == nil {
dst.ParseDescriptions = cfg.DefaultPreferences.ParseDescriptions
}
if dst.AutoplayNextTrack == nil {
dst.AutoplayNextTrack = cfg.DefaultPreferences.AutoplayNextTrack
}
}
func Get(c *fiber.Ctx) (cfg.Preferences, error) {
@@ -51,6 +55,7 @@ type PrefsForm struct {
Player string
ProxyStreams string
FullyPreloadTrack string
AutoplayNextTrack string
}
func Load(r fiber.Router) {
@@ -85,6 +90,12 @@ func Load(r fiber.Router) {
} else if p.ProxyStreams == "" {
old.ProxyStreams = &f
}
if p.AutoplayNextTrack == "on" {
old.AutoplayNextTrack = &t
} else {
old.AutoplayNextTrack = &f
}
}
if p.FullyPreloadTrack == "on" {

View File

@@ -187,7 +187,7 @@ func (p *Playlist) GetMissingTracks() error {
missing := []MissingTrack{}
for i, track := range p.Tracks {
if track.Title == "" {
missing = append(missing, MissingTrack{ID: track.ID, Index: i})
missing = append(missing, MissingTrack{ID: strconv.FormatInt(int64(track.IDint), 10), Index: i})
}
}
@@ -212,3 +212,7 @@ func (p *Playlist) GetMissingTracks() error {
return nil
}
func (p Playlist) Href() string {
return "/" + p.Author.Permalink + "/sets/" + p.Permalink
}

View File

@@ -433,3 +433,7 @@ func (t Track) DownloadImage() ([]byte, string, error) {
return data, string(resp.Header.Peek("Content-Type")), nil
}
func (t Track) Href() string {
return "/" + t.Author.Permalink + "/" + t.Permalink
}

33
main.go
View File

@@ -34,7 +34,7 @@ func main() {
app.Use(recover.New())
app.Use(compress.New(compress.Config{Level: compress.LevelBestSpeed}))
app.Static("/", "assets", fiber.Static{Compress: true, MaxAge: 3600}) // 1hour
app.Static("/", "assets", fiber.Static{Compress: true, MaxAge: 7200}) // 2 hours
app.Static("/js/hls.js/", "node_modules/hls.js/dist", fiber.Static{Compress: true, MaxAge: 28800}) // 8 hours
// Just for easy inspection of cache in development. Since debug is constant, the compiler will just remove the code below if it's set to false, so this has no runtime overhead.
@@ -222,6 +222,7 @@ func main() {
ProxyImages bool
ProxyStreams bool
Restream bool
GetWebProfiles bool
DefaultPreferences cfg.Preferences
}
@@ -230,6 +231,7 @@ func main() {
ProxyImages: cfg.ProxyImages,
ProxyStreams: cfg.ProxyStreams,
Restream: cfg.Restream,
GetWebProfiles: cfg.GetWebProfiles,
DefaultPreferences: cfg.DefaultPreferences,
})
})
@@ -365,8 +367,35 @@ func main() {
}
}
var playlist *sc.Playlist
var nextTrack *sc.Track
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
}
nextIndex := -1
for i, t := range p.Tracks {
if t.ID == track.ID {
nextIndex = i + 1
}
}
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), 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")), templates.TrackHeader(prefs, track)).Render(context.Background(), c)
})
app.Get("/:user", func(c *fiber.Ctx) error {

View File

@@ -13,7 +13,7 @@ templ FeaturedTracks(p *sc.Paginated[*sc.Track]) {
<p>no more tracks</p>
} else {
for _, track := range p.Collection {
@TrackItem(track, true)
@TrackItem(track, true, "")
}
if p.Next != "" {
<a class="btn" href={ templ.URL("/_/featured?pagination=" + url.QueryEscape(strings.Split(p.Next, "/all-music")[1])) } rel="noreferrer">more tracks</a>

View File

@@ -16,8 +16,12 @@ templ PlaylistHeader(p sc.Playlist) {
<link rel="icon" type="image/x-icon" href={ p.Artwork }/>
}
func playlist(t sc.Track, p sc.Playlist) string {
return t.Href() + "?playlist=" + p.Href()[1:]
}
templ PlaylistItem(playlist *sc.Playlist, showUsername bool) {
<a class="listing" href={ templ.URL("/" + playlist.Author.Permalink + "/sets/" + playlist.Permalink) }>
<a class="listing" href={ templ.URL(playlist.Href()) }>
if playlist.Artwork != "" {
<img src={ playlist.Artwork }/>
} else {
@@ -40,7 +44,7 @@ templ Playlist(prefs cfg.Preferences, p sc.Playlist) {
<h1>{ p.Title }</h1>
@UserItem(&p.Author)
<div style="display: flex;">
<a class="btn" href={ templ.URL("https://soundcloud.com/" + p.Author.Permalink + "/sets/" + p.Permalink) }>view on soundcloud</a>
<a class="btn" href={ templ.URL("https://soundcloud.com" + p.Href()) }>view on soundcloud</a>
</div>
<br/>
@Description(prefs, p.Description, nil)
@@ -49,7 +53,11 @@ templ Playlist(prefs cfg.Preferences, p sc.Playlist) {
<br/>
<div>
for _, track := range p.Tracks {
@TrackItem(&track, true)
if *prefs.AutoplayNextTrack {
@TrackItem(&track, true, playlist(track, p))
} else {
@TrackItem(&track, true, "")
}
}
</div>
if len(p.MissingTracks) != 0 {

View File

@@ -17,17 +17,14 @@ type option struct {
}
// i hate this
// ^ outdated, i no longer hate this
templ sel(name string, options []option, selected string) {
<select name={ name } autocomplete="off">
for _, opt := range options {
if opt.value == selected {
<option value={ opt.value } selected>{ opt.desc }</option>
} else {
if opt.disabled {
<option value={ opt.value } disabled>{ opt.desc }</option>
} else {
<option value={ opt.value }>{ opt.desc }</option>
}
<option value={ opt.value } disabled?={ opt.disabled }>{ opt.desc }</option>
}
}
</select>
@@ -71,6 +68,11 @@ templ Preferences(prefs cfg.Preferences) {
@checkbox("FullyPreloadTrack", *prefs.FullyPreloadTrack)
</label>
<br/>
<label>
Autoplay next track in playlists:
@checkbox("AutoplayNextTrack", *prefs.AutoplayNextTrack)
</label>
<br/>
}
<input type="submit" value="Update" class="btn" style="margin-top: 1rem;"/>
<br/>

View File

@@ -19,15 +19,23 @@ templ TrackHeader(prefs cfg.Preferences, t sc.Track) {
}
}
templ TrackPlayer(prefs cfg.Preferences, track sc.Track, stream string, displayErr string) {
func next(t *sc.Track, p *sc.Playlist) string {
return t.Href() + "?autoplay=true&playlist=" + p.Href()[1:]
}
templ TrackPlayer(prefs cfg.Preferences, track sc.Track, stream string, displayErr string, autoplay bool, nextTrack *sc.Track, playlist *sc.Playlist, volume string) {
if *prefs.Player == cfg.NonePlayer {
{{ return }}
}
if displayErr == "" {
if cfg.Restream && *prefs.Player == cfg.RestreamPlayer {
<audio src={ "/_/restream/" + track.Author.Permalink + "/" + track.Permalink } controls></audio>
<audio src={ "/_/restream" + track.Href() } controls autoplay?={ autoplay }></audio>
} else if stream != "" {
<audio id="track" src={ stream } controls></audio>
if nextTrack != nil {
<audio id="track" src={ stream } controls autoplay?={ autoplay } data-next={next(nextTrack, playlist)} volume={volume}></audio>
} else {
<audio id="track" src={ stream } controls autoplay?={ autoplay }></audio>
}
if *prefs.FullyPreloadTrack {
<script async src="/player_preload.js"></script>
} else {
@@ -49,9 +57,12 @@ templ TrackPlayer(prefs cfg.Preferences, track sc.Track, stream string, displayE
}
}
templ TrackItem(track *sc.Track, showUsername bool) {
templ TrackItem(track *sc.Track, showUsername bool, overrideHref string) {
if track.Title != "" {
<a class="listing" href={ templ.URL("/" + track.Author.Permalink + "/" + track.Permalink) }>
{{ if overrideHref == "" {
overrideHref = track.Href()
} }}
<a class="listing" href={ templ.URL(overrideHref) }>
if track.Artwork != "" {
<img src={ track.Artwork }/>
} else {
@@ -67,24 +78,36 @@ templ TrackItem(track *sc.Track, showUsername bool) {
}
}
templ Track(prefs cfg.Preferences, t sc.Track, stream string, displayErr string) {
templ Track(prefs cfg.Preferences, t sc.Track, stream string, displayErr string, autoplay bool, playlist *sc.Playlist, nextTrack *sc.Track, volume string) {
if t.Artwork != "" {
<img src={ t.Artwork } width="300px"/>
}
<h1>{ t.Title }</h1>
@TrackPlayer(prefs, t, stream, displayErr)
@TrackPlayer(prefs, t, stream, displayErr, autoplay, nextTrack, playlist, volume)
if t.Genre != "" {
<p class="tag">{ t.Genre }</p>
} else {
<br/>
<br/>
}
if playlist != nil {
<details 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)
<a href={templ.URL(t.Href())} class="link">Stop playlist playback</a>
</details>
}
@UserItem(&t.Author)
//<div class="btns">
<div style="display: flex; gap: 1rem">
<a class="btn" href={ templ.URL("https://soundcloud.com/" + t.Author.Permalink + "/" + t.Permalink) }>view on soundcloud</a>
<a class="btn" href={ templ.URL("https://soundcloud.com" + t.Href()) }>view on soundcloud</a>
if cfg.Restream {
<a class="btn" href={ templ.URL("/_/restream/" + t.Author.Permalink + "/" + t.Permalink + "?metadata=true") } download={ t.Author.Username + " - " + t.Title + ".mp3" }>download</a>
<a class="btn" href={ templ.URL("/_/restream" + t.Href() + "?metadata=true") } download={ t.Author.Username + " - " + t.Title + ".mp3" }>download</a>
}
</div>
<br/>
@@ -119,7 +142,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)
@TrackPlayer(prefs, t, stream, displayErr, false, nil, nil, "")
@UserItem(&t.Author)
</body>
</html>
@@ -133,7 +156,7 @@ templ SearchTracks(p *sc.Paginated[*sc.Track]) {
<p>no more results</p>
} else {
for _, track := range p.Collection {
@TrackItem(track, true)
@TrackItem(track, true, "")
}
if p.Next != "" && len(p.Collection) != int(p.Total) {
<a class="btn" href={ templ.URL("?type=tracks&pagination=" + url.QueryEscape(strings.Split(p.Next, "/tracks")[1])) } rel="noreferrer">more tracks</a>

View File

@@ -105,7 +105,7 @@ templ User(prefs cfg.Preferences, u sc.User, p *sc.Paginated[*sc.Track]) {
if len(p.Collection) != 0 {
<div>
for _, track := range p.Collection {
@TrackItem(track, false)
@TrackItem(track, false, "")
}
</div>
if p.Next != "" && len(p.Collection) != int(u.Tracks) {
@@ -160,7 +160,7 @@ templ UserReposts(prefs cfg.Preferences, u sc.User, p *sc.Paginated[*sc.Repost])
<div>
for _, repost := range p.Collection {
if repost.Type == sc.TrackRepost && repost.Track != nil {
@TrackItem(repost.Track, true)
@TrackItem(repost.Track, true, "")
} else if repost.Type == sc.PlaylistRepost && repost.Playlist != nil {
@PlaylistItem(repost.Playlist, true)
}
@@ -182,7 +182,7 @@ templ UserLikes(prefs cfg.Preferences, u sc.User, p *sc.Paginated[*sc.Like]) {
<div>
for _, like := range p.Collection {
if like.Track != nil {
@TrackItem(like.Track, true)
@TrackItem(like.Track, true, "")
} else if like.Playlist != nil {
@PlaylistItem(like.Playlist, true)
}