Merge pull request #61 from qvalentin/feature/artist-page

feat: add artist page
This commit is contained in:
Achraf RRAMI 2024-06-11 20:28:56 +00:00 committed by GitHub
commit 8ae6fac810
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 505 additions and 137 deletions

View file

@ -5,13 +5,19 @@ import (
"strings"
"github.com/PuerkitoBio/goquery"
"github.com/rramiachraf/dumb/utils"
)
type AlbumPreview struct {
Name string
Image string
URL string
}
type Album struct {
Artist string
Name string
Image string
About [2]string
AlbumPreview
Artist ArtistPreview
About string
Tracks []Track
}
@ -24,11 +30,11 @@ type Track struct {
type albumMetadata struct {
Album struct {
Id int `json:"id"`
Image string `json:"cover_art_thumbnail_url"`
Name string `json:"name"`
Description string `json:"description_preview"`
Artist `json:"artist"`
Id int `json:"id"`
Image string `json:"cover_art_thumbnail_url"`
Name string `json:"name"`
Description string `json:"description_preview"`
artistPreviewMetadata `json:"artist"`
}
AlbumAppearances []AlbumAppearances `json:"album_appearances"`
}
@ -42,8 +48,9 @@ type AlbumAppearances struct {
}
}
type Artist struct {
type artistPreviewMetadata struct {
Name string `json:"name"`
URL string `json:"url"`
}
func (a *Album) parseAlbumData(doc *goquery.Document) error {
@ -58,11 +65,13 @@ func (a *Album) parseAlbumData(doc *goquery.Document) error {
}
albumData := albumMetadataFromPage.Album
a.Artist = albumData.Artist.Name
a.Artist = ArtistPreview{
Name: albumData.artistPreviewMetadata.Name,
URL: utils.TrimURL(albumData.artistPreviewMetadata.URL),
}
a.Name = albumData.Name
a.Image = albumData.Image
a.About[0] = albumData.Description
a.About[1] = truncateText(albumData.Description)
a.About = albumData.Description
for _, track := range albumMetadataFromPage.AlbumAppearances {
url := strings.Replace(track.Song.Url, "https://genius.com", "", -1)

65
data/artist.go Normal file
View file

@ -0,0 +1,65 @@
package data
import (
"encoding/json"
"github.com/PuerkitoBio/goquery"
"github.com/rramiachraf/dumb/utils"
)
type ArtistPreview struct {
Name string
URL string
}
type Artist struct {
Name string
Description string
Albums []AlbumPreview
Image string
}
type artistMetadata struct {
Artist struct {
Id int `json:"id"`
Name string `json:"name"`
Description string `json:"description_preview"`
Image string `json:"image_url"`
}
Albums []struct {
Id int `json:"id"`
Image string `json:"cover_art_thumbnail_url"`
Name string `json:"name"`
URL string `json:"url"`
} `json:"artist_albums"`
}
func (a *Artist) parseArtistData(doc *goquery.Document) error {
pageMetadata, exists := doc.Find("meta[itemprop='page_data']").Attr("content")
if !exists {
return nil
}
var artistMetadataFromPage artistMetadata
if err := json.Unmarshal([]byte(pageMetadata), &artistMetadataFromPage); err != nil {
return err
}
a.Name = artistMetadataFromPage.Artist.Name
a.Description = artistMetadataFromPage.Artist.Description
a.Image = artistMetadataFromPage.Artist.Image
for _, album := range artistMetadataFromPage.Albums {
a.Albums = append(a.Albums, AlbumPreview{
Name: album.Name,
Image: album.Image,
URL: utils.TrimURL(album.URL),
})
}
return nil
}
func (a *Artist) Parse(doc *goquery.Document) error {
return a.parseArtistData(doc)
}

View file

@ -10,17 +10,14 @@ import (
)
type Song struct {
Artist string
Title string
Image string
Lyrics string
Credits map[string]string
About [2]string
Album struct {
URL string
Name string
Image string
}
Artist string
Title string
Image string
Lyrics string
Credits map[string]string
About string
Album AlbumPreview
ArtistPageURL string
}
type songResponse struct {
@ -38,12 +35,15 @@ type songResponse struct {
Image string `json:"cover_art_url"`
}
CustomPerformances []customPerformance `json:"custom_performances"`
WriterArtists []struct {
WriterArtists []struct {
Name string
} `json:"writer_artists"`
ProducerArtists []struct {
ProducerArtists []struct {
Name string
} `json:"producer_artists"`
PrimaryArtist struct {
URL string
} `json:"primary_artist"`
}
}
}
@ -98,11 +98,11 @@ func (s *Song) parseSongData(doc *goquery.Document) error {
s.Artist = songData.ArtistNames
s.Image = songData.Image
s.Title = songData.Title
s.About[0] = songData.Description.Plain
s.About[1] = truncateText(songData.Description.Plain)
s.About = songData.Description.Plain
s.Credits = make(map[string]string)
s.Album.Name = songData.Album.Name
s.Album.URL = strings.Replace(songData.Album.URL, "https://genius.com", "", -1)
s.ArtistPageURL = utils.TrimURL(songData.PrimaryArtist.URL)
s.Album.URL = utils.TrimURL(songData.Album.URL)
s.Album.Image = ExtractImageURL(songData.Album.Image)
s.Credits["Writers"] = joinNames(songData.WriterArtists)
@ -117,7 +117,8 @@ func (s *Song) parseSongData(doc *goquery.Document) error {
func joinNames(data []struct {
Name string
}) string {
},
) string {
var names []string
for _, hasName := range data {
names = append(names, hasName.Name)
@ -125,16 +126,6 @@ func joinNames(data []struct {
return strings.Join(names, ", ")
}
func truncateText(text string) string {
textArr := strings.Split(text, "")
if len(textArr) > 250 {
return strings.Join(textArr[0:250], "") + "..."
}
return text
}
func (s *Song) Parse(doc *goquery.Document) error {
if err := s.parseLyrics(doc); err != nil {
return err

View file

@ -11,6 +11,11 @@ type result struct {
Title string
Path string
Thumbnail string `json:"song_art_image_thumbnail_url"`
ArtistImage string `json:"image_url"`
ArtistName string `json:"name"`
URL string `json:"url"`
AlbumImage string `json:"cover_art_url"`
AlbumName string `json:"full_title"`
}
type hits []struct {

70
handlers/artist.go Normal file
View file

@ -0,0 +1,70 @@
package handlers
import (
"context"
"fmt"
"net/http"
"github.com/PuerkitoBio/goquery"
"github.com/gorilla/mux"
"github.com/rramiachraf/dumb/data"
"github.com/rramiachraf/dumb/utils"
"github.com/rramiachraf/dumb/views"
)
func artist(l *utils.Logger) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
artistName := mux.Vars(r)["artist"]
id := fmt.Sprintf("artist:%s", artistName)
if a, err := getCache[data.Artist](id); err == nil {
views.ArtistPage(a).Render(context.Background(), w)
return
}
url := fmt.Sprintf("https://genius.com/artists/%s", artistName)
resp, err := utils.SendRequest(url)
if err != nil {
l.Error(err.Error())
w.WriteHeader(http.StatusInternalServerError)
views.ErrorPage(500, "cannot reach Genius servers").Render(context.Background(), w)
return
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
w.WriteHeader(http.StatusNotFound)
views.ErrorPage(404, "page not found").Render(context.Background(), w)
return
}
doc, err := goquery.NewDocumentFromReader(resp.Body)
if err != nil {
l.Error(err.Error())
w.WriteHeader(http.StatusInternalServerError)
views.ErrorPage(500, "something went wrong").Render(context.Background(), w)
return
}
cf := doc.Find(".cloudflare_content").Length()
if cf > 0 {
l.Error("cloudflare got in the way")
views.ErrorPage(500, "cloudflare is detected").Render(context.Background(), w)
return
}
var a data.Artist
if err = a.Parse(doc); err != nil {
l.Error(err.Error())
}
views.ArtistPage(a).Render(context.Background(), w)
if err = setCache(id, a); err != nil {
l.Error(err.Error())
}
}
}

50
handlers/artist_test.go Normal file
View file

@ -0,0 +1,50 @@
package handlers
import (
"net/http"
"net/http/httptest"
"os"
"testing"
"github.com/PuerkitoBio/goquery"
"github.com/rramiachraf/dumb/utils"
)
func TestArtist(t *testing.T) {
url := "/artists/Red-hot-chili-peppers"
name := "Red Hot Chili Peppers"
firstAlbumName := "Cardiff, Wales: 6/23/04"
r, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
t.Fatal(err)
}
rr := httptest.NewRecorder()
l := utils.NewLogger(os.Stdout)
m := New(l, &assets{})
m.ServeHTTP(rr, r)
defer rr.Result().Body.Close()
if rr.Result().StatusCode != http.StatusOK {
t.Fatalf("expected %d, got %d\n", http.StatusOK, rr.Result().StatusCode)
}
doc, err := goquery.NewDocumentFromReader(rr.Result().Body)
if err != nil {
t.Fatal(err)
}
artistName := doc.Find("#metadata-info > h1").First().Text()
if artistName != name {
t.Fatalf("expected %q, got %q\n", name, artistName)
}
albumName := doc.Find("#artist-albumlist > a > p").First().Text()
if albumName != firstAlbumName {
t.Fatalf("expected %q, got %q\n", firstAlbumName, albumName)
}
}

View file

@ -10,7 +10,7 @@ import (
)
type cachable interface {
data.Album | data.Song | data.Annotation | []byte
data.Album | data.Song | data.Annotation | data.Artist | []byte
}
var c, _ = bigcache.New(context.Background(), bigcache.DefaultConfig(time.Hour*24))

View file

@ -11,25 +11,47 @@ import (
"github.com/rramiachraf/dumb/views"
)
type route struct {
Path string
Handler func(*utils.Logger) http.HandlerFunc
Method string
}
func New(logger *utils.Logger, staticFiles static) *mux.Router {
r := mux.NewRouter()
r.Use(utils.MustHeaders)
r.Use(gorillaHandlers.CompressHandler)
routes := []route{
{Path: "/albums/{artist}/{albumName}", Handler: album},
{Path: "/artists/{artist}", Handler: artist},
{Path: "/images/{filename}.{ext}", Handler: imageProxy},
{Path: "/search", Handler: search},
{Path: "/{annotation-id}/{artist-song}/{verse}/annotations", Handler: annotations},
{Path: "/instances.json", Handler: instances},
}
for _, rr := range routes {
method := rr.Method
if method == "" {
method = http.MethodGet
}
r.HandleFunc(rr.Path, rr.Handler(logger)).Methods(method)
}
r.PathPrefix("/static/").HandlerFunc(staticAssets(logger, staticFiles))
r.Handle("/", templ.Handler(views.HomePage()))
r.HandleFunc("/robots.txt", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("User-agent: *\nDisallow: /\n"))
})
r.HandleFunc("/albums/{artist}/{albumName}", album(logger)).Methods("GET")
r.HandleFunc("/images/{filename}.{ext}", imageProxy(logger)).Methods("GET")
r.HandleFunc("/search", search(logger)).Methods("GET")
r.HandleFunc("/{annotation-id}/{artist-song}/{verse}/annotations", annotations(logger)).Methods("GET")
r.HandleFunc("/instances.json", instances(logger)).Methods("GET")
r.PathPrefix("/static/").HandlerFunc(staticAssets(logger, staticFiles))
r.PathPrefix("/{annotation-id}/{artist-song}-lyrics").HandlerFunc(lyrics(logger)).Methods("GET")
r.PathPrefix("/{annotation-id}/{artist-song}").HandlerFunc(lyrics(logger)).Methods("GET")
r.PathPrefix("/{annotation-id}").HandlerFunc(lyrics(logger)).Methods("GET")
r.NotFoundHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
views.ErrorPage(404, "page not found").Render(context.Background(), w)

View file

@ -1,12 +1,12 @@
const fullAbout = document.querySelector("#about #full_about")
const summary = document.querySelector("#about #summary")
const description = document.querySelector("#description > #full")
const summary = document.querySelector("#description > #summary")
function showAbout() {
function showDescription() {
summary.classList.toggle("hidden")
fullAbout.classList.toggle("hidden")
description.classList.toggle("hidden")
}
fullAbout && [fullAbout, summary].forEach(item => item.onclick = showAbout)
description && [description, summary].forEach(item => item.onclick = showDescription)
window.addEventListener("load", () => {
const geniusURL = "https://genius.com" + document.location.pathname + document.location.search

72
style/artist.css Normal file
View file

@ -0,0 +1,72 @@
#artist-albumlist {
color: #181d31;
font-weight: 500;
display: grid;
grid-template-columns: repeat(auto-fill, 150px);
gap: 1.5rem;
}
#artwork-preview {
width: 150px;
height: 150px;
border-radius: 5px;
border: 1px solid #ddd;
}
#artist-image {
border-radius: 50%;
border: 2px solid #ddd;
}
#artist-name {
text-align: center;
}
#artist-single-album {
color: #111;
display: flex;
flex-direction: column;
gap: 0.5rem;
text-align: center;
}
.dark #artist-albumlist p {
color: #ddd;
}
#artist-albumlist small {
font-size: 1.5rem;
color: #333;
}
.dark #artist-albumlist small {
color: #ccc;
}
#metadata p {
color: #171717;
}
.dark #metadata p {
color: #ddd;
}
#artist-section {
display: flex;
flex-direction: column;
gap: 1rem;
}
#artist-sections {
display: flex;
flex-direction: column;
gap: 2rem;
}
#artist-section h2 {
font-size: 2rem;
}
.dark #artist-section h2 {
color: #ddd;
}

View file

@ -5,15 +5,23 @@
}
#container {
display: grid;
padding: 5rem 0;
grid-template-columns: 24rem calc(1024px - 56rem) 24rem;
width: 1024px;
margin: 0 auto;
gap: 4rem;
display: grid;
flex: 1;
}
.trio-split {
grid-template-columns: 24rem calc(1024px - 56rem) 24rem;
gap: 4rem;
}
.duo-split {
grid-template-columns: 24rem 1fr;
gap: 4rem;
}
.main {
flex-grow: 1;
}

View file

@ -47,13 +47,13 @@
flex-basis: 0;
}
#about {
#description {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
#about p {
#description p {
font-size: 1.4rem;
color: #171717;
line-height: 1.8rem;
@ -121,7 +121,7 @@
color: #ddd;
}
.dark #about p,
.dark #description p,
.dark #credits summary {
color: #ccc;
}
@ -132,4 +132,3 @@
text-align: center;
}
}

View file

@ -57,6 +57,10 @@ a {
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
body.dark {
background-color: #181d31;
}

View file

@ -15,13 +15,19 @@
#search-results {
display: flex;
flex-direction: column;
gap: 1rem;
gap: 4rem;
}
#search-results h1 {
text-align: center;
color: #111;
font-size: 2.5rem;
#search-results h2 {
color: #222;
font-size: 1.8rem;
font-weight: 500;
}
#search-section {
display: flex;
flex-direction: column;
gap: 1rem;
}
#search-item {
@ -34,9 +40,9 @@
box-shadow: 0 1px 1px #ddd;
}
#search-item h2 {
#search-item h3 {
font-size: 1.8rem;
color: #222;
color: #333;
}
#search-item span {
@ -58,44 +64,7 @@
color: #222;
}
#search-results {
display: flex;
flex-direction: column;
gap: 1rem;
}
#search-results h1 {
text-align: center;
color: #111;
font-size: 2.5rem;
}
#search-item {
display: flex;
height: 8rem;
border: 1px solid #eee;
border-radius: 5px;
gap: 1rem;
padding: 1rem;
box-shadow: 0 1px 1px #ddd;
}
#search-item h2 {
font-size: 1.8rem;
color: #222;
}
#search-item span {
font-size: 1.3rem;
color: #333;
}
#search-item img {
width: 8rem;
border-radius: 5px;
}
.dark #search-page h1 {
.dark #search-page h2 {
color: #eee;
}
@ -103,7 +72,7 @@
border: 1px solid #888;
}
.dark #search-item h2 {
.dark #search-item h3 {
color: #ddd;
}

9
utils/description.go Normal file
View file

@ -0,0 +1,9 @@
package utils
func TrimText(text string, keep int) string {
if len(text) > keep {
return text[0:keep] + "..."
}
return text
}

19
utils/url.go Normal file
View file

@ -0,0 +1,19 @@
package utils
import (
"net/url"
"strings"
)
func TrimURL(u string) string {
uu, err := url.Parse(u)
if err != nil {
return ""
}
if strings.HasPrefix(uu.Path, "/") {
return uu.Path
}
return "/" + uu.Path
}

View file

@ -3,15 +3,18 @@ package views
import (
"fmt"
"github.com/rramiachraf/dumb/data"
"github.com/rramiachraf/dumb/utils"
)
templ AlbumPage(a data.Album) {
@layout(fmt.Sprintf("%s - %s", a.Artist, a.Name)) {
<div id="container">
@layout(fmt.Sprintf("%s - %s", a.Artist.Name, a.Name)) {
<div id="container" class="trio-split">
<div id="metadata">
<img id="album-artwork" src={ data.ExtractImageURL(a.Image) } alt="Album image"/>
<div id="metadata-info">
<h2>{ a.Artist }</h2>
<a href={ templ.URL(a.Artist.URL) } id="album-artist">
<h2>{ a.Artist.Name }</h2>
</a>
<h1>{ a.Name }</h1>
</div>
</div>
@ -23,12 +26,12 @@ templ AlbumPage(a data.Album) {
</a>
}
</div>
if a.About[0] != "" {
if a.About != "" {
<div id="info">
<div id="about">
<div id="description" dir="auto">
<h1 id="title">About</h1>
<p class="hidden" id="full_about">{ a.About[0] }</p>
<p id="summary">{ a.About[1] }</p>
<p class="hidden" id="full">{ a.About }</p>
<p id="summary">{ utils.TrimText(a.About, 250) }</p>
</div>
</div>
}

49
views/artist.templ Normal file
View file

@ -0,0 +1,49 @@
package views
import (
"github.com/rramiachraf/dumb/data"
"github.com/rramiachraf/dumb/utils"
)
templ ArtistPage(a data.Artist) {
@layout(a.Name) {
<div id="container" class="duo-split">
<div id="metadata">
<img id="artist-image" src={ data.ExtractImageURL(a.Image) } alt="Artist image"/>
<div id="metadata-info">
<h1 id="artist-name">{ a.Name }</h1>
</div>
</div>
<div id="artist-sections">
if a.Description != "" {
<div id="artist-section">
<h2>About { a.Name }</h2>
<div id="description">
<p id="summary">
{ utils.TrimText(a.Description, 500) }
</p>
<p id="full" class="hidden">{ a.Description }</p>
</div>
</div>
}
if len(a.Albums) > 0 {
<div id="artist-section">
<h2>Albums</h2>
<div id="artist-albumlist">
for _, album := range a.Albums {
<a href={ templ.URL(album.URL) } id="artist-single-album">
<img
id="artwork-preview"
src={ data.ExtractImageURL(album.Image) }
alt="Album image"
/>
<p>{ album.Name }</p>
</a>
}
</div>
</div>
}
</div>
</div>
}
}

View file

@ -3,19 +3,18 @@ package views
import (
"fmt"
"github.com/rramiachraf/dumb/data"
"github.com/rramiachraf/dumb/utils"
)
templ LyricsPage(s data.Song) {
@layout(fmt.Sprintf("%s - %s lyrics", s.Artist, s.Title)) {
<div id="container">
<div id="container" class="trio-split">
<div id="metadata">
<img
id="album-artwork"
src={ data.ExtractImageURL(s.Image) }
alt="Song image"
/>
<img id="album-artwork" src={ data.ExtractImageURL(s.Image) } alt="Song image"/>
<div id="metadata-info">
<h2>{ s.Artist }</h2>
<a href={ templ.URL(s.ArtistPageURL) }>
<h2>{ s.Artist }</h2>
</a>
<h1>{ s.Title }</h1>
</div>
</div>
@ -23,11 +22,11 @@ templ LyricsPage(s data.Song) {
@templ.Raw(s.Lyrics)
</div>
<div id="info">
if s.About[0] != "?" {
<div id="about">
if s.About != "?" {
<div id="description" dir="auto">
<h1 id="title">About</h1>
<p class="hidden" id="full_about">{ s.About[0] }</p>
<p id="summary">{ s.About[1] }</p>
<p class="hidden" id="full">{ s.About }</p>
<p id="summary">{ utils.TrimText(s.About, 250) }</p>
</div>
}
if len(s.Credits) > 0 {

View file

@ -1,8 +1,8 @@
package views
import (
"fmt"
"github.com/rramiachraf/dumb/data"
"github.com/rramiachraf/dumb/utils"
)
templ SearchPage(r data.SearchResults) {
@ -14,19 +14,44 @@ templ SearchPage(r data.SearchResults) {
<div id="search-results">
for _, s := range r.Sections {
if s.Type == "song" {
<h1>Songs</h1>
for _, s := range s.Hits {
<a id="search-item" href={ templ.URL(s.Result.Path) }>
<img
src={ data.ExtractImageURL(s.Result.Thumbnail) }
alt={ fmt.Sprintf("%s image", s.Result.Title) }
/>
<div>
<span>{ s.Result.ArtistNames }</span>
<h2>{ s.Result.Title }</h2>
</div>
</a>
}
<div id="search-section">
<h2>Songs</h2>
for _, s := range s.Hits {
<a id="search-item" href={ templ.URL(s.Result.Path) }>
<img src={ data.ExtractImageURL(s.Result.Thumbnail) } alt="Song image"/>
<div>
<span>{ s.Result.ArtistNames }</span>
<h3>{ utils.TrimText(s.Result.Title,70) }</h3>
</div>
</a>
}
</div>
}
if s.Type == "album" {
<div id="search-section">
<h2>Albums</h2>
for _, a := range s.Hits {
<a id="search-item" href={ templ.URL(utils.TrimURL(a.Result.URL)) }>
<img src={ data.ExtractImageURL(a.Result.AlbumImage) } alt="Album image"/>
<div>
<h3>{ utils.TrimText(a.Result.AlbumName, 70) }</h3>
</div>
</a>
}
</div>
}
if s.Type == "artist" {
<div id="search-section">
<h2>Artists</h2>
for _, a := range s.Hits {
<a id="search-item" href={ templ.URL(utils.TrimURL(a.Result.URL)) }>
<img src={ data.ExtractImageURL(a.Result.ArtistImage) } alt="Artist image"/>
<div>
<h3>{ utils.TrimText(a.Result.ArtistName, 70) }</h3>
</div>
</a>
}
</div>
}
}
</div>