grimoire: icecast frontend

This commit is contained in:
Iris Lightshard 2024-10-29 23:38:05 -06:00
commit cf3715068c
Signed by: nilix
GPG key ID: 688407174966CAF3
10 changed files with 289 additions and 0 deletions

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/grimoire

112
config.go Normal file
View file

@ -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
}

5
go.mod Normal file
View file

@ -0,0 +1,5 @@
module forge.lightcrystal.systems/lightcrystal/grimoire
go 1.23.0
require hacklab.nilfm.cc/quartzgun v0.3.2 // indirect

2
go.sum Normal file
View file

@ -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=

60
grimoire.go Normal file
View file

@ -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)
}

BIN
static/banner.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

BIN
static/online.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

61
static/style.css Normal file
View file

@ -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;
}

18
templates/err.html Normal file
View file

@ -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>

30
templates/radio.html Normal file
View file

@ -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>