From 32a57cc0502328a7ec3784e5812faa9ba20bc2a5 Mon Sep 17 00:00:00 2001 From: Jeremy Baxter Date: Sun, 4 Jan 2026 23:33:46 +1300 Subject: [PATCH] initial commit --- Makefile | 11 ++ go.mod | 10 ++ go.sum | 8 + main.go | 60 +++++++ musicindex/index.go | 193 +++++++++++++++++++++ musicindex/musicindex.go | 118 +++++++++++++ server/pages.go | 274 ++++++++++++++++++++++++++++++ server/server.go | 263 ++++++++++++++++++++++++++++ server/static/style.css | 94 ++++++++++ server/static/templates/base.html | 22 +++ static | 1 + util/util.go | 115 +++++++++++++ 12 files changed, 1169 insertions(+) create mode 100644 Makefile create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 musicindex/index.go create mode 100644 musicindex/musicindex.go create mode 100644 server/pages.go create mode 100644 server/server.go create mode 100644 server/static/style.css create mode 100644 server/static/templates/base.html create mode 120000 static create mode 100644 util/util.go diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..92f1f60 --- /dev/null +++ b/Makefile @@ -0,0 +1,11 @@ +GO = go + +all: records + +records: + ${GO} build -o $@ ${GOFLAGS} + +clean: + rm -f records + +.PHONY: all clean records diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..513bf9e --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..412e9c0 --- /dev/null +++ b/go.sum @@ -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= diff --git a/main.go b/main.go new file mode 100644 index 0000000..9634176 --- /dev/null +++ b/main.go @@ -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)) +} diff --git a/musicindex/index.go b/musicindex/index.go new file mode 100644 index 0000000..76a983e --- /dev/null +++ b/musicindex/index.go @@ -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 +} diff --git a/musicindex/musicindex.go b/musicindex/musicindex.go new file mode 100644 index 0000000..c29406c --- /dev/null +++ b/musicindex/musicindex.go @@ -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 +} diff --git a/server/pages.go b/server/pages.go new file mode 100644 index 0000000..3d21cee --- /dev/null +++ b/server/pages.go @@ -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(`
`) + + for i < len(albums) { + b.WriteString(`
`) + for range 3 { + fmt.Fprintf(&b, ``, + util.EscapePath(albums[i].RelativeDirectory()), + util.EscapePath(albums[i].RelativeCoverPath())) + i++ + if i >= len(albums) { + break + } + } + b.WriteString(`
`) + } + b.WriteString(`
`) + 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(`
sorting %d %s by %s;`, + count, itemDesc, sortBy)) + for _, v := range sortOptions { + if v == sortBy { + continue + } + b.WriteString(` ` + v + ``) + } + b.WriteString(`
`) +} + +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(`
`) + 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(`
`) + + 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(``, + url.QueryEscape(album.RelativeCoverPath()))) + b.WriteString(fmt.Sprintf(``, + util.EscapePath(album.RelativeDirectory()), + album.Name, album.Artist.Name, len(album.TrackFiles))) + } + b.WriteString(`
%s

%s
%d songs
`) +} + +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(`

` + artist.Name + `

`) + + 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(`
`) + b.WriteString(`
`) + b.WriteString(`

`) + b.WriteString(album.Name + `

` + album.Artist.Name) + b.WriteString(`

    `) + + for _, track := range album.TrackFiles { + b.WriteString(`
  • ` + track + `
  • `) + } + + tarball := album.Name + ".tar.gz" + tarballURI := "/" + url.QueryEscape(album.Artist.Name) + "/" + + url.QueryEscape(tarball) + + b.WriteString(`

Download album

⤥ ` + tarball) + b.WriteString(fmt.Sprintf(` (%d MiB)
`, + 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) } diff --git a/server/server.go b/server/server.go new file mode 100644 index 0000000..fc95364 --- /dev/null +++ b/server/server.go @@ -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") +} diff --git a/server/static/style.css b/server/static/style.css new file mode 100644 index 0000000..7654622 --- /dev/null +++ b/server/static/style.css @@ -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% +} diff --git a/server/static/templates/base.html b/server/static/templates/base.html new file mode 100644 index 0000000..c331a7c --- /dev/null +++ b/server/static/templates/base.html @@ -0,0 +1,22 @@ + + + + + + {{.Title}} + + +

records.baxters.nz

+ +
+ {{.Body}} + + + diff --git a/static b/static new file mode 120000 index 0000000..382d3e6 --- /dev/null +++ b/static @@ -0,0 +1 @@ +server/static \ No newline at end of file diff --git a/util/util.go b/util/util.go new file mode 100644 index 0000000..79c32f6 --- /dev/null +++ b/util/util.go @@ -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) +}