Refactor/rewrite, new frontend, new radio logic

This commit is contained in:
dsc 2023-09-02 21:02:09 +03:00
parent abb67ada08
commit 62c04edc09
40 changed files with 1744 additions and 914 deletions

143
README.md
View File

@ -6,145 +6,6 @@ all your friends. Great fun!
![](https://i.imgur.com/MsGaSr3.png) ![](https://i.imgur.com/MsGaSr3.png)
### Stack # How to install
IRC!Radio aims to be minimalistic/small using: Good luck!
- 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.

175
data/soap.liq_example Normal file
View File

@ -0,0 +1,175 @@
#!/usr/bin/liquidsoap
set("log.stdout", true)
set("log.file",false)
#%include "cross.liq"
# 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,
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,
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,
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,
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,
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,
icy_metadata="true", description="WOW!Radio | Raves",
password = "lel", mount = "raves.ogg",
praves)
output.icecast(%vorbis.cbr(samplerate=48000, channels=2, bitrate=164),
host = "10.7.0.3", port = 24100,
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,
icy_metadata="true", description="WOW!Radio | Weed",
password = "lel", mount = "weed.ogg",
pweed)

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,35 @@
# SPDX-License-Identifier: BSD-3-Clause # SPDX-License-Identifier: BSD-3-Clause
# Copyright (c) 2021, dsc@xmr.pm # Copyright (c) 2021, dsc@xmr.pm
import sys
import collections
from typing import List, Optional from typing import List, Optional
import os import os
import logging import logging
import asyncio import asyncio
from asyncio import Queue
import bottom 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 import settings
from ircradio.radio import Radio 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 from ircradio.youtube import YouTube
import ircradio.models import ircradio.models
app = None app = None
user_agents: List[str] = None user_agents: Optional[List[str]] = None
websocket_sessions = set() websocket_status_bus = MultisubscriberQueue()
download_queue = asyncio.Queue() irc_message_announce_bus = MultisubscriberQueue()
websocket_status_bus_last_item: Optional[dict[str, Station]] = None
irc_bot = None irc_bot = None
price = Price() keycloak = None
soap = Radio() soap = Radio()
# icecast2 = IceCast2()
async def download_thing():
global download_queue
a = await download_queue.get()
e = 1
async def _setup_icecast2(app: Quart): async def _setup_icecast2(app: Quart):
@ -44,45 +44,90 @@ async def _setup_database(app: Quart):
m.create_table() 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())
async def _setup_irc(app: Quart): async def _setup_irc(app: Quart):
global irc_bot global irc_bot
loop = asyncio.get_event_loop() 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 from ircradio.irc import start, message_worker
start() start()
asyncio.create_task(message_worker()) 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): async def _setup_requirements(app: Quart):
ls_reachable = soap.liquidsoap_reachable() ls_reachable = soap.liquidsoap_reachable()
if not ls_reachable: if not ls_reachable:
raise Exception("liquidsoap is not running, please start it first") 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)
def create_app(): def create_app():
global app, soap, icecast2 global app, soap, icecast2
app = Quart(__name__) 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 @app.before_serving
async def startup(): async def startup():
await _setup_requirements(app) global keycloak
await _setup_database(app) await _setup_database(app)
await _setup_user_agents(app) await _setup_cache(app)
await _setup_irc(app) await _setup_irc(app)
await _setup_tasks(app)
import ircradio.routes 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 from ircradio.youtube import YouTube
asyncio.create_task(YouTube.update_loop()) asyncio.create_task(YouTube.update_loop())
#asyncio.create_task(price.wownero_usd_price_loop())
print_banner() print_banner()
return app return app

View File

@ -1,6 +1,6 @@
# SPDX-License-Identifier: BSD-3-Clause # SPDX-License-Identifier: BSD-3-Clause
# Copyright (c) 2021, dsc@xmr.pm # Copyright (c) 2021, dsc@xmr.pm
import sys
from typing import List, Optional from typing import List, Optional
import os import os
import time import time
@ -8,12 +8,15 @@ import asyncio
import random import random
from ircradio.factory import irc_bot as bot from ircradio.factory import irc_bot as bot
from ircradio.radio import Radio from ircradio.station import Station
from ircradio.youtube import YouTube from ircradio.youtube import YouTube
import settings import settings
from settings import radio_stations
radio_default = radio_stations["wow"]
msg_queue = asyncio.Queue() msg_queue = asyncio.Queue()
YT_DLP_LOCK = asyncio.Lock()
DU_LOCK = asyncio.Lock()
async def message_worker(): async def message_worker():
@ -35,11 +38,14 @@ async def connect(**kwargs):
bot.send('NICK', nick=settings.irc_nick) bot.send('NICK', nick=settings.irc_nick)
bot.send('USER', user=settings.irc_nick, realname=settings.irc_realname) 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( done, pending = await asyncio.wait(
[bot.wait("RPL_ENDOFMOTD"), bot.wait("ERR_NOMOTD")], [bot.wait("RPL_ENDOFMOTD"), bot.wait("ERR_NOMOTD")],
loop=bot.loop, **args
return_when=asyncio.FIRST_COMPLETED
) )
# Cancel whichever waiter's event didn't come in. # Cancel whichever waiter's event didn't come in.
@ -66,29 +72,42 @@ def reconnect(**kwargs):
class Commands: class Commands:
LOOKUP = ['np', 'tune', 'boo', 'request', 'dj', LOOKUP = ['np', 'tune', 'boo', 'request', 'dj', 'url', 'urls',
'skip', 'listeners', 'queue', 'skip', 'listeners', 'queue',
'queue_user', 'pop', 'search', 'stats', 'queue_user', 'pop', 'search', 'searchq', 'stats',
'rename', 'ban', 'whoami'] 'rename', 'ban', 'whoami']
@staticmethod @staticmethod
async def np(*args, target=None, nick=None, **kwargs): async def np(*args, target=None, nick=None, **kwargs):
"""current song""" """current song"""
history = Radio.history() radio_station = await Commands._parse_radio_station(args, target)
if not history: if not radio_station:
return
song = await radio_station.np()
if not song:
return await send_message(target, f"Nothing is playing?!") 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})" np = "Now playing"
await send_message(target=target, message=np) 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 @staticmethod
async def tune(*args, target=None, nick=None, **kwargs): async def tune(*args, target=None, nick=None, **kwargs):
"""upvote song""" """upvote song, only wow supported, not mixes"""
history = Radio.history() song = await radio_default.np()
if not history: if not song:
return await send_message(target, f"Nothing is playing?!") return await send_message(target, f"Nothing is playing?!")
song = history[0]
song.karma += 1 song.karma += 1
song.save() song.save()
@ -99,10 +118,9 @@ class Commands:
@staticmethod @staticmethod
async def boo(*args, target=None, nick=None, **kwargs): async def boo(*args, target=None, nick=None, **kwargs):
"""downvote song""" """downvote song"""
history = Radio.history() song = await radio_default.np()
if not history: if not song:
return await send_message(target, f"Nothing is playing?!") return await send_message(target, f"Nothing is playing?!")
song = history[0]
if song.karma >= 1: if song.karma >= 1:
song.karma -= 1 song.karma -= 1
@ -115,9 +133,8 @@ class Commands:
async def request(*args, target=None, nick=None, **kwargs): async def request(*args, target=None, nick=None, **kwargs):
"""request a song by title or YouTube id""" """request a song by title or YouTube id"""
from ircradio.models import Song from ircradio.models import Song
if not args: if not args:
send_message(target=target, message="usage: !request <id>") await send_message(target=target, message="usage: !request <id>")
needle = " ".join(args) needle = " ".join(args)
try: try:
@ -135,35 +152,75 @@ class Commands:
return return
song = songs[0] song = songs[0]
await radio_default.queue_push(song.filepath)
msg = f"Added {song.title} to the queue" msg = f"Added {song.title} to the queue"
Radio.queue(song)
return await send_message(target, msg) return await send_message(target, msg)
@staticmethod @staticmethod
async def search(*args, target=None, nick=None, **kwargs): async def search(*args, target=None, nick=None, **kwargs):
from ircradio.models import Song
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
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""" """search for a title"""
from ircradio.models import Song from ircradio.models import Song
if not args: if not args:
return await send_message(target=target, message="usage: !search <id>") return await send_message(target=target, message="usage: !search <id>")
report_quality = kwargs.get('report_quality')
needle = " ".join(args) 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
songs = Song.search(needle) songs = Song.search(needle)
if not songs: if not songs:
return await send_message(target, "No song(s) found!") return await send_message(target, "No song(s) found!")
if len(songs) == 1: if songs and needle_2nd:
song = songs[0] songs = [s for s in songs if needle_2nd in s.title.lower()]
await send_message(target, f"{song.utube_id} | {song.title}")
else: len_songs = len(songs)
random.shuffle(songs) max_songs = 6
moar = len_songs > max_songs
if len_songs > 1:
await send_message(target, "Multiple found:") 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 @staticmethod
async def dj(*args, target=None, nick=None, **kwargs): 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 from ircradio.models import Song
if not args or args[0] not in ["-", "+"]: if not args or args[0] not in ["-", "+"]:
return await send_message(target, "usage: dj+ <youtube_id>") return await send_message(target, "usage: dj+ <youtube_id>")
@ -174,6 +231,7 @@ class Commands:
return await send_message(target, "YouTube ID not valid.") return await send_message(target, "YouTube ID not valid.")
if add: if add:
async with YT_DLP_LOCK:
try: try:
await send_message(target, f"Scheduled download for '{utube_id}'") await send_message(target, f"Scheduled download for '{utube_id}'")
song = await YouTube.download(utube_id, added_by=nick) song = await YouTube.download(utube_id, added_by=nick)
@ -191,43 +249,64 @@ class Commands:
async def skip(*args, target=None, nick=None, **kwargs): async def skip(*args, target=None, nick=None, **kwargs):
"""skips current song""" """skips current song"""
from ircradio.factory import app 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: try:
Radio.skip() await radio_station.skip()
except Exception as ex: except Exception as ex:
app.logger.error(f"{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 @staticmethod
async def listeners(*args, target=None, nick=None, **kwargs): async def listeners(*args, target=None, nick=None, **kwargs):
"""current amount of listeners""" """current amount of listeners"""
from ircradio.factory import app from ircradio.factory import app
try: radio_station = await Commands._parse_radio_station(args, target)
listeners = await Radio.listeners() if not radio_station:
if listeners: 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" msg = f"{listeners} client"
if listeners >= 2: if listeners >= 2:
msg += "s" msg += "s"
msg += " connected" msg += " connected"
return await send_message(target, msg) 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 @staticmethod
async def queue(*args, target=None, nick=None, **kwargs): async def queue(*args, target=None, nick=None, **kwargs):
"""show currently queued tracks""" """show currently queued tracks"""
from ircradio.models import Song 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: if not q:
return await send_message(target, "queue empty") return await send_message(target, "queue empty")
for i, s in enumerate(q): for i, s in enumerate(q):
await send_message(target, f"{s.utube_id} | {s.title}") await send_message(target, f"{s.utube_id} | {s.title}")
if i >= 12: if i >= 8:
await send_message(target, "And some more...") await send_message(target, "And some more...")
@staticmethod @staticmethod
@ -273,8 +352,8 @@ class Commands:
for i in range(0, 5): for i in range(0, 5):
song = random.choice(songs) song = random.choice(songs)
res = await radio_default.queue(song.filepath)
if Radio.queue(song): if res:
return await send_message(target, f"A random {added_by} has appeared in the queue: {song.title}") return await send_message(target, f"A random {added_by} has appeared in the queue: {song.title}")
await send_message(target, "queue_user exhausted!") await send_message(target, "queue_user exhausted!")
@ -291,8 +370,11 @@ class Commands:
except: except:
pass pass
async with DU_LOCK:
disk = os.popen(f"du -h {settings.dir_music}").read().split("\t")[0] 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 @staticmethod
async def ban(*args, target=None, nick=None, **kwargs): async def ban(*args, target=None, nick=None, **kwargs):
@ -324,6 +406,45 @@ class Commands:
else: else:
await send_message(target, "user") 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') @bot.on('PRIVMSG')
async def message(nick, target, message, **kwargs): async def message(nick, target, message, **kwargs):
@ -370,7 +491,6 @@ async def message(nick, target, message, **kwargs):
await attr(*spl, **data) await attr(*spl, **data)
except Exception as ex: except Exception as ex:
app.logger.error(f"message_worker(): {ex}") app.logger.error(f"message_worker(): {ex}")
pass
def start(): def start():

View File

@ -1,14 +1,21 @@
# SPDX-License-Identifier: BSD-3-Clause # SPDX-License-Identifier: BSD-3-Clause
# Copyright (c) 2021, dsc@xmr.pm # Copyright (c) 2021, dsc@xmr.pm
import functools
import os import os
import logging
import json
import re import re
from typing import Optional, List from typing import Optional, List
from datetime import datetime from datetime import datetime, timedelta
from dataclasses import dataclass
import mutagen import mutagen
from mutagen.oggvorbis import OggVorbisInfo, OggVCommentDict
import aiofiles
from peewee import SqliteDatabase, SQL from peewee import SqliteDatabase, SQL
import peewee as pw import peewee as pw
from quart import current_app
from ircradio.youtube import YouTube from ircradio.youtube import YouTube
import settings import settings
@ -24,16 +31,34 @@ class Ban(pw.Model):
database = db 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): class Song(pw.Model):
id = pw.AutoField() id = pw.AutoField()
date_added = pw.DateTimeField(default=datetime.now) date_added: datetime = pw.DateTimeField(default=datetime.now)
title = pw.CharField(index=True) title: str = pw.CharField(index=True)
utube_id = pw.CharField(index=True, unique=True) utube_id: str = pw.CharField(index=True, unique=True)
added_by = pw.CharField(index=True, constraints=[SQL('COLLATE NOCASE')]) # ILIKE index added_by: str = pw.CharField(index=True, constraints=[SQL('COLLATE NOCASE')]) # ILIKE index
duration = pw.IntegerField() duration: int = pw.IntegerField() # seconds
karma = pw.IntegerField(default=5, index=True) karma: int = pw.IntegerField(default=5, index=True)
banned = pw.BooleanField(default=False) 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 @property
def to_json(self): def to_json(self):
@ -85,42 +110,106 @@ class Song(pw.Model):
except: except:
pass pass
@staticmethod @classmethod
def from_filepath(filepath: str) -> Optional['Song']: async def from_filepath(cls, path: str) -> 'Song':
fn = os.path.basename(filepath) if not os.path.exists(path):
name, ext = fn.split(".", 1) raise Exception("filepath does not exist")
if not YouTube.is_valid_uid(name):
raise Exception("invalid youtube id") # try to detect youtube id in filename
basename = os.path.splitext(os.path.basename(path))[0]
if YouTube.is_valid_uid(basename):
try: try:
return Song.select().filter(utube_id=name).get() song = cls.select().where(Song.utube_id == basename).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
except Exception as ex: 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 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 @property
def filepath(self): def filepath(self):
@ -135,5 +224,61 @@ class Song(pw.Model):
except: except:
return self.filepath 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: class Meta:
database = db database = db

View File

@ -1,7 +1,8 @@
# SPDX-License-Identifier: BSD-3-Clause # SPDX-License-Identifier: BSD-3-Clause
# Copyright (c) 2021, dsc@xmr.pm # Copyright (c) 2021, dsc@xmr.pm
import logging
import re import re
import json
import os import os
import socket import socket
from typing import List, Optional, Dict from typing import List, Optional, Dict
@ -10,161 +11,69 @@ import sys
import settings import settings
from ircradio.models import Song from ircradio.models import Song
from ircradio.station import Station
from ircradio.utils import httpget from ircradio.utils import httpget
from ircradio.youtube import YouTube from ircradio.youtube import YouTube
class Radio: class Radio:
@staticmethod @staticmethod
def queue(song: Song) -> bool: async def icecast_metadata(radio: Station) -> dict:
from ircradio.factory import app cache_key = f"icecast_meta_{radio.id}"
queues = Radio.queues()
queues_filepaths = [s.filepath for s in queues]
if song.filepath in queues_filepaths: from quart import current_app
app.logger.info(f"already added to queue: {song.filepath}") if current_app: # caching only when Quart is active
return False 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"http://{settings.icecast2_bind_host}:{settings.icecast2_bind_port}"
url = f"{url}/status-json.xsl" url = f"{url}/status-json.xsl"
try:
blob = await httpget(url, json=True) blob = await httpget(url, json=True)
if not isinstance(blob, dict) or "icestats" not in blob: if not isinstance(blob, dict) or "icestats" not in blob:
raise Exception("icecast2 metadata not dict") 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: except Exception as ex:
app.logger.error(f"{ex}") raise Exception("no metadata results #2")
@staticmethod @staticmethod
def history() -> Optional[List[Song]]: async def command(cmd: str) -> bytes:
# 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:
"""via LiquidSoap control port""" """via LiquidSoap control port"""
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) from datetime import datetime
sock.connect((settings.liquidsoap_host, settings.liquidsoap_port))
sock.sendall(cmd.encode() + b"\n") if settings.debug:
data = sock.recv(4096*1000) print(f"cmd: {cmd}")
sock.close()
return data
@staticmethod
def liquidsoap_reachable():
from ircradio.factory import app
try: try:
Radio.command("help") reader, writer = await asyncio.open_connection(
settings.liquidsoap_host, settings.liquidsoap_port)
except Exception as ex: except Exception as ex:
app.logger.error("liquidsoap not reachable") raise Exception(f"error connecting to {settings.liquidsoap_host}:{settings.liquidsoap_port}: {ex}")
return False
return True writer.write(cmd.encode() + b"\n")
await writer.drain()
@staticmethod
def now_playing():
try: try:
now_playing = Radio.history() task = reader.readuntil(b"\x0d\x0aEND\x0d\x0a")
if now_playing: data = await asyncio.wait_for(task, 1)
return now_playing[0] except Exception as ex:
except: logging.error(ex)
pass return b""
@staticmethod return data
async def listeners():
data: dict = await Radio.get_icecast_metadata()
if isinstance(data, list):
data = next(s for s in data if s['server_name'].endswith('wow.ogg'))
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

View File

@ -1,9 +1,10 @@
# SPDX-License-Identifier: BSD-3-Clause # SPDX-License-Identifier: BSD-3-Clause
# Copyright (c) 2021, dsc@xmr.pm # Copyright (c) 2021, dsc@xmr.pm
import os, re, dataclasses, random
from datetime import datetime from datetime import datetime
from typing import Tuple, Optional 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 asyncio
import json import json
@ -14,31 +15,15 @@ from ircradio.radio import Radio
@app.route("/") @app.route("/")
async def root(): 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("/login")
async def login():
from ircradio.factory import keycloak
@app.route("/history.txt") if 'auth_token' not in session:
async def history(): return redirect(url_for(keycloak.endpoint_name_login))
global history_cache return redirect('root')
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("/search") @app.route("/search")
@ -89,10 +74,14 @@ async def search():
@app.route("/library") @app.route("/library")
async def user_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 from ircradio.models import Song
name = request.args.get("name") name = request.args.get("name")
if not name: if not name:
abort(404) return await render_template('user.html')
try: try:
by_date = Song.select().filter(Song.added_by == name)\ by_date = Song.select().filter(Song.added_by == name)\
@ -109,34 +98,242 @@ async def user_library():
except: except:
by_karma = [] 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") @app.websocket("/ws")
async def np(): async def ws():
last_song = "" 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: while True:
"""get current song from history""" async for data in websocket_status_bus.subscribe():
history = Radio.history() current_app.logger.debug('sending data to ws peer')
val = "" await send_all(data)
if not history:
val = f"Nothing is playing?!"
else:
song = history[0]
val = song.title
if val != last_song:
data = json.dumps({"now_playing": val})
await websocket.send(f"{data}")
last_song = val @app.route("/assets/art/<path:path>")
await asyncio.sleep(5) async def assets_art(path: str):
img_default = "album_art_default.jpg"
_base = os.path.join(settings.cwd, "ircradio", "static")
if settings.json_songs_route: try:
@app.route(settings.json_songs_route) for _dirname in [settings.dir_meta, settings.dir_music]:
async def songs_json(): _path = os.path.join(_dirname, path)
from ircradio.models import Song if os.path.exists(_path):
data = [] return await send_from_directory(_dirname, path)
for song in Song.select().filter(Song.banned == False): except Exception as ex:
data.append(song.to_json) current_app.logger.debug(ex)
return jsonify(data) 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

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

@ -0,0 +1,24 @@
.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-muted { color: #dedede !important;}
.btn-outline-primary {
color: #5586b7;
border-color: #527ca8;
}
.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;
}
@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

188
ircradio/station.py Normal file
View File

@ -0,0 +1,188 @@
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
liq_meta = await Radio.command(self.telnet_cmd_metadata)
liq_meta = liq_meta.decode(errors="ignore")
liq_filenames = re.findall(r"filename=\"(.*)\"", liq_meta)
liq_filenames = [fn for fn in liq_filenames if os.path.exists(fn)]
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.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"{self.mount_point}.metadata"
@property
def telnet_cmd_remaining(self):
return f"{self.mount_point}.remaining"
@property
def telnet_cmd_skip(self):
return f"{self.mount_point}.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> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<!-- <!--
░░░░░░░█▐▓▓░████▄▄▄█▀▄▓▓▓▌█ very website ░░░░░░░█▐▓▓░████▄▄▄█▀▄▓▓▓▌█ very radio
░░░░░▄█▌▀▄▓▓▄▄▄▄▀▀▀▄▓▓▓▓▓▌█ ░░░░░▄█▌▀▄▓▓▄▄▄▄▀▀▀▄▓▓▓▓▓▌█
░░░▄█▀▀▄▓█▓▓▓▓▓▓▓▓▓▓▓▓▀░▓▌█ ░░░▄█▀▀▄▓█▓▓▓▓▓▓▓▓▓▓▓▓▀░▓▌█
░░█▀▄▓▓▓███▓▓▓███▓▓▓▄░░▄▓▐█▌ such html ░░█▀▄▓▓▓███▓▓▓███▓▓▓▄░░▄▓▐█▌ such html
@ -10,10 +10,11 @@
█▌███▓▓▓▓▓▓▓▓▐░░▄▓▓███▓▓▓▄▀▐█ █▌███▓▓▓▓▓▓▓▓▐░░▄▓▓███▓▓▓▄▀▐█
█▐█▓▀░░▀▓▓▓▓▓▓▓▓▓██████▓▓▓▓▐█ █▐█▓▀░░▀▓▓▓▓▓▓▓▓▓██████▓▓▓▓▐█
▌▓▄▌▀░▀░▐▀█▄▓▓██████████▓▓▓▌█▌ ▌▓▄▌▀░▀░▐▀█▄▓▓██████████▓▓▓▌█▌
▌▓▓▓▄▄▀▀▓▓▓▀▓▓▓▓▓▓▓▓█▓█▓█▓▓▌█▌ many music ▌▓▓▓▄▄▀▀▓▓▓▀▓▓▓▓▓▓▓▓█▓█▓█▓▓▌█▌ much music
█▐▓▓▓▓▓▓▄▄▄▓▓▓▓▓▓█▓█▓█▓█▓▓▓▐█ █▐▓▓▓▓▓▓▄▄▄▓▓▓▓▓▓█▓█▓█▓█▓▓▓▐█
--> -->
<head> <head>
<title>IRC!Radio</title>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
@ -27,40 +28,23 @@
<meta name="application-name" content="IRC!Radio"> <meta name="application-name" content="IRC!Radio">
<meta name="msapplication-TileColor" content="#da532c"> <meta name="msapplication-TileColor" content="#da532c">
<meta name="description" content="IRC!Radio"/> <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"> <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css">
{% if ENABLE_SEARCH_ROUTE or SHOW_PREVIOUS_TRACKS %}
<link rel="stylesheet" href="https://code.jquery.com/ui/1.12.1/themes/base/jquery-ui.css"></link> <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/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> <script src="https://code.jquery.com/ui/1.12.1/jquery-ui.min.js" integrity="sha256-VazP97ZCwtekAsvgPBSUwPFKdrwD3unUfSGVYrahUqU=" crossorigin="anonymous"></script>
{% endif %} <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">
<script> <link rel="stylesheet" href="{{ url_for('static', filename='index.css') }}">
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("#prev_two").innerText = document.querySelector("#prev_one").innerText;
document.querySelector("#prev_one").innerText = document.querySelector("#now_playing").innerText;
document.querySelector("#now_playing").innerText = np;
// console.log(np);
return false;
};
ws.onclose = function(event) {
console.log("WebSocket is closed now.");
};
</script>
</head> </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> <body>
{% block content %} {% endblock %} {% block content %} {% endblock %}
</body> </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,100 +1,234 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block content %} {% block content %}
{% set radio_default = settings.radio_stations['wow'] %}
<!-- Page Content --> <!-- Page Content -->
<div class="container"> <div class="container">
<div class="row"> <div class="row">
<!-- Post Content Column --> {% for rs in radio_stations %}
<div class="col-lg-9"> <div class="col-md-4 col-sm-6 col-xl-3">
<!-- Title --> <div data-radio="{{ rs.id }}" class="card box-shadow mb-4">
<h1 class="mt-4" style="margin-bottom: 2rem;"> <div class="card-header">
IRC!Radio <h5 class="my-0 font-weight-normal">{{ rs.title }}</h5>
</h1> </div>
<p>Enjoy the music :)</p> <img class="img_header card-img-top" src="{{ url_for('static', filename=rs.image) }}" alt="">
<hr> <div class="card-body text-center">
<audio controls src="/{{ settings.icecast2_mount }}">Your browser does not support the<code>audio</code> element.</audio> <h5 class="title_str card-title pricing-card-title text-muted">|</h5>
<p> </p> <ul class="list-unstyled mt-3 mb-4">
<li class="listeners_str">0 listeners</li>
<li class="progress_str text-muted">00:00 / 00:00</li>
</ul>
<h3>Now playing: </h3> <button data-playing="false" data-url="{{ "http://" + settings.icecast2_hostname + "/" + rs.mount_point }}" data-radio="{{ rs.id }}" type="button" class="btn btn-play btn-block btn-outline-primary mb-2">
<div id="now_playing" style="padding-top:6px; margin-bottom:1.75rem;">Nothing here yet</div> <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>
{% if SHOW_PREVIOUS_TRACKS %} {% if logged_in %}
<h5>Previous: </h5> {% if rs.id == radio_default.id %}
<div id="prev_one" style="font-size:12px;">Nothing here yet</div> <div class="btnMeta mb-2" data-url="{{ url_for('api_tune', radio_id=rs.id) }}">
<div id="prev_two" style="font-size:12px;"></div> <button data-playing="false" data-radio="{{ rs.id }}" type="button" class="btn btn-block btn-outline-primary">
{% endif %} <span>Tune</span>
<hr> </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" target="_blank">View in new tab</a>
</div> </div>
<div class="col-md-5"> <div class="btnMeta mb-2" data-url="{{ url_for('api_boo', radio_id=rs.id) }}">
<h4>View User Library <button data-playing="false" data-radio="{{ rs.id }}" type="button" class="btn btn-block btn-outline-primary">
<small style="font-size:12px"></small> <span>Boo</span>
</h4> </button>
<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>
{% 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> </div>
{% endif %} {% endif %}
<hr>
<h4>IRC</h4> <div class="btnMeta mb-2" data-url="{{ url_for('api_skip', radio_id=rs.id) }}">
<pre>{{ settings.irc_host }}:{{ settings.irc_port }} <button data-playing="false" data-radio="{{ rs.id }}" type="button" class="btn btn-block btn-outline-primary">
{{ settings.irc_channels | join(" ") }} <span>Skip</span>
</pre> </button>
</div>
</div>
</div> </div>
{% if ENABLE_SEARCH_ROUTE %}
<script src="static/search.js"></script>
{% endif %} {% endif %}
<small class="footer d-block text-muted mt-3">{{ rs.description | safe }}</small>
</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>
<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 = '{{ url_for('ws', _external=True) }}';
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-primary');
_sel.attr('data-playing', 'true');
_sel.prepend(icon_stop);
} else {
_sel.removeClass('btn-primary');
_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;
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);
sel_radio_cards[rs.id].find('.progress_str').text(song.progress_str);
}
}
}
// 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 %} {% 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="utubeMaker" data-field="utube_id" data-sortable="false"></th>
<th data-field="title" data-sortable="false">Name</th>
<th data-field="added_by" data-sortable="false">User</th>
<th data-field="karma" data-sortable="true">Karma</th>
<th data-formatter="btnMaker" 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="row">
<div class="col-lg-6"> <div class="col-lg-6">
<h5>By date</h5> <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> {% endfor %}</pre>
</div> </div>
<div class="col-lg-6"> <div class="col-lg-6">
<h5>By karma</h5> <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> {% endfor %}</pre>
</div> </div>
</div> </div>
@ -27,5 +27,7 @@
</a> </a>
</div> </div>
</div> </div>
{% include 'footer.html' %}
</div> </div>
{% endblock %} {% endblock %}

View File

@ -11,11 +11,16 @@ import time
import asyncio import asyncio
from asyncio.subprocess import Process from asyncio.subprocess import Process
from io import TextIOWrapper from io import TextIOWrapper
from dataclasses import dataclass
import mutagen
import aiofiles import aiofiles
import aiohttp import aiohttp
import jinja2 import jinja2
from aiocache import cached, Cache
from aiocache.serializers import PickleSerializer
from jinja2 import Environment, PackageLoader, select_autoescape from jinja2 import Environment, PackageLoader, select_autoescape
from quart import current_app
import settings import settings
@ -140,34 +145,6 @@ def systemd_servicefile(
return template.encode() 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): async def httpget(url: str, json=True, timeout: int = 5, raise_for_status=True, verify_tls=True):
headers = {"User-Agent": random_agent()} headers = {"User-Agent": random_agent()}
opts = {"timeout": aiohttp.ClientTimeout(total=timeout)} 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(): def random_agent():
from ircradio.factory import user_agents from ircradio import user_agents
return random.choice(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(): def print_banner():
print("""\033[91m ▪ ▄▄▄ ▄▄· ▄▄▄ ▄▄▄· ·▄▄▄▄ ▪ print("""\033[91m ▪ ▄▄▄ ▄▄· ▄▄▄ ▄▄▄· ·▄▄▄▄ ▪
· · · ·
@ -214,3 +172,82 @@ def print_banner():
. . . .
. · . \033[0m . · . \033[0m
""".strip()) """.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

@ -29,9 +29,7 @@ class YouTube:
if os.path.exists(output): if os.path.exists(output):
song = Song.by_uid(utube_id) song = Song.by_uid(utube_id)
if not song: if not song:
# exists on disk but not in db; add to db raise Exception("exists on disk but not in db")
return Song.from_filepath(output)
raise Exception("Song already exists.") raise Exception("Song already exists.")
try: try:

View File

@ -1,7 +1,12 @@
asyncio-multisubscriber-queue
quart quart
quart-session
quart-keycloak
yt-dlp yt-dlp
aiofiles aiofiles
aiohttp aiohttp
aiocache
redis
bottom bottom
tinytag tinytag
python-dateutil python-dateutil

View File

@ -9,46 +9,150 @@ def bool_env(val):
return val is True or (isinstance(val, str) and (val.lower() == 'true' or val == '1')) return val is True or (isinstance(val, str) and (val.lower() == 'true' or val == '1'))
debug = True debug = False
host = "127.0.0.1" host = "127.0.0.1"
port = 2600 port = 2600
timezone = "Europe/Amsterdam" 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_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"))
if not os.path.exists(dir_music):
os.mkdir(dir_music)
if not os.path.exists(dir_meta):
os.mkdir(dir_meta)
enable_search_route = bool_env(os.environ.get("ENABLE_SEARCH_ROUTE", False)) irc_admins_nicknames = ["dsc_", "qvqc", "lza_menace", "wowario", "scoobybejesus", "JockChamp[m]", "wowario[m]"]
show_previous_tracks = bool_env(os.environ.get("SHOW_PREVIOUS_TRACKS", False)) # irc_host = os.environ.get('IRC_HOST', 'irc.OFTC.net')
irc_host = os.environ.get('IRC_HOST', '127.0.0.1')
irc_admins_nicknames = ["dsc_"]
irc_host = os.environ.get('IRC_HOST', 'localhost')
irc_port = int(os.environ.get('IRC_PORT', 6667)) irc_port = int(os.environ.get('IRC_PORT', 6667))
irc_ssl = bool_env(os.environ.get('IRC_SSL', False)) # untested irc_ssl = bool_env(os.environ.get('IRC_SSL', False)) # untested
irc_nick = os.environ.get('IRC_NICK', 'DJIRC') irc_nick = os.environ.get('IRC_NICK', 'DjWow')
irc_channels = os.environ.get('IRC_CHANNELS', '#mychannel').split() irc_channels = os.environ.get('IRC_CHANNELS', '#wownero-music').split()
irc_realname = os.environ.get('IRC_REALNAME', 'DJIRC') irc_realname = os.environ.get('IRC_REALNAME', 'DjWow')
irc_ignore_pms = False irc_ignore_pms = False
irc_command_prefix = "!" irc_command_prefix = "!"
icecast2_hostname = "localhost" icecast2_hostname = "radio.wownero.com"
icecast2_max_clients = 32 icecast2_max_clients = 32
icecast2_bind_host = "127.0.0.1" icecast2_bind_host = "127.0.0.1"
icecast2_bind_port = 24100 icecast2_bind_port = 24100
icecast2_mount = "radio.ogg" icecast2_mount = "wow.ogg"
icecast2_source_password = "changeme" icecast2_source_password = ""
icecast2_admin_password = "changeme" icecast2_admin_password = ""
icecast2_relay_password = "changeme" # for livestreams icecast2_relay_password = "" # for livestreams
icecast2_live_mount = "live.ogg" icecast2_live_mount = "live.ogg"
icecast2_logdir = "/var/log/icecast2/" icecast2_logdir = "/var/log/icecast2/"
liquidsoap_host = "127.0.0.1" liquidsoap_host = "127.0.0.1"
liquidsoap_port = 7555 # telnet liquidsoap_port = 7555 # telnet
liquidsoap_description = "IRC!Radio" liquidsoap_description = "WOW!Radio"
liquidsoap_samplerate = 48000 liquidsoap_samplerate = 48000
liquidsoap_bitrate = 164 # youtube is max 164kbps liquidsoap_bitrate = 164 # youtube is max 164kbps
liquidsoap_crossfades = False # not implemented yet liquidsoap_crossfades = False # not implemented yet
liquidsoap_normalize = False # not implemented yet liquidsoap_normalize = False # not implemented yet
liquidsoap_iface = icecast2_mount.replace(".", "(dot)") 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}$" re_youtube = r"[a-zA-Z0-9_-]{11}$"
json_songs_route = ""
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"
)
}