initial commit

This commit is contained in:
Jeremy Baxter 2026-01-04 23:33:46 +13:00
commit 32a57cc050
12 changed files with 1169 additions and 0 deletions

274
server/pages.go Normal file
View file

@ -0,0 +1,274 @@
package server
import (
"cmp"
"embed"
"fmt"
"slices"
"strings"
"html/template"
"math/rand"
"net/http"
"net/url"
"git.baxters.nz/jeremy/records/musicindex"
"git.baxters.nz/jeremy/records/util"
)
//go:embed static/*
var staticFS embed.FS
type Page interface {
Body() template.HTML
SourceFile() string
Title() string
}
func writePage(p Page, w http.ResponseWriter) {
t, err := template.ParseFS(staticFS, "static/templates/" + p.SourceFile())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
err = t.Execute(w, p)
if err != nil {
w.Write([]byte(err.Error()))
}
}
type IndexPage struct {
Showcase string
}
func MakeIndexPage() IndexPage {
var p IndexPage
albums := musicindex.Albums()
rand.Shuffle(len(albums), func(i, j int) {
albums[i], albums[j] = albums[j], albums[i]
})
i := 0
var b strings.Builder
b.WriteString(`<div class="grid">`)
for i < len(albums) {
b.WriteString(`<div class="row">`)
for range 3 {
fmt.Fprintf(&b, `<a href="/%s"><img width="300" height="300" src="/media/%s"></a>`,
util.EscapePath(albums[i].RelativeDirectory()),
util.EscapePath(albums[i].RelativeCoverPath()))
i++
if i >= len(albums) {
break
}
}
b.WriteString(`</div>`)
}
b.WriteString(`</div>`)
p.Showcase = b.String()
return p
}
func (p IndexPage) SourceFile() string { return "base.html" }
func (p IndexPage) Title() string { return "records.baxters.nz" }
func (p IndexPage) Body() template.HTML {
return template.HTML(p.Showcase)
}
func writeSortingWidget(b *strings.Builder, sortBy string, sortOptions []string, count int, itemDesc string) {
b.WriteString(fmt.Sprintf(`<div class="sortby">sorting %d %s by %s;`,
count, itemDesc, sortBy))
for _, v := range sortOptions {
if v == sortBy {
continue
}
b.WriteString(` <a href="?by=` + v + `">` + v + `</a>`)
}
b.WriteString(`</div>`)
}
type ArtistsPage struct {
bodyHTML string
}
func ArtistSortOptions() []string {
return []string{"name", "albums", "songs"}
}
func MakeArtistsPage(sortBy string) ArtistsPage {
var p ArtistsPage
var b strings.Builder
artistNames := musicindex.Artists()
writeSortingWidget(&b, sortBy, ArtistSortOptions(), len(artistNames),
"artist" + util.OptionalString(len(artistNames) != 1, "s"))
b.WriteString(`<div class="artists"><ul>`)
var artists []musicindex.Artist
for _, name := range artistNames {
artists = append(artists, musicindex.FindArtist(name))
}
switch sortBy {
case "albums":
slices.SortFunc(artists, func(a, b musicindex.Artist) int {
return cmp.Compare(len(b.Albums), len(a.Albums))
})
case "songs":
slices.SortFunc(artists, func(a, b musicindex.Artist) int {
return cmp.Compare(b.Songs, a.Songs)
})
case "name": // already sorted
fallthrough
default:
}
for _, artist := range artists {
albums := len(artist.Albums)
b.WriteString(fmt.Sprintf(`<li class="artist"><a href="/%s">%s</a>`,
url.QueryEscape(artist.Name), artist.Name))
b.WriteString(fmt.Sprintf("<br>%d album%s, %d songs</li><br>",
albums, util.OptionalString(albums != 1, "s"), artist.Songs))
}
b.WriteString(`</div>`)
p.bodyHTML = b.String()
return p
}
func (p ArtistsPage) SourceFile() string { return "base.html" }
func (p ArtistsPage) Title() string { return "records.baxters.nz - artists" }
func (p ArtistsPage) Body() template.HTML { return template.HTML(p.bodyHTML) }
type AlbumsPage struct {
bodyHTML string
}
func AlbumSortOptions() []string {
return []string{"name", "artist", "songs"}
}
func writeAlbums(b *strings.Builder, sortBy string, albums []musicindex.Album, artistAlbums bool) {
sortOptions := AlbumSortOptions()
if artistAlbums {
// remove the option to sort by "artist"
sortOptions = slices.DeleteFunc(sortOptions, func(s string) bool {
return s == "artist"
})
}
writeSortingWidget(b, sortBy, sortOptions, len(albums),
"album" + util.OptionalString(len(albums) != 1, "s"))
b.WriteString(`<br><div class="albums"><table><tbody>`)
switch sortBy {
case "artist":
slices.SortFunc(albums, func(a, b musicindex.Album) int {
return cmp.Compare(a.Artist.Name, b.Artist.Name)
})
case "songs":
slices.SortFunc(albums, func(a, b musicindex.Album) int {
return cmp.Compare(len(b.TrackFiles), len(a.TrackFiles))
})
case "name": // already sorted
fallthrough
default:
slices.SortFunc(albums, func(a, b musicindex.Album) int {
return cmp.Compare(a.Name, b.Name)
})
}
for _, album := range albums {
b.WriteString(`<tr class="album"><td><img `)
b.WriteString(fmt.Sprintf(`src="/media/%s" width="100" height="100"></td>`,
url.QueryEscape(album.RelativeCoverPath())))
b.WriteString(fmt.Sprintf(`<td><a href="/%s">%s</a><br><br>%s<br>%d songs</td></tr>`,
util.EscapePath(album.RelativeDirectory()),
album.Name, album.Artist.Name, len(album.TrackFiles)))
}
b.WriteString(`</tbody></table></div>`)
}
func MakeAlbumsPage(sortBy string) AlbumsPage {
var p AlbumsPage
var b strings.Builder
writeAlbums(&b, sortBy, musicindex.Albums(), false)
p.bodyHTML = b.String()
return p
}
func (p AlbumsPage) SourceFile() string { return "base.html" }
func (p AlbumsPage) Title() string { return "records.baxters.nz - albums" }
func (p AlbumsPage) Body() template.HTML { return template.HTML(p.bodyHTML) }
type ArtistPage struct {
Artist musicindex.Artist
bodyHTML string
}
func MakeArtistPage(sortBy string, name string) ArtistPage {
var p ArtistPage
var b strings.Builder
artist := musicindex.FindArtist(name)
b.WriteString(`<h3>` + artist.Name + `</h3>`)
writeAlbums(&b, sortBy, util.MapToValues(artist.Albums), true)
p.Artist = artist
p.bodyHTML = b.String()
return p
}
func (p ArtistPage) SourceFile() string { return "base.html" }
func (p ArtistPage) Title() string { return "records.baxters.nz - artist " + p.Artist.Name }
func (p ArtistPage) Body() template.HTML { return template.HTML(p.bodyHTML) }
type AlbumPage struct {
Album musicindex.Album
bodyHTML string
}
func MakeAlbumPage(album musicindex.Album) AlbumPage {
var p AlbumPage
var b strings.Builder
b.WriteString(`<br><div class="album"><div class="album-cover">`)
b.WriteString(`<img width="300" height="300" src="/media/`)
b.WriteString(album.RelativeCoverPath() + `"></div>`)
b.WriteString(`<div class="album-data"><div class="album-meta"><h3>`)
b.WriteString(album.Name + `</h3><a class="artist" href="/`)
b.WriteString(url.QueryEscape(album.Artist.Name) + `">` + album.Artist.Name)
b.WriteString(`</a></div><br><div class="album-tracks"><ul>`)
for _, track := range album.TrackFiles {
b.WriteString(`<li class="trackfile"><a href="/media/`)
b.WriteString(album.RelativeDirectory() + `/` + track)
b.WriteString(`">` + track + `</a></li>`)
}
tarball := album.Name + ".tar.gz"
tarballURI := "/" + url.QueryEscape(album.Artist.Name) + "/" +
url.QueryEscape(tarball)
b.WriteString(`</ul><h4>Download album</h4><a class="tarball" href="`)
b.WriteString(tarballURI + `">&#x2925; ` + tarball)
b.WriteString(fmt.Sprintf(` (%d MiB)</a></div></div></div>`,
album.TarballSize / (1024 * 1024)))
p.Album = album
p.bodyHTML = b.String()
return p
}
func (p AlbumPage) SourceFile() string { return "base.html" }
func (p AlbumPage) Title() string { return "records.baxters.nz - " + p.Album.Name }
func (p AlbumPage) Body() template.HTML { return template.HTML(p.bodyHTML) }

263
server/server.go Normal file
View file

@ -0,0 +1,263 @@
package server
import (
"log"
"os"
"regexp"
"slices"
"strings"
"net/http"
"net/url"
"path/filepath"
"git.baxters.nz/jeremy/records/musicindex"
"git.baxters.nz/jeremy/records/util"
)
var pagesInitialised = false
var indexPage IndexPage
var artistsPages map[string]ArtistsPage
var albumsPages map[string]AlbumsPage
// mapping of artist names to sort-by strings to pages
var artistPages map[string]map[string]ArtistPage
// mapping of artist names to album names to pages
var albumPages map[string]map[string]AlbumPage
func doIndexPage(w http.ResponseWriter, req *http.Request) {
writePage(indexPage, w)
}
func doArtistsPage(w http.ResponseWriter, req *http.Request) {
by := req.URL.Query().Get("by")
if slices.Contains(ArtistSortOptions(), by) {
writePage(artistsPages[by], w)
return
}
writePage(artistsPages["name"], w)
}
func doAlbumsPage(w http.ResponseWriter, req *http.Request) {
by := req.URL.Query().Get("by")
if slices.Contains(AlbumSortOptions(), by) {
writePage(albumsPages[by], w)
return
}
writePage(albumsPages["name"], w)
}
func detectTextType(fileName string) string {
if strings.HasSuffix(fileName, ".html") {
return "text/html"
}
if strings.HasSuffix(fileName, ".css") {
return "text/css"
}
return "text/plain"
}
func serveStaticFiles(w http.ResponseWriter, req *http.Request) {
if strings.HasPrefix(req.RequestURI, "/static/templates/") {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
contents, err := staticFS.ReadFile(req.RequestURI[1:])
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
contentType := http.DetectContentType(contents)
if strings.HasPrefix(contentType, "text/plain") {
contentType = detectTextType(filepath.Base(req.RequestURI))
}
w.Header().Add("Content-Type", contentType)
w.Write(contents)
}
func serveMediaDirectory(w http.ResponseWriter, req *http.Request) {
path, err := url.QueryUnescape("/" + strings.SplitN(req.RequestURI[1:], "/", 2)[1])
if err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
filePath := musicindex.MediaDirectory() + path
if strings.Contains(path, "/..") || strings.Contains(path, "/.") {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
stat, err := os.Stat(filePath)
if err != nil {
http.Error(w, "not found", http.StatusNotFound)
return
}
if stat.IsDir() {
http.Error(w, "is a directory; maybe you are looking for " + path, http.StatusForbidden)
return
}
f, err := os.Open(filePath)
if err != nil {
http.Error(w, "not found", http.StatusNotFound)
return
}
util.DoChunks(f, func (buf []byte) { w.Write(buf) })
}
func handleArtistAlbumPage(w http.ResponseWriter, req *http.Request) {
// test artist
rx, err := regexp.Compile("^/([^/?]+)/?(\\?.*)?$")
if err != nil {
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
if (rx.MatchString(req.RequestURI)) {
artist, err := url.QueryUnescape(rx.FindStringSubmatch(req.RequestURI)[1])
if err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
if musicindex.ArtistExists(artist) {
by := req.URL.Query().Get("by")
if slices.Contains(AlbumSortOptions(), by) {
writePage(artistPages[artist][by], w)
return
}
writePage(artistPages[artist]["name"], w)
return
}
// artist not found
http.Error(w, "not found", http.StatusNotFound)
return
}
// test album tarball
rx, err = regexp.Compile("^/([^/?]+)/([^/?]+)\\.tar\\.gz$")
if err != nil {
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
if (rx.MatchString(req.RequestURI)) {
captures := rx.FindStringSubmatch(req.RequestURI)
artist, err := url.QueryUnescape(captures[1])
if err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
albumName, err := url.QueryUnescape(captures[2])
if err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
if musicindex.ArtistExists(artist) {
album, ok := musicindex.FindArtist(artist).Albums[albumName]
if ok {
contents, err := os.ReadFile(album.Tarball)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
w.Header().Set("Content-Disposition", `attachment; filename="` +
strings.ReplaceAll(album.Name, `"`, `'`) + `.tar.gz"`)
w.Write(contents)
return
}
}
// artist or album not found
http.Error(w, "not found", http.StatusNotFound)
return
}
// test album
rx, err = regexp.Compile("^/([^/?]+)/([^/?]+)/?(\\?.*)?$")
if err != nil {
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
if (rx.MatchString(req.RequestURI)) {
captures := rx.FindStringSubmatch(req.RequestURI)
artist, err := url.QueryUnescape(captures[1])
if err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
albumName, err := url.QueryUnescape(captures[2])
if err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
if musicindex.ArtistExists(artist) {
album, ok := musicindex.FindArtist(artist).Albums[albumName]
if ok {
writePage(albumPages[artist][album.Name], w)
return
}
}
// artist or album not found
http.Error(w, "not found", http.StatusNotFound)
return
}
// illegal URI format
http.Error(w, "not found", http.StatusNotFound)
}
func RunOn(address string) {
if !pagesInitialised {
indexPage = MakeIndexPage()
artistsPages = make(map[string]ArtistsPage)
for _, v := range ArtistSortOptions() {
artistsPages[v] = MakeArtistsPage(v)
}
albumsPages = make(map[string]AlbumsPage)
for _, v := range AlbumSortOptions() {
albumsPages[v] = MakeAlbumsPage(v)
}
artistPages = make(map[string]map[string]ArtistPage)
for _, artist := range musicindex.Artists() {
artistPages[artist] = make(map[string]ArtistPage)
for _, v := range AlbumSortOptions() {
artistPages[artist][v] = MakeArtistPage(v, artist)
}
}
albumPages = make(map[string]map[string]AlbumPage)
for _, artist := range musicindex.Artists() {
albumPages[artist] = make(map[string]AlbumPage)
for _, album := range musicindex.FindArtist(artist).Albums {
albumPages[artist][album.Name] = MakeAlbumPage(album)
}
}
pagesInitialised = true
}
http.HandleFunc("/{$}", doIndexPage)
http.HandleFunc("/index/artists", doArtistsPage)
http.HandleFunc("/index/albums", doAlbumsPage)
http.HandleFunc("/static/", serveStaticFiles)
http.HandleFunc("/media/", serveMediaDirectory)
http.HandleFunc("/", handleArtistAlbumPage)
log.Printf("listening on %s\n", address)
err := http.ListenAndServe(address, nil)
util.Die("%s", err.Error())
}
func Run() {
RunOn(":8000")
}

94
server/static/style.css Normal file
View file

@ -0,0 +1,94 @@
body {
background-color: #11111b;
color: #cdd6f4;
font-family: sans-serif;
font-size: large;
text-align: center;
}
a, a:visited {
color: #cdd6f4;
font-weight: bold;
text-decoration: none;
}
a:hover {
background-color: #cdd6f4;
color: #11111b;
}
div.grid {
line-height: 0;
}
div.artists ul {
display: inline-block;
font-style: italic;
list-style-type: none;
padding: 0;
}
li.artist a {
font-style: normal;
}
div.albums {
display: flex;
justify-content: center;
}
div.album {
display: flex;
justify-content: center
}
div.album-data {
padding-left: 30px;
text-align: left;
}
div.album-meta h3 {
margin-top: 0;
}
div.album-tracks ul {
font-family: monospace;
list-style-type: none;
margin-top: 0;
padding: 0;
}
li.trackfile a {
color: #89b4fa;
}
li.trackfile a:hover {
background-color: #89b4fa;
color: #11111b;
}
a.artist {
font-style: italic;
color: #cdd6f4;
text-decoration: none;
font-weight: normal;
}
a.artist:hover {
font-style: italic;
color: #cdd6f4;
background-color: #11111b;
text-decoration: underline;
}
a.tarball {
font-family: monospace;
color: #89b4fa;
}
a.tarball:hover {
font-family: monospace;
background-color: #89b4fa;
color: #11111b;
}
footer hr {
width: 50%
}

View file

@ -0,0 +1,22 @@
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<link rel="stylesheet" href="/static/style.css">
<title>{{.Title}}</title>
</head>
<body>
<h2>records.baxters.nz</h2>
<nav>
<a href="/">home</a> &#x2022;
<a href="/index/artists">artists</a> &#x2022;
<a href="/index/albums">albums</a>
</nav>
<br>
{{.Body}}
<footer>
<hr>
<i>records</i>
</footer>
</body>
</html>