1
0

Compare commits

..

407 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
eb6b0c68b3
No need in preloading. Because in this moment liquidsoap starts and adds a new song that Icecast writes in playlist.log so IN_MODIFY is being triggered. 2023-03-13 05:35:39 +04:00
c02442ebcd
Okay, there is actually a new line at the end of file. Let's skip that. 2023-03-13 05:10:17 +04:00
c58ef0685a
Another missing / was found. 2023-03-13 04:55:24 +04:00
c7773874c5
Added -trimpath flag. FLAGS var created. 2023-03-13 04:47:43 +04:00
38b427815f
Replace pkgdir with srcdir for gopath. 2023-03-13 04:43:14 +04:00
3ecd86db10
Added missing slashes in Makefile. 2023-03-13 04:38:03 +04:00
f477a3a829
Added missing -. xD 2023-03-13 04:13:39 +04:00
e5558e3f89
Added -modcacherw in Makefile. 2023-03-13 04:11:02 +04:00
eaea8df71e
Log only critical errors. Level 2 produces sentences like Decoding some/song.ogg ended: Ogg.End_of_stream for every song. 2023-03-13 04:02:05 +04:00
31a892e43d
Remove log section from config, since it is not used anymore. 2023-03-13 03:31:40 +04:00
aac6d8e43a
Logically separate handlers with new lines in main.go. 2023-03-13 03:28:01 +04:00
d1b1d2537f
Set min ver for go dependency. Set GOPATH. Set PREFIX. 2023-03-13 03:18:00 +04:00
285b9f75e2
First, learned about := operator that means assign once. Second, added PREFIX var. Third, do not copy LICENSE file. 2023-03-13 03:17:14 +04:00
acd0087ddf
Oh, if http server stop fail, then most listened song will not be saved. Fix that. 2023-03-13 02:20:57 +04:00
4e16f2b3ed
Add *.log file to .gitignore. 2023-03-13 02:15:25 +04:00
7f5a3bdb7f
Let's actually add a test config and a testing run target in makefile. 2023-03-13 02:14:52 +04:00
dd1469c957
Added .right class to align a text to right. 2023-03-13 02:12:38 +04:00
8e0d5e4fb7
Date format was moved out to a const. 2023-03-13 02:12:21 +04:00
95e9d97a22
Updated web.Index() call in a handler. 2023-03-13 02:12:02 +04:00
184561ab29
Convert date of a most listened song to client's local time. 2023-03-13 02:11:33 +04:00
bdf778cc67
Version up to 23.10.1. 2023-03-13 01:54:55 +04:00
0fffbce646
First try to load currently playing song. 2023-03-13 01:53:52 +04:00
2e60f2bb8a
Check for empty prev song. 2023-03-13 01:42:24 +04:00
f24953ae7c
Amount of listeners of next song is actually how much listeners was by the end of a prev song. 2023-03-13 01:42:00 +04:00
3207395b67
Load and store most listened song. 2023-03-13 01:41:13 +04:00
1ab82003e6
Catch SIGSEGV as well. 2023-03-13 01:40:56 +04:00
1b91f70edd
Added most_listened_song_file_path option. 2023-03-13 01:40:30 +04:00
2b47748c30
Show most listened song on a page. 2023-03-13 00:04:53 +04:00
e84a811a1d
Update most listened song. 2023-03-13 00:04:34 +04:00
8f16b5d96f
Implemented a functionality for storing and showing most listened song. 2023-03-13 00:03:59 +04:00
53d24c5781
Store current song in a separate variable. A base for most listened song functionality. 2023-03-12 22:25:58 +04:00
63fcb36e60
Nah, 3KiB should be enought to read. 2023-03-12 22:06:15 +04:00
341ea73e97
Check if tbody is present and create it if doesn't. 2023-03-12 21:57:03 +04:00
6b1f2e76e3
Do not remove first row while there is less than 10 rows. 2023-03-12 21:50:06 +04:00
d48972caa0
Okay, now let's return original idea of adding songs one by one. 2023-03-12 21:48:01 +04:00
6d4a276dd8
IcecastLastSongs() (formerly known as IcecastLastPlayedSongs()) args were changed, so let's delete unneeded arg. 2023-03-12 21:32:29 +04:00
517dd0e534
Fixed last songs displaying. 2023-03-12 21:31:18 +04:00
1ebecc91d9
Keep last songs section even if there are no songs. Otherwise a JS code will be broken without further bloating. 2023-03-12 04:05:07 +04:00
0409da3ca3
Oh, last commit led to loading of full list of prev songs. Let's remove this code. 2023-03-12 04:01:32 +04:00
10048c671e
Prevent reading latest song from a log right after a service started. 2023-03-12 03:53:09 +04:00
e73a9a3b12
Added my full name in a footer. 2023-03-12 03:06:02 +04:00
24b78e00ff
Put actual number of last songs displayed in a privacy statements. 2023-03-12 02:58:51 +04:00
ab19f045d2
Set log flag to only pring source file name and line. 2023-03-12 02:38:34 +04:00
028708e5aa
Changed version to 23.10.0. 2023-03-12 02:37:48 +04:00
db02047268
Now lastPlayedCache's initial capacity is not hardcoded to 10. And it will be filled slowly, instead of initial read of last N songs from a playlist.log. 2023-03-12 02:19:07 +04:00
47e3ff37ba
Reduced bufferSize because 16KiB is more than enough for a max of 10 songs. 2023-03-12 02:16:55 +04:00
38f995302d
Added optional dependencies to PKGBUILD. 2023-03-12 01:28:34 +04:00
71bbcd1edc
Set normalization to its current values, only threshold was changed from -75. to -40.. 2023-03-12 01:03:46 +04:00
e4ca8e424d
Change go version back to 1.17 what the program was initially made with. 2023-03-12 01:01:37 +04:00
6424702375
Do not keep bin dir since it is being created at compile time. 2023-03-12 00:58:59 +04:00
970fd2bb6a
Version up to 23.8.1. 2023-02-25 14:53:54 +04:00
9a27077d55
Made use of replaygain and tweaked normalization, so my radio must become louder. 2023-02-25 14:53:31 +04:00
0db75f30a2
Version up to 23.8.0. 2023-02-23 17:49:59 +04:00
83cdc222c7
Change arav.top to arav.su. 2023-02-23 17:49:01 +04:00
62 changed files with 1822 additions and 1043 deletions

4
.gitignore vendored
View File

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

View File

@ -1,4 +1,4 @@
Copyright (c) 2022 Alexander "Arav" Andreev <me@arav.top> Copyright (c) 2022,2023 Alexander "Arav" Andreev <me@arav.su>
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

View File

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

View File

View File

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

View File

@ -1,83 +1,159 @@
package main package main
import ( import (
"dwelling-radio/internal/configuration" ihttp "dwelling-radio/internal/http"
"dwelling-radio/internal/http"
"dwelling-radio/internal/radio" "dwelling-radio/internal/radio"
"errors" sqlite_stats "dwelling-radio/internal/statistics/db/sqlite"
"dwelling-radio/pkg/utils"
"dwelling-radio/web"
"dwelling-radio/web/locales"
"flag" "flag"
"fmt" "fmt"
"log" "log"
"net/http"
"os" "os"
"os/signal" "os/signal"
"path"
"syscall" "syscall"
"git.arav.su/Arav/httpr"
"github.com/invopop/ctxi18n"
)
var (
listenAddress = flag.String("listen", "/var/run/dwelling-radio/sock", "listen address (ip:port|unix_path)")
workDirPath = flag.String("work-dir", "/mnt/data/appdata/radio", "path to a working directory")
playlistName = flag.String("playlist", "all-rand", "a playlist name")
songListLen = flag.Int64("list-length", 10, "number of songs to show in last N songs table")
showVersion = flag.Bool("v", false, "show version")
) )
var version string var version string
var configPath *string = flag.String("conf", "config.yaml", "path to configuration file")
var noLiquidsoap *bool = flag.Bool("no-liquidsoap", false, "don't run liquidsoap")
var showVersion *bool = flag.Bool("v", false, "show version")
func main() { func main() {
flag.Parse() flag.Parse()
log.SetFlags(log.Lshortfile)
if *showVersion { if *showVersion {
fmt.Println("dwelling-radio ver.", version, "\nCopyright (c) 2022,2023 Alexander \"Arav\" Andreev <me@arav.top>") fmt.Println("dwelling-radio ver.", version, "\nCopyright (c) 2022-2024 Alexander \"Arav\" Andreev <me@arav.su>")
return return
} }
config, err := configuration.Load(*configPath) stats, err := sqlite_stats.New(path.Join(*workDirPath, "statistics.db3"))
if err != nil {
log.Fatalln("Statistics:", err)
}
defer stats.Close()
currentSong := radio.Song{}
lstnrs := radio.NewListenerCounter()
plylst, err := radio.NewPlaylist(path.Join(*workDirPath, "playlists", *playlistName), true)
if err != nil { if err != nil {
log.Fatalln(err) log.Fatalln(err)
} }
if typ, addr := config.SplitNetworkAddress(); typ == "unix" { if err := ctxi18n.LoadWithDefault(locales.Content, "en"); err != nil {
defer os.Remove(addr)
}
playlistWatcher := radio.NewPlaylistLogWatcher()
if err := playlistWatcher.Watch(config.Icecast.Playlist, config.ListLastNSongs); err != nil {
log.Fatalln(err) log.Fatalln(err)
} }
defer playlistWatcher.Close()
hand := http.NewHandlers(config) r := httpr.New()
srv := http.NewHttpServer()
srv.ServeStatic("/assets/*filepath", hand.AssetsFS()) r.Handler(http.MethodGet, "/", func(w http.ResponseWriter, r *http.Request) {
srv.GET("/", hand.Index) lst, err := stats.LastNSongs(*songListLen)
srv.GET("/status", hand.Status)
srv.GET("/lastsong", hand.LastSong)
srv.GET("/playlist", hand.Playlist)
srv.GET("/filelist", hand.Filelist)
srv.GET("/robots.txt", hand.RobotsTxt)
if !*noLiquidsoap {
liquid, err := radio.NewLiquidsoap(config.Liquidsoap.ExecPath, config.Liquidsoap.ScriptPath)
if err != nil { if err != nil {
log.Fatalln("liquidsoap:", err) log.Printf("Failed to fetch last N songs: %s\n", err)
} }
defer func() { lstnrs.RLock()
if err := liquid.Stop(); err != nil { defer lstnrs.RUnlock()
if !errors.Is(err, radio.ErrLiquidsoapNotRunning) { web.Index(version, &currentSong, lst, *songListLen, lstnrs, r).Render(r.Context(), w)
log.Println(err) })
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() {
if err := srv.Stop(); err != nil {
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()
if err := srv.Start(config.SplitNetworkAddress()); err != nil { defer plylst.Unlock()
log.Fatalln(err) if err := plylst.Reload(); err != nil {
} log.Println(err)
}
doneSignal := make(chan os.Signal, 1) }
signal.Notify(doneSignal, os.Interrupt, syscall.SIGINT, syscall.SIGTERM)
<-doneSignal
if err := srv.Stop(); err != nil {
log.Fatalln(err)
} }
} }
func I18nMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
lang := "en"
if lq := r.URL.Query().Get("lang"); lq != "" {
lc := http.Cookie{Name: "lang", Value: lq, HttpOnly: false, MaxAge: 0}
http.SetCookie(w, &lc)
lang = lq
} else if l, err := r.Cookie("lang"); err == nil {
lang = l.Value
} else if al := r.Header.Get("Accept-Language"); al != "" {
lang = r.Header.Get("Accept-Language")
}
ctx, err := ctxi18n.WithLocale(r.Context(), lang)
if err != nil {
log.Println("i18nmw:", err)
}
next.ServeHTTP(w, r.WithContext(ctx))
})
}

View File

@ -1,16 +0,0 @@
# Sets network type (could be tcp{,4,6}, unix)
# and address:port or /path/to/unix.sock to
# listen on.
listen_on: "unix /var/run/dwelling-radio/sock"
icecast:
# URL to Icecast's status-json.xsl
url: "http://radio.arav.home.arpa/status-json.xsl"
playlist_path: "/var/log/icecast/playlist.log"
filelist_path: "/srv/radio/filelist.html"
liquidsoap:
executable_path: "/opt/opam/4.14.0/bin/liquidsoap"
script_path: "/etc/dwelling/radio.liq"
# How much songs to list on a page
list_last_n_songs: 10
log:
error: "/var/log/dwelling-radio/error.log"

46
configs/ezstream.xml Normal file
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,22 +1,23 @@
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.top radio.arav.i2p mkgnmhmzqm7kyzv7jnzzafvgm7xlmlfvzhgorpapd5or2arnhuktqd.onion; http2 on;
server_name radio.arav.su radio.arav.i2p plkybcgxt4cdanot75cy3pbnqlbqcsrib2fmrpsnug4bqphqvfda.b32.i2p mkgnmhmzqm7kyzv7jnzzafvgm7xlmlfvzhgorpapd5or2arnhuktqd.onion;
access_log /var/log/nginx/dwelling/radio.log main if=$nolog; access_log /var/log/nginx/dwelling/radio.log main if=$nolog;
ssl_certificate /etc/letsencrypt/live/arav.top/fullchain.pem; ssl_certificate /etc/letsencrypt/live/arav.su/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/arav.top/privkey.pem; ssl_certificate_key /etc/letsencrypt/live/arav.su/privkey.pem;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self'; media-src 'self'; object-src 'none'; frame-src 'none'; frame-ancestors 'none'; font-src 'self'; form-action 'none'"; add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self'; media-src 'self'; object-src 'none'; frame-src 'none'; frame-ancestors 'none'; font-src 'self'; form-action 'none'";
add_header X-Frame-Options "DENY"; add_header X-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 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,12 +37,24 @@ 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.top; server_name radio.arav.su;
access_log /var/log/nginx/dwelling/radio.http.log main if=$nolog; access_log /var/log/nginx/dwelling/radio.http.log main if=$nolog;
@ -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_buffering off; proxy_bind $remote_addr transparent;
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,14 @@ def fallback_alter_title(m) =
("title", string.concat(["No stream. (Playing ", m["artist"], " - ", m["title"], ")"]))] ("title", string.concat(["No stream. (Playing ", m["artist"], " - ", m["title"], ")"]))]
end end
settings.server.telnet.set(false) settings.server.telnet := false
settings.harbor.bind_addrs.set(["0.0.0.0"]) settings.harbor.bind_addrs := ["0.0.0.0"]
settings.log.level.set(2) settings.log.level := 1
settings.log.file.set(true) settings.log.file := true
settings.log.file.path.set(log_file_path) settings.log.file.path := log_file_path
settings.log.stdout.set(false) settings.log.stdout := false
enable_replaygain_metadata()
fallback_song = mksafe(single(fullpath("fallback.ogg"))) fallback_song = mksafe(single(fullpath("fallback.ogg")))
fallback_song = metadata.map(fallback_alter_title, fallback_song) fallback_song = metadata.map(fallback_alter_title, fallback_song)
@ -34,17 +36,18 @@ live_show = input.harbor("adr-live-show", port = harbor_port, password = harbor_
live_show = metadata.map(fun (_) -> [("artist", radio_name), ("title", "Live Show")], live_show) live_show = metadata.map(fun (_) -> [("artist", radio_name), ("title", "Live Show")], live_show)
playlist_random = playlist(fullpath("playlists/all-rand"), playlist_random = playlist(fullpath("playlists/all-rand"),
prefix = fullpath("music/"), prefix = string.concat(["replaygain:", fullpath("music/")]),
mode = "normal", reload_mode = "watch") mode = "normal", reload_mode = "watch")
music = audio_to_stereo(playlist_random) music = audio_to_stereo(playlist_random)
music = replaygain(music)
radio = smooth_add(p = 0.18, normal = music, special = live_mixin) radio = smooth_add(p = 0.18, normal = music, special = live_mixin)
radio = fallback(track_sensitive = false, radio = fallback(track_sensitive = false,
transitions = [xfade, xfade, xfade], transitions = [xfade, xfade, xfade],
[ blank.strip(max_blank=15., live_show), [ blank.strip(max_blank=15., live_show),
blank.strip(max_blank=90., radio), fallback_song]) blank.strip(max_blank=90., radio), fallback_song])
radio = normalize(radio) radio = normalize(target = -12., threshold = -40., lufs = true, radio)
output.icecast(%vorbis.cbr(bitrate = 128, samplerate = 44100, channels = 2), output.icecast(%vorbis.cbr(bitrate = 128, samplerate = 44100, channels = 2),
host = icecast_host, port = icecast_port, host = icecast_host, port = icecast_port,

View File

@ -2,17 +2,17 @@
log_file_path = "/var/log/dwelling-radio/liquidsoap.log" log_file_path = "/var/log/dwelling-radio/liquidsoap.log"
radio_url = "https://radio.arav.top" radio_url = "https://radio.arav.su"
radio_name = "Arav's dwelling / Radio" radio_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.20 go 1.21
toolchain go1.22.3
require github.com/pkg/errors v0.9.1
require git.arav.su/Arav/httpr v0.3.2
require github.com/a-h/templ v0.2.778
require ( require (
github.com/julienschmidt/httprouter v1.3.0 github.com/invopop/ctxi18n v0.8.1
github.com/pkg/errors v0.9.1 github.com/mattn/go-sqlite3 v1.14.23
gopkg.in/yaml.v3 v3.0.1 )
require (
github.com/invopop/yaml v0.3.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
) )

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

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,112 +0,0 @@
package http
import (
"dwelling-radio/internal/configuration"
"dwelling-radio/internal/radio"
"dwelling-radio/pkg/utils"
"dwelling-radio/web"
"encoding/json"
"log"
"net/http"
"os"
"time"
)
const FormatISO8601 = "2006-01-02T15:04:05-0700"
type Handlers struct {
conf *configuration.Configuration
}
func NewHandlers(conf *configuration.Configuration) *Handlers {
return &Handlers{conf: conf}
}
func (h *Handlers) AssetsFS() http.FileSystem {
return web.Assets()
}
func (h *Handlers) Index(w http.ResponseWriter, r *http.Request) {
status, err := radio.IcecastGetStatus(h.conf.Icecast.URL)
if err != nil {
log.Println("failed to get Icecast status:", err)
} else {
if tim, err := time.Parse(time.RFC1123Z, status.ServerStartDate); err == nil {
status.ServerStartDate = utils.ToClientTimezone(tim, r).Format(time.RFC1123)
}
if tim, err := time.Parse(FormatISO8601, status.ServerStartISO8601); err == nil {
status.ServerStartISO8601 = utils.ToClientTimezone(tim, r).Format(FormatISO8601)
}
}
songs, err := radio.IcecastLastPlayedSongs(h.conf.ListLastNSongs,
h.conf.Icecast.Playlist)
if err != nil {
log.Println("cannot retrieve last songs:", err)
} else {
for i := 0; i < len(songs); i++ {
if tim, err := time.Parse(radio.SongTimeFormat, songs[i].Time); err == nil {
songs[i].Time = utils.ToClientTimezone(tim, r).Format("15:04")
}
}
}
web.Index(utils.MainSite(r.Host), h.conf.ListLastNSongs, status, &songs, w)
}
func (h *Handlers) Status(w http.ResponseWriter, r *http.Request) {
status, err := radio.IcecastGetStatus(h.conf.Icecast.URL)
if err != nil {
log.Println("cannot retrieve Icecast status:", err)
http.Error(w, "cannot retrieve Icecast status", http.StatusInternalServerError)
return
}
if tim, err := time.Parse(time.RFC1123Z, status.ServerStartDate); err == nil {
status.ServerStartDate = utils.ToClientTimezone(tim, r).Format(time.RFC1123)
}
if tim, err := time.Parse(FormatISO8601, status.ServerStartISO8601); err == nil {
status.ServerStartISO8601 = utils.ToClientTimezone(tim, r).Format(FormatISO8601)
}
w.Header().Add("Content-Type", "application/json")
json.NewEncoder(w).Encode(status)
}
func (h *Handlers) LastSong(w http.ResponseWriter, r *http.Request) {
song, err := radio.IcecastLastSong(h.conf.Icecast.Playlist)
if err != nil {
log.Println("cannot retrieve last songs:", err)
}
if song.Time == "" {
w.WriteHeader(http.StatusNotFound)
return
}
if tim, err := time.Parse(radio.SongTimeFormat, song.Time); err == nil {
song.Time = utils.ToClientTimezone(tim, r).Format("15:04")
}
w.Header().Add("Content-Type", "application/json")
json.NewEncoder(w).Encode(song)
}
func (h *Handlers) Playlist(w http.ResponseWriter, _ *http.Request) {
w.Header().Add("Content-Disposition", "attachment; filename=\"radio.arav.top.m3u\"")
fc, _ := web.AssetsGetFile("radio.arav.top.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,225 +0,0 @@
package radio
import (
"bytes"
"dwelling-radio/pkg/watcher"
"encoding/json"
"io"
"net/http"
"os"
"sync"
"syscall"
"time"
"github.com/pkg/errors"
)
const (
IcecastPlaylistDateFormat = "02/Jan/2006:15:04:05 -0700"
SongTimeFormat = "2006 15:04-0700"
bufferSize = 32768
)
var (
lastPlayedCache []Song = make([]Song, 10)
lastPlayedCacheMutex sync.Mutex
)
type IcecastStatusDTO struct {
Icestats struct {
ServerStartISO8601 string `json:"server_start_iso8601"`
ServerStartDate string `json:"server_start"`
Source struct {
Artist string `json:"artist"`
Title string `json:"title"`
ListenerPeak int `json:"listener_peak"`
Listeners int `json:"listeners"`
} `json:"source"`
} `json:"icestats"`
}
func (is *IcecastStatusDTO) SongName() string {
return is.Icestats.Source.Artist + " - " + is.Icestats.Source.Title
}
type IcecastStatus struct {
ServerStartISO8601 string `json:"server_start_iso8601"`
ServerStartDate string `json:"server_start_date"`
SongName string `json:"song"`
ListenerPeak int `json:"listener_peak"`
Listeners int `json:"listeners"`
}
type Song struct {
Time string `json:"time"`
Listeners string `json:"listeners"`
Song string `json:"song"`
}
func IcecastGetStatus(icecastURL string) (*IcecastStatus, error) {
resp, err := http.Get(icecastURL)
if err != nil {
return nil, err
}
iceStatDTO := &IcecastStatusDTO{}
if err := json.NewDecoder(resp.Body).Decode(iceStatDTO); err != nil {
return nil, err
}
return &IcecastStatus{
ServerStartISO8601: iceStatDTO.Icestats.ServerStartISO8601,
ServerStartDate: iceStatDTO.Icestats.ServerStartDate,
SongName: iceStatDTO.SongName(),
ListenerPeak: iceStatDTO.Icestats.Source.ListenerPeak,
Listeners: iceStatDTO.Icestats.Source.Listeners,
}, nil
}
func IcecastLastPlayedSongs(lastNSongs int, playlistPath string) ([]Song, error) {
{
lastPlayedCacheMutex.Lock()
defer lastPlayedCacheMutex.Unlock()
if lpcLen := len(lastPlayedCache); lpcLen > 0 {
if lastNSongs > lpcLen {
lastNSongs = lpcLen
}
var ret []Song = make([]Song, lastNSongs)
copy(ret[:], lastPlayedCache[lpcLen-lastNSongs:])
return ret, nil
}
}
songs, err := icecastLastPlayedSongs(playlistPath, lastNSongs)
if err != nil {
return make([]Song, 0), err
}
return songs, nil
}
func IcecastLastSong(playlistPath string) (Song, error) {
{
lastPlayedCacheMutex.Lock()
defer lastPlayedCacheMutex.Unlock()
if lpcLen := len(lastPlayedCache); lpcLen > 0 {
return lastPlayedCache[lpcLen-1], nil
}
}
song, err := icecastLastPlayedSongs(playlistPath, 1)
if err != nil {
return Song{}, err
}
return song[0], nil
}
func icecastLastPlayedSongs(playlistPath string, n int) ([]Song, error) {
songs := make([]Song, n)
var buf []byte
var offset int64 = 0
playlist, err := os.Open(playlistPath)
if err != nil {
return nil, err
}
defer playlist.Close()
playlist_stat, _ := playlist.Stat()
if playlist_stat.Size() == 0 {
return nil, nil
}
if playlist_stat.Size() < bufferSize {
buf = make([]byte, playlist_stat.Size())
} else {
buf = make([]byte, bufferSize)
offset = playlist_stat.Size() - bufferSize
}
_, err = playlist.ReadAt(buf, offset)
if err != nil && err != io.EOF {
return nil, err
}
lines := bytes.Split(buf, []byte("\n"))
if len(lines) < 3 {
return nil, nil
}
lines = lines[:len(lines)-2]
if len(lines) > n {
lines = lines[len(lines)-n:]
}
for _, line := range lines {
fields := bytes.Split(line, []byte("|"))
tim, _ := time.Parse(IcecastPlaylistDateFormat, string(fields[0]))
songs = append(songs, Song{
Time: tim.Format(SongTimeFormat),
Listeners: string(fields[2]),
Song: string(fields[3])})
}
return songs, nil
}
type PlaylistLogWatcher struct {
watcher *watcher.InotifyWatcher
changed chan uint32
}
func NewPlaylistLogWatcher() *PlaylistLogWatcher {
return &PlaylistLogWatcher{changed: make(chan uint32)}
}
func (pw *PlaylistLogWatcher) Watch(playlistPath string, n int) (err error) {
if pw.watcher != nil {
pw.watcher.Close()
}
pw.watcher, err = watcher.NewInotifyWatcher()
if err != nil {
return errors.Wrap(err, "cannot instantiate inotify watcher")
}
err = pw.watcher.AddWatch(playlistPath, watcher.ModIgnMask)
if err != nil {
return errors.Wrap(err, "cannot set a playlist to watch")
}
pw.watcher.WatchForMask(pw.changed, watcher.ModIgnMask)
go func() {
for {
select {
case mask := <-pw.changed:
if mask&syscall.IN_MODIFY > 0 {
lastPlayedCacheMutex.Lock()
if songs, err := icecastLastPlayedSongs(playlistPath, n); err == nil {
lastPlayedCache = songs
}
lastPlayedCacheMutex.Unlock()
} else if mask&syscall.IN_IGNORED > 0 {
pw.Close()
pw.Watch(playlistPath, n)
return
}
}
}
}()
return nil
}
func (pw *PlaylistLogWatcher) Close() {
pw.watcher.Close()
}

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

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

@ -16,7 +16,14 @@ func MainSite(host string) string {
return "http://[300:a98d:d6d0:8a08::f]" return "http://[300:a98d:d6d0:8a08::f]"
} }
return "https://arav.top" return "https://arav.su"
}
func Site(host string) string {
if strings.Contains(host, ".su") {
return "https://radio.arav.su"
}
return "http://" + host
} }
// ToClientTimezone converts given time to timezone set in a // ToClientTimezone converts given time to timezone set in a

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

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,42 +1,92 @@
function $(id) { return document.getElementById(id); } const $ = id => document.getElementById(id);
function updateRadioStatus() { const formatDuration = date => `${date.getUTCHours() > 0 ? date.getUTCHours() + ":" : ""}${date.getUTCMinutes()}:${date.getUTCSeconds().toString().padStart(2, "0")}`;
fetch("/status") const formatStartAt = date => `${date.getHours().toString().padStart(2, "0")}:${date.getMinutes().toString().padStart(2, "0")}`;
.then(r => r.json())
.then(r => { let cursong_startat = null;
$("radio-status").innerHTML = let cursong_duration_msec = 0;
`On-air since <time datetime="${r.server_start_iso8601}">${r.server_start_date}</time>`;
$("radio-song").textContent = r.song; async function updateStatus() {
$("radio-listeners").textContent = r.listeners; const resp = await fetch("/api/status");
$("radio-listener-peak").textContent = r.listener_peak;
}).catch(() => { if (!resp.ok || 200 != resp.status) {
$("radio-status").textContent = "Radio is offline."; $("radio-song").textContent =
$("radio-song").textContent = ""; $("radio-duration-estimate").textContent =
$("radio-listeners").textContent = $("radio-duration").textContent = "";
$("radio-listener-peak").textContent = "0"; $("radio-listeners").textContent = "0";
}); $("last-songs").lastChild.remove();
return [-1, null];
}
const s = await resp.json();
if (undefined != s.last_songs) {
$("last-songs").lastChild.remove();
$("last-songs").appendChild(document.createElement("tbody"));
for (let i = 0; i < s.last_songs.length; ++i) {
let row = $("last-songs").lastChild.insertRow();
row.insertCell().appendChild(document.createTextNode(formatStartAt(new Date(s.last_songs[i].start_at))));
row.insertCell().appendChild(document.createTextNode((s.last_songs[i].listeners == undefined ? "" : s.last_songs[i].listeners + "/") + (s.last_songs[i].peak_listeners == undefined ? "" : s.last_songs[i].peak_listeners)));
row.insertCell().appendChild(document.createTextNode(`${s.last_songs[i].artist} - ${s.last_songs[i].title}`));
}
}
if (undefined == s.current_song || undefined == s.current_song.duration_msec)
return [-1, null];
$("radio-song").textContent = `${s.current_song.artist} - ${s.current_song.title}`;
$("radio-listeners").textContent = s.listeners;
$("radio-duration").textContent = formatDuration(new Date(s.current_song.duration_msec));
return [s.current_song.duration_msec, new Date(s.current_song.start_at)];
} }
function updateLastPlayedSong() { async function update() {
fetch('/lastsong') if (null === cursong_startat)
.then(r => r.json()) return -1;
.then(last_played => {
if (last_played.time == $('last-played').firstChild.lastChild.firstChild.innerText)
return;
$('last-played').firstChild.firstChild.remove(); const estimate = (new Date()) - (new Date(cursong_startat));
if (estimate >= cursong_duration_msec) {
return 1;
}
let row = $('last-played').insertRow(); $("radio-duration-estimate").textContent = `${formatDuration(new Date(estimate))} / `;
row.insertCell().appendChild(document.createTextNode(last_played.time)); return 0;
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", () => { let update_interval_id = null;
updateLastPlayedSong(); async function interval() {
updateRadioStatus(); 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);
}
}
setInterval(updateRadioStatus, 45000); updateStatus().then(r => [cursong_duration_msec, cursong_startat] = r);
setInterval(updateLastPlayedSong, 45000); 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)"; });

View File

@ -1,8 +1,8 @@
#EXTM3U #EXTM3U
#EXTINF:-1,Arav's dwelling / Radio #EXTINF:-1,Arav's dwelling / Radio
http://radio.arav.top:8000/stream.ogg http://radio.arav.su:8000/stream.ogg
#EXTINF:-1,Arav's dwelling / Radio (HTTPS) #EXTINF:-1,Arav's dwelling / Radio (HTTPS)
https://radio.arav.top/live/stream.ogg https://radio.arav.su/live/stream.ogg
#EXTINF:-1,Arav's dwelling / Radio on Tor #EXTINF:-1,Arav's dwelling / Radio on Tor
http://wsmkgnmhmzqm7kyzv7jnzzafvgm7xlmlfvzhgorpapd5or2arnhuktqd.onion/live/stream.ogg http://wsmkgnmhmzqm7kyzv7jnzzafvgm7xlmlfvzhgorpapd5or2arnhuktqd.onion/live/stream.ogg
#EXTINF:-1,Arav's dwelling / Radio on I2P #EXTINF:-1,Arav's dwelling / Radio on I2P

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