initial commit
This commit is contained in:
commit
32a57cc050
12 changed files with 1169 additions and 0 deletions
11
Makefile
Normal file
11
Makefile
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
GO = go
|
||||
|
||||
all: records
|
||||
|
||||
records:
|
||||
${GO} build -o $@ ${GOFLAGS}
|
||||
|
||||
clean:
|
||||
rm -f records
|
||||
|
||||
.PHONY: all clean records
|
||||
10
go.mod
Normal file
10
go.mod
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
module git.baxters.nz/jeremy/records
|
||||
|
||||
go 1.24.0
|
||||
|
||||
require (
|
||||
github.com/pborman/getopt v1.1.0
|
||||
github.com/walle/targz v0.0.0-20140417120357-57fe4206da5a
|
||||
golang.org/x/sys v0.39.0
|
||||
golang.org/x/term v0.38.0
|
||||
)
|
||||
8
go.sum
Normal file
8
go.sum
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
github.com/pborman/getopt v1.1.0 h1:eJ3aFZroQqq0bWmraivjQNt6Dmm5M0h2JcDW38/Azb0=
|
||||
github.com/pborman/getopt v1.1.0/go.mod h1:FxXoW1Re00sQG/+KIkuSqRL/LwQgSkv7uyac+STFsbk=
|
||||
github.com/walle/targz v0.0.0-20140417120357-57fe4206da5a h1:6cKSHLRphD9Fo1LJlISiulvgYCIafJ3QfKLimPYcAGc=
|
||||
github.com/walle/targz v0.0.0-20140417120357-57fe4206da5a/go.mod h1:nccQrXCnc5SjsThFLmL7hYbtT/mHJcuolPifzY5vJqE=
|
||||
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
|
||||
golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
|
||||
60
main.go
Normal file
60
main.go
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"github.com/pborman/getopt"
|
||||
|
||||
"git.baxters.nz/jeremy/records/musicindex"
|
||||
"git.baxters.nz/jeremy/records/server"
|
||||
"git.baxters.nz/jeremy/records/util"
|
||||
)
|
||||
|
||||
const version = "0-pre"
|
||||
|
||||
var addr *string
|
||||
var port *uint16
|
||||
var showVersion *bool
|
||||
|
||||
func main() {
|
||||
addr = getopt.String('a', "0.0.0.0")
|
||||
port = getopt.Uint16('p', 8000)
|
||||
showVersion = getopt.Bool('V')
|
||||
if err := getopt.Getopt(nil); err != nil {
|
||||
util.Die(err.Error())
|
||||
}
|
||||
|
||||
if *showVersion {
|
||||
fmt.Printf("records %s\n", version)
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
args := getopt.Args()
|
||||
if len(args) != 1 {
|
||||
fmt.Fprintf(os.Stderr, "usage: %s [-V] [-a address] [-p port] directory\n", os.Args[0])
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
mediaDir := args[0]
|
||||
|
||||
log.Printf("This is records %s\n", version)
|
||||
log.Println("https://git.baxters.nz/jeremy/records")
|
||||
|
||||
musicindex.Init(mediaDir)
|
||||
|
||||
var artistCount, albumCount, songCount int
|
||||
artistCount = len(musicindex.Artists())
|
||||
albumCount = 0
|
||||
songCount = 0
|
||||
for _, album := range musicindex.Albums() {
|
||||
songCount += len(album.TrackFiles)
|
||||
albumCount++
|
||||
}
|
||||
|
||||
log.Printf("indexed %d artists, %d albums and %d songs\n",
|
||||
artistCount, albumCount, songCount);
|
||||
|
||||
server.RunOn(fmt.Sprintf("%s:%d", *addr, *port))
|
||||
}
|
||||
193
musicindex/index.go
Normal file
193
musicindex/index.go
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
package musicindex
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log"
|
||||
"os"
|
||||
"os/user"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
"github.com/walle/targz"
|
||||
|
||||
"git.baxters.nz/jeremy/records/util"
|
||||
)
|
||||
|
||||
var TempDir = "/tmp/records"
|
||||
|
||||
func assertAccessTo(path string) {
|
||||
f, err := os.Stat(path)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
util.Die("no such file or directory: %s", path)
|
||||
}
|
||||
util.Die("cannot stat %s: %s", path, err.Error())
|
||||
}
|
||||
if !f.IsDir() {
|
||||
util.Die("not a directory: %s", path)
|
||||
}
|
||||
|
||||
if unix.Access(path, unix.R_OK) == nil {
|
||||
return
|
||||
}
|
||||
|
||||
procUser, err := user.Current()
|
||||
if err != nil {
|
||||
util.Die("cannot access directory: %s", path)
|
||||
return
|
||||
}
|
||||
|
||||
groupObject, err := user.LookupGroupId(strconv.Itoa(syscall.Getgid()))
|
||||
if err != nil {
|
||||
util.Die("cannot access directory: %s", path)
|
||||
return
|
||||
}
|
||||
|
||||
util.Die("cannot access directory %s\nrepair all directories with: chown -R %s:%s %s",
|
||||
path, procUser.Username, groupObject.Name, mediaDir)
|
||||
}
|
||||
|
||||
func indexArtists() {
|
||||
entries, err := util.Dirents(mediaDir)
|
||||
if err != nil {
|
||||
util.Die("%s", err.Error())
|
||||
}
|
||||
|
||||
err = os.RemoveAll(TempDir)
|
||||
if err != nil {
|
||||
util.Die("cannot remove temporary directory %s: %s", TempDir, err.Error())
|
||||
}
|
||||
err = os.Mkdir(TempDir, 0711)
|
||||
if err != nil {
|
||||
util.Die("cannot make temporary directory %s: %s", TempDir, err.Error())
|
||||
}
|
||||
|
||||
artists = make(map[string]Artist)
|
||||
for _, artist := range entries {
|
||||
stat, err := os.Stat(mediaDir + "/" + artist)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if !stat.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
var a Artist
|
||||
a.Name = artist
|
||||
a.Albums = indexAlbums(&a)
|
||||
a.Songs = 0
|
||||
a.Valid = true
|
||||
|
||||
for _, album := range a.Albums {
|
||||
a.Songs += len(album.TrackFiles)
|
||||
}
|
||||
|
||||
artists[a.Name] = a
|
||||
}
|
||||
}
|
||||
|
||||
func indexAlbums(artist *Artist) (albums map[string]Album) {
|
||||
artistDir := mediaDir + "/" + artist.Name
|
||||
assertAccessTo(artistDir)
|
||||
entries, err := util.Dirents(artistDir)
|
||||
if err != nil {
|
||||
util.Die("%s", err.Error())
|
||||
}
|
||||
|
||||
albums = make(map[string]Album)
|
||||
for _, albumName := range entries {
|
||||
util.Iprint("* index %s - %s\r", artist.Name, albumName)
|
||||
|
||||
albumDir := artistDir + "/" + albumName
|
||||
album, err := indexAlbum(artist, albumName, albumDir)
|
||||
if err != nil {
|
||||
log.Printf("warn: skipping inaccessible album %s: %s", albumName, err.Error())
|
||||
} else {
|
||||
albums[albumName] = album
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func indexAlbum(artist *Artist, albumName, albumDir string) (a Album, err error) {
|
||||
stat, err := os.Stat(albumDir)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if !stat.IsDir() {
|
||||
err = errors.New("not a directory")
|
||||
}
|
||||
|
||||
a.Artist = artist
|
||||
a.Name = albumName
|
||||
a.HasCover = false
|
||||
a.CoverFile = ""
|
||||
|
||||
err = unix.Access(albumDir + "/" + defaultCoverFile, unix.R_OK)
|
||||
if err == nil {
|
||||
a.HasCover = true
|
||||
a.CoverFile = defaultCoverFile
|
||||
}
|
||||
|
||||
var tracks []string
|
||||
albumDirEntries, err := util.Dirents(albumDir)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, track := range albumDirEntries {
|
||||
if strings.HasSuffix(track, trackFileExtension) {
|
||||
tracks = append(tracks, track)
|
||||
}
|
||||
}
|
||||
|
||||
slices.Sort(tracks)
|
||||
a.TrackFiles = tracks
|
||||
|
||||
hash := util.HashOf(a.Artist.Name + "::" + a.Name)
|
||||
path := TempDir + "/" + hash
|
||||
|
||||
if _, err = os.Stat(path); errors.Is(err, os.ErrNotExist) {
|
||||
a.Tarball = path
|
||||
} else if err == nil {
|
||||
a.Tarball = ""
|
||||
for i := range 9 {
|
||||
if _, err = os.Stat(fmt.Sprintf("%s.%d", path, i)); err == nil {
|
||||
continue
|
||||
} else if errors.Is(err, os.ErrNotExist) {
|
||||
a.Tarball = path
|
||||
} else {
|
||||
err = errors.New("cannot create tarball in filesystem: " +
|
||||
err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
if len(a.Tarball) == 0 {
|
||||
err = errors.New("cannot create tarball in filesystem; " +
|
||||
"please clean out " + TempDir)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
err = errors.New("cannot create tarball in filesystem: " + err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
a.Tarball = path
|
||||
err = targz.Compress(a.Directory(), a.Tarball)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
tarball, err := os.Stat(a.Tarball)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
a.TarballSize = tarball.Size()
|
||||
|
||||
err = nil
|
||||
return
|
||||
}
|
||||
118
musicindex/musicindex.go
Normal file
118
musicindex/musicindex.go
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
// musicindex.go
|
||||
// Copyright (c) 2025 Jeremy Baxter.
|
||||
|
||||
// Ephemeral music (artist, album, cover & track) index
|
||||
|
||||
package musicindex
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"slices"
|
||||
|
||||
"git.baxters.nz/jeremy/records/util"
|
||||
)
|
||||
|
||||
type Artist struct {
|
||||
Name string
|
||||
Albums map[string]Album
|
||||
Songs int
|
||||
Valid bool
|
||||
}
|
||||
|
||||
type Album struct {
|
||||
Artist *Artist
|
||||
Name string
|
||||
HasCover bool
|
||||
CoverFile string
|
||||
TrackFiles []string
|
||||
Tarball string
|
||||
TarballSize int64
|
||||
}
|
||||
|
||||
var initialised = false
|
||||
var artists map[string]Artist
|
||||
var coverFileExtension string
|
||||
var defaultCoverFile string
|
||||
var mediaDir string
|
||||
var trackFileExtension string
|
||||
|
||||
func Init(path string) {
|
||||
InitWith(path, ".flac", "cover.jpg", ".jpg")
|
||||
}
|
||||
|
||||
func InitWith(path, mediaExtension, coverFile, coverExtension string) {
|
||||
if initialised {
|
||||
return
|
||||
}
|
||||
|
||||
mediaDir = path
|
||||
trackFileExtension = mediaExtension
|
||||
defaultCoverFile = coverFile
|
||||
coverFileExtension = coverExtension
|
||||
|
||||
assertAccessTo(mediaDir)
|
||||
indexArtists()
|
||||
initialised = true
|
||||
}
|
||||
|
||||
func MediaDirectory() string {
|
||||
return mediaDir
|
||||
}
|
||||
|
||||
func Artists() (l []string) {
|
||||
for k, v := range artists {
|
||||
util.Assert(k == v.Name)
|
||||
l = append(l, k)
|
||||
}
|
||||
|
||||
slices.Sort(l)
|
||||
return
|
||||
}
|
||||
|
||||
func ArtistExists(name string) bool {
|
||||
_, ok := artists[name]
|
||||
return ok
|
||||
}
|
||||
|
||||
func FindArtist(name string) (a Artist) {
|
||||
if ArtistExists(name) {
|
||||
return artists[name]
|
||||
}
|
||||
|
||||
a.Name = "Invalid artist"
|
||||
a.Valid = false
|
||||
return
|
||||
}
|
||||
|
||||
func ArtistDirectory(name string) string {
|
||||
return mediaDir + "/" + name
|
||||
}
|
||||
|
||||
func (a Album) RelativeDirectory() string {
|
||||
return a.Artist.Name + "/" + a.Name
|
||||
}
|
||||
|
||||
func (a Album) Directory() string {
|
||||
return mediaDir + "/" + a.RelativeDirectory()
|
||||
}
|
||||
|
||||
func (a Album) RelativeCoverPath() string {
|
||||
return a.RelativeDirectory() + "/" + a.CoverFile
|
||||
}
|
||||
|
||||
func (a Album) CoverPath() string {
|
||||
return mediaDir + "/" + a.RelativeCoverPath()
|
||||
}
|
||||
|
||||
func Albums() (albums []Album) {
|
||||
for _, artist := range Artists() {
|
||||
for _, album := range FindArtist(artist).Albums {
|
||||
albums = append(albums, album)
|
||||
}
|
||||
}
|
||||
|
||||
slices.SortFunc(albums, func(a, b Album) int {
|
||||
return cmp.Compare(a.Name, b.Name)
|
||||
})
|
||||
return
|
||||
}
|
||||
274
server/pages.go
Normal file
274
server/pages.go
Normal 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 + `">⤥ ` + 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
263
server/server.go
Normal 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
94
server/static/style.css
Normal 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%
|
||||
}
|
||||
22
server/static/templates/base.html
Normal file
22
server/static/templates/base.html
Normal 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> •
|
||||
<a href="/index/artists">artists</a> •
|
||||
<a href="/index/albums">albums</a>
|
||||
</nav>
|
||||
<br>
|
||||
{{.Body}}
|
||||
<footer>
|
||||
<hr>
|
||||
<i>records</i>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
1
static
Symbolic link
1
static
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
server/static
|
||||
115
util/util.go
Normal file
115
util/util.go
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
package util
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
func Assert(condition bool) {
|
||||
if !condition {
|
||||
panic("assertion failed!")
|
||||
}
|
||||
}
|
||||
|
||||
func Dirents(dir string) (entries []string, err error) {
|
||||
d, err := os.Open(dir)
|
||||
if err != nil {
|
||||
return nil, errors.New(err.Error())
|
||||
}
|
||||
|
||||
names, err := d.Readdirnames(0)
|
||||
if err != nil {
|
||||
return nil, errors.New(err.Error())
|
||||
}
|
||||
|
||||
for _, e := range names {
|
||||
entries = append(entries, e)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func DoChunks(f io.Reader, fun func (buf []byte)) (err error) {
|
||||
err = nil
|
||||
bytes, chunks := int64(0), int64(0)
|
||||
r := bufio.NewReader(f)
|
||||
buf := make([]byte, 0, 4*1024)
|
||||
|
||||
for {
|
||||
var n int
|
||||
n, err = r.Read(buf[:cap(buf)])
|
||||
buf = buf[:n]
|
||||
if n == 0 {
|
||||
if err == nil {
|
||||
continue
|
||||
}
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
return
|
||||
}
|
||||
chunks++
|
||||
bytes += int64(len(buf))
|
||||
|
||||
Assert(n == len(buf))
|
||||
fun(buf)
|
||||
|
||||
if err != nil && err != io.EOF {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func EscapePath(path string) string {
|
||||
return strings.ReplaceAll(url.QueryEscape(path), "%2F", "/")
|
||||
}
|
||||
|
||||
func HashOf(s string) string {
|
||||
h := sha256.New()
|
||||
h.Write([]byte(s))
|
||||
return fmt.Sprintf("%x", h.Sum(nil))
|
||||
}
|
||||
|
||||
// print only if interactive
|
||||
func Iprint(format string, args ...any) {
|
||||
if term.IsTerminal(int(os.Stderr.Fd())) {
|
||||
fmt.Fprintf(os.Stderr, format, args...)
|
||||
}
|
||||
}
|
||||
|
||||
func MapToValues[Map ~map[K]V, K comparable, V any](m Map) (values []V) {
|
||||
for _, v := range m {
|
||||
values = append(values, v)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func OptionalString(cond bool, s string) string {
|
||||
if cond {
|
||||
return s
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func Warn(format string, args ...any) {
|
||||
fmt.Fprintf(os.Stderr, "%s: %s\n",
|
||||
filepath.Base(os.Args[0]), fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
func Die(format string, args ...any) {
|
||||
Warn(format, args...)
|
||||
os.Exit(1)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue