Highlight @username, links and emails in descriptions; and some fixes

This commit is contained in:
Laptop
2024-11-24 16:32:33 +02:00
parent ea3426edd9
commit 2ed5d08bbc
12 changed files with 133 additions and 39 deletions

View File

@@ -5,11 +5,12 @@ wip alternative frontend for soundcloud
# Already implemented # Already implemented
- Searching for songs, users, playlists - Searching for songs, users, playlists
- Basic user overview (songs, playlists, albums, metadata) - Basic user overview (songs, playlists, albums, reposts, metadata)
- Basic song overview (author, metadata) & streaming (requires some JS if instance has `Restream` disabled) - Basic song overview (author, metadata) & streaming (requires some JS if instance has `Restream` disabled)
- Basic playlist/set/album overview (songs list, author, metadata) - Basic playlist/set/album overview (songs list, author, metadata)
- Resolving shortened links (`https://on.soundcloud.com/boiKDP46fayYDoVK9` -> `https://sc.maid.zone/on/boiKDP46fayYDoVK9`) - Resolving shortened links (`https://on.soundcloud.com/boiKDP46fayYDoVK9` -> `https://sc.maid.zone/on/boiKDP46fayYDoVK9`)
- Content proxying (images, audio) - Content proxying (images, audio)
- View featured tracks, playlists
- Users can change their preferences (should proxying be enabled, what method of playing the song should be used etc) - Users can change their preferences (should proxying be enabled, what method of playing the song should be used etc)
## In the works ## In the works
@@ -25,6 +26,7 @@ An easier way is to navigate to `<instance>/_/preferences`.
If some features are disabled by the instance, they won't show up there. If some features are disabled by the instance, they won't show up there.
Available features: Available features:
- ParseDescriptions: Highlight `@username`, `https://example.com` and `email@example.com` in text as clickable links
- Proxy images: Retrieve images through the instance, instead of going to soundcloud's servers for them - Proxy images: Retrieve images through the instance, instead of going to soundcloud's servers for them
- Player: In what way should the track be streamed. Can be Restream (does not require JS, better compatibility, can be a bit buggy client-side) or HLS (requires JS, more stable, less good compatibility (you'll be ok unless you are using a very outdated browser)) - Player: In what way should the track be streamed. Can be Restream (does not require JS, better compatibility, can be a bit buggy client-side) or HLS (requires JS, more stable, less good compatibility (you'll be ok unless you are using a very outdated browser))
- Player-specific settings: They will only show up if you have selected HLS player currently. - Player-specific settings: They will only show up if you have selected HLS player currently.

View File

@@ -156,4 +156,8 @@ select:focus {
display: grid; display: grid;
grid-template: auto / auto auto auto; grid-template: auto / auto auto auto;
gap: 1rem; gap: 1rem;
}
.link {
color: var(--accent);
} }

View File

@@ -32,6 +32,9 @@ type Preferences struct {
FullyPreloadTrack *bool FullyPreloadTrack *bool
ProxyImages *bool ProxyImages *bool
// Highlight @username, https://example.com and email@example.com in text as clickable links
ParseDescriptions *bool
} }
// // config // // // // config // //
@@ -218,8 +221,10 @@ func init() {
// ProxyStreams: same as ProxyStreams in your config (false by default) // ProxyStreams: same as ProxyStreams in your config (false by default)
// FullyPreloadTrack: false // FullyPreloadTrack: false
// ProxyImages: same as ProxyImages in your config (false by default) // ProxyImages: same as ProxyImages in your config (false by default)
// ParseDescriptions: true
if config.DefaultPreferences != nil { if config.DefaultPreferences != nil {
var f bool var f bool
var t = true
if config.DefaultPreferences.Player != nil { if config.DefaultPreferences.Player != nil {
DefaultPreferences.Player = config.DefaultPreferences.Player DefaultPreferences.Player = config.DefaultPreferences.Player
} else { } else {
@@ -249,6 +254,12 @@ func init() {
} else { } else {
DefaultPreferences.ProxyImages = &ProxyImages DefaultPreferences.ProxyImages = &ProxyImages
} }
if config.DefaultPreferences.ParseDescriptions != nil {
DefaultPreferences.ParseDescriptions = config.DefaultPreferences.ParseDescriptions
} else {
DefaultPreferences.ParseDescriptions = &t
}
} else { } else {
var p string var p string
if Restream { if Restream {
@@ -261,8 +272,11 @@ func init() {
DefaultPreferences.ProxyStreams = &ProxyStreams DefaultPreferences.ProxyStreams = &ProxyStreams
var f bool var f bool
var t = true
DefaultPreferences.FullyPreloadTrack = &f DefaultPreferences.FullyPreloadTrack = &f
DefaultPreferences.ProxyImages = &ProxyImages DefaultPreferences.ProxyImages = &ProxyImages
DefaultPreferences.ParseDescriptions = &t
} }
} }

View File

@@ -26,6 +26,10 @@ func Defaults(dst *cfg.Preferences) {
if dst.ProxyImages == nil { if dst.ProxyImages == nil {
dst.ProxyImages = cfg.DefaultPreferences.ProxyImages dst.ProxyImages = cfg.DefaultPreferences.ProxyImages
} }
if dst.ParseDescriptions == nil {
dst.ParseDescriptions = cfg.DefaultPreferences.ParseDescriptions
}
} }
func Get(c *fiber.Ctx) (cfg.Preferences, error) { func Get(c *fiber.Ctx) (cfg.Preferences, error) {
@@ -43,6 +47,7 @@ func Get(c *fiber.Ctx) (cfg.Preferences, error) {
type PrefsForm struct { type PrefsForm struct {
ProxyImages string ProxyImages string
ParseDescriptions string
Player string Player string
ProxyStreams string ProxyStreams string
FullyPreloadTrack string FullyPreloadTrack string
@@ -96,6 +101,12 @@ func Load(r fiber.Router) {
old.ProxyImages = &f old.ProxyImages = &f
} }
} }
if p.ParseDescriptions == "on" {
old.ParseDescriptions = &t
} else {
old.ParseDescriptions = &f
}
old.Player = &p.Player old.Player = &p.Player
data, err := json.Marshal(old) data, err := json.Marshal(old)

View File

@@ -119,13 +119,19 @@ func GetClientID() (string, error) {
} }
func DoWithRetry(httpc *fasthttp.HostClient, req *fasthttp.Request, resp *fasthttp.Response) (err error) { func DoWithRetry(httpc *fasthttp.HostClient, req *fasthttp.Request, resp *fasthttp.Response) (err error) {
for i := 0; i < 5; i++ { for i := 0; i < 10; i++ {
err = httpc.Do(req, resp) err = httpc.Do(req, resp)
if err == nil { if err == nil {
return nil return nil
} }
if err != fasthttp.ErrTimeout && err != fasthttp.ErrConnectionClosed && !os.IsTimeout(err) && !errors.Is(err, syscall.EPIPE) { // EPIPE is "broken pipe" error if err != fasthttp.ErrTimeout &&
err != fasthttp.ErrDialTimeout &&
err != fasthttp.ErrTLSHandshakeTimeout &&
err != fasthttp.ErrConnectionClosed &&
!os.IsTimeout(err) &&
!errors.Is(err, syscall.EPIPE) && // EPIPE is "broken pipe" error
err.Error() != "timeout" {
return return
} }
} }

42
lib/textparsing/init.go Normal file
View File

@@ -0,0 +1,42 @@
package textparsing
import (
"fmt"
"html"
"net/url"
"regexp"
"strings"
)
//var wordre = regexp.MustCompile(`\S+`)
// var urlre = regexp.MustCompile(`https?:\/\/[-a-zA-Z0-9@%._\+~#=]{2,256}\.[a-z]{1,6}[-a-zA-Z0-9@:%_\+.~#?&\/\/=]*`)
// var emailre = regexp.MustCompile(`[-a-zA-Z0-9%._\+~#=]+@[-a-zA-Z0-9%._\+~=&]{2,256}\.[a-z]{1,6}`)
// var usernamere = regexp.MustCompile(`@[a-z0-9\-]+`)
var theregex = regexp.MustCompile(`@[a-z0-9\-]+|(?:https?:\/\/[-a-zA-Z0-9@%._\+~#=]{2,256}\.[a-z]{1,6}[-a-zA-Z0-9@:%_\+.~#?&\/\/=]*)|(?:[-a-zA-Z0-9%._\+~#=]+@[-a-zA-Z0-9%._\+~=&]{2,256}\.[a-z]{1,6})`)
func replacer(ent string) string {
if strings.HasPrefix(ent, "@") {
return fmt.Sprintf(`<a class="link" href="/%s">%s</a>`, ent[1:], ent)
}
if strings.HasPrefix(ent, "https://") || strings.HasPrefix(ent, "http://") {
ent = html.UnescapeString(ent)
parsed, err := url.Parse(ent)
if err == nil {
href := ent
if parsed.Host == "soundcloud.com" || strings.HasSuffix(parsed.Host, ".soundcloud.com") {
href = "/" + strings.Join(strings.Split(ent, "/")[3:], "/")
}
return fmt.Sprintf(`<a class="link" href="%s" referrerpolicy="no-referrer" rel="external nofollow noopener noreferrer ugc" target="_blank">%s</a>`, href, ent)
}
}
// Otherwise, it can only be an email
return fmt.Sprintf(`<a class="link" href="mailto:%s">%s</a>`, ent, ent)
}
func Format(text string) string {
return theregex.ReplaceAllStringFunc(html.EscapeString(text), replacer)
}

10
main.go
View File

@@ -243,7 +243,7 @@ func main() {
} }
c.Set("Content-Type", "text/html") c.Set("Content-Type", "text/html")
return templates.Base(user.Username, templates.UserPlaylists(user, pl), templates.UserHeader(user)).Render(context.Background(), c) return templates.Base(user.Username, templates.UserPlaylists(prefs, user, pl), templates.UserHeader(user)).Render(context.Background(), c)
}) })
app.Get("/:user/albums", func(c *fiber.Ctx) error { app.Get("/:user/albums", func(c *fiber.Ctx) error {
@@ -265,7 +265,7 @@ func main() {
} }
c.Set("Content-Type", "text/html") c.Set("Content-Type", "text/html")
return templates.Base(user.Username, templates.UserAlbums(user, pl), templates.UserHeader(user)).Render(context.Background(), c) return templates.Base(user.Username, templates.UserAlbums(prefs, user, pl), templates.UserHeader(user)).Render(context.Background(), c)
}) })
app.Get("/:user/reposts", func(c *fiber.Ctx) error { app.Get("/:user/reposts", func(c *fiber.Ctx) error {
@@ -287,7 +287,7 @@ func main() {
} }
c.Set("Content-Type", "text/html") c.Set("Content-Type", "text/html")
return templates.Base(user.Username, templates.UserReposts(user, p), templates.UserHeader(user)).Render(context.Background(), c) return templates.Base(user.Username, templates.UserReposts(prefs, user, p), templates.UserHeader(user)).Render(context.Background(), c)
}) })
app.Get("/:user/:track", func(c *fiber.Ctx) error { app.Get("/:user/:track", func(c *fiber.Ctx) error {
@@ -347,7 +347,7 @@ func main() {
//fmt.Println("gettracks", time.Since(h)) //fmt.Println("gettracks", time.Since(h))
c.Set("Content-Type", "text/html") c.Set("Content-Type", "text/html")
return templates.Base(usr.Username, templates.User(usr, p), templates.UserHeader(usr)).Render(context.Background(), c) return templates.Base(usr.Username, templates.User(prefs, usr, p), templates.UserHeader(usr)).Render(context.Background(), c)
}) })
app.Get("/:user/sets/:playlist", func(c *fiber.Ctx) error { app.Get("/:user/sets/:playlist", func(c *fiber.Ctx) error {
@@ -375,7 +375,7 @@ func main() {
} }
c.Set("Content-Type", "text/html") c.Set("Content-Type", "text/html")
return templates.Base(playlist.Title+" by "+playlist.Author.Username, templates.Playlist(playlist), templates.PlaylistHeader(playlist)).Render(context.Background(), c) return templates.Base(playlist.Title+" by "+playlist.Author.Username, templates.Playlist(prefs, playlist), templates.PlaylistHeader(playlist)).Render(context.Background(), c)
}) })
log.Fatal(app.Listen(cfg.Addr)) log.Fatal(app.Listen(cfg.Addr))

View File

@@ -1,5 +1,10 @@
package templates package templates
import (
"github.com/maid-zone/soundcloak/lib/cfg"
"github.com/maid-zone/soundcloak/lib/textparsing"
)
templ Base(title string, content templ.Component, head templ.Component) { templ Base(title string, content templ.Component, head templ.Component) {
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
@@ -22,3 +27,16 @@ templ Base(title string, content templ.Component, head templ.Component) {
</body> </body>
</html> </html>
} }
templ Description(prefs cfg.Preferences, text string) {
<details>
<summary>Toggle description</summary>
<p style="white-space: pre-wrap;">
if *prefs.ParseDescriptions {
@templ.Raw(textparsing.Format(text))
} else {
{ text }
}
</p>
</details>
}

View File

@@ -5,6 +5,7 @@ import (
"net/url" "net/url"
"strconv" "strconv"
"strings" "strings"
"github.com/maid-zone/soundcloak/lib/cfg"
) )
templ PlaylistHeader(p sc.Playlist) { templ PlaylistHeader(p sc.Playlist) {
@@ -32,7 +33,7 @@ templ PlaylistItem(playlist *sc.Playlist, showUsername bool) {
</a> </a>
} }
templ Playlist(p sc.Playlist) { templ Playlist(prefs cfg.Preferences, p sc.Playlist) {
if p.Artwork != "" { if p.Artwork != "" {
<img src={ p.Artwork } width="300px"/> <img src={ p.Artwork } width="300px"/>
} }
@@ -43,10 +44,7 @@ templ Playlist(p sc.Playlist) {
</div> </div>
<br/> <br/>
if p.Description != "" { if p.Description != "" {
<details> @Description(prefs, p.Description)
<summary>Toggle description</summary>
<p style="white-space: pre-wrap">{ p.Description }</p>
</details>
} }
<p>{ strconv.FormatInt(p.TrackCount, 10) } tracks</p> <p>{ strconv.FormatInt(p.TrackCount, 10) } tracks</p>
<br/> <br/>

View File

@@ -11,8 +11,8 @@ templ checkbox(name string, checked bool) {
} }
type option struct { type option struct {
value string value string
desc string desc string
disabled bool disabled bool
} }
@@ -36,6 +36,11 @@ templ sel(name string, options []option, selected string) {
templ Preferences(prefs cfg.Preferences) { templ Preferences(prefs cfg.Preferences) {
<h1>Preferences</h1> <h1>Preferences</h1>
<form method="post"> <form method="post">
<label>
Parse descriptions:
@checkbox("ParseDescriptions", *prefs.ParseDescriptions)
</label>
<br/>
if cfg.ProxyImages { if cfg.ProxyImages {
<label> <label>
Proxy images: Proxy images:
@@ -68,9 +73,8 @@ templ Preferences(prefs cfg.Preferences) {
<br/> <br/>
} }
<input type="submit" value="Update" class="btn" style="margin-top: 1rem;"/> <input type="submit" value="Update" class="btn" style="margin-top: 1rem;"/>
<br/>
<br> <br/>
<br>
<p>These preferences get saved in a cookie.</p> <p>These preferences get saved in a cookie.</p>
</form> </form>
} }

View File

@@ -87,10 +87,7 @@ templ Track(prefs cfg.Preferences, t sc.Track, stream string, displayErr string)
</div> </div>
<br/> <br/>
if t.Description != "" { if t.Description != "" {
<details> @Description(prefs, t.Description)
<summary>Toggle description</summary>
<p style="white-space: pre-wrap">{ t.Description }</p>
</details>
} }
<p>{ strconv.FormatInt(t.Likes, 10) } likes</p> <p>{ strconv.FormatInt(t.Likes, 10) } likes</p>
<p>{ strconv.FormatInt(t.Played, 10) } plays</p> <p>{ strconv.FormatInt(t.Played, 10) } plays</p>

View File

@@ -5,6 +5,7 @@ import (
"net/url" "net/url"
"strconv" "strconv"
"strings" "strings"
"github.com/maid-zone/soundcloak/lib/cfg"
) )
templ UserHeader(u sc.User) { templ UserHeader(u sc.User) {
@@ -31,7 +32,7 @@ templ UserItem(user *sc.User) {
</a> </a>
} }
templ UserBase(u sc.User) { templ UserBase(prefs cfg.Preferences, u sc.User) {
<div> <div>
if u.Avatar != "" { if u.Avatar != "" {
<img src={ u.Avatar } width="300px"/> <img src={ u.Avatar } width="300px"/>
@@ -45,10 +46,7 @@ templ UserBase(u sc.User) {
} }
</div> </div>
if u.Description != "" { if u.Description != "" {
<details> @Description(prefs, u.Description)
<summary>Toggle description</summary>
<p style="white-space: pre-wrap">{ u.Description }</p>
</details>
} }
<div> <div>
<p>{ strconv.FormatInt(u.Followers, 10) } followers</p> <p>{ strconv.FormatInt(u.Followers, 10) } followers</p>
@@ -61,15 +59,15 @@ templ UserBase(u sc.User) {
</div> </div>
} }
templ User(u sc.User, p *sc.Paginated[*sc.Track]) { templ User(prefs cfg.Preferences, u sc.User, p *sc.Paginated[*sc.Track]) {
@UserBase(u) @UserBase(prefs, u)
// kinda tedious but whatever, might make it more flexible in the future // kinda tedious but whatever, might make it more flexible in the future
<div class="btns"> <div class="btns">
<a class="btn active">tracks</a> <a class="btn active">tracks</a>
<a class="btn" href={ templ.URL("/" + u.Permalink + "/sets") }>playlists</a> <a class="btn" href={ templ.URL("/" + u.Permalink + "/sets") }>playlists</a>
<a class="btn" href={ templ.URL("/" + u.Permalink + "/albums") }>albums</a> <a class="btn" href={ templ.URL("/" + u.Permalink + "/albums") }>albums</a>
<a class="btn" href={ templ.URL("/" + u.Permalink + "/reposts") }>reposts</a> <a class="btn" href={ templ.URL("/" + u.Permalink + "/reposts") }>reposts</a>
<a class="btn" href={ templ.URL("https://soundcloud.com/" + u.Permalink) }>view on soundcloud</a> <a class="btn" href={ templ.URL("https://soundcloud.com/" + u.Permalink) } referrerpolicy="no-referrer" rel="external nofollow noopener noreferrer" target="_blank">view on soundcloud</a>
</div> </div>
<br/> <br/>
if len(p.Collection) != 0 { if len(p.Collection) != 0 {
@@ -86,14 +84,14 @@ templ User(u sc.User, p *sc.Paginated[*sc.Track]) {
} }
} }
templ UserPlaylists(u sc.User, p *sc.Paginated[*sc.Playlist]) { templ UserPlaylists(prefs cfg.Preferences, u sc.User, p *sc.Paginated[*sc.Playlist]) {
@UserBase(u) @UserBase(prefs, u)
<div class="btns"> <div class="btns">
<a class="btn" href={ templ.URL("/" + u.Permalink) }>tracks</a> <a class="btn" href={ templ.URL("/" + u.Permalink) }>tracks</a>
<a class="btn active">playlists</a> <a class="btn active">playlists</a>
<a class="btn" href={ templ.URL("/" + u.Permalink + "/albums") }>albums</a> <a class="btn" href={ templ.URL("/" + u.Permalink + "/albums") }>albums</a>
<a class="btn" href={ templ.URL("/" + u.Permalink + "/reposts") }>reposts</a> <a class="btn" href={ templ.URL("/" + u.Permalink + "/reposts") }>reposts</a>
<a class="btn" href={ templ.URL("https://soundcloud.com/" + u.Permalink) }>view on soundcloud</a> <a class="btn" href={ templ.URL("https://soundcloud.com/" + u.Permalink) } referrerpolicy="no-referrer" rel="external nofollow noopener noreferrer" target="_blank">view on soundcloud</a>
</div> </div>
<br/> <br/>
if len(p.Collection) != 0 { if len(p.Collection) != 0 {
@@ -110,14 +108,14 @@ templ UserPlaylists(u sc.User, p *sc.Paginated[*sc.Playlist]) {
} }
} }
templ UserAlbums(u sc.User, p *sc.Paginated[*sc.Playlist]) { templ UserAlbums(prefs cfg.Preferences, u sc.User, p *sc.Paginated[*sc.Playlist]) {
@UserBase(u) @UserBase(prefs, u)
<div class="btns"> <div class="btns">
<a class="btn" href={ templ.URL("/" + u.Permalink) }>tracks</a> <a class="btn" href={ templ.URL("/" + u.Permalink) }>tracks</a>
<a class="btn" href={ templ.URL("/" + u.Permalink + "/sets") }>playlists</a> <a class="btn" href={ templ.URL("/" + u.Permalink + "/sets") }>playlists</a>
<a class="btn active">albums</a> <a class="btn active">albums</a>
<a class="btn" href={ templ.URL("/" + u.Permalink + "/reposts") }>reposts</a> <a class="btn" href={ templ.URL("/" + u.Permalink + "/reposts") }>reposts</a>
<a class="btn" href={ templ.URL("https://soundcloud.com/" + u.Permalink) }>view on soundcloud</a> <a class="btn" href={ templ.URL("https://soundcloud.com/" + u.Permalink) } referrerpolicy="no-referrer" rel="external nofollow noopener noreferrer" target="_blank">view on soundcloud</a>
</div> </div>
<br/> <br/>
if len(p.Collection) != 0 { if len(p.Collection) != 0 {
@@ -134,14 +132,14 @@ templ UserAlbums(u sc.User, p *sc.Paginated[*sc.Playlist]) {
} }
} }
templ UserReposts(u sc.User, p *sc.Paginated[*sc.Repost]) { templ UserReposts(prefs cfg.Preferences, u sc.User, p *sc.Paginated[*sc.Repost]) {
@UserBase(u) @UserBase(prefs, u)
<div class="btns"> <div class="btns">
<a class="btn" href={ templ.URL("/" + u.Permalink) }>tracks</a> <a class="btn" href={ templ.URL("/" + u.Permalink) }>tracks</a>
<a class="btn" href={ templ.URL("/" + u.Permalink + "/sets") }>playlists</a> <a class="btn" href={ templ.URL("/" + u.Permalink + "/sets") }>playlists</a>
<a class="btn" href={ templ.URL("/" + u.Permalink + "/albums") }>albums</a> <a class="btn" href={ templ.URL("/" + u.Permalink + "/albums") }>albums</a>
<a class="btn active">reposts</a> <a class="btn active">reposts</a>
<a class="btn" href={ templ.URL("https://soundcloud.com/" + u.Permalink) }>view on soundcloud</a> <a class="btn" href={ templ.URL("https://soundcloud.com/" + u.Permalink) } referrerpolicy="no-referrer" rel="external nofollow noopener noreferrer" target="_blank">view on soundcloud</a>
</div> </div>
<br/> <br/>
if len(p.Collection) != 0 { if len(p.Collection) != 0 {