all repos — grimoire @ cf3715068c81e9a3a8b6ebd4a6a3dc298f819011

dead simple icecast frontend

grimoire: icecast frontend
Iris Lightshard nilix@nilfm.cc
PGP Signature
-----BEGIN PGP SIGNATURE-----

iHUEABYKAB0WIQT/foVVmI9pK13hPWFohAcXSWbK8wUCZyHGPQAKCRBohAcXSWbK
89flAP96Cp7wBdBBqMeSlhip9bZObU0D+THZVSizLDqxRUIwSQEAuR+hUeaqGsZC
j30CfKFxjso6kk+PTfxES7Wi1wVbhA0=
=NdQX
-----END PGP SIGNATURE-----
commit

cf3715068c81e9a3a8b6ebd4a6a3dc298f819011

A .gitignore

@@ -0,0 +1,1 @@

+/grimoire
A config.go

@@ -0,0 +1,112 @@

+package main + +import ( + "os" + "path/filepath" + "runtime" + "strings" +) + +type Config struct { + Streams map[string]string // stream in the form stream::name=url + DataDir string // location of templates/ and static/ + Name string // name of this instance + ListenAddress string // address the webserver listens on +} + +func GetConfigLocation() string { + home := os.Getenv("HOME") + appdata := os.Getenv("APPDATA") + switch runtime.GOOS { + case "windows": + return filepath.Join(appdata, "grimoire") + case "darwin": + return filepath.Join(home, "Library", "Application Support", "grimoire") + case "plan9": + return filepath.Join(home, "lib", "grimoire") + default: + return filepath.Join(home, ".config", "grimoire") + } +} + +func ensureConfigLocationExists() { + fileInfo, err := os.Stat(GetConfigLocation()) + + if os.IsNotExist(err) { + os.MkdirAll(GetConfigLocation(), os.ModePerm) + } else if !fileInfo.IsDir() { + panic("Config location is not a directory!") + } +} + +func ReadConfig() *Config { + ensureConfigLocationExists() + return parseConfig(filepath.Join(GetConfigLocation(), "grimoire.conf")) +} + +func (self *Config) Write() error { + ensureConfigLocationExists() + return writeConfig(self, filepath.Join(GetConfigLocation(), "grimoire.conf")) +} + +func writeConfig(cfg *Config, configFile string) error { + f, err := os.Create(configFile) + if err != nil { + return err + } + + defer f.Close() + + f.WriteString("listenAddress=" + cfg.ListenAddress + "\n") + f.WriteString("dataDir=" + cfg.DataDir + "\n") + f.WriteString("name=" + cfg.Name + "\n") + for k, v := range cfg.Streams { + f.WriteString("stream::" + k + "=" + v) + } + return nil +} + +func parseConfig(configFile string) *Config { + f, err := os.ReadFile(configFile) + cfg := &Config{} + cfg.Streams = make(map[string]string) + if err != nil { + return cfg + } + + fileData := string(f[:]) + + lines := strings.Split(fileData, "\n") + + for _, l := range lines { + if len(l) == 0 { + continue + } + if !strings.Contains(l, "=") { + panic("Malformed config not in INI format") + } + + kvp := strings.Split(l, "=") + k := strings.TrimSpace(kvp[0]) + v := strings.TrimSpace(kvp[1]) + switch k { + case "listenAddress": + cfg.ListenAddress = v + case "dataDir": + cfg.DataDir = v + case "name": + cfg.Name = v + default: + if strings.HasPrefix(k, "stream::") { + stream := strings.Split(k, "stream::")[1] + if len(stream) == 0 { + panic("stream in config has no name!") + } + cfg.Streams[stream] = v + } else { + panic("Unrecognized config option: " + k) + } + } + } + return cfg +}
A go.mod

@@ -0,0 +1,5 @@

+module forge.lightcrystal.systems/lightcrystal/grimoire + +go 1.23.0 + +require hacklab.nilfm.cc/quartzgun v0.3.2 // indirect
A go.sum

@@ -0,0 +1,2 @@

+hacklab.nilfm.cc/quartzgun v0.3.2 h1:PmRFZ/IgsXVWyNn1iOsQ/ZeMnOQIQy0PzFakhXBdZoU= +hacklab.nilfm.cc/quartzgun v0.3.2/go.mod h1:P6qK4HB0CD/xfyRq8wdEGevAPFDDmv0KCaESSvv93LU=
A grimoire.go

@@ -0,0 +1,60 @@

+package main + +import ( + "hacklab.nilfm.cc/quartzgun/renderer" + "hacklab.nilfm.cc/quartzgun/router" + "hacklab.nilfm.cc/quartzgun/util" + + "net/http" + "path/filepath" + "html/template" +) + +func withTitleAndStreamsAndSentry(next http.Handler, cfg Config, sentry *Sentry) http.Handler { + handlerFunc := func(w http.ResponseWriter, req *http.Request) { + util.AddContextValue(req, "title", cfg.Name) + util.AddContextValue(req, "streams", cfg.Streams) + util.AddContextValue(req, "sentry", sentry) + next.ServeHTTP(w, req) + } + + return http.HandlerFunc(handlerFunc) +} + +type Sentry struct {} + +func (self *Sentry) GetStatus(url string) int { + resp, err := http.Get(url) + if err != nil { + return 500 + } else { + return resp.StatusCode + } +} + +func main() { + cfg := ReadConfig() + + templateRoot := filepath.Join(cfg.DataDir, "templates") + + rtr := &router.Router{ + StaticPaths: map[string]string{ + "/static/": filepath.Join(cfg.DataDir, "static"), + }, + Fallback: *template.Must(template.ParseFiles( + filepath.Join(templateRoot, "err.html"), + )), + } + + + + rtr.Get("/", withTitleAndStreamsAndSentry( + renderer.Template( + filepath.Join(templateRoot, "radio.html"), + ), + *cfg, + &Sentry{}, + )) + + http.ListenAndServe(cfg.ListenAddress, rtr) +}
A static/style.css

@@ -0,0 +1,61 @@

+* { + padding: 0; + margin: 0; +} + +body { + background:#000; + color:#fff; + text-align:center; + font-family: monospace; +} + +header { + background:url('/static/banner.gif'); + background-position:center; + background-size:cover; + width: 100vw; + padding: 2ch; + box-sizing: border-box; + margin: auto; + box-shadow: inset 0 0 0.5em 1em black; +} + +h1, li a { + color: goldenrod; + text-shadow: black 0 0 1em; + font-weight: bold; + text-decoration: none; +} + +h1 { + color: #fff !important; +} + +ul { list-style: none;padding:0;margin: 0.5em; } + +ul li { + background-position:center; + background-size:cover; + box-shadow: inset 0 0 0.5em 1em black; + max-width: fit-content; + margin: auto; +} + +ul li.online { + background-image:url('/static/online.gif'); + +} + +ul li.offline a { + color: grey; +} + +ul li.offline a::after { + content: " (offline)" +} + +ul li a { + display:inline-block; + padding: 2em; +}
A templates/err.html

@@ -0,0 +1,18 @@

+{{ $params := (.Context).Value "params" }} + +<!DOCTYPE html> +<html lang='en'> + <head> + <meta charset='utf-8'> + <meta name='description' content='oops'/> + <meta name='viewport' content='width=device-width,initial-scale=1'> + <link rel='stylesheet' type='text/css' href='/static/style.css'> + <title>Grimoire &mdash; Error</title> + </head> + <body> + <header><h1>Error</h1></header> + <span class="error">{{$params.ErrorCode}}: {{$params.ErrorMessage}}</span> + </body> + </head> +</html> +
A templates/radio.html

@@ -0,0 +1,30 @@

+{{ $title := (.Context).Value "title" }} +{{ $stations := (.Context).Value "streams" }} +{{ $sentry := (.Context).Value "sentry" }} + +<!DOCTYPE html> +<html lang='en'> + <head> + <meta charset='utf-8'> + <meta name='description' content='oops'/> + <meta name='viewport' content='width=device-width,initial-scale=1'> + <link rel='stylesheet' type='text/css' href='/static/style.css'> + <title>{{ $title }}</title> + </head> + <body> + <header><h1>{{$title}}</h1></header> + <main> + <ul> + {{range $name, $stationUrl := $stations}} + {{ if gt (($sentry).GetStatus $stationUrl) 400 }} + <li class="offline"><a href="#">{{$name}}</a></li> + {{else}} + <li class="online"><a href="{{$stationUrl}}" target="_blank">{{$name}}</a></li> + {{end}} + {{end}} + </ul> + </main> + </body> + </head> +</html> +