Refactor/rewrite, new frontend, new radio logic

pull/8/head
dsc 4 weeks ago
parent abb67ada08
commit 62c04edc09

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

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

@ -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()]

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

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

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

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

@ -1,9 +1,10 @@
# SPDX-License-Identifier: BSD-3-Clause
# Copyright (c) 2021, dsc@xmr.pm
import os, re, dataclasses, random
from datetime import datetime
from typing import Tuple, Optional
from quart import request, render_template, abort, jsonify
from quart import request, render_template, abort, jsonify, send_from_directory, current_app, websocket, redirect, session, url_for
import asyncio
import json
@ -14,31 +15,15 @@ from ircradio.radio import Radio
@app.route("/")
async def root():
return await render_template("index.html", settings=settings)
return await render_template("index.html", settings=settings, radio_stations=settings.radio_stations.values())
history_cache: Optional[Tuple] = None
@app.route("/history.txt")
async def history():
global history_cache
now = datetime.now()
if history_cache:
if (now - history_cache[0]).total_seconds() <= 5:
print("from cache")
return history_cache[1]
history = Radio.history()
if not history:
return "no history"
data = ""
for i, s in enumerate(history[:10]):
data += f"{i+1}) <a target=\"_blank\" href=\"https://www.youtube.com/watch?v={s.utube_id}\">{s.utube_id}</a>; {s.title} <br>"
history_cache = [now, data]
return data
@app.route("/login")
async def login():
from ircradio.factory import keycloak
if 'auth_token' not in session:
return redirect(url_for(keycloak.endpoint_name_login))
return redirect('root')
@app.route("/search")
@ -89,10 +74,14 @@ async def search():
@app.route("/library")
async def user_library():
from ircradio.factory import keycloak
if 'auth_token' not in session:
return redirect(url_for(keycloak.endpoint_name_login))
from ircradio.models import Song
name = request.args.get("name")
if not name:
abort(404)
return await render_template('user.html')
try:
by_date = Song.select().filter(Song.added_by == name)\
@ -109,34 +98,242 @@ async def user_library():
except: