├── .gitignore ├── screenshot.png ├── views ├── navbar.tmpl ├── footer.tmpl ├── error.tmpl ├── home.tmpl ├── search.tmpl └── lyrics.tmpl ├── static ├── fonts │ ├── inter-v12-cyrillic_greek_latin_vietnamese-500.woff │ ├── inter-v12-cyrillic_greek_latin_vietnamese-500.woff2 │ ├── inter-v12-cyrillic_greek_latin_vietnamese-700.woff │ ├── inter-v12-cyrillic_greek_latin_vietnamese-700.woff2 │ ├── inter-v12-cyrillic_greek_latin_vietnamese-regular.woff │ └── inter-v12-cyrillic_greek_latin_vietnamese-regular.woff2 ├── script.js ├── logo.svg └── style.css ├── Dockerfile ├── go.mod ├── scripts ├── dumb.service └── nginx-config ├── LICENCE ├── search.go ├── proxy.go ├── main.go ├── README.md ├── utils.go ├── go.sum └── lyrics.go /.gitignore: -------------------------------------------------------------------------------- 1 | dumb 2 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PrivacyDevel/dumb/main/screenshot.png -------------------------------------------------------------------------------- /views/navbar.tmpl: -------------------------------------------------------------------------------- 1 | {{define "navbar"}} 2 | 5 | {{end}} 6 | -------------------------------------------------------------------------------- /static/fonts/inter-v12-cyrillic_greek_latin_vietnamese-500.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PrivacyDevel/dumb/main/static/fonts/inter-v12-cyrillic_greek_latin_vietnamese-500.woff -------------------------------------------------------------------------------- /static/fonts/inter-v12-cyrillic_greek_latin_vietnamese-500.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PrivacyDevel/dumb/main/static/fonts/inter-v12-cyrillic_greek_latin_vietnamese-500.woff2 -------------------------------------------------------------------------------- /static/fonts/inter-v12-cyrillic_greek_latin_vietnamese-700.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PrivacyDevel/dumb/main/static/fonts/inter-v12-cyrillic_greek_latin_vietnamese-700.woff -------------------------------------------------------------------------------- /static/fonts/inter-v12-cyrillic_greek_latin_vietnamese-700.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PrivacyDevel/dumb/main/static/fonts/inter-v12-cyrillic_greek_latin_vietnamese-700.woff2 -------------------------------------------------------------------------------- /static/fonts/inter-v12-cyrillic_greek_latin_vietnamese-regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PrivacyDevel/dumb/main/static/fonts/inter-v12-cyrillic_greek_latin_vietnamese-regular.woff -------------------------------------------------------------------------------- /static/fonts/inter-v12-cyrillic_greek_latin_vietnamese-regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PrivacyDevel/dumb/main/static/fonts/inter-v12-cyrillic_greek_latin_vietnamese-regular.woff2 -------------------------------------------------------------------------------- /views/footer.tmpl: -------------------------------------------------------------------------------- 1 | {{define "footer"}} 2 | 5 | {{end}} 6 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.19.4-alpine3.17 2 | 3 | WORKDIR /code 4 | 5 | COPY go.mod go.sum ./ 6 | RUN go mod download 7 | 8 | COPY . . 9 | RUN go build 10 | 11 | EXPOSE 5555/tcp 12 | 13 | CMD ["/code/dumb"] 14 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/rramiachraf/dumb 2 | 3 | go 1.18 4 | 5 | require ( 6 | github.com/PuerkitoBio/goquery v1.8.0 7 | github.com/allegro/bigcache/v3 v3.0.2 8 | github.com/gorilla/mux v1.8.0 9 | github.com/sirupsen/logrus v1.9.0 10 | ) 11 | 12 | require ( 13 | github.com/andybalholm/cascadia v1.3.1 // indirect 14 | golang.org/x/net v0.0.0-20210916014120-12bc252f5db8 // indirect 15 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 // indirect 16 | ) 17 | -------------------------------------------------------------------------------- /scripts/dumb.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=ListMonk 3 | Documentation=https://github.com/rramiachraf/dumb 4 | After=system.slice multi-user.target postgresql.service network.target 5 | 6 | [Service] 7 | User=git 8 | Type=simple 9 | 10 | StandardOutput=syslog 11 | StandardError=syslog 12 | SyslogIdentifier=listmonk 13 | 14 | WorkingDirectory=/etc/dumb 15 | ExecStart=/etc/dumb/dumb 16 | 17 | Restart=always 18 | RestartSec=5 19 | 20 | [Install] 21 | WantedBy=multi-user.target 22 | -------------------------------------------------------------------------------- /views/error.tmpl: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | dumb 5 | 6 | 7 | 8 | 9 | 10 |
11 | {{template "navbar"}} 12 |
13 |

{{.Status}}

14 |

{{.Error}}

15 |
16 | {{template "footer"}} 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /static/script.js: -------------------------------------------------------------------------------- 1 | const fullAbout = document.querySelector("#about #full_about") 2 | const summary = document.querySelector("#about #summary") 3 | 4 | function showAbout() { 5 | summary.classList.toggle("hidden") 6 | fullAbout.classList.toggle("hidden") 7 | } 8 | 9 | [fullAbout, summary].forEach(item => item.onclick = showAbout) 10 | 11 | document.querySelectorAll("#lyrics a").forEach(item => { 12 | item.addEventListener("click", getAnnotation) 13 | }) 14 | 15 | function getAnnotation(e) { 16 | e.preventDefault() 17 | //const uri = e.target.parentElement.getAttribute("href") 18 | console.log("Annotations are not yet implemented!") 19 | } 20 | -------------------------------------------------------------------------------- /views/home.tmpl: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | dumb 5 | 6 | 7 | 8 | 9 | 10 |
11 | {{template "navbar"}} 12 |
13 |
14 |

Welcome to dumb

15 |

An alternative frontend for genius.com

16 |
17 |
18 | 19 |
20 | 21 |
22 | {{template "footer"}} 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /views/search.tmpl: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Search - dumb 5 | 6 | 7 | 8 | 9 | 10 |
11 | {{template "navbar"}} 12 |
13 |
14 | 15 |
16 |
17 | {{range .Sections}} 18 | {{if eq .Type "song"}} 19 |

Songs

20 | {{range .Hits}} 21 | 22 | 23 |
24 | {{.Result.ArtistNames}} 25 |

{{.Result.Title}}

26 |
27 |
28 | {{end}} 29 | {{end}} 30 | {{end}} 31 |
32 |
33 | {{template "footer"}} 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /LICENCE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Achraf RRAMI 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /views/lyrics.tmpl: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{.Artist}} - {{.Title}} lyrics 5 | 6 | 7 | 8 | 9 | 10 | 11 | {{template "navbar"}} 12 |
13 |
14 | 15 |

{{.Artist}}

16 |

{{.Title}}

17 |
18 |
{{.Lyrics}}
19 |
20 |
21 |

About

22 | 23 |

{{index .About 1}}

24 |
25 |
26 |

Credits

27 | {{range $key, $val := .Credits}} 28 |
29 | {{$key}} 30 |

{{$val}}

31 |
32 | {{end}} 33 |
34 |
35 |
36 | {{template "footer"}} 37 | 38 | 39 | -------------------------------------------------------------------------------- /scripts/nginx-config: -------------------------------------------------------------------------------- 1 | server { 2 | # root /var/www/dumb.yoursite.com/html; 3 | # index index.html index.htm index.nginx-debian.html; 4 | 5 | server_name dumb.yoursite.com; 6 | # www.dumb.yoursite.com; 7 | 8 | location / { 9 | try_files $uri $uri/ =404; 10 | proxy_pass http://localhost:5555; 11 | proxy_set_header Host $host; 12 | proxy_set_header X-Real-IP $remote_addr; 13 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 14 | proxy_set_header X-Forwarded-Proto $scheme; 15 | } 16 | 17 | listen 443 ssl; # managed by Certbot 18 | ssl_certificate /etc/letsencrypt/live/dumb.yoursite.com/fullchain.pem; # managed by Certbot 19 | ssl_certificate_key /etc/letsencrypt/live/dumb.yoursite.com/privkey.pem; # managed by Certbot 20 | include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot 21 | ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot 22 | 23 | } 24 | server { 25 | if ($host = dumb.yoursite.com) { 26 | return 301 https://$host$request_uri; 27 | } # managed by Certbot 28 | 29 | server_name dumb.yoursite.com; 30 | 31 | listen 80; 32 | return 404; # managed by Certbot 33 | 34 | } 35 | 36 | -------------------------------------------------------------------------------- /search.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | "net/url" 8 | ) 9 | 10 | type response struct { 11 | Response struct { 12 | Sections sections 13 | } 14 | } 15 | 16 | type result struct { 17 | ArtistNames string `json:"artist_names"` 18 | Title string 19 | Path string 20 | Thumbnail string `json:"song_art_image_thumbnail_url"` 21 | } 22 | 23 | type hits []struct { 24 | Result result 25 | } 26 | 27 | type sections []struct { 28 | Type string 29 | Hits hits 30 | } 31 | 32 | type renderVars struct { 33 | Query string 34 | Sections sections 35 | } 36 | 37 | func searchHandler(w http.ResponseWriter, r *http.Request) { 38 | query := r.URL.Query().Get("q") 39 | url := fmt.Sprintf(`https://genius.com/api/search/multi?q=%s`, url.QueryEscape(query)) 40 | 41 | res, err := sendRequest(url) 42 | if err != nil { 43 | logger.Errorln(err) 44 | w.WriteHeader(http.StatusInternalServerError) 45 | render("error", w, map[string]string{ 46 | "Status": "500", 47 | "Error": "cannot reach genius servers", 48 | }) 49 | } 50 | 51 | defer res.Body.Close() 52 | 53 | var data response 54 | 55 | d := json.NewDecoder(res.Body) 56 | d.Decode(&data) 57 | 58 | vars := renderVars{query, data.Response.Sections} 59 | 60 | render("search", w, vars) 61 | } 62 | -------------------------------------------------------------------------------- /proxy.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "net/http" 7 | "net/url" 8 | "strings" 9 | 10 | "github.com/gorilla/mux" 11 | ) 12 | 13 | func isValidExt(ext string) bool { 14 | valid := []string{"jpg", "jpeg", "png", "gif"} 15 | for _, c := range valid { 16 | if strings.ToLower(ext) == c { 17 | return true 18 | } 19 | } 20 | 21 | return false 22 | } 23 | 24 | func extractURL(image string) string { 25 | u, err := url.Parse(image) 26 | if err != nil { 27 | return "" 28 | } 29 | 30 | return fmt.Sprintf("/images%s", u.Path) 31 | } 32 | 33 | func proxyHandler(w http.ResponseWriter, r *http.Request) { 34 | v := mux.Vars(r) 35 | f := v["filename"] 36 | ext := v["ext"] 37 | 38 | if !isValidExt(ext) { 39 | w.WriteHeader(http.StatusBadRequest) 40 | render("error", w, map[string]string{ 41 | "Status": "400", 42 | "Error": "Something went wrong", 43 | }) 44 | return 45 | } 46 | 47 | // first segment of URL resize the image to reduce bandwith usage. 48 | url := fmt.Sprintf("https://t2.genius.com/unsafe/300x300/https://images.genius.com/%s.%s", f, ext) 49 | 50 | res, err := sendRequest(url) 51 | if err != nil { 52 | logger.Errorln(err) 53 | w.WriteHeader(http.StatusInternalServerError) 54 | render("error", w, map[string]string{ 55 | "Status": "500", 56 | "Error": "cannot reach genius servers", 57 | }) 58 | return 59 | } 60 | 61 | if res.StatusCode != http.StatusOK { 62 | w.WriteHeader(http.StatusInternalServerError) 63 | render("error", w, map[string]string{ 64 | "Status": "500", 65 | "Error": "something went wrong", 66 | }) 67 | 68 | return 69 | } 70 | 71 | w.Header().Add("Content-type", fmt.Sprintf("image/%s", ext)) 72 | io.Copy(w, res.Body) 73 | } 74 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "net" 6 | "net/http" 7 | "os" 8 | "strconv" 9 | "time" 10 | 11 | "github.com/allegro/bigcache/v3" 12 | "github.com/gorilla/mux" 13 | "github.com/sirupsen/logrus" 14 | ) 15 | 16 | var logger = logrus.New() 17 | var client = &http.Client{} 18 | 19 | func main() { 20 | client = &http.Client{ 21 | Timeout: 20 * time.Second, 22 | } 23 | 24 | c, err := bigcache.NewBigCache(bigcache.DefaultConfig(time.Hour * 24)) 25 | if err != nil { 26 | logger.Fatalln("can't initialize caching") 27 | } 28 | cache = c 29 | 30 | r := mux.NewRouter() 31 | 32 | r.Use(securityHeaders) 33 | 34 | r.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { render("home", w, nil) }) 35 | r.HandleFunc("/search", searchHandler).Methods("GET") 36 | r.HandleFunc("/{id}-lyrics", lyricsHandler) 37 | r.HandleFunc("/images/{filename}.{ext}", proxyHandler) 38 | r.PathPrefix("/static/").Handler(http.StripPrefix("/static/", http.FileServer(http.Dir("static")))) 39 | r.NotFoundHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 40 | w.WriteHeader(http.StatusNotFound) 41 | render("error", w, map[string]string{ 42 | "Status": "404", 43 | "Error": "page not found", 44 | }) 45 | 46 | }) 47 | 48 | server := &http.Server{ 49 | Handler: r, 50 | WriteTimeout: 25 * time.Second, 51 | ReadTimeout: 25 * time.Second, 52 | } 53 | 54 | port, _ := strconv.Atoi(os.Getenv("PORT")) 55 | 56 | if port == 0 { 57 | port = 5555 58 | } 59 | 60 | l, err := net.Listen("tcp", fmt.Sprintf(":%d", port)) 61 | if err != nil { 62 | logger.Fatalln(err) 63 | } 64 | 65 | logger.Infof("server is listening on port %d\n", port) 66 | 67 | logger.Fatalln(server.Serve(l)) 68 | } 69 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # dumb 2 | With the massive daily increase of useless scripts on Genius's web frontend and having to download megabytes of clutter, [dumb](https://github.com/rramiachraf/dumb) tries to make reading lyrics from Genius a pleasant experience and as lightweight as possible. 3 | 4 | 5 | 6 | ![Screenshot](https://raw.githubusercontent.com/rramiachraf/dumb/main/screenshot.png) 7 | 8 | ## Installation & Usage 9 | [Go 1.18+](https://go.dev/dl) is required. 10 | ```bash 11 | git clone https://github.com/rramiachraf/dumb 12 | cd dumb 13 | go build 14 | ./dumb 15 | ``` 16 | 17 | The default port is 5555, you can use other ports by setting the `PORT` environment variable. 18 | 19 | ## Public Instances 20 | 21 | | URL | Region | CDN? | Operator | 22 | | --- | --- | --- | --- | 23 | | | US | No | https://vern.cc | 24 | | (experimental) | DE | No | @NunoSempere | 25 | | | US/DE | Yes | Whatever Social | 26 | | | DE | Yes | @MaximilianGT500 | 27 | | | CA | No | https://esmailelbob.xyz | 28 | 29 | ### Tor 30 | | URL | Operator | 31 | | --- | --- | 32 | | | https://vern.cc | 33 | | | https://esmailelbob.xyz | 34 | 35 | ### I2P 36 | | URL | Operator | 37 | | --- | --- | 38 | | | https://vern.cc | 39 | 40 | For people who might be capable and interested in hosting a public instance feel free to do so and don't forget to open a pull request so your instance can be included here. 41 | 42 | ## Contributing 43 | Contributions are welcome. 44 | 45 | ## License 46 | [MIT](https://github.com/rramiachraf/dumb/blob/main/LICENCE) 47 | 48 | -------------------------------------------------------------------------------- /utils.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | "net/url" 8 | "path" 9 | "text/template" 10 | 11 | "github.com/allegro/bigcache/v3" 12 | ) 13 | 14 | var cache *bigcache.BigCache 15 | 16 | func setCache(key string, entry interface{}) error { 17 | data, err := json.Marshal(&entry) 18 | if err != nil { 19 | return err 20 | } 21 | 22 | return cache.Set(key, data) 23 | } 24 | 25 | func getCache(key string) (interface{}, error) { 26 | data, err := cache.Get(key) 27 | if err != nil { 28 | return nil, err 29 | } 30 | 31 | var decoded interface{} 32 | 33 | if err = json.Unmarshal(data, &decoded); err != nil { 34 | return nil, err 35 | } 36 | 37 | return decoded, nil 38 | } 39 | 40 | func write(w http.ResponseWriter, status int, data []byte) { 41 | w.WriteHeader(status) 42 | w.Write(data) 43 | } 44 | 45 | func securityHeaders(next http.Handler) http.Handler { 46 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 47 | csp := "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self'; object-src 'none'" 48 | w.Header().Add("content-security-policy", csp) 49 | w.Header().Add("referrer-policy", "no-referrer") 50 | w.Header().Add("x-content-type-options", "nosniff") 51 | next.ServeHTTP(w, r) 52 | }) 53 | } 54 | 55 | func getTemplates(templates ...string) []string { 56 | var pths []string 57 | for _, t := range templates { 58 | tmpl := path.Join("views", fmt.Sprintf("%s.tmpl", t)) 59 | pths = append(pths, tmpl) 60 | } 61 | return pths 62 | } 63 | 64 | func render(n string, w http.ResponseWriter, data interface{}) { 65 | w.Header().Set("content-type", "text/html") 66 | t := template.New(n + ".tmpl").Funcs(template.FuncMap{"extractURL": extractURL}) 67 | t, err := t.ParseFiles(getTemplates(n, "navbar", "footer")...) 68 | if err != nil { 69 | logger.Errorln(err) 70 | w.WriteHeader(http.StatusInternalServerError) 71 | return 72 | } 73 | 74 | if err = t.Execute(w, data); err != nil { 75 | logger.Errorln(err) 76 | w.WriteHeader(http.StatusInternalServerError) 77 | return 78 | } 79 | } 80 | 81 | const UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.0.0 Safari/537.36" 82 | 83 | func sendRequest(u string) (*http.Response, error) { 84 | url, err := url.Parse(u) 85 | if err != nil { 86 | return nil, err 87 | } 88 | 89 | req := &http.Request{ 90 | Method: http.MethodGet, 91 | URL: url, 92 | Header: map[string][]string{ 93 | "Accept-Language": {"en-US"}, 94 | "User-Agent": {UA}, 95 | }, 96 | } 97 | 98 | return client.Do(req) 99 | } 100 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/PuerkitoBio/goquery v1.8.0 h1:PJTF7AmFCFKk1N6V6jmKfrNH9tV5pNE6lZMkG0gta/U= 2 | github.com/PuerkitoBio/goquery v1.8.0/go.mod h1:ypIiRMtY7COPGk+I/YbZLbxsxn9g5ejnI2HSMtkjZvI= 3 | github.com/allegro/bigcache/v3 v3.0.2 h1:AKZCw+5eAaVyNTBmI2fgyPVJhHkdWder3O9IrprcQfI= 4 | github.com/allegro/bigcache/v3 v3.0.2/go.mod h1:aPyh7jEvrog9zAwx5N7+JUQX5dZTSGpxF1LAR4dr35I= 5 | github.com/andybalholm/cascadia v1.3.1 h1:nhxRkql1kdYCc8Snf7D5/D3spOX+dBgjA6u8x004T2c= 6 | github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA= 7 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 8 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 9 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 10 | github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= 11 | github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= 12 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 13 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 14 | github.com/sirupsen/logrus v1.9.0 h1:trlNQbNUG3OdDrDil03MCb1H2o9nJ1x4/5LYw7byDE0= 15 | github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= 16 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 17 | github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= 18 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 19 | golang.org/x/net v0.0.0-20210916014120-12bc252f5db8 h1:/6y1LfuqNuQdHAm0jjtPtgRcxIxjVZgm5OTu8/QhZvk= 20 | golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 21 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 22 | golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 23 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 h1:0A+M6Uqn+Eje4kHMK80dtF3JCXC4ykBgQG4Fe06QRhQ= 24 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 25 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 26 | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 27 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 28 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 29 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= 30 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 31 | -------------------------------------------------------------------------------- /static/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 14 | 25 | 27 | 32 | db 46 | . 57 | . 68 | 69 | 70 | -------------------------------------------------------------------------------- /lyrics.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | "strings" 8 | 9 | "github.com/PuerkitoBio/goquery" 10 | "github.com/gorilla/mux" 11 | ) 12 | 13 | type song struct { 14 | Artist string 15 | Title string 16 | Image string 17 | Lyrics string 18 | Credits map[string]string 19 | About [2]string 20 | } 21 | 22 | type songResponse struct { 23 | Response struct { 24 | Song struct { 25 | ArtistNames string `json:"artist_names"` 26 | Image string `json:"song_art_image_thumbnail_url"` 27 | Title string 28 | Description struct { 29 | Plain string 30 | } 31 | CustomPerformances []customPerformance `json:"custom_performances"` 32 | } 33 | } 34 | } 35 | 36 | type customPerformance struct { 37 | Label string 38 | Artists []struct { 39 | Name string 40 | } 41 | } 42 | 43 | func (s *song) parseLyrics(doc *goquery.Document) { 44 | doc.Find("[data-lyrics-container='true']").Each(func(i int, ss *goquery.Selection) { 45 | h, err := ss.Html() 46 | if err != nil { 47 | logger.Errorln("unable to parse lyrics", err) 48 | } 49 | s.Lyrics += h 50 | }) 51 | } 52 | 53 | func (s *song) parseSongData(doc *goquery.Document) { 54 | attr, exists := doc.Find("meta[property='twitter:app:url:iphone']").Attr("content") 55 | if exists { 56 | songID := strings.Replace(attr, "genius://songs/", "", 1) 57 | u := fmt.Sprintf("https://genius.com/api/songs/%s?text_format=plain", songID) 58 | 59 | res, err := sendRequest(u) 60 | if err != nil { 61 | logger.Errorln(err) 62 | } 63 | 64 | defer res.Body.Close() 65 | 66 | var data songResponse 67 | decoder := json.NewDecoder(res.Body) 68 | err = decoder.Decode(&data) 69 | if err != nil { 70 | logger.Errorln(err) 71 | } 72 | 73 | songData := data.Response.Song 74 | s.Artist = songData.ArtistNames 75 | s.Image = songData.Image 76 | s.Title = songData.Title 77 | s.About[0] = songData.Description.Plain 78 | s.About[1] = truncateText(songData.Description.Plain) 79 | s.Credits = make(map[string]string) 80 | 81 | for _, perf := range songData.CustomPerformances { 82 | var artists []string 83 | for _, artist := range perf.Artists { 84 | artists = append(artists, artist.Name) 85 | } 86 | s.Credits[perf.Label] = strings.Join(artists, ", ") 87 | } 88 | } 89 | } 90 | 91 | func truncateText(text string) string { 92 | textArr := strings.Split(text, "") 93 | 94 | if len(textArr) > 250 { 95 | return strings.Join(textArr[0:250], "") + "..." 96 | } 97 | 98 | return text 99 | } 100 | 101 | func (s *song) parse(doc *goquery.Document) { 102 | s.parseLyrics(doc) 103 | s.parseSongData(doc) 104 | } 105 | 106 | func lyricsHandler(w http.ResponseWriter, r *http.Request) { 107 | id := mux.Vars(r)["id"] 108 | 109 | if data, err := getCache(id); err == nil { 110 | render("lyrics", w, data) 111 | return 112 | } 113 | 114 | url := fmt.Sprintf("https://genius.com/%s-lyrics", id) 115 | resp, err := sendRequest(url) 116 | if err != nil { 117 | logger.Errorln(err) 118 | w.WriteHeader(http.StatusInternalServerError) 119 | render("error", w, map[string]string{ 120 | "Status": "500", 121 | "Error": "cannot reach genius servers", 122 | }) 123 | return 124 | } 125 | 126 | defer resp.Body.Close() 127 | 128 | if resp.StatusCode == http.StatusNotFound { 129 | w.WriteHeader(http.StatusNotFound) 130 | render("error", w, map[string]string{ 131 | "Status": "404", 132 | "Error": "page not found", 133 | }) 134 | return 135 | } 136 | 137 | doc, err := goquery.NewDocumentFromReader(resp.Body) 138 | if err != nil { 139 | logger.Errorln(err) 140 | w.WriteHeader(http.StatusInternalServerError) 141 | render("error", w, map[string]string{ 142 | "Status": "500", 143 | "Error": "something went wrong", 144 | }) 145 | return 146 | } 147 | 148 | cf := doc.Find(".cloudflare_content").Length() 149 | if cf > 0 { 150 | logger.Errorln("cloudflare got in the way") 151 | render("error", w, map[string]string{ 152 | "Status": "500", 153 | "Error": "damn cloudflare, issue #21 on GitHub", 154 | }) 155 | return 156 | } 157 | 158 | var s song 159 | s.parse(doc) 160 | 161 | render("lyrics", w, s) 162 | setCache(id, s) 163 | } 164 | -------------------------------------------------------------------------------- /static/style.css: -------------------------------------------------------------------------------- 1 | /* inter-regular - cyrillic_greek_latin_vietnamese */ 2 | @font-face { 3 | font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ 4 | font-family: 'Inter'; 5 | font-style: normal; 6 | font-weight: 400; 7 | src: url('/static/fonts/inter-v12-cyrillic_greek_latin_vietnamese-regular.woff2') format('woff2'), /* Chrome 36+, Opera 23+, Firefox 39+ */ 8 | url('/static/fonts/inter-v12-cyrillic_greek_latin_vietnamese-regular.woff') format('woff'); /* Chrome 5+, Firefox 3.6+, IE 9+, Safari 5.1+ */ 9 | } 10 | 11 | /* inter-500 - cyrillic_greek_latin_vietnamese */ 12 | @font-face { 13 | font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ 14 | font-family: 'Inter'; 15 | font-style: normal; 16 | font-weight: 500; 17 | src: url('/static/fonts/inter-v12-cyrillic_greek_latin_vietnamese-500.woff2') format('woff2'), /* Chrome 36+, Opera 23+, Firefox 39+ */ 18 | url('/static/fonts/inter-v12-cyrillic_greek_latin_vietnamese-500.woff') format('woff'); /* Chrome 5+, Firefox 3.6+, IE 9+, Safari 5.1+ */ 19 | } 20 | 21 | /* inter-700 - cyrillic_greek_latin_vietnamese */ 22 | @font-face { 23 | font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */ 24 | font-family: 'Inter'; 25 | font-style: normal; 26 | font-weight: 700; 27 | src: url('/static/fonts/inter-v12-cyrillic_greek_latin_vietnamese-700.woff2') format('woff2'), /* Chrome 36+, Opera 23+, Firefox 39+ */ 28 | url('/static/fonts/inter-v12-cyrillic_greek_latin_vietnamese-700.woff') format('woff'); /* Chrome 5+, Firefox 3.6+, IE 9+, Safari 5.1+ */ 29 | } 30 | 31 | * { 32 | margin: 0; 33 | padding: 0; 34 | } 35 | 36 | html { 37 | font-size: 62.5%; 38 | } 39 | 40 | body { 41 | font-size: 1.5rem; 42 | font-family: inter; 43 | background-color: #f9f9f9; 44 | } 45 | 46 | #lyrics { 47 | color: #171717; 48 | line-height: 2.5rem; 49 | flex-basis: 0; 50 | flex-shrink: 0; 51 | flex-grow: 1; 52 | } 53 | 54 | #lyrics a { 55 | color: inherit; 56 | cursor: initial; 57 | } 58 | 59 | /*** NOT YET IMPLEMENTED 60 | #lyrics a { 61 | background-color: #ddd; 62 | color: inherit; 63 | } 64 | 65 | #lyrics a:hover { 66 | background-color: #ccc; 67 | } 68 | ***/ 69 | 70 | nav { 71 | background-color: #ffcd38; 72 | display: flex; 73 | align-items: center; 74 | justify-content: center; 75 | padding: 0.5rem; 76 | } 77 | 78 | nav img { 79 | width: 50px; 80 | } 81 | 82 | a { 83 | text-decoration: none; 84 | } 85 | 86 | #metadata { 87 | display: flex; 88 | flex-direction: column; 89 | gap: 0.5rem; 90 | flex-basis: 0; 91 | } 92 | 93 | #metadata h1 { 94 | font-size: 2rem; 95 | color: #171717; 96 | } 97 | 98 | #metadata h2 { 99 | font-size: 1.4rem; 100 | color: #1e1e1e; 101 | font-weight: 500; 102 | } 103 | 104 | #metadata > img { 105 | width: 20rem; 106 | border-radius: 3px; 107 | box-shadow: 0 1px 1px #ddd; 108 | } 109 | 110 | #container { 111 | display: flex; 112 | padding: 5rem 10rem; 113 | gap: 5rem; 114 | } 115 | 116 | #credits { 117 | display: flex; 118 | flex-direction: column; 119 | gap: 0.5rem; 120 | } 121 | 122 | #title { 123 | font-size: 2rem; 124 | color: #1b1a17; 125 | } 126 | 127 | #credits summary { 128 | font-size: 1.4rem; 129 | cursor: pointer; 130 | color: #1e1e1e; 131 | } 132 | 133 | #credits p { 134 | font-size: 1.3rem; 135 | padding: 0.5rem; 136 | color: #171717; 137 | } 138 | 139 | #info { 140 | display: flex; 141 | flex-direction: column; 142 | gap: 2rem; 143 | flex-basis: 0; 144 | } 145 | 146 | #about { 147 | display: flex; 148 | flex-direction: column; 149 | gap: 0.5rem; 150 | } 151 | 152 | #about p { 153 | font-size: 1.4rem; 154 | color: #171717; 155 | line-height: 1.8rem; 156 | cursor: pointer; 157 | } 158 | 159 | .hidden { 160 | display: none; 161 | } 162 | 163 | #home { 164 | display: flex; 165 | flex-direction: column; 166 | gap: 1.5rem; 167 | align-items: center; 168 | padding: 2rem; 169 | flex-grow: 1; 170 | } 171 | 172 | #home div { 173 | text-align: center; 174 | } 175 | 176 | #home h1 { 177 | font-weight: 600; 178 | font-size: 2.2rem; 179 | color: #222; 180 | } 181 | 182 | #home p { 183 | color: #333; 184 | } 185 | 186 | #home code { 187 | background-color: #eee; 188 | padding: 0.3rem 1rem; 189 | border-radius: 0.5rem; 190 | color: #333; 191 | } 192 | 193 | @media screen and (max-width: 900px) { 194 | #container { 195 | padding: 3rem 2rem; 196 | flex-direction: column; 197 | gap: 3rem; 198 | } 199 | 200 | #metadata { 201 | align-items: center; 202 | } 203 | } 204 | 205 | footer { 206 | text-align: center; 207 | background-color: #ffcd38; 208 | padding: 1rem; 209 | } 210 | 211 | footer a { 212 | font-weight: 500; 213 | color: #1b1a17; 214 | transition: 0.3s ease text-decoration; 215 | font-size: 1.4rem; 216 | text-transform: uppercase; 217 | } 218 | 219 | footer a:hover { 220 | text-decoration: underline; 221 | } 222 | 223 | #app { 224 | display: flex; 225 | flex-direction: column; 226 | min-height: 100vh; 227 | } 228 | 229 | #error { 230 | display: flex; 231 | flex-direction: column; 232 | gap: 1rem; 233 | align-items: center; 234 | justify-content: center; 235 | padding: 2rem; 236 | flex-grow: 1; 237 | } 238 | 239 | #error h1 { 240 | font-size: 5rem; 241 | color: #111; 242 | } 243 | 244 | #error p { 245 | text-transform: uppercase; 246 | font-size: 1.6rem; 247 | color: #222; 248 | text-align: center; 249 | } 250 | 251 | .main { 252 | flex-grow: 1; 253 | } 254 | 255 | #search-page { 256 | display: flex; 257 | flex-direction: column; 258 | gap: 3rem; 259 | padding: 2rem 1rem; 260 | } 261 | 262 | @media screen and (min-width: 800px) { 263 | #search-page { 264 | width: 80rem; 265 | margin: 0 auto; 266 | } 267 | } 268 | 269 | #search-input { 270 | width: 100%; 271 | padding: 1rem 2rem; 272 | box-sizing: border-box; 273 | border-radius: 5px; 274 | border: 1px solid #ddd; 275 | color: #222; 276 | } 277 | 278 | #search-results { 279 | display: flex; 280 | flex-direction: column; 281 | gap: 1rem; 282 | } 283 | 284 | #search-results h1 { 285 | text-align:center; 286 | color: #111; 287 | font-size: 2.5rem; 288 | } 289 | 290 | #search-item { 291 | display: flex; 292 | height: 8rem; 293 | border: 1px solid #eee; 294 | border-radius: 5px; 295 | gap: 1rem; 296 | padding: 1rem; 297 | box-shadow: 0 1px 1px #ddd; 298 | } 299 | 300 | #search-item h2 { 301 | font-size: 1.8rem; 302 | color: #222; 303 | } 304 | 305 | #search-item span { 306 | font-size: 1.3rem; 307 | color: #333; 308 | } 309 | 310 | #search-item img { 311 | width: 8rem; 312 | border-radius: 5px; 313 | } 314 | 315 | /* dark mode */ 316 | @media (prefers-color-scheme: dark) { 317 | body { 318 | background-color: #181d31; 319 | } 320 | 321 | nav, 322 | footer { 323 | background-color: #fec260; 324 | } 325 | 326 | #lyrics { 327 | color: #ccc; 328 | } 329 | 330 | #metadata h1 { 331 | color: #ddd; 332 | } 333 | 334 | #metadata h2, 335 | #credits p { 336 | color: #eee; 337 | } 338 | 339 | #title { 340 | color: #ddd; 341 | } 342 | 343 | #about p, 344 | #credits summary { 345 | color: #ccc; 346 | } 347 | 348 | #home h1, #error h1 { 349 | color: #eee; 350 | } 351 | 352 | #home p, #error p{ 353 | color: #ddd; 354 | } 355 | 356 | #search-input { 357 | background-color: #ddd; 358 | } 359 | 360 | #search-page h1 { 361 | color: #eee; 362 | } 363 | 364 | #search-item { 365 | border: 1px solid #888; 366 | } 367 | 368 | #search-item h2 { 369 | color: #ddd; 370 | } 371 | 372 | #search-item span { 373 | color: #bbb; 374 | } 375 | } 376 | --------------------------------------------------------------------------------