Compare commits
405 Commits
Author | SHA1 | Date | |
---|---|---|---|
e092a34055 | |||
962bddaa0f | |||
1beda9fe96 | |||
2179d74239 | |||
e148bad281 | |||
812af85f63 | |||
2eb79a86a8 | |||
e7be56e64f | |||
e331370bdb | |||
41aea2112a | |||
3a9f39f7a8 | |||
f4c82925b4 | |||
3d7e3b0193 | |||
398b38561a | |||
d1bd7982ce | |||
e3f7b10200 | |||
c7ca05401f | |||
99ef685e5e | |||
293e1a3126 | |||
9b959eb7ab | |||
5bc653f50a | |||
7e9e641330 | |||
e5d1c6be8d | |||
b8f6163cb8 | |||
88ecf675b6 | |||
baad7da10d | |||
2facf9662a | |||
f265494ea8 | |||
b155b81064 | |||
c21838314f | |||
8d3ec1a327 | |||
a728ea2164 | |||
c8c153cd62 | |||
69f24a6b7b | |||
38c3e11b07 | |||
f266e4fcf7 | |||
51a0ef167c | |||
40d2993b03 | |||
e2555d82e3 | |||
154682ea4f | |||
d2db23be5d | |||
c8f71b205b | |||
a374dfd510 | |||
5d042cdb3a | |||
056f2c022b | |||
904af3107a | |||
872e8f4978 | |||
4feeb518fc | |||
08203c4be0 | |||
cf90bba297 | |||
454df3bb74 | |||
d94c029fda | |||
1682362779 | |||
3b22643733 | |||
84e23b5b85 | |||
23f53609ae | |||
c49c5b0112 | |||
0a55b115fb | |||
fb5c46381e | |||
bf643e6cf6 | |||
665a8e8c75 | |||
670b6ea032 | |||
7c34a3a632 | |||
ec6c1df474 | |||
c1d64700ff | |||
71844a106d | |||
d936b41483 | |||
816a8d88a7 | |||
9ef0771389 | |||
ec5b6a028b | |||
0244f6afd5 | |||
5575408560 | |||
92ae8e13f0 | |||
b0f24b25b9 | |||
c795c2b87b | |||
dbba53c0fa | |||
34d4da946b | |||
10ca532844 | |||
8c0d070b41 | |||
78b2740c58 | |||
0bd91c3c1f | |||
bc85efb3db | |||
1d7cce75d5 | |||
a18d5432b5 | |||
7d4de3f6a6 | |||
46dbe72253 | |||
b76d861ab6 | |||
c43bd5ea92 | |||
c9e30f76e6 | |||
5de3f8e9af | |||
3ba47c2d41 | |||
54fb77c8ae | |||
fb46a912f7 | |||
f960d8516a | |||
e63f0aead3 | |||
04e09dd800 | |||
c548bc3382 | |||
c8e1176d8d | |||
27affc5e73 | |||
bbfdb8c956 | |||
490eed2a24 | |||
a1b951845f | |||
dbf71f3f64 | |||
635fc4ec1d | |||
5c061b82f0 | |||
7f1a49ec91 | |||
2ea21e12b2 | |||
24ce8115e7 | |||
b9ecf9a3c6 | |||
740d47fb9d | |||
730bc0599f | |||
a567007463 | |||
1efa8b73ed | |||
83955866c9 | |||
22a6200ddb | |||
f0aa00b932 | |||
e0e5e314f1 | |||
d549393e42 | |||
099824bfed | |||
6311b998d4 | |||
1599d502c1 | |||
eb65071b51 | |||
0d8032da46 | |||
d84d985962 | |||
da4e97f1aa | |||
131ea35341 | |||
4a4c228984 | |||
eba2c7d18f | |||
141e0f3717 | |||
0590eaa1c5 | |||
65ec8c1db2 | |||
b8afed6e1b | |||
f0420e9bcd | |||
238705b00f | |||
020676f113 | |||
07b0199193 | |||
f53f30963d | |||
1d50bbe790 | |||
62f6e3b976 | |||
3251d9e983 | |||
2f96976f19 | |||
c33ff03bd8 | |||
9beb179cc0 | |||
9b14d7846b | |||
c7f6b3072d | |||
f1eaba016f | |||
eeadbc4f96 | |||
b659464118 | |||
8079058b5c | |||
f8fd13f8ed | |||
e0fa65fbd7 | |||
8587225dfc | |||
6ae8a40493 | |||
10bc3a3785 | |||
6ce4700420 | |||
1c6d288cd7 | |||
20b8b62b73 | |||
98ed4035a7 | |||
0bf81f93c8 | |||
64c2868fcf | |||
1990f1c7f0 | |||
29714b30ca | |||
8c510e6958 | |||
44d0f6fc3b | |||
fe61d71149 | |||
976ad683ca | |||
c2c8c82212 | |||
35429e57d2 | |||
9757f5f748 | |||
f1e2eca876 | |||
010774d775 | |||
437403aa9e | |||
0519bc979c | |||
3e0a1fe181 | |||
bd9b80076a | |||
58b3d18288 | |||
f9d85d45f5 | |||
d0722131df | |||
5e8e9943f9 | |||
0245145a64 | |||
698d7787e6 | |||
f0438ff822 | |||
d2851c6fb8 | |||
f709ac4b02 | |||
8322d2a7a8 | |||
1b38c5d86c | |||
7e4ca6990b | |||
3ed0f7b62a | |||
ddf5f3d0f7 | |||
7cc228968d | |||
c054c3b32e | |||
a741e3eb9a | |||
17eeebf1f3 | |||
aaf14e0c83 | |||
96baa42fe0 | |||
381da691a4 | |||
2be516236b | |||
dea283df27 | |||
dc3658d6de | |||
4faf2a0309 | |||
9f361be6c4 | |||
6ebe24fa5a | |||
10b817df2e | |||
2ea74277ab | |||
ae4b2c9dee | |||
d8aa7076c3 | |||
9673da35ce | |||
707b45e4ad | |||
59d2c1dbf9 | |||
a4f7366213 | |||
8c1e46900a | |||
cbfc1549ed | |||
5a058aa706 | |||
07d11ce3ff | |||
18bd1fb12d | |||
ca33de09cc | |||
88b5f21343 | |||
3d2b172deb | |||
2e6f9f27c4 | |||
855397ad0c | |||
8677af243c | |||
978a2602d8 | |||
61e2f6d8fd | |||
328dbc644e | |||
c3b3604a6f | |||
e3f555a01a | |||
758bc50e50 | |||
18b3ecd135 | |||
cbf00cc0f8 | |||
8a182a7fb0 | |||
c760b5ce99 | |||
d2df4b1bf7 | |||
fbcd656348 | |||
ef740eeeca | |||
bb2c7e4e6d | |||
89f7d0e49c | |||
f52d52b1b3 | |||
b86cd122b3 | |||
b02d89836f | |||
4b8ab610d5 | |||
fea96118bc | |||
ad5608375c | |||
4a620c30fa | |||
b1428812c8 | |||
60045d4ca2 | |||
7bb91cfcbe | |||
e8ad10a16d | |||
2cf6e1a6cb | |||
978b4c6454 | |||
a6f92b56da | |||
eccf0ff4e9 | |||
fd3775d5fb | |||
c020031127 | |||
cbf8eb8747 | |||
812d374354 | |||
39e872256b | |||
8385e36340 | |||
21f48e2366 | |||
8a5666743f | |||
790b08e22c | |||
28c2cf21a0 | |||
bc9437cd2c | |||
5e247f8d5c | |||
82e2720156 | |||
3120855858 | |||
696bcb9e89 | |||
865bdd7eca | |||
472a68768c | |||
3837ca9c56 | |||
347fb97cf2 | |||
c0018f3d50 | |||
b40942b5fb | |||
4b68b93e7b | |||
e082527edc | |||
9ac60e9f28 | |||
5c16576fa4 | |||
76b3e2e8ad | |||
60fae56a28 | |||
007691c534 | |||
e53fb45bd9 | |||
60f59f423d | |||
f7fcfc1ba0 | |||
f9b2afdd50 | |||
48bbd32eb8 | |||
075e171b40 | |||
7df94bef12 | |||
e402829724 | |||
8ab04a7906 | |||
681d1afc68 | |||
af0cbda364 | |||
3566e90b1a | |||
b874b533ca | |||
8d546917d3 | |||
dbaa813815 | |||
8d372645ce | |||
4bdbc28f43 | |||
949e96d195 | |||
8a9eead4c1 | |||
6c46d2cf5f | |||
64353210d7 | |||
4d93cb26a0 | |||
a31d93aa70 | |||
bb5ec9f791 | |||
9b7ea6984e | |||
83a0277d71 | |||
cfb9837974 | |||
5bf80666d6 | |||
dc52b7b3be | |||
ab0634ba17 | |||
53c28dc409 | |||
8acc18dfba | |||
bf79f77788 | |||
7a9c673721 | |||
1df162445e | |||
7518420289 | |||
a1bf9cf30a | |||
832387f663 | |||
4cc4d0138a | |||
877b5d4013 | |||
46b78b4667 | |||
64d2347eef | |||
b05b878910 | |||
38f04aa9f8 | |||
4512e6aa53 | |||
abf815ddf3 | |||
94e777bbd4 | |||
2c9117dbb6 | |||
205eb0ab76 | |||
12a7c7732b | |||
f05fe5d888 | |||
b4d38e6146 | |||
914784bb9f | |||
3ef213ab0d | |||
cac5751ede | |||
f6b9a511ae | |||
de4e5204e9 | |||
66df3e0ea8 | |||
17b5ab48ca | |||
8b439bbd5a | |||
84e25ae7a2 | |||
898642dfa5 | |||
1dd1ffd83e | |||
af5bafea5c | |||
f4bbfad9a9 | |||
3e513087ca | |||
95a6da8f5d | |||
47bc1a8e02 | |||
2987cf4a2a | |||
ca4391784a | |||
7a12928a56 | |||
15dc540c0e | |||
1517060296 | |||
d1253242d4 | |||
eb6b0c68b3 | |||
c02442ebcd | |||
c58ef0685a | |||
c7773874c5 | |||
38b427815f | |||
3ecd86db10 | |||
f477a3a829 | |||
e5558e3f89 | |||
eaea8df71e | |||
31a892e43d | |||
aac6d8e43a | |||
d1b1d2537f | |||
285b9f75e2 | |||
acd0087ddf | |||
4e16f2b3ed | |||
7f5a3bdb7f | |||
dd1469c957 | |||
8e0d5e4fb7 | |||
95e9d97a22 | |||
184561ab29 | |||
bdf778cc67 | |||
0fffbce646 | |||
2e60f2bb8a | |||
f24953ae7c | |||
3207395b67 | |||
1ab82003e6 | |||
1b91f70edd | |||
2b47748c30 | |||
e84a811a1d | |||
8f16b5d96f | |||
53d24c5781 | |||
63fcb36e60 | |||
341ea73e97 | |||
6b1f2e76e3 | |||
d48972caa0 | |||
6d4a276dd8 | |||
517dd0e534 | |||
1ebecc91d9 | |||
0409da3ca3 | |||
10048c671e | |||
e73a9a3b12 | |||
24b78e00ff | |||
ab19f045d2 | |||
028708e5aa | |||
db02047268 | |||
47e3ff37ba | |||
38f995302d | |||
71bbcd1edc | |||
e4ca8e424d | |||
6424702375 | |||
970fd2bb6a | |||
9a27077d55 |
4
.gitignore
vendored
@ -1,4 +1,6 @@
|
|||||||
bin/*
|
bin/*
|
||||||
!bin/.keep
|
!bin/.keep
|
||||||
.vscode
|
.vscode
|
||||||
*.pug.go
|
*.log
|
||||||
|
*_templ.go
|
||||||
|
/test
|
73
Makefile
@ -1,36 +1,63 @@
|
|||||||
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=/
|
|
||||||
|
|
||||||
LDFLAGS=-ldflags "-s -w -X main.version=23.8.0" -tags osusergo,netgo
|
DESTDIR ?=
|
||||||
|
PREFIX ?= /usr/local
|
||||||
|
|
||||||
all: ${TARGET}
|
VERSION ?= 24.38.0
|
||||||
|
|
||||||
.PHONY: ${TARGET} install uninstall
|
GOFLAGS := -buildmode=pie -modcacherw -mod=readonly -trimpath
|
||||||
|
LDFLAGS := -ldflags "-linkmode=external -extldflags \"${LDFLAGS}\" -s -w -X main.version=${VERSION}" -tags osusergo,netgo
|
||||||
|
|
||||||
${TARGET}:
|
.PHONY: run install uninstall clean
|
||||||
go generate web/web.go
|
|
||||||
go build -o bin/$@ ${LDFLAGS} cmd/$@/main.go
|
|
||||||
|
|
||||||
install-jade:
|
${TARGET}: web/*_templ.go
|
||||||
go install github.com/Joker/jade/cmd/jade@latest
|
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
|
||||||
|
|
||||||
install:
|
install:
|
||||||
install -Dm 0755 bin/${TARGET} ${DESTDIR}usr/bin/${TARGET}
|
install -Dm 0755 bin/${TARGET} ${DESTDIR}${PREFIX}/bin/${TARGET}
|
||||||
install -Dm 0755 tools/radioctl ${DESTDIR}usr/bin/${TARGET}ctl
|
install -Dm 0755 tools/radioctl ${DESTDIR}${PREFIX}/bin/${TARGET}ctl
|
||||||
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 0644 init/systemd.service ${DESTDIR}${SYSDDIR}/${TARGET}.service
|
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
|
||||||
|
|
||||||
uninstall:
|
uninstall:
|
||||||
rm ${DESTDIR}usr/bin/${TARGET}
|
rm ${DESTDIR}${PREFIX}/bin/${TARGET}
|
||||||
rm ${DESTDIR}usr/share/licenses/${TARGET}/LICENSE
|
rm ${DESTDIR}${PREFIX}/bin/${TARGET}ctl
|
||||||
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}.service
|
||||||
|
# rm ${DESTDIR}${SYSDDIR}/${TARGET}-liquidsoap.service
|
||||||
|
rm ${DESTDIR}${SYSDDIR}/${TARGET}-ezstream.service
|
||||||
|
|
||||||
|
clean:
|
||||||
|
rm -f web/*.jade.go
|
||||||
|
go clean
|
@ -1,33 +1,30 @@
|
|||||||
# Maintainer: Alexander "Arav" Andreev <me@arav.su>
|
# Maintainer: Alexander "Arav" Andreev <me@arav.su>
|
||||||
pkgname=dwelling-radio
|
pkgname=dwelling-radio
|
||||||
pkgver=23.8.0
|
pkgver=24.38.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')
|
||||||
url="https://git.arav.su/Arav/dwelling-radio"
|
url="https://git.arav.su/Arav/dwelling-radio"
|
||||||
license=('MIT')
|
license=('MIT')
|
||||||
groups=()
|
makedepends=('go>=1.17')
|
||||||
depends=()
|
optdepends=(
|
||||||
makedepends=('go')
|
'tree: to make a filelist html file'
|
||||||
provides=('dwelling-radio')
|
'ffmpeg: to convert media to ogg and get duration of songs')
|
||||||
conflicts=('dwelling-radio')
|
backup=('etc/dwelling/ezstream.xml')
|
||||||
replaces=()
|
source=("${pkgver}.tar.gz::https://git.arav.su/Arav/dwelling-radio/archive/v${pkgver}.tar.gz")
|
||||||
backup=('etc/dwelling/radio.yaml' 'etc/dwelling/radio.vars.liq')
|
|
||||||
options=()
|
|
||||||
install=
|
|
||||||
source=('https://git.arav.su/Arav/dwelling-radio/archive/23.8.0.tar.gz')
|
|
||||||
noextract=()
|
|
||||||
md5sums=('SKIP')
|
md5sums=('SKIP')
|
||||||
|
|
||||||
build() {
|
build() {
|
||||||
cd "$srcdir/$pkgname"
|
cd "$srcdir/$pkgname"
|
||||||
if [ ! -f "$(go env GOPATH)/bin/jade" ]; then
|
export GOPATH="$srcdir"/gopath
|
||||||
make DESTDIR="$pkgdir/" install-jade
|
export CGO_CPPFLAGS="${CPPFLAGS}"
|
||||||
fi
|
export CGO_CFLAGS="${CFLAGS}"
|
||||||
make DESTDIR="$pkgdir/"
|
export CGO_CXXFLAGS="${CXXFLAGS}"
|
||||||
|
export CGO_LDFLAGS="${LDFLAGS}"
|
||||||
|
make VERSION=$pkgver DESTDIR="$pkgdir" PREFIX="/usr"
|
||||||
}
|
}
|
||||||
|
|
||||||
package() {
|
package() {
|
||||||
cd "$srcdir/$pkgname"
|
cd "$srcdir/$pkgname"
|
||||||
make DESTDIR="$pkgdir/" install
|
make DESTDIR="$pkgdir" PREFIX="/usr" install
|
||||||
}
|
}
|
||||||
|
@ -1,83 +1,159 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"dwelling-radio/internal/configuration"
|
ihttp "dwelling-radio/internal/http"
|
||||||
"dwelling-radio/internal/http"
|
|
||||||
"dwelling-radio/internal/radio"
|
"dwelling-radio/internal/radio"
|
||||||
"errors"
|
sqlite_stats "dwelling-radio/internal/statistics/db/sqlite"
|
||||||
|
"dwelling-radio/pkg/utils"
|
||||||
|
"dwelling-radio/web"
|
||||||
|
"dwelling-radio/web/locales"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
|
"path"
|
||||||
"syscall"
|
"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 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() {
|
func main() {
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
log.SetFlags(log.Lshortfile)
|
||||||
|
|
||||||
if *showVersion {
|
if *showVersion {
|
||||||
fmt.Println("dwelling-radio ver.", version, "\nCopyright (c) 2022,2023 Alexander \"Arav\" Andreev <me@arav.su>")
|
fmt.Println("dwelling-radio ver.", version, "\nCopyright (c) 2022-2024 Alexander \"Arav\" Andreev <me@arav.su>")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
config, err := configuration.Load(*configPath)
|
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)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalln(err)
|
log.Fatalln(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if typ, addr := config.SplitNetworkAddress(); typ == "unix" {
|
if err := ctxi18n.LoadWithDefault(locales.Content, "en"); err != nil {
|
||||||
defer os.Remove(addr)
|
|
||||||
}
|
|
||||||
|
|
||||||
playlistWatcher := radio.NewPlaylistLogWatcher()
|
|
||||||
if err := playlistWatcher.Watch(config.Icecast.Playlist, config.ListLastNSongs); err != nil {
|
|
||||||
log.Fatalln(err)
|
log.Fatalln(err)
|
||||||
}
|
}
|
||||||
defer playlistWatcher.Close()
|
|
||||||
|
|
||||||
hand := http.NewHandlers(config)
|
r := httpr.New()
|
||||||
srv := http.NewHttpServer()
|
|
||||||
|
|
||||||
srv.ServeStatic("/assets/*filepath", hand.AssetsFS())
|
r.Handler(http.MethodGet, "/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
srv.GET("/", hand.Index)
|
lst, err := stats.LastNSongs(*songListLen)
|
||||||
srv.GET("/status", hand.Status)
|
|
||||||
srv.GET("/lastsong", hand.LastSong)
|
|
||||||
srv.GET("/playlist", hand.Playlist)
|
|
||||||
srv.GET("/filelist", hand.Filelist)
|
|
||||||
srv.GET("/robots.txt", hand.RobotsTxt)
|
|
||||||
|
|
||||||
if !*noLiquidsoap {
|
|
||||||
liquid, err := radio.NewLiquidsoap(config.Liquidsoap.ExecPath, config.Liquidsoap.ScriptPath)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalln("liquidsoap:", err)
|
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 {
|
||||||
|
log.Fatalln(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
defer func() {
|
defer func() {
|
||||||
if err := liquid.Stop(); err != nil {
|
|
||||||
if !errors.Is(err, radio.ErrLiquidsoapNotRunning) {
|
|
||||||
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 {
|
if err := srv.Stop(); err != nil {
|
||||||
log.Fatalln(err)
|
log.Fatalln(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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
case syscall.SIGHUP:
|
||||||
|
plylst.Lock()
|
||||||
|
defer plylst.Unlock()
|
||||||
|
if err := plylst.Reload(); err != nil {
|
||||||
|
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,16 +0,0 @@
|
|||||||
# 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"
|
|
||||||
filelist_path: "/srv/radio/filelist.html"
|
|
||||||
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"
|
|
46
configs/ezstream.xml
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
<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>
|
82
configs/icecast.xml
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
<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,5 +1,6 @@
|
|||||||
/var/log/dwelling-radio/*log {
|
/var/log/dwelling-radio/*log {
|
||||||
nocreate
|
nocreate
|
||||||
|
copytruncate
|
||||||
missingok
|
missingok
|
||||||
notifempty
|
notifempty
|
||||||
size 10M
|
size 10M
|
||||||
@ -8,8 +9,4 @@
|
|||||||
compressext .zst
|
compressext .zst
|
||||||
compressoptions -T0 --long -15
|
compressoptions -T0 --long -15
|
||||||
uncompresscmd /usr/bin/unzstd
|
uncompresscmd /usr/bin/unzstd
|
||||||
sharedscripts
|
|
||||||
postrotate
|
|
||||||
/bin/pkill -HUP dwelling-radio
|
|
||||||
endscript
|
|
||||||
}
|
}
|
@ -1,10 +1,11 @@
|
|||||||
server {
|
server {
|
||||||
listen 443 ssl http2;
|
listen 443 ssl;
|
||||||
# listen 8090; # Tor
|
listen 8091; # Tor I2P
|
||||||
listen 127.0.0.1:8111; # I2P
|
|
||||||
listen [300:a98d:d6d0:8a08::e]:80; # Yggdrasil
|
listen [300:a98d:d6d0:8a08::e]:80; # Yggdrasil
|
||||||
|
|
||||||
server_name radio.arav.su radio.arav.i2p mkgnmhmzqm7kyzv7jnzzafvgm7xlmlfvzhgorpapd5or2arnhuktqd.onion;
|
http2 on;
|
||||||
|
|
||||||
|
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,7 +17,7 @@ server {
|
|||||||
add_header X-Content-Type-Options "nosniff";
|
add_header X-Content-Type-Options "nosniff";
|
||||||
add_header X-XSS-Protection "1; mode=block";
|
add_header X-XSS-Protection "1; mode=block";
|
||||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload";
|
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload";
|
||||||
# add_header Onion-Location "http://mkgnmhmzqm7kyzv7jnzzafvgm7xlmlfvzhgorpapd5or2arnhuktqd.onion$request_uri";
|
add_header Onion-Location "http://wsmkgnmhmzqm7kyzv7jnzzafvgm7xlmlfvzhgorpapd5or2arnhuktqd.onion$request_uri";
|
||||||
|
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
@ -28,6 +29,7 @@ server {
|
|||||||
|
|
||||||
location /live/ {
|
location /live/ {
|
||||||
proxy_pass http://127.0.0.1:8000/;
|
proxy_pass http://127.0.0.1:8000/;
|
||||||
|
proxy_bind $remote_addr transparent;
|
||||||
proxy_buffering off;
|
proxy_buffering off;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
}
|
}
|
||||||
@ -35,10 +37,22 @@ server {
|
|||||||
location /live/admin/ {
|
location /live/admin/ {
|
||||||
deny all;
|
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 {
|
server {
|
||||||
listen 8000;
|
listen 192.168.144.2:8000;
|
||||||
|
|
||||||
server_name radio.arav.su;
|
server_name radio.arav.su;
|
||||||
|
|
||||||
@ -48,12 +62,14 @@ server {
|
|||||||
add_header X-Frame-Options "DENY";
|
add_header X-Frame-Options "DENY";
|
||||||
add_header X-Content-Type-Options "nosniff";
|
add_header X-Content-Type-Options "nosniff";
|
||||||
add_header X-XSS-Protection "1; mode=block";
|
add_header X-XSS-Protection "1; mode=block";
|
||||||
# add_header Onion-Location "http://mkgnmhmzqm7kyzv7jnzzafvgm7xlmlfvzhgorpapd5or2arnhuktqd.onion/live$request_uri";
|
add_header Onion-Location "http://wsmkgnmhmzqm7kyzv7jnzzafvgm7xlmlfvzhgorpapd5or2arnhuktqd.onion/live$request_uri";
|
||||||
|
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://127.0.0.1:8000/;
|
proxy_pass http://127.0.0.1:8000/;
|
||||||
|
proxy_bind $remote_addr transparent;
|
||||||
proxy_buffering off;
|
proxy_buffering off;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /admin/ {
|
location /admin/ {
|
||||||
|
3
configs/override.icecast.service
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
[Unit]
|
||||||
|
Requires=dwelling-radio.service
|
||||||
|
After=dwelling-radio.service
|
@ -10,8 +10,8 @@ end
|
|||||||
def xfade(a, b)
|
def xfade(a, b)
|
||||||
add(normalize = false,
|
add(normalize = false,
|
||||||
[ sequence([ blank(duration = 2.),
|
[ sequence([ blank(duration = 2.),
|
||||||
fade.initial(duration = 2., b)]),
|
fade.in(duration = 2., b)]),
|
||||||
fade.final(duration = 2., a)])
|
fade.out(duration = 2., a)])
|
||||||
end
|
end
|
||||||
|
|
||||||
def fallback_alter_title(m) =
|
def fallback_alter_title(m) =
|
||||||
@ -19,12 +19,14 @@ def fallback_alter_title(m) =
|
|||||||
("title", string.concat(["No stream. (Playing ", m["artist"], " - ", m["title"], ")"]))]
|
("title", string.concat(["No stream. (Playing ", m["artist"], " - ", m["title"], ")"]))]
|
||||||
end
|
end
|
||||||
|
|
||||||
settings.server.telnet.set(false)
|
settings.server.telnet := false
|
||||||
settings.harbor.bind_addrs.set(["0.0.0.0"])
|
settings.harbor.bind_addrs := ["0.0.0.0"]
|
||||||
settings.log.level.set(2)
|
settings.log.level := 1
|
||||||
settings.log.file.set(true)
|
settings.log.file := true
|
||||||
settings.log.file.path.set(log_file_path)
|
settings.log.file.path := log_file_path
|
||||||
settings.log.stdout.set(false)
|
settings.log.stdout := false
|
||||||
|
|
||||||
|
enable_replaygain_metadata()
|
||||||
|
|
||||||
fallback_song = mksafe(single(fullpath("fallback.ogg")))
|
fallback_song = mksafe(single(fullpath("fallback.ogg")))
|
||||||
fallback_song = metadata.map(fallback_alter_title, fallback_song)
|
fallback_song = metadata.map(fallback_alter_title, fallback_song)
|
||||||
@ -34,17 +36,18 @@ 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)
|
live_show = metadata.map(fun (_) -> [("artist", radio_name), ("title", "Live Show")], live_show)
|
||||||
|
|
||||||
playlist_random = playlist(fullpath("playlists/all-rand"),
|
playlist_random = playlist(fullpath("playlists/all-rand"),
|
||||||
prefix = fullpath("music/"),
|
prefix = string.concat(["replaygain:", fullpath("music/")]),
|
||||||
mode = "normal", reload_mode = "watch")
|
mode = "normal", reload_mode = "watch")
|
||||||
|
|
||||||
music = audio_to_stereo(playlist_random)
|
music = audio_to_stereo(playlist_random)
|
||||||
|
music = replaygain(music)
|
||||||
|
|
||||||
radio = smooth_add(p = 0.18, normal = music, special = live_mixin)
|
radio = smooth_add(p = 0.18, normal = music, special = live_mixin)
|
||||||
radio = fallback(track_sensitive = false,
|
radio = fallback(track_sensitive = false,
|
||||||
transitions = [xfade, xfade, xfade],
|
transitions = [xfade, xfade, xfade],
|
||||||
[ blank.strip(max_blank=15., live_show),
|
[ blank.strip(max_blank=15., live_show),
|
||||||
blank.strip(max_blank=90., radio), fallback_song])
|
blank.strip(max_blank=90., radio), fallback_song])
|
||||||
radio = normalize(radio)
|
radio = normalize(target = -12., threshold = -40., lufs = true, radio)
|
||||||
|
|
||||||
output.icecast(%vorbis.cbr(bitrate = 128, samplerate = 44100, channels = 2),
|
output.icecast(%vorbis.cbr(bitrate = 128, samplerate = 44100, channels = 2),
|
||||||
host = icecast_host, port = icecast_port,
|
host = icecast_host, port = icecast_port,
|
||||||
|
@ -6,13 +6,13 @@ radio_url = "https://radio.arav.su"
|
|||||||
radio_name = "Arav's dwelling / Radio"
|
radio_name = "Arav's dwelling / Radio"
|
||||||
radio_desc = "Broadcasting from under my desk."
|
radio_desc = "Broadcasting from under my desk."
|
||||||
|
|
||||||
radio_dir = "/srv/radio/"
|
radio_dir = "/mnt/data/appdata/radio/"
|
||||||
|
|
||||||
harbor_port = 8002
|
harbor_port = 8002
|
||||||
harbor_password = ""
|
harbor_password = ""
|
||||||
|
|
||||||
icecast_host = "127.0.0.1"
|
icecast_host = "127.0.0.1"
|
||||||
icecast_port = 8000
|
icecast_port = 8001
|
||||||
icecast_password = ""
|
icecast_password = ""
|
||||||
icecast_mount = "stream.ogg"
|
icecast_mount = "stream.ogg"
|
||||||
icecast_genre = "Various"
|
icecast_genre = "Various"
|
20
go.mod
@ -1,9 +1,21 @@
|
|||||||
module dwelling-radio
|
module dwelling-radio
|
||||||
|
|
||||||
go 1.20
|
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
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/julienschmidt/httprouter v1.3.0
|
github.com/invopop/ctxi18n v0.8.1
|
||||||
github.com/pkg/errors v0.9.1
|
github.com/mattn/go-sqlite3 v1.14.23
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/invopop/yaml v0.3.1 // indirect
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
20
go.sum
@ -1,7 +1,23 @@
|
|||||||
github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U=
|
git.arav.su/Arav/httpr v0.3.2 h1:a+ifu+9+FnQe6p/Kd4kgTDKAFN6zBOJjBTMjbAuHxVk=
|
||||||
github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
|
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 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
55
init/ezstream.service
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
[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
|
54
init/liquidsoap.service
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
[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,9 +7,13 @@ After=network-online.target icecast.service
|
|||||||
Type=simple
|
Type=simple
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
DynamicUser=yes
|
DynamicUser=yes
|
||||||
ExecStart=/usr/bin/dwelling-radio -conf /etc/dwelling/radio.yaml
|
ExecStart=/usr/bin/dwelling-radio -listen /var/run/dwelling-radio/sock \
|
||||||
|
-work-dir /mnt/data/appdata/radio \
|
||||||
|
-playlist all-rand \
|
||||||
|
-lst-len 10
|
||||||
|
|
||||||
ReadOnlyPaths=/
|
ReadOnlyPaths=/
|
||||||
|
ReadWritePaths=/mnt/data/appdata/radio
|
||||||
|
|
||||||
LogsDirectory=dwelling-radio
|
LogsDirectory=dwelling-radio
|
||||||
RuntimeDirectory=dwelling-radio
|
RuntimeDirectory=dwelling-radio
|
||||||
@ -21,18 +25,33 @@ LockPersonality=true
|
|||||||
MemoryDenyWriteExecute=true
|
MemoryDenyWriteExecute=true
|
||||||
NoNewPrivileges=true
|
NoNewPrivileges=true
|
||||||
PrivateDevices=true
|
PrivateDevices=true
|
||||||
|
PrivateTmp=true
|
||||||
|
PrivateUsers=true
|
||||||
|
ProcSubset=pid
|
||||||
ProtectClock=true
|
ProtectClock=true
|
||||||
ProtectControlGroups=true
|
ProtectControlGroups=true
|
||||||
ProtectHome=true
|
ProtectHome=true
|
||||||
|
ProtectHostname=true
|
||||||
ProtectKernelLogs=true
|
ProtectKernelLogs=true
|
||||||
ProtectKernelModules=true
|
ProtectKernelModules=true
|
||||||
ProtectKernelTunables=true
|
ProtectKernelTunables=true
|
||||||
|
ProtectProc=noaccess
|
||||||
ProtectSystem=strict
|
ProtectSystem=strict
|
||||||
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
|
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
|
||||||
RestrictNamespaces=true
|
RestrictNamespaces=true
|
||||||
RestrictRealtime=true
|
RestrictRealtime=true
|
||||||
RestrictSUIDSGID=true
|
RestrictSUIDSGID=true
|
||||||
SystemCallArchitectures=native
|
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]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
@ -1,50 +0,0 @@
|
|||||||
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"`
|
|
||||||
FilelistPath string `yaml:"filelist_path"`
|
|
||||||
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]
|
|
||||||
}
|
|
156
internal/http/dj_handlers.go
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
@ -1,112 +0,0 @@
|
|||||||
package http
|
|
||||||
|
|
||||||
import (
|
|
||||||
"dwelling-radio/internal/configuration"
|
|
||||||
"dwelling-radio/internal/radio"
|
|
||||||
"dwelling-radio/pkg/utils"
|
|
||||||
"dwelling-radio/web"
|
|
||||||
"encoding/json"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"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.su.m3u\"")
|
|
||||||
fc, _ := web.AssetsGetFile("radio.arav.su.m3u")
|
|
||||||
w.Write(fc)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handlers) Filelist(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
w.Header().Add("Content-Type", "text/html")
|
|
||||||
data, _ := os.ReadFile(h.conf.FilelistPath)
|
|
||||||
w.Write(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handlers) RobotsTxt(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
w.Header().Add("Content-Disposition", "attachment; filename=\"robots.txt\"")
|
|
||||||
w.Write([]byte("User-agent: *\nDisallow: /assets/\nDisallow: /live/"))
|
|
||||||
}
|
|
@ -1,76 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
76
internal/http/server.go
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
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
|
||||||
|
}
|
@ -1,225 +0,0 @@
|
|||||||
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()
|
|
||||||
}
|
|
@ -1,54 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
93
internal/radio/listener_counter.go
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
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})
|
||||||
|
}
|
64
internal/radio/playlist.go
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
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()
|
||||||
|
}
|
40
internal/radio/song.go
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
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)})
|
||||||
|
}
|
90
internal/statistics/db/sqlite/db.go
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
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()
|
||||||
|
}
|
3
internal/statistics/db/sqlite/queries/history_add.sql
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
INSERT OR IGNORE INTO `history`
|
||||||
|
(`start_at`, `song_id`, `listeners`, `peak_listeners`)
|
||||||
|
VALUES (?,?,?,?);
|
19
internal/statistics/db/sqlite/queries/last_n_songs.sql
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
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;
|
10
internal/statistics/db/sqlite/queries/most_popular_songs.sql
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
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 ?;
|
@ -0,0 +1,8 @@
|
|||||||
|
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`;
|
15
internal/statistics/db/sqlite/queries/schema.sql
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
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 );
|
6
internal/statistics/db/sqlite/queries/song_add.sql
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
INSERT INTO `song`
|
||||||
|
(`artist`, `title`)
|
||||||
|
VALUES (?, ?)
|
||||||
|
ON CONFLICT DO
|
||||||
|
UPDATE SET `song_id`=`song_id`
|
||||||
|
RETURNING `song_id`;
|
138
internal/statistics/statistics.go
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
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()
|
||||||
|
}
|
106
pkg/oggtag/oggtag.go
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
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
|
||||||
|
}
|
57
pkg/oggtag/oggtag_test.go
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
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())
|
||||||
|
}
|
@ -19,6 +19,13 @@ 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.
|
||||||
|
@ -1,85 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
9
tools/radio-fetch
Executable file
@ -0,0 +1,9 @@
|
|||||||
|
#!/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,17 +1,22 @@
|
|||||||
#!/usr/bin/sh
|
#!/usr/bin/sh
|
||||||
|
|
||||||
radio_dir=/srv/radio
|
radio_dir=/mnt/data/appdata/radio
|
||||||
|
|
||||||
case $1 in
|
case $1 in
|
||||||
f | filelist)
|
f | filelist)
|
||||||
tree -H '' -T "Arav's dwelling / Radio / List" \
|
tree -H '' -T "List – Arav's dwelling / Radio" \
|
||||||
-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
|
||||||
;;
|
;;
|
||||||
p | playlist)
|
p | playlist)
|
||||||
find -L $radio_dir/music/* -type f -iname '*.ogg' |
|
find -L $radio_dir/music/* -type f -iname '*.ogg' |
|
||||||
cut -c 18- | sort -d > $radio_dir/playlists/all
|
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
|
break
|
||||||
;;
|
;;
|
||||||
s | shuffle)
|
s | shuffle)
|
||||||
@ -23,18 +28,39 @@ case $1 in
|
|||||||
if [[ "$file" == *.ogg ]]; then
|
if [[ "$file" == *.ogg ]]; then
|
||||||
continue;
|
continue;
|
||||||
fi
|
fi
|
||||||
|
if [ -f "${file%.*}.ogg" ]; then
|
||||||
|
continue;
|
||||||
|
fi
|
||||||
ffmpeg -hide_banner -i "$file" -y -vn -c:a libvorbis -b:a 128k "${file%.*}.ogg";
|
ffmpeg -hide_banner -i "$file" -y -vn -c:a libvorbis -b:a 128k "${file%.*}.ogg";
|
||||||
if [ $? -eq 0 ] && [ $2 = "del" ]; then
|
if [ $? -eq 0 ] && [ $3 = "del" ]; then
|
||||||
rm "$file";
|
rm "$file";
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
break
|
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 "f|ilelist - to generate a filelist.html"
|
||||||
echo "p|laylist - to generate a playlist 'all'"
|
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 "s|huffle - to shuffle a playlist and store as all-rand"
|
||||||
echo "c|onvert DIR - convert all files in DIR to ogg"
|
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
|
exit
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
@ -11,6 +11,7 @@
|
|||||||
--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) {
|
||||||
@ -26,13 +27,16 @@
|
|||||||
background-color: var(--secondary-color);
|
background-color: var(--secondary-color);
|
||||||
color: var(--background-color); }
|
color: var(--background-color); }
|
||||||
|
|
||||||
|
.small { font-size: .8rem; }
|
||||||
|
|
||||||
|
.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;
|
||||||
@ -40,9 +44,7 @@ button:hover {
|
|||||||
|
|
||||||
button {
|
button {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none; }
|
||||||
font: inherit;
|
|
||||||
padding: 0; }
|
|
||||||
|
|
||||||
p {
|
p {
|
||||||
text-align: justify;
|
text-align: justify;
|
||||||
@ -62,24 +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);
|
||||||
box-shadow: 5px 5px var(--primary-color);
|
|
||||||
width: 100%; }
|
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%); }
|
html { margin-left: calc(100vw - 100%); }
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@ -96,44 +84,73 @@ header {
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
justify-content: space-between; }
|
justify-content: space-between; }
|
||||||
|
|
||||||
#logo {
|
header svg text { fill: var(--text-color); }
|
||||||
display: block;
|
|
||||||
width: 360px; }
|
|
||||||
|
|
||||||
#logo text { fill: var(--text-color); }
|
header svg text:first-child {
|
||||||
|
font-size: 3.55rem;
|
||||||
#logo .logo {
|
|
||||||
font-size: 2rem;
|
|
||||||
font-variant-caps: small-caps;
|
font-variant-caps: small-caps;
|
||||||
font-weight: bold; }
|
font-weight: bold; }
|
||||||
|
|
||||||
@media screen and (-webkit-min-device-pixel-ratio:0) {
|
header svg text:last-child { font-size: 1.5rem; }
|
||||||
#logo .logo { font-size: 2.082rem; } }
|
|
||||||
|
|
||||||
@-moz-document url-prefix() {
|
@supports (-moz-appearance:none) {
|
||||||
#logo .logo { font-size: 2rem; } }
|
header svg text:last-child { transform: scale(.993, 1); } }
|
||||||
|
|
||||||
#logo .under { font-size: .88rem; }
|
header nav {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
font-variant: small-caps;
|
||||||
|
justify-content: space-evenly; }
|
||||||
|
|
||||||
nav { margin-top: .5rem; }
|
header nav h1 {
|
||||||
|
|
||||||
nav a { font-variant: small-caps; }
|
|
||||||
|
|
||||||
nav h1 {
|
|
||||||
color: var(--secondary-color);
|
color: var(--secondary-color);
|
||||||
margin: 0; }
|
margin: 0; }
|
||||||
|
|
||||||
section { margin-top: 1rem; }
|
section { margin-top: 1rem; }
|
||||||
|
|
||||||
#last-played {
|
#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 {
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
min-width: 80%;
|
min-width: 80%;
|
||||||
width: 80%; }
|
width: 80%; }
|
||||||
|
|
||||||
#last-played tbody tr {
|
#last-songs :is(thead tr, tbody tr) {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: .5rem;
|
gap: .5rem;
|
||||||
grid-template-columns: 3rem 2rem 1fr; }
|
grid-template-columns: 3rem 3rem 1fr; }
|
||||||
|
|
||||||
|
#last-songs thead tr { font-weight: bold; }
|
||||||
|
|
||||||
footer {
|
footer {
|
||||||
font-size: .8rem;
|
font-size: .8rem;
|
||||||
@ -141,12 +158,10 @@ footer {
|
|||||||
padding: 1rem 0; }
|
padding: 1rem 0; }
|
||||||
|
|
||||||
@media screen and (max-width: 640px) {
|
@media screen and (max-width: 640px) {
|
||||||
header { display: block; }
|
header {
|
||||||
|
align-items: center;
|
||||||
|
flex-direction: column; }
|
||||||
|
|
||||||
#logo {
|
header svg { width: 100%; }
|
||||||
margin: 0 auto;
|
|
||||||
width: 100%; }
|
|
||||||
|
|
||||||
nav {
|
#player { flex-direction: column; } }
|
||||||
width: 100%;
|
|
||||||
text-align: center; } }
|
|
1
web/assets/img/duration.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<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>
|
After Width: | Height: | Size: 564 B |
@ -1,2 +1 @@
|
|||||||
<?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.3 KiB After Width: | Height: | Size: 1.2 KiB |
1
web/assets/img/headphones.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<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>
|
After Width: | Height: | Size: 779 B |
1
web/assets/img/note.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<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>
|
After Width: | Height: | Size: 1.0 KiB |
1
web/assets/img/play.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<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>
|
After Width: | Height: | Size: 220 B |
1
web/assets/img/stop.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<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>
|
After Width: | Height: | Size: 169 B |
BIN
web/assets/img/stopit.mp4
Executable file
@ -1,42 +1,92 @@
|
|||||||
function $(id) { return document.getElementById(id); }
|
const $ = id => document.getElementById(id);
|
||||||
|
|
||||||
function updateRadioStatus() {
|
const formatDuration = date => `${date.getUTCHours() > 0 ? date.getUTCHours() + ":" : ""}${date.getUTCMinutes()}:${date.getUTCSeconds().toString().padStart(2, "0")}`;
|
||||||
fetch("/status")
|
const formatStartAt = date => `${date.getHours().toString().padStart(2, "0")}:${date.getMinutes().toString().padStart(2, "0")}`;
|
||||||
.then(r => r.json())
|
|
||||||
.then(r => {
|
let cursong_startat = null;
|
||||||
$("radio-status").innerHTML =
|
let cursong_duration_msec = 0;
|
||||||
`On-air since <time datetime="${r.server_start_iso8601}">${r.server_start_date}</time>`;
|
|
||||||
$("radio-song").textContent = r.song;
|
async function updateStatus() {
|
||||||
$("radio-listeners").textContent = r.listeners;
|
const resp = await fetch("/api/status");
|
||||||
$("radio-listener-peak").textContent = r.listener_peak;
|
|
||||||
}).catch(() => {
|
if (!resp.ok || 200 != resp.status) {
|
||||||
$("radio-status").textContent = "Radio is offline.";
|
$("radio-song").textContent =
|
||||||
$("radio-song").textContent = "";
|
$("radio-duration-estimate").textContent =
|
||||||
$("radio-listeners").textContent =
|
$("radio-duration").textContent = "";
|
||||||
$("radio-listener-peak").textContent = "0";
|
$("radio-listeners").textContent = "0";
|
||||||
});
|
$("last-songs").lastChild.remove();
|
||||||
|
return [-1, null];
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateLastPlayedSong() {
|
const s = await resp.json();
|
||||||
fetch('/lastsong')
|
|
||||||
.then(r => r.json())
|
|
||||||
.then(last_played => {
|
|
||||||
if (last_played.time == $('last-played').firstChild.lastChild.firstChild.innerText)
|
|
||||||
return;
|
|
||||||
|
|
||||||
$('last-played').firstChild.firstChild.remove();
|
if (undefined != s.last_songs) {
|
||||||
|
$("last-songs").lastChild.remove();
|
||||||
let row = $('last-played').insertRow();
|
$("last-songs").appendChild(document.createElement("tbody"));
|
||||||
row.insertCell().appendChild(document.createTextNode(last_played.time));
|
for (let i = 0; i < s.last_songs.length; ++i) {
|
||||||
row.insertCell().appendChild(document.createTextNode(last_played.listeners == 0 ? "" : last_played.listeners));
|
let row = $("last-songs").lastChild.insertRow();
|
||||||
row.insertCell().appendChild(document.createTextNode(last_played.song));
|
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}`));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById("btn-update").addEventListener("click", () => {
|
if (undefined == s.current_song || undefined == s.current_song.duration_msec)
|
||||||
updateLastPlayedSong();
|
return [-1, null];
|
||||||
updateRadioStatus();
|
|
||||||
})
|
|
||||||
|
|
||||||
setInterval(updateRadioStatus, 45000);
|
$("radio-song").textContent = `${s.current_song.artist} - ${s.current_song.title}`;
|
||||||
setInterval(updateLastPlayedSong, 45000);
|
$("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)];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function update() {
|
||||||
|
if (null === cursong_startat)
|
||||||
|
return -1;
|
||||||
|
|
||||||
|
const estimate = (new Date()) - (new Date(cursong_startat));
|
||||||
|
if (estimate >= cursong_duration_msec) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
$("radio-duration-estimate").textContent = `${formatDuration(new Date(estimate))} / `;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)"; });
|
||||||
|
3
web/assets/robots.txt
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
User-agent: *
|
||||||
|
Disallow: /assets/
|
||||||
|
Disallow: /live/
|
15
web/assets/sitemap.xml
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
<?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
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
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
@ -1,141 +0,0 @@
|
|||||||
// 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")
|
|
||||||
}
|
|
19
web/locales/en/en.yaml
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
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
|
7
web/locales/locales.go
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
package locales
|
||||||
|
|
||||||
|
import "embed"
|
||||||
|
|
||||||
|
//go:embed en
|
||||||
|
//go:embed ru
|
||||||
|
var Content embed.FS
|
19
web/locales/ru/ru.yaml
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
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: О приватности
|
@ -1,63 +0,0 @@
|
|||||||
: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.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
|
|
||||||
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.su') me@arav.su]>
|
|
22
web/web.go
@ -6,9 +6,6 @@ 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 -stdbuf -stdlib -writer templates/index.pug
|
|
||||||
|
|
||||||
//go:embed assets
|
//go:embed assets
|
||||||
var assetsDir embed.FS
|
var assetsDir embed.FS
|
||||||
|
|
||||||
@ -17,6 +14,21 @@ func Assets() http.FileSystem {
|
|||||||
return http.FS(f)
|
return http.FS(f)
|
||||||
}
|
}
|
||||||
|
|
||||||
func AssetsGetFile(path string) ([]byte, error) {
|
func ServeAsset(path, mime, attachement string) func(http.ResponseWriter, *http.Request) {
|
||||||
return assetsDir.ReadFile("assets/" + path)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|