diff --git a/README.md b/README.md index 7835d1c..7715529 100644 --- a/README.md +++ b/README.md @@ -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! diff --git a/data/soap.liq_example b/data/soap.liq_example new file mode 100644 index 0000000..216029f --- /dev/null +++ b/data/soap.liq_example @@ -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) diff --git a/ircradio/__init__.py b/ircradio/__init__.py index 5ae656a..4d51799 100644 --- a/ircradio/__init__.py +++ b/ircradio/__init__.py @@ -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()] diff --git a/ircradio/factory.py b/ircradio/factory.py index 830f07b..c4f0c9a 100644 --- a/ircradio/factory.py +++ b/ircradio/factory.py @@ -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 diff --git a/ircradio/irc.py b/ircradio/irc.py index fb23973..23ed0af 100644 --- a/ircradio/irc.py +++ b/ircradio/irc.py @@ -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 ") + await send_message(target=target, message="usage: !request ") 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 ") + 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+ ") @@ -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(): diff --git a/ircradio/models.py b/ircradio/models.py index ad5114c..5de78a5 100644 --- a/ircradio/models.py +++ b/ircradio/models.py @@ -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 = pw.CharField(index=True) - utube_id = pw.CharField(index=True, unique=True) - added_by = pw.CharField(index=True, constraints=[SQL('COLLATE NOCASE')]) # ILIKE index - duration = pw.IntegerField() - karma = pw.IntegerField(default=5, index=True) - banned = pw.BooleanField(default=False) + title: str = pw.CharField(index=True) + utube_id: str = pw.CharField(index=True, unique=True) + added_by: str = pw.CharField(index=True, constraints=[SQL('COLLATE NOCASE')]) # ILIKE index + duration: int = pw.IntegerField() # seconds + karma: int = pw.IntegerField(default=5, index=True) + banned: bool = pw.BooleanField(default=False) + + meta: SongMeta = None # directly from file (exif) or metadata json + path: Optional[str] = None + remaining: int = None # liquidsoap playing status in seconds @property def to_json(self): @@ -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 diff --git a/ircradio/radio.py b/ircradio/radio.py index 37351eb..898c495 100644 --- a/ircradio/radio.py +++ b/ircradio/radio.py @@ -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 - - @staticmethod - def skip() -> None: - Radio.command(f"{settings.liquidsoap_iface}.skip") - - @staticmethod - def queues() -> Optional[List[Song]]: - """get queued songs""" - from ircradio.factory import app - - queues = Radio.command(f"requests.queue") - try: - queues = [q for q in queues.split(b"\r\n") if q != b"END" and q] - if not queues: - return [] - queues = [q.decode() for q in queues[0].split(b" ")] - except Exception as ex: - app.logger.error(str(ex)) - raise Exception("Error") - - paths = [] - for request_id in queues: - meta = Radio.command(f"request.metadata {request_id}") - path = Radio.filenames_from_strlist(meta.decode(errors="ignore").split("\n")) - if path: - paths.append(path[0]) - - songs = [] - for fn in list(dict.fromkeys(paths)): - try: - song = Song.from_filepath(fn) - if not song: - continue - songs.append(song) - except Exception as ex: - app.logger.warning(f"skipping {fn}; file not found or something: {ex}") - - # remove the now playing song from the queue - now_playing = Radio.now_playing() - if songs and now_playing: - if songs[0].filepath == now_playing.filepath: - songs = songs[1:] - return songs - - @staticmethod - async def get_icecast_metadata() -> Optional[Dict]: - from ircradio.factory import app - # http://127.0.0.1:24100/status-json.xsl url = f"http://{settings.icecast2_bind_host}:{settings.icecast2_bind_port}" url = f"{url}/status-json.xsl" + + blob = await httpget(url, json=True) + if not isinstance(blob, dict) or "icestats" not in blob: + raise Exception("icecast2 metadata not dict") + + arr = blob["icestats"].get('source') + if not isinstance(arr, list) or not arr: + raise Exception("no metadata results #1") + 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') + res = next(r for r in arr if radio.mount_point == r['server_name']) + + from quart import current_app + if current_app: # caching only when Quart is active + cache: SessionInterface = current_app.session_interface + await cache.set(cache_key, json.dumps(res), expiry=4) + + return res + except Exception as ex: - app.logger.error(f"{ex}") + raise Exception("no metadata results #2") @staticmethod - def history() -> Optional[List[Song]]: - # 0 = currently playing - from ircradio.factory import app - - try: - status = Radio.command(f"{settings.liquidsoap_iface}.metadata") - status = status.decode(errors="ignore") - except Exception as ex: - app.logger.error(f"{ex}") - raise Exception("failed to contact liquidsoap") - - try: - # paths = re.findall(r"filename=\"(.*)\"", status) - paths = Radio.filenames_from_strlist(status.split("\n")) - # reverse, limit - paths = paths[::-1][:5] - - songs = [] - for fn in list(dict.fromkeys(paths)): - try: - song = Song.from_filepath(fn) - if not song: - continue - songs.append(song) - except Exception as ex: - app.logger.warning(f"skipping {fn}; file not found or something: {ex}") - except Exception as ex: - app.logger.error(f"{ex}") - app.logger.error(f"liquidsoap status:\n{status}") - raise Exception("error parsing liquidsoap status") - return songs - - @staticmethod - def command(cmd: str) -> bytes: + async def command(cmd: str) -> bytes: """via LiquidSoap control port""" - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sock.connect((settings.liquidsoap_host, settings.liquidsoap_port)) - sock.sendall(cmd.encode() + b"\n") - data = sock.recv(4096*1000) - sock.close() - return data + from datetime import datetime + + if settings.debug: + print(f"cmd: {cmd}") - @staticmethod - def liquidsoap_reachable(): - from ircradio.factory import app try: - Radio.command("help") + reader, writer = await asyncio.open_connection( + settings.liquidsoap_host, settings.liquidsoap_port) except Exception as ex: - app.logger.error("liquidsoap not reachable") - return False - return True + raise Exception(f"error connecting to {settings.liquidsoap_host}:{settings.liquidsoap_port}: {ex}") + + writer.write(cmd.encode() + b"\n") + await writer.drain() - @staticmethod - def now_playing(): try: - now_playing = Radio.history() - if now_playing: - return now_playing[0] - except: - pass + task = reader.readuntil(b"\x0d\x0aEND\x0d\x0a") + data = await asyncio.wait_for(task, 1) + except Exception as ex: + logging.error(ex) + return b"" - @staticmethod - async def listeners(): - data: dict = await Radio.get_icecast_metadata() - if 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 + return data diff --git a/ircradio/routes.py b/ircradio/routes.py index 1caeb3a..ea33e5e 100644 --- a/ircradio/routes.py +++ b/ircradio/routes.py @@ -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}) {s.utube_id}; {s.title}
" - - 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: 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/') +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/') +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/') +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/') +async def api_skip(radio_id: str): + from ircradio.models import Song + from ircradio.factory import irc_message_announce_bus + if not radio_id or radio_id not in settings.radio_stations: + return abort(500) + if 'auth_token' not in session: + return abort(403) + user = session['auth_token'] + username = user.get('preferred_username') + + # throttling + cache_key = f"throttle_api_skip_{radio_id}_{username}" + res = await current_app.session_interface.get(cache_key) + if res: + return jsonify({}) # silently fail + + radio_station = settings.radio_stations[radio_id] + await radio_station.skip() + + # set cache + await current_app.session_interface.set(cache_key, b'1', 15) + + hates = ['Booo', 'Rude', 'Wtf'] + msg = f"{username} skipped. {random.choice(hates)}! >:|" + + if radio_station.id == "wow": + await irc_message_announce_bus.put(msg) + + return jsonify({'msg': msg}) + + +@app.route("/history") +async def history(): + from ircradio.factory import keycloak + if 'auth_token' not in session: + return redirect(url_for(keycloak.endpoint_name_login)) + + radio_default = settings.radio_stations['wow'] + songs = await radio_default.history() + if not songs: + return "no history" + + return await render_template('history.html', songs=songs) @app.websocket("/ws") -async def np(): - last_song = "" +async def ws(): + current_app.logger.info('websocket client connected') + from ircradio.factory import websocket_status_bus, websocket_status_bus_last_item + from ircradio.station import Station + + async def send_all(data: dict[str, Station]): + return await websocket.send_json({ + k: dataclasses.asdict(v) for k, v in data.items() + }) + + if isinstance(websocket_status_bus_last_item, dict): + current_app.logger.debug('sending data to ws peer') + await send_all(websocket_status_bus_last_item) + while True: - """get current song from history""" - history = Radio.history() - val = "" - if not history: - val = f"Nothing is playing?!" - else: - song = history[0] - val = song.title + async for data in websocket_status_bus.subscribe(): + current_app.logger.debug('sending data to ws peer') + await send_all(data) - if val != last_song: - data = json.dumps({"now_playing": val}) - await websocket.send(f"{data}") - last_song = val - await asyncio.sleep(5) +@app.route("/assets/art/") +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: - @app.route(settings.json_songs_route) - async def songs_json(): - from ircradio.models import Song - data = [] - for song in Song.select().filter(Song.banned == False): - data.append(song.to_json) - return jsonify(data) \ No newline at end of file + try: + for _dirname in [settings.dir_meta, settings.dir_music]: + _path = os.path.join(_dirname, path) + if os.path.exists(_path): + return await send_from_directory(_dirname, path) + except Exception as ex: + current_app.logger.debug(ex) + return await send_from_directory(_base, img_default), 500 + + return await send_from_directory(_base, img_default), 404 + + +@app.route("/static_music_meta/") +async def static_music_meta(path: str): + return await send_from_directory( + settings.dir_meta, + file_name=path) diff --git a/ircradio/static/album_art_default.jpg b/ircradio/static/album_art_default.jpg new file mode 100644 index 0000000..6df11e4 Binary files /dev/null and b/ircradio/static/album_art_default.jpg differ diff --git a/ircradio/static/anjunadeep.jpg b/ircradio/static/anjunadeep.jpg new file mode 100644 index 0000000..c428f07 Binary files /dev/null and b/ircradio/static/anjunadeep.jpg differ diff --git a/ircradio/static/berlin.jpg b/ircradio/static/berlin.jpg new file mode 100644 index 0000000..43cab12 Binary files /dev/null and b/ircradio/static/berlin.jpg differ diff --git a/ircradio/static/breakbeat.webp b/ircradio/static/breakbeat.webp new file mode 100644 index 0000000..c08277e Binary files /dev/null and b/ircradio/static/breakbeat.webp differ diff --git a/ircradio/static/chiptune.webp b/ircradio/static/chiptune.webp new file mode 100644 index 0000000..4fb6a77 Binary files /dev/null and b/ircradio/static/chiptune.webp differ diff --git a/ircradio/static/dnb.jpg b/ircradio/static/dnb.jpg new file mode 100644 index 0000000..cfcb7d9 Binary files /dev/null and b/ircradio/static/dnb.jpg differ diff --git a/ircradio/static/index.css b/ircradio/static/index.css new file mode 100644 index 0000000..687f66a --- /dev/null +++ b/ircradio/static/index.css @@ -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;} +} \ No newline at end of file diff --git a/ircradio/static/index.js b/ircradio/static/index.js new file mode 100644 index 0000000..8b384d7 --- /dev/null +++ b/ircradio/static/index.js @@ -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(); + }; +} diff --git a/ircradio/static/psyduck.png b/ircradio/static/psyduck.png new file mode 100644 index 0000000..cd9575a Binary files /dev/null and b/ircradio/static/psyduck.png differ diff --git a/ircradio/static/raves.jpg b/ircradio/static/raves.jpg new file mode 100644 index 0000000..9ba6ae3 Binary files /dev/null and b/ircradio/static/raves.jpg differ diff --git a/ircradio/static/rock.webp b/ircradio/static/rock.webp new file mode 100644 index 0000000..71d5142 Binary files /dev/null and b/ircradio/static/rock.webp differ diff --git a/ircradio/static/search.js b/ircradio/static/search.js deleted file mode 100644 index a298594..0000000 --- a/ircradio/static/search.js +++ /dev/null @@ -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 = '' + added + ''; - let title = song.title; - let id = song.utube_id; - let id_link = '' + id + ''; - $('#table tbody').append(''+id_link+''+added_link+''+title+'') - }) - - } else { - setTimeout(renderTable, 30); // try again in 30 milliseconds - } - }; - - renderTable(); - -}); diff --git a/ircradio/static/trance.jpg b/ircradio/static/trance.jpg new file mode 100644 index 0000000..a0be784 Binary files /dev/null and b/ircradio/static/trance.jpg differ diff --git a/ircradio/static/weed.jpg b/ircradio/static/weed.jpg new file mode 100644 index 0000000..a1dac47 Binary files /dev/null and b/ircradio/static/weed.jpg differ diff --git a/ircradio/static/wow.jpg b/ircradio/static/wow.jpg new file mode 100644 index 0000000..78abd55 Binary files /dev/null and b/ircradio/static/wow.jpg differ diff --git a/ircradio/static/wow.png b/ircradio/static/wow.png new file mode 100644 index 0000000..0e7b42b Binary files /dev/null and b/ircradio/static/wow.png differ diff --git a/ircradio/station.py b/ircradio/station.py new file mode 100644 index 0000000..f5c58f1 --- /dev/null +++ b/ircradio/station.py @@ -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}" diff --git a/ircradio/templates/acme.service.jinja2 b/ircradio/templates/acme.service.jinja2 deleted file mode 100644 index 1962c01..0000000 --- a/ircradio/templates/acme.service.jinja2 +++ /dev/null @@ -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 diff --git a/ircradio/templates/base.html b/ircradio/templates/base.html index 4b7ba98..f0b3f7d 100644 --- a/ircradio/templates/base.html +++ b/ircradio/templates/base.html @@ -1,7 +1,7 @@ + IRC!Radio @@ -27,40 +28,23 @@ - IRC!Radio - - {% if ENABLE_SEARCH_ROUTE or SHOW_PREVIOUS_TRACKS %} - - {% endif %} - - - + + + +
+ +

Radio!WOW

+
+

Enjoy the music :)

+
+ {% block content %} {% endblock %} diff --git a/ircradio/templates/cross.liq.jinja2 b/ircradio/templates/cross.liq.jinja2 deleted file mode 100644 index 4028753..0000000 --- a/ircradio/templates/cross.liq.jinja2 +++ /dev/null @@ -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 diff --git a/ircradio/templates/footer.html b/ircradio/templates/footer.html new file mode 100644 index 0000000..4c1ac12 --- /dev/null +++ b/ircradio/templates/footer.html @@ -0,0 +1,24 @@ + \ No newline at end of file diff --git a/ircradio/templates/history.html b/ircradio/templates/history.html new file mode 100644 index 0000000..d2c46c6 --- /dev/null +++ b/ircradio/templates/history.html @@ -0,0 +1,18 @@ +{% extends "base.html" %} +{% block content %} + +
+ +
+
+
History
+
{% for s in songs %}{{s.utube_id}} {{s.title}}
+{% endfor %}
+
+
+ + {% include 'footer.html' %} +
+ + +{% endblock %} diff --git a/ircradio/templates/icecast.xml.jinja2 b/ircradio/templates/icecast.xml.jinja2 deleted file mode 100644 index d95c287..0000000 --- a/ircradio/templates/icecast.xml.jinja2 +++ /dev/null @@ -1,53 +0,0 @@ - - Somewhere - my@email.tld - - - 32 - 2 - 524288 - 30 - 15 - 10 - 0 - 65535 - - - - {{ source_password }} - {{ relay_password }} - admin - {{ admin_password }} - - - {{ hostname }} - - - {{ icecast2_bind_host }} - {{ icecast2_bind_port }} - - - -
- - - 1 - - - /usr/share/icecast2 - {{ log_dir }} - /usr/share/icecast2/web - /usr/share/icecast2/admin - - - - icecast2_access.log - icecast2_error.log - 3 - 10000 - - - - 0 - - \ No newline at end of file diff --git a/ircradio/templates/index.html b/ircradio/templates/index.html index 309d96d..d805e5f 100644 --- a/ircradio/templates/index.html +++ b/ircradio/templates/index.html @@ -1,100 +1,234 @@ {% extends "base.html" %} {% block content %} + +{% set radio_default = settings.radio_stations['wow'] %} +
- -
- -

- IRC!Radio -

-

Enjoy the music :)

-
- -

- -

Now playing:

-
Nothing here yet
- - {% if SHOW_PREVIOUS_TRACKS %} -
Previous:
-
Nothing here yet
-
- {% endif %} -
- -

Command list:

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

History

- View in new tab + {% for rs in radio_stations %} +
+
+
+
{{ rs.title }}
+ +
+
|
+
    +
  • 0 listeners
  • +
  • 00:00 / 00:00
  • +
-
-

View User Library - -

-
-
- -
- + + + {% if logged_in %} + {% if rs.id == radio_default.id %} +
+
+ +
+ +
+ {% endif %} + +
+
- + {% endif %} + + {{ rs.description | safe }}
- {% if ENABLE_SEARCH_ROUTE %} -
-
-
-

Quick Search - (general) -

-
- -
-
-
- - - - - -
-
- {% endif %} -
-

IRC

-
{{ settings.irc_host }}:{{ settings.irc_port }}
-{{ settings.irc_channels | join(" ") }}
-      
+ {% endfor %} + +
+ {% for rs in radio_stations %} + + {% endfor %}
+ + {% include 'footer.html' %}
-{% if ENABLE_SEARCH_ROUTE %} - -{% endif %} + + + {% endblock %} diff --git a/ircradio/templates/nginx.jinja2 b/ircradio/templates/nginx.jinja2 deleted file mode 100644 index 5eea65c..0000000 --- a/ircradio/templates/nginx.jinja2 +++ /dev/null @@ -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; - } -} diff --git a/ircradio/templates/request.html b/ircradio/templates/request.html new file mode 100644 index 0000000..5aea50d --- /dev/null +++ b/ircradio/templates/request.html @@ -0,0 +1,87 @@ +{% extends "base.html" %} +{% block content %} + + + + + + +
+ +
+
+ +
+
+
Request song
+
+
+

Note: Requesting a song can take a minute or two before it starts playing.

+ + + + + + + + + + +
NameUserKarma
+
+
+
+
+ + {% include 'footer.html' %} +
+ +{% endblock %} diff --git a/ircradio/templates/user.html b/ircradio/templates/user.html new file mode 100644 index 0000000..87fdaf3 --- /dev/null +++ b/ircradio/templates/user.html @@ -0,0 +1,25 @@ +{% extends "base.html" %} +{% block content %} + +
+
+
+

View User Library + +

+
+
+ +
+ +
+
+ +
+
+
+ + {% include 'footer.html' %} +
+ +{% endblock %} diff --git a/ircradio/templates/library.html b/ircradio/templates/user_library.html similarity index 56% rename from ircradio/templates/library.html rename to ircradio/templates/user_library.html index 9fd6e07..11fb3b9 100644 --- a/ircradio/templates/library.html +++ b/ircradio/templates/user_library.html @@ -11,13 +11,13 @@
By date
-
{% for s in by_date %}{{s.utube_id}} {{s.title}}
+          
{% for s in by_date %}{{s.utube_id}} {{s.title}}
 {% endfor %}
By karma
-
{% for s in by_karma %}{{s.utube_id}} {{s.karma}} - {{s.title}}
+          
{% for s in by_karma %}{{s.utube_id}} {{s.karma}} - {{s.title}}
 {% endfor %}
@@ -27,5 +27,7 @@
+ + {% include 'footer.html' %}
{% endblock %} \ No newline at end of file diff --git a/ircradio/utils.py b/ircradio/utils.py index 5d96a9a..dbc37a0 100644 --- a/ircradio/utils.py +++ b/ircradio/utils.py @@ -11,11 +11,16 @@ import time import asyncio from asyncio.subprocess import Process from io import TextIOWrapper +from dataclasses import dataclass +import mutagen import aiofiles import aiohttp import jinja2 +from aiocache import cached, Cache +from aiocache.serializers import PickleSerializer from jinja2 import Environment, PackageLoader, select_autoescape +from quart import current_app import settings @@ -140,34 +145,6 @@ def systemd_servicefile( return template.encode() -def liquidsoap_version(): - ls = shutil.which("liquidsoap") - f = os.popen(f"{ls} --version 2>/dev/null").read() - if not f: - print("please install liquidsoap\n\napt install -y liquidsoap") - sys.exit() - - f = f.lower() - match = re.search(r"liquidsoap (\d+.\d+.\d+)", f) - if not match: - return - return match.groups()[0] - - -def liquidsoap_check_symlink(): - msg = """ - Due to a bug you need to create this symlink: - - $ sudo ln -s /usr/share/liquidsoap/ /usr/share/liquidsoap/1.4.1 - - info: https://github.com/savonet/liquidsoap/issues/1224 - """ - version = liquidsoap_version() - if not os.path.exists(f"/usr/share/liquidsoap/{version}"): - print(msg) - sys.exit() - - async def httpget(url: str, json=True, timeout: int = 5, raise_for_status=True, verify_tls=True): headers = {"User-Agent": random_agent()} opts = {"timeout": aiohttp.ClientTimeout(total=timeout)} @@ -184,29 +161,10 @@ async def httpget(url: str, json=True, timeout: int = 5, raise_for_status=True, def random_agent(): - from ircradio.factory import user_agents + from ircradio import user_agents return random.choice(user_agents) -class Price: - def __init__(self): - self.usd = 0.3 - - def calculate(self): - pass - - async def wownero_usd_price_loop(self): - while True: - self.usd = await Price.wownero_usd_price() - asyncio.sleep(1200) - - @staticmethod - async def wownero_usd_price(): - url = "https://api.coingecko.com/api/v3/simple/price?ids=wownero&vs_currencies=usd" - blob = await httpget(url, json=True) - return blob.get('usd', 0) - - def print_banner(): print("""\033[91m ▪ ▄▄▄ ▄▄· ▄▄▄ ▄▄▄· ·▄▄▄▄ ▪ ██ ▀▄ █·▐█ ▌▪▀▄ █·▐█ ▀█ ██▪ ██ ██ ▪ @@ -214,3 +172,82 @@ def print_banner(): ▐█▌▐█•█▌▐███▌▐█•█▌▐█ ▪▐▌██. ██ ▐█▌▐█▌.▐▌ ▀▀▀.▀ ▀·▀▀▀ .▀ ▀ ▀ ▀ ▀▀▀▀▀• ▀▀▀ ▀█▄▀▪\033[0m """.strip()) + + +async def radio_update_task_run_forever(): + while True: + sleep_secs = 15 + try: + sleep_secs = await radio_update_task(sleep_secs) + await asyncio.sleep(sleep_secs) + except Exception as ex: + current_app.logger.error(ex) + await asyncio.sleep(sleep_secs) + + +async def radio_update_task(sleep_secs) -> int: + from ircradio.factory import websocket_status_bus + from ircradio.station import SongDataclass + from ircradio.radio import Radio + if len(websocket_status_bus.subscribers) >= 1: + sleep_secs = 4 + + blob = {} + radio_stations = list(settings.radio_stations.values()) + # radio_stations = radio_stations[1:] + radio_stations = radio_stations[:2] + + for radio_station in radio_stations: + radio_station.song = None + + data = { + 'added_by': 'system', + 'image': None, + 'duration': None, + 'progress': None, + } + + np = await radio_station.np() + if np: + listeners = await radio_station.get_listeners() + if listeners is not None: + radio_station.listeners = listeners + + data['title'] = np.title_cleaned + data['karma'] = np.karma + data['utube_id'] = np.utube_id + data['image'] = np.image() + data['duration'] = np.duration + data['added_by'] = np.added_by + + time_status = np.time_status() + if time_status: + a, b = time_status + pct = percentage(a.seconds, b.seconds) + if pct >= 100: + pct = 100 + data['progress'] = int(pct) + data['progress_str'] = " / ".join(map(str, time_status)) + + radio_station.song = SongDataclass(**data) + blob[radio_station.id] = radio_station + + if blob: + await websocket_status_bus.put(blob) + + return sleep_secs + + +@cached(ttl=3600, cache=Cache.MEMORY, + key_builder=lambda *args, **kw: f"mutagen_file_{args[1]}", + serializer=PickleSerializer()) +async def mutagen_file(path): + from quart import current_app + if current_app: + return await current_app.sync_to_async(mutagen.File)(path) + else: + return mutagen.File(path) + + +def percentage(part, whole): + return 100 * float(part)/float(whole) diff --git a/ircradio/youtube.py b/ircradio/youtube.py index cc1b9fb..bba5db3 100644 --- a/ircradio/youtube.py +++ b/ircradio/youtube.py @@ -29,9 +29,7 @@ class YouTube: if os.path.exists(output): song = Song.by_uid(utube_id) if not song: - # exists on disk but not in db; add to db - return Song.from_filepath(output) - + raise Exception("exists on disk but not in db") raise Exception("Song already exists.") try: diff --git a/requirements.txt b/requirements.txt index 359f909..ea308bb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,12 @@ +asyncio-multisubscriber-queue quart +quart-session +quart-keycloak yt-dlp aiofiles aiohttp +aiocache +redis bottom tinytag python-dateutil diff --git a/settings.py_example b/settings.py_example index 9755ee8..91face5 100644 --- a/settings.py_example +++ b/settings.py_example @@ -9,46 +9,150 @@ def bool_env(val): return val is True or (isinstance(val, str) and (val.lower() == 'true' or val == '1')) -debug = True +debug = False host = "127.0.0.1" port = 2600 timezone = "Europe/Amsterdam" +redis_uri = os.environ.get('REDIS_URI', 'redis://localhost:6379') + dir_music = os.environ.get("DIR_MUSIC", os.path.join(cwd, "data", "music")) +dir_meta = os.environ.get("DIR_MUSIC", os.path.join(cwd, "data", "music_metadata")) +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)) -show_previous_tracks = bool_env(os.environ.get("SHOW_PREVIOUS_TRACKS", False)) - -irc_admins_nicknames = ["dsc_"] -irc_host = os.environ.get('IRC_HOST', 'localhost') +irc_admins_nicknames = ["dsc_", "qvqc", "lza_menace", "wowario", "scoobybejesus", "JockChamp[m]", "wowario[m]"] +# irc_host = os.environ.get('IRC_HOST', 'irc.OFTC.net') +irc_host = os.environ.get('IRC_HOST', '127.0.0.1') irc_port = int(os.environ.get('IRC_PORT', 6667)) irc_ssl = bool_env(os.environ.get('IRC_SSL', False)) # untested -irc_nick = os.environ.get('IRC_NICK', 'DJIRC') -irc_channels = os.environ.get('IRC_CHANNELS', '#mychannel').split() -irc_realname = os.environ.get('IRC_REALNAME', 'DJIRC') +irc_nick = os.environ.get('IRC_NICK', 'DjWow') +irc_channels = os.environ.get('IRC_CHANNELS', '#wownero-music').split() +irc_realname = os.environ.get('IRC_REALNAME', 'DjWow') irc_ignore_pms = False irc_command_prefix = "!" -icecast2_hostname = "localhost" +icecast2_hostname = "radio.wownero.com" icecast2_max_clients = 32 icecast2_bind_host = "127.0.0.1" icecast2_bind_port = 24100 -icecast2_mount = "radio.ogg" -icecast2_source_password = "changeme" -icecast2_admin_password = "changeme" -icecast2_relay_password = "changeme" # for livestreams +icecast2_mount = "wow.ogg" +icecast2_source_password = "" +icecast2_admin_password = "" +icecast2_relay_password = "" # for livestreams icecast2_live_mount = "live.ogg" icecast2_logdir = "/var/log/icecast2/" liquidsoap_host = "127.0.0.1" liquidsoap_port = 7555 # telnet -liquidsoap_description = "IRC!Radio" +liquidsoap_description = "WOW!Radio" liquidsoap_samplerate = 48000 liquidsoap_bitrate = 164 # youtube is max 164kbps liquidsoap_crossfades = False # not implemented yet liquidsoap_normalize = False # not implemented yet liquidsoap_iface = icecast2_mount.replace(".", "(dot)") -liquidsoap_max_song_duration = 60 * 11 # seconds +liquidsoap_max_song_duration = 60 * 14 # seconds re_youtube = r"[a-zA-Z0-9_-]{11}$" -json_songs_route = "" \ No newline at end of file + +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 ", + 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" + ) +}