Compare commits
No commits in common. "master" and "v24.10.0" have entirely different histories.
5
.gitignore
vendored
@ -1,6 +1,7 @@
|
|||||||
bin/*
|
bin/*
|
||||||
!bin/.keep
|
!bin/.keep
|
||||||
.vscode
|
.vscode
|
||||||
|
*.pug.go
|
||||||
*.log
|
*.log
|
||||||
*_templ.go
|
jade.go
|
||||||
/test
|
mls-test
|
||||||
|
40
Makefile
@ -1,37 +1,39 @@
|
|||||||
TARGET := dwelling-radio
|
TARGET:=dwelling-radio
|
||||||
|
|
||||||
SYSDDIR_ := ${shell pkg-config systemd --variable=systemdsystemunitdir}
|
SYSDDIR_:=${shell pkg-config systemd --variable=systemdsystemunitdir}
|
||||||
SYSDDIR := ${SYSDDIR_:/%=%}
|
SYSDDIR:=${SYSDDIR_:/%=%}
|
||||||
|
|
||||||
DESTDIR ?=
|
DESTDIR:=
|
||||||
PREFIX ?= /usr/local
|
PREFIX:=/usr/local
|
||||||
|
|
||||||
VERSION ?= 24.38.0
|
VERSION=24.10.0
|
||||||
|
|
||||||
GOFLAGS := -buildmode=pie -modcacherw -mod=readonly -trimpath
|
FLAGS:=-buildmode=pie -modcacherw -mod=readonly -trimpath
|
||||||
LDFLAGS := -ldflags "-linkmode=external -extldflags \"${LDFLAGS}\" -s -w -X main.version=${VERSION}" -tags osusergo,netgo
|
LDFLAGS:= -ldflags "-s -w -X main.version=${VERSION}" -tags osusergo,netgo
|
||||||
|
|
||||||
.PHONY: run install uninstall clean
|
.PHONY: run install uninstall clean
|
||||||
|
|
||||||
${TARGET}: web/*_templ.go
|
${TARGET}: web/*.pug.go
|
||||||
go build -o bin/$@ ${LDFLAGS} ${GOFLAGS} cmd/$@/main.go
|
go build -o bin/$@ ${LDFLAGS} ${FLAGS} cmd/$@/main.go
|
||||||
|
|
||||||
web/*_templ.go: web/*.templ
|
web/*.pug.go: web/templates/*.pug
|
||||||
ifeq (,$(wildcard $(shell go env GOPATH)/bin/templ))
|
ifeq (,$(wildcard $(shell go env GOPATH)/bin/jade))
|
||||||
go install github.com/a-h/templ/cmd/templ@latest
|
go install github.com/Joker/jade/cmd/jade@latest
|
||||||
endif
|
endif
|
||||||
$(shell go env GOPATH)/bin/templ generate
|
go generate web/web.go
|
||||||
|
|
||||||
run: | ${TARGET}
|
run:
|
||||||
bin/dwelling-radio -listen 127.0.0.1:18322 \
|
bin/dwelling-radio -listen 127.0.0.1:18322 -mls-file ./mls-test \
|
||||||
-work-dir test \
|
-playlist /mnt/data/appdata/radio/playlists/all-rand
|
||||||
-playlist test
|
-fallback-song /mnt/data/appdata/radio/fallback.ogg
|
||||||
|
|
||||||
install:
|
install:
|
||||||
install -Dm 0755 bin/${TARGET} ${DESTDIR}${PREFIX}/bin/${TARGET}
|
install -Dm 0755 bin/${TARGET} ${DESTDIR}${PREFIX}/bin/${TARGET}
|
||||||
install -Dm 0755 tools/radioctl ${DESTDIR}${PREFIX}/bin/${TARGET}ctl
|
install -Dm 0755 tools/radioctl ${DESTDIR}${PREFIX}/bin/${TARGET}ctl
|
||||||
|
|
||||||
install -Dm 0755 tools/radio-fetch ${DESTDIR}${PREFIX}/bin/${TARGET}-fetch
|
install -Dm 0755 tools/radio-fetch ${DESTDIR}${PREFIX}/bin/${TARGET}-fetch
|
||||||
|
# install -Dm 0755 tools/radio-listener-connect ${DESTDIR}${PREFIX}/bin/${TARGET}-listener-connect
|
||||||
|
# install -Dm 0755 tools/radio-listener-disconnect ${DESTDIR}${PREFIX}/bin/${TARGET}-listener-disconnect
|
||||||
|
|
||||||
# install -Dm 0644 configs/radio.liq ${DESTDIR}/etc/dwelling/radio.liq
|
# 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/radio.vars.liq ${DESTDIR}/etc/dwelling/radio.vars.liq
|
||||||
@ -48,6 +50,8 @@ uninstall:
|
|||||||
rm ${DESTDIR}${PREFIX}/bin/${TARGET}ctl
|
rm ${DESTDIR}${PREFIX}/bin/${TARGET}ctl
|
||||||
|
|
||||||
rm ${DESTDIR}${PREFIX}/bin/${TARGET}-fetch
|
rm ${DESTDIR}${PREFIX}/bin/${TARGET}-fetch
|
||||||
|
# rm ${DESTDIR}${PREFIX}/bin/${TARGET}-listener-connect
|
||||||
|
# rm ${DESTDIR}${PREFIX}/bin/${TARGET}-listener-disconnect
|
||||||
|
|
||||||
# rm ${DESTDIR}/etc/dwelling/radio.liq
|
# rm ${DESTDIR}/etc/dwelling/radio.liq
|
||||||
rm ${DESTDIR}/etc/dwelling/ezstream.xml
|
rm ${DESTDIR}/etc/dwelling/ezstream.xml
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
# Maintainer: Alexander "Arav" Andreev <me@arav.su>
|
# Maintainer: Alexander "Arav" Andreev <me@arav.su>
|
||||||
pkgname=dwelling-radio
|
pkgname=dwelling-radio
|
||||||
pkgver=24.38.0
|
pkgver=24.10.0
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Arav's dwelling / Radio"
|
pkgdesc="Arav's dwelling / Radio"
|
||||||
arch=('i686' 'x86_64' 'arm' 'armv6h' 'armv7h' 'aarch64')
|
arch=('i686' 'x86_64' 'arm' 'armv6h' 'armv7h' 'aarch64')
|
||||||
|
@ -3,28 +3,26 @@ package main
|
|||||||
import (
|
import (
|
||||||
ihttp "dwelling-radio/internal/http"
|
ihttp "dwelling-radio/internal/http"
|
||||||
"dwelling-radio/internal/radio"
|
"dwelling-radio/internal/radio"
|
||||||
sqlite_stats "dwelling-radio/internal/statistics/db/sqlite"
|
|
||||||
"dwelling-radio/pkg/utils"
|
|
||||||
"dwelling-radio/web"
|
"dwelling-radio/web"
|
||||||
"dwelling-radio/web/locales"
|
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"path"
|
|
||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
"git.arav.su/Arav/httpr"
|
"git.arav.su/Arav/httpr"
|
||||||
"github.com/invopop/ctxi18n"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
listenAddress = flag.String("listen", "/var/run/dwelling-radio/sock", "listen address (ip:port|unix_path)")
|
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")
|
filelistPath = flag.String("filelist", "/mnt/data/appdata/radio/filelist.html", "path to a filelist.html file")
|
||||||
playlistName = flag.String("playlist", "all-rand", "a playlist name")
|
playlist = flag.String("playlist", "", "path to a playlist")
|
||||||
songListLen = flag.Int64("list-length", 10, "number of songs to show in last N songs table")
|
fallbackSong = flag.String("fallback-song", "", "path to a fallback song")
|
||||||
|
mostListenedSongPath = flag.String("mls-file", "/mnt/data/appdata/radio/mostlistenedsong", "path to a file that stores info about the most listened song")
|
||||||
|
songListLen = flag.Int("list-length", 10, "number of songs to show in last N songs table")
|
||||||
|
|
||||||
showVersion = flag.Bool("v", false, "show version")
|
showVersion = flag.Bool("v", false, "show version")
|
||||||
)
|
)
|
||||||
@ -40,72 +38,57 @@ func main() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
stats, err := sqlite_stats.New(path.Join(*workDirPath, "statistics.db3"))
|
var mostListenedSong radio.MostListenedSong
|
||||||
if err != nil {
|
if data, err := os.ReadFile(*mostListenedSongPath); err == nil {
|
||||||
log.Fatalln("Statistics:", err)
|
if err := mostListenedSong.Load(data); err != nil {
|
||||||
|
log.Fatalln(err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
defer stats.Close()
|
|
||||||
|
|
||||||
currentSong := radio.Song{}
|
songList := radio.NewSongList(*songListLen)
|
||||||
lstnrs := radio.NewListenerCounter()
|
lstnrs := radio.NewListenerCounter()
|
||||||
|
plylst, err := radio.NewPlaylist(*playlist, true)
|
||||||
plylst, err := radio.NewPlaylist(path.Join(*workDirPath, "playlists", *playlistName), true)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalln(err)
|
log.Fatalln(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := ctxi18n.LoadWithDefault(locales.Content, "en"); err != nil {
|
hand := ihttp.NewHandlers(*filelistPath, songList, lstnrs, &mostListenedSong)
|
||||||
log.Fatalln(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
r := httpr.New()
|
r := httpr.New()
|
||||||
|
|
||||||
r.Handler(http.MethodGet, "/", func(w http.ResponseWriter, r *http.Request) {
|
r.Handler(http.MethodGet, "/", hand.Index)
|
||||||
lst, err := stats.LastNSongs(*songListLen)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Failed to fetch last N songs: %s\n", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
lstnrs.RLock()
|
r.Handler(http.MethodGet, "/playlist", hand.Playlist)
|
||||||
defer lstnrs.RUnlock()
|
r.Handler(http.MethodGet, "/filelist", hand.Filelist)
|
||||||
web.Index(version, ¤tSong, lst, *songListLen, lstnrs, r).Render(r.Context(), w)
|
|
||||||
})
|
|
||||||
|
|
||||||
r.Handler(http.MethodGet, "/filelist", func(w http.ResponseWriter, r *http.Request) {
|
r.Handler(http.MethodGet, "/robots.txt", ihttp.RobotsTxt)
|
||||||
data, err := os.ReadFile(path.Join(*workDirPath, "filelist.html"))
|
r.Handler(http.MethodGet, "/sitemap.xml", ihttp.SitemapXML)
|
||||||
if err != nil {
|
r.Handler(http.MethodGet, "/favicon.svg", ihttp.Favicon)
|
||||||
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())
|
r.ServeStatic("/assets/*filepath", web.Assets())
|
||||||
|
|
||||||
djh := ihttp.NewDJHandlers(lstnrs, plylst, stats, ¤tSong, *songListLen, path.Join(*workDirPath, "fallback.ogg"))
|
djh := ihttp.NewDJHandlers(lstnrs, plylst, songList, &mostListenedSong, *fallbackSong)
|
||||||
|
|
||||||
s := r.Sub("/api")
|
s := r.Sub("/api")
|
||||||
|
|
||||||
s.Handler(http.MethodPost, "/listener/icecast", djh.ListenersUpdateIcecast)
|
s.Handler(http.MethodPost, "/listener/icecast", djh.ListenersUpdate)
|
||||||
s.Handler(http.MethodGet, "/playlist", djh.PlaylistNext)
|
s.Handler(http.MethodGet, "/playlist", djh.PlaylistNext)
|
||||||
s.Handler(http.MethodGet, "/status", djh.Status)
|
s.Handler(http.MethodGet, "/status", djh.Status)
|
||||||
|
|
||||||
srv := ihttp.NewHttpServer(I18nMiddleware(r))
|
srv := ihttp.NewHttpServer(r)
|
||||||
|
|
||||||
if err := srv.Start(*listenAddress); err != nil {
|
if err := srv.Start(*listenAddress); err != nil {
|
||||||
log.Fatalln(err)
|
log.Fatalln(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
defer func() {
|
defer func() {
|
||||||
|
fileData := mostListenedSong.Store()
|
||||||
|
if fileData != nil {
|
||||||
|
err := os.WriteFile(*mostListenedSongPath, fileData, fs.ModePerm)
|
||||||
|
if err != nil {
|
||||||
|
log.Println("failed to store a most listened song:", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if err := srv.Stop(); err != nil {
|
if err := srv.Stop(); err != nil {
|
||||||
log.Fatalln(err)
|
log.Fatalln(err)
|
||||||
}
|
}
|
||||||
@ -117,43 +100,11 @@ func main() {
|
|||||||
for {
|
for {
|
||||||
switch <-sysSignal {
|
switch <-sysSignal {
|
||||||
case os.Interrupt, syscall.SIGINT, syscall.SIGTERM, syscall.SIGABRT, syscall.SIGSEGV:
|
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
case syscall.SIGHUP:
|
case syscall.SIGHUP:
|
||||||
plylst.Lock()
|
|
||||||
defer plylst.Unlock()
|
|
||||||
if err := plylst.Reload(); err != nil {
|
if err := plylst.Reload(); err != nil {
|
||||||
log.Println(err)
|
log.Println(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))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
@ -1,10 +1,8 @@
|
|||||||
server {
|
server {
|
||||||
listen 443 ssl;
|
listen 443 ssl http2;
|
||||||
listen 8091; # Tor I2P
|
listen 8091; # Tor I2P
|
||||||
listen [300:a98d:d6d0:8a08::e]:80; # Yggdrasil
|
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.su radio.arav.i2p plkybcgxt4cdanot75cy3pbnqlbqcsrib2fmrpsnug4bqphqvfda.b32.i2p mkgnmhmzqm7kyzv7jnzzafvgm7xlmlfvzhgorpapd5or2arnhuktqd.onion;
|
||||||
|
|
||||||
access_log /var/log/nginx/dwelling/radio.log main if=$nolog;
|
access_log /var/log/nginx/dwelling/radio.log main if=$nolog;
|
||||||
|
16
go.mod
@ -1,21 +1,7 @@
|
|||||||
module dwelling-radio
|
module dwelling-radio
|
||||||
|
|
||||||
go 1.21
|
go 1.17
|
||||||
|
|
||||||
toolchain go1.22.3
|
|
||||||
|
|
||||||
require github.com/pkg/errors v0.9.1
|
require github.com/pkg/errors v0.9.1
|
||||||
|
|
||||||
require git.arav.su/Arav/httpr v0.3.2
|
require git.arav.su/Arav/httpr v0.3.2
|
||||||
|
|
||||||
require github.com/a-h/templ v0.2.778
|
|
||||||
|
|
||||||
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
|
|
||||||
)
|
|
||||||
|
20
go.sum
@ -1,24 +1,4 @@
|
|||||||
git.arav.su/Arav/httpr v0.3.2 h1:a+ifu+9+FnQe6p/Kd4kgTDKAFN6zBOJjBTMjbAuHxVk=
|
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=
|
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/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
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=
|
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
|
||||||
|
@ -8,12 +8,13 @@ Type=simple
|
|||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
DynamicUser=yes
|
DynamicUser=yes
|
||||||
ExecStart=/usr/bin/dwelling-radio -listen /var/run/dwelling-radio/sock \
|
ExecStart=/usr/bin/dwelling-radio -listen /var/run/dwelling-radio/sock \
|
||||||
-work-dir /mnt/data/appdata/radio \
|
-filelist /mnt/data/appdata/radio/filelist.html \
|
||||||
-playlist all-rand \
|
-playlist /mnt/data/appdata/radio/playlists/all-rand \
|
||||||
|
-fallback-song /mnt/data/appdata/radio/fallback.ogg \
|
||||||
|
-mls-file /mnt/data/appdata/radio/mostlistenedsong \
|
||||||
-lst-len 10
|
-lst-len 10
|
||||||
|
|
||||||
ReadOnlyPaths=/
|
ReadOnlyPaths=/
|
||||||
ReadWritePaths=/mnt/data/appdata/radio
|
|
||||||
|
|
||||||
LogsDirectory=dwelling-radio
|
LogsDirectory=dwelling-radio
|
||||||
RuntimeDirectory=dwelling-radio
|
RuntimeDirectory=dwelling-radio
|
||||||
|
@ -2,7 +2,6 @@ package http
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"dwelling-radio/internal/radio"
|
"dwelling-radio/internal/radio"
|
||||||
"dwelling-radio/internal/statistics"
|
|
||||||
"dwelling-radio/pkg/oggtag"
|
"dwelling-radio/pkg/oggtag"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
@ -15,42 +14,38 @@ import (
|
|||||||
type DJHandlers struct {
|
type DJHandlers struct {
|
||||||
listeners *radio.ListenerCounter
|
listeners *radio.ListenerCounter
|
||||||
playlist *radio.Playlist
|
playlist *radio.Playlist
|
||||||
stats statistics.Statistics
|
songList *radio.SongList
|
||||||
curSong *radio.Song
|
mostLSong *radio.MostListenedSong
|
||||||
listLen int64
|
|
||||||
fallbackSong string
|
fallbackSong string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewDJHandlers(l *radio.ListenerCounter, p *radio.Playlist,
|
func NewDJHandlers(l *radio.ListenerCounter, p *radio.Playlist,
|
||||||
stats statistics.Statistics, cs *radio.Song, n int64, fS string) *DJHandlers {
|
sl *radio.SongList, mls *radio.MostListenedSong, fS string) *DJHandlers {
|
||||||
return &DJHandlers{listeners: l, playlist: p,
|
return &DJHandlers{listeners: l, playlist: p, songList: sl,
|
||||||
stats: stats, curSong: cs, listLen: n, fallbackSong: fS}
|
mostLSong: mls, fallbackSong: fS}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (dj *DJHandlers) ListenersUpdateIcecast(w http.ResponseWriter, r *http.Request) {
|
func (dj *DJHandlers) ListenersUpdate(w http.ResponseWriter, r *http.Request) {
|
||||||
defer func() {
|
defer func() {
|
||||||
if err := recover(); err != nil {
|
if err := recover(); err != nil {
|
||||||
log.Println("DJHandlers.ListenersUpdateIcecast panic:", err)
|
log.Println("DJHandlers.ListenersUpdate panic:", err)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
if err := r.ParseForm(); err != nil {
|
if err := r.ParseForm(); err != nil {
|
||||||
log.Println("DJHandlers.ListenersUpdateIcecast:", err)
|
log.Println("DJHandlers.ListenersUpdate:", err)
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
http.Error(w, "cannot parse form", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
switch r.FormValue("action") {
|
switch r.FormValue("action") {
|
||||||
case "listener_add":
|
case "listener_add":
|
||||||
dj.listeners.Lock()
|
l := dj.listeners.Inc()
|
||||||
_ = dj.listeners.Inc()
|
go dj.songList.UpdateCurrentMaxListeners(l)
|
||||||
dj.listeners.Unlock()
|
|
||||||
case "listener_remove":
|
case "listener_remove":
|
||||||
dj.listeners.Lock()
|
|
||||||
defer dj.listeners.Unlock()
|
|
||||||
if _, err := dj.listeners.Dec(); err != nil {
|
if _, err := dj.listeners.Dec(); err != nil {
|
||||||
log.Println("DJHandlers.ListenersUpdateIcecast:", err)
|
log.Println("DJHandlers.ListenersUpdate:", err)
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
@ -63,92 +58,61 @@ func (dj *DJHandlers) ListenersUpdateIcecast(w http.ResponseWriter, r *http.Requ
|
|||||||
w.WriteHeader(http.StatusOK)
|
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) {
|
func (dj *DJHandlers) PlaylistNext(w http.ResponseWriter, _ *http.Request) {
|
||||||
w.Header().Add("Content-Type", "text/plain")
|
w.Header().Add("Content-Type", "text/plain")
|
||||||
dj.playlist.Lock()
|
|
||||||
nxt := dj.playlist.Next()
|
nxt := dj.playlist.Next()
|
||||||
dj.playlist.Unlock()
|
|
||||||
if nxt == "" {
|
if nxt == "" {
|
||||||
log.Println("the end of a playlist has been reached")
|
log.Println("the end of a playlist has been reached")
|
||||||
if nxt = dj.fallbackSong; nxt == "" {
|
if nxt = dj.fallbackSong; nxt == "" {
|
||||||
log.Println("a fallback song is not set")
|
log.Println("a fallback song is not set")
|
||||||
w.WriteHeader(http.StatusNotFound)
|
http.Error(w, "a playlist is empty and a fallback song is not set", http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
oggf, err := oggtag.NewOggFile(nxt)
|
go func() {
|
||||||
if err != nil {
|
oggf, err := oggtag.NewOggFile(nxt)
|
||||||
log.Println("cannot read an OGG file", nxt, ":", err)
|
if err != nil {
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
log.Println("cannot read an OGG file", nxt, ":", err)
|
||||||
return
|
http.Error(w, "cannot read an OGG file", 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
|
song := radio.Song{
|
||||||
dj.curSong.Listeners = 0
|
Artist: oggf.GetTag("artist"),
|
||||||
dj.curSong.PeakListeners = 0
|
Title: oggf.GetTag("title"),
|
||||||
|
Duration: oggf.GetDuration(),
|
||||||
|
MaxListeners: dj.listeners.Current(),
|
||||||
|
// 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 strings.HasSuffix(nxt, "/fallback.ogg") {
|
||||||
|
song.Artist = "Nothing to play. Playing a fallback: " + song.Artist
|
||||||
|
}
|
||||||
|
|
||||||
|
if dj.songList.Current() != nil {
|
||||||
|
dj.mostLSong.Update(*dj.songList.Current())
|
||||||
|
}
|
||||||
|
|
||||||
|
dj.songList.Add(song)
|
||||||
|
}()
|
||||||
fmt.Fprintln(w, nxt)
|
fmt.Fprintln(w, nxt)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (dj *DJHandlers) Status(w http.ResponseWriter, r *http.Request) {
|
func (dj *DJHandlers) Status(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Add("Content-Type", "application/json")
|
w.Header().Add("Content-Type", "application/json")
|
||||||
|
err := json.NewEncoder(w).Encode(&struct {
|
||||||
lst, err := dj.stats.LastNSongs(dj.listLen)
|
Current *radio.Song `json:"current_song,omitempty"`
|
||||||
if err != nil {
|
Listeners *radio.ListenerCounter `json:"listeners"`
|
||||||
log.Println("failed to fetch last n songs:", err)
|
List []radio.Song `json:"last_songs,omitempty"`
|
||||||
}
|
Mls *radio.MostListenedSong `json:"most_listened_song,omitempty"`
|
||||||
|
|
||||||
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,
|
Current: dj.songList.Current(),
|
||||||
Listeners: dj.listeners.Current(),
|
Listeners: dj.listeners,
|
||||||
List: lst})
|
List: dj.songList.List(),
|
||||||
|
Mls: dj.mostLSong.Get()})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Println("DJHandlers.Status:", err)
|
log.Println("DJHandlers.Status:", err)
|
||||||
http.Error(w, "status parsing failed", http.StatusInternalServerError)
|
http.Error(w, "status parsing failed", http.StatusInternalServerError)
|
||||||
|
55
internal/http/handlers.go
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
package http
|
||||||
|
|
||||||
|
import (
|
||||||
|
"dwelling-radio/internal/radio"
|
||||||
|
"dwelling-radio/pkg/utils"
|
||||||
|
"dwelling-radio/web"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Handlers struct {
|
||||||
|
songList *radio.SongList
|
||||||
|
listeners *radio.ListenerCounter
|
||||||
|
mostLSong *radio.MostListenedSong
|
||||||
|
filelistPath string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHandlers(filelistPath string, songList *radio.SongList, listeners *radio.ListenerCounter, mls *radio.MostListenedSong) *Handlers {
|
||||||
|
return &Handlers{
|
||||||
|
songList: songList,
|
||||||
|
filelistPath: filelistPath,
|
||||||
|
listeners: listeners,
|
||||||
|
mostLSong: mls}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handlers) Index(w http.ResponseWriter, r *http.Request) {
|
||||||
|
web.Index(utils.MainSite(r.Host), h.songList, h.listeners, h.mostLSong.Get(), r, w)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handlers) Playlist(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Add("Content-Disposition", "attachment; filename=\"radio.arav.su.m3u\"")
|
||||||
|
fc, _ := web.AssetsGetFile("playlist.m3u")
|
||||||
|
w.Write(fc)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handlers) Filelist(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Add("Content-Type", "text/html")
|
||||||
|
data, _ := os.ReadFile(h.filelistPath)
|
||||||
|
w.Write(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func RobotsTxt(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Add("Content-Disposition", "attachment; filename=\"robots.txt\"")
|
||||||
|
w.Write([]byte("User-agent: *\nDisallow: /assets/\nDisallow: /live/"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func Favicon(w http.ResponseWriter, r *http.Request) {
|
||||||
|
data, _ := web.AssetsGetFile("img/favicon.svg")
|
||||||
|
w.Write(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func SitemapXML(w http.ResponseWriter, r *http.Request) {
|
||||||
|
data, _ := web.AssetsGetFile("sitemap.xml")
|
||||||
|
w.Write(data)
|
||||||
|
}
|
@ -3,15 +3,15 @@ package radio
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
"sync"
|
"sync"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ListenerCounter stores the current, overall and peak numbers of listeners.
|
// ListenerCounter stores the current and peak numbers of listeners.
|
||||||
type ListenerCounter struct {
|
type ListenerCounter struct {
|
||||||
sync.RWMutex
|
mutex sync.RWMutex
|
||||||
current, peak int64
|
current, peak int
|
||||||
overall, cur_peak int64
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewListenerCounter returns a new ListenerCounter struct instance.
|
// NewListenerCounter returns a new ListenerCounter struct instance.
|
||||||
@ -20,48 +20,39 @@ func NewListenerCounter() *ListenerCounter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Current returns a number of current listeners.
|
// Current returns a number of current listeners.
|
||||||
func (l *ListenerCounter) Current() int64 {
|
func (l *ListenerCounter) Current() int {
|
||||||
|
l.mutex.RLock()
|
||||||
|
defer l.mutex.RUnlock()
|
||||||
return l.current
|
return l.current
|
||||||
}
|
}
|
||||||
|
|
||||||
// Peak returns a peak number of listeners.
|
// Current returns a number of peak listeners.
|
||||||
func (l *ListenerCounter) Peak() int64 {
|
func (l *ListenerCounter) Peak() int {
|
||||||
|
l.mutex.RLock()
|
||||||
|
defer l.mutex.RUnlock()
|
||||||
return l.peak
|
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.
|
// Inc increments by 1 a current number of listeners and updates a peak number.
|
||||||
func (l *ListenerCounter) Inc() int64 {
|
func (l *ListenerCounter) Inc() int {
|
||||||
if l.current == math.MaxInt64 {
|
l.mutex.Lock()
|
||||||
|
defer l.mutex.Unlock()
|
||||||
|
if l.current == math.MaxInt {
|
||||||
// We panic here because if this will ever happen, then something's going certainly wrong.
|
// We panic here because if this will ever happen, then something's going certainly wrong.
|
||||||
panic("a current number of listeners exceeded MaxInt64")
|
panic(fmt.Sprint("a current number of listeners exceeded MaxInt which is", math.MaxInt))
|
||||||
}
|
}
|
||||||
l.current++
|
l.current++
|
||||||
if l.current > l.peak {
|
if l.current > l.peak {
|
||||||
l.peak = l.current
|
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
|
return l.current
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dec decrements by 1 a current number of listeners. An error will occur if
|
// Dec decrements by 1 a current number of listeners. An error will occur if
|
||||||
// a resulting number is less than 0.
|
// a resulting number is less than 0.
|
||||||
func (l *ListenerCounter) Dec() (int64, error) {
|
func (l *ListenerCounter) Dec() (int, error) {
|
||||||
|
l.mutex.Lock()
|
||||||
|
defer l.mutex.Unlock()
|
||||||
if l.current == 0 {
|
if l.current == 0 {
|
||||||
return l.current, errors.New("an attempt to decrement a number of current listeners down to less than 0")
|
return l.current, errors.New("an attempt to decrement a number of current listeners down to less than 0")
|
||||||
}
|
}
|
||||||
@ -69,25 +60,13 @@ func (l *ListenerCounter) Dec() (int64, error) {
|
|||||||
return l.current, nil
|
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) {
|
func (l *ListenerCounter) MarshalJSON() ([]byte, error) {
|
||||||
|
l.mutex.RLock()
|
||||||
|
defer l.mutex.RUnlock()
|
||||||
return json.Marshal(&struct {
|
return json.Marshal(&struct {
|
||||||
Current int64 `json:"current"`
|
Current int `json:"current"`
|
||||||
Peak int64 `json:"peak"`
|
Peak int `json:"peak"`
|
||||||
Overall int64 `json:"overall"`
|
|
||||||
CurPeak int64 `json:"current_peak"`
|
|
||||||
}{
|
}{
|
||||||
Current: l.current,
|
Current: l.current,
|
||||||
Peak: l.peak,
|
Peak: l.peak})
|
||||||
Overall: l.overall,
|
|
||||||
CurPeak: l.cur_peak})
|
|
||||||
}
|
}
|
||||||
|
97
internal/radio/mostlistened.go
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
package radio
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"strconv"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
const MostListenedDateFormat string = "02 January 2006 at 15:04:05 MST"
|
||||||
|
|
||||||
|
// MostListenedSong holds a metadata for a most listened song.
|
||||||
|
type MostListenedSong struct {
|
||||||
|
mutex sync.RWMutex
|
||||||
|
Date time.Time `json:"date"`
|
||||||
|
Listeners int `json:"listeners"`
|
||||||
|
Song string `json:"song"`
|
||||||
|
changed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (mls *MostListenedSong) Update(song Song) {
|
||||||
|
mls.mutex.Lock()
|
||||||
|
defer mls.mutex.Unlock()
|
||||||
|
if song.Artist == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if song.MaxListeners > mls.Listeners {
|
||||||
|
mls.Listeners = song.MaxListeners
|
||||||
|
mls.Date = song.StartAt
|
||||||
|
mls.Song = song.Artist + " - " + song.Title
|
||||||
|
mls.changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (mls *MostListenedSong) Get() *MostListenedSong {
|
||||||
|
mls.mutex.RLock()
|
||||||
|
defer mls.mutex.RUnlock()
|
||||||
|
|
||||||
|
if mls.Date.Year() == 1 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return &MostListenedSong{
|
||||||
|
Date: mls.Date,
|
||||||
|
Listeners: mls.Listeners,
|
||||||
|
Song: mls.Song}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load parses given data and fill a MostListenedSong.
|
||||||
|
func (mls *MostListenedSong) Load(data []byte) (err error) {
|
||||||
|
mls.mutex.Lock()
|
||||||
|
defer mls.mutex.Unlock()
|
||||||
|
|
||||||
|
lines := bytes.Split(data, []byte{'\n'})
|
||||||
|
if len(lines) != 3 {
|
||||||
|
return errors.New("lines count mismatch, should be 3")
|
||||||
|
}
|
||||||
|
|
||||||
|
var date time.Time
|
||||||
|
if date, err = time.Parse(time.RFC3339, string(lines[0])); err != nil {
|
||||||
|
return errors.Wrap(err, "wrong date/time format")
|
||||||
|
}
|
||||||
|
|
||||||
|
var listeners int
|
||||||
|
if listeners, err = strconv.Atoi(string(lines[1])); err != nil {
|
||||||
|
return errors.Wrap(err, "a listeners number failed to parse")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(lines[2]) == 0 {
|
||||||
|
return errors.New("a song is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
mls.Date = date
|
||||||
|
mls.Listeners = listeners
|
||||||
|
mls.Song = string(lines[2])
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store returns a byte slice of a marshalled to text MostListenedSong.
|
||||||
|
func (mls *MostListenedSong) Store() []byte {
|
||||||
|
if !mls.changed {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
buf := make([]byte, 0, 30+len(mls.Song))
|
||||||
|
b := bytes.NewBuffer(buf)
|
||||||
|
|
||||||
|
b.WriteString(mls.Date.Format(time.RFC3339))
|
||||||
|
b.WriteByte('\n')
|
||||||
|
b.WriteString(strconv.Itoa(mls.Listeners))
|
||||||
|
b.WriteByte('\n')
|
||||||
|
b.WriteString(mls.Song)
|
||||||
|
|
||||||
|
return b.Bytes()
|
||||||
|
}
|
@ -10,7 +10,7 @@ import (
|
|||||||
|
|
||||||
// Playlist holds a list of paths to a song files.
|
// Playlist holds a list of paths to a song files.
|
||||||
type Playlist struct {
|
type Playlist struct {
|
||||||
sync.Mutex
|
mutex sync.Mutex
|
||||||
filePath string
|
filePath string
|
||||||
playlist []string
|
playlist []string
|
||||||
cur int
|
cur int
|
||||||
@ -27,6 +27,8 @@ func NewPlaylist(filePath string, repeat bool) (*Playlist, error) {
|
|||||||
// Next returns the next song to play. Returns an empty string if repeat is
|
// Next returns the next song to play. Returns an empty string if repeat is
|
||||||
// false and the end of a playlist was reached.
|
// false and the end of a playlist was reached.
|
||||||
func (p *Playlist) Next() (song string) {
|
func (p *Playlist) Next() (song string) {
|
||||||
|
p.mutex.Lock()
|
||||||
|
defer p.mutex.Unlock()
|
||||||
if p.cur == len(p.playlist) {
|
if p.cur == len(p.playlist) {
|
||||||
// If the end of a playlist was reached and repeat is set to true,
|
// 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
|
// then go back to the head of it, thus repeating it. Return an empty
|
||||||
@ -53,8 +55,10 @@ func (p *Playlist) load() error {
|
|||||||
return errors.New("a playlist file is empty. Did not update")
|
return errors.New("a playlist file is empty. Did not update")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
p.mutex.Lock()
|
||||||
p.playlist = strings.Split(string(data), "\n")
|
p.playlist = strings.Split(string(data), "\n")
|
||||||
p.cur = 0
|
p.cur = 0
|
||||||
|
p.mutex.Unlock()
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
@ -2,16 +2,18 @@ package radio
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Song stores artist and title of a song, a timestamp of when it started, its
|
||||||
|
// duration, and a maximum number of listeners.
|
||||||
type Song struct {
|
type Song struct {
|
||||||
Artist string
|
Artist string
|
||||||
Title string
|
Title string
|
||||||
StartAt time.Time
|
Duration time.Duration
|
||||||
Duration time.Duration
|
MaxListeners int
|
||||||
Listeners int64
|
StartAt time.Time
|
||||||
PeakListeners int64
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// DurationString returns song's duration as a string formatted as [H:]M:SS.
|
// DurationString returns song's duration as a string formatted as [H:]M:SS.
|
||||||
@ -24,17 +26,84 @@ func (s *Song) DurationString() string {
|
|||||||
|
|
||||||
func (s *Song) MarshalJSON() ([]byte, error) {
|
func (s *Song) MarshalJSON() ([]byte, error) {
|
||||||
return json.Marshal(&struct {
|
return json.Marshal(&struct {
|
||||||
Artist string `json:"artist"`
|
Artist string `json:"artist"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
DurationMill int64 `json:"duration_msec,omitempty"`
|
DurationMill int64 `json:"duration_msec"`
|
||||||
Listeners int64 `json:"listeners,omitempty"`
|
MaxListeners int `json:"listeners"`
|
||||||
PeakListeners int64 `json:"peak_listeners,omitempty"`
|
StartAt string `json:"start_at"`
|
||||||
StartAt string `json:"start_at"`
|
|
||||||
}{
|
}{
|
||||||
Artist: s.Artist,
|
Artist: s.Artist,
|
||||||
Title: s.Title,
|
Title: s.Title,
|
||||||
DurationMill: s.Duration.Milliseconds(),
|
DurationMill: s.Duration.Milliseconds(),
|
||||||
Listeners: s.Listeners,
|
MaxListeners: s.MaxListeners,
|
||||||
PeakListeners: s.PeakListeners,
|
StartAt: s.StartAt.UTC().Format(time.RFC3339)})
|
||||||
StartAt: s.StartAt.UTC().Format(time.RFC3339)})
|
}
|
||||||
|
|
||||||
|
// SongList holds a currently playing song and a list of previously played ones
|
||||||
|
// with a maximal length of maxLen.
|
||||||
|
type SongList struct {
|
||||||
|
mutex sync.RWMutex
|
||||||
|
current Song
|
||||||
|
lastSongs []Song
|
||||||
|
maxLen int
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSongList returns a new SongList with a maximal length set with maxLen.
|
||||||
|
func NewSongList(maxLen int) *SongList {
|
||||||
|
return &SongList{maxLen: maxLen, lastSongs: make([]Song, 0, maxLen)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add a new song that is currently playing and update a list.
|
||||||
|
func (sl *SongList) Add(newSong Song) {
|
||||||
|
sl.mutex.Lock()
|
||||||
|
defer sl.mutex.Unlock()
|
||||||
|
|
||||||
|
if sl.current.StartAt.Year() == 1 {
|
||||||
|
sl.current = newSong
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(sl.lastSongs) == sl.maxLen {
|
||||||
|
sl.lastSongs = append(sl.lastSongs[1:], sl.current)
|
||||||
|
} else {
|
||||||
|
sl.lastSongs = append(sl.lastSongs, sl.current)
|
||||||
|
}
|
||||||
|
sl.current = newSong
|
||||||
|
}
|
||||||
|
|
||||||
|
// Current returns a currently playing song or nil if it isn't set yet.
|
||||||
|
func (sl *SongList) Current() *Song {
|
||||||
|
sl.mutex.RLock()
|
||||||
|
defer sl.mutex.RUnlock()
|
||||||
|
if sl.current.StartAt.Year() == 1 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &Song{
|
||||||
|
Artist: sl.current.Artist,
|
||||||
|
Title: sl.current.Title,
|
||||||
|
Duration: sl.current.Duration,
|
||||||
|
MaxListeners: sl.current.MaxListeners,
|
||||||
|
StartAt: sl.current.StartAt}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateCurrentMaxListeners checks and updates a maximal number of listeners
|
||||||
|
// for a current song.
|
||||||
|
func (sl *SongList) UpdateCurrentMaxListeners(listeners int) {
|
||||||
|
sl.mutex.Lock()
|
||||||
|
defer sl.mutex.Unlock()
|
||||||
|
if listeners > sl.current.MaxListeners {
|
||||||
|
sl.current.MaxListeners = listeners
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// List returns a list of lastly played songs.
|
||||||
|
func (sl *SongList) List() []Song {
|
||||||
|
sl.mutex.RLock()
|
||||||
|
defer sl.mutex.RUnlock()
|
||||||
|
return sl.lastSongs
|
||||||
|
}
|
||||||
|
|
||||||
|
// MaxLen returns a maximal length of a song list.
|
||||||
|
func (sl *SongList) MaxLen() int {
|
||||||
|
return sl.maxLen
|
||||||
}
|
}
|
||||||
|
@ -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()
|
|
||||||
}
|
|
@ -19,13 +19,6 @@ func MainSite(host string) string {
|
|||||||
return "https://arav.su"
|
return "https://arav.su"
|
||||||
}
|
}
|
||||||
|
|
||||||
func Site(host string) string {
|
|
||||||
if strings.Contains(host, ".su") {
|
|
||||||
return "https://radio.arav.su"
|
|
||||||
}
|
|
||||||
return "http://" + host
|
|
||||||
}
|
|
||||||
|
|
||||||
// ToClientTimezone converts given time to timezone set in a
|
// ToClientTimezone converts given time to timezone set in a
|
||||||
// X-Client-Timezone header. If this header is not set, then
|
// X-Client-Timezone header. If this header is not set, then
|
||||||
// converts to UTC.
|
// converts to UTC.
|
||||||
|
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
|
||||||
|
}
|
7
tools/radio-listener-connect
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
|
||||||
|
response=$(curl -XPOST --unix-socket /var/run/dwelling-radio/sock http://localhost/api/listener -o /dev/null -s -w "%{response_code}")
|
||||||
|
|
||||||
|
if [ "$response" -ne "201" ]; then
|
||||||
|
exit 1;
|
||||||
|
fi
|
7
tools/radio-listener-disconnect
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
|
||||||
|
response=$(curl -XDELETE --unix-socket /var/run/dwelling-radio/sock http://localhost/api/listener -o /dev/null -s -w "%{response_code}")
|
||||||
|
|
||||||
|
if [ "$response" -ne "200" ]; then
|
||||||
|
exit 1;
|
||||||
|
fi
|
@ -4,7 +4,7 @@ radio_dir=/mnt/data/appdata/radio
|
|||||||
|
|
||||||
case $1 in
|
case $1 in
|
||||||
f | filelist)
|
f | filelist)
|
||||||
tree -H '' -T "List – Arav's dwelling / Radio" \
|
tree -H '' -T "Arav's dwelling / Radio / List" \
|
||||||
-P '*.ogg' --charset=utf-8 --prune --du -hnl \
|
-P '*.ogg' --charset=utf-8 --prune --du -hnl \
|
||||||
--nolinks -o $radio_dir/filelist.html $radio_dir/music
|
--nolinks -o $radio_dir/filelist.html $radio_dir/music
|
||||||
break
|
break
|
||||||
|
@ -11,7 +11,6 @@
|
|||||||
--secondary-color: #9f2b68;
|
--secondary-color: #9f2b68;
|
||||||
--text-color: #f5f5f5;
|
--text-color: #f5f5f5;
|
||||||
--text-indent: 1.6rem;
|
--text-indent: 1.6rem;
|
||||||
color-scheme: light dark;
|
|
||||||
scrollbar-color: var(--primary-color) var(--background-color); }
|
scrollbar-color: var(--primary-color) var(--background-color); }
|
||||||
|
|
||||||
@media (prefers-color-scheme: light) {
|
@media (prefers-color-scheme: light) {
|
||||||
@ -27,16 +26,15 @@
|
|||||||
background-color: var(--secondary-color);
|
background-color: var(--secondary-color);
|
||||||
color: var(--background-color); }
|
color: var(--background-color); }
|
||||||
|
|
||||||
.small { font-size: .8rem; }
|
.right { text-align: right; }
|
||||||
|
|
||||||
.small.player-links a { margin: 0 .2rem; }
|
|
||||||
|
|
||||||
a,
|
a,
|
||||||
button {
|
button {
|
||||||
color: var(--primary-color);
|
color: var(--primary-color);
|
||||||
text-decoration: none; }
|
text-decoration: none; }
|
||||||
|
|
||||||
a:hover {
|
a:hover,
|
||||||
|
button:hover {
|
||||||
color: var(--secondary-color);
|
color: var(--secondary-color);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
text-decoration: underline dotted;
|
text-decoration: underline dotted;
|
||||||
@ -44,7 +42,9 @@ a:hover {
|
|||||||
|
|
||||||
button {
|
button {
|
||||||
background: none;
|
background: none;
|
||||||
border: none; }
|
border: none;
|
||||||
|
font: inherit;
|
||||||
|
padding: 0; }
|
||||||
|
|
||||||
p {
|
p {
|
||||||
text-align: justify;
|
text-align: justify;
|
||||||
@ -64,6 +64,10 @@ h2 {
|
|||||||
font-size: 1.4rem;
|
font-size: 1.4rem;
|
||||||
margin: 1rem 0; }
|
margin: 1rem 0; }
|
||||||
|
|
||||||
|
small { font-size: .8rem; }
|
||||||
|
|
||||||
|
small.player-links a { margin: 0 .2rem; }
|
||||||
|
|
||||||
audio {
|
audio {
|
||||||
background-color: var(--primary-color);
|
background-color: var(--primary-color);
|
||||||
width: 100%; }
|
width: 100%; }
|
||||||
@ -84,25 +88,28 @@ header {
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
justify-content: space-between; }
|
justify-content: space-between; }
|
||||||
|
|
||||||
|
header svg { width: 360px; }
|
||||||
|
|
||||||
header svg text { fill: var(--text-color); }
|
header svg text { fill: var(--text-color); }
|
||||||
|
|
||||||
header svg text:first-child {
|
header svg text:first-child {
|
||||||
font-size: 3.55rem;
|
font-size: 2rem;
|
||||||
font-variant-caps: small-caps;
|
font-variant-caps: small-caps;
|
||||||
font-weight: bold; }
|
font-weight: bold; }
|
||||||
|
|
||||||
header svg text:last-child { font-size: 1.5rem; }
|
header svg text:last-child { font-size: .88rem; }
|
||||||
|
|
||||||
@supports (-moz-appearance:none) {
|
@media screen and (-webkit-min-device-pixel-ratio:0) {
|
||||||
header svg text:last-child { transform: scale(.993, 1); } }
|
header svg text:first-child { font-size: 2.082rem; } }
|
||||||
|
|
||||||
header nav {
|
@-moz-document url-prefix() {
|
||||||
display: flex;
|
header svg text:first-child { font-size: 2rem; } }
|
||||||
flex-direction: column;
|
|
||||||
font-variant: small-caps;
|
|
||||||
justify-content: space-evenly; }
|
|
||||||
|
|
||||||
header nav h1 {
|
nav { margin-top: .5rem; }
|
||||||
|
|
||||||
|
nav a { font-variant: small-caps; }
|
||||||
|
|
||||||
|
nav h1 {
|
||||||
color: var(--secondary-color);
|
color: var(--secondary-color);
|
||||||
margin: 0; }
|
margin: 0; }
|
||||||
|
|
||||||
@ -110,47 +117,50 @@ section { margin-top: 1rem; }
|
|||||||
|
|
||||||
#banner { text-align: center; }
|
#banner { text-align: center; }
|
||||||
|
|
||||||
|
#banner video { max-width: 90%; }
|
||||||
|
|
||||||
#player {
|
#player {
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: center; }
|
align-items: center; }
|
||||||
|
|
||||||
#player p { text-indent: 1rem; }
|
#player p { text-indent: 1rem; }
|
||||||
|
|
||||||
#player img,
|
|
||||||
button#radio-play {
|
button#radio-play {
|
||||||
filter: drop-shadow(0px 0px 4px var(--text-color));
|
-webkit-mask-image: url(/assets/img/play.svg);
|
||||||
height: 1rem;
|
background-color: var(--primary-color);
|
||||||
padding: 0 .7rem; }
|
|
||||||
|
|
||||||
button#radio-play {
|
|
||||||
background-image: url(/assets/img/play.svg);
|
|
||||||
height: 3rem;
|
height: 3rem;
|
||||||
|
mask-image: url(/assets/img/play.svg);
|
||||||
min-width: 3rem;
|
min-width: 3rem;
|
||||||
width: 3rem; }
|
width: 3rem; }
|
||||||
|
|
||||||
|
button#radio-play:hover { text-decoration: none; }
|
||||||
|
|
||||||
input#radio-volume {
|
input#radio-volume {
|
||||||
|
appearance: slider-vertical;
|
||||||
|
-webkit-appearance: slider-vertical;
|
||||||
accent-color: var(--primary-color);
|
accent-color: var(--primary-color);
|
||||||
direction: rtl;
|
height: 5rem;
|
||||||
height: 4rem;
|
margin-left: .5rem; }
|
||||||
margin-left: .5rem;
|
|
||||||
writing-mode: vertical-lr; }
|
|
||||||
|
|
||||||
#player div:first-child {
|
#player div:first-child {
|
||||||
display: none;
|
display: none;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: center; }
|
align-items: center; }
|
||||||
|
|
||||||
|
#player div:first-child div {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
#last-songs {
|
#last-songs {
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
min-width: 80%;
|
min-width: 80%;
|
||||||
width: 80%; }
|
width: 80%; }
|
||||||
|
|
||||||
#last-songs :is(thead tr, tbody tr) {
|
#last-songs tbody tr {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: .5rem;
|
gap: .5rem;
|
||||||
grid-template-columns: 3rem 3rem 1fr; }
|
grid-template-columns: 3rem 2rem 1fr; }
|
||||||
|
|
||||||
#last-songs thead tr { font-weight: bold; }
|
|
||||||
|
|
||||||
footer {
|
footer {
|
||||||
font-size: .8rem;
|
font-size: .8rem;
|
||||||
@ -158,10 +168,14 @@ footer {
|
|||||||
padding: 1rem 0; }
|
padding: 1rem 0; }
|
||||||
|
|
||||||
@media screen and (max-width: 640px) {
|
@media screen and (max-width: 640px) {
|
||||||
header {
|
header { display: block; }
|
||||||
align-items: center;
|
|
||||||
flex-direction: column; }
|
|
||||||
|
|
||||||
header svg { width: 100%; }
|
header svg {
|
||||||
|
margin: 0 auto;
|
||||||
|
width: 100%; }
|
||||||
|
|
||||||
|
nav {
|
||||||
|
width: 100%;
|
||||||
|
text-align: center; }
|
||||||
|
|
||||||
#player { flex-direction: column; } }
|
#player { flex-direction: column; } }
|
@ -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>
|
<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 +1,2 @@
|
|||||||
<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>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg version="1.1" viewBox="0 0 100 100" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path transform="matrix(.75195 -.012986 .014978 .65194 38.971 49.316)" d="m-25.978 46.549 1.6542-92 78.847 47.433-40.251 22.284z" stroke-width=".26458"/><circle cx="50" cy="50" r="47.585" fill="none" stroke="#000" stroke-width="4.8301"/></svg>
|
||||||
|
Before Width: | Height: | Size: 220 B After Width: | Height: | Size: 380 B |
@ -1 +1,2 @@
|
|||||||
<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>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg version="1.1" viewBox="0 0 100 100" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path transform="matrix(.75195 -.012986 .014978 .65194 38.971 49.316)" d="m-25.978 46.549 1.6542-92 78.847 47.433-40.251 22.284z" stroke-width=".26458"/><circle cx="50" cy="50" r="47.585" fill="none" stroke="#000" stroke-width="4.8301"/><rect x="20.886" y="20.886" width="58.228" height="58.228" rx="57.392" ry="0" stroke="#000" stroke-width="1.7724"/></svg>
|
||||||
|
Before Width: | Height: | Size: 169 B After Width: | Height: | Size: 495 B |
@ -13,30 +13,42 @@ async function updateStatus() {
|
|||||||
$("radio-song").textContent =
|
$("radio-song").textContent =
|
||||||
$("radio-duration-estimate").textContent =
|
$("radio-duration-estimate").textContent =
|
||||||
$("radio-duration").textContent = "";
|
$("radio-duration").textContent = "";
|
||||||
$("radio-listeners").textContent = "0";
|
$("radio-listeners").textContent =
|
||||||
$("last-songs").lastChild.remove();
|
$("radio-listener-peak").textContent = "0";
|
||||||
|
$("last-songs").firstChild.remove();
|
||||||
return [-1, null];
|
return [-1, null];
|
||||||
}
|
}
|
||||||
|
|
||||||
const s = await resp.json();
|
const s = await resp.json();
|
||||||
|
|
||||||
if (undefined != s.last_songs) {
|
if (undefined != s.most_listened_song) {
|
||||||
$("last-songs").lastChild.remove();
|
$("radio-mls-song").textContent = s.most_listened_song.song;
|
||||||
$("last-songs").appendChild(document.createElement("tbody"));
|
$("radio-mls-listeners").textContent = s.most_listened_song.listeners;
|
||||||
for (let i = 0; i < s.last_songs.length; ++i) {
|
$("radio-mls-date").textContent = (new Intl.DateTimeFormat('en-GB',
|
||||||
let row = $("last-songs").lastChild.insertRow();
|
{timeStyle: "long",
|
||||||
row.insertCell().appendChild(document.createTextNode(formatStartAt(new Date(s.last_songs[i].start_at))));
|
dateStyle: "long",
|
||||||
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)));
|
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone}))
|
||||||
row.insertCell().appendChild(document.createTextNode(`${s.last_songs[i].artist} - ${s.last_songs[i].title}`));
|
.format(new Date(s.most_listened_song.date))
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (undefined == s.current_song || undefined == s.current_song.duration_msec)
|
if (undefined == s.current_song)
|
||||||
return [-1, null];
|
return [-1, null];
|
||||||
|
|
||||||
$("radio-song").textContent = `${s.current_song.artist} - ${s.current_song.title}`;
|
$("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));
|
$("radio-duration").textContent = formatDuration(new Date(s.current_song.duration_msec));
|
||||||
|
$("radio-listeners").textContent = s.listeners.current;
|
||||||
|
$("radio-listener-peak").textContent = s.listeners.peak;
|
||||||
|
|
||||||
|
if (undefined != s.last_songs) {
|
||||||
|
$("last-songs").firstChild.remove();
|
||||||
|
$("last-songs").appendChild(document.createElement("tbody"));
|
||||||
|
for (let i = 0; i < s.last_songs.length; ++i) {
|
||||||
|
let row = $("last-songs").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 == 0 ? "" : s.last_songs[i].listeners));
|
||||||
|
row.insertCell().appendChild(document.createTextNode(`${s.last_songs[i].artist} - ${s.last_songs[i].title}`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return [s.current_song.duration_msec, new Date(s.current_song.start_at)];
|
return [s.current_song.duration_msec, new Date(s.current_song.start_at)];
|
||||||
}
|
}
|
||||||
@ -54,6 +66,10 @@ async function update() {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$("radio-update").addEventListener("click", async () =>
|
||||||
|
[cursong_duration_msec, cursong_startat] = await updateStatus());
|
||||||
|
|
||||||
|
|
||||||
let update_interval_id = null;
|
let update_interval_id = null;
|
||||||
async function interval() {
|
async function interval() {
|
||||||
switch (await update()) {
|
switch (await update()) {
|
||||||
@ -78,15 +94,12 @@ audio.hidden = true;
|
|||||||
const audio_src = audio.childNodes[0].src;
|
const audio_src = audio.childNodes[0].src;
|
||||||
|
|
||||||
const volume = $("radio-volume");
|
const volume = $("radio-volume");
|
||||||
volume.value = +(localStorage.getItem("volume") || 50);
|
|
||||||
audio.volume = volume.value / 100.0;
|
audio.volume = volume.value / 100.0;
|
||||||
volume.addEventListener("input", e => {
|
volume.addEventListener("input", e => audio.volume = e.target.value / 100.0);
|
||||||
audio.volume = e.target.value / 100.0;
|
|
||||||
localStorage.setItem("volume", e.target.value); });
|
|
||||||
|
|
||||||
$("player").style.display = $("player").firstChild.style.display = "flex";
|
$("player").style.display = $("player").firstChild.style.display = "flex";
|
||||||
|
|
||||||
$("radio-play").addEventListener("click", e => {
|
$("radio-play").addEventListener("click", e => {
|
||||||
audio.paused ? (audio.src = audio_src) && audio.play() : audio.src = "";
|
audio.paused ? (audio.src = audio_src) && audio.play() : audio.src = "";
|
||||||
e.target.style.backgroundImage = audio.paused ?
|
e.target.style.maskImage = e.target.style.webkitMaskImage = audio.paused ?
|
||||||
"url(/assets/img/play.svg)" : "url(/assets/img/stop.svg)"; });
|
"url(/assets/img/play.svg)" : "url(/assets/img/stop.svg)"; });
|
||||||
|
@ -1,3 +0,0 @@
|
|||||||
User-agent: *
|
|
||||||
Disallow: /assets/
|
|
||||||
Disallow: /live/
|
|
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>
|
|
||||||
}
|
|
@ -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: О приватности
|
|
71
web/templates/index.pug
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
:go:func Index(mainSite string, songList *radio.SongList, listeners *radio.ListenerCounter, mls *radio.MostListenedSong, r *http.Request)
|
||||||
|
|
||||||
|
:go:import "dwelling-radio/internal/radio"
|
||||||
|
:go:import "dwelling-radio/pkg/utils"
|
||||||
|
|
||||||
|
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(viewBox='0 -25 216 40')
|
||||||
|
text Arav's dwelling
|
||||||
|
text(y='11') Welcome to my sacred place, wanderer
|
||||||
|
nav
|
||||||
|
a(href=mainSite) Back to main website
|
||||||
|
h1 Radio
|
||||||
|
section#banner
|
||||||
|
video(playsinline='' autoplay='' loop='' muted='')
|
||||||
|
source(src="/assets/img/stopit.mp4", type="video/mp4")
|
||||||
|
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.su: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
|
||||||
|
div#player
|
||||||
|
div
|
||||||
|
div
|
||||||
|
button#radio-play
|
||||||
|
input#radio-volume(type="range" min="0" max="100" orient="vertical")
|
||||||
|
audio(preload='none' controls='' playsinline='')
|
||||||
|
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]!
|
||||||
|
div
|
||||||
|
if (songList.Current() != nil)
|
||||||
|
- cur := *songList.Current()
|
||||||
|
p Now playing: #[span#radio-song #{cur.Artist} - #{cur.Title}] ( #[span#radio-duration-estimate ] #[span#radio-duration #{cur.DurationString()}] )
|
||||||
|
else
|
||||||
|
p Now playing: #[span#radio-song ] ( #[span#radio-duration-estimate ] #[span#radio-duration ] )
|
||||||
|
p Current/peak listeners: #[span#radio-listeners #{listeners.Current()}] / #[span#radio-listener-peak #{listeners.Peak()}]
|
||||||
|
p
|
||||||
|
small Notice: information updates every new song. But you can #[button#radio-update update] it forcibly.
|
||||||
|
section
|
||||||
|
h2 Last #{songList.MaxLen()} songs
|
||||||
|
table#last-songs
|
||||||
|
tbody
|
||||||
|
each song in songList.List()
|
||||||
|
tr
|
||||||
|
td= utils.ToClientTimezone(song.StartAt, r).Format("15:04")
|
||||||
|
if song.MaxListeners != 0
|
||||||
|
td= song.MaxListeners
|
||||||
|
else
|
||||||
|
td
|
||||||
|
td #{song.Artist} - #{song.Title}
|
||||||
|
if mls != nil
|
||||||
|
p.right Most listened song was "#[span#radio-mls-song #{mls.Song}]" on #[span#radio-mls-date #{utils.ToClientTimezone(mls.Date, r).Format(radio.MostListenedDateFormat)}] with #[b#radio-mls-listeners #{mls.Listeners}] listeners.
|
||||||
|
footer
|
||||||
|
| 2017—2024 Alexander "Arav" Andreev <#[a(href='mailto:me@arav.su') me@arav.su]> #[a(href=mainSite+'/privacy') Privacy statements]
|
22
web/web.go
@ -6,6 +6,9 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// To install a Jade compiler: go install github.com/Joker/jade/cmd/jade@latest
|
||||||
|
//go:generate $GOPATH/bin/jade -pkg=web -writer templates/index.pug
|
||||||
|
|
||||||
//go:embed assets
|
//go:embed assets
|
||||||
var assetsDir embed.FS
|
var assetsDir embed.FS
|
||||||
|
|
||||||
@ -14,21 +17,6 @@ func Assets() http.FileSystem {
|
|||||||
return http.FS(f)
|
return http.FS(f)
|
||||||
}
|
}
|
||||||
|
|
||||||
func ServeAsset(path, mime, attachement string) func(http.ResponseWriter, *http.Request) {
|
func AssetsGetFile(path string) ([]byte, error) {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return assetsDir.ReadFile("assets/" + path)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|