grimoire: icecast frontend
This commit is contained in:
commit
cf3715068c
10 changed files with 289 additions and 0 deletions
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
||||||
|
/grimoire
|
112
config.go
Normal file
112
config.go
Normal 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
5
go.mod
Normal 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
2
go.sum
Normal 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
60
grimoire.go
Normal 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
BIN
static/banner.gif
Normal file
Binary file not shown.
After Width: | Height: | Size: 57 KiB |
BIN
static/online.gif
Normal file
BIN
static/online.gif
Normal file
Binary file not shown.
After Width: | Height: | Size: 36 KiB |
61
static/style.css
Normal file
61
static/style.css
Normal 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
18
templates/err.html
Normal 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 — 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
30
templates/radio.html
Normal 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>
|
||||||
|
|
Loading…
Reference in a new issue