Compare commits

..

No commits in common. "46fae8ace25652aedfd1c5ed9ac8e0d80c8ee47e" and "21160e17c55953a3d161850782e445edc1f908b2" have entirely different histories.

41 changed files with 930 additions and 1915 deletions

1
.gitignore vendored
View File

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

143
README.md
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 145 KiB

View File

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

View File

@ -1,24 +0,0 @@
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();
};
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 200 KiB

79
ircradio/static/search.js Normal file
View File

@ -0,0 +1,79 @@
// 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();
});

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

View File

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

View File

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

View File

@ -1,7 +1,7 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<!-- <!--
░░░░░░░█▐▓▓░████▄▄▄█▀▄▓▓▓▌█ very radio ░░░░░░░█▐▓▓░████▄▄▄█▀▄▓▓▓▌█ very website
░░░░░▄█▌▀▄▓▓▄▄▄▄▀▀▀▄▓▓▓▓▓▌█ ░░░░░▄█▌▀▄▓▓▄▄▄▄▀▀▀▄▓▓▓▓▓▌█
░░░▄█▀▀▄▓█▓▓▓▓▓▓▓▓▓▓▓▓▀░▓▌█ ░░░▄█▀▀▄▓█▓▓▓▓▓▓▓▓▓▓▓▓▀░▓▌█
░░█▀▄▓▓▓███▓▓▓███▓▓▓▄░░▄▓▐█▌ such html ░░█▀▄▓▓▓███▓▓▓███▓▓▓▄░░▄▓▐█▌ such html
@ -10,11 +10,10 @@
█▌███▓▓▓▓▓▓▓▓▐░░▄▓▓███▓▓▓▄▀▐█ █▌███▓▓▓▓▓▓▓▓▐░░▄▓▓███▓▓▓▄▀▐█
█▐█▓▀░░▀▓▓▓▓▓▓▓▓▓██████▓▓▓▓▐█ █▐█▓▀░░▀▓▓▓▓▓▓▓▓▓██████▓▓▓▓▐█
▌▓▄▌▀░▀░▐▀█▄▓▓██████████▓▓▓▌█▌ ▌▓▄▌▀░▀░▐▀█▄▓▓██████████▓▓▓▌█▌
▌▓▓▓▄▄▀▀▓▓▓▀▓▓▓▓▓▓▓▓█▓█▓█▓▓▌█▌ much music ▌▓▓▓▄▄▀▀▓▓▓▀▓▓▓▓▓▓▓▓█▓█▓█▓▓▌█▌ many music
█▐▓▓▓▓▓▓▄▄▄▓▓▓▓▓▓█▓█▓█▓█▓▓▓▐█ █▐▓▓▓▓▓▓▄▄▄▓▓▓▓▓▓█▓█▓█▓█▓▓▓▐█
--> -->
<head> <head>
<title>IRC!Radio</title>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
@ -28,22 +27,37 @@
<meta name="application-name" content="IRC!Radio"> <meta name="application-name" content="IRC!Radio">
<meta name="msapplication-TileColor" content="#da532c"> <meta name="msapplication-TileColor" content="#da532c">
<meta name="description" content="IRC!Radio"/> <meta name="description" content="IRC!Radio"/>
<title>IRC!Radio</title>
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css"> <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css">
{% if ENABLE_SEARCH_ROUTE %}
<link rel="stylesheet" href="https://code.jquery.com/ui/1.12.1/themes/base/jquery-ui.css"></link> <link rel="stylesheet" href="https://code.jquery.com/ui/1.12.1/themes/base/jquery-ui.css"></link>
<script src="https://code.jquery.com/jquery-3.6.0.min.js" integrity="sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4=" crossorigin="anonymous"></script> <script src="https://code.jquery.com/jquery-3.6.0.min.js" integrity="sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4=" crossorigin="anonymous"></script>
<script src="https://code.jquery.com/ui/1.12.1/jquery-ui.min.js" integrity="sha256-VazP97ZCwtekAsvgPBSUwPFKdrwD3unUfSGVYrahUqU=" crossorigin="anonymous"></script> <script src="https://code.jquery.com/ui/1.12.1/jquery-ui.min.js" integrity="sha256-VazP97ZCwtekAsvgPBSUwPFKdrwD3unUfSGVYrahUqU=" crossorigin="anonymous"></script>
<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"> {% endif %}
<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"> <script>
<a href="/" style="color: inherit; text-decoration: none;">
<h1 class="display-4 d-inline-flex">Radio!WOW</h1> var ws = new WebSocket('wss://' + document.domain + ':' + location.port + '/ws');
</a>
<p class="lead">Enjoy the music :)</p> ws.onmessage = function (event) {
</div> // console.log(event.data);
json = JSON.parse(event.data);
np = json.now_playing;
document.querySelector("#now_playing").innerText = np;
// console.log(np);
return false;
};
ws.onclose = function(event) {
console.log("WebSocket is closed now.");
};
</script>
</head>
<body> <body>
{% block content %} {% endblock %} {% block content %} {% endblock %}

View File

@ -0,0 +1,75 @@
# 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

@ -1,24 +0,0 @@
<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

@ -1,18 +0,0 @@
{% 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

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

View File

@ -11,13 +11,13 @@
<div class="row"> <div class="row">
<div class="col-lg-6"> <div class="col-lg-6">
<h5>By date</h5> <h5>By date</h5>
<pre style="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}} <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}}
{% endfor %}</pre> {% endfor %}</pre>
</div> </div>
<div class="col-lg-6"> <div class="col-lg-6">
<h5>By karma</h5> <h5>By karma</h5>
<pre style="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}} <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}}
{% endfor %}</pre> {% endfor %}</pre>
</div> </div>
</div> </div>
@ -27,7 +27,5 @@
</a> </a>
</div> </div>
</div> </div>
{% include 'footer.html' %}
</div> </div>
{% endblock %} {% endblock %}

View File

@ -0,0 +1,56 @@
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

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

View File

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

View File

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

View File

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

View File

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