Compare commits

..

35 Commits

Author SHA1 Message Date
dsc
46fae8ace2 update soap.liq, compat. with liquidsoap >2.2 2024-04-12 00:39:46 +03:00
dsc
d4ec43f6dc this is fucked 2024-04-10 00:40:35 +03:00
dsc
72e0b79618 fuck liquidsoap 2024-04-09 22:33:24 +03:00
dsc
2045da9778 Merge pull request 'Add rudimentary error handling of title' (#11) from scoobybejesus/ircradio:title into master
Reviewed-on: https://git.wownero.com/dsc/ircradio/pulls/11
2023-10-18 20:48:52 +00:00
scoobybejesus
1e70b240f3 Add rudimentary error handling of title 2023-10-18 16:31:42 -04:00
dsc
60f3b6c53b Merge pull request 'Factor out song retrieval and printing. Allow request() to benefit from '|' operator.' (#10) from scoobybejesus/ircradio:master into master
Reviewed-on: https://git.wownero.com/dsc/ircradio/pulls/10
2023-10-16 22:02:39 +00:00
scoobybejesus
a4930649fc Incorporate _search into request(). Add helper functions for returning
and printing song results.
2023-09-18 13:40:20 -04:00
dsc
bbb9c7b0c6 fix lower() 2023-09-05 23:13:23 +03:00
dsc
0bd046cf77 Merge pull request 'flexxing boxes and fading button' (#8) from blasty/ircradio:master into master
Reviewed-on: https://git.wownero.com/dsc/ircradio/pulls/8
2023-09-05 19:55:29 +00:00
Ricky 'fluffybrony' Spaghettio
590a6ffec2 flexxing boxes and fading button 2023-09-05 21:43:46 +02:00
dsc
a0440060b4 sum listeners 2023-09-03 21:34:25 +03:00
dsc
cabef9ffe2 reformat the request table a bit 2023-09-03 21:30:05 +03:00
dsc
1f04b1e9d5 fix thumbnails 2023-09-02 22:24:12 +03:00
dsc
9cff22f6ac more scheme nonsense 2023-09-02 22:13:24 +03:00
dsc
ba37f74599 close writer 2023-09-02 22:11:38 +03:00
dsc
c59e2edf8e browsers are too strict nowadays 2023-09-02 21:46:31 +03:00
dsc
238022c052 fix yt-dlp path 2023-09-02 21:38:14 +03:00
dsc
311e3279bf remove station truncation :P 2023-09-02 21:26:45 +03:00
dsc
4a6d6025d7 fix youtube download 2023-09-02 21:19:05 +03:00
dsc
62c04edc09 Refactor/rewrite, new frontend, new radio logic 2023-09-02 21:02:09 +03:00
dsc
abb67ada08 update gitignore 2023-08-31 04:15:26 +03:00
dsc
0bf9a07cf0 fix yt-dlp 2023-08-31 04:12:06 +03:00
dsc
5d8caf4d24 support multiple streams 2023-08-31 04:11:11 +03:00
dsc
1cf5b0e79a More search results in IRC output 2023-08-31 04:06:27 +03:00
dsc
22441c932a More than 10 karma allowed ;) 2023-08-31 04:06:03 +03:00
dsc
9a23d7e71f Merge pull request 'JSON API route' (#7) from json_api into master
Reviewed-on: https://git.wownero.com/dsc/ircradio/pulls/7
2023-08-31 01:04:28 +00:00
dsc
48b1189c3e fix yt-dlp 2023-07-07 22:20:19 +03:00
dsc
077ce3f3b7 Merge pull request 'Add SHOW_PREVIOUS_TRACKS env var. Subtle style change. Launch new tabs on history/library.' (#4) from scoobybejesus/ircradio:script-or into master
Reviewed-on: https://git.wownero.com/dsc/ircradio/pulls/4
2022-06-23 19:38:42 +00:00
dsc
67ec5a5999 Merge pull request 'Modify base.html websocket script to update elements on index.html. Modify index.html to show previous two tracks.' (#3) from scoobybejesus/ircradio:prev-track into master
Reviewed-on: https://git.wownero.com/dsc/ircradio/pulls/3
2022-06-23 19:30:18 +00:00
dsc
8aac79cd5f Merge pull request 'Remove duplicate requirement.' (#2) from scoobybejesus/ircradio:master into master
Reviewed-on: https://git.wownero.com/dsc/ircradio/pulls/2
2022-06-23 15:34:43 +00:00
scoobybejesus
9a66e1b853 Subtle style change, plus launch new tabs on history/library. 2022-06-22 21:27:19 -04:00
scoobybejesus
eb74dc4ae5 Add SHOW_PREVIOUS_TRACKS env var, and use in base.html and index.html 2022-06-22 21:16:14 -04:00
scoobybejesus
25b46feac7 Modify base.html websocket script to update elements on index.html. Modify index.html to show previous two tracks. 2022-06-22 19:05:51 -04:00
scoobybejesus
781aebc6ad Remove duplicate requirement. 2022-06-21 19:33:53 -04:00
dsc
337831ea1d JSON API route 2022-03-18 10:30:08 +02:00
41 changed files with 1909 additions and 924 deletions

1
.gitignore vendored
View File

@ -2,5 +2,6 @@
data/music/*.jpg
data/music/*.webp
data/music/*.ogg*
data/music/*.json
__pycache__
settings.py

143
README.md
View File

@ -6,145 +6,6 @@ all your friends. Great fun!
![](https://i.imgur.com/MsGaSr3.png)
### Stack
# How to install
IRC!Radio aims to be minimalistic/small using:
- Python >= 3.7
- SQLite
- LiquidSoap >= 1.4.3
- Icecast2
- Quart web framework
## Command list
```text
- !np - current song
- !tune - upvote song
- !boo - downvote song
- !request - search and queue a song by title or YouTube id
- !dj+ - add a YouTube ID to the radiostream
- !dj- - remove a YouTube ID
- !ban+ - ban a YouTube ID and/or nickname
- !ban- - unban a YouTube ID and/or nickname
- !skip - skips current song
- !listeners - show current amount of listeners
- !queue - show queued up music
- !queue_user - queue a random song by user
- !search - search for a title
- !stats - stats
```
## Ubuntu installation
No docker. The following assumes you have a VPS somewhere with root access.
#### 1. Requirements
As `root`:
```
apt install -y liquidsoap icecast2 nginx python3-certbot-nginx python3-virtualenv libogg-dev ffmpeg sqlite3
ufw allow 80
ufw allow 443
```
When the installation asks for icecast2 configuration, skip it.
#### 2. Create system user
As `root`:
```text
adduser radio
```
#### 2. Clone this project
As `radio`:
```bash
su radio
cd ~/
git clone https://git.wownero.com/dsc/ircradio.git
cd ircradio/
virtualenv -p /usr/bin/python3 venv
source venv/bin/activate
pip install -r requirements.txt
```
#### 3. Generate some configs
```bash
cp settings.py_example settings.py
```
Look at `settings.py` and configure it to your liking:
- Change `icecast2_hostname` to your hostname, i.e: `radio.example.com`
- Change `irc_host`, `irc_port`, `irc_channels`, and `irc_admins_nicknames`
- Change the passwords under `icecast2_`
- Change the `liquidsoap_description` to whatever
When you are done, execute this command:
```bash
python run generate
```
This will write icecast2/liquidsoap/nginx configuration files into `data/`.
#### 4. Applying configuration
As `root`, copy the following files:
```bash
cp data/icecast.xml /etc/icecast2/
cp data/liquidsoap.service /etc/systemd/system/
cp data/radio_nginx.conf /etc/nginx/sites-enabled/
```
#### 5. Starting some stuff
As `root` 'enable' icecast2/liquidsoap/nginx, this is to
make sure these applications start when the server reboots.
```bash
sudo systemctl enable liquidsoap
sudo systemctl enable nginx
sudo systemctl enable icecast2
```
And start them:
```bash
sudo systemctl start icecast2
sudo systemctl start liquidsoap
```
Reload & start nginx:
```bash
systemctl reload nginx
sudo systemctl start nginx
```
### 6. Run the webif and IRC bot:
As `radio`, issue the following command:
```bash
python3 run webdev
```
Run it in `screen` or `tux` to keep it up, or write a systemd unit file for it.
### 7. Generate HTTPs certificate
```bash
certbot --nginx
```
Pick "Yes" for redirects.
Good luck!

202
data/soap.liq_example Normal file
View File

@ -0,0 +1,202 @@
#!/usr/bin/liquidsoap
set("log.stdout", true)
set("log.file",false)
# Allow requests from Telnet (Liquidsoap Requester)
set("server.telnet", true)
set("server.telnet.bind_addr", "127.0.0.1")
set("server.telnet.port", 7555)
set("server.telnet.reverse_dns", false)
pmain = playlist(
id="playlist",
timeout=90.0,
mode="random",
reload=300,
reload_mode="seconds",
mime_type="audio/ogg",
"/home/radio/ircradio/data/music"
)
# ==== ANJUNADEEP
panjunadeep = playlist(
id="panjunadeep",
timeout=90.0,
mode="random",
reload=300,
reload_mode="seconds",
mime_type="audio/ogg",
"/home/radio/mixes/anjunadeep/"
)
# ==== BERLIN
pberlin = playlist(
id="berlin",
timeout=90.0,
mode="random",
reload=300,
reload_mode="seconds",
mime_type="audio/ogg",
"/home/radio/mixes/berlin/"
)
# ==== BREAKBEAT
pbreaks = playlist(
id="breaks",
mode="random",
reload=300,
reload_mode="seconds",
mime_type="audio/ogg",
"/home/radio/mixes/breakbeat/"
)
# ==== DNB
pdnb = playlist(
id="dnb",
timeout=90.0,
mode="random",
reload=300,
reload_mode="seconds",
mime_type="audio/ogg",
"/home/radio/mixes/dnb/"
)
# ==== RAVES
praves = playlist(
id="raves",
timeout=90.0,
mode="random",
reload=300,
reload_mode="seconds",
mime_type="audio/ogg",
"/home/radio/mixes/raves/"
)
# ==== TRANCE
ptrance = playlist(
id="trance",
timeout=90.0,
mode="random",
reload=300,
reload_mode="seconds",
mime_type="audio/ogg",
"/home/radio/mixes/trance/"
)
# ==== WEED
pweed = playlist(
id="weed",
timeout=90.0,
mode="random",
reload=300,
reload_mode="seconds",
mime_type="audio/ogg",
"/home/radio/mixes/weed/"
)
req_pmain = request.queue(id="pmain")
req_panjunadeep = request.queue(id="panjunadeep")
req_pberlin = request.queue(id="pberlin")
req_pbreaks = request.queue(id="pbreaks")
req_pdnb = request.queue(id="pdnb")
req_praves = request.queue(id="praves")
req_ptrance = request.queue(id="ptrance")
req_pweed = request.queue(id="pweed")
pmain = fallback(id="switcher",track_sensitive = true, [req_pmain, pmain, blank(duration=5.)])
panjunadeep = fallback(id="switcher",track_sensitive = true, [req_panjunadeep, panjunadeep, blank(duration=5.)])
pberlin = fallback(id="switcher",track_sensitive = true, [req_pberlin, pberlin, blank(duration=5.)])
pbreaks = fallback(id="switcher",track_sensitive = true, [req_pbreaks, pbreaks, blank(duration=5.)])
pdnb = fallback(id="switcher",track_sensitive = true, [req_pdnb, pdnb, blank(duration=5.)])
praves = fallback(id="switcher",track_sensitive = true, [req_praves, praves, blank(duration=5.)])
ptrance = fallback(id="switcher",track_sensitive = true, [req_ptrance, ptrance, blank(duration=5.)])
pweed = fallback(id="switcher",track_sensitive = true, [req_pweed, pweed, blank(duration=5.)])
# iTunes-style (so-called "dumb" - but good enough) crossfading
pmain_crossed = crossfade(pmain)
pmain_crossed = mksafe(pmain_crossed)
output.icecast(%vorbis.cbr(samplerate=48000, channels=2, bitrate=164),
host = "10.7.0.3", port = 24100,
send_icy_metadata=true, description="WOW!Radio",
password = "lel", mount = "wow.ogg",
pmain_crossed)
output.icecast(%vorbis.cbr(samplerate=48000, channels=2, bitrate=164),
host = "10.7.0.3", port = 24100,
send_icy_metadata=true, description="WOW!Radio | Anjunadeep",
password = "lel", mount = "anjunadeep.ogg",
panjunadeep)
output.icecast(%vorbis.cbr(samplerate=48000, channels=2, bitrate=164),
host = "10.7.0.3", port = 24100,
send_icy_metadata=true, description="WOW!Radio | Berlin",
password = "lel", mount = "berlin.ogg",
pberlin)
output.icecast(%vorbis.cbr(samplerate=48000, channels=2, bitrate=164),
host = "10.7.0.3", port = 24100,
send_icy_metadata=true, description="WOW!Radio | Breakbeat",
password = "lel", mount = "breaks.ogg",
pbreaks)
output.icecast(%vorbis.cbr(samplerate=48000, channels=2, bitrate=164),
host = "10.7.0.3", port = 24100,
send_icy_metadata=true, description="WOW!Radio | Dnb",
password = "lel", mount = "dnb.ogg",
pdnb)
output.icecast(%vorbis.cbr(samplerate=48000, channels=2, bitrate=164),
host = "10.7.0.3", port = 24100,
send_icy_metadata=true, description="WOW!Radio | Trance",
password = "lel", mount = "trance.ogg",
ptrance)
output.icecast(%vorbis.cbr(samplerate=48000, channels=2, bitrate=164),
host = "10.7.0.3", port = 24100,
send_icy_metadata=true, description="WOW!Radio | Weed",
password = "lel", mount = "weed.ogg",
pweed)
output.icecast(%vorbis.cbr(samplerate=48000, channels=2, bitrate=128),
host = "10.7.0.3", port = 24100,
send_icy_metadata=true, description="WOW!Radio | Rave",
password = "lel", mount = "rave.ogg",
praves)
def get_now_playing_filepaths(_)
def null_list(key, _list)
list.assoc.mem(key, _list) ? list.assoc(key, _list) : null()
end
pmain_meta = pmain.last_metadata() ?? []
panjunadeep_meta = panjunadeep.last_metadata() ?? []
pberlin_meta = pberlin.last_metadata() ?? []
pbreaks_meta = pbreaks.last_metadata() ?? []
pdnb_meta = pdnb.last_metadata() ?? []
praves_meta = praves.last_metadata() ?? []
ptrance_meta = ptrance.last_metadata() ?? []
pweed_meta = pweed.last_metadata() ?? []
pmain_filename = null_list("filename", pmain_meta)
panjunadeep_filename = null_list("filename", panjunadeep_meta)
pberlin_filename = null_list("filename", pberlin_meta)
pbreaks_filename = null_list("filename", pbreaks_meta)
pdnb_filename = null_list("filename", pdnb_meta)
praves_filename = null_list("filename", praves_meta)
ptrance_filename = null_list("filename", ptrance_meta)
pweed_filename = null_list("filename", pweed_meta)
"pmain_filename=#{pmain_filename}\npanjunadeep_filename=#{panjunadeep_filename}\npberlin_filename=#{pberlin_filename}\npbreaks_filename=#{pbreaks_filename}\npdnb_filename=#{pdnb_filename}\npraves_filename=#{praves_filename}\nptrance_filename=#{ptrance_filename}\npweed_filename=#{pweed_filename}\n"
end
server.register("now_playing", get_now_playing_filepaths)

View File

@ -1,4 +1,8 @@
from ircradio.utils import liquidsoap_check_symlink
import os
from typing import List, Optional
import settings
liquidsoap_check_symlink()
with open(os.path.join(settings.cwd, 'data', 'agents.txt'), 'r') as f:
user_agents: Optional[List[str]] = [
l.strip() for l in f.readlines() if l.strip()]

View File

@ -1,35 +1,37 @@
# SPDX-License-Identifier: BSD-3-Clause
# Copyright (c) 2021, dsc@xmr.pm
import re
import sys
import collections
from typing import List, Optional
import os
import logging
import asyncio
from asyncio import Queue
import bottom
from quart import Quart
from quart import Quart, session, redirect, url_for
from quart_keycloak import Keycloak, KeycloakAuthToken
from quart_session import Session
from asyncio_multisubscriber_queue import MultisubscriberQueue
import settings
from ircradio.radio import Radio
from ircradio.utils import Price, print_banner
from ircradio.station import Station
from ircradio.utils import print_banner
from ircradio.youtube import YouTube
import ircradio.models
app = None
user_agents: List[str] = None
websocket_sessions = set()
download_queue = asyncio.Queue()
user_agents: Optional[List[str]] = None
websocket_status_bus = MultisubscriberQueue()
irc_message_announce_bus = MultisubscriberQueue()
websocket_status_bus_last_item: Optional[dict[str, Station]] = None
irc_bot = None
price = Price()
keycloak = None
NP_MAP = {} # station, filepath
soap = Radio()
# icecast2 = IceCast2()
async def download_thing():
global download_queue
a = await download_queue.get()
e = 1
async def _setup_icecast2(app: Quart):
@ -44,45 +46,114 @@ async def _setup_database(app: Quart):
m.create_table()
async def _setup_tasks(app: Quart):
from ircradio.utils import radio_update_task_run_forever
asyncio.create_task(radio_update_task_run_forever())
async def last_websocket_item_updater():
global websocket_status_bus_last_item
async for data in websocket_status_bus.subscribe():
websocket_status_bus_last_item = data
async def irc_announce_task():
from ircradio.irc import send_message
async for data in irc_message_announce_bus.subscribe():
await send_message(settings.irc_channels[0], data)
asyncio.create_task(last_websocket_item_updater())
asyncio.create_task(irc_announce_task())
asyncio.create_task(_now_playing_watch())
async def _setup_irc(app: Quart):
global irc_bot
loop = asyncio.get_event_loop()
irc_bot = bottom.Client(host=settings.irc_host, port=settings.irc_port, ssl=settings.irc_ssl, loop=loop)
bottom_client = bottom.Client
if sys.version_info.major == 3 and sys.version_info.minor >= 10:
class Python310Client(bottom.Client):
def __init__(self, host: str, port: int, *, encoding: str = "utf-8", ssl: bool = True,
loop: Optional[asyncio.AbstractEventLoop] = None) -> None:
"""Fix 3.10 error: https://github.com/numberoverzero/bottom/issues/60"""
super().__init__(host, port, encoding=encoding, ssl=ssl, loop=loop)
self._events = collections.defaultdict(lambda: asyncio.Event())
bottom_client = Python310Client
irc_bot = bottom_client(host=settings.irc_host, port=settings.irc_port, ssl=settings.irc_ssl, loop=loop)
from ircradio.irc import start, message_worker
start()
asyncio.create_task(message_worker())
async def _setup_user_agents(app: Quart):
global user_agents
with open(os.path.join(settings.cwd, 'data', 'agents.txt'), 'r') as f:
user_agents = [l.strip() for l in f.readlines() if l.strip()]
async def _setup_requirements(app: Quart):
ls_reachable = soap.liquidsoap_reachable()
if not ls_reachable:
raise Exception("liquidsoap is not running, please start it first")
async def _setup_cache(app: Quart):
app.config['SESSION_TYPE'] = 'redis'
app.config['SESSION_URI'] = settings.redis_uri
Session(app)
async def _now_playing_watch():
global NP_MAP
proc = await asyncio.create_subprocess_exec(
"journalctl", "-n15000", "-xefu", "liquidsoap",
stdout=asyncio.subprocess.PIPE,
)
line = await proc.stdout.readline()
while line:
line = line.decode().strip()
if '] Prepared "/' in line and ".ogg" in line:
try:
filename = re.findall(r"\"(.*\.ogg)\"", line)[0]
radio = re.findall(r"\[(\w+)\:\d\]", line)[0]
if radio == "playlist":
radio = "pmain"
NP_MAP[radio] = filename
except Exception as ex:
print(f"_now_playing_watch: {ex}")
line = await proc.stdout.readline()
def create_app():
global app, soap, icecast2
app = Quart(__name__)
app.logger.setLevel(logging.INFO)
app.config['TEMPLATES_AUTO_RELOAD'] = True
app.logger.setLevel(logging.DEBUG if settings.debug else logging.INFO)
if settings.redis_uri:
pass
@app.before_serving
async def startup():
await _setup_requirements(app)
global keycloak
await _setup_database(app)
await _setup_user_agents(app)
await _setup_cache(app)
await _setup_irc(app)
await _setup_tasks(app)
import ircradio.routes
keycloak = Keycloak(app, **settings.openid_keycloak_config)
@app.context_processor
def inject_all_templates():
return dict(settings=settings, logged_in='auth_token' in session)
@keycloak.after_login()
async def handle_user_login(auth_token: KeycloakAuthToken):
user = await keycloak.user_info(auth_token.access_token)
session['auth_token'] = user
return redirect(url_for('root'))
from ircradio.youtube import YouTube
asyncio.create_task(YouTube.update_loop())
#asyncio.create_task(price.wownero_usd_price_loop())
print_banner()
return app

View File

@ -1,6 +1,6 @@
# SPDX-License-Identifier: BSD-3-Clause
# Copyright (c) 2021, dsc@xmr.pm
import sys
from typing import List, Optional
import os
import time
@ -8,12 +8,15 @@ import asyncio
import random
from ircradio.factory import irc_bot as bot
from ircradio.radio import Radio
from ircradio.station import Station
from ircradio.youtube import YouTube
import settings
from settings import radio_stations
radio_default = radio_stations["wow"]
msg_queue = asyncio.Queue()
YT_DLP_LOCK = asyncio.Lock()
DU_LOCK = asyncio.Lock()
async def message_worker():
@ -35,11 +38,14 @@ async def connect(**kwargs):
bot.send('NICK', nick=settings.irc_nick)
bot.send('USER', user=settings.irc_nick, realname=settings.irc_realname)
# Don't try to join channels until server sent MOTD
# @TODO: get rid of this nonsense after a while
args = {"return_when": asyncio.FIRST_COMPLETED}
if sys.version_info.major == 3 and sys.version_info.minor < 10:
args["loop"] = bot.loop
done, pending = await asyncio.wait(
[bot.wait("RPL_ENDOFMOTD"), bot.wait("ERR_NOMOTD")],
loop=bot.loop,
return_when=asyncio.FIRST_COMPLETED
**args
)
# Cancel whichever waiter's event didn't come in.
@ -66,31 +72,43 @@ def reconnect(**kwargs):
class Commands:
LOOKUP = ['np', 'tune', 'boo', 'request', 'dj',
LOOKUP = ['np', 'tune', 'boo', 'request', 'dj', 'url', 'urls',
'skip', 'listeners', 'queue',
'queue_user', 'pop', 'search', 'stats',
'queue_user', 'pop', 'search', 'searchq', 'stats',
'rename', 'ban', 'whoami']
@staticmethod
async def np(*args, target=None, nick=None, **kwargs):
"""current song"""
history = Radio.history()
if not history:
radio_station = await Commands._parse_radio_station(args, target)
if not radio_station:
return
song = await radio_station.np()
if not song:
return await send_message(target, f"Nothing is playing?!")
song = history[0]
np = f"Now playing: {song.title} (rating: {song.karma}/10; submitter: {song.added_by}; id: {song.utube_id})"
await send_message(target=target, message=np)
np = "Now playing"
if radio_station.id != "wow":
np += f" @{radio_station.id}"
message = f"{np}: {song.title_cleaned}"
if song.id:
message += f" (rating: {song.karma}/10; by: {song.added_by}; id: {song.utube_id})"
time_status_str = song.time_status_str()
if time_status_str:
message += f" {time_status_str}"
await send_message(target=target, message=message)
@staticmethod
async def tune(*args, target=None, nick=None, **kwargs):
"""upvote song"""
history = Radio.history()
if not history:
"""upvote song, only wow supported, not mixes"""
song = await radio_default.np()
if not song:
return await send_message(target, f"Nothing is playing?!")
song = history[0]
if song.karma <= 9:
song.karma += 1
song.save()
@ -100,10 +118,9 @@ class Commands:
@staticmethod
async def boo(*args, target=None, nick=None, **kwargs):
"""downvote song"""
history = Radio.history()
if not history:
song = await radio_default.np()
if not song:
return await send_message(target, f"Nothing is playing?!")
song = history[0]
if song.karma >= 1:
song.karma -= 1
@ -116,55 +133,110 @@ class Commands:
async def request(*args, target=None, nick=None, **kwargs):
"""request a song by title or YouTube id"""
from ircradio.models import Song
if not args:
send_message(target=target, message="usage: !request <id>")
await send_message(target=target, message="usage: !request <id>")
songs = await Commands._return_song_results(*args, target=target, nick=nick, **kwargs)
if songs and len(songs) == 1:
song = songs[0]
await radio_default.queue_push(song.filepath)
msg = f"Added {song.title} to the queue"
return await send_message(target, msg)
if songs:
return await Commands._print_song_results(*args, target=target, nick=nick, songs=songs, **kwargs)
@staticmethod
async def search(*args, target=None, nick=None, **kwargs):
from ircradio.models import Song
if not args:
return await send_message(target=target, message="usage: !search <id>")
return await Commands._search(*args, target=target, nick=nick, **kwargs)
@staticmethod
async def searchq(*args, target=None, nick=None, **kwargs):
from ircradio.models import Song
if not args:
return await send_message(target=target, message="usage: !searchq <id>")
return await Commands._search(*args, target=target, nick=nick, report_quality=True, **kwargs)
@staticmethod
async def _search(*args, target=None, nick=None, **kwargs) -> Optional[List['Song']]:
"""search for a title"""
from ircradio.models import Song
report_quality = kwargs.get('report_quality')
songs = await Commands._return_song_results(*args, target=target, nick=nick, **kwargs)
if songs:
return await Commands._print_song_results(*args, target=target, nick=nick, report_quality=report_quality, songs=songs, **kwargs)
@staticmethod
async def _return_song_results(*args, target=None, nick=None, **kwargs) -> Optional[List['Song']]:
from ircradio.models import Song
needle = " ".join(args)
# https://git.wownero.com/dsc/ircradio/issues/1
needle_2nd = None
if "|" in needle:
spl = needle.split('|', 1)
a = spl[0].strip()
b = spl[1].strip()
needle = a
needle_2nd = b
try:
songs = Song.search(needle)
except Exception as ex:
return await send_message(target, f"{ex}")
if not songs:
return await send_message(target, "Not found!")
if len(songs) >= 2:
random.shuffle(songs)
await send_message(target, "Multiple found:")
for s in songs[:4]:
await send_message(target, f"{s.utube_id} | {s.title}")
return
song = songs[0]
msg = f"Added {song.title} to the queue"
Radio.queue(song)
return await send_message(target, msg)
@staticmethod
async def search(*args, target=None, nick=None, **kwargs):
"""search for a title"""
from ircradio.models import Song
if not args:
return await send_message(target=target, message="usage: !search <id>")
needle = " ".join(args)
songs = Song.search(needle)
if not songs:
return await send_message(target, "No song(s) found!")
if len(songs) == 1:
song = songs[0]
await send_message(target, f"{song.utube_id} | {song.title}")
else:
random.shuffle(songs)
if songs and needle_2nd:
songs = [s for s in songs if s.title and needle_2nd in s.title.lower()]
if not songs:
return await send_message(target, "No song(s) found after '|'!")
return songs
@staticmethod
async def _print_song_results(*args, target=None, nick=None, report_quality=None, songs=None, **kwargs):
from ircradio.models import Song
len_songs = len(songs)
max_songs = 6
moar = len_songs > max_songs
if len_songs > 1:
await send_message(target, "Multiple found:")
for s in songs[:4]:
await send_message(target, f"{s.utube_id} | {s.title}")
random.shuffle(songs)
for s in songs[:max_songs]:
msg = f"{s.utube_id} | {s.title}"
await s.scan(s.path or s.filepath)
if report_quality and s.meta:
if s.meta.bitrate:
msg += f" ({s.meta.bitrate / 1000}kbps)"
if s.meta.channels:
msg += f" (channels: {s.meta.channels}) "
if s.meta.sample_rate:
msg += f" (sample_rate: {s.meta.sample_rate}) "
await send_message(target, msg)
if moar:
await send_message(target, "[...]")
@staticmethod
async def dj(*args, target=None, nick=None, **kwargs):
"""add (or remove) a YouTube ID to the radiostream"""
"""add (or remove) a YouTube ID to the default radio"""
from ircradio.models import Song
if not args or args[0] not in ["-", "+"]:
return await send_message(target, "usage: dj+ <youtube_id>")
@ -175,6 +247,7 @@ class Commands:
return await send_message(target, "YouTube ID not valid.")
if add:
async with YT_DLP_LOCK:
try:
await send_message(target, f"Scheduled download for '{utube_id}'")
song = await YouTube.download(utube_id, added_by=nick)
@ -192,43 +265,64 @@ class Commands:
async def skip(*args, target=None, nick=None, **kwargs):
"""skips current song"""
from ircradio.factory import app
radio_station = await Commands._parse_radio_station(args, target)
if not radio_station:
return
# song = radio_station.np()
# if not song:
# app.logger.error(f"nothing is playing?")
# return await send_message(target=target, message="Nothing is playing ?!")
try:
Radio.skip()
await radio_station.skip()
except Exception as ex:
app.logger.error(f"{ex}")
return await send_message(target=target, message="Error")
return await send_message(target=target, message="Nothing is playing ?!")
await send_message(target, message="Song skipped. Booo! >:|")
if radio_station.id == "wow":
_type = "Song"
else:
_type = "Mix"
await send_message(target, message=f"{_type} skipped. Booo! >:|")
@staticmethod
async def listeners(*args, target=None, nick=None, **kwargs):
"""current amount of listeners"""
from ircradio.factory import app
try:
listeners = await Radio.listeners()
if listeners:
radio_station = await Commands._parse_radio_station(args, target)
if not radio_station:
return
listeners = await radio_station.get_listeners()
if listeners is None:
return await send_message(target, f"something went wrong")
if listeners == 0:
await send_message(target, f"no listeners, much sad :((")
msg = f"{listeners} client"
if listeners >= 2:
msg += "s"
msg += " connected"
return await send_message(target, msg)
return await send_message(target, f"no listeners, much sad :((")
except Exception as ex:
app.logger.error(f"{ex}")
await send_message(target=target, message="Error")
@staticmethod
async def queue(*args, target=None, nick=None, **kwargs):
"""show currently queued tracks"""
from ircradio.models import Song
q: List[Song] = Radio.queues()
from ircradio.factory import app
radio_station = await Commands._parse_radio_station(args, target)
if not radio_station:
return
q: List[Song] = await radio_station.queue_get()
if not q:
return await send_message(target, "queue empty")
for i, s in enumerate(q):
await send_message(target, f"{s.utube_id} | {s.title}")
if i >= 12:
if i >= 8:
await send_message(target, "And some more...")
@staticmethod
@ -274,8 +368,8 @@ class Commands:
for i in range(0, 5):
song = random.choice(songs)
if Radio.queue(song):
res = await radio_default.queue(song.filepath)
if res:
return await send_message(target, f"A random {added_by} has appeared in the queue: {song.title}")
await send_message(target, "queue_user exhausted!")
@ -292,8 +386,11 @@ class Commands:
except:
pass
async with DU_LOCK:
disk = os.popen(f"du -h {settings.dir_music}").read().split("\t")[0]
await send_message(target, f"Songs: {songs} | Disk: {disk}")
mixes = os.popen(f"du -h /home/radio/mixes/").read().split("\n")[-2].split("\t")[0]
await send_message(target, f"Songs: {songs} | Mixes: {mixes} | Songs: {disk}")
@staticmethod
async def ban(*args, target=None, nick=None, **kwargs):
@ -325,6 +422,45 @@ class Commands:
else:
await send_message(target, "user")
@staticmethod
async def url(*args, target=None, nick=None, **kwargs):
radio_station = await Commands._parse_radio_station(args, target)
if not radio_station:
return
msg = f"https://{settings.icecast2_hostname}/{radio_station.mount_point}"
await send_message(target, msg)
@staticmethod
async def urls(*args, target=None, nick=None, **kwargs):
url = f"https://{settings.icecast2_hostname}/{radio_stations['wow'].mount_point}"
msg = f"main programming: {url}"
await send_message(target, msg)
msg = "mixes: "
for _, radio_station in radio_stations.items():
if _ == "wow":
continue
url = f"https://{settings.icecast2_hostname}/{radio_station.mount_point}"
msg += f"{url} "
await send_message(target, msg)
@staticmethod
async def _parse_radio_station(args, target) -> Optional[Station]:
extras = " ".join(args).strip()
if not extras or len(args) >= 2:
return radio_default
err = f", available streams: " + " ".join(radio_stations.keys())
if not extras:
msg = "nothing not found (?) :-P" + err
return await send_message(target, msg)
extra = extras.strip()
if extra not in radio_stations:
msg = f"station \"{extra}\" not found" + err
return await send_message(target, msg)
return radio_stations[extra]
@bot.on('PRIVMSG')
async def message(nick, target, message, **kwargs):
@ -371,7 +507,6 @@ async def message(nick, target, message, **kwargs):
await attr(*spl, **data)
except Exception as ex:
app.logger.error(f"message_worker(): {ex}")
pass
def start():

View File

@ -1,14 +1,21 @@
# SPDX-License-Identifier: BSD-3-Clause
# Copyright (c) 2021, dsc@xmr.pm
import functools
import os
import logging
import json
import re
from typing import Optional, List
from datetime import datetime
from datetime import datetime, timedelta
from dataclasses import dataclass
import mutagen
from mutagen.oggvorbis import OggVorbisInfo, OggVCommentDict
import aiofiles
from peewee import SqliteDatabase, SQL
import peewee as pw
from quart import current_app
from ircradio.youtube import YouTube
import settings
@ -23,16 +30,46 @@ class Ban(pw.Model):
class Meta:
database = db
@dataclass
class SongMeta:
title: Optional[str] = None
description: Optional[str] = None
artist: Optional[str] = None
album: Optional[str] = None
album_cover: Optional[str] = None # path to image
bitrate: Optional[int] = None
length: Optional[float] = None
sample_rate: Optional[int] = None
channels: Optional[int] = None
mime: Optional[str] = None
class Song(pw.Model):
id = pw.AutoField()
date_added = pw.DateTimeField(default=datetime.now)
date_added: datetime = pw.DateTimeField(default=datetime.now)
title = pw.CharField(index=True)
utube_id = pw.CharField(index=True, unique=True)
added_by = pw.CharField(index=True, constraints=[SQL('COLLATE NOCASE')]) # ILIKE index
duration = pw.IntegerField()
karma = pw.IntegerField(default=5, index=True)
banned = pw.BooleanField(default=False)
title: str = pw.CharField(index=True)
utube_id: str = pw.CharField(index=True, unique=True)
added_by: str = pw.CharField(index=True, constraints=[SQL('COLLATE NOCASE')]) # ILIKE index
duration: int = pw.IntegerField() # seconds
karma: int = pw.IntegerField(default=5, index=True)
banned: bool = pw.BooleanField(default=False)
meta: SongMeta = None # directly from file (exif) or metadata json
path: Optional[str] = None
remaining: int = None # liquidsoap playing status in seconds
@property
def to_json(self):
return {
"title": self.title,
"utube_id": self.utube_id,
"added_by": self.added_by,
"duration": self.duration,
"karma": self.karma,
"banned": self.banned
}
@staticmethod
def delete_song(utube_id: str) -> bool:
@ -73,42 +110,106 @@ class Song(pw.Model):
except:
pass
@staticmethod
def from_filepath(filepath: str) -> Optional['Song']:
fn = os.path.basename(filepath)
name, ext = fn.split(".", 1)
if not YouTube.is_valid_uid(name):
raise Exception("invalid youtube id")
@classmethod
async def from_filepath(cls, path: str) -> 'Song':
if not os.path.exists(path):
raise Exception("filepath does not exist")
# try to detect youtube id in filename
basename = os.path.splitext(os.path.basename(path))[0]
if YouTube.is_valid_uid(basename):
try:
return Song.select().filter(utube_id=name).get()
except:
return Song.auto_create_from_filepath(filepath)
@staticmethod
def auto_create_from_filepath(filepath: str) -> Optional['Song']:
from ircradio.factory import app
fn = os.path.basename(filepath)
uid, ext = fn.split(".", 1)
if not YouTube.is_valid_uid(uid):
raise Exception("invalid youtube id")
metadata = YouTube.metadata_from_filepath(filepath)
if not metadata:
return
app.logger.info(f"auto-creating for {fn}")
try:
song = Song.create(
duration=metadata['duration'],
title=metadata['name'],
added_by='radio',
karma=5,
utube_id=uid)
return song
song = cls.select().where(Song.utube_id == basename).get()
except Exception as ex:
app.logger.error(f"{ex}")
song = Song()
else:
song = Song()
# scan for metadata
await song.scan(path)
return song
async def scan(self, path: str = None):
# update with metadata, etc.
if path is None:
path = self.filepath
if not os.path.exists(path):
raise Exception(f"filepath {path} does not exist")
basename = os.path.splitext(os.path.basename(path))[0]
self.meta = SongMeta()
self.path = path
# EXIF direct
from ircradio.utils import mutagen_file
_m = await mutagen_file(path)
if _m:
if _m.info:
self.meta.channels = _m.info.channels
self.meta.length = int(_m.info.length)
if hasattr(_m.info, 'bitrate'):
self.meta.bitrate = _m.info.bitrate
if hasattr(_m.info, 'sample_rate'):
self.meta.sample_rate = _m.info.sample_rate
if _m.tags:
if hasattr(_m.tags, 'as_dict'):
_tags = _m.tags.as_dict()
else:
try:
_tags = {k: v for k, v in _m.tags.items()}
except:
_tags = {}
for k, v in _tags.items():
if isinstance(v, list):
v = v[0]
elif isinstance(v, (str, int, float)):
pass
else:
continue
if k in ["title", "description", "language", "date", "purl", "artist"]:
if hasattr(self.meta, k):
setattr(self.meta, k, v)
# yt-dlp metadata json file
fn_utube_meta = os.path.join(settings.dir_meta, f"{basename}.info.json")
utube_meta = {}
if os.path.exists(fn_utube_meta):
async with aiofiles.open(fn_utube_meta, mode="r") as f:
try:
utube_meta = json.loads(await f.read())
except Exception as ex:
logging.error(f"could not parse {fn_utube_meta}, {ex}")
if utube_meta:
# utube_meta file does not have anything we care about
pass
if not self.title and not self.meta.title:
# just adopt filename
self.title = os.path.basename(self.path)
return self
@property
def title_for_real_nocap(self):
if self.title:
return self.title
if self.meta.title:
return self.meta.title
return "unknown!"
@property
def title_cleaned(self):
_title = self.title_for_real_nocap
_title = re.sub(r"\(official\)", "", _title, flags=re.IGNORECASE)
_title = re.sub(r"\(official \w+\)", "", _title, flags=re.IGNORECASE)
_title = _title.replace(" - Topic - ", "")
return _title
@property
def filepath(self):
@ -123,5 +224,61 @@ class Song(pw.Model):
except:
return self.filepath
def image(self) -> Optional[str]:
dirname = os.path.dirname(self.path)
basename = os.path.basename(self.path)
name, ext = os.path.splitext(basename)
for _dirname in [dirname, settings.dir_meta]:
for _ext in ["", ext]:
meta_json = os.path.join(_dirname, name + _ext + ".info.json")
if os.path.exists(meta_json):
try:
f = open(meta_json, "r")
data = f.read()
f.close()
blob = json.loads(data)
if "thumbnails" in blob and isinstance(blob['thumbnails'], list):
thumbs = list(sorted(blob['thumbnails'], key=lambda k: int(k['id']), reverse=True))
image_id = thumbs[0]['id']
for sep in ['.', "_"]:
_fn = os.path.join(_dirname, name + _ext + f"{sep}{image_id}")
for img_ext in ['webp', 'jpg', 'jpeg', 'png']:
_fn_full = f"{_fn}.{img_ext}"
if os.path.exists(_fn_full):
return os.path.basename(_fn_full)
except Exception as ex:
logging.error(f"could not parse {meta_json}, {ex}")
def time_status(self) -> Optional[tuple]:
if not self.remaining:
return
duration = self.duration
if not duration:
if self.meta.length:
duration = int(self.meta.length)
if not duration:
return
_ = lambda k: timedelta(seconds=k)
a = _(duration - self.remaining)
b = _(duration)
return a, b
def time_status_str(self) -> Optional[str]:
_status = self.time_status()
if not _status:
return
a, b = map(str, _status)
if a.startswith("0:") and \
b.startswith("0:"):
a = a[2:]
b = b[2:]
return f"({a}/{b})"
class Meta:
database = db

View File

@ -1,7 +1,8 @@
# SPDX-License-Identifier: BSD-3-Clause
# Copyright (c) 2021, dsc@xmr.pm
import logging
import re
import json
import os
import socket
from typing import List, Optional, Dict
@ -10,159 +11,70 @@ import sys
import settings
from ircradio.models import Song
from ircradio.station import Station
from ircradio.utils import httpget
from ircradio.youtube import YouTube
class Radio:
@staticmethod
def queue(song: Song) -> bool:
from ircradio.factory import app
queues = Radio.queues()
queues_filepaths = [s.filepath for s in queues]
async def icecast_metadata(radio: Station) -> dict:
cache_key = f"icecast_meta_{radio.id}"
if song.filepath in queues_filepaths:
app.logger.info(f"already added to queue: {song.filepath}")
return False
from quart import current_app
if current_app: # caching only when Quart is active
cache: SessionInterface = current_app.session_interface
res = await cache.get(cache_key)
if res:
return json.loads(res)
Radio.command(f"requests.push {song.filepath}")
return True
@staticmethod
def skip() -> None:
Radio.command(f"{settings.liquidsoap_iface}.skip")
@staticmethod
def queues() -> Optional[List[Song]]:
"""get queued songs"""
from ircradio.factory import app
queues = Radio.command(f"requests.queue")
try:
queues = [q for q in queues.split(b"\r\n") if q != b"END" and q]
if not queues:
return []
queues = [q.decode() for q in queues[0].split(b" ")]
except Exception as ex:
app.logger.error(str(ex))
raise Exception("Error")
paths = []
for request_id in queues:
meta = Radio.command(f"request.metadata {request_id}")
path = Radio.filenames_from_strlist(meta.decode(errors="ignore").split("\n"))
if path:
paths.append(path[0])
songs = []
for fn in list(dict.fromkeys(paths)):
try:
song = Song.from_filepath(fn)
if not song:
continue
songs.append(song)
except Exception as ex:
app.logger.warning(f"skipping {fn}; file not found or something: {ex}")
# remove the now playing song from the queue
now_playing = Radio.now_playing()
if songs and now_playing:
if songs[0].filepath == now_playing.filepath:
songs = songs[1:]
return songs
@staticmethod
async def get_icecast_metadata() -> Optional[Dict]:
from ircradio.factory import app
# http://127.0.0.1:24100/status-json.xsl
url = f"http://{settings.icecast2_bind_host}:{settings.icecast2_bind_port}"
url = f"{url}/status-json.xsl"
try:
blob = await httpget(url, json=True)
if not isinstance(blob, dict) or "icestats" not in blob:
raise Exception("icecast2 metadata not dict")
return blob["icestats"].get('source')
arr = blob["icestats"].get('source')
if not isinstance(arr, list) or not arr:
raise Exception("no metadata results #1")
try:
res = next(r for r in arr if radio.mount_point == r['server_name'])
from quart import current_app
if current_app: # caching only when Quart is active
cache: SessionInterface = current_app.session_interface
await cache.set(cache_key, json.dumps(res), expiry=4)
return res
except Exception as ex:
app.logger.error(f"{ex}")
raise Exception("no metadata results #2")
@staticmethod
def history() -> Optional[List[Song]]:
# 0 = currently playing
from ircradio.factory import app
try:
status = Radio.command(f"{settings.liquidsoap_iface}.metadata")
status = status.decode(errors="ignore")
except Exception as ex:
app.logger.error(f"{ex}")
raise Exception("failed to contact liquidsoap")
try:
# paths = re.findall(r"filename=\"(.*)\"", status)
paths = Radio.filenames_from_strlist(status.split("\n"))
# reverse, limit
paths = paths[::-1][:5]
songs = []
for fn in list(dict.fromkeys(paths)):
try:
song = Song.from_filepath(fn)
if not song:
continue
songs.append(song)
except Exception as ex:
app.logger.warning(f"skipping {fn}; file not found or something: {ex}")
except Exception as ex:
app.logger.error(f"{ex}")
app.logger.error(f"liquidsoap status:\n{status}")
raise Exception("error parsing liquidsoap status")
return songs
@staticmethod
def command(cmd: str) -> bytes:
async def command(cmd: str) -> bytes:
"""via LiquidSoap control port"""
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect((settings.liquidsoap_host, settings.liquidsoap_port))
sock.sendall(cmd.encode() + b"\n")
data = sock.recv(4096*1000)
sock.close()
return data
from datetime import datetime
if settings.debug:
print(f"cmd: {cmd}")
@staticmethod
def liquidsoap_reachable():
from ircradio.factory import app
try:
Radio.command("help")
reader, writer = await asyncio.open_connection(
settings.liquidsoap_host, settings.liquidsoap_port)
except Exception as ex:
app.logger.error("liquidsoap not reachable")
return False
return True
raise Exception(f"error connecting to {settings.liquidsoap_host}:{settings.liquidsoap_port}: {ex}")
writer.write(cmd.encode() + b"\n")
await writer.drain()
@staticmethod
def now_playing():
try:
now_playing = Radio.history()
if now_playing:
return now_playing[0]
except:
pass
task = reader.readuntil(b"\x0d\x0aEND\x0d\x0a")
data = await asyncio.wait_for(task, 1)
except Exception as ex:
logging.error(ex)
return b""
@staticmethod
async def listeners():
data: dict = await Radio.get_icecast_metadata()
if not data:
return 0
return data.get('listeners', 0)
@staticmethod
def filenames_from_strlist(strlist: List[str]) -> List[str]:
paths = []
for line in strlist:
if not line.startswith("filename"):
continue
line = line[10:]
fn = line[:-1]
if not os.path.exists(fn):
continue
paths.append(fn)
return paths
writer.close()
return data

View File

@ -1,9 +1,11 @@
# SPDX-License-Identifier: BSD-3-Clause
# Copyright (c) 2021, dsc@xmr.pm
import os, re, dataclasses, random
from glob import glob
from datetime import datetime
from typing import Tuple, Optional
from quart import request, render_template, abort, jsonify
from quart import request, render_template, abort, jsonify, send_from_directory, current_app, websocket, redirect, session, url_for
import asyncio
import json
@ -14,31 +16,15 @@ from ircradio.radio import Radio
@app.route("/")
async def root():
return await render_template("index.html", settings=settings)
return await render_template("index.html", settings=settings, radio_stations=settings.radio_stations.values())
history_cache: Optional[Tuple] = None
@app.route("/history.txt")
async def history():
global history_cache
now = datetime.now()
if history_cache:
if (now - history_cache[0]).total_seconds() <= 5:
print("from cache")
return history_cache[1]
history = Radio.history()
if not history:
return "no history"
data = ""
for i, s in enumerate(history[:10]):
data += f"{i+1}) <a target=\"_blank\" href=\"https://www.youtube.com/watch?v={s.utube_id}\">{s.utube_id}</a>; {s.title} <br>"
history_cache = [now, data]
return data
@app.route("/login")
async def login():
from ircradio.factory import keycloak
if 'auth_token' not in session:
return redirect(url_for(keycloak.endpoint_name_login))
return redirect('root')
@app.route("/search")
@ -89,10 +75,14 @@ async def search():
@app.route("/library")
async def user_library():
from ircradio.factory import keycloak
if 'auth_token' not in session:
return redirect(url_for(keycloak.endpoint_name_login))
from ircradio.models import Song
name = request.args.get("name")
if not name:
abort(404)
return await render_template('user.html')
try:
by_date = Song.select().filter(Song.added_by == name)\
@ -109,25 +99,243 @@ async def user_library():
except:
by_karma = []
return await render_template("library.html", name=name, by_date=by_date, by_karma=by_karma)
return await render_template("user_library.html", name=name, by_date=by_date, by_karma=by_karma)
@app.route("/request")
async def request_song():
from ircradio.factory import keycloak
if 'auth_token' not in session:
return redirect(url_for(keycloak.endpoint_name_login))
return await render_template('request.html')
@app.route('/api/songs')
async def api_songs():
from ircradio.factory import keycloak
from ircradio.models import Song, db
if 'auth_token' not in session:
return abort(403)
q = """
SELECT title, utube_id, added_by, karma
FROM song
WHERE
ORDER BY date_added DESC
LIMIT ? OFFSET ?;
"""
limit = int(request.args.get('limit', 150))
offset = int(request.args.get('offset', 0))
search = request.args.get('search', '')
sort_by = request.args.get('sort')
order = request.args.get('order', 'DESC')
if order.lower() in ['desc', 'asc']:
order = "desc" if order == "asc" else "desc" # yolo
q = q.replace('DESC', order)
if sort_by == "karma":
q = q.replace('date_added', 'karma')
args = [limit, offset]
if isinstance(search, str):
search = search[:8]
search = search.replace('%', '')
args.insert(0, f"%{search}%")
q = q.replace('WHERE', f'WHERE title LIKE ?')
else:
q = q.replace('WHERE', f'')
songs = []
cursor = db.execute_sql(q, tuple(args)) # no sqli for all the naughty people!!
for row in cursor.fetchall():
songs.append({'title': row[0], 'uid': row[1], 'added_by': row[2], 'karma': row[3]})
return jsonify(songs)
@app.route('/api/request/<path:utube_id>')
async def api_request(utube_id: str = None):
from ircradio.models import Song
from ircradio.factory import irc_message_announce_bus
if not utube_id:
return abort(500)
if 'auth_token' not in session:
return abort(403)
user = session['auth_token']
username = user.get('preferred_username')
try:
song = Song.select().filter(Song.utube_id == utube_id).get()
except Exception as ex:
return abort(404)
radio_default = settings.radio_stations['wow']
await radio_default.queue_push(song.path or song.filepath)
msg = f"{username} added {song.title} to the queue via webif"
await irc_message_announce_bus.put(msg)
return jsonify({})
@app.route('/api/boo/<path:radio_id>')
async def api_boo(radio_id: str):
from ircradio.models import Song
from ircradio.factory import irc_message_announce_bus
if not radio_id or radio_id not in settings.radio_stations:
return abort(500)
if 'auth_token' not in session:
return abort(403)
user = session['auth_token']
username = user.get('preferred_username')
# throttling
cache_key = f"throttle_api_boo_{username}"
res = await current_app.session_interface.get(cache_key)
if res:
return jsonify({}) # silently fail
radio_default = settings.radio_stations['wow']
song = await radio_default.np()
if not song:
current_app.logger.error(f"Nothing is playing?!")
return abort(500)
if song.karma >= 1:
song.karma -= 1
song.save()
# set cache
await current_app.session_interface.set(cache_key, b'1', 15)
hates = ['throwing shade', 'hating', 'boo\'ing', 'throwing tomatoes', 'flipping tables', 'raging']
msg = f"{username} {random.choice(hates)} from webif .. \"{song.title}\" is now {song.karma}/10 .. BOOO!!!!"
await irc_message_announce_bus.put(msg)
return jsonify({'msg': msg})
@app.route('/api/tune/<path:radio_id>')
async def api_tune(radio_id: str):
from ircradio.models import Song
from ircradio.factory import irc_message_announce_bus
if not radio_id or radio_id not in settings.radio_stations:
return abort(500)
if 'auth_token' not in session:
return abort(403)
user = session['auth_token']
username = user.get('preferred_username')
# throttling
cache_key = f"throttle_api_tune_{username}"
res = await current_app.session_interface.get(cache_key)
if res:
return jsonify({}) # silently fail
radio_default = settings.radio_stations['wow']
song = await radio_default.np()
if not song:
return await send_message(target, f"Nothing is playing?!")
song.karma += 1
song.save()
# set cache
await current_app.session_interface.set(cache_key, b'1', 15)
loves = ['dancing', 'vibin\'', 'boppin\'', 'breakdancing', 'raving', 'chair dancing']
msg = f"{username} {random.choice(loves)} .. \"{song.title}\" is now {song.karma}/10 .. PARTY ON!!!!"
await irc_message_announce_bus.put(msg)
return jsonify({'msg': msg})
@app.route('/api/skip/<path:radio_id>')
async def api_skip(radio_id: str):
from ircradio.models import Song
from ircradio.factory import irc_message_announce_bus
if not radio_id or radio_id not in settings.radio_stations:
return abort(500)
if 'auth_token' not in session:
return abort(403)
user = session['auth_token']
username = user.get('preferred_username')
# throttling
cache_key = f"throttle_api_skip_{radio_id}_{username}"
res = await current_app.session_interface.get(cache_key)
if res:
return jsonify({}) # silently fail
radio_station = settings.radio_stations[radio_id]
await radio_station.skip()
# set cache
await current_app.session_interface.set(cache_key, b'1', 15)
hates = ['Booo', 'Rude', 'Wtf']
msg = f"{username} skipped. {random.choice(hates)}! >:|"
if radio_station.id == "wow":
await irc_message_announce_bus.put(msg)
return jsonify({'msg': msg})
@app.route("/history")
async def history():
from ircradio.factory import keycloak
if 'auth_token' not in session:
return redirect(url_for(keycloak.endpoint_name_login))
radio_default = settings.radio_stations['wow']
songs = await radio_default.history()
if not songs:
return "no history"
return await render_template('history.html', songs=songs)
@app.websocket("/ws")
async def np():
last_song = ""
async def ws():
current_app.logger.info('websocket client connected')
from ircradio.factory import websocket_status_bus, websocket_status_bus_last_item
from ircradio.station import Station
async def send_all(data: dict[str, Station]):
return await websocket.send_json({
k: dataclasses.asdict(v) for k, v in data.items()
})
if isinstance(websocket_status_bus_last_item, dict):
current_app.logger.debug('sending data to ws peer')
await send_all(websocket_status_bus_last_item)
while True:
"""get current song from history"""
history = Radio.history()
val = ""
if not history:
val = f"Nothing is playing?!"
else:
song = history[0]
val = song.title
async for data in websocket_status_bus.subscribe():
current_app.logger.debug('sending data to ws peer')
await send_all(data)
if val != last_song:
data = json.dumps({"now_playing": val})
await websocket.send(f"{data}")
last_song = val
await asyncio.sleep(5)
@app.route("/assets/art/<path:path>")
async def assets_art(path: str):
img_default = "album_art_default.jpg"
_base = os.path.join(settings.cwd, "ircradio", "static")
mix_dirs = glob(settings.dir_mixes + "/*")
try:
for _dirname in [settings.dir_meta, settings.dir_music, *mix_dirs]:
_path = os.path.join(_dirname, path)
if os.path.exists(_path):
return await send_from_directory(_dirname, path)
except Exception as ex:
current_app.logger.debug(ex)
return await send_from_directory(_base, img_default), 500
return await send_from_directory(_base, img_default), 404
@app.route("/static_music_meta/<path:path>")
async def static_music_meta(path: str):
return await send_from_directory(
settings.dir_meta,
file_name=path)

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

BIN
ircradio/static/berlin.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

BIN
ircradio/static/dnb.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

43
ircradio/static/index.css Normal file
View File

@ -0,0 +1,43 @@
.hero {max-width:700px;}
a{color: #82b2e5;}
body {background-color: inherit !important; background: linear-gradient(45deg, #07070a, #001929);}
button, small.footer { text-align: left !important; }
button[data-playing="true"] { color: white; }
.container {max-width:96%;}
.card {border: 1px solid rgba(250,250,250,.1) !important;}
.card-body {background-color: #151515;}
.card-header{background-color: rgb(25, 25, 25) !important; text-align: center;}
.text-muted { color: #dedede !important;}
.btn-outline-primary {
color: #5586b7;
border-color: #527ca8;
}
@keyframes gradient {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
}
.btn-active {
color:white;
background: linear-gradient(45deg, rgba(255, 42, 212, 1) 0%, rgba(255, 204, 0, 1) 100%);
background-size: 400% 400%;
animation: gradient 3s ease infinite;
}
.bootstrap-table .search {
float: none !important;
width: 100% !important;
}
.card-header {padding: .5rem 1rem;}
.card-header .font-weight-normal {font-size: 1.2rem;}
.card-img-top {
width: 100%;
max-height: 170px;
object-fit: cover;
}
.card-footer {
border:none !important;
background-color: #151515 !important;
}
@media screen and (min-width:1100px){
.container {max-width:1200px;}
}

24
ircradio/static/index.js Normal file
View File

@ -0,0 +1,24 @@
function ws_connect(ws_url, onData) {
console.log('connecting');
var ws = new WebSocket(ws_url);
ws.onopen = function() {
// nothing
};
ws.onmessage = function(e) {
console.log('Message:', e.data);
onData(e.data);
};
ws.onclose = function(e) {
console.log('Socket is closed. Reconnect will be attempted in 2 seconds.', e.reason);
setTimeout(function() {
ws_connect(ws_url, onData);
}, 2000);
};
ws.onerror = function(err) {
console.error('Socket encountered error: ', err.message, 'Closing socket');
ws.close();
};
}

BIN
ircradio/static/psyduck.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

BIN
ircradio/static/raves.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

BIN
ircradio/static/rock.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

View File

@ -1,79 +0,0 @@
// tracks input in 'search' field (id=general)
let input_so_far = "";
// cached song list and cached queries
var queries = [];
var songs = new Map([]);
// track async fetch and processing
var returned = false;
$("#general").keyup( function() {
input_so_far = document.getElementsByName("general")[0].value;
if (input_so_far.length < 3) {
$("#table tr").remove();
return
};
if (!queries.includes(input_so_far.toLowerCase() ) ) {
queries.push(input_so_far.toLowerCase() );
returned = false;
const sanitized_input = encodeURIComponent( input_so_far );
const url = 'https://' + document.domain + ':' + location.port + '/search?name=' + sanitized_input + '&limit=15&offset=0'
const LoadData = async () => {
try {
const res = await fetch(url);
console.log("Status code 200 or similar: " + res.ok);
const data = await res.json();
return data;
} catch(err) {
console.error(err)
}
};
LoadData().then(newSongsJson => {
newSongsJson.forEach( (new_song) => {
let already_have = false;
songs.forEach( (_v, key) => {
if (new_song.id == key) { already_have = true; return; };
})
if (!already_have) { songs.set(new_song.utube_id, new_song) }
})
}).then( () => { returned = true } );
};
function renderTable () {
if (returned) {
$("#table tr").remove();
var filtered = new Map(
[...songs]
.filter(([k, v]) =>
( v.title.toLowerCase().includes( input_so_far.toLowerCase() ) ) ||
( v.added_by.toLowerCase().includes( input_so_far.toLowerCase() ) ) )
);
filtered.forEach( (song) => {
let added = song.added_by;
let added_link = '<a href="/library?name=' + added + '" target="_blank" rel="noopener noreferrer">' + added + '</a>';
let title = song.title;
let id = song.utube_id;
let id_link = '<a href="https://www.youtube.com/watch?v=' + id + '" target="_blank" rel="noopener noreferrer">' + id + '</a>';
$('#table tbody').append('<tr><td>'+id_link+'</td><td>'+added_link+'</td><td>'+title+'</td></tr>')
})
} else {
setTimeout(renderTable, 30); // try again in 30 milliseconds
}
};
renderTable();
});

BIN
ircradio/static/trance.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

BIN
ircradio/static/weed.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

BIN
ircradio/static/wow.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

BIN
ircradio/static/wow.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

195
ircradio/station.py Normal file
View File

@ -0,0 +1,195 @@
import re, os, json, logging
from collections import OrderedDict
from typing import List, Optional
from dataclasses import dataclass
import mutagen
import aiofiles
from aiocache import cached, Cache
from aiocache.serializers import PickleSerializer
import settings
from ircradio.models import Song
@dataclass
class SongDataclass:
title: str
karma: int
utube_id: str
added_by: str
image: Optional[str]
duration: Optional[int]
progress: Optional[int] # pct
progress_str: Optional[str] = None
@dataclass
class Station:
id: str
music_dir: str # /full/path/to/music/
mount_point: str # wow.ogg
request_id: str # pberlin (for telnet requests)
title: str # for webif
description: str # for webif
image: str # for webif
listeners: int = 0
song: Optional[SongDataclass] = None
async def skip(self) -> bool:
from ircradio.radio import Radio
try:
await Radio.command(self.telnet_cmd_skip)
return True
except Exception as ex:
return False
async def np(self) -> Optional[Song]:
history = await self.history()
if history:
return history[-1]
@cached(ttl=4, cache=Cache.MEMORY,
key_builder=lambda *args, **kw: f"history_station_{args[1].id}",
serializer=PickleSerializer())
async def history(self) -> List[Song]:
from ircradio.radio import Radio
# 1. ask liquidsoap for history
# 2. check database (Song.from_filepath)
# 3. check direct file exif (Song.from_filepath)
# 4. check .ogg.json metadata file (Song.from_filepath)
# 5. verify the above by comparing icecast metadata
# find a better way to get current song
liq_filenames = []
from ircradio.factory import NP_MAP
np_uid = self.id
if np_uid == "main":
np_uid = "pmain"
elif np_uid == "wow":
np_uid = "pmain"
if np_uid in NP_MAP:
liq_filenames = [NP_MAP[np_uid]]
liq_remaining = await Radio.command(self.telnet_cmd_remaining)
liq_remaining = liq_remaining.decode(errors="ignore")
remaining = None
if re.match('\d+.\d+', liq_remaining):
remaining = int(liq_remaining.split('.')[0])
songs = []
for liq_fn in liq_filenames:
try:
song = await Song.from_filepath(liq_fn)
songs.append(song)
except Exception as ex:
logging.error(ex)
if not songs:
return []
# icecast compare, silliness ahead
meta: dict = await Radio.icecast_metadata(self)
if meta:
meta_title = meta.get('title')
if meta_title and meta_title.lower() in [' ', 'unknown', 'error', 'empty', 'bleepbloopblurp']:
meta_title = None
if meta_title and len(songs) > 1 and songs:
title_a = songs[-1].title_for_real_nocap.lower()
title_b = meta_title.lower()
if title_a not in title_b and title_b not in title_a:
# song detection and actual icecast metadata differ
logging.error(f"song detection and icecast metadata differ:\n{meta_title}\n{songs[-1].title}")
songs[-1].title = meta_title
if remaining:
songs[-1].remaining = remaining
return songs
async def queue_get(self) -> List[Song]:
from ircradio.radio import Radio
queues = await Radio.command(self.telnet_cmd_queue)
queue_ids = re.findall(b"\d+", queues)
if not queue_ids:
return []
queue_ids: List[int] = list(reversed(list(map(int, queue_ids)))) # yolo
songs = []
for request_id in queue_ids:
liq_meta = await Radio.command(f"request.metadata {request_id}")
liq_meta = liq_meta.decode(errors='none')
liq_fn = re.findall(r"filename=\"(.*)\"", liq_meta)
if not liq_fn:
continue
liq_fn = liq_fn[0]
try:
song = await Song.from_filepath(liq_fn)
songs.append(song)
except Exception as ex:
logging.error(ex)
# remove the now playing song from the queue
now_playing = await self.np()
if songs and now_playing:
if songs[0].filepath == now_playing.filepath:
songs = songs[1:]
return songs
async def queue_push(self, filepath: str) -> bool:
from ircradio.radio import Radio
from ircradio.factory import app
if not os.path.exists(filepath):
logging.error(f"file does not exist: {filepath}")
return False
current_queue = await self.queue_get()
if filepath in [c.filepath for c in current_queue]:
logging.error(f"already added to queue: {song.filepath}")
return False
try:
await Radio.command(f"{self.telnet_cmd_push} {filepath}")
except Exception as ex:
logging.error(f"failed to push, idunno; {ex}")
return False
return True
async def get_listeners(self) -> int:
from ircradio.radio import Radio
meta: dict = await Radio.icecast_metadata(self)
return meta.get('listeners', 0)
@property
def telnet_cmd_push(self): # push into queue
return f"{self.request_id}.push"
@property
def telnet_cmd_queue(self): # view queue_ids
return f"{self.request_id}.queue"
@property
def telnet_cmd_metadata(self):
return f"now_playing"
@property
def telnet_cmd_remaining(self):
return f"{self.mount_point.replace('.', '_')}.remaining"
@property
def telnet_cmd_skip(self):
return f"{self.mount_point.replace('.', '_')}.skip"
@property
def stream_url(self):
return f"http://{settings.icecast2_hostname}/{self.mount_point}"

View File

@ -1,17 +0,0 @@
[Unit]
Description={{ description }}
After=network-online.target
Wants=network-online.target
[Service]
User={{ user }}
Group={{ group }}
Environment="{{ env }}"
StateDirectory={{ name | lower }}
LogsDirectory={{ name | lower }}
Type=simple
ExecStart={{ path_executable }} {{ args_executable }}
Restart=always
[Install]
WantedBy=multi-user.target

View File

@ -1,7 +1,7 @@
<!DOCTYPE html>
<html lang="en">
<!--
░░░░░░░█▐▓▓░████▄▄▄█▀▄▓▓▓▌█ very website
░░░░░░░█▐▓▓░████▄▄▄█▀▄▓▓▓▌█ very radio
░░░░░▄█▌▀▄▓▓▄▄▄▄▀▀▀▄▓▓▓▓▓▌█
░░░▄█▀▀▄▓█▓▓▓▓▓▓▓▓▓▓▓▓▀░▓▌█
░░█▀▄▓▓▓███▓▓▓███▓▓▓▄░░▄▓▐█▌ such html
@ -10,10 +10,11 @@
█▌███▓▓▓▓▓▓▓▓▐░░▄▓▓███▓▓▓▄▀▐█
█▐█▓▀░░▀▓▓▓▓▓▓▓▓▓██████▓▓▓▓▐█
▌▓▄▌▀░▀░▐▀█▄▓▓██████████▓▓▓▌█▌
▌▓▓▓▄▄▀▀▓▓▓▀▓▓▓▓▓▓▓▓█▓█▓█▓▓▌█▌ many music
▌▓▓▓▄▄▀▀▓▓▓▀▓▓▓▓▓▓▓▓█▓█▓█▓▓▌█▌ much music
█▐▓▓▓▓▓▓▄▄▄▓▓▓▓▓▓█▓█▓█▓█▓▓▓▐█
-->
<head>
<title>IRC!Radio</title>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
@ -27,38 +28,23 @@
<meta name="application-name" content="IRC!Radio">
<meta name="msapplication-TileColor" content="#da532c">
<meta name="description" content="IRC!Radio"/>
<title>IRC!Radio</title>
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css">
{% if ENABLE_SEARCH_ROUTE %}
<link rel="stylesheet" href="https://code.jquery.com/ui/1.12.1/themes/base/jquery-ui.css"></link>
<script src="https://code.jquery.com/jquery-3.6.0.min.js" integrity="sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4=" crossorigin="anonymous"></script>
<script src="https://code.jquery.com/ui/1.12.1/jquery-ui.min.js" integrity="sha256-VazP97ZCwtekAsvgPBSUwPFKdrwD3unUfSGVYrahUqU=" crossorigin="anonymous"></script>
{% endif %}
<script>
var ws = new WebSocket('wss://' + document.domain + ':' + location.port + '/ws');
ws.onmessage = function (event) {
// console.log(event.data);
json = JSON.parse(event.data);
np = json.now_playing;
document.querySelector("#now_playing").innerText = np;
// console.log(np);
return false;
};
ws.onclose = function(event) {
console.log("WebSocket is closed now.");
};
</script>
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,300i,400,400i,700,700i%7CRoboto+Mono:400,400i,700,700i&display=fallback">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/vinorodrigues/bootstrap-dark@0.6.1/dist/bootstrap-dark.min.css">
<link rel="stylesheet" href="{{ url_for('static', filename='index.css') }}">
</head>
<div class="hero px-3 py-3 pt-md-4 pb-md-3 mx-auto text-center">
<a href="/" style="color: inherit; text-decoration: none;">
<h1 class="display-4 d-inline-flex">Radio!WOW</h1>
</a>
<p class="lead">Enjoy the music :)</p>
</div>
<body>
{% block content %} {% endblock %}
</body>

View File

@ -1,75 +0,0 @@
# Crossfade between tracks,
# taking the respective volume levels
# into account in the choice of the
# transition.
# @category Source / Track Processing
# @param ~start_next Crossing duration, if any.
# @param ~fade_in Fade-in duration, if any.
# @param ~fade_out Fade-out duration, if any.
# @param ~width Width of the volume analysis window.
# @param ~conservative Always prepare for
# a premature end-of-track.
# @param s The input source.
def smart_crossfade (~start_next=5.,~fade_in=3.,
~fade_out=3., ~width=2.,
~conservative=false,s)
high = -20.
medium = -32.
margin = 4.
fade.out = fade.out(type="sin",duration=fade_out)
fade.in = fade.in(type="sin",duration=fade_in)
add = fun (a,b) -> add(normalize=false,[b,a])
log = log(label="smart_crossfade")
def transition(a,b,ma,mb,sa,sb)
list.iter(fun(x)->
log(level=4,"Before: #{x}"),ma)
list.iter(fun(x)->
log(level=4,"After : #{x}"),mb)
if
# If A and B and not too loud and close,
# fully cross-fade them.
a <= medium and
b <= medium and
abs(a - b) <= margin
then
log("Transition: crossed, fade-in, fade-out.")
add(fade.out(sa),fade.in(sb))
elsif
# If B is significantly louder than A,
# only fade-out A.
# We don't want to fade almost silent things,
# ask for >medium.
b >= a + margin and a >= medium and b <= high
then
log("Transition: crossed, fade-out.")
add(fade.out(sa),sb)
elsif
# Do not fade if it's already very low.
b >= a + margin and a <= medium and b <= high
then
log("Transition: crossed, no fade-out.")
add(sa,sb)
elsif
# Opposite as the previous one.
a >= b + margin and b >= medium and a <= high
then
log("Transition: crossed, fade-in.")
add(sa,fade.in(sb))
# What to do with a loud end and
# a quiet beginning ?
# A good idea is to use a jingle to separate
# the two tracks, but that's another story.
else
# Otherwise, A and B are just too loud
# to overlap nicely, or the difference
# between them is too large and
# overlapping would completely mask one
# of them.
log("No transition: just sequencing.")
sequence([sa, sb])
end
end
cross(width=width, duration=start_next,
conservative=conservative,
transition,s)
end

View File

@ -0,0 +1,24 @@
<footer class="pt-4 my-md-5 pt-md-5 border-top">
<div class="row">
<div class="col-12 col-md">
<img class="mb-2" src="{{ url_for('static', filename='wow.png') }}" alt="" width="24" height="24">
<small class="d-block mb-3 text-muted">IRC!Radio</small>
</div>
<div class="col-4 col-md">
<h5>Menu</h5>
<ul class="list-unstyled text-small">
<li><a class="text-muted" href=" {{ url_for('login') }} ">Login</a></li>
<li><a class="text-muted" href=" {{ url_for('history') }} ">History</a></li>
<li><a class="text-muted" href=" {{ url_for('user_library') }} ">User Library</a></li>
<li><a class="text-muted" href=" {{ url_for('request_song') }} ">Request</a></li>
<li><a class="text-muted" href="https://git.wownero.com/dsc/ircradio">Source</a></li>
</ul>
</div>
<div class="col-4 col-md">
</div>
<div class="col-4 col-md">
</div>
</div>
</footer>

View File

@ -0,0 +1,18 @@
{% extends "base.html" %}
{% block content %}
<!-- Page Content -->
<div class="container">
<div class="row">
<div class="col-lg-8">
<h5>History</h5>
<pre style="color:white;font-family:monospace;font-size:14px;">{% for s in songs %}<a target="_blank" href="https://www.youtube.com/watch?v={{s.utube_id}}">{{s.utube_id}}</a> {{s.title}}
{% endfor %}</pre>
</div>
</div>
{% include 'footer.html' %}
</div>
{% endblock %}

View File

@ -1,53 +0,0 @@
<icecast>
<location>Somewhere</location>
<admin>my@email.tld</admin>
<limits>
<clients>32</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>0</burst-on-connect>
<burst-size>65535</burst-size>
</limits>
<authentication>
<source-password>{{ source_password }}</source-password>
<relay-password>{{ relay_password }}</relay-password> <!-- for livestreams -->
<admin-user>admin</admin-user>
<admin-password>{{ admin_password }}</admin-password>
</authentication>
<hostname>{{ hostname }}</hostname>
<listen-socket>
<bind-address>{{ icecast2_bind_host }}</bind-address>
<port>{{ icecast2_bind_port }}</port>
</listen-socket>
<http-headers>
<header name="Access-Control-Allow-Origin" value="*" />
</http-headers>
<fileserve>1</fileserve>
<paths>
<basedir>/usr/share/icecast2</basedir>
<logdir>{{ log_dir }}</logdir>
<webroot>/usr/share/icecast2/web</webroot>
<adminroot>/usr/share/icecast2/admin</adminroot>
</paths>
<logging>
<accesslog>icecast2_access.log</accesslog>
<errorlog>icecast2_error.log</errorlog>
<loglevel>3</loglevel> <!-- 4 Debug, 3 Info, 2 Warn, 1 Error -->
<logsize>10000</logsize> <!-- Max size of a logfile -->
</logging>
<security>
<chroot>0</chroot>
</security>
</icecast>

View File

@ -1,93 +1,245 @@
{% extends "base.html" %}
{% block content %}
{% set radio_default = settings.radio_stations['wow'] %}
<!-- Page Content -->
<div class="container">
<div class="row">
<!-- Post Content Column -->
<div class="col-lg-12">
<!-- Title -->
<h1 class="mt-4" style="margin-bottom: 2rem;">
IRC!Radio
</h1>
<p>Enjoy the music :)</p>
<hr>
<audio controls src="/{{ settings.icecast2_mount }}">Your browser does not support the<code>audio</code> element.</audio>
<p> </p>
<h5>Now playing: </h5>
<div id="now_playing">Nothing here yet</div>
<hr>
{% for rs in radio_stations %}
<div class="col-md-4 col-sm-6 col-xl-3 d-flex">
<div data-radio="{{ rs.id }}" class="card box-shadow mb-4 flex-fill">
<div class="card-header">
<h5 class="my-0 font-weight-normal">{{ rs.title }}</h5>
</div>
<img class="img_header card-img-top" src="{{ url_for('static', filename=rs.image) }}" alt="">
<div class="card-body text-center">
<h5 class="title_str card-title pricing-card-title text-muted">|</h5>
<ul class="list-unstyled mt-3 mb-4">
<li class="d-none listeners_str">0 listeners</li>
<li class="progress_str text-muted">00:00 / 00:00</li>
</ul>
</div>
<div class="card-footer">
<small class="footer d-block text-muted mt-3">{{ rs.description | safe }}</small>
</div>
<div class="card-footer">
<button data-playing="false" data-url="{{ settings.icecast2_scheme + settings.icecast2_hostname + "/" + rs.mount_point }}" data-radio="{{ rs.id }}" type="button" class="btn btn-play btn-block btn-outline-primary mb-2">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="btn_audio_icon bi bi-play" viewBox="0 0 16 16">
<path d="M10.804 8 5 4.633v6.734L10.804 8zm.792-.696a.802.802 0 0 1 0 1.392l-6.363 3.692C4.713 12.69 4 12.345 4 11.692V4.308c0-.653.713-.998 1.233-.696l6.363 3.692z"></path>
</svg>
<span>Play</span>
</button>
<h4>Command list:</h4>
<pre style="font-size:12px;">!np - current song
!tune - upvote song
!boo - downvote song
!request - search and queue a song by title or YouTube id
!dj+ - add a YouTube ID to the radiostream
!dj- - remove a YouTube ID
!ban+ - ban a YouTube ID and/or nickname
!ban- - unban a YouTube ID and/or nickname
!skip - skips current song
!listeners - show current amount of listeners
!queue - show queued up music
!queue_user - queue a random song by user
!search - search for a title
!stats - stats
</pre>
<hr>
<div class="row">
<div class="col-md-3">
<h4>History</h4>
<a href="/history.txt">history.txt</a>
{% if logged_in %}
{% if rs.id == radio_default.id %}
<div class="btnMeta mb-2" data-url="{{ url_for('api_tune', radio_id=rs.id) }}">
<button data-playing="false" data-radio="{{ rs.id }}" type="button" class="btn btn-block btn-outline-primary">
<span>Tune</span>
</button>
</div>
<div class="col-md-5">
<h4>Library
<small style="font-size:12px">(by user)</small>
</h4>
<form method="GET" action="/library">
<div class="input-group mb-3 style=no-gutters">
<input type="text" class="form-control" id="name" name="name" placeholder="username...">
<div class="input-group-append">
<input class="btn btn-outline-secondary" type="submit" value="Search">
</div>
</div>
</form>
</div>
</div>
{% if ENABLE_SEARCH_ROUTE %}
<hr>
<div class="row">
<div class="col-md-8">
<h4>Quick Search
<small style="font-size:12px">(general)</small>
</h4>
<div class="input-group mb-3">
<input type="text" class="form-control" id="general" name="general" placeholder="query...">
</div>
</div>
<div class="col-md-12">
<table class="table table-sm table-hover table-bordered" id="table" style="font-size:12px">
<thead>
<tbody style="">
</tbody>
</thead>
</table>
</div>
<div class="btnMeta mb-2" data-url="{{ url_for('api_boo', radio_id=rs.id) }}">
<button data-playing="false" data-radio="{{ rs.id }}" type="button" class="btn btn-block btn-outline-primary">
<span>Boo</span>
</button>
</div>
{% endif %}
<hr>
<h4>IRC</h4>
<pre>{{ settings.irc_host }}:{{ settings.irc_port }}
{{ settings.irc_channels | join(" ") }}
</pre>
<div class="btnMeta mb-2" data-url="{{ url_for('api_skip', radio_id=rs.id) }}">
<button data-playing="false" data-radio="{{ rs.id }}" type="button" class="btn btn-block btn-outline-primary">
<span>Skip</span>
</button>
</div>
{% endif %}
</div>
</div>
</div>
{% endfor %}
<div class="audio d-none">
{% for rs in radio_stations %}
<audio data-url="{{ rs.stream_url }}" id="player_{{ rs.id }}" controls preload="none">
<source src="{{ rs.stream_url }}" type="audio/ogg">
</audio>
{% endfor %}
</div>
</div>
{% include 'footer.html' %}
</div>
{% if ENABLE_SEARCH_ROUTE %}
<script src="static/search.js"></script>
{% endif %}
<script>
var radio_default_images = {
{% for rs in radio_stations %}
"{{ rs.id }}": "{{ url_for('static', filename=rs.image) }}",
{% endfor %}
}
</script>
<script src="static/index.js"></script>
<script>
var radio_data = null;
// jquery selection caches
var audio_np = null;
// lookup, to keep order
let radio_station_ids = [{% for rs in radio_stations %}'{{ rs.id }}',{% endfor %}];
var sel_radio_cards = {};
var url_icecast = '{{ settings.icecast2_hostname }}';
var url_album_art = '/assets/art/';
var ws_url = '{{ settings.ws_url }}';
var icon_play = `
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="btn_audio_icon bi bi-play" viewBox="0 0 16 16">
<path d="M10.804 8 5 4.633v6.734L10.804 8zm.792-.696a.802.802 0 0 1 0 1.392l-6.363 3.692C4.713 12.69 4 12.345 4 11.692V4.308c0-.653.713-.998 1.233-.696l6.363 3.692z"></path>
</svg>
`;
var icon_stop = `
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="btn_audio_icon bi bi-stop" viewBox="0 0 16 16">
<path d="M3.5 5A1.5 1.5 0 0 1 5 3.5h6A1.5 1.5 0 0 1 12.5 5v6a1.5 1.5 0 0 1-1.5 1.5H5A1.5 1.5 0 0 1 3.5 11V5zM5 4.5a.5.5 0 0 0-.5.5v6a.5.5 0 0 0 .5.5h6a.5.5 0 0 0 .5-.5V5a.5.5 0 0 0-.5-.5H5z"></path>
</svg>
`;
function stopRadio(radio_id) {
let radio = document.getElementById("player_" + radio_id);
radio.pause();
radio.src = radio.src; // trick to force disconnect from radio
}
function playRadio(radio_id) {
let radio = document.getElementById("player_" + radio_id);
let sel = $(radio);
radio.src = sel.attr('data-url');
radio.play();
}
function listeners_str(amount){
if(amount >= 2 || amount === 0) return `${amount} listeners`;
return `${amount} listener`;
}
function btnPlayChangeText(radio_id, text) {
sel_radio_cards[radio_id].find('.btn-play span').text(text);
}
function resetAll() {
for (let radio_id of radio_station_ids) {
stopRadio(radio_id);
btnPlayChangeActive(radio_id, false);
btnPlayChangeText(radio_id, 'Play');
}
}
function btnPlayChangeActive(radio_id, active) {
let _sel = sel_radio_cards[radio_id].find('.btn-play');
_sel.find('.btn_audio_icon').remove();
if(active) {
_sel.addClass('btn-active');
_sel.attr('data-playing', 'true');
_sel.prepend(icon_stop);
} else {
_sel.removeClass('btn-active');
_sel.attr('data-playing', 'false');
_sel.prepend(icon_play);
}
}
function labelSetListeners(radio_id) {
if(!radio_data.hasOwnProperty(radio_id)) return;
let listeners = radio_data[radio_id].listeners;
sel_radio_cards[radio_id].find('.listeners_str').text(listeners_str(listeners));
}
function btnPlay(event) {
let playing = $(event.currentTarget).attr('data-playing');
let url = $(event.currentTarget).attr('data-url');
let uid = $(event.currentTarget).attr('data-radio');
resetAll();
if(playing === "true" && audio_np !== null && audio_np.uid === uid) {
stopRadio(uid);
return;
}
playRadio(uid);
audio_np = {'url': url, 'uid': uid}
radio_data[uid].listeners += 1;
labelSetListeners(uid);
btnPlayChangeActive(uid, true);
btnPlayChangeText(uid, 'Pause');
}
$(document).ready(() => {
for (let radio_station_id of radio_station_ids) {
let _sel = $("div[data-radio=" + radio_station_id + "]");
_sel.find('.btn-play').on('click', btnPlay); // playBtn click handler
sel_radio_cards[radio_station_id] = _sel;
}
function onData(data) {
let blob = JSON.parse(data);
radio_data = blob;
let listeners = 0;
for (let radio_station of radio_station_ids) {
if(!blob.hasOwnProperty(radio_station)) continue;
let rs = blob[radio_station];
if(rs.song !== null) {
let song = rs.song;
console.log(song.album_art);
if(song.hasOwnProperty('image') && song.image !== null) {
sel_radio_cards[rs.id].find('.img_header').attr('src', `${url_album_art}/${song.image}`);
}
sel_radio_cards[rs.id].find('.title_str').text(song.title);
//labelSetListeners(rs.id, rs.listeners);
listeners += rs.listeners;
sel_radio_cards[rs.id].find('.progress_str').text(song.progress_str);
}
}
if(listeners >= 1) {
$('p.lead').text(listeners_str(listeners));
} else {
$('p.lead').text('Enjoy the music :)');
}
}
// btnMeta: skip, boo, tune
$(document).on('click', '.btnMeta', async (ev) => {
let sel = $(ev.currentTarget);
let url = sel.attr('data-url');
fetch(url).then((response) => {
if (response.ok) {
return response.json();
}
throw new Error('Something went wrong');
})
.then((responseJson) => {
if(responseJson.hasOwnProperty('msg')) {
let msg = responseJson['msg'];
alert(msg);
}
})
.catch((error) => {
alert(error);
});
});
ws_connect(ws_url, onData);
});
</script>
{% endblock %}

View File

@ -1,56 +0,0 @@
server {
listen 80;
server_name {{ hostname }};
root /var/www/html;
access_log /dev/null;
error_log /var/log/nginx/radio_error;
client_max_body_size 120M;
fastcgi_read_timeout 1600;
proxy_read_timeout 1600;
index index.html;
error_page 403 /403.html;
location = /403.html {
root /var/www/html;
allow all;
internal;
}
location '/.well-known/acme-challenge' {
default_type "text/plain";
root /tmp/letsencrypt;
autoindex on;
}
location / {
root /var/www/html/;
proxy_pass http://{{ host }}:{{ port }};
proxy_redirect off;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
allow all;
}
location /{{ icecast2_mount }} {
allow all;
add_header 'Access-Control-Allow-Origin' '*';
proxy_pass http://{{ icecast2_bind_host }}:{{ icecast2_bind_port }}/{{ icecast2_mount }};
proxy_redirect off;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /ws {
proxy_pass http://{{ host }}:{{ port }}/ws;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
proxy_set_header Host $host;
}
}

View File

@ -0,0 +1,87 @@
{% extends "base.html" %}
{% block content %}
<link rel="stylesheet" href="https://unpkg.com/bootstrap-table@1.21.3/dist/bootstrap-table.min.css">
<script src="https://unpkg.com/bootstrap-table@1.21.3/dist/bootstrap-table.min.js"></script>
<style>
.card-body .form-control {
color: #b1b1b1;
background-color: #171717;
border: 1px solid #515151;
margin-bottom: 20px;
}
.fixed-table-pagination {display: none !important;}
</style>
<!-- Page Content -->
<div class="container">
<div class="row mb-5">
<div class="col-lg-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0">Request song</h5>
</div>
<div class="card-body">
<p style="color: #626262 !important;">Note: Requesting a song can take a minute or two before it starts playing.</p>
<table
id="songsTable"
data-side-pagination="server"
data-classes="table"
data-pagination="true"
data-toggle="table"
data-flat="true"
data-page-size="150"
data-search="true"
data-url="{{url_for('api_songs')}}">
<thead>
<tr>
<th data-formatter="btnMaker" data-sortable="false"></th>
<th data-field="title" data-sortable="false">Name</th>
<th data-field="karma" data-sortable="true">Karma</th>
<th data-field="added_by" data-sortable="false">User</th>
<th data-formatter="utubeMaker" data-field="utube_id" data-sortable="false"></th>
</tr>
</thead>
</table>
</div>
</div>
</div>
</div>
<script>
function utubeMaker(value, row, index) {
return `<a target="_blank" href="https://youtube.com/watch?v=${row.uid}">${row.uid}</a>`;
}
function btnMaker(value, row, index) {
return `<span class="btnRequest" style="cursor:pointer;color:#82b2e5;" data-uid="${row.uid}">request</span>`;
}
$(document).ready(() => {
$(document).on('click', '.btnRequest', async (ev) => {
let sel = $(ev.currentTarget);
let uid = sel.attr('data-uid');
let url = "{{ url_for('api_request', utube_id='') }}" + uid;
fetch(url).then((response) => {
if (response.ok) {
return response.json();
}
throw new Error('Something went wrong');
})
.then((responseJson) => {
sel.text('added');
})
.catch((error) => {
alert(error);
});
});
});
</script>
{% include 'footer.html' %}
</div>
{% endblock %}

View File

@ -0,0 +1,25 @@
{% extends "base.html" %}
{% block content %}
<!-- Page Content -->
<div class="container">
<div class="row">
<div class="col-md-5">
<h4>View User Library
<small style="font-size:12px"></small>
</h4>
<form target="_blank" method="GET" action="/library">
<div class="input-group mb-3 style=no-gutters">
<input type="text" class="form-control" id="name" name="name" placeholder="username...">
<div class="input-group-append">
<input class="btn btn-outline-secondary" type="submit" value="Search">
</div>
</div>
</form>
</div>
</div>
{% include 'footer.html' %}
</div>
{% endblock %}

View File

@ -11,13 +11,13 @@
<div class="row">
<div class="col-lg-6">
<h5>By date</h5>
<pre style="font-size:12px;">{% for s in by_date %}<a target="_blank" href="https://www.youtube.com/watch?v={{s.utube_id}}">{{s.utube_id}}</a> {{s.title}}
<pre style="color:white;font-family:monospace;font-size:14px;">{% for s in by_date %}<a target="_blank" href="https://www.youtube.com/watch?v={{s.utube_id}}">{{s.utube_id}}</a> {{s.title}}
{% endfor %}</pre>
</div>
<div class="col-lg-6">
<h5>By karma</h5>
<pre style="font-size:12px;">{% for s in by_karma %}<a target="_blank" href="https://www.youtube.com/watch?v={{s.utube_id}}">{{s.utube_id}}</a> {{s.karma}} - {{s.title}}
<pre style="color:white;font-family:monospace;font-size:14px;">{% for s in by_karma %}<a target="_blank" href="https://www.youtube.com/watch?v={{s.utube_id}}">{{s.utube_id}}</a> {{s.karma}} - {{s.title}}
{% endfor %}</pre>
</div>
</div>
@ -27,5 +27,7 @@
</a>
</div>
</div>
{% include 'footer.html' %}
</div>
{% endblock %}

View File

@ -11,11 +11,16 @@ import time
import asyncio
from asyncio.subprocess import Process
from io import TextIOWrapper
from dataclasses import dataclass
import mutagen
import aiofiles
import aiohttp
import jinja2
from aiocache import cached, Cache
from aiocache.serializers import PickleSerializer
from jinja2 import Environment, PackageLoader, select_autoescape
from quart import current_app
import settings
@ -140,34 +145,6 @@ def systemd_servicefile(
return template.encode()
def liquidsoap_version():
ls = shutil.which("liquidsoap")
f = os.popen(f"{ls} --version 2>/dev/null").read()
if not f:
print("please install liquidsoap\n\napt install -y liquidsoap")
sys.exit()
f = f.lower()
match = re.search(r"liquidsoap (\d+.\d+.\d+)", f)
if not match:
return
return match.groups()[0]
def liquidsoap_check_symlink():
msg = """
Due to a bug you need to create this symlink:
$ sudo ln -s /usr/share/liquidsoap/ /usr/share/liquidsoap/1.4.1
info: https://github.com/savonet/liquidsoap/issues/1224
"""
version = liquidsoap_version()
if not os.path.exists(f"/usr/share/liquidsoap/{version}"):
print(msg)
sys.exit()
async def httpget(url: str, json=True, timeout: int = 5, raise_for_status=True, verify_tls=True):
headers = {"User-Agent": random_agent()}
opts = {"timeout": aiohttp.ClientTimeout(total=timeout)}
@ -184,29 +161,10 @@ async def httpget(url: str, json=True, timeout: int = 5, raise_for_status=True,
def random_agent():
from ircradio.factory import user_agents
from ircradio import user_agents
return random.choice(user_agents)
class Price:
def __init__(self):
self.usd = 0.3
def calculate(self):
pass
async def wownero_usd_price_loop(self):
while True:
self.usd = await Price.wownero_usd_price()
asyncio.sleep(1200)
@staticmethod
async def wownero_usd_price():
url = "https://api.coingecko.com/api/v3/simple/price?ids=wownero&vs_currencies=usd"
blob = await httpget(url, json=True)
return blob.get('usd', 0)
def print_banner():
print("""\033[91m ▪ ▄▄▄ ▄▄· ▄▄▄ ▄▄▄· ·▄▄▄▄ ▪
· ·
@ -214,3 +172,82 @@ def print_banner():
. .
. · . \033[0m
""".strip())
async def radio_update_task_run_forever():
while True:
sleep_secs = 15
try:
sleep_secs = await radio_update_task(sleep_secs)
await asyncio.sleep(sleep_secs)
except Exception as ex:
current_app.logger.error(ex)
await asyncio.sleep(sleep_secs)
async def radio_update_task(sleep_secs) -> int:
from ircradio.factory import websocket_status_bus
from ircradio.station import SongDataclass
from ircradio.radio import Radio
if len(websocket_status_bus.subscribers) >= 1:
sleep_secs = 4
blob = {}
radio_stations = list(settings.radio_stations.values())
# radio_stations = radio_stations[1:]
# radio_stations = radio_stations[:2]
for radio_station in radio_stations:
radio_station.song = None
data = {
'added_by': 'system',
'image': None,
'duration': None,
'progress': None,
}
np = await radio_station.np()
if np:
listeners = await radio_station.get_listeners()
if listeners is not None:
radio_station.listeners = listeners
data['title'] = np.title_cleaned
data['karma'] = np.karma
data['utube_id'] = np.utube_id
data['image'] = np.image()
data['duration'] = np.duration
data['added_by'] = np.added_by
time_status = np.time_status()
if time_status:
a, b = time_status
pct = percentage(a.seconds, b.seconds)
if pct >= 100:
pct = 100
data['progress'] = int(pct)
data['progress_str'] = " / ".join(map(str, time_status))
radio_station.song = SongDataclass(**data)
blob[radio_station.id] = radio_station
if blob:
await websocket_status_bus.put(blob)
return sleep_secs
@cached(ttl=3600, cache=Cache.MEMORY,
key_builder=lambda *args, **kw: f"mutagen_file_{args[1]}",
serializer=PickleSerializer())
async def mutagen_file(path):
from quart import current_app
if current_app:
return await current_app.sync_to_async(mutagen.File)(path)
else:
return mutagen.File(path)
def percentage(part, whole):
return 100 * float(part)/float(whole)

View File

@ -17,7 +17,7 @@ class YouTube:
from ircradio.factory import app
from ircradio.models import Song
output = f"{settings.dir_music}/{utube_id}.ogg"
output = f"{settings.dir_music}/{utube_id}"
song = Song.by_uid(utube_id)
if song:
if not os.path.exists(output):
@ -29,14 +29,13 @@ class YouTube:
if os.path.exists(output):
song = Song.by_uid(utube_id)
if not song:
# exists on disk but not in db; add to db
return Song.from_filepath(output)
raise Exception("exists on disk but not in db")
raise Exception("Song already exists.")
try:
path_yt_dlp = os.path.join(settings.cwd, "venv", "bin", "yt-dlp")
proc = await asyncio.create_subprocess_exec(
*["yt-dlp",
*[path_yt_dlp,
"--add-metadata",
"--write-all-thumbnails",
"--write-info-json",
@ -44,7 +43,7 @@ class YouTube:
"--max-filesize", "30M",
"--extract-audio",
"--audio-format", "vorbis",
"-o", f"{settings.dir_music}/%(id)s.ogg",
"-o", f"{settings.dir_music}/%(id)s",
f"https://www.youtube.com/watch?v={utube_id}"],
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE)
@ -82,6 +81,13 @@ class YouTube:
from ircradio.factory import app
import mutagen
if not filepath.endswith('.ogg'):
filepath = filepath + ".ogg"
if not os.path.exists(filepath):
app.logger.error(f"path does not exist: {filepath}")
return
try:
metadata = mutagen.File(filepath)
except Exception as ex:
@ -115,8 +121,9 @@ class YouTube:
title = 'Unknown'
app.logger.warning(f"could not detect artist/title from metadata for {filepath}")
title = title if '-' in title else f"{artist} - {title}"
return {
"name": f"{artist} - {title}",
"name": f"{title}",
"data": metadata,
"duration": duration,
"path": filepath

View File

@ -1,10 +1,14 @@
asyncio-multisubscriber-queue
quart
quart-session
quart-keycloak
yt-dlp
aiofiles
aiohttp
aiocache
redis
bottom
tinytag
peewee
python-dateutil
mutagen
peewee

View File

@ -9,44 +9,154 @@ def bool_env(val):
return val is True or (isinstance(val, str) and (val.lower() == 'true' or val == '1'))
debug = True
debug = False
host = "127.0.0.1"
ws_url = "http://127.0.0.1:2600/ws"
port = 2600
timezone = "Europe/Amsterdam"
redis_uri = os.environ.get('REDIS_URI', 'redis://localhost:6379')
dir_music = os.environ.get("DIR_MUSIC", os.path.join(cwd, "data", "music"))
dir_meta = os.environ.get("DIR_MUSIC", os.path.join(cwd, "data", "music_metadata"))
dir_mixes = "/home/radio/mixes/"
enable_search_route = bool_env(os.environ.get("ENABLE_SEARCH_ROUTE", False))
if not os.path.exists(dir_music):
os.mkdir(dir_music)
if not os.path.exists(dir_meta):
os.mkdir(dir_meta)
irc_admins_nicknames = ["dsc_"]
irc_host = os.environ.get('IRC_HOST', 'localhost')
irc_admins_nicknames = ["dsc_", "qvqc", "lza_menace", "wowario", "scoobybejesus", "JockChamp[m]", "wowario[m]"]
# irc_host = os.environ.get('IRC_HOST', 'irc.OFTC.net')
irc_host = os.environ.get('IRC_HOST', '127.0.0.1')
irc_port = int(os.environ.get('IRC_PORT', 6667))
irc_ssl = bool_env(os.environ.get('IRC_SSL', False)) # untested
irc_nick = os.environ.get('IRC_NICK', 'DJIRC')
irc_channels = os.environ.get('IRC_CHANNELS', '#mychannel').split()
irc_realname = os.environ.get('IRC_REALNAME', 'DJIRC')
irc_nick = os.environ.get('IRC_NICK', 'DjWow')
irc_channels = os.environ.get('IRC_CHANNELS', '#wownero-music').split()
irc_realname = os.environ.get('IRC_REALNAME', 'DjWow')
irc_ignore_pms = False
irc_command_prefix = "!"
icecast2_hostname = "localhost"
icecast2_hostname = "radio.wownero.com"
icecast2_scheme = "https"
icecast2_max_clients = 32
icecast2_bind_host = "127.0.0.1"
icecast2_bind_port = 24100
icecast2_mount = "radio.ogg"
icecast2_source_password = "changeme"
icecast2_admin_password = "changeme"
icecast2_relay_password = "changeme" # for livestreams
icecast2_mount = "wow.ogg"
icecast2_source_password = ""
icecast2_admin_password = ""
icecast2_relay_password = "" # for livestreams
icecast2_live_mount = "live.ogg"
icecast2_logdir = "/var/log/icecast2/"
liquidsoap_host = "127.0.0.1"
liquidsoap_port = 7555 # telnet
liquidsoap_description = "IRC!Radio"
liquidsoap_description = "WOW!Radio"
liquidsoap_samplerate = 48000
liquidsoap_bitrate = 164 # youtube is max 164kbps
liquidsoap_crossfades = False # not implemented yet
liquidsoap_normalize = False # not implemented yet
liquidsoap_iface = icecast2_mount.replace(".", "(dot)")
liquidsoap_max_song_duration = 60 * 11 # seconds
liquidsoap_max_song_duration = 60 * 14 # seconds
re_youtube = r"[a-zA-Z0-9_-]{11}$"
openid_keycloak_config = {
"client_id": "",
"client_secret": "",
"configuration": "https://login.wownero.com/realms/master/.well-known/openid-configuration"
}
from ircradio.station import Station
radio_stations = {
"wow": Station(
id="wow",
music_dir=dir_music,
mount_point="wow.ogg",
request_id="pmain",
title="Radio!WOW",
description="random programming",
image="wow.jpg"
),
"berlin": Station(
id="berlin",
music_dir="/home/radio/mixes/berlin",
mount_point="berlin.ogg",
request_id="pberlin",
title="Berlin",
description="Progressive, techno, minimal, tech-trance",
image="berlin.jpg"
),
"dnb": Station(
id="dnb",
music_dir="/home/radio/mixes/dnb",
mount_point="dnb.ogg",
request_id="pdnb",
title="Drum and Bass",
description="Big up selecta",
image="dnb.jpg"
),
"trance": Station(
id="trance",
music_dir="/home/radio/mixes/trance",
mount_point="trance.ogg",
request_id="ptrance",
title="Trance",
description="du-du-du",
image="trance.jpg"
),
"chiptune": Station(
id="chiptune",
music_dir="/home/radio/mixes/chiptune",
mount_point="chiptune.ogg",
request_id="pchiptune",
title="Chiptune",
description="8-bit, 16-bit, PSG sound chips, consoles, handhelds, demoscene",
image="chiptune.webp"
),
"anju": Station(
id="anju",
music_dir="/home/radio/mixes/anjunadeep",
mount_point="anjunadeep.ogg",
request_id="panjunadeep",
title="Anjunadeep",
description="a collection of the anjunadeep edition podcasts",
image="anjunadeep.jpg"
),
"breaks": Station(
id="breaks",
music_dir="/home/radio/mixes/breaks",
mount_point="breaks.ogg",
request_id="pbreaks",
title="Breakbeat",
description="Breakbeat, breakstep, Florida breaks",
image="breakbeat.webp"
),
"raves": Station(
id="raves",
music_dir="/home/radio/mixes/raves",
mount_point="raves.ogg",
title="90s rave",
request_id="praves",
description="Abandoned warehouses, empty apartment lofts, under bridges, open fields",
image="raves.jpg"
),
"weed": Station(
id="weed",
music_dir="/home/radio/mixes/weed",
mount_point="weed.ogg",
title="Chill vibes 🌿",
description="psybient, psychill, psydub, psyduck <img width=32px height=48px src=\"/static/psyduck.png\">",
image="weed.jpg",
request_id="pweed"
),
"rock": Station(
id="rock",
music_dir="/home/radio/mixes/rock",
mount_point="rock.ogg",
request_id="prock",
title="Rock 🎸",
description="Rock & metal",
image="rock.webp"
)
}