Compare commits
No commits in common. "master" and "23.6.0" have entirely different histories.
4
.gitignore
vendored
@ -1,6 +1,4 @@
|
||||
bin/*
|
||||
!bin/.keep
|
||||
.vscode
|
||||
*.log
|
||||
*_templ.go
|
||||
/test
|
||||
*.pug.go
|
2
LICENSE
@ -1,4 +1,4 @@
|
||||
Copyright (c) 2022,2023 Alexander "Arav" Andreev <me@arav.su>
|
||||
Copyright (c) 2022 Alexander "Arav" Andreev <me@arav.top>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
|
73
Makefile
@ -1,63 +1,32 @@
|
||||
TARGET := dwelling-radio
|
||||
TARGET=dwelling-radio
|
||||
|
||||
SYSDDIR_ := ${shell pkg-config systemd --variable=systemdsystemunitdir}
|
||||
SYSDDIR := ${SYSDDIR_:/%=%}
|
||||
SYSDDIR_=${shell pkg-config systemd --variable=systemdsystemunitdir}
|
||||
SYSDDIR=${SYSDDIR_:/%=%}
|
||||
DESTDIR=/
|
||||
|
||||
DESTDIR ?=
|
||||
PREFIX ?= /usr/local
|
||||
LDFLAGS=-ldflags "-s -w -X main.version=23.6.0" -tags osusergo,netgo
|
||||
|
||||
VERSION ?= 24.38.0
|
||||
all: ${TARGET}
|
||||
|
||||
GOFLAGS := -buildmode=pie -modcacherw -mod=readonly -trimpath
|
||||
LDFLAGS := -ldflags "-linkmode=external -extldflags \"${LDFLAGS}\" -s -w -X main.version=${VERSION}" -tags osusergo,netgo
|
||||
.PHONY: ${TARGET} install uninstall
|
||||
|
||||
.PHONY: run install uninstall clean
|
||||
|
||||
${TARGET}: web/*_templ.go
|
||||
go build -o bin/$@ ${LDFLAGS} ${GOFLAGS} cmd/$@/main.go
|
||||
|
||||
web/*_templ.go: web/*.templ
|
||||
ifeq (,$(wildcard $(shell go env GOPATH)/bin/templ))
|
||||
go install github.com/a-h/templ/cmd/templ@latest
|
||||
endif
|
||||
$(shell go env GOPATH)/bin/templ generate
|
||||
|
||||
run: | ${TARGET}
|
||||
bin/dwelling-radio -listen 127.0.0.1:18322 \
|
||||
-work-dir test \
|
||||
-playlist test
|
||||
${TARGET}:
|
||||
go generate web/web.go
|
||||
go build -o bin/$@ ${LDFLAGS} cmd/$@/main.go
|
||||
|
||||
install:
|
||||
install -Dm 0755 bin/${TARGET} ${DESTDIR}${PREFIX}/bin/${TARGET}
|
||||
install -Dm 0755 tools/radioctl ${DESTDIR}${PREFIX}/bin/${TARGET}ctl
|
||||
install -Dm 0755 bin/${TARGET} ${DESTDIR}usr/bin/${TARGET}
|
||||
install -Dm 0644 configs/config.yaml ${DESTDIR}etc/dwelling/radio.yaml
|
||||
install -Dm 0644 configs/radio.liq ${DESTDIR}etc/dwelling/radio.liq
|
||||
install -Dm 0644 configs/radio.vars.liq ${DESTDIR}etc/dwelling/radio.vars.liq
|
||||
install -Dm 0644 LICENSE ${DESTDIR}usr/share/licenses/${TARGET}/LICENSE
|
||||
install -Dm 0644 configs/logrotate ${DESTDIR}etc/logrotate.d/${TARGET}
|
||||
|
||||
install -Dm 0755 tools/radio-fetch ${DESTDIR}${PREFIX}/bin/${TARGET}-fetch
|
||||
|
||||
# install -Dm 0644 configs/radio.liq ${DESTDIR}/etc/dwelling/radio.liq
|
||||
# install -Dm 0644 configs/radio.vars.liq ${DESTDIR}/etc/dwelling/radio.vars.liq
|
||||
install -Dm 0644 configs/ezstream.xml ${DESTDIR}/etc/dwelling/ezstream.xml
|
||||
install -Dm 0644 configs/logrotate ${DESTDIR}/etc/logrotate.d/${TARGET}
|
||||
install -Dm 0644 configs/override.icecast.service ${DESTDIR}/etc/systemd/system/icecast.service.override.d/override.conf
|
||||
|
||||
install -Dm 0644 init/radio.service ${DESTDIR}/${SYSDDIR}/${TARGET}.service
|
||||
# install -Dm 0644 init/liquidsoap.service ${DESTDIR}/${SYSDDIR}/${TARGET}-liquidsoap.service
|
||||
install -Dm 0644 init/ezstream.service ${DESTDIR}/${SYSDDIR}/${TARGET}-ezstream.service
|
||||
install -Dm 0644 init/systemd/${TARGET}.service ${DESTDIR}${SYSDDIR}/${TARGET}.service
|
||||
|
||||
uninstall:
|
||||
rm ${DESTDIR}${PREFIX}/bin/${TARGET}
|
||||
rm ${DESTDIR}${PREFIX}/bin/${TARGET}ctl
|
||||
rm ${DESTDIR}usr/bin/${TARGET}
|
||||
rm ${DESTDIR}usr/share/licenses/${TARGET}/LICENSE
|
||||
rm ${DESTDIR}etc/logrotate.d/${TARGET}
|
||||
|
||||
rm ${DESTDIR}${PREFIX}/bin/${TARGET}-fetch
|
||||
|
||||
# rm ${DESTDIR}/etc/dwelling/radio.liq
|
||||
rm ${DESTDIR}/etc/dwelling/ezstream.xml
|
||||
rm ${DESTDIR}/etc/logrotate.d/${TARGET}
|
||||
rm ${DESTDIR}/etc/systemd/system/icecast.service.override.d/override.conf
|
||||
|
||||
rm ${DESTDIR}${SYSDDIR}/${TARGET}.service
|
||||
# rm ${DESTDIR}${SYSDDIR}/${TARGET}-liquidsoap.service
|
||||
rm ${DESTDIR}${SYSDDIR}/${TARGET}-ezstream.service
|
||||
|
||||
clean:
|
||||
rm -f web/*.jade.go
|
||||
go clean
|
||||
rm ${DESTDIR}${SYSDDIR}/${TARGET}.service
|
@ -1,30 +1,30 @@
|
||||
# Maintainer: Alexander "Arav" Andreev <me@arav.su>
|
||||
# Maintainer: Alexander "Arav" Andreev <me@arav.top>
|
||||
pkgname=dwelling-radio
|
||||
pkgver=24.38.0
|
||||
pkgver=23.6.0
|
||||
pkgrel=1
|
||||
pkgdesc="Arav's dwelling / Radio"
|
||||
arch=('i686' 'x86_64' 'arm' 'armv6h' 'armv7h' 'aarch64')
|
||||
url="https://git.arav.su/Arav/dwelling-radio"
|
||||
url="https://git.arav.top/Arav/dwelling-radio"
|
||||
license=('MIT')
|
||||
makedepends=('go>=1.17')
|
||||
optdepends=(
|
||||
'tree: to make a filelist html file'
|
||||
'ffmpeg: to convert media to ogg and get duration of songs')
|
||||
backup=('etc/dwelling/ezstream.xml')
|
||||
source=("${pkgver}.tar.gz::https://git.arav.su/Arav/dwelling-radio/archive/v${pkgver}.tar.gz")
|
||||
groups=()
|
||||
depends=()
|
||||
makedepends=('go')
|
||||
provides=('dwelling-radio')
|
||||
conflicts=('dwelling-radio')
|
||||
replaces=()
|
||||
backup=('etc/dwelling/radio.yaml' 'etc/dwelling/radio.vars.liq')
|
||||
options=()
|
||||
install=
|
||||
source=('https://git.arav.top/Arav/dwelling-radio/archive/23.6.0.tar.gz')
|
||||
noextract=()
|
||||
md5sums=('SKIP')
|
||||
|
||||
build() {
|
||||
cd "$srcdir/$pkgname"
|
||||
export GOPATH="$srcdir"/gopath
|
||||
export CGO_CPPFLAGS="${CPPFLAGS}"
|
||||
export CGO_CFLAGS="${CFLAGS}"
|
||||
export CGO_CXXFLAGS="${CXXFLAGS}"
|
||||
export CGO_LDFLAGS="${LDFLAGS}"
|
||||
make VERSION=$pkgver DESTDIR="$pkgdir" PREFIX="/usr"
|
||||
make DESTDIR="$pkgdir/"
|
||||
}
|
||||
|
||||
package() {
|
||||
cd "$srcdir/$pkgname"
|
||||
make DESTDIR="$pkgdir" PREFIX="/usr" install
|
||||
make DESTDIR="$pkgdir/" install
|
||||
}
|
||||
|
@ -1,159 +1,81 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
ihttp "dwelling-radio/internal/http"
|
||||
"dwelling-radio/internal/configuration"
|
||||
"dwelling-radio/internal/http"
|
||||
"dwelling-radio/internal/radio"
|
||||
sqlite_stats "dwelling-radio/internal/statistics/db/sqlite"
|
||||
"dwelling-radio/pkg/utils"
|
||||
"dwelling-radio/web"
|
||||
"dwelling-radio/web/locales"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path"
|
||||
"syscall"
|
||||
|
||||
"git.arav.su/Arav/httpr"
|
||||
"github.com/invopop/ctxi18n"
|
||||
)
|
||||
|
||||
var (
|
||||
listenAddress = flag.String("listen", "/var/run/dwelling-radio/sock", "listen address (ip:port|unix_path)")
|
||||
workDirPath = flag.String("work-dir", "/mnt/data/appdata/radio", "path to a working directory")
|
||||
playlistName = flag.String("playlist", "all-rand", "a playlist name")
|
||||
songListLen = flag.Int64("list-length", 10, "number of songs to show in last N songs table")
|
||||
|
||||
showVersion = flag.Bool("v", false, "show version")
|
||||
)
|
||||
|
||||
var version string
|
||||
|
||||
var configPath *string = flag.String("conf", "config.yaml", "path to configuration file")
|
||||
var noLiquidsoap *bool = flag.Bool("no-liquidsoap", false, "don't run liquidsoap")
|
||||
var showVersion *bool = flag.Bool("v", false, "show version")
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
log.SetFlags(log.Lshortfile)
|
||||
|
||||
if *showVersion {
|
||||
fmt.Println("dwelling-radio ver.", version, "\nCopyright (c) 2022-2024 Alexander \"Arav\" Andreev <me@arav.su>")
|
||||
fmt.Println("dwelling-radio ver.", version, "\nCopyright (c) 2022,2023 Alexander \"Arav\" Andreev <me@arav.top>")
|
||||
return
|
||||
}
|
||||
|
||||
stats, err := sqlite_stats.New(path.Join(*workDirPath, "statistics.db3"))
|
||||
if err != nil {
|
||||
log.Fatalln("Statistics:", err)
|
||||
}
|
||||
defer stats.Close()
|
||||
|
||||
currentSong := radio.Song{}
|
||||
lstnrs := radio.NewListenerCounter()
|
||||
|
||||
plylst, err := radio.NewPlaylist(path.Join(*workDirPath, "playlists", *playlistName), true)
|
||||
config, err := configuration.Load(*configPath)
|
||||
if err != nil {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
|
||||
if err := ctxi18n.LoadWithDefault(locales.Content, "en"); err != nil {
|
||||
log.Fatalln(err)
|
||||
if typ, addr := config.SplitNetworkAddress(); typ == "unix" {
|
||||
defer os.Remove(addr)
|
||||
}
|
||||
|
||||
r := httpr.New()
|
||||
|
||||
r.Handler(http.MethodGet, "/", func(w http.ResponseWriter, r *http.Request) {
|
||||
lst, err := stats.LastNSongs(*songListLen)
|
||||
if err != nil {
|
||||
log.Printf("Failed to fetch last N songs: %s\n", err)
|
||||
}
|
||||
|
||||
lstnrs.RLock()
|
||||
defer lstnrs.RUnlock()
|
||||
web.Index(version, ¤tSong, lst, *songListLen, lstnrs, r).Render(r.Context(), w)
|
||||
})
|
||||
|
||||
r.Handler(http.MethodGet, "/filelist", func(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := os.ReadFile(path.Join(*workDirPath, "filelist.html"))
|
||||
if err != nil {
|
||||
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Add("Content-Type", "text/html")
|
||||
w.Header().Add("Link", "<"+utils.Site(r.Host)+"/filelist>; rel=\"canonical\"")
|
||||
w.Write(data)
|
||||
})
|
||||
|
||||
r.Handler(http.MethodGet, "/playlist", web.ServeAsset("playlist.m3u", "", "radio.arav.su.m3u"))
|
||||
|
||||
r.Handler(http.MethodGet, "/robots.txt", web.ServeAsset("robots.txt", "text/plain", ""))
|
||||
r.Handler(http.MethodGet, "/sitemap.xml", web.ServeAsset("sitemap.xml", "application/xml", ""))
|
||||
r.Handler(http.MethodGet, "/favicon.svg", web.ServeAsset("favicon.svg", "image/svg", ""))
|
||||
|
||||
r.ServeStatic("/assets/*filepath", web.Assets())
|
||||
|
||||
djh := ihttp.NewDJHandlers(lstnrs, plylst, stats, ¤tSong, *songListLen, path.Join(*workDirPath, "fallback.ogg"))
|
||||
|
||||
s := r.Sub("/api")
|
||||
|
||||
s.Handler(http.MethodPost, "/listener/icecast", djh.ListenersUpdateIcecast)
|
||||
s.Handler(http.MethodGet, "/playlist", djh.PlaylistNext)
|
||||
s.Handler(http.MethodGet, "/status", djh.Status)
|
||||
|
||||
srv := ihttp.NewHttpServer(I18nMiddleware(r))
|
||||
|
||||
if err := srv.Start(*listenAddress); err != nil {
|
||||
playlistWatcher := radio.NewPlaylistLogWatcher()
|
||||
if err := playlistWatcher.Watch(config.Icecast.Playlist, config.ListLastNSongs); err != nil {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
defer playlistWatcher.Close()
|
||||
|
||||
defer func() {
|
||||
if err := srv.Stop(); err != nil {
|
||||
log.Fatalln(err)
|
||||
hand := http.NewHandlers(config)
|
||||
srv := http.NewHttpServer()
|
||||
|
||||
srv.ServeStatic("/assets/*filepath", hand.AssetsFS())
|
||||
srv.GET("/", hand.Index)
|
||||
srv.GET("/status", hand.Status)
|
||||
srv.GET("/lastsong", hand.LastSong)
|
||||
srv.GET("/playlist", hand.Playlist)
|
||||
|
||||
if !*noLiquidsoap {
|
||||
liquid, err := radio.NewLiquidsoap(config.Liquidsoap.ExecPath, config.Liquidsoap.ScriptPath)
|
||||
if err != nil {
|
||||
log.Fatalln("liquidsoap:", err)
|
||||
}
|
||||
}()
|
||||
|
||||
sysSignal := make(chan os.Signal, 1)
|
||||
signal.Notify(sysSignal, os.Interrupt, syscall.SIGINT, syscall.SIGTERM, syscall.SIGABRT, syscall.SIGSEGV, syscall.SIGHUP)
|
||||
|
||||
for {
|
||||
switch <-sysSignal {
|
||||
case os.Interrupt, syscall.SIGINT, syscall.SIGTERM, syscall.SIGABRT, syscall.SIGSEGV:
|
||||
if currentSong.Artist != "" {
|
||||
lstnrs.Lock()
|
||||
defer lstnrs.Unlock()
|
||||
currentSong.Listeners, currentSong.PeakListeners = lstnrs.Reset()
|
||||
if err := stats.Add(¤tSong); err != nil {
|
||||
log.Println("failed to save a current song during a shutdown:", err)
|
||||
defer func() {
|
||||
if err := liquid.Stop(); err != nil {
|
||||
if !errors.Is(err, radio.ErrLiquidsoapNotRunning) {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
return
|
||||
case syscall.SIGHUP:
|
||||
plylst.Lock()
|
||||
defer plylst.Unlock()
|
||||
if err := plylst.Reload(); err != nil {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
if err := srv.Start(config.SplitNetworkAddress()); err != nil {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
|
||||
doneSignal := make(chan os.Signal, 1)
|
||||
signal.Notify(doneSignal, os.Interrupt, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
<-doneSignal
|
||||
|
||||
if err := srv.Stop(); err != nil {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
}
|
||||
|
||||
func I18nMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
lang := "en"
|
||||
|
||||
if lq := r.URL.Query().Get("lang"); lq != "" {
|
||||
lc := http.Cookie{Name: "lang", Value: lq, HttpOnly: false, MaxAge: 0}
|
||||
http.SetCookie(w, &lc)
|
||||
lang = lq
|
||||
} else if l, err := r.Cookie("lang"); err == nil {
|
||||
lang = l.Value
|
||||
} else if al := r.Header.Get("Accept-Language"); al != "" {
|
||||
lang = r.Header.Get("Accept-Language")
|
||||
}
|
||||
|
||||
ctx, err := ctxi18n.WithLocale(r.Context(), lang)
|
||||
if err != nil {
|
||||
log.Println("i18nmw:", err)
|
||||
}
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
15
configs/config.yaml
Normal file
@ -0,0 +1,15 @@
|
||||
# Sets network type (could be tcp{,4,6}, unix)
|
||||
# and address:port or /path/to/unix.sock to
|
||||
# listen on.
|
||||
listen_on: "unix /var/run/dwelling-radio/sock"
|
||||
icecast:
|
||||
# URL to Icecast's status-json.xsl
|
||||
url: "http://radio.arav.home.arpa/status-json.xsl"
|
||||
playlist_path: "/var/log/icecast/playlist.log"
|
||||
liquidsoap:
|
||||
executable_path: "/opt/opam/4.14.0/bin/liquidsoap"
|
||||
script_path: "/etc/dwelling/radio.liq"
|
||||
# How much songs to list on a page
|
||||
list_last_n_songs: 10
|
||||
log:
|
||||
error: "/var/log/dwelling-radio/error.log"
|
@ -1,46 +0,0 @@
|
||||
<ezstream>
|
||||
<servers>
|
||||
<server>
|
||||
<name>default</name>
|
||||
<protocol>HTTP</protocol>
|
||||
<hostname>127.0.0.1</hostname>
|
||||
<port>8000</port>
|
||||
<password>SOURCEPWD</password>
|
||||
</server>
|
||||
</servers>
|
||||
<streams>
|
||||
<stream>
|
||||
<name>default</name>
|
||||
<mountpoint>/stream.ogg</mountpoint>
|
||||
<public>Yes</public>
|
||||
<intake>dwelling-radio</intake>
|
||||
<server>default</server>
|
||||
<format>Ogg</format>
|
||||
<stream_name>Arav's dwelling / Radio</stream_name>
|
||||
<stream_url>https://radio.arav.su</stream_url>
|
||||
<stream_genre>Various</stream_genre>
|
||||
<stream_description>Broadcasting from under my desk.</stream_description>
|
||||
<stream_bitrate>128</stream_bitrate>
|
||||
<stream_samplerate>44100</stream_samplerate>
|
||||
<stream_channels>2</stream_channels>
|
||||
</stream>
|
||||
</streams>
|
||||
<intakes>
|
||||
<intake>
|
||||
<name>default</name>
|
||||
<type>playlist</type>
|
||||
<filename>/mnt/data/appdata/radio/playlists/all-rand</filename>
|
||||
</intake>
|
||||
<intake>
|
||||
<name>dwelling-radio</name>
|
||||
<type>program</type>
|
||||
<filename>/usr/bin/dwelling-radio-fetch</filename>
|
||||
</intake>
|
||||
</intakes>
|
||||
<metadata>
|
||||
<format_str>@a@ - @t@</format_str>
|
||||
<refresh_interval>-1</refresh_interval>
|
||||
<normalize_strings>No</normalize_strings>
|
||||
<no_updates>No</no_updates>
|
||||
</metadata>
|
||||
</ezstream>
|
@ -1,82 +0,0 @@
|
||||
<icecast>
|
||||
<hostname>radio.arav.su</hostname>
|
||||
<location>Somewhere in Russia</location>
|
||||
<admin>admin@arav.su</admin>
|
||||
|
||||
<limits>
|
||||
<clients>128</clients>
|
||||
<sources>2</sources>
|
||||
<queue-size>524288</queue-size>
|
||||
<client-timeout>30</client-timeout>
|
||||
<header-timeout>15</header-timeout>
|
||||
<source-timeout>10</source-timeout>
|
||||
<burst-on-connect>1</burst-on-connect>
|
||||
<burst-size>65535</burst-size>
|
||||
</limits>
|
||||
|
||||
<authentication>
|
||||
<source-password>SOURCEPWD</source-password>
|
||||
<relay-password>RELAYPWD</relay-password>
|
||||
<admin-user>admin</admin-user>
|
||||
<admin-password>ADMINPWD</admin-password>
|
||||
</authentication>
|
||||
|
||||
<directory>
|
||||
<yp-url-timeout>15</yp-url-timeout>
|
||||
<yp-url>http://dir.xiph.org/cgi-bin/yp-cgi</yp-url>
|
||||
</directory>
|
||||
|
||||
<listen-socket>
|
||||
<port>8000</port>
|
||||
<bind-address>127.0.0.1</bind-address>
|
||||
</listen-socket>
|
||||
|
||||
<http-headers>
|
||||
<header name="Access-Control-Allow-Origin" value="*" />
|
||||
</http-headers>
|
||||
|
||||
|
||||
<mount type="normal">
|
||||
<mount-name>/stream.ogg</mount-name>
|
||||
<charset>UTF8</charset>
|
||||
<public>1</public>
|
||||
<authentication type="url">
|
||||
<option name="listener_add" value="https://radio.arav.su/api/listener/icecast"/>
|
||||
<option name="listener_remove" value="https://radio.arav.su/api/listener/icecast"/>
|
||||
<option name="auth_header" value="Icecast-Auth-User: 1"/>
|
||||
</authentication>
|
||||
</mount>
|
||||
|
||||
<mount type="normal">
|
||||
<mount-name>/test.ogg</mount-name>
|
||||
<charset>UTF8</charset>
|
||||
<public>0</public>
|
||||
</mount>
|
||||
|
||||
|
||||
<paths>
|
||||
<basedir>/usr/share/icecast</basedir>
|
||||
<logdir>/var/log/icecast</logdir>
|
||||
<webroot>/usr/share/icecast/web</webroot>
|
||||
<adminroot>/usr/share/icecast/admin</adminroot>
|
||||
<alias source="/" destination="/status.xsl"/>
|
||||
<x-forwarded-for>192.168.144.2</x-forwarded-for>
|
||||
</paths>
|
||||
|
||||
<logging>
|
||||
<accesslog>access.log</accesslog>
|
||||
<errorlog>error.log</errorlog>
|
||||
<playlistlog>playlist.log</playlistlog>
|
||||
<loglevel>1</loglevel> <!-- 4 Debug, 3 Info, 2 Warn, 1 Error -->
|
||||
<logsize>10000</logsize> <!-- Max size of a logfile -->
|
||||
<logarchive>0</logarchive>
|
||||
</logging>
|
||||
|
||||
<security>
|
||||
<chroot>0</chroot>
|
||||
<changeowner>
|
||||
<user>icecast</user>
|
||||
<group>icecast</group>
|
||||
</changeowner>
|
||||
</security>
|
||||
</icecast>
|
@ -1,6 +1,5 @@
|
||||
/var/log/dwelling-radio/*log {
|
||||
nocreate
|
||||
copytruncate
|
||||
missingok
|
||||
notifempty
|
||||
size 10M
|
||||
@ -9,4 +8,8 @@
|
||||
compressext .zst
|
||||
compressoptions -T0 --long -15
|
||||
uncompresscmd /usr/bin/unzstd
|
||||
sharedscripts
|
||||
postrotate
|
||||
/bin/pkill -HUP dwelling-radio
|
||||
endscript
|
||||
}
|
@ -1,23 +1,22 @@
|
||||
server {
|
||||
listen 443 ssl;
|
||||
listen 8091; # Tor I2P
|
||||
listen 443 ssl http2;
|
||||
# listen 8090; # Tor
|
||||
listen 127.0.0.1:8111; # I2P
|
||||
listen [300:a98d:d6d0:8a08::e]:80; # Yggdrasil
|
||||
|
||||
http2 on;
|
||||
|
||||
server_name radio.arav.su radio.arav.i2p plkybcgxt4cdanot75cy3pbnqlbqcsrib2fmrpsnug4bqphqvfda.b32.i2p mkgnmhmzqm7kyzv7jnzzafvgm7xlmlfvzhgorpapd5or2arnhuktqd.onion;
|
||||
server_name radio.arav.top radio.arav.i2p mkgnmhmzqm7kyzv7jnzzafvgm7xlmlfvzhgorpapd5or2arnhuktqd.onion;
|
||||
|
||||
access_log /var/log/nginx/dwelling/radio.log main if=$nolog;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/arav.su/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/arav.su/privkey.pem;
|
||||
ssl_certificate /etc/letsencrypt/live/arav.top/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/arav.top/privkey.pem;
|
||||
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self'; media-src 'self'; object-src 'none'; frame-src 'none'; frame-ancestors 'none'; font-src 'self'; form-action 'none'";
|
||||
add_header X-Frame-Options "DENY";
|
||||
add_header X-Content-Type-Options "nosniff";
|
||||
add_header X-XSS-Protection "1; mode=block";
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload";
|
||||
add_header Onion-Location "http://wsmkgnmhmzqm7kyzv7jnzzafvgm7xlmlfvzhgorpapd5or2arnhuktqd.onion$request_uri";
|
||||
# add_header Onion-Location "http://mkgnmhmzqm7kyzv7jnzzafvgm7xlmlfvzhgorpapd5or2arnhuktqd.onion$request_uri";
|
||||
|
||||
|
||||
location / {
|
||||
@ -27,9 +26,20 @@ server {
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
|
||||
location =/filelist {
|
||||
alias $http_root/shared/radio_filelist.html;
|
||||
default_type text/html;
|
||||
}
|
||||
|
||||
location =/robots.txt {
|
||||
alias $http_root/shared/files/radio.robots.txt;
|
||||
default_type text/html;
|
||||
}
|
||||
|
||||
|
||||
location /live/ {
|
||||
proxy_pass http://127.0.0.1:8000/;
|
||||
proxy_bind $remote_addr transparent;
|
||||
proxy_buffering off;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
@ -37,24 +47,12 @@ server {
|
||||
location /live/admin/ {
|
||||
deny all;
|
||||
}
|
||||
|
||||
location /api/listener/icecast {
|
||||
allow 127.0.0.1;
|
||||
allow 192.168.144.2;
|
||||
deny all;
|
||||
}
|
||||
|
||||
location /api/playlist {
|
||||
allow 127.0.0.1;
|
||||
allow 192.168.144.2;
|
||||
deny all;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 192.168.144.2:8000;
|
||||
listen 8000;
|
||||
|
||||
server_name radio.arav.su;
|
||||
server_name radio.arav.top;
|
||||
|
||||
access_log /var/log/nginx/dwelling/radio.http.log main if=$nolog;
|
||||
|
||||
@ -62,14 +60,12 @@ server {
|
||||
add_header X-Frame-Options "DENY";
|
||||
add_header X-Content-Type-Options "nosniff";
|
||||
add_header X-XSS-Protection "1; mode=block";
|
||||
add_header Onion-Location "http://wsmkgnmhmzqm7kyzv7jnzzafvgm7xlmlfvzhgorpapd5or2arnhuktqd.onion/live$request_uri";
|
||||
add_header Onion-Location "http://mkgnmhmzqm7kyzv7jnzzafvgm7xlmlfvzhgorpapd5or2arnhuktqd.onion/live$request_uri";
|
||||
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8000/;
|
||||
proxy_bind $remote_addr transparent;
|
||||
proxy_buffering off;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_pass http://127.0.0.1:8000/;
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
location /admin/ {
|
||||
|
@ -1,3 +0,0 @@
|
||||
[Unit]
|
||||
Requires=dwelling-radio.service
|
||||
After=dwelling-radio.service
|
@ -10,8 +10,8 @@ end
|
||||
def xfade(a, b)
|
||||
add(normalize = false,
|
||||
[ sequence([ blank(duration = 2.),
|
||||
fade.in(duration = 2., b)]),
|
||||
fade.out(duration = 2., a)])
|
||||
fade.initial(duration = 2., b)]),
|
||||
fade.final(duration = 2., a)])
|
||||
end
|
||||
|
||||
def fallback_alter_title(m) =
|
||||
@ -19,16 +19,14 @@ def fallback_alter_title(m) =
|
||||
("title", string.concat(["No stream. (Playing ", m["artist"], " - ", m["title"], ")"]))]
|
||||
end
|
||||
|
||||
settings.server.telnet := false
|
||||
settings.harbor.bind_addrs := ["0.0.0.0"]
|
||||
settings.log.level := 1
|
||||
settings.log.file := true
|
||||
settings.log.file.path := log_file_path
|
||||
settings.log.stdout := false
|
||||
settings.server.telnet.set(false)
|
||||
settings.harbor.bind_addrs.set(["0.0.0.0"])
|
||||
settings.log.level.set(2)
|
||||
settings.log.file.set(true)
|
||||
settings.log.file.path.set(log_file_path)
|
||||
settings.log.stdout.set(false)
|
||||
|
||||
enable_replaygain_metadata()
|
||||
|
||||
fallback_song = mksafe(single(fullpath("fallback.ogg")))
|
||||
fallback_song = mksafe(single(fullpath("fallback.mp3")))
|
||||
fallback_song = metadata.map(fallback_alter_title, fallback_song)
|
||||
|
||||
live_mixin = input.harbor("adr-live-mixin", port = harbor_port, password = harbor_password)
|
||||
@ -36,18 +34,17 @@ live_show = input.harbor("adr-live-show", port = harbor_port, password = harbor_
|
||||
live_show = metadata.map(fun (_) -> [("artist", radio_name), ("title", "Live Show")], live_show)
|
||||
|
||||
playlist_random = playlist(fullpath("playlists/all-rand"),
|
||||
prefix = string.concat(["replaygain:", fullpath("music/")]),
|
||||
prefix = fullpath("music/"),
|
||||
mode = "normal", reload_mode = "watch")
|
||||
|
||||
music = audio_to_stereo(playlist_random)
|
||||
music = replaygain(music)
|
||||
|
||||
radio = smooth_add(p = 0.18, normal = music, special = live_mixin)
|
||||
radio = fallback(track_sensitive = false,
|
||||
transitions = [xfade, xfade, xfade],
|
||||
[ blank.strip(max_blank=15., live_show),
|
||||
blank.strip(max_blank=90., radio), fallback_song])
|
||||
radio = normalize(target = -12., threshold = -40., lufs = true, radio)
|
||||
radio = normalize(radio)
|
||||
|
||||
output.icecast(%vorbis.cbr(bitrate = 128, samplerate = 44100, channels = 2),
|
||||
host = icecast_host, port = icecast_port,
|
||||
|
@ -2,17 +2,17 @@
|
||||
|
||||
log_file_path = "/var/log/dwelling-radio/liquidsoap.log"
|
||||
|
||||
radio_url = "https://radio.arav.su"
|
||||
radio_url = "https://radio.arav.top"
|
||||
radio_name = "Arav's dwelling / Radio"
|
||||
radio_desc = "Broadcasting from under my desk."
|
||||
|
||||
radio_dir = "/mnt/data/appdata/radio/"
|
||||
radio_dir = "/srv/radio/"
|
||||
|
||||
harbor_port = 8002
|
||||
harbor_password = ""
|
||||
|
||||
icecast_host = "127.0.0.1"
|
||||
icecast_port = 8001
|
||||
icecast_port = 8000
|
||||
icecast_password = ""
|
||||
icecast_mount = "stream.ogg"
|
||||
icecast_genre = "Various"
|
20
go.mod
@ -1,21 +1,9 @@
|
||||
module dwelling-radio
|
||||
|
||||
go 1.21
|
||||
|
||||
toolchain go1.22.3
|
||||
|
||||
require github.com/pkg/errors v0.9.1
|
||||
|
||||
require git.arav.su/Arav/httpr v0.3.2
|
||||
|
||||
require github.com/a-h/templ v0.2.778
|
||||
go 1.18
|
||||
|
||||
require (
|
||||
github.com/invopop/ctxi18n v0.8.1
|
||||
github.com/mattn/go-sqlite3 v1.14.23
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/invopop/yaml v0.3.1 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
github.com/julienschmidt/httprouter v1.3.0
|
||||
github.com/pkg/errors v0.9.1
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
20
go.sum
@ -1,23 +1,7 @@
|
||||
git.arav.su/Arav/httpr v0.3.2 h1:a+ifu+9+FnQe6p/Kd4kgTDKAFN6zBOJjBTMjbAuHxVk=
|
||||
git.arav.su/Arav/httpr v0.3.2/go.mod h1:z0SVYwe5dBReeVuFU9QH2PmBxICJwchxqY5OfZbeVzU=
|
||||
github.com/a-h/templ v0.2.778 h1:VzhOuvWECrwOec4790lcLlZpP4Iptt5Q4K9aFxQmtaM=
|
||||
github.com/a-h/templ v0.2.778/go.mod h1:lq48JXoUvuQrU0VThrK31yFwdRjTCnIE5bcPCM9IP1w=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/invopop/ctxi18n v0.8.1 h1:nfy5Mk6UfvLbGRBwpTi4T1g95+rmRo8bMllUmpCvVwI=
|
||||
github.com/invopop/ctxi18n v0.8.1/go.mod h1:1Osw+JGYA+anHt0Z4reF36r5FtGHYjGQ+m1X7keIhPc=
|
||||
github.com/invopop/yaml v0.3.1 h1:f0+ZpmhfBSS4MhG+4HYseMdJhoeeopbSKbq5Rpeelso=
|
||||
github.com/invopop/yaml v0.3.1/go.mod h1:PMOp3nn4/12yEZUFfmOuNHJsZToEEOwoWsT+D81KkeA=
|
||||
github.com/mattn/go-sqlite3 v1.14.23 h1:gbShiuAP1W5j9UOksQ06aiiqPMxYecovVGwmTxWtuw0=
|
||||
github.com/mattn/go-sqlite3 v1.14.23/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U=
|
||||
github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
|
@ -1,55 +0,0 @@
|
||||
[Unit]
|
||||
Description=Arav's dwelling / Radio / EZStream
|
||||
Requires=dwelling-radio.service icecast.service
|
||||
After=network-online.target dwelling-radio.service icecast.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
Restart=on-failure
|
||||
User=dwelling-radio
|
||||
DynamicUser=yes
|
||||
ExecStart=/usr/bin/ezstream -c /etc/dwelling/ezstream.xml
|
||||
ExecStop=/bin/kill -INT $MAINPID
|
||||
ExecReload=/bin/kill -HUP $MAINPID
|
||||
|
||||
ReadOnlyPaths=/
|
||||
|
||||
LogsDirectory=dwelling-radio
|
||||
|
||||
AmbientCapabilities=
|
||||
CapabilityBoundingSet=
|
||||
|
||||
LockPersonality=true
|
||||
MemoryDenyWriteExecute=true
|
||||
NoNewPrivileges=true
|
||||
PrivateDevices=true
|
||||
PrivateTmp=true
|
||||
PrivateUsers=true
|
||||
ProcSubset=pid
|
||||
ProtectClock=true
|
||||
ProtectControlGroups=true
|
||||
ProtectHome=true
|
||||
ProtectHostname=true
|
||||
ProtectKernelLogs=true
|
||||
ProtectKernelModules=true
|
||||
ProtectKernelTunables=true
|
||||
ProtectProc=noaccess
|
||||
ProtectSystem=strict
|
||||
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
|
||||
RestrictNamespaces=true
|
||||
RestrictRealtime=true
|
||||
RestrictSUIDSGID=true
|
||||
SystemCallArchitectures=native
|
||||
SystemCallFilter=~@clock
|
||||
SystemCallFilter=~@cpu-emulation
|
||||
SystemCallFilter=~@debug
|
||||
SystemCallFilter=~@module
|
||||
SystemCallFilter=~@mount
|
||||
SystemCallFilter=~@obsolete
|
||||
SystemCallFilter=~@privileged
|
||||
SystemCallFilter=~@raw-io
|
||||
SystemCallFilter=~@reboot
|
||||
SystemCallFilter=~@swap
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
@ -1,54 +0,0 @@
|
||||
[Unit]
|
||||
Description=Arav's dwelling / Radio / Liquidsoap
|
||||
Requires=icecast.service
|
||||
After=network-online.target icecast.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
Restart=on-failure
|
||||
User=dwelling-radio
|
||||
DynamicUser=yes
|
||||
ExecStart=/opt/opam/default/bin/liquidsoap /etc/dwelling/radio.liq
|
||||
ExecStop=/bin/kill -INT $MAINPID
|
||||
|
||||
ReadOnlyPaths=/
|
||||
|
||||
LogsDirectory=dwelling-radio
|
||||
|
||||
AmbientCapabilities=
|
||||
CapabilityBoundingSet=
|
||||
|
||||
LockPersonality=true
|
||||
MemoryDenyWriteExecute=true
|
||||
NoNewPrivileges=true
|
||||
PrivateDevices=true
|
||||
PrivateTmp=true
|
||||
PrivateUsers=true
|
||||
ProcSubset=pid
|
||||
ProtectClock=true
|
||||
ProtectControlGroups=true
|
||||
ProtectHome=true
|
||||
ProtectHostname=true
|
||||
ProtectKernelLogs=true
|
||||
ProtectKernelModules=true
|
||||
ProtectKernelTunables=true
|
||||
ProtectProc=noaccess
|
||||
ProtectSystem=strict
|
||||
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
|
||||
RestrictNamespaces=true
|
||||
RestrictRealtime=true
|
||||
RestrictSUIDSGID=true
|
||||
SystemCallArchitectures=native
|
||||
SystemCallFilter=~@clock
|
||||
SystemCallFilter=~@cpu-emulation
|
||||
SystemCallFilter=~@debug
|
||||
SystemCallFilter=~@module
|
||||
SystemCallFilter=~@mount
|
||||
SystemCallFilter=~@obsolete
|
||||
SystemCallFilter=~@privileged
|
||||
SystemCallFilter=~@raw-io
|
||||
SystemCallFilter=~@reboot
|
||||
SystemCallFilter=~@swap
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
@ -7,13 +7,9 @@ After=network-online.target icecast.service
|
||||
Type=simple
|
||||
Restart=on-failure
|
||||
DynamicUser=yes
|
||||
ExecStart=/usr/bin/dwelling-radio -listen /var/run/dwelling-radio/sock \
|
||||
-work-dir /mnt/data/appdata/radio \
|
||||
-playlist all-rand \
|
||||
-lst-len 10
|
||||
ExecStart=/usr/bin/dwelling-radio -conf /etc/dwelling/radio.yaml
|
||||
|
||||
ReadOnlyPaths=/
|
||||
ReadWritePaths=/mnt/data/appdata/radio
|
||||
|
||||
LogsDirectory=dwelling-radio
|
||||
RuntimeDirectory=dwelling-radio
|
||||
@ -25,33 +21,18 @@ LockPersonality=true
|
||||
MemoryDenyWriteExecute=true
|
||||
NoNewPrivileges=true
|
||||
PrivateDevices=true
|
||||
PrivateTmp=true
|
||||
PrivateUsers=true
|
||||
ProcSubset=pid
|
||||
ProtectClock=true
|
||||
ProtectControlGroups=true
|
||||
ProtectHome=true
|
||||
ProtectHostname=true
|
||||
ProtectKernelLogs=true
|
||||
ProtectKernelModules=true
|
||||
ProtectKernelTunables=true
|
||||
ProtectProc=noaccess
|
||||
ProtectSystem=strict
|
||||
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
|
||||
RestrictNamespaces=true
|
||||
RestrictRealtime=true
|
||||
RestrictSUIDSGID=true
|
||||
SystemCallArchitectures=native
|
||||
SystemCallFilter=~@clock
|
||||
SystemCallFilter=~@cpu-emulation
|
||||
SystemCallFilter=~@debug
|
||||
SystemCallFilter=~@module
|
||||
SystemCallFilter=~@mount
|
||||
SystemCallFilter=~@obsolete
|
||||
SystemCallFilter=~@privileged
|
||||
SystemCallFilter=~@raw-io
|
||||
SystemCallFilter=~@reboot
|
||||
SystemCallFilter=~@swap
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
49
internal/configuration/configuration.go
Normal file
@ -0,0 +1,49 @@
|
||||
package configuration
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type Configuration struct {
|
||||
ListenOn string `yaml:"listen_on"`
|
||||
Icecast struct {
|
||||
URL string `yaml:"url"`
|
||||
Playlist string `yaml:"playlist_path"`
|
||||
} `yaml:"icecast"`
|
||||
Liquidsoap struct {
|
||||
ExecPath string `yaml:"executable_path"`
|
||||
ScriptPath string `yaml:"script_path"`
|
||||
} `yaml:"liquidsoap"`
|
||||
ListLastNSongs int `yaml:"list_last_n_songs"`
|
||||
Log struct {
|
||||
Error string `yaml:"error"`
|
||||
} `yaml:"log"`
|
||||
}
|
||||
|
||||
// Load reads a YAML file that stores configuration of a service.
|
||||
func Load(path string) (*Configuration, error) {
|
||||
configFile, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to open configuration file")
|
||||
}
|
||||
defer configFile.Close()
|
||||
|
||||
config := &Configuration{}
|
||||
|
||||
if err := yaml.NewDecoder(configFile).Decode(config); err != nil {
|
||||
return nil, errors.Wrap(err, "failed to parse configuration file")
|
||||
}
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// SplitNetworkAddress splits ListenOn option into network type (e.g. tcp, unix,
|
||||
// udp) and address:port or /path/to/service.socket to listen on.
|
||||
func (c *Configuration) SplitNetworkAddress() (string, string) {
|
||||
s := strings.Split(c.ListenOn, " ")
|
||||
return s[0], s[1]
|
||||
}
|
@ -1,156 +0,0 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"dwelling-radio/internal/radio"
|
||||
"dwelling-radio/internal/statistics"
|
||||
"dwelling-radio/pkg/oggtag"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type DJHandlers struct {
|
||||
listeners *radio.ListenerCounter
|
||||
playlist *radio.Playlist
|
||||
stats statistics.Statistics
|
||||
curSong *radio.Song
|
||||
listLen int64
|
||||
fallbackSong string
|
||||
}
|
||||
|
||||
func NewDJHandlers(l *radio.ListenerCounter, p *radio.Playlist,
|
||||
stats statistics.Statistics, cs *radio.Song, n int64, fS string) *DJHandlers {
|
||||
return &DJHandlers{listeners: l, playlist: p,
|
||||
stats: stats, curSong: cs, listLen: n, fallbackSong: fS}
|
||||
}
|
||||
|
||||
func (dj *DJHandlers) ListenersUpdateIcecast(w http.ResponseWriter, r *http.Request) {
|
||||
defer func() {
|
||||
if err := recover(); err != nil {
|
||||
log.Println("DJHandlers.ListenersUpdateIcecast panic:", err)
|
||||
}
|
||||
}()
|
||||
|
||||
if err := r.ParseForm(); err != nil {
|
||||
log.Println("DJHandlers.ListenersUpdateIcecast:", err)
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
switch r.FormValue("action") {
|
||||
case "listener_add":
|
||||
dj.listeners.Lock()
|
||||
_ = dj.listeners.Inc()
|
||||
dj.listeners.Unlock()
|
||||
case "listener_remove":
|
||||
dj.listeners.Lock()
|
||||
defer dj.listeners.Unlock()
|
||||
if _, err := dj.listeners.Dec(); err != nil {
|
||||
log.Println("DJHandlers.ListenersUpdateIcecast:", err)
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotAcceptable)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Add("Content-Type", "text/plain")
|
||||
w.Header().Add("Icecast-Auth-User", "1")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
const defaultArtistTag = "[LOL, no artist tag]"
|
||||
const defaultTitleTag = "[No title tag for you -_-]"
|
||||
const defaultTitleTagNoArtist = "[And no title tag either! Pffft]"
|
||||
|
||||
func (dj *DJHandlers) PlaylistNext(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Add("Content-Type", "text/plain")
|
||||
dj.playlist.Lock()
|
||||
nxt := dj.playlist.Next()
|
||||
dj.playlist.Unlock()
|
||||
if nxt == "" {
|
||||
log.Println("the end of a playlist has been reached")
|
||||
if nxt = dj.fallbackSong; nxt == "" {
|
||||
log.Println("a fallback song is not set")
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
oggf, err := oggtag.NewOggFile(nxt)
|
||||
if err != nil {
|
||||
log.Println("cannot read an OGG file", nxt, ":", err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
newSong := radio.Song{
|
||||
Artist: oggf.GetTag("artist"),
|
||||
Title: oggf.GetTag("title"),
|
||||
Duration: oggf.GetDuration(),
|
||||
// Here 5 seconds are being added because it is approximately the
|
||||
// time between the creation of this Song object and when ezstream
|
||||
// actually starts to play it.
|
||||
StartAt: time.Now()} // .Add(5 * time.Second)
|
||||
|
||||
if newSong.Artist == "" && newSong.Title == "" {
|
||||
log.Println("Playlist:", nxt, "has no artist and title tags.")
|
||||
newSong.Artist = defaultArtistTag
|
||||
newSong.Title = defaultTitleTagNoArtist
|
||||
} else if newSong.Artist == "" {
|
||||
log.Println("Playlist:", nxt, "has no artist tag.")
|
||||
newSong.Artist = defaultArtistTag
|
||||
} else if newSong.Title == "" {
|
||||
log.Println("Playlist:", nxt, "has no title tag.")
|
||||
newSong.Title = defaultTitleTag
|
||||
}
|
||||
|
||||
if strings.HasSuffix(nxt, "/fallback.ogg") {
|
||||
newSong.Artist = "Nothing to play. Playing a fallback: " + newSong.Artist
|
||||
}
|
||||
|
||||
dj.listeners.Lock()
|
||||
dj.curSong.Listeners, dj.curSong.PeakListeners = dj.listeners.Reset()
|
||||
dj.listeners.Unlock()
|
||||
|
||||
if dj.curSong.Artist != "" && !strings.Contains(dj.curSong.Artist, "no artist tag") &&
|
||||
!strings.Contains(dj.curSong.Artist, "No title tag") {
|
||||
if err := dj.stats.Add(dj.curSong); err != nil {
|
||||
log.Println("cannot add a song to a stats DB:", err)
|
||||
}
|
||||
}
|
||||
|
||||
*dj.curSong = newSong
|
||||
dj.curSong.Listeners = 0
|
||||
dj.curSong.PeakListeners = 0
|
||||
|
||||
fmt.Fprintln(w, nxt)
|
||||
}
|
||||
|
||||
func (dj *DJHandlers) Status(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
|
||||
lst, err := dj.stats.LastNSongs(dj.listLen)
|
||||
if err != nil {
|
||||
log.Println("failed to fetch last n songs:", err)
|
||||
}
|
||||
|
||||
dj.listeners.RLock()
|
||||
defer dj.listeners.RUnlock()
|
||||
err = json.NewEncoder(w).Encode(&struct {
|
||||
Current *radio.Song `json:"current_song,omitempty"`
|
||||
Listeners int64 `json:"listeners"`
|
||||
List []radio.Song `json:"last_songs,omitempty"`
|
||||
}{
|
||||
Current: dj.curSong,
|
||||
Listeners: dj.listeners.Current(),
|
||||
List: lst})
|
||||
if err != nil {
|
||||
log.Println("DJHandlers.Status:", err)
|
||||
http.Error(w, "status parsing failed", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
100
internal/http/handlers.go
Normal file
@ -0,0 +1,100 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"dwelling-radio/internal/configuration"
|
||||
"dwelling-radio/internal/radio"
|
||||
"dwelling-radio/pkg/utils"
|
||||
"dwelling-radio/web"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
const FormatISO8601 = "2006-01-02T15:04:05-0700"
|
||||
|
||||
type Handlers struct {
|
||||
conf *configuration.Configuration
|
||||
}
|
||||
|
||||
func NewHandlers(conf *configuration.Configuration) *Handlers {
|
||||
return &Handlers{conf: conf}
|
||||
}
|
||||
|
||||
func (h *Handlers) AssetsFS() http.FileSystem {
|
||||
return web.Assets()
|
||||
}
|
||||
|
||||
func (h *Handlers) Index(w http.ResponseWriter, r *http.Request) {
|
||||
status, err := radio.IcecastGetStatus(h.conf.Icecast.URL)
|
||||
if err != nil {
|
||||
log.Println("failed to get Icecast status:", err)
|
||||
} else {
|
||||
if tim, err := time.Parse(time.RFC1123Z, status.ServerStartDate); err == nil {
|
||||
status.ServerStartDate = utils.ToClientTimezone(tim, r).Format(time.RFC1123)
|
||||
}
|
||||
|
||||
if tim, err := time.Parse(FormatISO8601, status.ServerStartISO8601); err == nil {
|
||||
status.ServerStartISO8601 = utils.ToClientTimezone(tim, r).Format(FormatISO8601)
|
||||
}
|
||||
}
|
||||
|
||||
songs, err := radio.IcecastLastPlayedSongs(h.conf.ListLastNSongs,
|
||||
h.conf.Icecast.Playlist)
|
||||
if err != nil {
|
||||
log.Println("cannot retrieve last songs:", err)
|
||||
} else {
|
||||
for i := 0; i < len(songs); i++ {
|
||||
if tim, err := time.Parse(radio.SongTimeFormat, songs[i].Time); err == nil {
|
||||
songs[i].Time = utils.ToClientTimezone(tim, r).Format("15:04")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
web.Index(utils.MainSite(r.Host), h.conf.ListLastNSongs, status, &songs, w)
|
||||
}
|
||||
|
||||
func (h *Handlers) Status(w http.ResponseWriter, r *http.Request) {
|
||||
status, err := radio.IcecastGetStatus(h.conf.Icecast.URL)
|
||||
if err != nil {
|
||||
log.Println("cannot retrieve Icecast status:", err)
|
||||
http.Error(w, "cannot retrieve Icecast status", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if tim, err := time.Parse(time.RFC1123Z, status.ServerStartDate); err == nil {
|
||||
status.ServerStartDate = utils.ToClientTimezone(tim, r).Format(time.RFC1123)
|
||||
}
|
||||
|
||||
if tim, err := time.Parse(FormatISO8601, status.ServerStartISO8601); err == nil {
|
||||
status.ServerStartISO8601 = utils.ToClientTimezone(tim, r).Format(FormatISO8601)
|
||||
}
|
||||
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(status)
|
||||
}
|
||||
|
||||
func (h *Handlers) LastSong(w http.ResponseWriter, r *http.Request) {
|
||||
song, err := radio.IcecastLastSong(h.conf.Icecast.Playlist)
|
||||
if err != nil {
|
||||
log.Println("cannot retrieve last songs:", err)
|
||||
}
|
||||
|
||||
if song.Time == "" {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
if tim, err := time.Parse(radio.SongTimeFormat, song.Time); err == nil {
|
||||
song.Time = utils.ToClientTimezone(tim, r).Format("15:04")
|
||||
}
|
||||
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(song)
|
||||
}
|
||||
|
||||
func (h *Handlers) Playlist(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Add("Content-Disposition", "attachment; filename=\"radio.arav.top.m3u\"")
|
||||
fc, _ := web.AssetsGetFile("radio.arav.top.m3u")
|
||||
w.Write(fc)
|
||||
}
|
76
internal/http/http.go
Normal file
@ -0,0 +1,76 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/julienschmidt/httprouter"
|
||||
)
|
||||
|
||||
type HttpServer struct {
|
||||
server *http.Server
|
||||
router *httprouter.Router
|
||||
}
|
||||
|
||||
func NewHttpServer() *HttpServer {
|
||||
r := httprouter.New()
|
||||
return &HttpServer{
|
||||
server: &http.Server{
|
||||
ReadTimeout: 3 * time.Second,
|
||||
WriteTimeout: 3 * time.Second,
|
||||
Handler: r,
|
||||
},
|
||||
router: r,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *HttpServer) GET(path string, handler http.HandlerFunc) {
|
||||
s.router.Handler(http.MethodGet, path, handler)
|
||||
}
|
||||
|
||||
func (s *HttpServer) ServeStatic(path string, fsys http.FileSystem) {
|
||||
s.router.ServeFiles(path, fsys)
|
||||
}
|
||||
|
||||
func (s *HttpServer) SetNotFoundHandler(handler http.HandlerFunc) {
|
||||
s.router.NotFound = handler
|
||||
}
|
||||
|
||||
// GetURLParam wrapper around underlying router for getting URL parameters.
|
||||
func GetURLParam(r *http.Request, param string) string {
|
||||
return httprouter.ParamsFromContext(r.Context()).ByName(param)
|
||||
}
|
||||
|
||||
func (s *HttpServer) Start(network, address string) error {
|
||||
listener, err := net.Listen(network, address)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if listener.Addr().Network() == "unix" {
|
||||
os.Chmod(address, 0777)
|
||||
}
|
||||
|
||||
go func() {
|
||||
if err = s.server.Serve(listener); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *HttpServer) Stop() error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := s.server.Shutdown(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
@ -1,76 +0,0 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type HttpServer struct {
|
||||
s http.Server
|
||||
addr net.Addr
|
||||
}
|
||||
|
||||
func NewHttpServer(r http.Handler) *HttpServer {
|
||||
return &HttpServer{s: http.Server{
|
||||
ReadTimeout: 3 * time.Second,
|
||||
WriteTimeout: 3 * time.Second,
|
||||
Handler: r}}
|
||||
}
|
||||
|
||||
func (s *HttpServer) Start(address string) error {
|
||||
var network string
|
||||
if !strings.ContainsRune(address, ':') {
|
||||
network = "unix"
|
||||
} else {
|
||||
ap, err := netip.ParseAddrPort(address)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if ap.Addr().Is4() {
|
||||
network = "tcp4"
|
||||
} else if ap.Addr().Is6() {
|
||||
network = "tcp6"
|
||||
}
|
||||
}
|
||||
|
||||
listener, err := net.Listen(network, address)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if listener.Addr().Network() == "unix" {
|
||||
os.Chmod(address, 0777)
|
||||
}
|
||||
|
||||
s.addr = listener.Addr()
|
||||
|
||||
go func() {
|
||||
if err = s.s.Serve(listener); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *HttpServer) Stop() error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if s.addr.Network() == "unix" {
|
||||
defer os.Remove(s.addr.String())
|
||||
}
|
||||
|
||||
if err := s.s.Shutdown(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
225
internal/radio/icecast.go
Normal file
@ -0,0 +1,225 @@
|
||||
package radio
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"dwelling-radio/pkg/watcher"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
const (
|
||||
IcecastPlaylistDateFormat = "02/Jan/2006:15:04:05 -0700"
|
||||
SongTimeFormat = "2006 15:04-0700"
|
||||
|
||||
bufferSize = 32768
|
||||
)
|
||||
|
||||
var (
|
||||
lastPlayedCache []Song = make([]Song, 10)
|
||||
lastPlayedCacheMutex sync.Mutex
|
||||
)
|
||||
|
||||
type IcecastStatusDTO struct {
|
||||
Icestats struct {
|
||||
ServerStartISO8601 string `json:"server_start_iso8601"`
|
||||
ServerStartDate string `json:"server_start"`
|
||||
Source struct {
|
||||
Artist string `json:"artist"`
|
||||
Title string `json:"title"`
|
||||
ListenerPeak int `json:"listener_peak"`
|
||||
Listeners int `json:"listeners"`
|
||||
} `json:"source"`
|
||||
} `json:"icestats"`
|
||||
}
|
||||
|
||||
func (is *IcecastStatusDTO) SongName() string {
|
||||
return is.Icestats.Source.Artist + " - " + is.Icestats.Source.Title
|
||||
}
|
||||
|
||||
type IcecastStatus struct {
|
||||
ServerStartISO8601 string `json:"server_start_iso8601"`
|
||||
ServerStartDate string `json:"server_start_date"`
|
||||
SongName string `json:"song"`
|
||||
ListenerPeak int `json:"listener_peak"`
|
||||
Listeners int `json:"listeners"`
|
||||
}
|
||||
|
||||
type Song struct {
|
||||
Time string `json:"time"`
|
||||
Listeners string `json:"listeners"`
|
||||
Song string `json:"song"`
|
||||
}
|
||||
|
||||
func IcecastGetStatus(icecastURL string) (*IcecastStatus, error) {
|
||||
resp, err := http.Get(icecastURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
iceStatDTO := &IcecastStatusDTO{}
|
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(iceStatDTO); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &IcecastStatus{
|
||||
ServerStartISO8601: iceStatDTO.Icestats.ServerStartISO8601,
|
||||
ServerStartDate: iceStatDTO.Icestats.ServerStartDate,
|
||||
SongName: iceStatDTO.SongName(),
|
||||
ListenerPeak: iceStatDTO.Icestats.Source.ListenerPeak,
|
||||
Listeners: iceStatDTO.Icestats.Source.Listeners,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func IcecastLastPlayedSongs(lastNSongs int, playlistPath string) ([]Song, error) {
|
||||
{
|
||||
lastPlayedCacheMutex.Lock()
|
||||
defer lastPlayedCacheMutex.Unlock()
|
||||
if lpcLen := len(lastPlayedCache); lpcLen > 0 {
|
||||
if lastNSongs > lpcLen {
|
||||
lastNSongs = lpcLen
|
||||
}
|
||||
var ret []Song = make([]Song, lastNSongs)
|
||||
copy(ret[:], lastPlayedCache[lpcLen-lastNSongs:])
|
||||
return ret, nil
|
||||
}
|
||||
}
|
||||
|
||||
songs, err := icecastLastPlayedSongs(playlistPath, lastNSongs)
|
||||
if err != nil {
|
||||
return make([]Song, 0), err
|
||||
}
|
||||
|
||||
return songs, nil
|
||||
}
|
||||
|
||||
func IcecastLastSong(playlistPath string) (Song, error) {
|
||||
{
|
||||
lastPlayedCacheMutex.Lock()
|
||||
defer lastPlayedCacheMutex.Unlock()
|
||||
if lpcLen := len(lastPlayedCache); lpcLen > 0 {
|
||||
return lastPlayedCache[lpcLen-1], nil
|
||||
}
|
||||
}
|
||||
|
||||
song, err := icecastLastPlayedSongs(playlistPath, 1)
|
||||
if err != nil {
|
||||
return Song{}, err
|
||||
}
|
||||
|
||||
return song[0], nil
|
||||
}
|
||||
|
||||
func icecastLastPlayedSongs(playlistPath string, n int) ([]Song, error) {
|
||||
songs := make([]Song, n)
|
||||
var buf []byte
|
||||
var offset int64 = 0
|
||||
|
||||
playlist, err := os.Open(playlistPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer playlist.Close()
|
||||
|
||||
playlist_stat, _ := playlist.Stat()
|
||||
|
||||
if playlist_stat.Size() == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if playlist_stat.Size() < bufferSize {
|
||||
buf = make([]byte, playlist_stat.Size())
|
||||
} else {
|
||||
buf = make([]byte, bufferSize)
|
||||
offset = playlist_stat.Size() - bufferSize
|
||||
}
|
||||
|
||||
_, err = playlist.ReadAt(buf, offset)
|
||||
if err != nil && err != io.EOF {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
lines := bytes.Split(buf, []byte("\n"))
|
||||
|
||||
if len(lines) < 3 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
lines = lines[:len(lines)-2]
|
||||
|
||||
if len(lines) > n {
|
||||
lines = lines[len(lines)-n:]
|
||||
}
|
||||
|
||||
for _, line := range lines {
|
||||
fields := bytes.Split(line, []byte("|"))
|
||||
|
||||
tim, _ := time.Parse(IcecastPlaylistDateFormat, string(fields[0]))
|
||||
|
||||
songs = append(songs, Song{
|
||||
Time: tim.Format(SongTimeFormat),
|
||||
Listeners: string(fields[2]),
|
||||
Song: string(fields[3])})
|
||||
}
|
||||
|
||||
return songs, nil
|
||||
}
|
||||
|
||||
type PlaylistLogWatcher struct {
|
||||
watcher *watcher.InotifyWatcher
|
||||
changed chan uint32
|
||||
}
|
||||
|
||||
func NewPlaylistLogWatcher() *PlaylistLogWatcher {
|
||||
return &PlaylistLogWatcher{changed: make(chan uint32)}
|
||||
}
|
||||
|
||||
func (pw *PlaylistLogWatcher) Watch(playlistPath string, n int) (err error) {
|
||||
if pw.watcher != nil {
|
||||
pw.watcher.Close()
|
||||
}
|
||||
|
||||
pw.watcher, err = watcher.NewInotifyWatcher()
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "cannot instantiate inotify watcher")
|
||||
}
|
||||
|
||||
err = pw.watcher.AddWatch(playlistPath, watcher.ModIgnMask)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "cannot set a playlist to watch")
|
||||
}
|
||||
|
||||
pw.watcher.WatchForMask(pw.changed, watcher.ModIgnMask)
|
||||
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case mask := <-pw.changed:
|
||||
if mask&syscall.IN_MODIFY > 0 {
|
||||
lastPlayedCacheMutex.Lock()
|
||||
if songs, err := icecastLastPlayedSongs(playlistPath, n); err == nil {
|
||||
lastPlayedCache = songs
|
||||
}
|
||||
lastPlayedCacheMutex.Unlock()
|
||||
} else if mask&syscall.IN_IGNORED > 0 {
|
||||
pw.Close()
|
||||
pw.Watch(playlistPath, n)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (pw *PlaylistLogWatcher) Close() {
|
||||
pw.watcher.Close()
|
||||
}
|
54
internal/radio/liquidsoap.go
Normal file
@ -0,0 +1,54 @@
|
||||
package radio
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"syscall"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
var ErrLiquidsoapNotRunning = errors.New("liquidsoap is not running")
|
||||
|
||||
type Liquidsoap struct {
|
||||
command *exec.Cmd
|
||||
}
|
||||
|
||||
func NewLiquidsoap(liquidsoapPath, scriptPath string) (*Liquidsoap, error) {
|
||||
if _, err := exec.LookPath(liquidsoapPath); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out, err := exec.Command(liquidsoapPath, "--verbose", "-c", scriptPath).CombinedOutput()
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "script cannot be validated")
|
||||
}
|
||||
|
||||
if len(out) > 0 {
|
||||
return nil, errors.Errorf("script validation failed: %s", string(out))
|
||||
}
|
||||
|
||||
cmd := exec.Command(liquidsoapPath, scriptPath)
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Liquidsoap{
|
||||
command: cmd}, nil
|
||||
}
|
||||
|
||||
func (l *Liquidsoap) Stop() error {
|
||||
if l.command.Process == nil && l.command.ProcessState != nil {
|
||||
return ErrLiquidsoapNotRunning
|
||||
}
|
||||
|
||||
if err := l.command.Process.Signal(syscall.SIGINT); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := l.command.Wait(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
@ -1,93 +0,0 @@
|
||||
package radio
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"math"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// ListenerCounter stores the current, overall and peak numbers of listeners.
|
||||
type ListenerCounter struct {
|
||||
sync.RWMutex
|
||||
current, peak int64
|
||||
overall, cur_peak int64
|
||||
}
|
||||
|
||||
// NewListenerCounter returns a new ListenerCounter struct instance.
|
||||
func NewListenerCounter() *ListenerCounter {
|
||||
return &ListenerCounter{}
|
||||
}
|
||||
|
||||
// Current returns a number of current listeners.
|
||||
func (l *ListenerCounter) Current() int64 {
|
||||
return l.current
|
||||
}
|
||||
|
||||
// Peak returns a peak number of listeners.
|
||||
func (l *ListenerCounter) Peak() int64 {
|
||||
return l.peak
|
||||
}
|
||||
|
||||
// CurrentPeak returns a peak number of listeners for a currently playing song.
|
||||
func (l *ListenerCounter) CurrentPeak() int64 {
|
||||
return l.cur_peak
|
||||
}
|
||||
|
||||
// Overall returns an overall number of listeners for a currently playing song.
|
||||
func (l *ListenerCounter) Overall() int64 {
|
||||
return l.overall
|
||||
}
|
||||
|
||||
// Inc increments by 1 a current number of listeners and updates a peak number.
|
||||
func (l *ListenerCounter) Inc() int64 {
|
||||
if l.current == math.MaxInt64 {
|
||||
// We panic here because if this will ever happen, then something's going certainly wrong.
|
||||
panic("a current number of listeners exceeded MaxInt64")
|
||||
}
|
||||
l.current++
|
||||
if l.current > l.peak {
|
||||
l.peak = l.current
|
||||
}
|
||||
if l.current > l.cur_peak {
|
||||
l.cur_peak = l.current
|
||||
}
|
||||
if l.overall == math.MaxInt64 {
|
||||
panic("an overall number of listeners exceeded MaxInt64")
|
||||
}
|
||||
l.overall++
|
||||
return l.current
|
||||
}
|
||||
|
||||
// Dec decrements by 1 a current number of listeners. An error will occur if
|
||||
// a resulting number is less than 0.
|
||||
func (l *ListenerCounter) Dec() (int64, error) {
|
||||
if l.current == 0 {
|
||||
return l.current, errors.New("an attempt to decrement a number of current listeners down to less than 0")
|
||||
}
|
||||
l.current--
|
||||
return l.current, nil
|
||||
}
|
||||
|
||||
// Reset current peak and overall listeners for a song that is playing.
|
||||
// And return its values.
|
||||
func (l *ListenerCounter) Reset() (overall, peak int64) {
|
||||
peak = l.cur_peak
|
||||
l.cur_peak = l.current
|
||||
overall = l.overall
|
||||
l.overall = l.current
|
||||
return
|
||||
}
|
||||
|
||||
func (l *ListenerCounter) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(&struct {
|
||||
Current int64 `json:"current"`
|
||||
Peak int64 `json:"peak"`
|
||||
Overall int64 `json:"overall"`
|
||||
CurPeak int64 `json:"current_peak"`
|
||||
}{
|
||||
Current: l.current,
|
||||
Peak: l.peak,
|
||||
Overall: l.overall,
|
||||
CurPeak: l.cur_peak})
|
||||
}
|
@ -1,64 +0,0 @@
|
||||
package radio
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
// Playlist holds a list of paths to a song files.
|
||||
type Playlist struct {
|
||||
sync.Mutex
|
||||
filePath string
|
||||
playlist []string
|
||||
cur int
|
||||
repeat bool
|
||||
}
|
||||
|
||||
// NewPlaylist returns an instance of a Playlist struct with a loaded playlist.
|
||||
// Returns an error if failed to load a playlist file.
|
||||
func NewPlaylist(filePath string, repeat bool) (*Playlist, error) {
|
||||
p := &Playlist{filePath: filePath, repeat: repeat}
|
||||
return p, p.load()
|
||||
}
|
||||
|
||||
// Next returns the next song to play. Returns an empty string if repeat is
|
||||
// false and the end of a playlist was reached.
|
||||
func (p *Playlist) Next() (song string) {
|
||||
if p.cur == len(p.playlist) {
|
||||
// If the end of a playlist was reached and repeat is set to true,
|
||||
// then go back to the head of it, thus repeating it. Return an empty
|
||||
// string otherwise.
|
||||
if p.repeat {
|
||||
p.cur = 0
|
||||
} else {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
song = p.playlist[p.cur]
|
||||
p.cur++
|
||||
return
|
||||
}
|
||||
|
||||
// Load reads a playlist file.
|
||||
func (p *Playlist) load() error {
|
||||
data, err := os.ReadFile(p.filePath)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "cannot open a playlist file")
|
||||
}
|
||||
|
||||
if len(data) == 0 {
|
||||
return errors.New("a playlist file is empty. Did not update")
|
||||
}
|
||||
|
||||
p.playlist = strings.Split(string(data), "\n")
|
||||
p.cur = 0
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Playlist) Reload() error {
|
||||
return p.load()
|
||||
}
|
@ -1,40 +0,0 @@
|
||||
package radio
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Song struct {
|
||||
Artist string
|
||||
Title string
|
||||
StartAt time.Time
|
||||
Duration time.Duration
|
||||
Listeners int64
|
||||
PeakListeners int64
|
||||
}
|
||||
|
||||
// DurationString returns song's duration as a string formatted as [H:]M:SS.
|
||||
func (s *Song) DurationString() string {
|
||||
if s.Duration.Hours() >= 1 {
|
||||
return time.UnixMilli(s.Duration.Milliseconds()).Format("3:4:05")
|
||||
}
|
||||
return time.UnixMilli(s.Duration.Milliseconds()).Format("4:05")
|
||||
}
|
||||
|
||||
func (s *Song) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(&struct {
|
||||
Artist string `json:"artist"`
|
||||
Title string `json:"title"`
|
||||
DurationMill int64 `json:"duration_msec,omitempty"`
|
||||
Listeners int64 `json:"listeners,omitempty"`
|
||||
PeakListeners int64 `json:"peak_listeners,omitempty"`
|
||||
StartAt string `json:"start_at"`
|
||||
}{
|
||||
Artist: s.Artist,
|
||||
Title: s.Title,
|
||||
DurationMill: s.Duration.Milliseconds(),
|
||||
Listeners: s.Listeners,
|
||||
PeakListeners: s.PeakListeners,
|
||||
StartAt: s.StartAt.UTC().Format(time.RFC3339)})
|
||||
}
|
@ -1,90 +0,0 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"dwelling-radio/internal/statistics"
|
||||
_ "embed"
|
||||
"fmt"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
const dbDateFormat = "2006-01-02 15:04:05.999"
|
||||
|
||||
var (
|
||||
//go:embed queries/schema.sql
|
||||
querySchema string
|
||||
|
||||
//go:embed queries/song_add.sql
|
||||
querySongAdd string
|
||||
|
||||
//go:embed queries/history_add.sql
|
||||
queryHistoryAdd string
|
||||
|
||||
//go:embed queries/last_n_songs.sql
|
||||
queryLastNSongs string
|
||||
//go:embed queries/most_popular_songs.sql
|
||||
queryMostPopularSongs string
|
||||
//go:embed queries/most_simultaneous_listeners.sql
|
||||
queryMostSimultaneousListeners string
|
||||
)
|
||||
|
||||
type SQLiteStatistics struct {
|
||||
statistics.BaseStatistics
|
||||
}
|
||||
|
||||
func New(path string) (statistics.Statistics, error) {
|
||||
db, err := sql.Open("sqlite3", fmt.Sprintf("file:%s?_journal=WAL&_mutex=full", path))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
stats := &SQLiteStatistics{
|
||||
BaseStatistics: statistics.BaseStatistics{
|
||||
Db: db, DbDateFormat: dbDateFormat}}
|
||||
|
||||
db.Exec("PRAGMA foreign_keys = ON;")
|
||||
|
||||
_, err = db.Exec(querySchema)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err,
|
||||
statistics.ErrPrepareStmt{Name: "initial schema"}.Error())
|
||||
}
|
||||
|
||||
stats.BaseStatistics.StmtSongAdd, err = db.Prepare(querySongAdd)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err,
|
||||
statistics.ErrPrepareStmt{Name: "song_add"}.Error())
|
||||
}
|
||||
|
||||
stats.BaseStatistics.StmtHistoryAdd, err = db.Prepare(queryHistoryAdd)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err,
|
||||
statistics.ErrPrepareStmt{Name: "history_add"}.Error())
|
||||
}
|
||||
|
||||
stats.BaseStatistics.StmtLastNSongs, err = db.Prepare(queryLastNSongs)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err,
|
||||
statistics.ErrPrepareStmt{Name: "last N songs"}.Error())
|
||||
}
|
||||
|
||||
stats.BaseStatistics.StmtMostPopularSongs, err = db.Prepare(queryMostPopularSongs)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err,
|
||||
statistics.ErrPrepareStmt{Name: "most popular song"}.Error())
|
||||
}
|
||||
|
||||
stats.BaseStatistics.StmtMostSimultaneousListeners, err = db.Prepare(queryMostSimultaneousListeners)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err,
|
||||
statistics.ErrPrepareStmt{Name: "most simultaneous listeners"}.Error())
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
func (s *SQLiteStatistics) Close() error {
|
||||
return s.BaseStatistics.Close()
|
||||
}
|
@ -1,3 +0,0 @@
|
||||
INSERT OR IGNORE INTO `history`
|
||||
(`start_at`, `song_id`, `listeners`, `peak_listeners`)
|
||||
VALUES (?,?,?,?);
|
@ -1,19 +0,0 @@
|
||||
SELECT
|
||||
`start_at`,
|
||||
`artist`,
|
||||
`title`,
|
||||
`listeners`,
|
||||
`peak_listeners`
|
||||
FROM
|
||||
(SELECT
|
||||
`start_at`,
|
||||
`artist`,
|
||||
`title`,
|
||||
`listeners`,
|
||||
`peak_listeners`
|
||||
FROM `history`
|
||||
LEFT JOIN `song`
|
||||
ON `song`.`song_id` = `history`.`song_id`
|
||||
ORDER BY `start_at` DESC
|
||||
LIMIT ? )
|
||||
ORDER BY `start_at` ASC;
|
@ -1,10 +0,0 @@
|
||||
SELECT
|
||||
`artist`,
|
||||
`title`,
|
||||
SUM(`listeners`) AS `most_listeners`
|
||||
FROM `history`
|
||||
LEFT JOIN `song`
|
||||
ON `song`.`song_id` = `history`.`song_id`
|
||||
GROUP BY `song`.`song_id`
|
||||
ORDER BY `most_listeners` DESC
|
||||
LIMIT ?;
|
@ -1,8 +0,0 @@
|
||||
SELECT
|
||||
MAX(`start_at`) AS `start_at`,
|
||||
`artist`,
|
||||
`title`,
|
||||
MAX(`peak_listeners`) AS `peak_listeners`
|
||||
FROM `history`
|
||||
LEFT JOIN `song`
|
||||
ON `song`.`song_id` = `history`.`song_id`;
|
@ -1,15 +0,0 @@
|
||||
CREATE TABLE IF NOT EXISTS `song` (
|
||||
`song_id` INTEGER NOT NULL,
|
||||
`artist` TEXT NOT NULL,
|
||||
`title` TEXT NOT NULL,
|
||||
PRIMARY KEY (`song_id` AUTOINCREMENT),
|
||||
UNIQUE (`artist`, `title`) );
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `history` (
|
||||
`start_at` TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f', 'now')),
|
||||
`song_id` INTEGER NOT NULL,
|
||||
`listeners` INTEGER NOT NULL,
|
||||
`peak_listeners` INTEGER NOT NULL,
|
||||
PRIMARY KEY (`start_at`),
|
||||
FOREIGN KEY (`song_id`) REFERENCES `song` (`song_id`)
|
||||
ON UPDATE CASCADE ON DELETE CASCADE );
|
@ -1,6 +0,0 @@
|
||||
INSERT INTO `song`
|
||||
(`artist`, `title`)
|
||||
VALUES (?, ?)
|
||||
ON CONFLICT DO
|
||||
UPDATE SET `song_id`=`song_id`
|
||||
RETURNING `song_id`;
|
@ -1,138 +0,0 @@
|
||||
package statistics
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"dwelling-radio/internal/radio"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
const MostListenedDateFormat string = "02 January 2006 at 15:04:05 MST"
|
||||
|
||||
type Statistics interface {
|
||||
Add(*radio.Song) error
|
||||
LastNSongs(n int64) ([]radio.Song, error)
|
||||
MostNPopularSongs(n int64) ([]radio.Song, error)
|
||||
MostSimultaneousListeners() (radio.Song, error)
|
||||
Close() error
|
||||
}
|
||||
|
||||
type ErrPrepareStmt struct {
|
||||
Name string
|
||||
}
|
||||
|
||||
func (e ErrPrepareStmt) Error() string {
|
||||
return fmt.Sprintf("failed to prepare an SQL statement '%s'", e.Name)
|
||||
}
|
||||
|
||||
var ErrNoSong = errors.New("no song was passed (a struct is nil or empty)")
|
||||
var ErrSongNotAdded = errors.New("song was not added")
|
||||
|
||||
type BaseStatistics struct {
|
||||
Db *sql.DB
|
||||
DbDateFormat string
|
||||
|
||||
StmtHistoryAdd *sql.Stmt
|
||||
StmtSongAdd *sql.Stmt
|
||||
|
||||
StmtLastNSongs *sql.Stmt
|
||||
StmtMostPopularSongs *sql.Stmt
|
||||
StmtMostSimultaneousListeners *sql.Stmt
|
||||
}
|
||||
|
||||
func (s *BaseStatistics) Add(song *radio.Song) error {
|
||||
if song == nil || song.Artist == "" || song.Title == "" {
|
||||
return ErrNoSong
|
||||
}
|
||||
|
||||
tx, err := s.Db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
row := tx.Stmt(s.StmtSongAdd).QueryRow(song.Artist, song.Title)
|
||||
if row.Err() != nil {
|
||||
return row.Err()
|
||||
}
|
||||
|
||||
var songID int64
|
||||
if err := row.Scan(&songID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = tx.Stmt(s.StmtHistoryAdd).Exec(song.StartAt.UTC().Format(s.DbDateFormat),
|
||||
songID, song.Listeners, song.PeakListeners)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, ErrSongNotAdded.Error())
|
||||
}
|
||||
|
||||
tx.Commit()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *BaseStatistics) LastNSongs(n int64) ([]radio.Song, error) {
|
||||
if n == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
tx, err := s.Db.Begin()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
rows, err := tx.Stmt(s.StmtLastNSongs).Query(n)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
songs := make([]radio.Song, n)
|
||||
|
||||
i := 0
|
||||
for rows.Next() {
|
||||
var startAt string
|
||||
|
||||
if err := rows.Scan(&startAt, &songs[i].Artist, &songs[i].Title,
|
||||
&songs[i].Listeners, &songs[i].PeakListeners); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
songs[i].StartAt, err = time.Parse(s.DbDateFormat, startAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
i++
|
||||
}
|
||||
|
||||
tx.Commit()
|
||||
|
||||
if i == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
lst := make([]radio.Song, i)
|
||||
copy(lst, songs[:])
|
||||
|
||||
return lst, nil
|
||||
}
|
||||
|
||||
func (s *BaseStatistics) MostNPopularSongs(n int64) ([]radio.Song, error) {
|
||||
if n == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *BaseStatistics) MostSimultaneousListeners() (radio.Song, error) {
|
||||
return radio.Song{}, nil
|
||||
}
|
||||
|
||||
func (s *BaseStatistics) Close() error {
|
||||
return s.Db.Close()
|
||||
}
|
@ -1,106 +0,0 @@
|
||||
package oggtag
|
||||
|
||||
/* oggtag is a naive implementation of OGG tag's reader that is just looking
|
||||
for certain tag names ending with an = character, e.g. artist= and title=.
|
||||
*/
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
bufferLength = 6144
|
||||
OggS = "OggS"
|
||||
OggSLen = len(OggS)
|
||||
Vorbis = "vorbis"
|
||||
VorbisLen = len(Vorbis)
|
||||
)
|
||||
|
||||
// OggFile holds a head of a file and a tail part conatining last granule.
|
||||
type OggFile struct {
|
||||
bufHead, bufLast []byte
|
||||
}
|
||||
|
||||
// NewOggFile reads a file and returns a new OggFile.
|
||||
func NewOggFile(path string) (*OggFile, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
of := &OggFile{}
|
||||
|
||||
of.bufHead = make([]byte, bufferLength)
|
||||
|
||||
if _, err := f.Read(of.bufHead); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
of.bufLast = make([]byte, bufferLength)
|
||||
|
||||
if _, err := f.Seek(-bufferLength, io.SeekEnd); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fst, err := f.Stat()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for offset := int64(bufferLength); offset <= fst.Size(); offset += bufferLength {
|
||||
if _, err := f.Seek(-offset, io.SeekEnd); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if _, err := f.Read(of.bufLast); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if bytes.Contains(of.bufLast, []byte(OggS)) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return of, nil
|
||||
}
|
||||
|
||||
// GetTag is searching for a certain tag and returns its value or an empty string.
|
||||
func (of *OggFile) GetTag(tag string) string {
|
||||
tagIdx := bytes.Index(of.bufHead, append([]byte{0, 0, 0}, (tag+"=")...))
|
||||
if tagIdx == -1 {
|
||||
if tagIdx = bytes.Index(of.bufHead, append([]byte{0, 0, 0}, (strings.ToUpper(tag)+"=")...)); tagIdx == -1 {
|
||||
if tagIdx = bytes.Index(of.bufHead, append([]byte{0, 0, 0}, (strings.Title(tag)+"=")...)); tagIdx == -1 {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
}
|
||||
tagIdx += 3
|
||||
tagNameLen := len(tag) + 1
|
||||
valStart := tagIdx + tagNameLen
|
||||
valLen := int(of.bufHead[tagIdx-4]) - tagNameLen
|
||||
return string(of.bufHead[valStart : valStart+valLen])
|
||||
}
|
||||
|
||||
// GetDuration returns song's duration in milliseconds.
|
||||
func (of *OggFile) GetDuration() time.Duration {
|
||||
rateIdx := bytes.Index(of.bufHead, []byte(Vorbis)) +
|
||||
VorbisLen + 5
|
||||
rateBytes := of.bufHead[rateIdx : rateIdx+4]
|
||||
rate := int32(rateBytes[0]) + int32(rateBytes[1])<<8 +
|
||||
int32(rateBytes[2])<<16 + int32(rateBytes[3])<<24
|
||||
|
||||
granuleIdx := bytes.LastIndex(of.bufLast, []byte(OggS)) +
|
||||
OggSLen + 2
|
||||
granuleBytes := of.bufLast[granuleIdx : granuleIdx+8]
|
||||
granule := int64(granuleBytes[0]) + int64(granuleBytes[1])<<8 +
|
||||
int64(granuleBytes[2])<<16 + int64(granuleBytes[3])<<24 +
|
||||
int64(granuleBytes[4])<<32 + int64(granuleBytes[5])<<40 +
|
||||
int64(granuleBytes[6])<<48 + int64(granuleBytes[7])<<56
|
||||
|
||||
return time.Duration(granule*1000/int64(rate)) * time.Millisecond
|
||||
}
|
@ -1,57 +0,0 @@
|
||||
package oggtag
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const sampleSong = "/mnt/data/appdata/radio/fallback.ogg"
|
||||
const sampleArtist = "breskina"
|
||||
const sampleTitle = "Песня про мечты"
|
||||
|
||||
func TestGetTag(t *testing.T) {
|
||||
oggf, err := NewOggFile(sampleSong)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tag := oggf.GetTag("artist")
|
||||
if tag != sampleArtist {
|
||||
t.Error(tag, "!=", sampleArtist)
|
||||
}
|
||||
tag = oggf.GetTag("title")
|
||||
if tag != sampleTitle {
|
||||
t.Error(tag, "!=", sampleTitle)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkGetTag(b *testing.B) {
|
||||
oggf, err := NewOggFile(sampleSong)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
oggf.GetTag("artist")
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkGetDuration(b *testing.B) {
|
||||
oggf, err := NewOggFile(sampleSong)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
oggf.GetDuration()
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDuration(t *testing.T) {
|
||||
oggf, err := NewOggFile(sampleSong)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dur := oggf.GetDuration()
|
||||
t.Log(dur, ((dur)/time.Second)*time.Second, dur.Milliseconds())
|
||||
}
|
@ -16,14 +16,7 @@ func MainSite(host string) string {
|
||||
return "http://[300:a98d:d6d0:8a08::f]"
|
||||
}
|
||||
|
||||
return "https://arav.su"
|
||||
}
|
||||
|
||||
func Site(host string) string {
|
||||
if strings.Contains(host, ".su") {
|
||||
return "https://radio.arav.su"
|
||||
}
|
||||
return "http://" + host
|
||||
return "https://arav.top"
|
||||
}
|
||||
|
||||
// ToClientTimezone converts given time to timezone set in a
|
||||
|
85
pkg/watcher/linux.go
Normal file
@ -0,0 +1,85 @@
|
||||
package watcher
|
||||
|
||||
import (
|
||||
"syscall"
|
||||
"unsafe"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
const (
|
||||
CrDelMask uint32 = syscall.IN_CREATE | syscall.IN_DELETE
|
||||
ModIgnMask uint32 = syscall.IN_MODIFY | syscall.IN_IGNORED
|
||||
)
|
||||
|
||||
const inotifyCount = 16
|
||||
|
||||
type InotifyWatcher struct {
|
||||
fd int
|
||||
wds []int
|
||||
closed bool
|
||||
}
|
||||
|
||||
func NewInotifyWatcher() (w *InotifyWatcher, err error) {
|
||||
w = &InotifyWatcher{closed: false}
|
||||
|
||||
w.fd, err = syscall.InotifyInit()
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to initialise inotify watcher")
|
||||
}
|
||||
|
||||
w.wds = make([]int, 0)
|
||||
|
||||
return w, nil
|
||||
}
|
||||
|
||||
func (w *InotifyWatcher) AddWatch(path string, mask uint32) error {
|
||||
wd, err := syscall.InotifyAddWatch(w.fd, path, mask)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "failed to set %s on watch", path)
|
||||
}
|
||||
|
||||
w.wds = append(w.wds, wd)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// WatchForMask checking for events from mask and returns inotify mask to channel.
|
||||
func (w *InotifyWatcher) WatchForMask(fired chan uint32, mask uint32) {
|
||||
go func() {
|
||||
for !w.closed {
|
||||
buffer := make([]byte, syscall.SizeofInotifyEvent*inotifyCount)
|
||||
n, err := syscall.Read(w.fd, buffer)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
|
||||
if n < syscall.SizeofInotifyEvent {
|
||||
continue
|
||||
}
|
||||
|
||||
for offset := 0; offset < len(buffer); offset += syscall.SizeofInotifyEvent {
|
||||
event := (*syscall.InotifyEvent)(unsafe.Pointer(&buffer[offset]))
|
||||
if event.Mask&mask > 0 {
|
||||
fired <- event.Mask
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (w *InotifyWatcher) Close() error {
|
||||
for _, wd := range w.wds {
|
||||
if _, err := syscall.InotifyRmWatch(w.fd, uint32(wd)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := syscall.Close(w.fd); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
w.closed = true
|
||||
|
||||
return nil
|
||||
}
|
@ -1,9 +0,0 @@
|
||||
#!/usr/bin/env sh
|
||||
|
||||
outp="$(curl -XGET --unix-socket /var/run/dwelling-radio/sock http://localhost/api/playlist -s -w '%{response_code}')"
|
||||
|
||||
if [ "${outp: -3}" != "200" ]; then
|
||||
exit 1;
|
||||
fi
|
||||
|
||||
echo ${outp%????}
|
@ -1,66 +0,0 @@
|
||||
#!/usr/bin/sh
|
||||
|
||||
radio_dir=/mnt/data/appdata/radio
|
||||
|
||||
case $1 in
|
||||
f | filelist)
|
||||
tree -H '' -T "List – Arav's dwelling / Radio" \
|
||||
-P '*.ogg' --charset=utf-8 --prune --du -hnl \
|
||||
--nolinks -o $radio_dir/filelist.html $radio_dir/music
|
||||
break
|
||||
;;
|
||||
p | playlist)
|
||||
find -L $radio_dir/music/* -type f -iname '*.ogg' |
|
||||
cut -c 31- | sort -d > $radio_dir/playlists/all
|
||||
break
|
||||
;;
|
||||
ep | ez-playlist)
|
||||
find -L $radio_dir/music/* -type f -iname '*.ogg' |
|
||||
sort -d > $radio_dir/playlists/all
|
||||
break
|
||||
;;
|
||||
s | shuffle)
|
||||
shuf $radio_dir/playlists/all > $radio_dir/playlists/all-rand
|
||||
break
|
||||
;;
|
||||
c | convert)
|
||||
for file in "$2"/*; do
|
||||
if [[ "$file" == *.ogg ]]; then
|
||||
continue;
|
||||
fi
|
||||
if [ -f "${file%.*}.ogg" ]; then
|
||||
continue;
|
||||
fi
|
||||
ffmpeg -hide_banner -i "$file" -y -vn -c:a libvorbis -b:a 128k "${file%.*}.ogg";
|
||||
if [ $? -eq 0 ] && [ $3 = "del" ]; then
|
||||
rm "$file";
|
||||
fi
|
||||
done
|
||||
break
|
||||
;;
|
||||
d | duration)
|
||||
find $radio_dir/music -iname '*.ogg' -exec ffprobe -i "{}" \
|
||||
-show_entries format=duration -v quiet -of csv="p=0" \; |
|
||||
paste -s -d+ - | bc
|
||||
break
|
||||
;;
|
||||
er | ez-reload)
|
||||
pkill -HUP ezstream
|
||||
break
|
||||
;;
|
||||
dr | dw-reload)
|
||||
pkill -HUP dwelling-radio
|
||||
break
|
||||
;;
|
||||
*)
|
||||
echo "f|ilelist - to generate a filelist.html"
|
||||
echo "p|laylist - to generate a playlist 'all'"
|
||||
echo "ep|ez-playlist- - to generate a playlist 'all' with full paths"
|
||||
echo "s|huffle - to shuffle a playlist and store as all-rand"
|
||||
echo "c|onvert DIR - convert all files in DIR to ogg"
|
||||
echo "d|uration - get total songs' duration"
|
||||
echo "er|ez-reload - send SIGHUP to ezstream to reload a playlist"
|
||||
echo "dr|dw-reload - send SIGHUP to dwelling-radio to reload a playlist"
|
||||
exit
|
||||
;;
|
||||
esac
|
@ -11,7 +11,6 @@
|
||||
--secondary-color: #9f2b68;
|
||||
--text-color: #f5f5f5;
|
||||
--text-indent: 1.6rem;
|
||||
color-scheme: light dark;
|
||||
scrollbar-color: var(--primary-color) var(--background-color); }
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
@ -27,16 +26,13 @@
|
||||
background-color: var(--secondary-color);
|
||||
color: var(--background-color); }
|
||||
|
||||
.small { font-size: .8rem; }
|
||||
|
||||
.small.player-links a { margin: 0 .2rem; }
|
||||
|
||||
a,
|
||||
button {
|
||||
color: var(--primary-color);
|
||||
text-decoration: none; }
|
||||
|
||||
a:hover {
|
||||
a:hover,
|
||||
button:hover {
|
||||
color: var(--secondary-color);
|
||||
cursor: pointer;
|
||||
text-decoration: underline dotted;
|
||||
@ -44,7 +40,9 @@ a:hover {
|
||||
|
||||
button {
|
||||
background: none;
|
||||
border: none; }
|
||||
border: none;
|
||||
font: inherit;
|
||||
padding: 0; }
|
||||
|
||||
p {
|
||||
text-align: justify;
|
||||
@ -64,10 +62,24 @@ h2 {
|
||||
font-size: 1.4rem;
|
||||
margin: 1rem 0; }
|
||||
|
||||
small { font-size: .8rem; }
|
||||
|
||||
small.player-links a { margin: 0 .2rem; }
|
||||
|
||||
audio {
|
||||
background-color: var(--primary-color);
|
||||
box-shadow: 5px 5px var(--primary-color);
|
||||
width: 100%; }
|
||||
|
||||
@media screen and (-webkit-min-device-pixel-ratio:0) {
|
||||
audio::-webkit-media-controls-panel {
|
||||
background-color: var(--secondary-color); }
|
||||
|
||||
audio { border-radius: 1.6rem; } }
|
||||
|
||||
@-moz-document url-prefix() {
|
||||
audio { border-radius: 0; } }
|
||||
|
||||
html { margin-left: calc(100vw - 100%); }
|
||||
|
||||
body {
|
||||
@ -84,73 +96,44 @@ header {
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between; }
|
||||
|
||||
header svg text { fill: var(--text-color); }
|
||||
#logo {
|
||||
display: block;
|
||||
width: 360px; }
|
||||
|
||||
header svg text:first-child {
|
||||
font-size: 3.55rem;
|
||||
#logo text { fill: var(--text-color); }
|
||||
|
||||
#logo .logo {
|
||||
font-size: 2rem;
|
||||
font-variant-caps: small-caps;
|
||||
font-weight: bold; }
|
||||
|
||||
header svg text:last-child { font-size: 1.5rem; }
|
||||
@media screen and (-webkit-min-device-pixel-ratio:0) {
|
||||
#logo .logo { font-size: 2.082rem; } }
|
||||
|
||||
@supports (-moz-appearance:none) {
|
||||
header svg text:last-child { transform: scale(.993, 1); } }
|
||||
@-moz-document url-prefix() {
|
||||
#logo .logo { font-size: 2rem; } }
|
||||
|
||||
header nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-variant: small-caps;
|
||||
justify-content: space-evenly; }
|
||||
#logo .under { font-size: .88rem; }
|
||||
|
||||
header nav h1 {
|
||||
nav { margin-top: .5rem; }
|
||||
|
||||
nav a { font-variant: small-caps; }
|
||||
|
||||
nav h1 {
|
||||
color: var(--secondary-color);
|
||||
margin: 0; }
|
||||
|
||||
section { margin-top: 1rem; }
|
||||
|
||||
#banner { text-align: center; }
|
||||
|
||||
#player {
|
||||
flex-direction: row;
|
||||
align-items: center; }
|
||||
|
||||
#player p { text-indent: 1rem; }
|
||||
|
||||
#player img,
|
||||
button#radio-play {
|
||||
filter: drop-shadow(0px 0px 4px var(--text-color));
|
||||
height: 1rem;
|
||||
padding: 0 .7rem; }
|
||||
|
||||
button#radio-play {
|
||||
background-image: url(/assets/img/play.svg);
|
||||
height: 3rem;
|
||||
min-width: 3rem;
|
||||
width: 3rem; }
|
||||
|
||||
input#radio-volume {
|
||||
accent-color: var(--primary-color);
|
||||
direction: rtl;
|
||||
height: 4rem;
|
||||
margin-left: .5rem;
|
||||
writing-mode: vertical-lr; }
|
||||
|
||||
#player div:first-child {
|
||||
display: none;
|
||||
flex-direction: row;
|
||||
align-items: center; }
|
||||
|
||||
#last-songs {
|
||||
#last-played {
|
||||
margin: 0 auto;
|
||||
min-width: 80%;
|
||||
width: 80%; }
|
||||
|
||||
#last-songs :is(thead tr, tbody tr) {
|
||||
#last-played tbody tr {
|
||||
display: grid;
|
||||
gap: .5rem;
|
||||
grid-template-columns: 3rem 3rem 1fr; }
|
||||
|
||||
#last-songs thead tr { font-weight: bold; }
|
||||
grid-template-columns: 3rem 2rem 1fr; }
|
||||
|
||||
footer {
|
||||
font-size: .8rem;
|
||||
@ -158,10 +141,12 @@ footer {
|
||||
padding: 1rem 0; }
|
||||
|
||||
@media screen and (max-width: 640px) {
|
||||
header {
|
||||
align-items: center;
|
||||
flex-direction: column; }
|
||||
header { display: block; }
|
||||
|
||||
header svg { width: 100%; }
|
||||
#logo {
|
||||
margin: 0 auto;
|
||||
width: 100%; }
|
||||
|
||||
#player { flex-direction: column; } }
|
||||
nav {
|
||||
width: 100%;
|
||||
text-align: center; } }
|
@ -1 +0,0 @@
|
||||
<svg version="1.1" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg"><circle cx="256" cy="272" r="221.19" fill="#9f2b68" stroke="#000" stroke-width="37.61"/><circle cx="259.01" cy="253.47" r="1.4062"/><path d="m332.82 405.5-72.697-150.72-84.181-65.471" fill="none" stroke="#000" stroke-width="30"/><circle cx="258.25" cy="47.49" r="1.4062"/><g fill="none" stroke="#000" stroke-width="30"><path d="m259.16 95.203-.17231-52.493"/><path d="m255.26 500.7-.17231-52.493"/><path d="m26.75 272.09 52.493-.17232"/><path d="m432.21 272.09 52.493-.17232"/></g></svg>
|
Before Width: | Height: | Size: 564 B |
@ -1 +1,2 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg version="1.1" viewBox="0 0 574.17 258.67" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><g fill="#cd2682" aria-label="A'sD"><path d="m131.67 206.33h-66.833l-13 49.667h-51.833l75.833-242.67h44.833l76.333 242.67h-52.333zm-56.167-40.833h45.333l-22.667-86.5z" style="font-variant-caps:small-caps"/><path d="m226 24.667-4.8333 67.5h-30.667v-92.167h35.5z" style="font-variant-caps:small-caps"/><path d="m338.5 203.83q0-9.5-5.6667-15.333-5.5-5.8333-20.833-10.667-34.167-9.3333-47.333-22.833-13.167-13.667-13.167-38.5 0-25 17.667-41.167 17.667-16.167 45.833-16.167 31.5 0 50.167 16.5 18.833 16.333 18.833 44.167h-47q0-11-6-17.833-5.8333-6.8333-15.833-6.8333-8.8333 0-13.833 5.5-5 5.5-5 14 0 8 5.5 13.167 5.6667 5.1667 20.167 10.333 32.667 8.3333 47 23.167t14.333 41-17.333 41.333q-17.333 15-48 15-14.667 0-28.5-4.3333-13.667-4.3333-23.5-13-20-17.333-20-47.333h47.333q0 16.167 6 22.667 6 6.3333 21.167 6.3333 18 0 18-19.167z" style="font-variant-caps:small-caps"/><path d="m416.5 256v-242.67h64.167q42.5 0 67.667 27 25.333 27 25.833 74v39.333q0 47.833-25.333 75.167-25.167 27.167-69.5 27.167zm49-201.83v161.17h14.667q24.5 0 34.5-12.833 10-13 10.5-44.667v-42.167q0-34-9.5-47.333-9.5-13.5-32.333-14.167z" style="font-variant-caps:small-caps"/></g></svg>
|
||||
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.3 KiB |
@ -1 +0,0 @@
|
||||
<svg version="1.1" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg"><g transform="matrix(1.1429 0 0 1.1429 -36.128 15.982)" stroke="#000" stroke-width="40" style="paint-order:normal"><path transform="scale(-1)" d="m-60.17-296.45a200.55 200.55 0 01-83.602 209.44 200.55 200.55 0 01-225.51-.71844 200.55 200.55 0 01-82.266-209.97" fill="none" style="paint-order:normal"/><path d="m459.59 316.81a44.801 97.191 0 01-44.801 97.191 44.801 97.191 0 01-44.801-97.19 44.801 97.191 0 0144.8-97.192 44.801 97.191 0 0144.802 97.189" fill="#9f2b68" style="paint-order:normal"/><path d="m51.611 316.81a44.801 97.191 0 0144.801-97.19 44.801 97.191 0 0144.801 97.191 44.801 97.191 0 01-44.801 97.19 44.801 97.191 0 01-44.801-97.191" fill="#9f2b68" style="paint-order:normal"/></g></svg>
|
Before Width: | Height: | Size: 779 B |
@ -1 +0,0 @@
|
||||
<svg version="1.1" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg"><path transform="matrix(.9978 .066276 -.61683 .78709 0 0)" d="m592.2 522.06c0 19.539-8.204 38.278-22.808 52.095-14.605 13.816-34.412 21.578-55.065 21.578s-40.46-7.762-55.064-21.578c-14.604-13.817-22.809-32.556-22.809-52.095 0-19.54 8.205-38.279 22.809-52.095 14.604-13.817 34.411-21.579 55.064-21.579 43.008 0 77.873 32.985 77.873 73.674z" fill="#9f2b68" stroke="#000" stroke-width="22.245"/><path d="m289.88 415.68c0 .503-.2.985-.555 1.341-.356.355-.838.555-1.341.555-1.046 0-1.895-.849-1.895-1.896 0-1.046.849-1.895 1.895-1.895.503 0 .985.2 1.341.555.355.356.555.838.555 1.34z" stroke-width="1.2638"/><path d="m281.19 408.95c0 1.047-.849 1.895-1.896 1.895s-1.896-.848-1.896-1.895c0-.503.2-.985.556-1.341.355-.355.837-.555 1.34-.555s.985.2 1.34.555c.356.356.556.838.556 1.341z" stroke-width="1.2638"/><path d="m280.73 32.078v384.56" fill="none" stroke="#000" stroke-width="27"/><path d="m293.03 52.222c77.939.0697 9.165 140.67 121.96 195.68" fill="none" stroke="#000" stroke-width="40.44"/></svg>
|
Before Width: | Height: | Size: 1.0 KiB |
@ -1 +0,0 @@
|
||||
<svg version="1.1" viewBox="0 0 17 17" xmlns="http://www.w3.org/2000/svg"><path d="m1.1317 1.8418v6.6254l-.0005522 6.6248 6.6353-3.3119 6.6348-3.3113-6.6348-3.3135z" fill="#9f2b68" stroke="#000" stroke-width="2"/></svg>
|
Before Width: | Height: | Size: 220 B |
@ -1 +0,0 @@
|
||||
<svg version="1.1" viewBox="0 0 17 17" xmlns="http://www.w3.org/2000/svg"><rect x="1" y="1" width="14" height="14" fill="#9f2b68" stroke="#000" stroke-width="3"/></svg>
|
Before Width: | Height: | Size: 169 B |
@ -1,92 +1,42 @@
|
||||
const $ = id => document.getElementById(id);
|
||||
function $(id) { return document.getElementById(id); }
|
||||
|
||||
const formatDuration = date => `${date.getUTCHours() > 0 ? date.getUTCHours() + ":" : ""}${date.getUTCMinutes()}:${date.getUTCSeconds().toString().padStart(2, "0")}`;
|
||||
const formatStartAt = date => `${date.getHours().toString().padStart(2, "0")}:${date.getMinutes().toString().padStart(2, "0")}`;
|
||||
|
||||
let cursong_startat = null;
|
||||
let cursong_duration_msec = 0;
|
||||
|
||||
async function updateStatus() {
|
||||
const resp = await fetch("/api/status");
|
||||
|
||||
if (!resp.ok || 200 != resp.status) {
|
||||
$("radio-song").textContent =
|
||||
$("radio-duration-estimate").textContent =
|
||||
$("radio-duration").textContent = "";
|
||||
$("radio-listeners").textContent = "0";
|
||||
$("last-songs").lastChild.remove();
|
||||
return [-1, null];
|
||||
}
|
||||
|
||||
const s = await resp.json();
|
||||
|
||||
if (undefined != s.last_songs) {
|
||||
$("last-songs").lastChild.remove();
|
||||
$("last-songs").appendChild(document.createElement("tbody"));
|
||||
for (let i = 0; i < s.last_songs.length; ++i) {
|
||||
let row = $("last-songs").lastChild.insertRow();
|
||||
row.insertCell().appendChild(document.createTextNode(formatStartAt(new Date(s.last_songs[i].start_at))));
|
||||
row.insertCell().appendChild(document.createTextNode((s.last_songs[i].listeners == undefined ? "" : s.last_songs[i].listeners + "/") + (s.last_songs[i].peak_listeners == undefined ? "" : s.last_songs[i].peak_listeners)));
|
||||
row.insertCell().appendChild(document.createTextNode(`${s.last_songs[i].artist} - ${s.last_songs[i].title}`));
|
||||
}
|
||||
}
|
||||
|
||||
if (undefined == s.current_song || undefined == s.current_song.duration_msec)
|
||||
return [-1, null];
|
||||
|
||||
$("radio-song").textContent = `${s.current_song.artist} - ${s.current_song.title}`;
|
||||
$("radio-listeners").textContent = s.listeners;
|
||||
$("radio-duration").textContent = formatDuration(new Date(s.current_song.duration_msec));
|
||||
|
||||
return [s.current_song.duration_msec, new Date(s.current_song.start_at)];
|
||||
function updateRadioStatus() {
|
||||
fetch("/status")
|
||||
.then(r => r.json())
|
||||
.then(r => {
|
||||
$("radio-status").innerHTML =
|
||||
`On-air since <time datetime="${r.server_start_iso8601}">${r.server_start_date}</time>`;
|
||||
$("radio-song").textContent = r.song;
|
||||
$("radio-listeners").textContent = r.listeners;
|
||||
$("radio-listener-peak").textContent = r.listener_peak;
|
||||
}).catch(() => {
|
||||
$("radio-status").textContent = "Radio is offline.";
|
||||
$("radio-song").textContent = "";
|
||||
$("radio-listeners").textContent =
|
||||
$("radio-listener-peak").textContent = "0";
|
||||
});
|
||||
}
|
||||
|
||||
async function update() {
|
||||
if (null === cursong_startat)
|
||||
return -1;
|
||||
function updateLastPlayedSong() {
|
||||
fetch('/lastsong')
|
||||
.then(r => r.json())
|
||||
.then(last_played => {
|
||||
if (last_played.time == $('last-played').firstChild.lastChild.firstChild.innerText)
|
||||
return;
|
||||
|
||||
const estimate = (new Date()) - (new Date(cursong_startat));
|
||||
if (estimate >= cursong_duration_msec) {
|
||||
return 1;
|
||||
}
|
||||
$('last-played').firstChild.firstChild.remove();
|
||||
|
||||
$("radio-duration-estimate").textContent = `${formatDuration(new Date(estimate))} / `;
|
||||
return 0;
|
||||
let row = $('last-played').insertRow();
|
||||
row.insertCell().appendChild(document.createTextNode(last_played.time));
|
||||
row.insertCell().appendChild(document.createTextNode(last_played.listeners == 0 ? "" : last_played.listeners));
|
||||
row.insertCell().appendChild(document.createTextNode(last_played.song));
|
||||
});
|
||||
}
|
||||
|
||||
let update_interval_id = null;
|
||||
async function interval() {
|
||||
switch (await update()) {
|
||||
case 1:
|
||||
[cursong_duration_msec, cursong_startat] = await updateStatus();
|
||||
break;
|
||||
case -1:
|
||||
clearInterval(update_interval_id);
|
||||
await new Promise(resolve => setTimeout(resolve, 5000));
|
||||
[cursong_duration_msec, cursong_startat] = await updateStatus();
|
||||
update_interval_id = setInterval(interval, 1000);
|
||||
}
|
||||
}
|
||||
document.getElementById("btn-update").addEventListener("click", () => {
|
||||
updateLastPlayedSong();
|
||||
updateRadioStatus();
|
||||
})
|
||||
|
||||
updateStatus().then(r => [cursong_duration_msec, cursong_startat] = r);
|
||||
update_interval_id = setInterval(interval, 1000);
|
||||
|
||||
|
||||
|
||||
const audio = document.getElementsByTagName("audio")[0];
|
||||
audio.hidden = true;
|
||||
const audio_src = audio.childNodes[0].src;
|
||||
|
||||
const volume = $("radio-volume");
|
||||
volume.value = +(localStorage.getItem("volume") || 50);
|
||||
audio.volume = volume.value / 100.0;
|
||||
volume.addEventListener("input", e => {
|
||||
audio.volume = e.target.value / 100.0;
|
||||
localStorage.setItem("volume", e.target.value); });
|
||||
|
||||
$("player").style.display = $("player").firstChild.style.display = "flex";
|
||||
|
||||
$("radio-play").addEventListener("click", e => {
|
||||
audio.paused ? (audio.src = audio_src) && audio.play() : audio.src = "";
|
||||
e.target.style.backgroundImage = audio.paused ?
|
||||
"url(/assets/img/play.svg)" : "url(/assets/img/stop.svg)"; });
|
||||
setInterval(updateRadioStatus, 45000);
|
||||
setInterval(updateLastPlayedSong, 45000);
|
@ -1,8 +1,8 @@
|
||||
#EXTM3U
|
||||
#EXTINF:-1,Arav's dwelling / Radio
|
||||
http://radio.arav.su:8000/stream.ogg
|
||||
http://radio.arav.top:8000/stream.ogg
|
||||
#EXTINF:-1,Arav's dwelling / Radio (HTTPS)
|
||||
https://radio.arav.su/live/stream.ogg
|
||||
https://radio.arav.top/live/stream.ogg
|
||||
#EXTINF:-1,Arav's dwelling / Radio on Tor
|
||||
http://wsmkgnmhmzqm7kyzv7jnzzafvgm7xlmlfvzhgorpapd5or2arnhuktqd.onion/live/stream.ogg
|
||||
#EXTINF:-1,Arav's dwelling / Radio on I2P
|
@ -1,15 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
<url>
|
||||
<loc>https://radio.arav.su/</loc>
|
||||
<lastmod>2024-03-06</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://radio.arav.su/filelist</loc>
|
||||
<changefreq>always</changefreq>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://radio.arav.su/playlist</loc>
|
||||
<lastmod>2023-02-23</lastmod>
|
||||
</url>
|
||||
</urlset>
|
129
web/index.templ
@ -1,129 +0,0 @@
|
||||
package web
|
||||
|
||||
import "net/http"
|
||||
import "strconv"
|
||||
|
||||
import "github.com/invopop/ctxi18n/i18n"
|
||||
|
||||
import "dwelling-radio/internal/radio"
|
||||
import "dwelling-radio/pkg/utils"
|
||||
|
||||
templ Index(prgVer string, curSong *radio.Song, sl []radio.Song, slLen int64, lstnrs *radio.ListenerCounter, r *http.Request) {
|
||||
<!DOCTYPE html>
|
||||
<html lang={ i18n.GetLocale(ctx).Code().String() }>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#cd2682" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
|
||||
<title>Arav's dwelling / { i18n.T(ctx, "title") }</title>
|
||||
|
||||
<meta name="author" content={ "Alexander \"Arav\" Andreev" } />
|
||||
<meta name="description" content={ i18n.T(ctx, "description") } />
|
||||
<meta name="keywords" content={ i18n.T(ctx, "keywords") } />
|
||||
|
||||
<link rel="canonical" href={ utils.Site(r.Host) } />
|
||||
|
||||
<link rel="icon" href="/assets/img/favicon.svg" sizes="any" type="image/svg+xml" />
|
||||
<link rel="stylesheet" href="/assets/css/main.css" />
|
||||
|
||||
<script src="/assets/js/main.js" defer />
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<svg width="360" viewBox="0 -36 360 66">
|
||||
<text y="7" textLength="360" lengthAdjust="spacingAndGlyphs">Arav's dwelling</text>
|
||||
<text y="25" textLength="360" lengthAdjust="spacingAndGlyphs">Welcome to my sacred place, wanderer</text>
|
||||
</svg>
|
||||
<nav>
|
||||
<a href={ templ.SafeURL(utils.MainSite(r.Host)) }>{ i18n.T(ctx, "back-home") }</a>
|
||||
<h1>{ i18n.T(ctx, "title") }</h1>
|
||||
</nav>
|
||||
</header>
|
||||
<section id="banner">
|
||||
<video playsinline autoplay loop muted>
|
||||
<source src="/assets/img/stopit.mp4" type="video/mp4" />
|
||||
</video>
|
||||
</section>
|
||||
<section>
|
||||
<div class="small player-links">
|
||||
<a href="/filelist">{ i18n.T(ctx, "link.filelist") }</a>
|
||||
<a href="/playlist">{ i18n.T(ctx, "link.playlist") }</a>
|
||||
<a href="/live/stream.ogg">{ i18n.T(ctx, "link.direct-link") }</a>
|
||||
(<a href="http://radio.arav.su:8000/stream.ogg">http</a>
|
||||
<a href="http://wsmkgnmhmzqm7kyzv7jnzzafvgm7xlmlfvzhgorpapd5or2arnhuktqd.onion/live/stream.ogg">Tor</a>
|
||||
<a href="http://radio.arav.i2p/live/stream.ogg">I2P</a>
|
||||
<a href="http://[300:a98d:d6d0:8a08::e]/live/stream.ogg">Ygg</a>)
|
||||
<a href="https://dir.xiph.org/search?q=arav's+dwelling">Xiph</a>
|
||||
| OGG 128 Kb/s
|
||||
</div>
|
||||
<div id="player">
|
||||
<div>
|
||||
<button id="radio-play" />
|
||||
<input id="radio-volume" type="range" min="0" max="100" orient="vertical" />
|
||||
</div>
|
||||
<audio preload="none" controls playsinline>
|
||||
<source src="/live/stream.ogg" type="audio/ogg" />
|
||||
{ i18n.T(ctx, "no-audio-tag") } <a href="/playlist">{ i18n.T(ctx, "link.playlist") }</a>.
|
||||
</audio>
|
||||
<div>
|
||||
<p>
|
||||
<img src="/assets/img/headphones.svg" alt="Listeners" title="Listeners" />
|
||||
<span id="radio-listeners">{ strconv.FormatInt(lstnrs.Current(), 10) }</span>
|
||||
<img src="/assets/img/duration.svg" alt="Duration" title="Duration" />
|
||||
<span id="radio-duration-estimate"></span>
|
||||
<span id="radio-duration">
|
||||
if curSong != nil && curSong.Artist != "" {
|
||||
{ curSong.DurationString() }
|
||||
} else {
|
||||
0:00
|
||||
}
|
||||
</span>
|
||||
</p>
|
||||
<p>
|
||||
<img src="/assets/img/note.svg" alt="Song" title="Song" />
|
||||
<span id="radio-song">
|
||||
if curSong != nil && curSong.Artist != "" {
|
||||
{ curSong.Artist } - { curSong.Title }
|
||||
}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section>
|
||||
<h2>{ i18n.T(ctx, "last-songs.h", i18n.M{"n": strconv.FormatInt(slLen, 10)}) }</h2>
|
||||
<table id="last-songs">
|
||||
<thead class="small">
|
||||
<tr>
|
||||
<td>{ i18n.T(ctx, "last-songs.tab-start") }</td>
|
||||
<td><abbr title={ i18n.T(ctx, "last-songs.tab-stat-tip") }>{ i18n.T(ctx, "last-songs.tab-stat") }</abbr></td>
|
||||
<td>{ i18n.T(ctx, "last-songs.tab-song") }</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
for _, song := range sl {
|
||||
<tr>
|
||||
<td>{ utils.ToClientTimezone(song.StartAt, r).Format("15:04") }</td>
|
||||
<td>
|
||||
if song.PeakListeners != 0 {
|
||||
{ strconv.FormatInt(song.Listeners, 10) }/{ strconv.FormatInt(song.PeakListeners, 10) }
|
||||
}
|
||||
</td>
|
||||
<td>{ song.Artist } - { song.Title }</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
<footer>
|
||||
<a href="?lang=ru">рус</a>
|
||||
<a href="?lang=en">eng</a>
|
||||
<br/>
|
||||
v{ prgVer } 2017—2024 { i18n.T(ctx, "footer.author") } <<a href="mailto:me@arav.su">me@arav.su</a>> <a href={ templ.SafeURL(utils.MainSite(r.Host) + "/privacy") }>{ i18n.T(ctx, "footer.privacy") }</a>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
}
|
141
web/jade.go
Normal file
@ -0,0 +1,141 @@
|
||||
// Code generated by "jade.go"; DO NOT EDIT.
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
var (
|
||||
escaped = []byte{'<', '>', '"', '\'', '&'}
|
||||
replacing = []string{"<", ">", """, "'", "&"}
|
||||
)
|
||||
|
||||
func WriteEscString(st string, buffer *WriterAsBuffer) {
|
||||
for i := 0; i < len(st); i++ {
|
||||
if n := bytes.IndexByte(escaped, st[i]); n >= 0 {
|
||||
buffer.WriteString(replacing[n])
|
||||
} else {
|
||||
buffer.WriteByte(st[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type WriterAsBuffer struct {
|
||||
io.Writer
|
||||
}
|
||||
|
||||
func (w *WriterAsBuffer) WriteString(s string) (n int, err error) {
|
||||
n, err = w.Write([]byte(s))
|
||||
return
|
||||
}
|
||||
|
||||
func (w *WriterAsBuffer) WriteByte(b byte) (err error) {
|
||||
_, err = w.Write([]byte{b})
|
||||
return
|
||||
}
|
||||
|
||||
type stringer interface {
|
||||
String() string
|
||||
}
|
||||
|
||||
func WriteAll(a interface{}, escape bool, buffer *WriterAsBuffer) {
|
||||
switch v := a.(type) {
|
||||
case string:
|
||||
if escape {
|
||||
WriteEscString(v, buffer)
|
||||
} else {
|
||||
buffer.WriteString(v)
|
||||
}
|
||||
case int:
|
||||
WriteInt(int64(v), buffer)
|
||||
case int8:
|
||||
WriteInt(int64(v), buffer)
|
||||
case int16:
|
||||
WriteInt(int64(v), buffer)
|
||||
case int32:
|
||||
WriteInt(int64(v), buffer)
|
||||
case int64:
|
||||
WriteInt(v, buffer)
|
||||
case uint:
|
||||
WriteUint(uint64(v), buffer)
|
||||
case uint8:
|
||||
WriteUint(uint64(v), buffer)
|
||||
case uint16:
|
||||
WriteUint(uint64(v), buffer)
|
||||
case uint32:
|
||||
WriteUint(uint64(v), buffer)
|
||||
case uint64:
|
||||
WriteUint(v, buffer)
|
||||
case float32:
|
||||
buffer.WriteString(strconv.FormatFloat(float64(v), 'f', -1, 64))
|
||||
case float64:
|
||||
buffer.WriteString(strconv.FormatFloat(v, 'f', -1, 64))
|
||||
case bool:
|
||||
WriteBool(v, buffer)
|
||||
case stringer:
|
||||
if escape {
|
||||
WriteEscString(v.String(), buffer)
|
||||
} else {
|
||||
buffer.WriteString(v.String())
|
||||
}
|
||||
default:
|
||||
buffer.WriteString("\n<<< unprinted type, fmt.Stringer implementation needed >>>\n")
|
||||
}
|
||||
}
|
||||
|
||||
func ternary(condition bool, iftrue, iffalse interface{}) interface{} {
|
||||
if condition {
|
||||
return iftrue
|
||||
} else {
|
||||
return iffalse
|
||||
}
|
||||
}
|
||||
|
||||
// Used part of go source:
|
||||
// https://github.com/golang/go/blob/master/src/strconv/itoa.go
|
||||
func WriteUint(u uint64, buffer *WriterAsBuffer) {
|
||||
var a [64 + 1]byte
|
||||
i := len(a)
|
||||
|
||||
if ^uintptr(0)>>32 == 0 {
|
||||
for u > uint64(^uintptr(0)) {
|
||||
q := u / 1e9
|
||||
us := uintptr(u - q*1e9)
|
||||
for j := 9; j > 0; j-- {
|
||||
i--
|
||||
qs := us / 10
|
||||
a[i] = byte(us - qs*10 + '0')
|
||||
us = qs
|
||||
}
|
||||
u = q
|
||||
}
|
||||
}
|
||||
|
||||
us := uintptr(u)
|
||||
for us >= 10 {
|
||||
i--
|
||||
q := us / 10
|
||||
a[i] = byte(us - q*10 + '0')
|
||||
us = q
|
||||
}
|
||||
|
||||
i--
|
||||
a[i] = byte(us + '0')
|
||||
buffer.Write(a[i:])
|
||||
}
|
||||
func WriteInt(i int64, buffer *WriterAsBuffer) {
|
||||
if i < 0 {
|
||||
buffer.WriteByte('-')
|
||||
i = -i
|
||||
}
|
||||
WriteUint(uint64(i), buffer)
|
||||
}
|
||||
func WriteBool(b bool, buffer *WriterAsBuffer) {
|
||||
if b {
|
||||
buffer.WriteString("true")
|
||||
return
|
||||
}
|
||||
buffer.WriteString("false")
|
||||
}
|
@ -1,19 +0,0 @@
|
||||
en:
|
||||
title: Radio
|
||||
description: Internet-radio broadcasting from under my desk.
|
||||
keywords: self-host radio home-radio various music
|
||||
back-home: Back home
|
||||
link:
|
||||
filelist: filelist
|
||||
playlist: playlist
|
||||
direct-link: direct link
|
||||
no-audio-tag: Seems like your browser doesn't support an audio element, but you can grab the
|
||||
last-songs:
|
||||
h: Last %{n} songs
|
||||
tab-start: Start
|
||||
tab-stat: O/P
|
||||
tab-stat-tip: Overall/Peak listeners
|
||||
tab-song: Song
|
||||
footer:
|
||||
author: Alexander ❝Arav❞ Andreev
|
||||
privacy: Privacy statements
|
@ -1,7 +0,0 @@
|
||||
package locales
|
||||
|
||||
import "embed"
|
||||
|
||||
//go:embed en
|
||||
//go:embed ru
|
||||
var Content embed.FS
|
@ -1,19 +0,0 @@
|
||||
ru:
|
||||
title: Радио
|
||||
description: Интернет-радио вещающееся из-под моего стола.
|
||||
keywords: само-хост селф-хост радио разное музыка
|
||||
back-home: Назад домой
|
||||
link:
|
||||
filelist: список файлов
|
||||
playlist: плейлист
|
||||
direct-link: прямая ссылка
|
||||
no-audio-tag: Похоже на то, что твой браузер не поддерживает audio элемент, хреновенько, но можешь взять
|
||||
last-songs:
|
||||
h: Последние %{n} песен
|
||||
tab-start: Начало
|
||||
tab-stat: В/П
|
||||
tab-stat-tip: Всего/Пиковое кол-во слушателей
|
||||
tab-song: Песня
|
||||
footer:
|
||||
author: Александр «Arav» Андреев
|
||||
privacy: О приватности
|
63
web/templates/index.pug
Normal file
@ -0,0 +1,63 @@
|
||||
:go:func Index(mainSite string, songsNum int, status *radio.IcecastStatus, songs *[]radio.Song)
|
||||
|
||||
:go:import "dwelling-radio/internal/radio"
|
||||
|
||||
doctype html
|
||||
html(lang='en')
|
||||
head
|
||||
title Arav's dwelling / Radio
|
||||
meta(charset='utf-8')
|
||||
meta(http-equiv='X-UA-Compatible' content='IE=edge')
|
||||
meta(name='viewport' content='width=device-width, initial-scale=1.0')
|
||||
meta(name='theme-color' content='#cd2682')
|
||||
meta(name='description' content='Internet-radio broadcasting from under my desk.')
|
||||
link(rel='icon' href='/assets/img/favicon.svg' sizes='any' type='image/svg+xml')
|
||||
link(href='/assets/css/main.css' rel='stylesheet')
|
||||
script(src='/assets/js/main.js' defer='')
|
||||
body
|
||||
header
|
||||
svg#logo(viewBox='0 -25 216 40')
|
||||
text.logo Arav's dwelling
|
||||
text.under(y='11') Welcome to my sacred place, wanderer
|
||||
nav
|
||||
a(href=mainSite) Back to main website
|
||||
h1 Radio
|
||||
section
|
||||
small.player-links
|
||||
a(href='/filelist') filelist
|
||||
a(href='/playlist') playlist (.m3u)
|
||||
a(href='/live/stream.ogg') direct link
|
||||
a(href='http://radio.arav.top:8000/stream.ogg') direct link (http)
|
||||
a(href='http://wsmkgnmhmzqm7kyzv7jnzzafvgm7xlmlfvzhgorpapd5or2arnhuktqd.onion/live/stream.ogg') direct link (Tor)
|
||||
a(href='http://radio.arav.i2p/live/stream.ogg') direct link (I2P)
|
||||
a(href="https://dir.xiph.org/search?q=arav's+dwelling") Xiph
|
||||
| OGG 128 Kb/s
|
||||
audio(preload='none' controls='')
|
||||
source(src='/live/stream.ogg' type='audio/ogg')
|
||||
| Your browser doesn't support an audio element, it's sad... But you always can take the #[a(href='/playlist') playlist]!
|
||||
if status.ServerStartDate != ""
|
||||
p#radio-status On-air since
|
||||
time(datetime=status.ServerStartISO8601)= status.ServerStartDate
|
||||
else
|
||||
p#radio-status Radio is offline.
|
||||
p Now playing: #[span#radio-song #{status.SongName}]
|
||||
p Current/peak listeners: #[span#radio-listeners #{status.Listeners}] / #[span#radio-listener-peak #{status.ListenerPeak}]
|
||||
p
|
||||
small Notice: information updates every 45 seconds. But you can #[button(id='btn-update') update] it forcibly.
|
||||
if len(*songs) > 0
|
||||
section
|
||||
h2 Last #{songsNum} songs
|
||||
table#last-played
|
||||
each song in *songs
|
||||
tr
|
||||
td= song.Time
|
||||
if song.Listeners != "0"
|
||||
td= song.Listeners
|
||||
else
|
||||
td
|
||||
td= song.Song
|
||||
section
|
||||
h2 Privacy statements
|
||||
p Logs are collected and include access date and time, IP-address, User-Agent, referer URL, request. This website makes use of JavaScript to update a radio status and last 10 songs list.
|
||||
footer
|
||||
| 2017—2023 Arav <#[a(href='mailto:me@arav.top') me@arav.top]>
|
22
web/web.go
@ -6,6 +6,9 @@ import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// To install a Jade compiler: go install github.com/Joker/jade/cmd/jade@latest
|
||||
//go:generate $GOPATH/bin/jade -pkg=web -stdbuf -stdlib -writer templates/index.pug
|
||||
|
||||
//go:embed assets
|
||||
var assetsDir embed.FS
|
||||
|
||||
@ -14,21 +17,6 @@ func Assets() http.FileSystem {
|
||||
return http.FS(f)
|
||||
}
|
||||
|
||||
func ServeAsset(path, mime, attachement string) func(http.ResponseWriter, *http.Request) {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if mime != "" {
|
||||
w.Header().Add("Content-Type", mime)
|
||||
}
|
||||
|
||||
if attachement != "" {
|
||||
w.Header().Add("Content-Disposition", "attachment; filename=\""+attachement+"\"")
|
||||
}
|
||||
|
||||
data, err := assetsDir.ReadFile("assets/" + path)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
w.Write(data)
|
||||
}
|
||||
func AssetsGetFile(path string) ([]byte, error) {
|
||||
return assetsDir.ReadFile("assets/" + path)
|
||||
}
|
||||
|