Compare commits
457 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 | |||
0db75f30a2 | |||
83cdc222c7 | |||
edfcb9526b | |||
0339ce27f6 | |||
4ef674dcb7 | |||
297b8d5985 | |||
77183bb14e | |||
e58b44191d | |||
3dc2380e09 | |||
7ffc108b11 | |||
aa0d818488 | |||
182a4c02c9 | |||
cac1ad56fb | |||
90d683f424 | |||
4c74b45c58 | |||
44080a8a66 | |||
fb6ab57a29 | |||
c7894bd472 | |||
bd35189caa | |||
6d26d695ec | |||
ac3e2b8e92 | |||
97294bd9f0 | |||
dde372fd5d | |||
39486e7cf0 | |||
9e72a59fba | |||
c18c52e839 | |||
c1296cb7c5 | |||
ca8613745f | |||
3eb739bc28 | |||
dc8ec51530 | |||
bde8a59a31 | |||
37643156fe | |||
7c5e1465af | |||
91329ceaec | |||
af40ccfd2c | |||
0ff8f4310e | |||
192fa0d58f | |||
98786e6964 | |||
0bb2cf9a08 | |||
8db8086c58 | |||
62aef657a8 | |||
7e83be82cb | |||
52e5f1be55 | |||
f631fdfa7c | |||
3727f44381 | |||
a7d91dc92e | |||
e8e3570a8f | |||
73fc9861fd | |||
fb891c5603 | |||
025bc6509c | |||
3e39839df3 | |||
98b0a8085a |
3
.gitignore
vendored
@ -1,3 +1,6 @@
|
||||
bin/*
|
||||
!bin/.keep
|
||||
.vscode
|
||||
*.log
|
||||
*_templ.go
|
||||
/test
|
2
LICENSE
@ -1,4 +1,4 @@
|
||||
Copyright (c) 2022 Alexander "Arav" Andreev <me@arav.top>
|
||||
Copyright (c) 2022,2023 Alexander "Arav" Andreev <me@arav.su>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
|
68
Makefile
@ -1,37 +1,63 @@
|
||||
TARGET=dwelling-radio
|
||||
TARGET := dwelling-radio
|
||||
|
||||
SYSDDIR_=${shell pkg-config systemd --variable=systemdsystemunitdir}
|
||||
SYSDDIR=${SYSDDIR_:/%=%}
|
||||
DESTDIR=/
|
||||
SYSDDIR_ := ${shell pkg-config systemd --variable=systemdsystemunitdir}
|
||||
SYSDDIR := ${SYSDDIR_:/%=%}
|
||||
|
||||
LDFLAGS=-ldflags "-s -w -X main.version=22.35.0" -tags osusergo,netgo
|
||||
DESTDIR ?=
|
||||
PREFIX ?= /usr/local
|
||||
|
||||
all: ${TARGET}
|
||||
VERSION ?= 24.38.0
|
||||
|
||||
.PHONY: ${TARGET} run install uninstall clean
|
||||
GOFLAGS := -buildmode=pie -modcacherw -mod=readonly -trimpath
|
||||
LDFLAGS := -ldflags "-linkmode=external -extldflags \"${LDFLAGS}\" -s -w -X main.version=${VERSION}" -tags osusergo,netgo
|
||||
|
||||
${TARGET}:
|
||||
go build -o bin/$@ ${LDFLAGS} cmd/$@/main.go
|
||||
.PHONY: run install uninstall clean
|
||||
|
||||
run:
|
||||
bin/${TARGET} -conf configs/config.yaml
|
||||
${TARGET}: web/*_templ.go
|
||||
go build -o bin/$@ ${LDFLAGS} ${GOFLAGS} cmd/$@/main.go
|
||||
|
||||
web/*_templ.go: web/*.templ
|
||||
ifeq (,$(wildcard $(shell go env GOPATH)/bin/templ))
|
||||
go install github.com/a-h/templ/cmd/templ@latest
|
||||
endif
|
||||
$(shell go env GOPATH)/bin/templ generate
|
||||
|
||||
run: | ${TARGET}
|
||||
bin/dwelling-radio -listen 127.0.0.1:18322 \
|
||||
-work-dir test \
|
||||
-playlist test
|
||||
|
||||
install:
|
||||
install -Dm 0755 bin/${TARGET} ${DESTDIR}usr/bin/${TARGET}
|
||||
install -Dm 0644 configs/config.yaml ${DESTDIR}etc/dwelling/radio.yaml
|
||||
install -Dm 0644 configs/radio.liq ${DESTDIR}etc/dwelling/radio.liq
|
||||
install -Dm 0644 configs/radio.vars.liq ${DESTDIR}etc/dwelling/radio.vars.liq
|
||||
install -Dm 0644 LICENSE ${DESTDIR}usr/share/licenses/${TARGET}/LICENSE
|
||||
install -Dm 0644 configs/logrotate ${DESTDIR}etc/logrotate.d/${TARGET}
|
||||
install -Dm 0755 bin/${TARGET} ${DESTDIR}${PREFIX}/bin/${TARGET}
|
||||
install -Dm 0755 tools/radioctl ${DESTDIR}${PREFIX}/bin/${TARGET}ctl
|
||||
|
||||
install -Dm 0644 init/systemd/${TARGET}.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:
|
||||
rm ${DESTDIR}usr/bin/${TARGET}
|
||||
rm ${DESTDIR}usr/share/licenses/${TARGET}/LICENSE
|
||||
rm ${DESTDIR}etc/logrotate.d/${TARGET}
|
||||
rm ${DESTDIR}${PREFIX}/bin/${TARGET}
|
||||
rm ${DESTDIR}${PREFIX}/bin/${TARGET}ctl
|
||||
|
||||
rm ${DESTDIR}${PREFIX}/bin/${TARGET}-fetch
|
||||
|
||||
# rm ${DESTDIR}/etc/dwelling/radio.liq
|
||||
rm ${DESTDIR}/etc/dwelling/ezstream.xml
|
||||
rm ${DESTDIR}/etc/logrotate.d/${TARGET}
|
||||
rm ${DESTDIR}/etc/systemd/system/icecast.service.override.d/override.conf
|
||||
|
||||
rm ${DESTDIR}${SYSDDIR}/${TARGET}.service
|
||||
# rm ${DESTDIR}${SYSDDIR}/${TARGET}-liquidsoap.service
|
||||
rm ${DESTDIR}${SYSDDIR}/${TARGET}-ezstream.service
|
||||
|
||||
clean:
|
||||
rm -f web/*.jade.go
|
||||
go clean
|
@ -1,30 +1,30 @@
|
||||
# Maintainer: Alexander "Arav" Andreev <me@arav.top>
|
||||
# Maintainer: Alexander "Arav" Andreev <me@arav.su>
|
||||
pkgname=dwelling-radio
|
||||
pkgver=22.35.0
|
||||
pkgver=24.38.0
|
||||
pkgrel=1
|
||||
pkgdesc="Arav's dwelling / Radio"
|
||||
arch=('i686' 'x86_64' 'arm' 'armv6h' 'armv7h' 'aarch64')
|
||||
url="https://git.arav.top/Arav/dwelling-radio"
|
||||
url="https://git.arav.su/Arav/dwelling-radio"
|
||||
license=('MIT')
|
||||
groups=()
|
||||
depends=()
|
||||
makedepends=('go')
|
||||
provides=('dwelling-radio')
|
||||
conflicts=('dwelling-radio')
|
||||
replaces=()
|
||||
backup=('etc/dwelling/radio.yaml' 'etc/dwelling/radio.vars.liq')
|
||||
options=()
|
||||
install=
|
||||
source=('https://git.arav.top/Arav/dwelling-radio/archive/22.35.0.tar.gz')
|
||||
noextract=()
|
||||
makedepends=('go>=1.17')
|
||||
optdepends=(
|
||||
'tree: to make a filelist html file'
|
||||
'ffmpeg: to convert media to ogg and get duration of songs')
|
||||
backup=('etc/dwelling/ezstream.xml')
|
||||
source=("${pkgver}.tar.gz::https://git.arav.su/Arav/dwelling-radio/archive/v${pkgver}.tar.gz")
|
||||
md5sums=('SKIP')
|
||||
|
||||
build() {
|
||||
cd "$srcdir/$pkgname"
|
||||
make DESTDIR="$pkgdir/"
|
||||
export GOPATH="$srcdir"/gopath
|
||||
export CGO_CPPFLAGS="${CPPFLAGS}"
|
||||
export CGO_CFLAGS="${CFLAGS}"
|
||||
export CGO_CXXFLAGS="${CXXFLAGS}"
|
||||
export CGO_LDFLAGS="${LDFLAGS}"
|
||||
make VERSION=$pkgver DESTDIR="$pkgdir" PREFIX="/usr"
|
||||
}
|
||||
|
||||
package() {
|
||||
cd "$srcdir/$pkgname"
|
||||
make DESTDIR="$pkgdir/" install
|
||||
make DESTDIR="$pkgdir" PREFIX="/usr" install
|
||||
}
|
||||
|
@ -1,108 +1,159 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"dwelling-radio/internal/configuration"
|
||||
"dwelling-radio/internal/handlers"
|
||||
ihttp "dwelling-radio/internal/http"
|
||||
"dwelling-radio/internal/radio"
|
||||
"dwelling-radio/internal/radio/liquidsoap"
|
||||
"dwelling-radio/pkg/logging"
|
||||
"dwelling-radio/pkg/server"
|
||||
"errors"
|
||||
sqlite_stats "dwelling-radio/internal/statistics/db/sqlite"
|
||||
"dwelling-radio/pkg/utils"
|
||||
"dwelling-radio/web"
|
||||
"dwelling-radio/web/locales"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path"
|
||||
"syscall"
|
||||
|
||||
"git.arav.su/Arav/httpr"
|
||||
"github.com/invopop/ctxi18n"
|
||||
)
|
||||
|
||||
var (
|
||||
listenAddress = flag.String("listen", "/var/run/dwelling-radio/sock", "listen address (ip:port|unix_path)")
|
||||
workDirPath = flag.String("work-dir", "/mnt/data/appdata/radio", "path to a working directory")
|
||||
playlistName = flag.String("playlist", "all-rand", "a playlist name")
|
||||
songListLen = flag.Int64("list-length", 10, "number of songs to show in last N songs table")
|
||||
|
||||
showVersion = flag.Bool("v", false, "show version")
|
||||
)
|
||||
|
||||
var version string
|
||||
|
||||
var configPath *string = flag.String("conf", "config.yaml", "path to configuration file")
|
||||
var logToStdout *bool = flag.Bool("log-stdout", false, "write logs to stdout")
|
||||
var noLiquidsoap *bool = flag.Bool("no-liquidsoap", false, "don't run liquidsoap")
|
||||
var showVersion *bool = flag.Bool("v", false, "show version")
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
log.SetFlags(log.Lshortfile)
|
||||
|
||||
if *showVersion {
|
||||
fmt.Println("dwelling-radio Ver.", version, "\nCopyright (c) 2022 Alexander \"Arav\" Andreev <me@arav.top>")
|
||||
fmt.Println("dwelling-radio ver.", version, "\nCopyright (c) 2022-2024 Alexander \"Arav\" Andreev <me@arav.su>")
|
||||
return
|
||||
}
|
||||
|
||||
config, err := configuration.LoadConfiguration(*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 {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
|
||||
if *logToStdout {
|
||||
config.Log.ToStdout = true
|
||||
if err := ctxi18n.LoadWithDefault(locales.Content, "en"); err != nil {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
|
||||
r := httpr.New()
|
||||
|
||||
r.Handler(http.MethodGet, "/", func(w http.ResponseWriter, r *http.Request) {
|
||||
lst, err := stats.LastNSongs(*songListLen)
|
||||
if err != nil {
|
||||
log.Printf("Failed to fetch last N songs: %s\n", err)
|
||||
}
|
||||
|
||||
lstnrs.RLock()
|
||||
defer lstnrs.RUnlock()
|
||||
web.Index(version, ¤tSong, lst, *songListLen, lstnrs, r).Render(r.Context(), w)
|
||||
})
|
||||
|
||||
r.Handler(http.MethodGet, "/filelist", func(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := os.ReadFile(path.Join(*workDirPath, "filelist.html"))
|
||||
if err != nil {
|
||||
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Add("Content-Type", "text/html")
|
||||
w.Header().Add("Link", "<"+utils.Site(r.Host)+"/filelist>; rel=\"canonical\"")
|
||||
w.Write(data)
|
||||
})
|
||||
|
||||
r.Handler(http.MethodGet, "/playlist", web.ServeAsset("playlist.m3u", "", "radio.arav.su.m3u"))
|
||||
|
||||
r.Handler(http.MethodGet, "/robots.txt", web.ServeAsset("robots.txt", "text/plain", ""))
|
||||
r.Handler(http.MethodGet, "/sitemap.xml", web.ServeAsset("sitemap.xml", "application/xml", ""))
|
||||
r.Handler(http.MethodGet, "/favicon.svg", web.ServeAsset("favicon.svg", "image/svg", ""))
|
||||
|
||||
r.ServeStatic("/assets/*filepath", web.Assets())
|
||||
|
||||
djh := ihttp.NewDJHandlers(lstnrs, plylst, stats, ¤tSong, *songListLen, path.Join(*workDirPath, "fallback.ogg"))
|
||||
|
||||
s := r.Sub("/api")
|
||||
|
||||
s.Handler(http.MethodPost, "/listener/icecast", djh.ListenersUpdateIcecast)
|
||||
s.Handler(http.MethodGet, "/playlist", djh.PlaylistNext)
|
||||
s.Handler(http.MethodGet, "/status", djh.Status)
|
||||
|
||||
srv := ihttp.NewHttpServer(I18nMiddleware(r))
|
||||
|
||||
if err := srv.Start(*listenAddress); err != nil {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if typ, addr := config.SplitNetworkAddress(); typ == "unix" {
|
||||
os.Remove(addr)
|
||||
}
|
||||
}()
|
||||
|
||||
logErr, err := logging.NewLogger(config.Log.Error, config.Log.ToStdout)
|
||||
if err != nil {
|
||||
log.Fatalln("error logger:", err)
|
||||
}
|
||||
defer logErr.Close()
|
||||
|
||||
if err := radio.IcecastWatchPlaylist(config.Icecast.Playlist, config.ListLastNSongs); err != nil {
|
||||
logErr.Fatalln(err)
|
||||
}
|
||||
defer radio.IcecastWatchClose()
|
||||
|
||||
hand := handlers.NewRadioHandlers(config, logErr)
|
||||
srv := server.NewHttpServer()
|
||||
|
||||
srv.ServeStatic("/assets/*filepath", hand.AssetsFS())
|
||||
srv.GET("/", hand.Index)
|
||||
srv.GET("/status", hand.Status)
|
||||
srv.GET("/lastsong", hand.LastSong)
|
||||
srv.GET("/playlist", hand.Playlist)
|
||||
|
||||
if !*noLiquidsoap {
|
||||
liquid, err := liquidsoap.NewLiquidsoap(config.Liquidsoap.ExecPath, config.Liquidsoap.ScriptPath)
|
||||
if err != nil {
|
||||
logErr.Fatalln("liquidsoap:", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if err := liquid.Stop(); err != nil {
|
||||
if !errors.Is(err, liquidsoap.ErrNotRunning) {
|
||||
logErr.Println("liquidsoap:", err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
if err := srv.Start(config.SplitNetworkAddress()); err != nil {
|
||||
logErr.Fatalln(err)
|
||||
}
|
||||
|
||||
logReload := make(chan os.Signal, 1)
|
||||
signal.Notify(logReload, syscall.SIGHUP)
|
||||
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-logReload:
|
||||
logErr.Reopen(config.Log.Error)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
doneSignal := make(chan os.Signal, 1)
|
||||
signal.Notify(doneSignal, os.Interrupt, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
<-doneSignal
|
||||
|
||||
if err := srv.Stop(); err != nil {
|
||||
logErr.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,17 +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/r.sock"
|
||||
icecast:
|
||||
# URL to Icecast's status-json.xsl
|
||||
url: "http://radio.arav.home.arpa/status-json.xsl"
|
||||
playlist_path: "/var/log/icecast/playlist.log"
|
||||
liquidsoap:
|
||||
executable_path: "/opt/opam/4.14.0/bin/liquidsoap"
|
||||
script_path: "/etc/dwelling/radio.liq"
|
||||
# How much songs to list on a page
|
||||
list_last_n_songs: 10
|
||||
log:
|
||||
# Output messages to stdout as well as to theirs files.
|
||||
stdout: true
|
||||
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 {
|
||||
nocreate
|
||||
copytruncate
|
||||
missingok
|
||||
notifempty
|
||||
size 10M
|
||||
@ -8,8 +9,4 @@
|
||||
compressext .zst
|
||||
compressoptions -T0 --long -15
|
||||
uncompresscmd /usr/bin/unzstd
|
||||
sharedscripts
|
||||
postrotate
|
||||
/bin/pkill -HUP dwelling-radio
|
||||
endscript
|
||||
}
|
@ -1,45 +1,35 @@
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
# listen 8090; # Tor
|
||||
listen 127.0.0.1:8111; # I2P
|
||||
listen 443 ssl;
|
||||
listen 8091; # Tor I2P
|
||||
listen [300:a98d:d6d0:8a08::e]:80; # Yggdrasil
|
||||
|
||||
server_name radio.arav.top 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;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/arav.top/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/arav.top/privkey.pem;
|
||||
ssl_certificate /etc/letsencrypt/live/arav.su/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/arav.su/privkey.pem;
|
||||
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self'; media-src 'self'; object-src 'none'; frame-src 'none'; frame-ancestors 'none'; font-src 'self'; form-action 'none'";
|
||||
add_header X-Frame-Options "DENY";
|
||||
add_header X-Content-Type-Options "nosniff";
|
||||
add_header X-XSS-Protection "1; mode=block";
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload";
|
||||
# add_header Onion-Location "http://mkgnmhmzqm7kyzv7jnzzafvgm7xlmlfvzhgorpapd5or2arnhuktqd.onion$request_uri";
|
||||
add_header Onion-Location "http://wsmkgnmhmzqm7kyzv7jnzzafvgm7xlmlfvzhgorpapd5or2arnhuktqd.onion$request_uri";
|
||||
|
||||
|
||||
location / {
|
||||
proxy_pass http://unix:/var/run/dwelling-radio/r.sock;
|
||||
proxy_pass http://unix:/var/run/dwelling-radio/sock;
|
||||
proxy_buffering off;
|
||||
proxy_set_header X-Client-Timezone $gi2_location_tz;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
|
||||
location =/filelist {
|
||||
alias $http_root/shared/radio_filelist.html;
|
||||
default_type text/html;
|
||||
}
|
||||
|
||||
location =/robots.txt {
|
||||
alias $http_root/shared/files/radio.robots.txt;
|
||||
default_type text/html;
|
||||
}
|
||||
|
||||
|
||||
location /live/ {
|
||||
proxy_pass http://127.0.0.1:8000/;
|
||||
proxy_bind $remote_addr transparent;
|
||||
proxy_buffering off;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
@ -47,12 +37,24 @@ server {
|
||||
location /live/admin/ {
|
||||
deny all;
|
||||
}
|
||||
|
||||
location /api/listener/icecast {
|
||||
allow 127.0.0.1;
|
||||
allow 192.168.144.2;
|
||||
deny all;
|
||||
}
|
||||
|
||||
location /api/playlist {
|
||||
allow 127.0.0.1;
|
||||
allow 192.168.144.2;
|
||||
deny all;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 8000;
|
||||
listen 192.168.144.2:8000;
|
||||
|
||||
server_name radio.arav.top;
|
||||
server_name radio.arav.su;
|
||||
|
||||
access_log /var/log/nginx/dwelling/radio.http.log main if=$nolog;
|
||||
|
||||
@ -60,12 +62,14 @@ server {
|
||||
add_header X-Frame-Options "DENY";
|
||||
add_header X-Content-Type-Options "nosniff";
|
||||
add_header X-XSS-Protection "1; mode=block";
|
||||
add_header Onion-Location "http://mkgnmhmzqm7kyzv7jnzzafvgm7xlmlfvzhgorpapd5or2arnhuktqd.onion/live$request_uri";
|
||||
add_header Onion-Location "http://wsmkgnmhmzqm7kyzv7jnzzafvgm7xlmlfvzhgorpapd5or2arnhuktqd.onion/live$request_uri";
|
||||
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8000/;
|
||||
proxy_bind $remote_addr transparent;
|
||||
proxy_buffering off;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
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)
|
||||
add(normalize = false,
|
||||
[ sequence([ blank(duration = 2.),
|
||||
fade.initial(duration = 2., b)]),
|
||||
fade.final(duration = 2., a)])
|
||||
fade.in(duration = 2., b)]),
|
||||
fade.out(duration = 2., a)])
|
||||
end
|
||||
|
||||
def fallback_alter_title(m) =
|
||||
@ -19,32 +19,35 @@ def fallback_alter_title(m) =
|
||||
("title", string.concat(["No stream. (Playing ", m["artist"], " - ", m["title"], ")"]))]
|
||||
end
|
||||
|
||||
settings.server.telnet.set(false)
|
||||
settings.harbor.bind_addrs.set(["0.0.0.0"])
|
||||
settings.log.level.set(2)
|
||||
settings.log.file.set(true)
|
||||
settings.log.file.path.set(log_file_path)
|
||||
settings.log.stdout.set(false)
|
||||
settings.server.telnet := false
|
||||
settings.harbor.bind_addrs := ["0.0.0.0"]
|
||||
settings.log.level := 1
|
||||
settings.log.file := true
|
||||
settings.log.file.path := log_file_path
|
||||
settings.log.stdout := false
|
||||
|
||||
fallback_song = mksafe(single(fullpath("fallback.mp3")))
|
||||
fallback_song = map_metadata(fallback_alter_title, fallback_song)
|
||||
enable_replaygain_metadata()
|
||||
|
||||
fallback_song = mksafe(single(fullpath("fallback.ogg")))
|
||||
fallback_song = metadata.map(fallback_alter_title, fallback_song)
|
||||
|
||||
live_mixin = input.harbor("adr-live-mixin", port = harbor_port, password = harbor_password)
|
||||
live_show = input.harbor("adr-live-show", port = harbor_port, password = harbor_password)
|
||||
live_show = map_metadata(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"),
|
||||
prefix = fullpath("music/"),
|
||||
prefix = string.concat(["replaygain:", fullpath("music/")]),
|
||||
mode = "normal", reload_mode = "watch")
|
||||
|
||||
music = audio_to_stereo(playlist_random)
|
||||
music = replaygain(music)
|
||||
|
||||
radio = smooth_add(p = 0.18, normal = music, special = live_mixin)
|
||||
radio = fallback(track_sensitive = false,
|
||||
transitions = [xfade, xfade, xfade],
|
||||
[ blank.strip(max_blank=15., live_show),
|
||||
blank.strip(max_blank=90., radio), fallback_song])
|
||||
radio = normalize(radio)
|
||||
radio = normalize(target = -12., threshold = -40., lufs = true, radio)
|
||||
|
||||
output.icecast(%vorbis.cbr(bitrate = 128, samplerate = 44100, channels = 2),
|
||||
host = icecast_host, port = icecast_port,
|
||||
|
@ -2,17 +2,17 @@
|
||||
|
||||
log_file_path = "/var/log/dwelling-radio/liquidsoap.log"
|
||||
|
||||
radio_url = "https://radio.arav.top"
|
||||
radio_url = "https://radio.arav.su"
|
||||
radio_name = "Arav's dwelling / Radio"
|
||||
radio_desc = "Broadcasting from under my desk."
|
||||
|
||||
radio_dir = "/srv/radio/"
|
||||
radio_dir = "/mnt/data/appdata/radio/"
|
||||
|
||||
harbor_port = 8002
|
||||
harbor_password = ""
|
||||
|
||||
icecast_host = "127.0.0.1"
|
||||
icecast_port = 8000
|
||||
icecast_port = 8001
|
||||
icecast_password = ""
|
||||
icecast_mount = "stream.ogg"
|
||||
icecast_genre = "Various"
|
20
go.mod
@ -1,9 +1,21 @@
|
||||
module dwelling-radio
|
||||
|
||||
go 1.18
|
||||
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 (
|
||||
github.com/julienschmidt/httprouter v1.3.0
|
||||
github.com/pkg/errors v0.9.1
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
github.com/invopop/ctxi18n v0.8.1
|
||||
github.com/mattn/go-sqlite3 v1.14.23
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/invopop/yaml v0.3.1 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
20
go.sum
@ -1,7 +1,23 @@
|
||||
github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U=
|
||||
github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
|
||||
git.arav.su/Arav/httpr v0.3.2 h1:a+ifu+9+FnQe6p/Kd4kgTDKAFN6zBOJjBTMjbAuHxVk=
|
||||
git.arav.su/Arav/httpr v0.3.2/go.mod h1:z0SVYwe5dBReeVuFU9QH2PmBxICJwchxqY5OfZbeVzU=
|
||||
github.com/a-h/templ v0.2.778 h1:VzhOuvWECrwOec4790lcLlZpP4Iptt5Q4K9aFxQmtaM=
|
||||
github.com/a-h/templ v0.2.778/go.mod h1:lq48JXoUvuQrU0VThrK31yFwdRjTCnIE5bcPCM9IP1w=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/invopop/ctxi18n v0.8.1 h1:nfy5Mk6UfvLbGRBwpTi4T1g95+rmRo8bMllUmpCvVwI=
|
||||
github.com/invopop/ctxi18n v0.8.1/go.mod h1:1Osw+JGYA+anHt0Z4reF36r5FtGHYjGQ+m1X7keIhPc=
|
||||
github.com/invopop/yaml v0.3.1 h1:f0+ZpmhfBSS4MhG+4HYseMdJhoeeopbSKbq5Rpeelso=
|
||||
github.com/invopop/yaml v0.3.1/go.mod h1:PMOp3nn4/12yEZUFfmOuNHJsZToEEOwoWsT+D81KkeA=
|
||||
github.com/mattn/go-sqlite3 v1.14.23 h1:gbShiuAP1W5j9UOksQ06aiiqPMxYecovVGwmTxWtuw0=
|
||||
github.com/mattn/go-sqlite3 v1.14.23/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
|
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
|
||||
Restart=on-failure
|
||||
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=/
|
||||
ReadWritePaths=/mnt/data/appdata/radio
|
||||
|
||||
LogsDirectory=dwelling-radio
|
||||
RuntimeDirectory=dwelling-radio
|
||||
@ -21,18 +25,33 @@ LockPersonality=true
|
||||
MemoryDenyWriteExecute=true
|
||||
NoNewPrivileges=true
|
||||
PrivateDevices=true
|
||||
PrivateTmp=true
|
||||
PrivateUsers=true
|
||||
ProcSubset=pid
|
||||
ProtectClock=true
|
||||
ProtectControlGroups=true
|
||||
ProtectHome=true
|
||||
ProtectHostname=true
|
||||
ProtectKernelLogs=true
|
||||
ProtectKernelModules=true
|
||||
ProtectKernelTunables=true
|
||||
ProtectProc=noaccess
|
||||
ProtectSystem=strict
|
||||
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
|
||||
RestrictNamespaces=true
|
||||
RestrictRealtime=true
|
||||
RestrictSUIDSGID=true
|
||||
SystemCallArchitectures=native
|
||||
SystemCallFilter=~@clock
|
||||
SystemCallFilter=~@cpu-emulation
|
||||
SystemCallFilter=~@debug
|
||||
SystemCallFilter=~@module
|
||||
SystemCallFilter=~@mount
|
||||
SystemCallFilter=~@obsolete
|
||||
SystemCallFilter=~@privileged
|
||||
SystemCallFilter=~@raw-io
|
||||
SystemCallFilter=~@reboot
|
||||
SystemCallFilter=~@swap
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
@ -1,51 +0,0 @@
|
||||
package configuration
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// Configuration holds a list of process names to be tracked and a listen address.
|
||||
type Configuration struct {
|
||||
ListenOn string `yaml:"listen_on"`
|
||||
Icecast struct {
|
||||
URL string `yaml:"url"`
|
||||
Playlist string `yaml:"playlist_path"`
|
||||
} `yaml:"icecast"`
|
||||
Liquidsoap struct {
|
||||
ExecPath string `yaml:"executable_path"`
|
||||
ScriptPath string `yaml:"script_path"`
|
||||
} `yaml:"liquidsoap"`
|
||||
ListLastNSongs int `yaml:"list_last_n_songs"`
|
||||
Log struct {
|
||||
ToStdout bool `yaml:"stdout"`
|
||||
Error string `yaml:"error"`
|
||||
} `yaml:"log"`
|
||||
}
|
||||
|
||||
func LoadConfiguration(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 and returns as two strings
|
||||
// network type (e.g. tcp, unix, udp) and address:port or /path/to/prog.socket
|
||||
// to listen on.
|
||||
func (c *Configuration) SplitNetworkAddress() (string, string) {
|
||||
s := strings.Split(c.ListenOn, " ")
|
||||
return s[0], s[1]
|
||||
}
|
@ -1,103 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"dwelling-radio/internal/configuration"
|
||||
"dwelling-radio/internal/radio"
|
||||
"dwelling-radio/pkg/logging"
|
||||
"dwelling-radio/pkg/utils"
|
||||
"dwelling-radio/web"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
const FormatISO8601 = "2006-01-02T15:04:05-0700"
|
||||
|
||||
type RadioHandlers struct {
|
||||
conf *configuration.Configuration
|
||||
logErr *logging.Logger
|
||||
}
|
||||
|
||||
func NewRadioHandlers(conf *configuration.Configuration, lErr *logging.Logger) *RadioHandlers {
|
||||
return &RadioHandlers{
|
||||
conf: conf,
|
||||
logErr: lErr}
|
||||
}
|
||||
|
||||
func (h *RadioHandlers) AssetsFS() http.FileSystem {
|
||||
return web.Assets()
|
||||
}
|
||||
|
||||
func (h *RadioHandlers) Index(w http.ResponseWriter, r *http.Request) {
|
||||
status, err := radio.IcecastGetStatus(h.conf.Icecast.URL)
|
||||
if err != nil {
|
||||
h.logErr.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 {
|
||||
h.logErr.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), status, &songs, w)
|
||||
}
|
||||
|
||||
func (h *RadioHandlers) Status(w http.ResponseWriter, r *http.Request) {
|
||||
status, err := radio.IcecastGetStatus(h.conf.Icecast.URL)
|
||||
if err != nil {
|
||||
h.logErr.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 *RadioHandlers) LastSong(w http.ResponseWriter, r *http.Request) {
|
||||
song, err := radio.IcecastLastSong(h.conf.Icecast.Playlist)
|
||||
if err != nil {
|
||||
h.logErr.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 *RadioHandlers) Playlist(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Add("Content-Disposition", "attachment; filename=\"radio.arav.top.m3u\"")
|
||||
fc, _ := web.AssetsGetFile("radio.arav.top.m3u")
|
||||
w.Write(fc)
|
||||
}
|
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)
|
||||
}
|
||||
}
|
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,209 +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) Song() 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.Song(),
|
||||
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 := lastPlayedSongs(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 := lastPlayedSongs(playlistPath, 1)
|
||||
if err != nil {
|
||||
return Song{}, err
|
||||
}
|
||||
|
||||
return song[0], nil
|
||||
}
|
||||
|
||||
func lastPlayedSongs(playlistPath string, n int) ([]Song, error) {
|
||||
songs := make([]Song, n)
|
||||
buf := make([]byte, bufferSize)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
playlist.Seek(-bufferSize, os.SEEK_END)
|
||||
|
||||
_, err = playlist.Read(buf)
|
||||
if err != nil && err != io.EOF {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
lines := bytes.Split(buf, []byte("\n"))
|
||||
|
||||
if len(lines) < 2 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if len(lines) < n {
|
||||
n = len(lines)
|
||||
}
|
||||
|
||||
lines = lines[len(lines)-n-3 : len(lines)-3]
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
var playlistWatcher watcher.InotifyWatcher
|
||||
var playlistFired chan uint32 = make(chan uint32)
|
||||
|
||||
func IcecastWatchPlaylist(playlistPath string, lastNSongs int) error {
|
||||
playlistWatcher, err := watcher.NewInotifyWatcher()
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "cannot instantiate inotify watcher")
|
||||
}
|
||||
|
||||
err = playlistWatcher.AddWatch(playlistPath, watcher.ModIgnMask)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "cannot set a playlist to watch")
|
||||
}
|
||||
|
||||
playlistWatcher.WatchForMask(playlistFired, watcher.ModIgnMask)
|
||||
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case mask := <-playlistFired:
|
||||
if mask&syscall.IN_MODIFY > 0 {
|
||||
lastPlayedCacheMutex.Lock()
|
||||
if songs, err := lastPlayedSongs(playlistPath, lastNSongs); err == nil {
|
||||
lastPlayedCache = songs
|
||||
}
|
||||
lastPlayedCacheMutex.Unlock()
|
||||
} else if mask&syscall.IN_IGNORED > 0 {
|
||||
playlistWatcher.Close()
|
||||
IcecastWatchPlaylist(playlistPath, lastNSongs)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func IcecastWatchClose() {
|
||||
playlistWatcher.Close()
|
||||
}
|
@ -1,54 +0,0 @@
|
||||
package liquidsoap
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"syscall"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
var ErrNotRunning = 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, "cannot check script")
|
||||
}
|
||||
|
||||
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 ErrNotRunning
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
@ -1,110 +0,0 @@
|
||||
package logging
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
type Logger struct {
|
||||
file io.WriteCloser
|
||||
toStdout bool
|
||||
mut sync.Mutex
|
||||
}
|
||||
|
||||
// NewLogger creates a Logger instance with given filename and
|
||||
// toStdout tells wether to write to Stdout as well or not.
|
||||
func NewLogger(path string, toStdout bool) (*Logger, error) {
|
||||
f, err := os.OpenFile(path, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0660)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to open log file")
|
||||
}
|
||||
|
||||
return &Logger{file: f, toStdout: toStdout}, nil
|
||||
}
|
||||
|
||||
func (l *Logger) Reopen(path string) error {
|
||||
f, err := os.OpenFile(path, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0660)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
l.file.Close()
|
||||
l.file = f
|
||||
return nil
|
||||
}
|
||||
|
||||
func (l *Logger) Println(v ...interface{}) {
|
||||
l.mut.Lock()
|
||||
defer l.mut.Unlock()
|
||||
|
||||
nowStr := time.Now().UTC().Format(time.RFC3339)
|
||||
|
||||
fmt.Fprintln(l.file, nowStr, v)
|
||||
|
||||
if l.toStdout {
|
||||
fmt.Println(v...)
|
||||
}
|
||||
}
|
||||
|
||||
func (l *Logger) Printf(format string, v ...interface{}) {
|
||||
l.mut.Lock()
|
||||
defer l.mut.Unlock()
|
||||
|
||||
// Ensure a new line will be written
|
||||
if !strings.HasSuffix(format, "\n") {
|
||||
format += "\n"
|
||||
}
|
||||
|
||||
nowStr := time.Now().UTC().Format(time.RFC3339)
|
||||
|
||||
fmt.Fprintf(l.file, nowStr+" "+format, v...)
|
||||
|
||||
if l.toStdout {
|
||||
fmt.Printf(format, v...)
|
||||
}
|
||||
}
|
||||
|
||||
func (l *Logger) Fatalln(v ...interface{}) {
|
||||
l.mut.Lock()
|
||||
|
||||
nowStr := time.Now().UTC().Format(time.RFC3339)
|
||||
|
||||
fmt.Fprintln(l.file, nowStr, v)
|
||||
|
||||
if l.toStdout {
|
||||
fmt.Println(v...)
|
||||
}
|
||||
|
||||
l.file.Close()
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
func (l *Logger) Fatalf(format string, v ...interface{}) {
|
||||
l.mut.Lock()
|
||||
|
||||
// Ensure a new line will be written
|
||||
if !strings.HasSuffix(format, "\n") {
|
||||
format += "\n"
|
||||
}
|
||||
|
||||
nowStr := time.Now().UTC().Format(time.RFC3339)
|
||||
|
||||
fmt.Fprintf(l.file, nowStr+" "+format, v...)
|
||||
|
||||
if l.toStdout {
|
||||
fmt.Printf(format, v...)
|
||||
}
|
||||
|
||||
l.file.Close()
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
func (l *Logger) Close() error {
|
||||
return l.file.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())
|
||||
}
|
@ -1,76 +0,0 @@
|
||||
package server
|
||||
|
||||
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
|
||||
}
|
@ -16,7 +16,14 @@ func MainSite(host string) string {
|
||||
return "http://[300:a98d:d6d0:8a08::f]"
|
||||
}
|
||||
|
||||
return "https://arav.top"
|
||||
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
|
||||
|
@ -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%????}
|
66
tools/radioctl
Normal file
@ -0,0 +1,66 @@
|
||||
#!/usr/bin/sh
|
||||
|
||||
radio_dir=/mnt/data/appdata/radio
|
||||
|
||||
case $1 in
|
||||
f | filelist)
|
||||
tree -H '' -T "List – Arav's dwelling / Radio" \
|
||||
-P '*.ogg' --charset=utf-8 --prune --du -hnl \
|
||||
--nolinks -o $radio_dir/filelist.html $radio_dir/music
|
||||
break
|
||||
;;
|
||||
p | playlist)
|
||||
find -L $radio_dir/music/* -type f -iname '*.ogg' |
|
||||
cut -c 31- | sort -d > $radio_dir/playlists/all
|
||||
break
|
||||
;;
|
||||
ep | ez-playlist)
|
||||
find -L $radio_dir/music/* -type f -iname '*.ogg' |
|
||||
sort -d > $radio_dir/playlists/all
|
||||
break
|
||||
;;
|
||||
s | shuffle)
|
||||
shuf $radio_dir/playlists/all > $radio_dir/playlists/all-rand
|
||||
break
|
||||
;;
|
||||
c | convert)
|
||||
for file in "$2"/*; do
|
||||
if [[ "$file" == *.ogg ]]; then
|
||||
continue;
|
||||
fi
|
||||
if [ -f "${file%.*}.ogg" ]; then
|
||||
continue;
|
||||
fi
|
||||
ffmpeg -hide_banner -i "$file" -y -vn -c:a libvorbis -b:a 128k "${file%.*}.ogg";
|
||||
if [ $? -eq 0 ] && [ $3 = "del" ]; then
|
||||
rm "$file";
|
||||
fi
|
||||
done
|
||||
break
|
||||
;;
|
||||
d | duration)
|
||||
find $radio_dir/music -iname '*.ogg' -exec ffprobe -i "{}" \
|
||||
-show_entries format=duration -v quiet -of csv="p=0" \; |
|
||||
paste -s -d+ - | bc
|
||||
break
|
||||
;;
|
||||
er | ez-reload)
|
||||
pkill -HUP ezstream
|
||||
break
|
||||
;;
|
||||
dr | dw-reload)
|
||||
pkill -HUP dwelling-radio
|
||||
break
|
||||
;;
|
||||
*)
|
||||
echo "f|ilelist - to generate a filelist.html"
|
||||
echo "p|laylist - to generate a playlist 'all'"
|
||||
echo "ep|ez-playlist- - to generate a playlist 'all' with full paths"
|
||||
echo "s|huffle - to shuffle a playlist and store as all-rand"
|
||||
echo "c|onvert DIR - convert all files in DIR to ogg"
|
||||
echo "d|uration - get total songs' duration"
|
||||
echo "er|ez-reload - send SIGHUP to ezstream to reload a playlist"
|
||||
echo "dr|dw-reload - send SIGHUP to dwelling-radio to reload a playlist"
|
||||
exit
|
||||
;;
|
||||
esac
|
@ -11,6 +11,7 @@
|
||||
--secondary-color: #9f2b68;
|
||||
--text-color: #f5f5f5;
|
||||
--text-indent: 1.6rem;
|
||||
color-scheme: light dark;
|
||||
scrollbar-color: var(--primary-color) var(--background-color); }
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
@ -26,13 +27,16 @@
|
||||
background-color: var(--secondary-color);
|
||||
color: var(--background-color); }
|
||||
|
||||
.small { font-size: .8rem; }
|
||||
|
||||
.small.player-links a { margin: 0 .2rem; }
|
||||
|
||||
a,
|
||||
button {
|
||||
color: var(--primary-color);
|
||||
text-decoration: none; }
|
||||
|
||||
a:hover,
|
||||
button:hover {
|
||||
a:hover {
|
||||
color: var(--secondary-color);
|
||||
cursor: pointer;
|
||||
text-decoration: underline dotted;
|
||||
@ -40,9 +44,7 @@ button:hover {
|
||||
|
||||
button {
|
||||
background: none;
|
||||
border: none;
|
||||
font: inherit;
|
||||
padding: 0; }
|
||||
border: none; }
|
||||
|
||||
p {
|
||||
text-align: justify;
|
||||
@ -62,24 +64,10 @@ h2 {
|
||||
font-size: 1.4rem;
|
||||
margin: 1rem 0; }
|
||||
|
||||
small { font-size: .8rem; }
|
||||
|
||||
small.player-links a { margin: 0 .2rem; }
|
||||
|
||||
audio {
|
||||
background-color: var(--primary-color);
|
||||
box-shadow: 5px 5px var(--primary-color);
|
||||
width: 100%; }
|
||||
|
||||
@media screen and (-webkit-min-device-pixel-ratio:0) {
|
||||
audio::-webkit-media-controls-panel {
|
||||
background-color: var(--secondary-color); }
|
||||
|
||||
audio { border-radius: 1.6rem; } }
|
||||
|
||||
@-moz-document url-prefix() {
|
||||
audio { border-radius: 0; } }
|
||||
|
||||
html { margin-left: calc(100vw - 100%); }
|
||||
|
||||
body {
|
||||
@ -96,44 +84,73 @@ header {
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between; }
|
||||
|
||||
#logo {
|
||||
display: block;
|
||||
width: 360px; }
|
||||
header svg text { fill: var(--text-color); }
|
||||
|
||||
#logo text { fill: var(--text-color); }
|
||||
|
||||
#logo .logo {
|
||||
font-size: 2rem;
|
||||
header svg text:first-child {
|
||||
font-size: 3.55rem;
|
||||
font-variant-caps: small-caps;
|
||||
font-weight: bold; }
|
||||
|
||||
@media screen and (-webkit-min-device-pixel-ratio:0) {
|
||||
#logo .logo { font-size: 2.082rem; } }
|
||||
header svg text:last-child { font-size: 1.5rem; }
|
||||
|
||||
@-moz-document url-prefix() {
|
||||
#logo .logo { font-size: 2rem; } }
|
||||
@supports (-moz-appearance:none) {
|
||||
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; }
|
||||
|
||||
nav a { font-variant: small-caps; }
|
||||
|
||||
nav h1 {
|
||||
header nav h1 {
|
||||
color: var(--secondary-color);
|
||||
margin: 0; }
|
||||
|
||||
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;
|
||||
min-width: 80%;
|
||||
width: 80%; }
|
||||
|
||||
#last-played tbody tr {
|
||||
#last-songs :is(thead tr, tbody tr) {
|
||||
display: grid;
|
||||
gap: .5rem;
|
||||
grid-template-columns: 3rem 2rem 1fr; }
|
||||
grid-template-columns: 3rem 3rem 1fr; }
|
||||
|
||||
#last-songs thead tr { font-weight: bold; }
|
||||
|
||||
footer {
|
||||
font-size: .8rem;
|
||||
@ -141,12 +158,10 @@ footer {
|
||||
padding: 1rem 0; }
|
||||
|
||||
@media screen and (max-width: 640px) {
|
||||
header { display: block; }
|
||||
header {
|
||||
align-items: center;
|
||||
flex-direction: column; }
|
||||
|
||||
#logo {
|
||||
margin: 0 auto;
|
||||
width: 100%; }
|
||||
header svg { width: 100%; }
|
||||
|
||||
nav {
|
||||
width: 100%;
|
||||
text-align: center; } }
|
||||
#player { flex-direction: column; } }
|
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>
|
||||
|
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() {
|
||||
fetch("/status")
|
||||
.then(r => r.json())
|
||||
.then(r => {
|
||||
$("radio-status").innerHTML =
|
||||
`On-air since <time datetime="${r.server_start_iso8601}">${r.server_start_date}</time>`;
|
||||
$("radio-song").textContent = r.song;
|
||||
$("radio-listeners").textContent = r.listeners;
|
||||
$("radio-listener-peak").textContent = r.listener_peak;
|
||||
}).catch(() => {
|
||||
$("radio-status").textContent = "Radio is offline.";
|
||||
$("radio-song").textContent = "";
|
||||
$("radio-listeners").textContent =
|
||||
$("radio-listener-peak").textContent = "0";
|
||||
});
|
||||
const formatDuration = date => `${date.getUTCHours() > 0 ? date.getUTCHours() + ":" : ""}${date.getUTCMinutes()}:${date.getUTCSeconds().toString().padStart(2, "0")}`;
|
||||
const formatStartAt = date => `${date.getHours().toString().padStart(2, "0")}:${date.getMinutes().toString().padStart(2, "0")}`;
|
||||
|
||||
let cursong_startat = null;
|
||||
let cursong_duration_msec = 0;
|
||||
|
||||
async function updateStatus() {
|
||||
const resp = await fetch("/api/status");
|
||||
|
||||
if (!resp.ok || 200 != resp.status) {
|
||||
$("radio-song").textContent =
|
||||
$("radio-duration-estimate").textContent =
|
||||
$("radio-duration").textContent = "";
|
||||
$("radio-listeners").textContent = "0";
|
||||
$("last-songs").lastChild.remove();
|
||||
return [-1, null];
|
||||
}
|
||||
|
||||
function updateLastPlayedSong() {
|
||||
fetch('/lastsong')
|
||||
.then(r => r.json())
|
||||
.then(last_played => {
|
||||
if (last_played.time == $('last-played').firstChild.lastChild.firstChild.innerText)
|
||||
return;
|
||||
const s = await resp.json();
|
||||
|
||||
$('last-played').firstChild.firstChild.remove();
|
||||
|
||||
let row = $('last-played').insertRow();
|
||||
row.insertCell().appendChild(document.createTextNode(last_played.time));
|
||||
row.insertCell().appendChild(document.createTextNode(last_played.listeners == 0 ? "" : last_played.listeners));
|
||||
row.insertCell().appendChild(document.createTextNode(last_played.song));
|
||||
});
|
||||
if (undefined != s.last_songs) {
|
||||
$("last-songs").lastChild.remove();
|
||||
$("last-songs").appendChild(document.createElement("tbody"));
|
||||
for (let i = 0; i < s.last_songs.length; ++i) {
|
||||
let row = $("last-songs").lastChild.insertRow();
|
||||
row.insertCell().appendChild(document.createTextNode(formatStartAt(new Date(s.last_songs[i].start_at))));
|
||||
row.insertCell().appendChild(document.createTextNode((s.last_songs[i].listeners == undefined ? "" : s.last_songs[i].listeners + "/") + (s.last_songs[i].peak_listeners == undefined ? "" : s.last_songs[i].peak_listeners)));
|
||||
row.insertCell().appendChild(document.createTextNode(`${s.last_songs[i].artist} - ${s.last_songs[i].title}`));
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById("btn-update").addEventListener("click", () => {
|
||||
updateLastPlayedSong();
|
||||
updateRadioStatus();
|
||||
})
|
||||
if (undefined == s.current_song || undefined == s.current_song.duration_msec)
|
||||
return [-1, null];
|
||||
|
||||
setInterval(updateRadioStatus, 45000);
|
||||
setInterval(updateLastPlayedSong, 45000);
|
||||
$("radio-song").textContent = `${s.current_song.artist} - ${s.current_song.title}`;
|
||||
$("radio-listeners").textContent = s.listeners;
|
||||
$("radio-duration").textContent = formatDuration(new Date(s.current_song.duration_msec));
|
||||
|
||||
return [s.current_song.duration_msec, new Date(s.current_song.start_at)];
|
||||
}
|
||||
|
||||
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)"; });
|
||||
|
@ -1,11 +1,11 @@
|
||||
#EXTM3U
|
||||
#EXTINF:-1,Arav's dwelling / Radio
|
||||
http://radio.arav.top:8000/stream.ogg
|
||||
http://radio.arav.su:8000/stream.ogg
|
||||
#EXTINF:-1,Arav's dwelling / Radio (HTTPS)
|
||||
https://radio.arav.top/live/stream.ogg
|
||||
https://radio.arav.su/live/stream.ogg
|
||||
#EXTINF:-1,Arav's dwelling / Radio on Tor
|
||||
http://wsmkgnmhmzqm7kyzv7jnzzafvgm7xlmlfvzhgorpapd5or2arnhuktqd.onion/live/stream.ogg
|
||||
#EXTINF:-1,Arav's dwelling / Radio on I2P
|
||||
http://radio.arav.i2p/live/stream.ogg
|
||||
#EXTINF:-1,Arav's dwelling / Radio on Ygg
|
||||
#EXTINF:-1,Arav's dwelling / Radio on Yggdrasil
|
||||
http://[300:a98d:d6d0:8a08::e]/live/stream.ogg
|
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>
|
@ -1,82 +0,0 @@
|
||||
// Code generated by "jade.go"; DO NOT EDIT.
|
||||
|
||||
package web
|
||||
|
||||
import (
|
||||
"dwelling-radio/internal/radio"
|
||||
"io"
|
||||
)
|
||||
|
||||
const (
|
||||
index__0 = `<!DOCTYPE html><html lang="en"><head><title>Arav's dwelling / Radio</title><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=""></script></head><body><header><svg id="logo" viewBox="0 -25 216 40"><text class="logo">Arav's dwelling</text><text class="under" y="11">Welcome to my sacred place, wanderer</text></svg><nav><a href="`
|
||||
index__1 = `">Back to main website</a><h1>Radio</h1></nav></header><section><small class="player-links"><a href="/filelist">filelist</a><a href="/playlist">playlist (.m3u)</a><a href="/live/stream.ogg">direct link</a><a href="http://radio.arav.top:8000/stream.ogg">direct link (http)</a><a href="http://wsmkgnmhmzqm7kyzv7jnzzafvgm7xlmlfvzhgorpapd5or2arnhuktqd.onion/live/stream.ogg">direct link (Tor)</a><a href="http://radio.arav.i2p/live/stream.ogg">direct link (I2P)</a><a href="https://dir.xiph.org/search?q=arav's+dwelling">Xiph</a>OGG 128 Kb/s</small><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</a>!</audio>`
|
||||
index__2 = `<p>Now playing: <span id="radio-song">`
|
||||
index__3 = `</span></p><p>Current/peak listeners: <span id="radio-listeners">`
|
||||
index__4 = `</span> / <span id="radio-listener-peak">`
|
||||
index__5 = `</span></p><p><small>Notice: information updates every 45 seconds. But you can <button id="btn-update">update</button> it forcibly.</small></p></section>`
|
||||
index__6 = `<section><p>The largest number of simultaneous listeners was <b>7</b> at <time datetime="2022-02-19">19 February 2022</time>, and the song was "Röyksopp - 49 Percent".</p></section><section><h2>Privacy statements</h2><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.</p></section><footer>2017—2022 Arav <<a href="mailto:me@arav.top">me@arav.top</a>></footer></body></html>`
|
||||
index__7 = `<p id="radio-status">On-air since <time datetime="`
|
||||
index__8 = `">`
|
||||
index__9 = `</time></p>`
|
||||
index__10 = `<p id="radio-status">Radio is offline.</p>`
|
||||
index__11 = `<section><h2>Last 10 songs</h2><table id="last-played">`
|
||||
index__12 = `</table></section>`
|
||||
index__13 = `<tr><td>`
|
||||
index__14 = `</td>`
|
||||
index__15 = `<td>`
|
||||
index__16 = `</td></tr>`
|
||||
index__19 = `<td></td>`
|
||||
)
|
||||
|
||||
func Index(mainSite string, status *radio.IcecastStatus, songs *[]radio.Song, wr io.Writer) {
|
||||
buffer := &WriterAsBuffer{wr}
|
||||
|
||||
buffer.WriteString(index__0)
|
||||
WriteEscString(mainSite, buffer)
|
||||
buffer.WriteString(index__1)
|
||||
|
||||
if status.ServerStartDate != "" {
|
||||
buffer.WriteString(index__7)
|
||||
WriteEscString(status.ServerStartISO8601, buffer)
|
||||
buffer.WriteString(index__8)
|
||||
WriteEscString(status.ServerStartDate, buffer)
|
||||
buffer.WriteString(index__9)
|
||||
|
||||
} else {
|
||||
buffer.WriteString(index__10)
|
||||
|
||||
}
|
||||
buffer.WriteString(index__2)
|
||||
WriteEscString(status.SongName, buffer)
|
||||
buffer.WriteString(index__3)
|
||||
WriteInt(int64(status.Listeners), buffer)
|
||||
buffer.WriteString(index__4)
|
||||
WriteInt(int64(status.ListenerPeak), buffer)
|
||||
buffer.WriteString(index__5)
|
||||
|
||||
if len(*songs) > 0 {
|
||||
buffer.WriteString(index__11)
|
||||
|
||||
for _, song := range *songs {
|
||||
buffer.WriteString(index__13)
|
||||
WriteEscString(song.Time, buffer)
|
||||
buffer.WriteString(index__14)
|
||||
if song.Listeners != "0" {
|
||||
buffer.WriteString(index__15)
|
||||
WriteEscString(song.Listeners, buffer)
|
||||
buffer.WriteString(index__14)
|
||||
} else {
|
||||
buffer.WriteString(index__19)
|
||||
|
||||
}
|
||||
buffer.WriteString(index__15)
|
||||
WriteEscString(song.Song, buffer)
|
||||
buffer.WriteString(index__16)
|
||||
|
||||
}
|
||||
buffer.WriteString(index__12)
|
||||
|
||||
}
|
||||
buffer.WriteString(index__6)
|
||||
|
||||
}
|
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,65 +0,0 @@
|
||||
:go:func Index(mainSite string, status *radio.IcecastStatus, songs *[]radio.Song)
|
||||
|
||||
:go:import "dwelling-radio/internal/radio"
|
||||
|
||||
doctype html
|
||||
html(lang='en')
|
||||
head
|
||||
title Arav's dwelling / Radio
|
||||
meta(charset='utf-8')
|
||||
meta(http-equiv='X-UA-Compatible' content='IE=edge')
|
||||
meta(name='viewport' content='width=device-width, initial-scale=1.0')
|
||||
meta(name='theme-color' content='#cd2682')
|
||||
meta(name='description' content='Internet-radio broadcasting from under my desk.')
|
||||
link(rel='icon' href='/assets/img/favicon.svg' sizes='any' type='image/svg+xml')
|
||||
link(href='/assets/css/main.css' rel='stylesheet')
|
||||
script(src='/assets/js/main.js' defer='')
|
||||
body
|
||||
header
|
||||
svg#logo(viewBox='0 -25 216 40')
|
||||
text.logo Arav's dwelling
|
||||
text.under(y='11') Welcome to my sacred place, wanderer
|
||||
nav
|
||||
a(href=mainSite) Back to main website
|
||||
h1 Radio
|
||||
section
|
||||
small.player-links
|
||||
a(href='/filelist') filelist
|
||||
a(href='/playlist') playlist (.m3u)
|
||||
a(href='/live/stream.ogg') direct link
|
||||
a(href='http://radio.arav.top:8000/stream.ogg') direct link (http)
|
||||
a(href='http://wsmkgnmhmzqm7kyzv7jnzzafvgm7xlmlfvzhgorpapd5or2arnhuktqd.onion/live/stream.ogg') direct link (Tor)
|
||||
a(href='http://radio.arav.i2p/live/stream.ogg') direct link (I2P)
|
||||
a(href="https://dir.xiph.org/search?q=arav's+dwelling") Xiph
|
||||
| OGG 128 Kb/s
|
||||
audio(preload='none' controls='')
|
||||
source(src='/live/stream.ogg' type='audio/ogg')
|
||||
| Your browser doesn't support an audio element, it's sad... But you always can take the #[a(href='/playlist') playlist]!
|
||||
if status.ServerStartDate != ""
|
||||
p#radio-status On-air since
|
||||
time(datetime=status.ServerStartISO8601)= status.ServerStartDate
|
||||
else
|
||||
p#radio-status Radio is offline.
|
||||
p Now playing: #[span#radio-song #{status.SongName}]
|
||||
p Current/peak listeners: #[span#radio-listeners #{status.Listeners}] / #[span#radio-listener-peak #{status.ListenerPeak}]
|
||||
p
|
||||
small Notice: information updates every 45 seconds. But you can #[button(id='btn-update') update] it forcibly.
|
||||
if len(*songs) > 0
|
||||
section
|
||||
h2 Last 10 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
|
||||
p The largest number of simultaneous listeners was #[b 7] at #[time(datetime='2022-02-19') 19 February 2022], and the song was "Röyksopp - 49 Percent".
|
||||
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—2022 Arav <#[a(href='mailto:me@arav.top') me@arav.top]>
|
22
web/web.go
@ -6,9 +6,6 @@ import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// To install a Jade compiler: go install github.com/Joker/jade/cmd/jade@latest
|
||||
//go:generate $GOPATH/bin/jade -pkg=web -writer templates/index.jade
|
||||
|
||||
//go:embed assets
|
||||
var assetsDir embed.FS
|
||||
|
||||
@ -17,6 +14,21 @@ func Assets() http.FileSystem {
|
||||
return http.FS(f)
|
||||
}
|
||||
|
||||
func AssetsGetFile(path string) ([]byte, error) {
|
||||
return assetsDir.ReadFile("assets/" + path)
|
||||
func ServeAsset(path, mime, attachement string) func(http.ResponseWriter, *http.Request) {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if mime != "" {
|
||||
w.Header().Add("Content-Type", mime)
|
||||
}
|
||||
|
||||
if attachement != "" {
|
||||
w.Header().Add("Content-Disposition", "attachment; filename=\""+attachement+"\"")
|
||||
}
|
||||
|
||||
data, err := assetsDir.ReadFile("assets/" + path)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
w.Write(data)
|
||||
}
|
||||
}
|
||||
|