1
0

Compare commits

..

353 Commits

Author SHA1 Message Date
e092a34055
Index was translated. Also all necessary i18n code was added. 2024-09-17 04:06:22 +04:00
962bddaa0f
Dependency updated, also ctxi18n was added. 2024-09-17 04:05:47 +04:00
1beda9fe96
Version set to 24.38.0. 2024-09-17 04:05:17 +04:00
2179d74239
Updated @media and removed max-width for a video banner. 2024-06-25 04:14:51 +04:00
e148bad281
Updated a a-h/templ dependency version. 2024-06-25 03:54:26 +04:00
812af85f63
Changed a message in an audio element for in case it is not supported by a browser. 2024-06-25 03:52:38 +04:00
2eb79a86a8
Updated a run target in the Makefile. 2024-06-25 03:51:47 +04:00
e7be56e64f
Updated .gitignore to ignore a test directory. 2024-06-25 03:51:16 +04:00
e331370bdb
Simplifying configuration via introducing a -work-dir option. 2024-06-25 03:31:00 +04:00
41aea2112a
For note.svg the missing alt and title attrs were added. 2024-06-25 03:02:37 +04:00
3a9f39f7a8
listener.svg was renamed to headphones.svg. 2024-06-25 03:01:55 +04:00
f4c82925b4
Version set to 24.25.0 2024-06-20 01:39:35 +04:00
3d7e3b0193
Spaces was converted to tabs in templ. 2024-06-20 01:38:28 +04:00
398b38561a
Updated template. 2024-06-20 01:30:15 +04:00
d1bd7982ce
Reorganise imports in a template. 2024-06-20 00:50:37 +04:00
e3f7b10200
Add shadow to a play button. 2024-06-20 00:49:31 +04:00
c7ca05401f
Forgot to set a text color of logo. 2024-06-19 03:07:15 +04:00
99ef685e5e
Well, still need to detect a browser, at least, just a Firefox, found a way that doesn't trigger on chromium. 2024-06-19 01:30:01 +04:00
293e1a3126
Simplified a for loop for last songs list. 2024-06-19 01:03:51 +04:00
9b959eb7ab
Updated a play button. 2024-06-19 00:52:55 +04:00
5bc653f50a
Use writing-mode + direction instead of a deprecated appearance approach to make a range input vertical. 2024-06-19 00:10:54 +04:00
7e9e641330
Probably a successful attempt to fix an SVG logo sizing. 2024-06-19 00:09:54 +04:00
e5d1c6be8d
Remove unused .right class from main.css. 2024-06-18 19:51:28 +04:00
b8f6163cb8
Need to use RUnlock for RLock. 2024-06-18 19:50:56 +04:00
88ecf675b6
And same for a Playlist. 2024-05-22 04:07:33 +04:00
baad7da10d
After a research on how mutexes work, a mutex bcame embedded. And locks should be invoke from outside. 2024-05-22 04:05:15 +04:00
2facf9662a
Updated Makefile. Fixed DESTDIR, PREFIX and VERSION vars. FLAGS -> GOFLAGS. Added -linkmode and -extldflags. Also perform a build when invoke a run target if there are changes. 2024-05-22 03:43:12 +04:00
f265494ea8
Send only a response code without any text to Icecast and Ezstream. 2024-05-22 03:40:33 +04:00
b155b81064
Fixed a systemd service. 2024-05-16 02:32:52 +04:00
c21838314f
Version was set to 24.20.0. 2024-05-16 02:02:04 +04:00
8d3ec1a327
Since ListenersUpdate works with Icecast specifically, let's point it out in its name. 2024-05-16 01:59:26 +04:00
a728ea2164
Let's try to not add 5 seconds to Song.StartAt. 2024-05-16 01:44:45 +04:00
c8c153cd62
Don't print a hyphen in place of a song. 2024-05-16 01:43:59 +04:00
69f24a6b7b
Make use of the new icons, also add some styling for them. 2024-05-16 00:15:52 +04:00
38c3e11b07
Added new SVG icons, also optimised by removing unnecessary <?xml... ?> header. 2024-05-16 00:15:02 +04:00
f266e4fcf7
Restructurised Statistics. 2024-05-15 23:51:10 +04:00
51a0ef167c
Do not log songs whose artist and title tags are empty. 2024-05-13 01:14:05 +04:00
40d2993b03
Remove commented out go func() {}. 2024-05-13 01:13:05 +04:00
e2555d82e3
A title was changed for a filelist. 2024-05-12 04:02:10 +04:00
154682ea4f
A page layout was changed. 2024-05-12 03:58:35 +04:00
d2db23be5d
Now that only current number of listeners is being shown, send just it instead of a whole ListenerCounter. 2024-05-12 03:57:54 +04:00
c8f71b205b
For Song omit listeners and peak_listeners if they are zero. 2024-05-12 03:56:44 +04:00
a374dfd510
Changed layout for a song info. 2024-05-11 05:35:14 +04:00
5d042cdb3a
Fixed Last N songs section. 2024-05-11 05:34:32 +04:00
056f2c022b
max_listeners -> peak_listeners. 2024-05-11 05:05:27 +04:00
904af3107a
Show overall and peak numbers of listeners for a currently playing song. 2024-05-11 05:03:20 +04:00
872e8f4978
A MaxListeners field of a Song struct was renamed to a more logically appropriate PeakListeners. 2024-05-11 04:52:40 +04:00
4feeb518fc
Added methods for a ListenerCounter struct to access a current peak and overall numbers of listeners. 2024-05-11 04:42:06 +04:00
08203c4be0
Also return a current peak listeners number. 2024-05-11 04:38:08 +04:00
cf90bba297
Updated logic in DJ handlers. Also let's give a try to not run this updating code in a PlaylistNext() handler in a separate thread. Also funnier handling of audio tag missing. 2024-05-11 04:36:23 +04:00
454df3bb74
Write listeners statistics for a current song in case of an interrupting signal. 2024-05-11 04:22:06 +04:00
d94c029fda
In ListenerCounter struct also keep track of overall number of listeners for a song and its max simultaneous number of listeners. These parameters are being reset with a Reset() method that returns its values. 2024-05-11 04:21:00 +04:00
1682362779
Do not alter Song struct listeners with its methods, use a ListenerCounter to populate Listeners and MaxListeners. Eliminates the need for a mutex and SetFrom(), UpdateMaxListeners() and IncListeners() methods. 2024-05-11 04:18:28 +04:00
3b22643733
In an index.templ a range for was replaced by good old C-style for loop to prevent copying by value, and to get rid of that warning about mutex copy. 2024-05-10 04:59:18 +04:00
84e23b5b85
Remove an http.Error() func call from an anonym func that is spawned in a separate thread because that w (http.ResponseWriter) could be not available. 2024-05-10 04:39:56 +04:00
23f53609ae
Forgot to add preload="none", controls and playsinline to an audio tag. 2024-05-10 04:37:25 +04:00
c49c5b0112
No need in URL escaping since these links are safe already, so just cast them to SafeURL. 2024-05-10 02:48:47 +04:00
0a55b115fb
In main.js fixed a bug and removed a multiplication by 100 for volume control. 2024-05-10 02:23:09 +04:00
fb5c46381e
Remove a comment at the end of index.templ. 2024-05-10 00:13:12 +04:00
bf643e6cf6
Updated go.{mod,sum}, Makefile and .gitignore. 2024-05-10 00:12:14 +04:00
665a8e8c75
Current state of main.go. 2024-05-10 00:11:51 +04:00
670b6ea032
Jade was replaced with Templ. Also some changes were made to a layout. A ServeAsset() handler was introduced. 2024-05-10 00:11:33 +04:00
7c34a3a632
A func Site was added to return a schema://host string. 2024-05-10 00:09:35 +04:00
ec6c1df474
For Song struct a method SetFrom was added. 2024-05-10 00:08:59 +04:00
c1d64700ff
A separate MostListenedSong struct is not needed anymore. 2024-05-10 00:07:57 +04:00
71844a106d
Updated internal/http. 2024-05-10 00:07:07 +04:00
d936b41483
Now http2 is a separate option in nginx.conf. 2024-05-10 00:04:06 +04:00
816a8d88a7
Introduce a statistics DB implemented in SQLite3. 2024-05-09 23:53:33 +04:00
9ef0771389
SongList struct was removed. A Song struct got UpdateMaxListeners() and IncListeners() methods. A mutex was Introduced for it. A Listeners field to store overall number of listeners during a song playing was introduced as well. A comment for the Song struct was removed. 2024-05-09 20:30:17 +04:00
ec5b6a028b
In ListenerCounter struct int -> int64. 2024-05-09 20:18:06 +04:00
0244f6afd5
A unified ServeAsset() func was introduced. AssetsGetFile() now will panic if a file doesn't exists, because it must not be the case. 2024-04-20 18:54:05 +04:00
5575408560
Remove unused inode watcher. 2024-04-20 18:25:04 +04:00
92ae8e13f0
Remove unused radio-listener-dis/connect scripts. 2024-04-20 18:24:45 +04:00
b0f24b25b9
Version set to 24.10.0. 2024-03-06 04:49:27 +04:00
c795c2b87b
Updated a year in a Copyright. 2024-03-06 04:48:32 +04:00
dbba53c0fa
Fixed a typo on a description for -fallback-song arg. 2024-03-06 04:44:55 +04:00
34d4da946b
In sitemap.xml updated a loc's lastmod for an index page. 2024-03-06 04:33:30 +04:00
10ca532844
2023 -> 2024 in a footer. 2024-03-06 04:31:46 +04:00
8c0d070b41
Fixed a notice, since now info is being updated on a song reach its end. 2024-03-06 04:31:19 +04:00
78b2740c58
Make a radio volume slider vertical on chrome and chrome-like browsers. 2024-03-06 04:21:53 +04:00
0bd91c3c1f
Adding a muted attribute to a video tag makes it actually play on chrome and chrome-like browsers. 2024-03-06 04:20:43 +04:00
bc85efb3db
In main.js just call updateStatus() without waiting if there is a current song. 2023-10-09 01:44:46 +04:00
1d7cce75d5
Removed radio.vars.liq from backup= in PKGBUILD. 2023-10-09 01:23:08 +04:00
a18d5432b5
In ezstream.xml port 8001 was replaced by 8000. 2023-10-09 01:22:03 +04:00
7d4de3f6a6
Add 5 seconds to time.Now() to more accurately represent the time when a song actually starts to play. 2023-10-09 01:15:05 +04:00
46dbe72253
In icecast.xml authentication added missing s to http in URLs. 2023-10-09 01:03:42 +04:00
b76d861ab6
In icecast.xml set listen-port to 8000. 2023-10-09 00:34:44 +04:00
c43bd5ea92
In nginx.conf changed Icecast's port to 8000. And set to listen on 192.168.144.2:8000. 2023-10-09 00:30:16 +04:00
c9e30f76e6
Let's actually hide RWMutex's methods to clean a namespace. 2023-10-09 00:21:44 +04:00
5de3f8e9af
In oggtag.go bufTail was renamed to a more suitable bufLast. 2023-10-09 00:15:21 +04:00
3ba47c2d41
Updated sitemap.xml. 2023-10-09 00:12:32 +04:00
54fb77c8ae
In Makefile -fallback-song added in a run target. 2023-10-09 00:10:39 +04:00
fb46a912f7
In song.go updated comments. 2023-10-09 00:05:28 +04:00
f960d8516a
Since SongList.Current() now return a copy then this code is no longer needed. 2023-10-08 23:55:55 +04:00
e63f0aead3
Make a SongList.Current() return a copy of a current song. 2023-10-08 23:52:10 +04:00
04e09dd800
Removed debug fmt.Println from ListenersUpdate. 2023-10-08 23:07:04 +04:00
c548bc3382
Add a default source password to ezstream.xml. 2023-10-08 23:04:24 +04:00
c8e1176d8d
In nginx.conf fixed location path. 2023-10-08 22:51:38 +04:00
27affc5e73
Let's actually remove unused handlers. 2023-10-08 22:50:36 +04:00
bbfdb8c956
In nginx.conf added allow 127.0.0.1. Also found out a proxy_bind option. With it Icecast will see client's IP. 2023-10-08 22:49:04 +04:00
490eed2a24
Do not copy radio-listener-* scripts. 2023-10-08 22:20:16 +04:00
a1b951845f
Version set to 23.40.1. 2023-10-08 22:19:49 +04:00
dbf71f3f64
Fixed listeners number update, well, on-dis/connect in mount is for a SOURCE. To get listeners you need to use <authentication type="url">.
Also, disabled all listener handler, added POST /api/listener/icecast handler.
2023-10-08 22:17:56 +04:00
635fc4ec1d
Fixed a typo in Makefile. 2023-10-08 04:30:46 +04:00
5c061b82f0
In radio-* scripts fixed the malformed URLs. 2023-10-08 04:30:19 +04:00
7f1a49ec91
In main.js also update MLS. 2023-10-08 04:09:41 +04:00
2ea21e12b2
In index.pug added ids for MLS fields. 2023-10-08 04:09:05 +04:00
24ce8115e7
MostListenedDateFormat was changed. 2023-10-08 04:08:27 +04:00
b9ecf9a3c6
In Makefile un/install target added an override for an Icecast service. 2023-10-08 03:12:52 +04:00
740d47fb9d
Added an override for icecast.service. 2023-10-08 03:06:53 +04:00
730bc0599f
In ezstream.service changed order in Requires= and After=. 2023-10-08 03:04:55 +04:00
a567007463
In ezstream.service Requires= also added a dwelling-radio.service. 2023-10-08 03:02:26 +04:00
1efa8b73ed
In ezstream.service After= added a dwelling-radio.service. 2023-10-08 03:01:31 +04:00
83955866c9
Alter an artist tag to tell that there is nothing left to play, so a fallback song is used to fill a silence. 2023-10-08 02:56:19 +04:00
22a6200ddb
In radio.service added a -fallback-song option. 2023-10-08 02:52:57 +04:00
f0aa00b932
Added a fallback song option. Altered a PlaylistNext() header to use it. 2023-10-08 02:52:37 +04:00
e0e5e314f1
Added icecast.xml. 2023-10-08 02:40:54 +04:00
d549393e42
Updated radioctl. Changed a name for ezstream playlist gen. Added commands to reaload a playlist for ezstream and dwelling-radio. 2023-10-08 02:39:23 +04:00
099824bfed
Version set to 23.40.0. 2023-10-08 02:05:18 +04:00
6311b998d4
In nginx.conf deny access to /api/{listener,playlist} from outside, scripts are connecting directly to theservice via a unix socket. 2023-10-08 02:04:24 +04:00
1599d502c1
Decided to keep handlers for /api/{song,songs,mostlistenedsong} but disable them. Everything can be fetched at an /api/status endpoint. 2023-10-08 02:03:06 +04:00
eb65071b51
In OggFile.GetTag() was added another layer of indexing, this time we are looking for a capitalised tag name (e.g. Artist). Why there's no consistency in OGG vorbis tags..? *Sigh* 2023-10-08 01:23:28 +04:00
0d8032da46
MostListenedSong was rewritten. 2023-10-08 00:52:40 +04:00
d84d985962
In ListenerCounter a sync.Mutex was replaced by a sync.RWMutex. 2023-10-07 23:21:00 +04:00
da4e97f1aa
In Playlist.load() a check for an empty file was added. 2023-10-07 23:17:42 +04:00
131ea35341
A reduntant method Song.ArtistTitle() was removed. 2023-10-07 21:31:00 +04:00
4a4c228984
In main.js added a call to updateStatus() before starting an interval. 2023-10-07 21:30:19 +04:00
eba2c7d18f
Do not install liquidsoap's files. 2023-10-07 18:03:12 +04:00
141e0f3717
In radio-listener-* vars was removed. 2023-10-07 06:04:49 +04:00
0590eaa1c5
Fixed and simplified radio-fetch. 2023-10-07 06:03:16 +04:00
65ec8c1db2
In index.pug avoid nil pointer dereferencing. 2023-10-07 05:35:16 +04:00
b8afed6e1b
In PlaylistNext() CheckAndUpdateMostListenedSong() call was moved after songList.Add to avoid nil pointer dereferencing. 2023-10-07 05:34:50 +04:00
f0420e9bcd
main.js was rewritten and now it displays estimate and total duration of a current song. 2023-10-07 05:33:57 +04:00
238705b00f
In an index.pug file make a copy of a current song for thread safety. 2023-10-07 05:25:13 +04:00
020676f113
While I want thread safety I also want a not set current song to not appear in a status JSON object. 2023-10-07 05:23:45 +04:00
07b0199193
In a PlaylistNext() handler pass a current song as a copy. 2023-10-07 05:20:27 +04:00
f53f30963d
sync.Mutex was replaced by sync.RWMutex for SongList. 2023-10-07 05:16:16 +04:00
1d50bbe790
In MarshalJSON() for Song a string field Duration was removed, just msec left. 2023-10-07 05:11:58 +04:00
62f6e3b976
Updated SIG* handling code in main(). 2023-10-06 04:58:58 +04:00
3251d9e983
In .gitignore added a file mls-test. 2023-10-06 03:17:59 +04:00
2f96976f19
In DJHandlers.Status in an anonymous struct removed a pointer to Song. 2023-10-05 18:54:44 +04:00
c33ff03bd8
Updated using of SongList.Current() in an index.pug file. 2023-10-05 18:53:57 +04:00
9beb179cc0
SongList.Current() now returns a copy of Song instead of a pointer. 2023-10-05 18:52:18 +04:00
9b14d7846b
In CheckAndUpdateMostListenedSong() accept Song by value instead of a pointer. 2023-10-05 18:19:41 +04:00
c7f6b3072d
Embedded a sync.Mutex to a ListenerCounter. 2023-10-05 18:18:48 +04:00
f1eaba016f
In DJHandlers.ListenersInc() a code for updating max listeners was replaced by a function call to UpdateCurrentMaxListeners(). 2023-10-05 18:05:27 +04:00
eeadbc4f96
To a SongList a method UpdateCurrentMaxListeners() was added, it checks and updates max listeners for a current song in a thread safe manner. 2023-10-05 18:04:15 +04:00
b659464118
For Song.ArtistTitle() a comment was updated. 2023-10-05 18:02:33 +04:00
8079058b5c
Added a handler for SIGHUP signal that is used for playlist reloading. 2023-10-05 17:37:13 +04:00
f8fd13f8ed
In playlist load() add an error message. 2023-10-05 17:36:02 +04:00
e0fa65fbd7
Decided to remove inotify watcher from playlist. 2023-10-05 17:31:12 +04:00
8587225dfc
Added SIGABRT to doneSignal. 2023-10-05 17:26:48 +04:00
6ae8a40493
In oggtag.go a struct named OggFIle was created, ReadFile turned into a constructor NewOggFile. And GetTag and GetDuration turned into methods. 2023-10-05 17:26:13 +04:00
10bc3a3785
In Playlist embed a sync.Mutex instead. 2023-10-04 18:37:13 +04:00
6ce4700420
In index.pug: songList.Len() was replaced with songList.MaxLen(). 2023-10-04 18:15:09 +04:00
1c6d288cd7
In song.go: added comments; embed sync.Mutex in SongList instead of having it as a field; changed a json field name duration_milliseconds to duration_msec; listMaxLen field was renamed to maxLen; Len() was renamed to MaxLen(). 2023-10-04 18:10:17 +04:00
20b8b62b73
In an index.pug file added a tbody tag to a table. 2023-10-02 18:20:07 +04:00
98ed4035a7
In ListenersDec() handler added a missing return. 2023-10-02 18:09:25 +04:00
0bf81f93c8
In Song.DurationString() updated format. 2023-10-02 18:08:57 +04:00
64c2868fcf
Updated CheckAndUpdateMostListenedSong() func. 2023-10-02 15:04:51 +04:00
1990f1c7f0
In a PlaylistNext handler return a \n character as well. 2023-10-02 15:03:47 +04:00
29714b30ca
A file listeners.go was renamed to listener_counter.go. 2023-10-02 04:33:11 +04:00
8c510e6958
Fixed a dwelling-radio intake. Replaced a default playlist with this one. 2023-10-02 04:10:15 +04:00
44d0f6fc3b
Updated args in radio.service. 2023-10-02 04:03:39 +04:00
fe61d71149
Removed an unneeded radiodj.service file. 2023-10-02 04:03:23 +04:00
976ad683ca
Updated tools/radio-*. 2023-10-02 04:00:59 +04:00
c2c8c82212
Updated main.js. Added a new update() function that does everything. Not completed yet. 2023-10-02 03:56:57 +04:00
35429e57d2
In an index.pug added span#radio-duration. 2023-10-02 03:55:51 +04:00
9757f5f748
Oh, time.Duration works a little differently... 2023-10-02 03:55:17 +04:00
f1e2eca876
Added a DurationString() method for a Song that returns time in format 04:05 or 15:04:05 if a song is one hour or more long. 2023-10-02 03:52:26 +04:00
010774d775
Return a formatted string alongside with duration in milliseconds. 2023-10-02 03:48:31 +04:00
437403aa9e
From Makefile dwelling-radiodj was purged completely. 2023-10-02 03:22:06 +04:00
0519bc979c
Status() and LastSong() handlers was removed from main handlers. 2023-10-02 03:20:48 +04:00
3e0a1fe181
Updated index.pug. 2023-10-02 03:19:48 +04:00
bd9b80076a
Updated CheckAndUpdateMostListenedSong() to work with a new Song struct. 2023-10-02 03:19:23 +04:00
58b3d18288
Pass a SongList instead of instantiating. 2023-10-02 03:18:54 +04:00
f9d85d45f5
Song_ was renamed to Song. Also added Len() method to a SongList. 2023-10-02 03:18:30 +04:00
d0722131df
Added handlers and flags and instantiated all need stuff. Removed flags for icecast. 2023-10-02 03:16:17 +04:00
5e8e9943f9
Icecast-specific code was removed, because now it all is controled inside a service. 2023-10-02 03:15:05 +04:00
0245145a64
tools/radiodj-* was renamed to tools/radio-*. 2023-10-02 03:14:12 +04:00
698d7787e6
A separate executable dwelling-radiodj was removed. 2023-10-02 03:13:39 +04:00
f0438ff822
In ListenersGet() text/plain was replaced by application/json. 2023-10-02 02:35:01 +04:00
d2851c6fb8
Added http.Error() to a PlaylistNext() handler. 2023-10-02 02:29:18 +04:00
f709ac4b02
Added a MostListenedSong() handler. 2023-10-02 02:28:46 +04:00
8322d2a7a8
In Status handler: added missing Content-Type; Added most listened song; Removed with-list query, just return everything; Added http.Error(). 2023-10-02 02:22:14 +04:00
1b38c5d86c
Added a /mostlistenedsong endpoint. 2023-10-02 02:08:38 +04:00
7e4ca6990b
Removed Sub for a /playlist endpoint. 2023-10-02 02:08:16 +04:00
3ed0f7b62a
Added MarshalJSON() method to MostListenedSong. 2023-10-02 02:07:39 +04:00
ddf5f3d0f7
Added check if mostListened set. 2023-10-02 02:07:08 +04:00
7cc228968d
In handlers made use of oggtag.ReadFile() func. Also updated func names. 2023-10-02 01:27:14 +04:00
c054c3b32e
Added a duration field to Song_ struct. 2023-10-02 01:26:22 +04:00
a741e3eb9a
In oggtag a prefix Ogg was removed from funtions and consts. Also added the consts that hold len of sequences. 2023-10-02 01:25:43 +04:00
17eeebf1f3
Implemented OggReadFile() func to get raw data from an OGG file. Also a func OggGetDuration() was implemented that returns duration of a song. 2023-10-02 01:08:24 +04:00
aaf14e0c83
A /status endpoint was added that returns all data available. At this time cur song, listeners cur and peak, and a list of prev songs. 2023-10-01 22:07:04 +04:00
96baa42fe0
/song endpoint was broken into two, the second one is /songs that returns a list of prev played songs, and /song just a current one. 2023-10-01 22:05:39 +04:00
381da691a4
For ListenerCounter implemented MarshalJSON() method. 2023-10-01 22:03:26 +04:00
2be516236b
SongList.Current() will return nil if current song wasn't set yet. 2023-10-01 22:02:54 +04:00
dea283df27
Listeners struct was renamed to ListenerCounter. 2023-10-01 20:32:07 +04:00
dc3658d6de
Decided to implement an inotify watcher for a playlist file. Dunno if it works correctly, but it seems like, not tested yet. 2023-10-01 06:44:36 +04:00
4faf2a0309
In Playlist a method Load() was renamed into load(), and a public Reload() method created. 2023-10-01 06:40:05 +04:00
9f361be6c4
On SIGHUP reload a playlist file. 2023-10-01 06:38:21 +04:00
6ebe24fa5a
In SongList Add check if a current song wasn't set yet. 2023-10-01 06:37:47 +04:00
10b817df2e
In oggtag handle a case when tag name are in upper case. 2023-10-01 06:36:47 +04:00
2ea74277ab
Added run-dj test target in Makefile. 2023-10-01 06:13:15 +04:00
ae4b2c9dee
In radiodj main fixed loading of an MLS file. 2023-10-01 06:10:27 +04:00
d8aa7076c3
In Makefile added radiodj bash scripts. 2023-10-01 06:05:09 +04:00
9673da35ce
In Makefile added radiodj. 2023-10-01 05:59:33 +04:00
707b45e4ad
Added a systemd unit for radiodj. 2023-10-01 05:58:49 +04:00
59d2c1dbf9
Just found a typo in a directory name. 2023-10-01 05:55:01 +04:00
a4f7366213
In handlers made use of a SongList. 2023-10-01 05:43:52 +04:00
8c1e46900a
Added a SongList and Song_ types. An underscore is temporary and is because there is an another Song type in an icecast.go file. 2023-10-01 05:42:55 +04:00
cbfc1549ed
Added a mutex for Listeners struct for thread safety. 2023-10-01 04:18:43 +04:00
5a058aa706
Added most listened song and song list length. Also removed a year 2022 from a version string. 2023-10-01 04:17:49 +04:00
07d11ce3ff
A dummy Song handler was added. 2023-10-01 03:35:33 +04:00
18bd1fb12d
Implementation of a simple OGG tag reader. 2023-10-01 03:34:16 +04:00
ca33de09cc
Added a playlist instance and pass a PlaylistNext handler. 2023-10-01 01:33:55 +04:00
88b5f21343
Added playlist handler. 2023-10-01 01:32:57 +04:00
3d2b172deb
Replaced unused r arg with _ in handlers. 2023-10-01 01:32:17 +04:00
2e6f9f27c4
Added a Playlist struct. 2023-10-01 00:47:07 +04:00
855397ad0c
In a bash script for disconn handling a resp code 204 was replaced by 200. 2023-10-01 00:02:04 +04:00
8677af243c
Added main for a radiodj service. 2023-10-01 00:00:39 +04:00
978a2602d8
Added handlers used in a radiodj service. Currently just for listeners. 2023-09-30 23:59:48 +04:00
61e2f6d8fd
Added bash scripts that are used in Ezstream to fetch the next song, and for Icecast to handle conns/disconns. 2023-09-30 23:59:06 +04:00
328dbc644e
A struct to hold a number of current and peak listeners was added. 2023-09-30 23:57:31 +04:00
c3b3604a6f
Found and fixed a typo in nginx.conf. And added a X-Real-IP header for :8000 HTTP port. 2023-09-23 23:09:47 +04:00
e3f555a01a
In PKGBUILD provides= and conflicts= options were removed since there is no need to add $pkgname itself. It implicitly is there. 2023-09-23 03:33:47 +04:00
758bc50e50
Added an intake for a future dwelling-radiodj utility. 2023-09-21 05:01:33 +04:00
18b3ecd135
In PKGBUILD added ezstream.xml to a backup= option. 2023-09-21 03:31:48 +04:00
cbf00cc0f8
Fixed a typo in Makefile. 2023-09-21 03:24:46 +04:00
8a182a7fb0
In ezstream.service added an ExecReload option to reread a playlist. 2023-09-21 02:11:05 +04:00
c760b5ce99
In ezstream.service added missing -c flag in ExecStart. 2023-09-21 02:06:32 +04:00
d2df4b1bf7
Added description of pe command in radioctl. 2023-09-21 02:05:17 +04:00
fbcd656348
Version set to 23.38.0. 2023-09-21 02:02:48 +04:00
ef740eeeca
Added ezstream in Makefile. 2023-09-21 02:02:13 +04:00
bb2c7e4e6d
Due to all the strange headaches with liquidsoap and since I don't have any jingles or live streams I added support for ezstream. 2023-09-21 02:00:28 +04:00
89f7d0e49c
In liquidsoap.service a switch name was changed to default. 2023-09-20 03:39:36 +04:00
f52d52b1b3
In radioctl fixed -c param for cut in playlist. 2023-09-20 03:39:02 +04:00
b86cd122b3
In radio.liq *.set() replaced by := for settings. 2023-09-20 03:38:22 +04:00
b02d89836f
In radio.liq replaced fade.initial and fade.final by fade.in and fade.out. 2023-09-20 03:37:53 +04:00
4b8ab610d5
Added comments for separator* consts. 2023-09-17 19:18:18 +04:00
fea96118bc
Set a new path to radio files (where playlists, filelist, and music are stored). 2023-09-17 19:15:56 +04:00
ad5608375c
Updated httpr to v0.3.2. 2023-09-15 04:39:51 +04:00
4a620c30fa
Version set to 23.37.0. 2023-09-15 04:24:31 +04:00
b1428812c8
Comment out log in a benchmark. 2023-09-15 04:24:12 +04:00
60045d4ca2
icecastLastPlayedSongs was replaced by icecastCurrentSong that fetch only one song at the very end of a playlist.log. Also added a benchmark for this func. 2023-09-15 04:19:28 +04:00
7bb91cfcbe
A little reorganisation in icecast.go. 2023-09-15 03:20:12 +04:00
e8ad10a16d
In CheckAndUpdateMostListenedSong pass cur and prev songs by pointer. 2023-09-15 03:17:44 +04:00
2cf6e1a6cb
append() was replaced with more efficient copy() (only 1 allocation with make). 2023-09-14 18:20:47 +04:00
978b4c6454
Removed unused error returning (error is always nil). 2023-09-14 17:59:14 +04:00
a6f92b56da
Reorganised Handlers struct fields. 2023-09-14 17:58:35 +04:00
eccf0ff4e9
Removed unused const FormatISO8601. 2023-09-14 17:51:15 +04:00
fd3775d5fb
In nginx.conf added b32.i2p address to a server_name option. 2023-09-14 03:15:42 +04:00
c020031127
Do not overwrite mostlistenedsong file if a song wasn't changed. 2023-09-10 18:23:06 +04:00
cbf8eb8747
Made a ${TARGET} target dependable on a web/*.pug.go target. Also added ifeq ... endif check for jade compiler. 2023-09-10 18:18:41 +04:00
812d374354
Removed all target and fixed .PHONY. 2023-09-10 18:16:45 +04:00
39e872256b
Added -buildmode=pie for security. 2023-09-10 18:16:07 +04:00
8385e36340
Version set to 23.34.0. 2023-08-22 17:34:25 +04:00
21f48e2366
Don't show elapsed time under a play button. 2023-08-22 17:11:15 +04:00
8a5666743f
Further code shortening in main.js! 2023-08-22 16:50:27 +04:00
790b08e22c
Changed formatting in main.js. Also innerText was replaced with textContent. 2023-08-22 16:37:00 +04:00
28c2cf21a0
$() func was made into an arrow func. 2023-08-22 16:35:43 +04:00
bc9437cd2c
Replaced everything with id. Also #last-played renamed to #last-songs. Everything in #player got a prefix radio-. 2023-08-22 04:37:17 +04:00
5e247f8d5c
Refactored main.js. 2023-08-22 04:18:30 +04:00
82e2720156
Removed excessive div. 2023-08-22 04:18:05 +04:00
3120855858
In updateLastPlayedSong ' symbol replaced by ". 2023-08-22 03:41:09 +04:00
696bcb9e89
Removed unused radio-status from JS. 2023-08-22 03:40:48 +04:00
865bdd7eca
For update button replaced getElementById with getElementsByName. 2023-08-22 03:40:09 +04:00
472a68768c
Updated player's JS. 2023-08-22 03:39:29 +04:00
3837ca9c56
Updated CSS. 2023-08-22 03:38:51 +04:00
347fb97cf2
Changed naming of tags. 2023-08-22 03:38:19 +04:00
c0018f3d50
In sitemap.xml updated lastmod for /. 2023-08-21 18:40:04 +04:00
b40942b5fb
Made volume slider vertical. 2023-08-21 18:33:00 +04:00
4b68b93e7b
Removed vol up and down buttons. 2023-08-21 18:32:39 +04:00
e082527edc
In case of an http error show Offline in a Now playing field. 2023-08-21 18:31:06 +04:00
9ac60e9f28
Don't show server start date and time on a page. 2023-08-21 18:30:21 +04:00
5c16576fa4
Removed one nesting level by removing select statement. 2023-08-21 06:08:21 +04:00
76b3e2e8ad
For some log messages changed capital letter to lowercase. 2023-08-21 05:45:12 +04:00
60fae56a28
Set min-width for a player. Sorted properties. 2023-08-21 05:22:22 +04:00
007691c534
Defer server Stop and writing of MLS instead of call after doneSignal fired. 2023-08-21 04:39:38 +04:00
e53fb45bd9
Show zeroes in cur-time. 2023-08-20 18:22:33 +04:00
60f59f423d
Version set to 23.33.2. 2023-08-20 18:20:48 +04:00
f7fcfc1ba0
Set column direction for player on narrow screens. 2023-08-20 18:06:14 +04:00
f9b2afdd50
Added log for failed attempts on retrieving songs from playlist.log. 2023-08-20 18:03:51 +04:00
48bbd32eb8
Remove a debug print accidenatlly commited. 2023-08-20 03:27:08 +04:00
075e171b40
Populate a freshly created cache with songs if there are any. 2023-08-20 03:26:14 +04:00
7df94bef12
Only then call make on cache if it is nil. 2023-08-20 03:20:49 +04:00
e402829724
Removed repeated ProtectProc= from services. 2023-08-20 02:45:47 +04:00
8ab04a7906
Forgot to revert src back. Set version to 23.33.1. 2023-08-20 02:38:05 +04:00
681d1afc68
Shortened a code for changing maskImage. 2023-08-20 02:23:00 +04:00
af0cbda364
Added -webkit-mask-image to work on Chrome. 2023-08-20 02:19:04 +04:00
3566e90b1a
Correct audio stopping. 2023-08-20 02:10:29 +04:00
b874b533ca
Version set to 23.33.0. 2023-08-20 01:22:05 +04:00
8d546917d3
Added SVG play and stop buttons. 2023-08-20 01:21:01 +04:00
dbaa813815
Added styling for a custom player. 2023-08-20 01:20:41 +04:00
8d372645ce
Added JS code for a custom player. 2023-08-20 01:20:28 +04:00
4bdbc28f43
Added custom player that is diplayed if JS is enabled. 2023-08-20 01:20:13 +04:00
949e96d195
Removed unnecessary styling for an audio element. 2023-08-20 01:17:11 +04:00
8a9eead4c1
Added -ic-url option in a Makefile. 2023-08-20 01:16:11 +04:00
6c46d2cf5f
Changed path to a mostlistenedsong file. 2023-08-19 19:48:28 +04:00
64353210d7
Added new options to liquidsoap.service as well. 2023-08-19 19:47:04 +04:00
4d93cb26a0
Rearranged options alphabetically in radio.service. 2023-08-19 19:46:42 +04:00
a31d93aa70
Further hardening in a radio's systemd unit. 2023-08-19 19:40:24 +04:00
bb5ec9f791
Version set to 23.32.0. 2023-08-13 00:59:47 +04:00
9b7ea6984e
Added stopit.mp4 banner at the index page. :) 2023-08-13 00:59:07 +04:00
83a0277d71
make run command changed to use new args. 2023-08-13 00:48:40 +04:00
cfb9837974
Updated httpr to version 0.3.1. 2023-08-12 20:37:19 +04:00
5bf80666d6
Removed explicit type for showVersion var. 2023-08-06 03:44:06 +04:00
dc52b7b3be
/filelist in sitemap.xml was changed to changefreq always. 2023-08-06 03:37:56 +04:00
ab0634ba17
Removed auto-generated jade.go from repo and ignored it. 2023-08-06 03:35:32 +04:00
53c28dc409
For ffmpeg optdepend updated description in PKGBUILD. 2023-08-06 03:26:43 +04:00
8acc18dfba
In PKGBUILD removed radio.yaml from backup=. 2023-08-06 03:25:41 +04:00
bf79f77788
Added mod=readonly flag. Removed config.yaml from install target. 2023-08-06 03:25:05 +04:00
7a9c673721
From go.mod and go.sum removed yaml.v3 and updated httpr packages. 2023-08-06 03:22:58 +04:00
1df162445e
Removed configuration file. 2023-08-06 03:19:26 +04:00
7518420289
Version was set to 23.31.0. 2023-08-06 03:17:48 +04:00
a1bf9cf30a
Use flags instead of config file. Also replaced HttpServer with a new code. 2023-08-06 03:17:18 +04:00
832387f663
A little refactoring in main.go. 2023-07-22 23:17:45 +04:00
4cc4d0138a
Return an empty IcecastStatus struct instead of nil in case of an error. 2023-07-22 23:08:18 +04:00
877b5d4013
Playlist file renamed to playlist.m3u. 2023-07-22 22:58:42 +04:00
46b78b4667
In a Makefile added a rm liquidsoap service statement in an uninstall target. 2023-07-22 22:58:17 +04:00
64d2347eef
Uncomment Onion-Location header. Removed a separate I2P listen statement. 2023-07-22 22:49:43 +04:00
b05b878910
Version set to 23.29.0. 2023-07-22 22:16:28 +04:00
38f04aa9f8
Removed code for liquidsoap. 2023-07-22 22:15:24 +04:00
4512e6aa53
In radioctl convert added check for existing ogg file. 2023-07-08 17:41:31 +04:00
abf815ddf3
Added missing break;; 2023-06-25 22:10:44 +04:00
94e777bbd4
Added duration cmd to radioctl. 2023-06-25 22:07:32 +04:00
2c9117dbb6
Send SIGINT to stop Liquidsoap correctly. With default SIGKILL it hangs. 2023-06-13 00:40:32 +04:00
205eb0ab76
Decided to move privacy statements on a separate page on a main site. 2023-06-12 22:01:25 +04:00
12a7c7732b
Now if a list is emty and a table doesn't contains a tbody element it will be correctly created and a song will be added. 2023-06-12 21:50:10 +04:00
f05fe5d888
No-no-no, service itself demands a runtime dir. 2023-06-12 21:38:35 +04:00
b4d38e6146
In logrotate truncate file instead of recreating it. 2023-06-12 21:35:24 +04:00
914784bb9f
Run liquidsoap as a separate service instead. Added a -no-liquidsoap flag to radio service. systemd.service renamed to radio.service. Version was set to 23.24.0. 2023-06-12 21:34:56 +04:00
3ef213ab0d
Changed main a little. 2023-05-27 00:36:23 +04:00
cac5751ede
Removed AssetsFS() since it is not needed anymore. 2023-05-27 00:36:03 +04:00
f6b9a511ae
Oh, forgot to change jade to pug. 2023-05-27 00:35:27 +04:00
de4e5204e9
Removed jade check in PKGBUILD. Updated version. 2023-05-26 23:58:13 +04:00
66df3e0ea8
Removed httprouter dependency in go.mod/.sum. 2023-05-26 23:56:33 +04:00
17b5ab48ca
Use httpr instead of httprouter. 2023-05-26 23:56:00 +04:00
8b439bbd5a
Removed httprouter. 2023-05-26 23:55:40 +04:00
84e25ae7a2
Added web/*.jade.go target to Makefile. 2023-05-26 23:55:15 +04:00
898642dfa5
Changed icecast port in radio.vars.liq. 2023-05-26 23:37:45 +04:00
1dd1ffd83e
Changed icecast port in nginx.conf to 8001. 2023-05-26 23:03:52 +04:00
af5bafea5c
VERSION isn't used in an install make target. 2023-05-21 23:39:39 +04:00
f4bbfad9a9
Week part of version was incremented to 20. 2023-05-21 23:11:45 +04:00
3e513087ca
Let's dynamically calculate bufferSize with 320 bytes per line. 2023-05-21 23:11:15 +04:00
95a6da8f5d
Removed unneeded fields from PKGBUILD. 2023-05-21 22:49:24 +04:00
47bc1a8e02
Added sitemap.xml. 2023-05-21 22:44:09 +04:00
2987cf4a2a
Moved RobotsTxt handler out of Handlers struct. 2023-05-21 22:42:17 +04:00
ca4391784a
Added /favicon.svg endpoint. 2023-05-21 22:35:37 +04:00
7a12928a56
Version up in PKGBUILD. 2023-05-02 17:48:05 +04:00
15dc540c0e
Fixed a typo in radioctl. 2023-05-02 17:47:36 +04:00
1517060296
Make use of VERSION var in Makefile. 2023-05-02 17:38:10 +04:00
d1253242d4
Make use of $pkgver in PKGBUILD. Pass VERSION var to Makefile. 2023-05-02 17:36:04 +04:00
62 changed files with 1796 additions and 1146 deletions

3
.gitignore vendored
View File

@ -1,5 +1,6 @@
bin/* bin/*
!bin/.keep !bin/.keep
.vscode .vscode
*.pug.go
*.log *.log
*_templ.go
/test

View File

@ -3,39 +3,61 @@ TARGET:=dwelling-radio
SYSDDIR_ := ${shell pkg-config systemd --variable=systemdsystemunitdir} SYSDDIR_ := ${shell pkg-config systemd --variable=systemdsystemunitdir}
SYSDDIR := ${SYSDDIR_:/%=%} SYSDDIR := ${SYSDDIR_:/%=%}
DESTDIR:= DESTDIR ?=
PREFIX:=/usr/local PREFIX ?= /usr/local
FLAGS:=-modcacherw -trimpath VERSION ?= 24.38.0
LDFLAGS:= -ldflags "-s -w -X main.version=23.10.1" -tags osusergo,netgo
all: ${TARGET} GOFLAGS := -buildmode=pie -modcacherw -mod=readonly -trimpath
.PHONY: ${TARGET} install uninstall install-jade run LDFLAGS := -ldflags "-linkmode=external -extldflags \"${LDFLAGS}\" -s -w -X main.version=${VERSION}" -tags osusergo,netgo
${TARGET}: .PHONY: run install uninstall clean
go generate web/web.go
go build -o bin/$@ ${LDFLAGS} ${FLAGS} cmd/$@/main.go
install-jade: ${TARGET}: web/*_templ.go
go install github.com/Joker/jade/cmd/jade@latest go build -o bin/$@ ${LDFLAGS} ${GOFLAGS} cmd/$@/main.go
run: web/*_templ.go: web/*.templ
bin/dwelling-radio -no-liquidsoap -conf configs/config.test.yaml ifeq (,$(wildcard $(shell go env GOPATH)/bin/templ))
go install github.com/a-h/templ/cmd/templ@latest
endif
$(shell go env GOPATH)/bin/templ generate
run: | ${TARGET}
bin/dwelling-radio -listen 127.0.0.1:18322 \
-work-dir test \
-playlist test
install: install:
install -Dm 0755 bin/${TARGET} ${DESTDIR}${PREFIX}/bin/${TARGET} install -Dm 0755 bin/${TARGET} ${DESTDIR}${PREFIX}/bin/${TARGET}
install -Dm 0755 tools/radioctl ${DESTDIR}${PREFIX}/bin/${TARGET}ctl install -Dm 0755 tools/radioctl ${DESTDIR}${PREFIX}/bin/${TARGET}ctl
install -Dm 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 configs/logrotate ${DESTDIR}/etc/logrotate.d/${TARGET}
install -Dm 0644 init/systemd.service ${DESTDIR}/${SYSDDIR}/${TARGET}.service install -Dm 0755 tools/radio-fetch ${DESTDIR}${PREFIX}/bin/${TARGET}-fetch
# install -Dm 0644 configs/radio.liq ${DESTDIR}/etc/dwelling/radio.liq
# install -Dm 0644 configs/radio.vars.liq ${DESTDIR}/etc/dwelling/radio.vars.liq
install -Dm 0644 configs/ezstream.xml ${DESTDIR}/etc/dwelling/ezstream.xml
install -Dm 0644 configs/logrotate ${DESTDIR}/etc/logrotate.d/${TARGET}
install -Dm 0644 configs/override.icecast.service ${DESTDIR}/etc/systemd/system/icecast.service.override.d/override.conf
install -Dm 0644 init/radio.service ${DESTDIR}/${SYSDDIR}/${TARGET}.service
# install -Dm 0644 init/liquidsoap.service ${DESTDIR}/${SYSDDIR}/${TARGET}-liquidsoap.service
install -Dm 0644 init/ezstream.service ${DESTDIR}/${SYSDDIR}/${TARGET}-ezstream.service
uninstall: uninstall:
rm ${DESTDIR}${PREFIX}/bin/${TARGET} rm ${DESTDIR}${PREFIX}/bin/${TARGET}
rm ${DESTDIR}${PREFIX}/bin/${TARGET}ctl rm ${DESTDIR}${PREFIX}/bin/${TARGET}ctl
rm ${DESTDIR}/etc/dwelling/radio.liq
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/logrotate.d/${TARGET}
rm ${DESTDIR}/etc/systemd/system/icecast.service.override.d/override.conf
rm ${DESTDIR}${SYSDDIR}/${TARGET}.service rm ${DESTDIR}${SYSDDIR}/${TARGET}.service
# rm ${DESTDIR}${SYSDDIR}/${TARGET}-liquidsoap.service
rm ${DESTDIR}${SYSDDIR}/${TARGET}-ezstream.service
clean:
rm -f web/*.jade.go
go clean

View File

@ -1,37 +1,27 @@
# Maintainer: Alexander "Arav" Andreev <me@arav.su> # Maintainer: Alexander "Arav" Andreev <me@arav.su>
pkgname=dwelling-radio pkgname=dwelling-radio
pkgver=23.10.1 pkgver=24.38.0
pkgrel=1 pkgrel=1
pkgdesc="Arav's dwelling / Radio" pkgdesc="Arav's dwelling / Radio"
arch=('i686' 'x86_64' 'arm' 'armv6h' 'armv7h' 'aarch64') arch=('i686' 'x86_64' 'arm' 'armv6h' 'armv7h' 'aarch64')
url="https://git.arav.su/Arav/dwelling-radio" url="https://git.arav.su/Arav/dwelling-radio"
license=('MIT') license=('MIT')
groups=()
depends=()
makedepends=('go>=1.17') makedepends=('go>=1.17')
optdepends=( optdepends=(
'tree: to make a filelist html file' 'tree: to make a filelist html file'
'ffmpeg: to convert media to ogg') 'ffmpeg: to convert media to ogg and get duration of songs')
provides=('dwelling-radio') backup=('etc/dwelling/ezstream.xml')
conflicts=('dwelling-radio') source=("${pkgver}.tar.gz::https://git.arav.su/Arav/dwelling-radio/archive/v${pkgver}.tar.gz")
replaces=()
backup=('etc/dwelling/radio.yaml' 'etc/dwelling/radio.vars.liq')
options=()
install=
source=('https://git.arav.su/Arav/dwelling-radio/archive/23.10.1.tar.gz')
noextract=()
md5sums=('SKIP') md5sums=('SKIP')
build() { build() {
cd "$srcdir/$pkgname" cd "$srcdir/$pkgname"
export GOPATH="$srcdir"/gopath export GOPATH="$srcdir"/gopath
export CGO_CPPFLAGS="${CPPFLAGS}"
if [ ! -f "$(go env GOPATH)/bin/jade" ]; then export CGO_CFLAGS="${CFLAGS}"
make DESTDIR="$pkgdir" install-jade export CGO_CXXFLAGS="${CXXFLAGS}"
fi export CGO_LDFLAGS="${LDFLAGS}"
make VERSION=$pkgver DESTDIR="$pkgdir" PREFIX="/usr"
make DESTDIR="$pkgdir" PREFIX="/usr"
} }
package() { package() {

View File

@ -1,98 +1,159 @@
package main package main
import ( import (
"dwelling-radio/internal/configuration" ihttp "dwelling-radio/internal/http"
"dwelling-radio/internal/http"
"dwelling-radio/internal/radio" "dwelling-radio/internal/radio"
"errors" sqlite_stats "dwelling-radio/internal/statistics/db/sqlite"
"dwelling-radio/pkg/utils"
"dwelling-radio/web"
"dwelling-radio/web/locales"
"flag" "flag"
"fmt" "fmt"
"io/fs"
"log" "log"
"net/http"
"os" "os"
"os/signal" "os/signal"
"path"
"syscall" "syscall"
"git.arav.su/Arav/httpr"
"github.com/invopop/ctxi18n"
)
var (
listenAddress = flag.String("listen", "/var/run/dwelling-radio/sock", "listen address (ip:port|unix_path)")
workDirPath = flag.String("work-dir", "/mnt/data/appdata/radio", "path to a working directory")
playlistName = flag.String("playlist", "all-rand", "a playlist name")
songListLen = flag.Int64("list-length", 10, "number of songs to show in last N songs table")
showVersion = flag.Bool("v", false, "show version")
) )
var version string var version string
var configPath *string = flag.String("conf", "config.yaml", "path to configuration file")
var noLiquidsoap *bool = flag.Bool("no-liquidsoap", false, "don't run liquidsoap")
var showVersion *bool = flag.Bool("v", false, "show version")
func main() { func main() {
flag.Parse() flag.Parse()
log.SetFlags(log.Lshortfile)
if *showVersion { if *showVersion {
fmt.Println("dwelling-radio ver.", version, "\nCopyright (c) 2022,2023 Alexander \"Arav\" Andreev <me@arav.su>") fmt.Println("dwelling-radio ver.", version, "\nCopyright (c) 2022-2024 Alexander \"Arav\" Andreev <me@arav.su>")
return return
} }
log.SetFlags(log.Lshortfile) stats, err := sqlite_stats.New(path.Join(*workDirPath, "statistics.db3"))
if err != nil {
log.Fatalln("Statistics:", err)
}
defer stats.Close()
config, err := configuration.Load(*configPath) currentSong := radio.Song{}
lstnrs := radio.NewListenerCounter()
plylst, err := radio.NewPlaylist(path.Join(*workDirPath, "playlists", *playlistName), true)
if err != nil { if err != nil {
log.Fatalln(err) log.Fatalln(err)
} }
if typ, addr := config.SplitNetworkAddress(); typ == "unix" { if err := ctxi18n.LoadWithDefault(locales.Content, "en"); err != nil {
defer os.Remove(addr)
}
if data, err := os.ReadFile(config.MostListenedSongPath); err == nil {
if err := radio.LoadMostListenedSong(data); err != nil {
log.Fatalln(err) log.Fatalln(err)
} }
}
playlistWatcher := radio.NewPlaylistLogWatcher() r := httpr.New()
if err := playlistWatcher.Watch(config.Icecast.Playlist, config.ListLastNSongs); err != nil {
log.Fatalln(err)
}
defer playlistWatcher.Close()
hand := http.NewHandlers(config) r.Handler(http.MethodGet, "/", func(w http.ResponseWriter, r *http.Request) {
srv := http.NewHttpServer() lst, err := stats.LastNSongs(*songListLen)
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)
srv.GET("/filelist", hand.Filelist)
srv.GET("/robots.txt", hand.RobotsTxt)
if !*noLiquidsoap {
liquid, err := radio.NewLiquidsoap(config.Liquidsoap.ExecPath, config.Liquidsoap.ScriptPath)
if err != nil { if err != nil {
log.Fatalln("liquidsoap:", err) log.Printf("Failed to fetch last N songs: %s\n", err)
}
lstnrs.RLock()
defer lstnrs.RUnlock()
web.Index(version, &currentSong, 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, &currentSong, *songListLen, path.Join(*workDirPath, "fallback.ogg"))
s := r.Sub("/api")
s.Handler(http.MethodPost, "/listener/icecast", djh.ListenersUpdateIcecast)
s.Handler(http.MethodGet, "/playlist", djh.PlaylistNext)
s.Handler(http.MethodGet, "/status", djh.Status)
srv := ihttp.NewHttpServer(I18nMiddleware(r))
if err := srv.Start(*listenAddress); err != nil {
log.Fatalln(err)
} }
defer func() { defer func() {
if err := liquid.Stop(); err != nil {
if !errors.Is(err, radio.ErrLiquidsoapNotRunning) {
log.Println(err)
}
}
}()
}
if err := srv.Start(config.SplitNetworkAddress()); err != nil {
log.Fatalln(err)
}
doneSignal := make(chan os.Signal, 1)
signal.Notify(doneSignal, os.Interrupt, syscall.SIGINT, syscall.SIGTERM, syscall.SIGSEGV)
<-doneSignal
os.WriteFile(config.MostListenedSongPath, radio.StoreMostListenedSong(), fs.ModePerm)
if err := srv.Stop(); err != nil { if err := srv.Stop(); err != nil {
log.Fatalln(err) log.Fatalln(err)
} }
}()
sysSignal := make(chan os.Signal, 1)
signal.Notify(sysSignal, os.Interrupt, syscall.SIGINT, syscall.SIGTERM, syscall.SIGABRT, syscall.SIGSEGV, syscall.SIGHUP)
for {
switch <-sysSignal {
case os.Interrupt, syscall.SIGINT, syscall.SIGTERM, syscall.SIGABRT, syscall.SIGSEGV:
if currentSong.Artist != "" {
lstnrs.Lock()
defer lstnrs.Unlock()
currentSong.Listeners, currentSong.PeakListeners = lstnrs.Reset()
if err := stats.Add(&currentSong); 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))
})
} }

View File

@ -1,15 +0,0 @@
# Sets network type (could be tcp{,4,6}, unix)
# and address:port or /path/to/unix.sock to
# listen on.
listen_on: "tcp 127.0.0.1:16387"
icecast:
# URL to Icecast's status-json.xsl
url: "http://radio.arav.home.arpa/status-json.xsl"
playlist_path: "playlist.log"
filelist_path: "/srv/radio/filelist.html"
most_listened_song_file_path: "/mnt/data/appdata/mostlistenedsong"
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

View File

@ -1,15 +0,0 @@
# Sets network type (could be tcp{,4,6}, unix)
# and address:port or /path/to/unix.sock to
# listen on.
listen_on: "unix /var/run/dwelling-radio/sock"
icecast:
# URL to Icecast's status-json.xsl
url: "http://radio.arav.home.arpa/status-json.xsl"
playlist_path: "/var/log/icecast/playlist.log"
filelist_path: "/srv/radio/filelist.html"
most_listened_song_file_path: "/mnt/data/appdata/mostlistenedsong"
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

46
configs/ezstream.xml Normal file
View 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
View 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>

View File

@ -1,5 +1,6 @@
/var/log/dwelling-radio/*log { /var/log/dwelling-radio/*log {
nocreate nocreate
copytruncate
missingok missingok
notifempty notifempty
size 10M size 10M
@ -8,8 +9,4 @@
compressext .zst compressext .zst
compressoptions -T0 --long -15 compressoptions -T0 --long -15
uncompresscmd /usr/bin/unzstd uncompresscmd /usr/bin/unzstd
sharedscripts
postrotate
/bin/pkill -HUP dwelling-radio
endscript
} }

View File

@ -1,10 +1,11 @@
server { server {
listen 443 ssl http2; listen 443 ssl;
# listen 8090; # Tor listen 8091; # Tor I2P
listen 127.0.0.1:8111; # I2P
listen [300:a98d:d6d0:8a08::e]:80; # Yggdrasil listen [300:a98d:d6d0:8a08::e]:80; # Yggdrasil
server_name radio.arav.su radio.arav.i2p mkgnmhmzqm7kyzv7jnzzafvgm7xlmlfvzhgorpapd5or2arnhuktqd.onion; http2 on;
server_name radio.arav.su radio.arav.i2p plkybcgxt4cdanot75cy3pbnqlbqcsrib2fmrpsnug4bqphqvfda.b32.i2p mkgnmhmzqm7kyzv7jnzzafvgm7xlmlfvzhgorpapd5or2arnhuktqd.onion;
access_log /var/log/nginx/dwelling/radio.log main if=$nolog; access_log /var/log/nginx/dwelling/radio.log main if=$nolog;
@ -16,7 +17,7 @@ server {
add_header X-Content-Type-Options "nosniff"; add_header X-Content-Type-Options "nosniff";
add_header X-XSS-Protection "1; mode=block"; add_header X-XSS-Protection "1; mode=block";
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"; add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload";
# add_header Onion-Location "http://mkgnmhmzqm7kyzv7jnzzafvgm7xlmlfvzhgorpapd5or2arnhuktqd.onion$request_uri"; add_header Onion-Location "http://wsmkgnmhmzqm7kyzv7jnzzafvgm7xlmlfvzhgorpapd5or2arnhuktqd.onion$request_uri";
location / { location / {
@ -28,6 +29,7 @@ server {
location /live/ { location /live/ {
proxy_pass http://127.0.0.1:8000/; proxy_pass http://127.0.0.1:8000/;
proxy_bind $remote_addr transparent;
proxy_buffering off; proxy_buffering off;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
} }
@ -35,10 +37,22 @@ server {
location /live/admin/ { location /live/admin/ {
deny all; deny all;
} }
location /api/listener/icecast {
allow 127.0.0.1;
allow 192.168.144.2;
deny all;
}
location /api/playlist {
allow 127.0.0.1;
allow 192.168.144.2;
deny all;
}
} }
server { server {
listen 8000; listen 192.168.144.2:8000;
server_name radio.arav.su; server_name radio.arav.su;
@ -48,12 +62,14 @@ server {
add_header X-Frame-Options "DENY"; add_header X-Frame-Options "DENY";
add_header X-Content-Type-Options "nosniff"; add_header X-Content-Type-Options "nosniff";
add_header X-XSS-Protection "1; mode=block"; add_header X-XSS-Protection "1; mode=block";
# add_header Onion-Location "http://mkgnmhmzqm7kyzv7jnzzafvgm7xlmlfvzhgorpapd5or2arnhuktqd.onion/live$request_uri"; add_header Onion-Location "http://wsmkgnmhmzqm7kyzv7jnzzafvgm7xlmlfvzhgorpapd5or2arnhuktqd.onion/live$request_uri";
location / { location / {
proxy_pass http://127.0.0.1:8000/; proxy_pass http://127.0.0.1:8000/;
proxy_bind $remote_addr transparent;
proxy_buffering off; proxy_buffering off;
proxy_set_header X-Real-IP $remote_addr;
} }
location /admin/ { location /admin/ {

View File

@ -0,0 +1,3 @@
[Unit]
Requires=dwelling-radio.service
After=dwelling-radio.service

View File

@ -10,8 +10,8 @@ end
def xfade(a, b) def xfade(a, b)
add(normalize = false, add(normalize = false,
[ sequence([ blank(duration = 2.), [ sequence([ blank(duration = 2.),
fade.initial(duration = 2., b)]), fade.in(duration = 2., b)]),
fade.final(duration = 2., a)]) fade.out(duration = 2., a)])
end end
def fallback_alter_title(m) = def fallback_alter_title(m) =
@ -19,12 +19,12 @@ def fallback_alter_title(m) =
("title", string.concat(["No stream. (Playing ", m["artist"], " - ", m["title"], ")"]))] ("title", string.concat(["No stream. (Playing ", m["artist"], " - ", m["title"], ")"]))]
end end
settings.server.telnet.set(false) settings.server.telnet := false
settings.harbor.bind_addrs.set(["0.0.0.0"]) settings.harbor.bind_addrs := ["0.0.0.0"]
settings.log.level.set(1) settings.log.level := 1
settings.log.file.set(true) settings.log.file := true
settings.log.file.path.set(log_file_path) settings.log.file.path := log_file_path
settings.log.stdout.set(false) settings.log.stdout := false
enable_replaygain_metadata() enable_replaygain_metadata()

View File

@ -6,13 +6,13 @@ radio_url = "https://radio.arav.su"
radio_name = "Arav's dwelling / Radio" radio_name = "Arav's dwelling / Radio"
radio_desc = "Broadcasting from under my desk." radio_desc = "Broadcasting from under my desk."
radio_dir = "/srv/radio/" radio_dir = "/mnt/data/appdata/radio/"
harbor_port = 8002 harbor_port = 8002
harbor_password = "" harbor_password = ""
icecast_host = "127.0.0.1" icecast_host = "127.0.0.1"
icecast_port = 8000 icecast_port = 8001
icecast_password = "" icecast_password = ""
icecast_mount = "stream.ogg" icecast_mount = "stream.ogg"
icecast_genre = "Various" icecast_genre = "Various"

20
go.mod
View File

@ -1,9 +1,21 @@
module dwelling-radio module dwelling-radio
go 1.17 go 1.21
toolchain go1.22.3
require github.com/pkg/errors v0.9.1
require git.arav.su/Arav/httpr v0.3.2
require github.com/a-h/templ v0.2.778
require ( require (
github.com/julienschmidt/httprouter v1.3.0 github.com/invopop/ctxi18n v0.8.1
github.com/pkg/errors v0.9.1 github.com/mattn/go-sqlite3 v1.14.23
gopkg.in/yaml.v3 v3.0.1 )
require (
github.com/invopop/yaml v0.3.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
) )

20
go.sum
View File

@ -1,7 +1,23 @@
github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U= git.arav.su/Arav/httpr v0.3.2 h1:a+ifu+9+FnQe6p/Kd4kgTDKAFN6zBOJjBTMjbAuHxVk=
github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= git.arav.su/Arav/httpr v0.3.2/go.mod h1:z0SVYwe5dBReeVuFU9QH2PmBxICJwchxqY5OfZbeVzU=
github.com/a-h/templ v0.2.778 h1:VzhOuvWECrwOec4790lcLlZpP4Iptt5Q4K9aFxQmtaM=
github.com/a-h/templ v0.2.778/go.mod h1:lq48JXoUvuQrU0VThrK31yFwdRjTCnIE5bcPCM9IP1w=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/invopop/ctxi18n v0.8.1 h1:nfy5Mk6UfvLbGRBwpTi4T1g95+rmRo8bMllUmpCvVwI=
github.com/invopop/ctxi18n v0.8.1/go.mod h1:1Osw+JGYA+anHt0Z4reF36r5FtGHYjGQ+m1X7keIhPc=
github.com/invopop/yaml v0.3.1 h1:f0+ZpmhfBSS4MhG+4HYseMdJhoeeopbSKbq5Rpeelso=
github.com/invopop/yaml v0.3.1/go.mod h1:PMOp3nn4/12yEZUFfmOuNHJsZToEEOwoWsT+D81KkeA=
github.com/mattn/go-sqlite3 v1.14.23 h1:gbShiuAP1W5j9UOksQ06aiiqPMxYecovVGwmTxWtuw0=
github.com/mattn/go-sqlite3 v1.14.23/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

55
init/ezstream.service Normal file
View 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
View 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

View File

@ -7,9 +7,13 @@ After=network-online.target icecast.service
Type=simple Type=simple
Restart=on-failure Restart=on-failure
DynamicUser=yes DynamicUser=yes
ExecStart=/usr/bin/dwelling-radio -conf /etc/dwelling/radio.yaml ExecStart=/usr/bin/dwelling-radio -listen /var/run/dwelling-radio/sock \
-work-dir /mnt/data/appdata/radio \
-playlist all-rand \
-lst-len 10
ReadOnlyPaths=/ ReadOnlyPaths=/
ReadWritePaths=/mnt/data/appdata/radio
LogsDirectory=dwelling-radio LogsDirectory=dwelling-radio
RuntimeDirectory=dwelling-radio RuntimeDirectory=dwelling-radio
@ -21,18 +25,33 @@ LockPersonality=true
MemoryDenyWriteExecute=true MemoryDenyWriteExecute=true
NoNewPrivileges=true NoNewPrivileges=true
PrivateDevices=true PrivateDevices=true
PrivateTmp=true
PrivateUsers=true
ProcSubset=pid
ProtectClock=true ProtectClock=true
ProtectControlGroups=true ProtectControlGroups=true
ProtectHome=true ProtectHome=true
ProtectHostname=true
ProtectKernelLogs=true ProtectKernelLogs=true
ProtectKernelModules=true ProtectKernelModules=true
ProtectKernelTunables=true ProtectKernelTunables=true
ProtectProc=noaccess
ProtectSystem=strict ProtectSystem=strict
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
RestrictNamespaces=true RestrictNamespaces=true
RestrictRealtime=true RestrictRealtime=true
RestrictSUIDSGID=true RestrictSUIDSGID=true
SystemCallArchitectures=native SystemCallArchitectures=native
SystemCallFilter=~@clock
SystemCallFilter=~@cpu-emulation
SystemCallFilter=~@debug
SystemCallFilter=~@module
SystemCallFilter=~@mount
SystemCallFilter=~@obsolete
SystemCallFilter=~@privileged
SystemCallFilter=~@raw-io
SystemCallFilter=~@reboot
SystemCallFilter=~@swap
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target

View File

@ -1,48 +0,0 @@
package configuration
import (
"os"
"strings"
"github.com/pkg/errors"
"gopkg.in/yaml.v3"
)
type Configuration struct {
ListenOn string `yaml:"listen_on"`
Icecast struct {
URL string `yaml:"url"`
Playlist string `yaml:"playlist_path"`
} `yaml:"icecast"`
FilelistPath string `yaml:"filelist_path"`
MostListenedSongPath string `yaml:"most_listened_song_file_path"`
Liquidsoap struct {
ExecPath string `yaml:"executable_path"`
ScriptPath string `yaml:"script_path"`
} `yaml:"liquidsoap"`
ListLastNSongs int `yaml:"list_last_n_songs"`
}
// Load reads a YAML file that stores configuration of a service.
func Load(path string) (*Configuration, error) {
configFile, err := os.Open(path)
if err != nil {
return nil, errors.Wrap(err, "failed to open configuration file")
}
defer configFile.Close()
config := &Configuration{}
if err := yaml.NewDecoder(configFile).Decode(config); err != nil {
return nil, errors.Wrap(err, "failed to parse configuration file")
}
return config, nil
}
// SplitNetworkAddress splits ListenOn option into network type (e.g. tcp, unix,
// udp) and address:port or /path/to/service.socket to listen on.
func (c *Configuration) SplitNetworkAddress() (string, string) {
s := strings.Split(c.ListenOn, " ")
return s[0], s[1]
}

View 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)
}
}

View File

@ -1,111 +0,0 @@
package http
import (
"dwelling-radio/internal/configuration"
"dwelling-radio/internal/radio"
"dwelling-radio/pkg/utils"
"dwelling-radio/web"
"encoding/json"
"log"
"net/http"
"os"
"time"
)
const FormatISO8601 = "2006-01-02T15:04:05-0700"
type Handlers struct {
conf *configuration.Configuration
}
func NewHandlers(conf *configuration.Configuration) *Handlers {
return &Handlers{conf: conf}
}
func (h *Handlers) AssetsFS() http.FileSystem {
return web.Assets()
}
func (h *Handlers) Index(w http.ResponseWriter, r *http.Request) {
status, err := radio.IcecastGetStatus(h.conf.Icecast.URL)
if err != nil {
log.Println("failed to get Icecast status:", err)
} else {
if tim, err := time.Parse(time.RFC1123Z, status.ServerStartDate); err == nil {
status.ServerStartDate = utils.ToClientTimezone(tim, r).Format(time.RFC1123)
}
if tim, err := time.Parse(FormatISO8601, status.ServerStartISO8601); err == nil {
status.ServerStartISO8601 = utils.ToClientTimezone(tim, r).Format(FormatISO8601)
}
}
songs, err := radio.IcecastLastSongs(h.conf.Icecast.Playlist)
if err != nil {
log.Println("cannot retrieve last songs:", err)
} else {
for i := 0; i < len(songs); i++ {
if tim, err := time.Parse(radio.SongTimeFormat, songs[i].Time); err == nil {
songs[i].Time = utils.ToClientTimezone(tim, r).Format("15:04")
}
}
}
web.Index(utils.MainSite(r.Host), h.conf.ListLastNSongs, status, &songs, r, w)
}
func (h *Handlers) Status(w http.ResponseWriter, r *http.Request) {
status, err := radio.IcecastGetStatus(h.conf.Icecast.URL)
if err != nil {
log.Println("cannot retrieve Icecast status:", err)
http.Error(w, "cannot retrieve Icecast status", http.StatusInternalServerError)
return
}
if tim, err := time.Parse(time.RFC1123Z, status.ServerStartDate); err == nil {
status.ServerStartDate = utils.ToClientTimezone(tim, r).Format(time.RFC1123)
}
if tim, err := time.Parse(FormatISO8601, status.ServerStartISO8601); err == nil {
status.ServerStartISO8601 = utils.ToClientTimezone(tim, r).Format(FormatISO8601)
}
w.Header().Add("Content-Type", "application/json")
json.NewEncoder(w).Encode(status)
}
func (h *Handlers) LastSong(w http.ResponseWriter, r *http.Request) {
song, err := radio.IcecastLastSong(h.conf.Icecast.Playlist)
if err != nil {
log.Println("cannot retrieve last songs:", err)
}
if song == nil {
w.WriteHeader(http.StatusNotFound)
return
}
if tim, err := time.Parse(radio.SongTimeFormat, song.Time); err == nil {
song.Time = utils.ToClientTimezone(tim, r).Format("15:04")
}
w.Header().Add("Content-Type", "application/json")
json.NewEncoder(w).Encode(song)
}
func (h *Handlers) Playlist(w http.ResponseWriter, _ *http.Request) {
w.Header().Add("Content-Disposition", "attachment; filename=\"radio.arav.su.m3u\"")
fc, _ := web.AssetsGetFile("radio.arav.su.m3u")
w.Write(fc)
}
func (h *Handlers) Filelist(w http.ResponseWriter, _ *http.Request) {
w.Header().Add("Content-Type", "text/html")
data, _ := os.ReadFile(h.conf.FilelistPath)
w.Write(data)
}
func (h *Handlers) RobotsTxt(w http.ResponseWriter, _ *http.Request) {
w.Header().Add("Content-Disposition", "attachment; filename=\"robots.txt\"")
w.Write([]byte("User-agent: *\nDisallow: /assets/\nDisallow: /live/"))
}

View File

@ -1,76 +0,0 @@
package http
import (
"context"
"log"
"net"
"net/http"
"os"
"time"
"github.com/julienschmidt/httprouter"
)
type HttpServer struct {
server *http.Server
router *httprouter.Router
}
func NewHttpServer() *HttpServer {
r := httprouter.New()
return &HttpServer{
server: &http.Server{
ReadTimeout: 3 * time.Second,
WriteTimeout: 3 * time.Second,
Handler: r,
},
router: r,
}
}
func (s *HttpServer) GET(path string, handler http.HandlerFunc) {
s.router.Handler(http.MethodGet, path, handler)
}
func (s *HttpServer) ServeStatic(path string, fsys http.FileSystem) {
s.router.ServeFiles(path, fsys)
}
func (s *HttpServer) SetNotFoundHandler(handler http.HandlerFunc) {
s.router.NotFound = handler
}
// GetURLParam wrapper around underlying router for getting URL parameters.
func GetURLParam(r *http.Request, param string) string {
return httprouter.ParamsFromContext(r.Context()).ByName(param)
}
func (s *HttpServer) Start(network, address string) error {
listener, err := net.Listen(network, address)
if err != nil {
return err
}
if listener.Addr().Network() == "unix" {
os.Chmod(address, 0777)
}
go func() {
if err = s.server.Serve(listener); err != nil && err != http.ErrServerClosed {
log.Fatalln(err)
}
}()
return nil
}
func (s *HttpServer) Stop() error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := s.server.Shutdown(ctx); err != nil {
return err
}
return nil
}

76
internal/http/server.go Normal file
View 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
}

View File

@ -1,222 +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 = 3072
)
var (
currentlyPlaying Song
lastPlayedCache []Song
lastPlayedCacheMutex sync.Mutex
)
type IcecastStatusDTO struct {
Icestats struct {
ServerStartISO8601 string `json:"server_start_iso8601"`
ServerStartDate string `json:"server_start"`
Source struct {
Artist string `json:"artist"`
Title string `json:"title"`
ListenerPeak int `json:"listener_peak"`
Listeners int `json:"listeners"`
} `json:"source"`
} `json:"icestats"`
}
func (is *IcecastStatusDTO) SongName() string {
return is.Icestats.Source.Artist + " - " + is.Icestats.Source.Title
}
type IcecastStatus struct {
ServerStartISO8601 string `json:"server_start_iso8601"`
ServerStartDate string `json:"server_start_date"`
SongName string `json:"song"`
ListenerPeak int `json:"listener_peak"`
Listeners int `json:"listeners"`
}
type Song struct {
Time string `json:"time"`
Listeners string `json:"listeners"`
Song string `json:"song"`
}
func IcecastGetStatus(icecastURL string) (*IcecastStatus, error) {
resp, err := http.Get(icecastURL)
if err != nil {
return nil, err
}
iceStatDTO := &IcecastStatusDTO{}
if err := json.NewDecoder(resp.Body).Decode(iceStatDTO); err != nil {
return nil, err
}
return &IcecastStatus{
ServerStartISO8601: iceStatDTO.Icestats.ServerStartISO8601,
ServerStartDate: iceStatDTO.Icestats.ServerStartDate,
SongName: iceStatDTO.SongName(),
ListenerPeak: iceStatDTO.Icestats.Source.ListenerPeak,
Listeners: iceStatDTO.Icestats.Source.Listeners,
}, nil
}
func IcecastLastSongs(playlistPath string) ([]Song, error) {
lastPlayedCacheMutex.Lock()
defer lastPlayedCacheMutex.Unlock()
if lpcLen := len(lastPlayedCache); lpcLen > 0 {
ret := make([]Song, 0, lpcLen)
ret = append(ret, lastPlayedCache...)
return ret, nil
}
return nil, nil
}
func IcecastLastSong(playlistPath string) (*Song, error) {
lastPlayedCacheMutex.Lock()
defer lastPlayedCacheMutex.Unlock()
if lpcLen := len(lastPlayedCache); lpcLen > 0 {
return &lastPlayedCache[lpcLen-1], nil
}
return nil, nil
}
func icecastLastPlayedSongs(playlistPath string, n int) ([]Song, error) {
var buf []byte
var offset int64 = 0
playlist, err := os.Open(playlistPath)
if err != nil {
return nil, err
}
defer playlist.Close()
playlist_stat, _ := playlist.Stat()
if playlist_stat.Size() == 0 {
return nil, nil
}
if playlist_stat.Size() < bufferSize {
buf = make([]byte, playlist_stat.Size())
} else {
buf = make([]byte, bufferSize)
offset = playlist_stat.Size() - bufferSize
}
_, err = playlist.ReadAt(buf, offset)
if err != nil && err != io.EOF {
return nil, err
}
lines := bytes.Split(buf, []byte("\n"))
if len(lines) < 2 {
return nil, nil
}
lines = lines[:len(lines)-1]
if len(lines) > n {
lines = lines[len(lines)-n:]
}
songs := make([]Song, 0, len(lines))
for _, line := range lines {
fields := bytes.Split(line, []byte("|"))
tim, _ := time.Parse(IcecastPlaylistDateFormat, string(fields[0]))
songs = append(songs, Song{
Time: tim.Format(SongTimeFormat),
Listeners: string(fields[2]),
Song: string(fields[3])})
}
return songs, nil
}
type PlaylistLogWatcher struct {
watcher *watcher.InotifyWatcher
changed chan uint32
}
func NewPlaylistLogWatcher() *PlaylistLogWatcher {
return &PlaylistLogWatcher{changed: make(chan uint32)}
}
func (pw *PlaylistLogWatcher) Watch(playlistPath string, n int) (err error) {
if pw.watcher != nil {
pw.watcher.Close()
}
pw.watcher, err = watcher.NewInotifyWatcher()
if err != nil {
return errors.Wrap(err, "cannot instantiate inotify watcher")
}
err = pw.watcher.AddWatch(playlistPath, watcher.ModIgnMask)
if err != nil {
return errors.Wrap(err, "cannot set a playlist to watch")
}
pw.watcher.WatchForMask(pw.changed, watcher.ModIgnMask)
lastPlayedCache = make([]Song, 0, n)
go func() {
for {
select {
case mask := <-pw.changed:
if mask&syscall.IN_MODIFY > 0 {
lastPlayedCacheMutex.Lock()
if songs, err := icecastLastPlayedSongs(playlistPath, 1); err == nil && len(songs) > 0 {
CheckAndUpdateMostListenedSong(songs[0], currentlyPlaying)
if currentlyPlaying.Time == "" {
currentlyPlaying = songs[0]
} else {
currentlyPlaying.Listeners = songs[0].Listeners
if len(lastPlayedCache) == n {
lastPlayedCache = append(lastPlayedCache[1:], currentlyPlaying)
} else {
lastPlayedCache = append(lastPlayedCache, currentlyPlaying)
}
currentlyPlaying = songs[0]
}
}
lastPlayedCacheMutex.Unlock()
} else if mask&syscall.IN_IGNORED > 0 {
pw.Close()
pw.Watch(playlistPath, n)
return
}
}
}
}()
return nil
}
func (pw *PlaylistLogWatcher) Close() {
pw.watcher.Close()
}

View File

@ -1,54 +0,0 @@
package radio
import (
"os/exec"
"syscall"
"github.com/pkg/errors"
)
var ErrLiquidsoapNotRunning = errors.New("liquidsoap is not running")
type Liquidsoap struct {
command *exec.Cmd
}
func NewLiquidsoap(liquidsoapPath, scriptPath string) (*Liquidsoap, error) {
if _, err := exec.LookPath(liquidsoapPath); err != nil {
return nil, err
}
out, err := exec.Command(liquidsoapPath, "--verbose", "-c", scriptPath).CombinedOutput()
if err != nil {
return nil, errors.Wrap(err, "script cannot be validated")
}
if len(out) > 0 {
return nil, errors.Errorf("script validation failed: %s", string(out))
}
cmd := exec.Command(liquidsoapPath, scriptPath)
if err := cmd.Start(); err != nil {
return nil, err
}
return &Liquidsoap{
command: cmd}, nil
}
func (l *Liquidsoap) Stop() error {
if l.command.Process == nil && l.command.ProcessState != nil {
return ErrLiquidsoapNotRunning
}
if err := l.command.Process.Signal(syscall.SIGINT); err != nil {
return err
}
if err := l.command.Wait(); err != nil {
return err
}
return nil
}

View 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})
}

View File

@ -1,81 +0,0 @@
package radio
import (
"bytes"
"errors"
"strconv"
"time"
)
const MostListenedDateFormat = "02 January 2006"
type MostListenedSong struct {
Listeners int
Date time.Time
Song string
}
func (mls *MostListenedSong) DateString() string {
return mls.Date.Format(MostListenedDateFormat)
}
var mostListened MostListenedSong
// CheckAndUpdateMostListenedSong compares current most played song with
// provided `cur`rent song's listeners, and if it is larger, then it takes
// `prev`ious song's name.
//
// Why we take a previous song's name? Experimentally I noticed that Icecast
// writes amount of listeners that was by the very start of a next song. So
// it means that it was actually amount of listeners by the end of
// the previous song.
//
// So it would be fairer to give these listeners back to a song they was
// listening to.
func CheckAndUpdateMostListenedSong(cur, prev Song) {
if prev.Song == "" {
return
}
l, _ := strconv.Atoi(cur.Listeners)
if l > mostListened.Listeners {
mostListened = MostListenedSong{
Listeners: l,
Date: time.Now().UTC(),
Song: prev.Song}
}
}
// MostListened returns song that currently is the song with most simultaneous
// listeners.
func MostListened() *MostListenedSong {
return &mostListened
}
func LoadMostListenedSong(data []byte) (err error) {
lines := bytes.Split(data, []byte{'\n'})
if len(lines) != 3 {
return errors.New("lines count mismatch, should be 3")
}
mostListened = MostListenedSong{}
if mostListened.Date, err = time.Parse(time.RFC3339, string(lines[0])); err != nil {
return err
}
if mostListened.Listeners, err = strconv.Atoi(string(lines[1])); err != nil {
return err
}
mostListened.Song = string(lines[2])
return nil
}
func StoreMostListenedSong() []byte {
buf := make([]byte, 0, 30+len(mostListened.Song))
b := bytes.NewBuffer(buf)
b.WriteString(mostListened.Date.Format(time.RFC3339))
b.WriteByte('\n')
b.WriteString(strconv.Itoa(mostListened.Listeners))
b.WriteByte('\n')
b.WriteString(mostListened.Song)
return b.Bytes()
}

View 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
View 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)})
}

View 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()
}

View File

@ -0,0 +1,3 @@
INSERT OR IGNORE INTO `history`
(`start_at`, `song_id`, `listeners`, `peak_listeners`)
VALUES (?,?,?,?);

View 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;

View 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 ?;

View File

@ -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`;

View 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 );

View File

@ -0,0 +1,6 @@
INSERT INTO `song`
(`artist`, `title`)
VALUES (?, ?)
ON CONFLICT DO
UPDATE SET `song_id`=`song_id`
RETURNING `song_id`;

View File

@ -0,0 +1,138 @@
package statistics
import (
"database/sql"
"dwelling-radio/internal/radio"
"fmt"
"time"
"github.com/pkg/errors"
)
const MostListenedDateFormat string = "02 January 2006 at 15:04:05 MST"
type Statistics interface {
Add(*radio.Song) error
LastNSongs(n int64) ([]radio.Song, error)
MostNPopularSongs(n int64) ([]radio.Song, error)
MostSimultaneousListeners() (radio.Song, error)
Close() error
}
type ErrPrepareStmt struct {
Name string
}
func (e ErrPrepareStmt) Error() string {
return fmt.Sprintf("failed to prepare an SQL statement '%s'", e.Name)
}
var ErrNoSong = errors.New("no song was passed (a struct is nil or empty)")
var ErrSongNotAdded = errors.New("song was not added")
type BaseStatistics struct {
Db *sql.DB
DbDateFormat string
StmtHistoryAdd *sql.Stmt
StmtSongAdd *sql.Stmt
StmtLastNSongs *sql.Stmt
StmtMostPopularSongs *sql.Stmt
StmtMostSimultaneousListeners *sql.Stmt
}
func (s *BaseStatistics) Add(song *radio.Song) error {
if song == nil || song.Artist == "" || song.Title == "" {
return ErrNoSong
}
tx, err := s.Db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
row := tx.Stmt(s.StmtSongAdd).QueryRow(song.Artist, song.Title)
if row.Err() != nil {
return row.Err()
}
var songID int64
if err := row.Scan(&songID); err != nil {
return err
}
_, err = tx.Stmt(s.StmtHistoryAdd).Exec(song.StartAt.UTC().Format(s.DbDateFormat),
songID, song.Listeners, song.PeakListeners)
if err != nil {
return errors.Wrap(err, ErrSongNotAdded.Error())
}
tx.Commit()
return nil
}
func (s *BaseStatistics) LastNSongs(n int64) ([]radio.Song, error) {
if n == 0 {
return nil, nil
}
tx, err := s.Db.Begin()
if err != nil {
return nil, err
}
defer tx.Rollback()
rows, err := tx.Stmt(s.StmtLastNSongs).Query(n)
if err != nil {
return nil, err
}
songs := make([]radio.Song, n)
i := 0
for rows.Next() {
var startAt string
if err := rows.Scan(&startAt, &songs[i].Artist, &songs[i].Title,
&songs[i].Listeners, &songs[i].PeakListeners); err != nil {
return nil, err
}
songs[i].StartAt, err = time.Parse(s.DbDateFormat, startAt)
if err != nil {
return nil, err
}
i++
}
tx.Commit()
if i == 0 {
return nil, nil
}
lst := make([]radio.Song, i)
copy(lst, songs[:])
return lst, nil
}
func (s *BaseStatistics) MostNPopularSongs(n int64) ([]radio.Song, error) {
if n == 0 {
return nil, nil
}
return nil, nil
}
func (s *BaseStatistics) MostSimultaneousListeners() (radio.Song, error) {
return radio.Song{}, nil
}
func (s *BaseStatistics) Close() error {
return s.Db.Close()
}

106
pkg/oggtag/oggtag.go Normal file
View 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
View 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())
}

View File

@ -19,6 +19,13 @@ func MainSite(host string) string {
return "https://arav.su" return "https://arav.su"
} }
func Site(host string) string {
if strings.Contains(host, ".su") {
return "https://radio.arav.su"
}
return "http://" + host
}
// ToClientTimezone converts given time to timezone set in a // ToClientTimezone converts given time to timezone set in a
// X-Client-Timezone header. If this header is not set, then // X-Client-Timezone header. If this header is not set, then
// converts to UTC. // converts to UTC.

View File

@ -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
View 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%????}

View File

@ -1,17 +1,22 @@
#!/usr/bin/sh #!/usr/bin/sh
radio_dir=/srv/radio radio_dir=/mnt/data/appdata/radio
case $1 in case $1 in
f | filelist) f | filelist)
tree -H '' -T "Arav's dwelling / Radio / List" \ tree -H '' -T "List &ndash; Arav's dwelling / Radio" \
-P '*.ogg' --charset=utf-8 --prune --du -hnl \ -P '*.ogg' --charset=utf-8 --prune --du -hnl \
--nolinks -o $radio_dir/filelist.html $radio_dir/music --nolinks -o $radio_dir/filelist.html $radio_dir/music
break break
;; ;;
p | playlist) p | playlist)
find -L $radio_dir/music/* -type f -iname '*.ogg' | find -L $radio_dir/music/* -type f -iname '*.ogg' |
cut -c 18- | sort -d > $radio_dir/playlists/all cut -c 31- | sort -d > $radio_dir/playlists/all
break
;;
ep | ez-playlist)
find -L $radio_dir/music/* -type f -iname '*.ogg' |
sort -d > $radio_dir/playlists/all
break break
;; ;;
s | shuffle) s | shuffle)
@ -23,18 +28,39 @@ case $1 in
if [[ "$file" == *.ogg ]]; then if [[ "$file" == *.ogg ]]; then
continue; continue;
fi fi
if [ -f "${file%.*}.ogg" ]; then
continue;
fi
ffmpeg -hide_banner -i "$file" -y -vn -c:a libvorbis -b:a 128k "${file%.*}.ogg"; ffmpeg -hide_banner -i "$file" -y -vn -c:a libvorbis -b:a 128k "${file%.*}.ogg";
if [ $? -eq 0 ] && [ $2 = "del" ]; then if [ $? -eq 0 ] && [ $3 = "del" ]; then
rm "$file"; rm "$file";
fi fi
done done
break break
;; ;;
d | duration)
find $radio_dir/music -iname '*.ogg' -exec ffprobe -i "{}" \
-show_entries format=duration -v quiet -of csv="p=0" \; |
paste -s -d+ - | bc
break
;;
er | ez-reload)
pkill -HUP ezstream
break
;;
dr | dw-reload)
pkill -HUP dwelling-radio
break
;;
*) *)
echo "f|ilelist - to generate a filelist.html" echo "f|ilelist - to generate a filelist.html"
echo "p|laylist - to generate a playlist 'all'" echo "p|laylist - to generate a playlist 'all'"
echo "ep|ez-playlist- - to generate a playlist 'all' with full paths"
echo "s|huffle - to shuffle a playlist and store as all-rand" echo "s|huffle - to shuffle a playlist and store as all-rand"
echo "c|onvert DIR - convert all files in DIR to ogg" echo "c|onvert DIR - convert all files in DIR to ogg"
echo "d|uration - get total songs' duration"
echo "er|ez-reload - send SIGHUP to ezstream to reload a playlist"
echo "dr|dw-reload - send SIGHUP to dwelling-radio to reload a playlist"
exit exit
;; ;;
esac esac

View File

@ -11,6 +11,7 @@
--secondary-color: #9f2b68; --secondary-color: #9f2b68;
--text-color: #f5f5f5; --text-color: #f5f5f5;
--text-indent: 1.6rem; --text-indent: 1.6rem;
color-scheme: light dark;
scrollbar-color: var(--primary-color) var(--background-color); } scrollbar-color: var(--primary-color) var(--background-color); }
@media (prefers-color-scheme: light) { @media (prefers-color-scheme: light) {
@ -26,15 +27,16 @@
background-color: var(--secondary-color); background-color: var(--secondary-color);
color: var(--background-color); } color: var(--background-color); }
.right { text-align: right; } .small { font-size: .8rem; }
.small.player-links a { margin: 0 .2rem; }
a, a,
button { button {
color: var(--primary-color); color: var(--primary-color);
text-decoration: none; } text-decoration: none; }
a:hover, a:hover {
button:hover {
color: var(--secondary-color); color: var(--secondary-color);
cursor: pointer; cursor: pointer;
text-decoration: underline dotted; text-decoration: underline dotted;
@ -42,9 +44,7 @@ button:hover {
button { button {
background: none; background: none;
border: none; border: none; }
font: inherit;
padding: 0; }
p { p {
text-align: justify; text-align: justify;
@ -64,24 +64,10 @@ h2 {
font-size: 1.4rem; font-size: 1.4rem;
margin: 1rem 0; } margin: 1rem 0; }
small { font-size: .8rem; }
small.player-links a { margin: 0 .2rem; }
audio { audio {
background-color: var(--primary-color); background-color: var(--primary-color);
box-shadow: 5px 5px var(--primary-color);
width: 100%; } width: 100%; }
@media screen and (-webkit-min-device-pixel-ratio:0) {
audio::-webkit-media-controls-panel {
background-color: var(--secondary-color); }
audio { border-radius: 1.6rem; } }
@-moz-document url-prefix() {
audio { border-radius: 0; } }
html { margin-left: calc(100vw - 100%); } html { margin-left: calc(100vw - 100%); }
body { body {
@ -98,44 +84,73 @@ header {
flex-wrap: wrap; flex-wrap: wrap;
justify-content: space-between; } justify-content: space-between; }
#logo { header svg text { fill: var(--text-color); }
display: block;
width: 360px; }
#logo text { fill: var(--text-color); } header svg text:first-child {
font-size: 3.55rem;
#logo .logo {
font-size: 2rem;
font-variant-caps: small-caps; font-variant-caps: small-caps;
font-weight: bold; } font-weight: bold; }
@media screen and (-webkit-min-device-pixel-ratio:0) { header svg text:last-child { font-size: 1.5rem; }
#logo .logo { font-size: 2.082rem; } }
@-moz-document url-prefix() { @supports (-moz-appearance:none) {
#logo .logo { font-size: 2rem; } } header svg text:last-child { transform: scale(.993, 1); } }
#logo .under { font-size: .88rem; } header nav {
display: flex;
flex-direction: column;
font-variant: small-caps;
justify-content: space-evenly; }
nav { margin-top: .5rem; } header nav h1 {
nav a { font-variant: small-caps; }
nav h1 {
color: var(--secondary-color); color: var(--secondary-color);
margin: 0; } margin: 0; }
section { margin-top: 1rem; } section { margin-top: 1rem; }
#last-played { #banner { text-align: center; }
#player {
flex-direction: row;
align-items: center; }
#player p { text-indent: 1rem; }
#player img,
button#radio-play {
filter: drop-shadow(0px 0px 4px var(--text-color));
height: 1rem;
padding: 0 .7rem; }
button#radio-play {
background-image: url(/assets/img/play.svg);
height: 3rem;
min-width: 3rem;
width: 3rem; }
input#radio-volume {
accent-color: var(--primary-color);
direction: rtl;
height: 4rem;
margin-left: .5rem;
writing-mode: vertical-lr; }
#player div:first-child {
display: none;
flex-direction: row;
align-items: center; }
#last-songs {
margin: 0 auto; margin: 0 auto;
min-width: 80%; min-width: 80%;
width: 80%; } width: 80%; }
#last-played tbody tr { #last-songs :is(thead tr, tbody tr) {
display: grid; display: grid;
gap: .5rem; gap: .5rem;
grid-template-columns: 3rem 2rem 1fr; } grid-template-columns: 3rem 3rem 1fr; }
#last-songs thead tr { font-weight: bold; }
footer { footer {
font-size: .8rem; font-size: .8rem;
@ -143,12 +158,10 @@ footer {
padding: 1rem 0; } padding: 1rem 0; }
@media screen and (max-width: 640px) { @media screen and (max-width: 640px) {
header { display: block; } header {
align-items: center;
flex-direction: column; }
#logo { header svg { width: 100%; }
margin: 0 auto;
width: 100%; }
nav { #player { flex-direction: column; } }
width: 100%;
text-align: center; } }

View 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

View File

@ -1,2 +1 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg version="1.1" viewBox="0 0 574.17 258.67" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><g fill="#cd2682" aria-label="A'sD"><path d="m131.67 206.33h-66.833l-13 49.667h-51.833l75.833-242.67h44.833l76.333 242.67h-52.333zm-56.167-40.833h45.333l-22.667-86.5z" style="font-variant-caps:small-caps"/><path d="m226 24.667-4.8333 67.5h-30.667v-92.167h35.5z" style="font-variant-caps:small-caps"/><path d="m338.5 203.83q0-9.5-5.6667-15.333-5.5-5.8333-20.833-10.667-34.167-9.3333-47.333-22.833-13.167-13.667-13.167-38.5 0-25 17.667-41.167 17.667-16.167 45.833-16.167 31.5 0 50.167 16.5 18.833 16.333 18.833 44.167h-47q0-11-6-17.833-5.8333-6.8333-15.833-6.8333-8.8333 0-13.833 5.5-5 5.5-5 14 0 8 5.5 13.167 5.6667 5.1667 20.167 10.333 32.667 8.3333 47 23.167t14.333 41-17.333 41.333q-17.333 15-48 15-14.667 0-28.5-4.3333-13.667-4.3333-23.5-13-20-17.333-20-47.333h47.333q0 16.167 6 22.667 6 6.3333 21.167 6.3333 18 0 18-19.167z" style="font-variant-caps:small-caps"/><path d="m416.5 256v-242.67h64.167q42.5 0 67.667 27 25.333 27 25.833 74v39.333q0 47.833-25.333 75.167-25.167 27.167-69.5 27.167zm49-201.83v161.17h14.667q24.5 0 34.5-12.833 10-13 10.5-44.667v-42.167q0-34-9.5-47.333-9.5-13.5-32.333-14.167z" style="font-variant-caps:small-caps"/></g></svg> <svg version="1.1" viewBox="0 0 574.17 258.67" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><g fill="#cd2682" aria-label="A'sD"><path d="m131.67 206.33h-66.833l-13 49.667h-51.833l75.833-242.67h44.833l76.333 242.67h-52.333zm-56.167-40.833h45.333l-22.667-86.5z" style="font-variant-caps:small-caps"/><path d="m226 24.667-4.8333 67.5h-30.667v-92.167h35.5z" style="font-variant-caps:small-caps"/><path d="m338.5 203.83q0-9.5-5.6667-15.333-5.5-5.8333-20.833-10.667-34.167-9.3333-47.333-22.833-13.167-13.667-13.167-38.5 0-25 17.667-41.167 17.667-16.167 45.833-16.167 31.5 0 50.167 16.5 18.833 16.333 18.833 44.167h-47q0-11-6-17.833-5.8333-6.8333-15.833-6.8333-8.8333 0-13.833 5.5-5 5.5-5 14 0 8 5.5 13.167 5.6667 5.1667 20.167 10.333 32.667 8.3333 47 23.167t14.333 41-17.333 41.333q-17.333 15-48 15-14.667 0-28.5-4.3333-13.667-4.3333-23.5-13-20-17.333-20-47.333h47.333q0 16.167 6 22.667 6 6.3333 21.167 6.3333 18 0 18-19.167z" style="font-variant-caps:small-caps"/><path d="m416.5 256v-242.67h64.167q42.5 0 67.667 27 25.333 27 25.833 74v39.333q0 47.833-25.333 75.167-25.167 27.167-69.5 27.167zm49-201.83v161.17h14.667q24.5 0 34.5-12.833 10-13 10.5-44.667v-42.167q0-34-9.5-47.333-9.5-13.5-32.333-14.167z" style="font-variant-caps:small-caps"/></g></svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

View 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
View 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
View 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
View 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

Binary file not shown.

View File

@ -1,46 +1,92 @@
function $(id) { return document.getElementById(id); } const $ = id => document.getElementById(id);
function updateRadioStatus() { const formatDuration = date => `${date.getUTCHours() > 0 ? date.getUTCHours() + ":" : ""}${date.getUTCMinutes()}:${date.getUTCSeconds().toString().padStart(2, "0")}`;
fetch("/status") const formatStartAt = date => `${date.getHours().toString().padStart(2, "0")}:${date.getMinutes().toString().padStart(2, "0")}`;
.then(r => r.json())
.then(r => { let cursong_startat = null;
$("radio-status").innerHTML = let cursong_duration_msec = 0;
`On-air since <time datetime="${r.server_start_iso8601}">${r.server_start_date}</time>`;
$("radio-song").textContent = r.song; async function updateStatus() {
$("radio-listeners").textContent = r.listeners; const resp = await fetch("/api/status");
$("radio-listener-peak").textContent = r.listener_peak;
}).catch(() => { if (!resp.ok || 200 != resp.status) {
$("radio-status").textContent = "Radio is offline."; $("radio-song").textContent =
$("radio-song").textContent = ""; $("radio-duration-estimate").textContent =
$("radio-listeners").textContent = $("radio-duration").textContent = "";
$("radio-listener-peak").textContent = "0"; $("radio-listeners").textContent = "0";
}); $("last-songs").lastChild.remove();
return [-1, null];
} }
function updateLastPlayedSong() { const s = await resp.json();
fetch('/lastsong')
.then(r => r.json())
.then(last_played => {
if (last_played.time == $('last-played').firstChild.lastChild.firstChild.innerText)
return;
if ($('last-played').firstChild === null) if (undefined != s.last_songs) {
$('last-played').appendChild(document.createElement("tbody")) $("last-songs").lastChild.remove();
$("last-songs").appendChild(document.createElement("tbody"));
if ($('last-played').firstChild.children.length == 10) for (let i = 0; i < s.last_songs.length; ++i) {
$('last-played').firstChild.firstChild.remove(); let row = $("last-songs").lastChild.insertRow();
row.insertCell().appendChild(document.createTextNode(formatStartAt(new Date(s.last_songs[i].start_at))));
let row = $('last-played').insertRow(); 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(last_played.time)); row.insertCell().appendChild(document.createTextNode(`${s.last_songs[i].artist} - ${s.last_songs[i].title}`));
row.insertCell().appendChild(document.createTextNode(last_played.listeners == 0 ? "" : last_played.listeners)); }
row.insertCell().appendChild(document.createTextNode(last_played.song));
});
} }
document.getElementById("btn-update").addEventListener("click", () => { if (undefined == s.current_song || undefined == s.current_song.duration_msec)
updateLastPlayedSong(); return [-1, null];
updateRadioStatus();
})
setInterval(updateRadioStatus, 45000); $("radio-song").textContent = `${s.current_song.artist} - ${s.current_song.title}`;
setInterval(updateLastPlayedSong, 45000); $("radio-listeners").textContent = s.listeners;
$("radio-duration").textContent = formatDuration(new Date(s.current_song.duration_msec));
return [s.current_song.duration_msec, new Date(s.current_song.start_at)];
}
async function update() {
if (null === cursong_startat)
return -1;
const estimate = (new Date()) - (new Date(cursong_startat));
if (estimate >= cursong_duration_msec) {
return 1;
}
$("radio-duration-estimate").textContent = `${formatDuration(new Date(estimate))} / `;
return 0;
}
let update_interval_id = null;
async function interval() {
switch (await update()) {
case 1:
[cursong_duration_msec, cursong_startat] = await updateStatus();
break;
case -1:
clearInterval(update_interval_id);
await new Promise(resolve => setTimeout(resolve, 5000));
[cursong_duration_msec, cursong_startat] = await updateStatus();
update_interval_id = setInterval(interval, 1000);
}
}
updateStatus().then(r => [cursong_duration_msec, cursong_startat] = r);
update_interval_id = setInterval(interval, 1000);
const audio = document.getElementsByTagName("audio")[0];
audio.hidden = true;
const audio_src = audio.childNodes[0].src;
const volume = $("radio-volume");
volume.value = +(localStorage.getItem("volume") || 50);
audio.volume = volume.value / 100.0;
volume.addEventListener("input", e => {
audio.volume = e.target.value / 100.0;
localStorage.setItem("volume", e.target.value); });
$("player").style.display = $("player").firstChild.style.display = "flex";
$("radio-play").addEventListener("click", e => {
audio.paused ? (audio.src = audio_src) && audio.play() : audio.src = "";
e.target.style.backgroundImage = audio.paused ?
"url(/assets/img/play.svg)" : "url(/assets/img/stop.svg)"; });

3
web/assets/robots.txt Normal file
View File

@ -0,0 +1,3 @@
User-agent: *
Disallow: /assets/
Disallow: /live/

15
web/assets/sitemap.xml Normal file
View File

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://radio.arav.su/</loc>
<lastmod>2024-03-06</lastmod>
</url>
<url>
<loc>https://radio.arav.su/filelist</loc>
<changefreq>always</changefreq>
</url>
<url>
<loc>https://radio.arav.su/playlist</loc>
<lastmod>2023-02-23</lastmod>
</url>
</urlset>

129
web/index.templ Normal file
View 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&mdash;2024 { i18n.T(ctx, "footer.author") } &lt;<a href="mailto:me@arav.su">me@arav.su</a>&gt; <a href={ templ.SafeURL(utils.MainSite(r.Host) + "/privacy") }>{ i18n.T(ctx, "footer.privacy") }</a>
</footer>
</body>
</html>
}

View File

@ -1,141 +0,0 @@
// Code generated by "jade.go"; DO NOT EDIT.
package web
import (
"bytes"
"io"
"strconv"
)
var (
escaped = []byte{'<', '>', '"', '\'', '&'}
replacing = []string{"&lt;", "&gt;", "&#34;", "&#39;", "&amp;"}
)
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
View 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
View 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
View 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: О приватности

View File

@ -1,66 +0,0 @@
:go:func Index(mainSite string, songsNum int, status *radio.IcecastStatus, songs *[]radio.Song, r *http.Request)
:go:import "dwelling-radio/internal/radio"
:go:import "dwelling-radio/pkg/utils"
doctype html
html(lang='en')
head
title Arav's dwelling / Radio
meta(charset='utf-8')
meta(http-equiv='X-UA-Compatible' content='IE=edge')
meta(name='viewport' content='width=device-width, initial-scale=1.0')
meta(name='theme-color' content='#cd2682')
meta(name='description' content='Internet-radio broadcasting from under my desk.')
link(rel='icon' href='/assets/img/favicon.svg' sizes='any' type='image/svg+xml')
link(href='/assets/css/main.css' rel='stylesheet')
script(src='/assets/js/main.js' defer='')
body
header
svg#logo(viewBox='0 -25 216 40')
text.logo Arav's dwelling
text.under(y='11') Welcome to my sacred place, wanderer
nav
a(href=mainSite) Back to main website
h1 Radio
section
small.player-links
a(href='/filelist') filelist
a(href='/playlist') playlist (.m3u)
a(href='/live/stream.ogg') direct link
a(href='http://radio.arav.su:8000/stream.ogg') direct link (http)
a(href='http://wsmkgnmhmzqm7kyzv7jnzzafvgm7xlmlfvzhgorpapd5or2arnhuktqd.onion/live/stream.ogg') direct link (Tor)
a(href='http://radio.arav.i2p/live/stream.ogg') direct link (I2P)
a(href="https://dir.xiph.org/search?q=arav's+dwelling") Xiph
| OGG 128 Kb/s
audio(preload='none' controls='')
source(src='/live/stream.ogg' type='audio/ogg')
| Your browser doesn't support an audio element, it's sad... But you always can take the #[a(href='/playlist') playlist]!
if status.ServerStartDate != ""
p#radio-status On-air since
time(datetime=status.ServerStartISO8601)= status.ServerStartDate
else
p#radio-status Radio is offline.
p Now playing: #[span#radio-song #{status.SongName}]
p Current/peak listeners: #[span#radio-listeners #{status.Listeners}] / #[span#radio-listener-peak #{status.ListenerPeak}]
p
small Notice: information updates every 45 seconds. But you can #[button(id='btn-update') update] it forcibly.
section
h2 Last #{songsNum} songs
table#last-played
each song in *songs
tr
td= song.Time
if song.Listeners != "0"
td= song.Listeners
else
td
td= song.Song
- ml := radio.MostListened()
if ml.Song != ""
p.right Most listened song was "#{ml.Song}" on #{utils.ToClientTimezone(ml.Date, r).Format(radio.MostListenedDateFormat)} with #[b #{ml.Listeners}] listeners.
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 #{songsNum} songs list.
footer
| 2017&mdash;2023 Alexander &quot;Arav&quot; Andreev &lt;#[a(href='mailto:me@arav.su') me@arav.su]&gt;

View File

@ -6,9 +6,6 @@ import (
"net/http" "net/http"
) )
// To install a Jade compiler: go install github.com/Joker/jade/cmd/jade@latest
//go:generate $GOPATH/bin/jade -pkg=web -stdbuf -stdlib -writer templates/index.pug
//go:embed assets //go:embed assets
var assetsDir embed.FS var assetsDir embed.FS
@ -17,6 +14,21 @@ func Assets() http.FileSystem {
return http.FS(f) return http.FS(f)
} }
func AssetsGetFile(path string) ([]byte, error) { func ServeAsset(path, mime, attachement string) func(http.ResponseWriter, *http.Request) {
return assetsDir.ReadFile("assets/" + path) return func(w http.ResponseWriter, r *http.Request) {
if mime != "" {
w.Header().Add("Content-Type", mime)
}
if attachement != "" {
w.Header().Add("Content-Disposition", "attachment; filename=\""+attachement+"\"")
}
data, err := assetsDir.ReadFile("assets/" + path)
if err != nil {
panic(err)
}
w.Write(data)
}
} }