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