Compare commits

..

No commits in common. "3bfcf5794f51f5001f8c40cd2d8b9e1a0f6792f1" and "cf8959b1d738ecf47b8cc86bbaef41ac5c358045" have entirely different histories.

6 changed files with 47 additions and 112 deletions

29
main.go
View file

@ -1,6 +1,3 @@
// Copyright (c) 2026 Jeremy Baxter.
// entry point for records
package main package main
import ( import (
@ -19,33 +16,18 @@ const version = "0-pre"
var addr *string var addr *string
var port *uint16 var port *uint16
var tempDir *string
var deleteAndExit *bool
var regenerate *bool var regenerate *bool
var showVersion *bool var showVersion *bool
func main() { func main() {
addr = getopt.String('a', "0.0.0.0") addr = getopt.String('a', "0.0.0.0")
port = getopt.Uint16('p', 8000) port = getopt.Uint16('p', 8000)
tempDir = getopt.String('t', "")
deleteAndExit = getopt.Bool('d')
regenerate = getopt.Bool('r') regenerate = getopt.Bool('r')
showVersion = getopt.Bool('V') showVersion = getopt.Bool('V')
if err := getopt.Getopt(nil); err != nil { if err := getopt.Getopt(nil); err != nil {
util.Die(err.Error()) util.Die(err.Error())
} }
if len(*tempDir) != 0 {
musicindex.TempDir = *tempDir
}
if *deleteAndExit {
err := os.RemoveAll(musicindex.TempDir)
if err != nil {
util.Die("cannot remove %s: %s", musicindex.TempDir, err.Error())
}
os.Exit(0)
}
if *showVersion { if *showVersion {
fmt.Printf("records %s\n", version) fmt.Printf("records %s\n", version)
os.Exit(0) os.Exit(0)
@ -53,20 +35,13 @@ func main() {
args := getopt.Args() args := getopt.Args()
if len(args) != 1 { if len(args) != 1 {
fmt.Fprintf(os.Stderr, "usage: %s [-drV] [-a address] [-p port] directory\n", os.Args[0]) fmt.Fprintf(os.Stderr, "usage: %s [-rV] [-a address] [-p port] directory\n", os.Args[0])
os.Exit(1) os.Exit(1)
} }
mediaDir := args[0] mediaDir := args[0]
{ log.Printf("This is records %s\n", version)
interactive := ""
if util.Interactive() {
interactive = ", running with interactive features"
}
log.Printf("This is records %s%s", version, interactive)
}
log.Println("https://git.baxters.nz/jeremy/records") log.Println("https://git.baxters.nz/jeremy/records")
musicindex.Init(mediaDir, *regenerate) musicindex.Init(mediaDir, *regenerate)

View file

@ -1,6 +1,3 @@
// Copyright (c) 2026 Jeremy Baxter.
// Music library indexing code
package musicindex package musicindex
import ( import (
@ -116,13 +113,11 @@ func indexAlbums(artist *Artist) (albums map[string]Album) {
albums = make(map[string]Album) albums = make(map[string]Album)
for _, albumName := range entries { for _, albumName := range entries {
util.Iprint("\033[2K\r \033[1;36mcaching\033[0m %s - %s", util.Iprint("* index %s - %s\r", artist.Name, albumName)
artist.Name, albumName)
albumDir := artistDir + "/" + albumName albumDir := artistDir + "/" + albumName
album, err := indexAlbum(artist, albumName, albumDir) album, err := indexAlbum(artist, albumName, albumDir)
if err != nil { if err != nil {
util.Iprint("\r")
log.Printf("warn: skipping inaccessible album %s: %s", albumName, err.Error()) log.Printf("warn: skipping inaccessible album %s: %s", albumName, err.Error())
} else { } else {
albums[albumName] = album albums[albumName] = album

View file

@ -1,5 +1,7 @@
// Copyright (c) 2026 Jeremy Baxter. // musicindex.go
// In-memory album and song database // Copyright (c) 2025 Jeremy Baxter.
// Ephemeral music (artist, album, cover & track) index
package musicindex package musicindex

View file

@ -1,6 +1,3 @@
// Copyright (c) 2026 Jeremy Baxter.
// HTML builders
package server package server
import ( import (
@ -31,8 +28,7 @@ type Page interface {
func writePage(p Page, w http.ResponseWriter) { func writePage(p Page, w http.ResponseWriter) {
t, err := template.ParseFS(staticFS, "static/templates/" + p.SourceFile()) t, err := template.ParseFS(staticFS, "static/templates/" + p.SourceFile())
if err != nil { if err != nil {
http.Error(w, "Internal server error: " + err.Error(), http.Error(w, err.Error(), http.StatusInternalServerError)
http.StatusInternalServerError)
return return
} }
@ -42,26 +38,13 @@ func writePage(p Page, w http.ResponseWriter) {
} }
} }
type ErrorPage struct {
Status string
Reason string
}
func MakeErrorPage(status, reason string) (p ErrorPage) {
p.Status = status
p.Reason = reason
return
}
func (p ErrorPage) SourceFile() string { return "error.html" }
func (p ErrorPage) Title() string { return "" }
func (p ErrorPage) Body() template.HTML { return template.HTML("") }
type IndexPage struct { type IndexPage struct {
Showcase string Showcase string
} }
func MakeIndexPage() (p IndexPage) { func MakeIndexPage() IndexPage {
var p IndexPage
albums := musicindex.Albums() albums := musicindex.Albums()
rand.Shuffle(len(albums), func(i, j int) { rand.Shuffle(len(albums), func(i, j int) {
albums[i], albums[j] = albums[j], albums[i] albums[i], albums[j] = albums[j], albums[i]
@ -85,11 +68,9 @@ func MakeIndexPage() (p IndexPage) {
} }
b.WriteString(`</div>`) b.WriteString(`</div>`)
} }
b.WriteString(`</div>`) b.WriteString(`</div>`)
p.Showcase = b.String() p.Showcase = b.String()
return p
return
} }
func (p IndexPage) SourceFile() string { return "base.html" } func (p IndexPage) SourceFile() string { return "base.html" }
@ -118,7 +99,8 @@ func ArtistSortOptions() []string {
return []string{"name", "albums", "songs"} return []string{"name", "albums", "songs"}
} }
func MakeArtistsPage(sortBy string) (p ArtistsPage) { func MakeArtistsPage(sortBy string) ArtistsPage {
var p ArtistsPage
var b strings.Builder var b strings.Builder
artistNames := musicindex.Artists() artistNames := musicindex.Artists()
@ -153,11 +135,9 @@ func MakeArtistsPage(sortBy string) (p ArtistsPage) {
b.WriteString(fmt.Sprintf("<br>%d album%s, %d songs</li><br>", b.WriteString(fmt.Sprintf("<br>%d album%s, %d songs</li><br>",
albums, util.OptionalString(albums != 1, "s"), artist.Songs)) albums, util.OptionalString(albums != 1, "s"), artist.Songs))
} }
b.WriteString(`</div>`) b.WriteString(`</div>`)
p.bodyHTML = b.String() p.bodyHTML = b.String()
return p
return
} }
func (p ArtistsPage) SourceFile() string { return "base.html" } func (p ArtistsPage) SourceFile() string { return "base.html" }
@ -213,13 +193,14 @@ func writeAlbums(b *strings.Builder, sortBy string, albums []musicindex.Album, a
b.WriteString(`</tbody></table></div>`) b.WriteString(`</tbody></table></div>`)
} }
func MakeAlbumsPage(sortBy string) (p AlbumsPage) { func MakeAlbumsPage(sortBy string) AlbumsPage {
var p AlbumsPage
var b strings.Builder var b strings.Builder
writeAlbums(&b, sortBy, musicindex.Albums(), false) writeAlbums(&b, sortBy, musicindex.Albums(), false)
p.bodyHTML = b.String() p.bodyHTML = b.String()
return return p
} }
func (p AlbumsPage) SourceFile() string { return "base.html" } func (p AlbumsPage) SourceFile() string { return "base.html" }
@ -231,7 +212,8 @@ type ArtistPage struct {
bodyHTML string bodyHTML string
} }
func MakeArtistPage(sortBy string, name string) (p ArtistPage) { func MakeArtistPage(sortBy string, name string) ArtistPage {
var p ArtistPage
var b strings.Builder var b strings.Builder
artist := musicindex.FindArtist(name) artist := musicindex.FindArtist(name)
@ -242,7 +224,7 @@ func MakeArtistPage(sortBy string, name string) (p ArtistPage) {
p.Artist = artist p.Artist = artist
p.bodyHTML = b.String() p.bodyHTML = b.String()
return return p
} }
func (p ArtistPage) SourceFile() string { return "base.html" } func (p ArtistPage) SourceFile() string { return "base.html" }
@ -254,7 +236,8 @@ type AlbumPage struct {
bodyHTML string bodyHTML string
} }
func MakeAlbumPage(album musicindex.Album) (p AlbumPage) { func MakeAlbumPage(album musicindex.Album) AlbumPage {
var p AlbumPage
var b strings.Builder var b strings.Builder
b.WriteString(`<br><div class="album"><div class="album-cover">`) b.WriteString(`<br><div class="album"><div class="album-cover">`)
@ -283,7 +266,7 @@ func MakeAlbumPage(album musicindex.Album) (p AlbumPage) {
p.Album = album p.Album = album
p.bodyHTML = b.String() p.bodyHTML = b.String()
return return p
} }
func (p AlbumPage) SourceFile() string { return "base.html" } func (p AlbumPage) SourceFile() string { return "base.html" }

View file

@ -1,6 +1,3 @@
// Copyright (c) 2026 Jeremy Baxter.
// HTTP music server
package server package server
import ( import (
@ -28,16 +25,6 @@ var artistPages map[string]map[string]ArtistPage
// mapping of artist names to album names to pages // mapping of artist names to album names to pages
var albumPages map[string]map[string]AlbumPage var albumPages map[string]map[string]AlbumPage
func httpErrorReason(w http.ResponseWriter, error int, reason string) {
errorName := fmt.Sprintf("%d %s", error, http.StatusText(error))
w.WriteHeader(error)
writePage(MakeErrorPage(errorName, reason), w)
}
func httpError(w http.ResponseWriter, error int) {
httpErrorReason(w, error, "")
}
func doIndexPage(w http.ResponseWriter, req *http.Request) { func doIndexPage(w http.ResponseWriter, req *http.Request) {
writePage(indexPage, w) writePage(indexPage, w)
} }
@ -74,13 +61,13 @@ func detectTextType(fileName string) string {
func serveStaticFiles(w http.ResponseWriter, req *http.Request) { func serveStaticFiles(w http.ResponseWriter, req *http.Request) {
if strings.HasPrefix(req.RequestURI, "/static/templates/") { if strings.HasPrefix(req.RequestURI, "/static/templates/") {
httpError(w, http.StatusForbidden) http.Error(w, "forbidden", http.StatusForbidden)
return return
} }
contents, err := staticFS.ReadFile(req.RequestURI[1:]) contents, err := staticFS.ReadFile(req.RequestURI[1:])
if err != nil { if err != nil {
httpError(w, http.StatusNotFound) http.Error(w, err.Error(), http.StatusNotFound)
return return
} }
@ -96,31 +83,30 @@ func serveStaticFiles(w http.ResponseWriter, req *http.Request) {
func serveMediaDirectory(w http.ResponseWriter, req *http.Request) { func serveMediaDirectory(w http.ResponseWriter, req *http.Request) {
path, err := url.QueryUnescape("/" + strings.SplitN(req.RequestURI[1:], "/", 2)[1]) path, err := url.QueryUnescape("/" + strings.SplitN(req.RequestURI[1:], "/", 2)[1])
if err != nil { if err != nil {
httpError(w, http.StatusBadRequest) http.Error(w, "bad request", http.StatusBadRequest)
return return
} }
filePath := musicindex.MediaDirectory() + path filePath := musicindex.MediaDirectory() + path
if strings.Contains(path, "/../") || strings.Contains(path, "/./") { if strings.Contains(path, "/..") || strings.Contains(path, "/.") {
httpError(w, http.StatusForbidden) http.Error(w, "forbidden", http.StatusForbidden)
return return
} }
stat, err := os.Stat(filePath) stat, err := os.Stat(filePath)
if err != nil { if err != nil {
httpError(w, http.StatusNotFound) http.Error(w, "not found", http.StatusNotFound)
return return
} }
if stat.IsDir() { if stat.IsDir() {
httpErrorReason(w, http.StatusForbidden, http.Error(w, "is a directory; maybe you are looking for " + path, http.StatusForbidden)
"is a directory; maybe you are looking for " + path)
return return
} }
f, err := os.Open(filePath) f, err := os.Open(filePath)
if err != nil { if err != nil {
httpError(w, http.StatusNotFound) http.Error(w, "not found", http.StatusNotFound)
return return
} }
defer f.Close() defer f.Close()
@ -131,14 +117,14 @@ func handleArtistAlbumPage(w http.ResponseWriter, req *http.Request) {
// test artist // test artist
rx, err := regexp.Compile("^/([^/?]+)/?(\\?.*)?$") rx, err := regexp.Compile("^/([^/?]+)/?(\\?.*)?$")
if err != nil { if err != nil {
httpError(w, http.StatusInternalServerError) http.Error(w, "internal server error", http.StatusInternalServerError)
return return
} }
if (rx.MatchString(req.RequestURI)) { if (rx.MatchString(req.RequestURI)) {
artist, err := url.QueryUnescape(rx.FindStringSubmatch(req.RequestURI)[1]) artist, err := url.QueryUnescape(rx.FindStringSubmatch(req.RequestURI)[1])
if err != nil { if err != nil {
httpError(w, http.StatusBadRequest) http.Error(w, "bad request", http.StatusBadRequest)
return return
} }
if musicindex.ArtistExists(artist) { if musicindex.ArtistExists(artist) {
@ -153,14 +139,14 @@ func handleArtistAlbumPage(w http.ResponseWriter, req *http.Request) {
} }
// artist not found // artist not found
httpError(w, http.StatusNotFound) http.Error(w, "not found", http.StatusNotFound)
return return
} }
// test album tarball // test album tarball
rx, err = regexp.Compile("^/([^/?]+)/([^/?]+)\\.tar\\.gz$") rx, err = regexp.Compile("^/([^/?]+)/([^/?]+)\\.tar\\.gz$")
if err != nil { if err != nil {
httpError(w, http.StatusInternalServerError) http.Error(w, "internal server error", http.StatusInternalServerError)
return return
} }
@ -168,12 +154,12 @@ func handleArtistAlbumPage(w http.ResponseWriter, req *http.Request) {
captures := rx.FindStringSubmatch(req.RequestURI) captures := rx.FindStringSubmatch(req.RequestURI)
artist, err := url.QueryUnescape(captures[1]) artist, err := url.QueryUnescape(captures[1])
if err != nil { if err != nil {
httpError(w, http.StatusBadRequest) http.Error(w, "bad request", http.StatusBadRequest)
return return
} }
albumName, err := url.QueryUnescape(captures[2]) albumName, err := url.QueryUnescape(captures[2])
if err != nil { if err != nil {
httpError(w, http.StatusBadRequest) http.Error(w, "bad request", http.StatusBadRequest)
return return
} }
@ -182,7 +168,8 @@ func handleArtistAlbumPage(w http.ResponseWriter, req *http.Request) {
if ok { if ok {
f, err := os.Open(album.Tarball) f, err := os.Open(album.Tarball)
if err != nil { if err != nil {
httpErrorReason(w, http.StatusInternalServerError, err.Error()) http.Error(w, "internal server error: " + err.Error(),
http.StatusInternalServerError)
return return
} }
defer f.Close() defer f.Close()
@ -197,14 +184,14 @@ func handleArtistAlbumPage(w http.ResponseWriter, req *http.Request) {
} }
// artist or album not found // artist or album not found
httpError(w, http.StatusNotFound) http.Error(w, "not found", http.StatusNotFound)
return return
} }
// test album // test album
rx, err = regexp.Compile("^/([^/?]+)/([^/?]+)/?(\\?.*)?$") rx, err = regexp.Compile("^/([^/?]+)/([^/?]+)/?(\\?.*)?$")
if err != nil { if err != nil {
httpErrorReason(w, http.StatusInternalServerError, err.Error()) http.Error(w, "internal server error", http.StatusInternalServerError)
return return
} }
@ -212,12 +199,12 @@ func handleArtistAlbumPage(w http.ResponseWriter, req *http.Request) {
captures := rx.FindStringSubmatch(req.RequestURI) captures := rx.FindStringSubmatch(req.RequestURI)
artist, err := url.QueryUnescape(captures[1]) artist, err := url.QueryUnescape(captures[1])
if err != nil { if err != nil {
httpError(w, http.StatusBadRequest) http.Error(w, "bad request", http.StatusBadRequest)
return return
} }
albumName, err := url.QueryUnescape(captures[2]) albumName, err := url.QueryUnescape(captures[2])
if err != nil { if err != nil {
httpError(w, http.StatusBadRequest) http.Error(w, "bad request", http.StatusBadRequest)
return return
} }
@ -230,12 +217,12 @@ func handleArtistAlbumPage(w http.ResponseWriter, req *http.Request) {
} }
// artist or album not found // artist or album not found
httpError(w, http.StatusNotFound) http.Error(w, "not found", http.StatusNotFound)
return return
} }
// illegal URI format // illegal URI format
httpError(w, http.StatusNotFound) http.Error(w, "not found", http.StatusNotFound)
} }
func RunOn(address string) { func RunOn(address string) {

View file

@ -1,6 +1,3 @@
// Copyright (c) 2026 Jeremy Baxter.
// Reusable utility functions
package util package util
import ( import (
@ -86,13 +83,9 @@ func HashOf(s string) string {
return fmt.Sprintf("%x", h.Sum(nil)) return fmt.Sprintf("%x", h.Sum(nil))
} }
func Interactive() bool {
return term.IsTerminal(int(os.Stderr.Fd()))
}
// print only if interactive // print only if interactive
func Iprint(format string, args ...any) { func Iprint(format string, args ...any) {
if Interactive() { if term.IsTerminal(int(os.Stderr.Fd())) {
fmt.Fprintf(os.Stderr, format, args...) fmt.Fprintf(os.Stderr, format, args...)
} }
} }