Refactor/rewrite, new frontend, new radio logic

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

143
README.md
View File

@ -6,145 +6,6 @@ all your friends. Great fun!
![](https://i.imgur.com/MsGaSr3.png)
### 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!

175
data/soap.liq_example Normal file
View File

@ -0,0 +1,175 @@
#!/usr/bin/liquidsoap
set("log.stdout", true)
set("log.file",false)
#%include "cross.liq"
# Allow requests from Telnet (Liquidsoap Requester)
set("server.telnet", true)
set("server.telnet.bind_addr", "127.0.0.1")
set("server.telnet.port", 7555)
set("server.telnet.reverse_dns", false)
pmain = playlist(
id="playlist",
timeout=90.0,
mode="random",
reload=300,
reload_mode="seconds",
mime_type="audio/ogg",
"/home/radio/ircradio/data/music"
)
# ==== ANJUNADEEP
panjunadeep = playlist(
id="panjunadeep",
timeout=90.0,
mode="random",
reload=300,
reload_mode="seconds",
mime_type="audio/ogg",
"/home/radio/mixes/anjunadeep/"
)
# ==== BERLIN
pberlin = playlist(
id="berlin",
timeout=90.0,
mode="random",
reload=300,
reload_mode="seconds",
mime_type="audio/ogg",
"/home/radio/mixes/berlin/"
)
# ==== BREAKBEAT
pbreaks = playlist(
id="breaks",
mode="random",
reload=300,
reload_mode="seconds",
mime_type="audio/ogg",
"/home/radio/mixes/breakbeat/"
)
# ==== DNB
pdnb = playlist(
id="dnb",
timeout=90.0,
mode="random",
reload=300,
reload_mode="seconds",
mime_type="audio/ogg",
"/home/radio/mixes/dnb/"
)
# ==== RAVES
praves = playlist(
id="raves",
timeout=90.0,
mode="random",
reload=300,
reload_mode="seconds",
mime_type="audio/ogg",
"/home/radio/mixes/raves/"
)
# ==== TRANCE
ptrance = playlist(
id="trance",
timeout=90.0,
mode="random",
reload=300,
reload_mode="seconds",
mime_type="audio/ogg",
"/home/radio/mixes/trance/"
)
# ==== WEED
pweed = playlist(
id="weed",
timeout=90.0,
mode="random",
reload=300,
reload_mode="seconds",
mime_type="audio/ogg",
"/home/radio/mixes/weed/"
)
req_pmain = request.queue(id="pmain")
req_panjunadeep = request.queue(id="panjunadeep")
req_pberlin = request.queue(id="pberlin")
req_pbreaks = request.queue(id="pbreaks")
req_pdnb = request.queue(id="pdnb")
req_praves = request.queue(id="praves")
req_ptrance = request.queue(id="ptrance")
req_pweed = request.queue(id="pweed")
pmain = fallback(id="switcher",track_sensitive = true, [req_pmain, pmain, blank(duration=5.)])
panjunadeep = fallback(id="switcher",track_sensitive = true, [req_panjunadeep, panjunadeep, blank(duration=5.)])
pberlin = fallback(id="switcher",track_sensitive = true, [req_pberlin, pberlin, blank(duration=5.)])
pbreaks = fallback(id="switcher",track_sensitive = true, [req_pbreaks, pbreaks, blank(duration=5.)])
pdnb = fallback(id="switcher",track_sensitive = true, [req_pdnb, pdnb, blank(duration=5.)])
praves = fallback(id="switcher",track_sensitive = true, [req_praves, praves, blank(duration=5.)])
ptrance = fallback(id="switcher",track_sensitive = true, [req_ptrance, ptrance, blank(duration=5.)])
pweed = fallback(id="switcher",track_sensitive = true, [req_pweed, pweed, blank(duration=5.)])
# iTunes-style (so-called "dumb" - but good enough) crossfading
pmain_crossed = crossfade(pmain)
pmain_crossed = mksafe(pmain_crossed)
output.icecast(%vorbis.cbr(samplerate=48000, channels=2, bitrate=164),
host = "10.7.0.3", port = 24100,
icy_metadata="true", description="WOW!Radio",
password = "lel", mount = "wow.ogg",
pmain_crossed)
output.icecast(%vorbis.cbr(samplerate=48000, channels=2, bitrate=164),
host = "10.7.0.3", port = 24100,
icy_metadata="true", description="WOW!Radio | Anjunadeep",
password = "lel", mount = "anjunadeep.ogg",
panjunadeep)
output.icecast(%vorbis.cbr(samplerate=48000, channels=2, bitrate=164),
host = "10.7.0.3", port = 24100,
icy_metadata="true", description="WOW!Radio | Berlin",
password = "lel", mount = "berlin.ogg",
pberlin)
output.icecast(%vorbis.cbr(samplerate=48000, channels=2, bitrate=164),
host = "10.7.0.3", port = 24100,
icy_metadata="true", description="WOW!Radio | Breakbeat",
password = "lel", mount = "breaks.ogg",
pbreaks)
output.icecast(%vorbis.cbr(samplerate=48000, channels=2, bitrate=164),
host = "10.7.0.3", port = 24100,
icy_metadata="true", description="WOW!Radio | Dnb",
password = "lel", mount = "dnb.ogg",
pdnb)
output.icecast(%vorbis.cbr(samplerate=48000, channels=2, bitrate=164),
host = "10.7.0.3", port = 24100,
icy_metadata="true", description="WOW!Radio | Raves",
password = "lel", mount = "raves.ogg",
praves)
output.icecast(%vorbis.cbr(samplerate=48000, channels=2, bitrate=164),
host = "10.7.0.3", port = 24100,
icy_metadata="true", description="WOW!Radio | Trance",
password = "lel", mount = "trance.ogg",
ptrance)
output.icecast(%vorbis.cbr(samplerate=48000, channels=2, bitrate=164),
host = "10.7.0.3", port = 24100,
icy_metadata="true", description="WOW!Radio | Weed",
password = "lel", mount = "weed.ogg",
pweed)

View File

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

View File

@ -1,35 +1,35 @@
# SPDX-License-Identifier: BSD-3-Clause
# 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

View File

@ -1,6 +1,6 @@
# SPDX-License-Identifier: BSD-3-Clause
# Copyright (c) 2021, dsc@xmr.pm
import sys
from typing import List, Optional
import os
import time
@ -8,12 +8,15 @@ import asyncio
import random
from ircradio.factory import irc_bot as bot
from ircradio.radio import Radio
from ircradio.station import Station
from ircradio.youtube import YouTube
import settings
from settings import radio_stations
radio_default = radio_stations["wow"]
msg_queue = asyncio.Queue()
YT_DLP_LOCK = asyncio.Lock()
DU_LOCK = asyncio.Lock()
async def message_worker():
@ -35,11 +38,14 @@ async def connect(**kwargs):
bot.send('NICK', nick=settings.irc_nick)
bot.send('USER', user=settings.irc_nick, realname=settings.irc_realname)
# Don't try to join channels until server sent MOTD
# @TODO: get rid of this nonsense after a while
args = {"return_when": asyncio.FIRST_COMPLETED}
if sys.version_info.major == 3 and sys.version_info.minor < 10:
args["loop"] = bot.loop
done, pending = await asyncio.wait(
[bot.wait("RPL_ENDOFMOTD"), bot.wait("ERR_NOMOTD")],
loop=bot.loop,
return_when=asyncio.FIRST_COMPLETED
**args
)
# Cancel whichever waiter's event didn't come in.
@ -66,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():

View File

@ -1,14 +1,21 @@
# SPDX-License-Identifier: BSD-3-Clause
# Copyright (c) 2021, dsc@xmr.pm
import functools
import os
import logging
import json
import re
from typing import Optional, List
from datetime import datetime
from datetime import datetime, timedelta
from dataclasses import dataclass
import mutagen
from mutagen.oggvorbis import OggVorbisInfo, OggVCommentDict
import aiofiles
from peewee import SqliteDatabase, SQL
import peewee as pw
from quart import current_app
from ircradio.youtube import YouTube
import settings
@ -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

View File

@ -1,7 +1,8 @@
# SPDX-License-Identifier: BSD-3-Clause
# Copyright (c) 2021, dsc@xmr.pm
import logging
import re
import json
import os
import socket
from typing import List, Optional, Dict
@ -10,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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

BIN
ircradio/static/berlin.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

BIN
ircradio/static/dnb.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

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

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

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

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

BIN
ircradio/static/psyduck.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

BIN
ircradio/static/raves.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

BIN
ircradio/static/rock.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

View File

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

BIN
ircradio/static/trance.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

BIN
ircradio/static/weed.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

BIN
ircradio/static/wow.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

BIN
ircradio/static/wow.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

188
ircradio/station.py Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -9,46 +9,150 @@ def bool_env(val):
return val is True or (isinstance(val, str) and (val.lower() == 'true' or val == '1'))
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 = ""
openid_keycloak_config = {
"client_id": "",
"client_secret": "",
"configuration": "https://login.wownero.com/realms/master/.well-known/openid-configuration"
}
from ircradio.station import Station
radio_stations = {
"wow": Station(
id="wow",
music_dir=dir_music,
mount_point="wow.ogg",
request_id="pmain",
title="Radio!WOW",
description="random programming",
image="wow.jpg"
),
"berlin": Station(
id="berlin",
music_dir="/home/radio/mixes/berlin",
mount_point="berlin.ogg",
request_id="pberlin",
title="Berlin",
description="Progressive, techno, minimal, tech-trance",
image="berlin.jpg"
),
"dnb": Station(
id="dnb",
music_dir="/home/radio/mixes/dnb",
mount_point="dnb.ogg",
request_id="pdnb",
title="Drum and Bass",
description="Big up selecta",
image="dnb.jpg"
),
"trance": Station(
id="trance",
music_dir="/home/radio/mixes/trance",
mount_point="trance.ogg",
request_id="ptrance",
title="Trance",
description="du-du-du",
image="trance.jpg"
),
"chiptune": Station(
id="chiptune",
music_dir="/home/radio/mixes/chiptune",
mount_point="chiptune.ogg",
request_id="pchiptune",
title="Chiptune",
description="8-bit, 16-bit, PSG sound chips, consoles, handhelds, demoscene",
image="chiptune.webp"
),
"anju": Station(
id="anju",
music_dir="/home/radio/mixes/anjunadeep",
mount_point="anjunadeep.ogg",
request_id="panjunadeep",
title="Anjunadeep",
description="a collection of the anjunadeep edition podcasts",
image="anjunadeep.jpg"
),
"breaks": Station(
id="breaks",
music_dir="/home/radio/mixes/breaks",
mount_point="breaks.ogg",
request_id="pbreaks",
title="Breakbeat",
description="Breakbeat, breakstep, Florida breaks",
image="breakbeat.webp"
),
"raves": Station(
id="raves",
music_dir="/home/radio/mixes/raves",
mount_point="raves.ogg",
title="90s rave",
request_id="praves",
description="Abandoned warehouses, empty apartment lofts, under bridges, open fields",
image="raves.jpg"
),
"weed": Station(
id="weed",
music_dir="/home/radio/mixes/weed",
mount_point="weed.ogg",
title="Chill vibes 🌿",
description="psybient, psychill, psydub, psyduck <img width=32px height=48px src=\"/static/psyduck.png\">",
image="weed.jpg",
request_id="pweed"
),
"rock": Station(
id="rock",
music_dir="/home/radio/mixes/rock",
mount_point="rock.ogg",
request_id="prock",
title="Rock 🎸",
description="Rock & metal",
image="rock.webp"
)
}