mirror of
https://github.com/sanderfoobar/ircradio.git
synced 2024-11-21 19:39:42 +01:00
Initial commit
This commit is contained in:
commit
9c2d2b365f
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
.idea
|
||||||
|
data/music/*.jpg
|
||||||
|
data/music/*.webp
|
||||||
|
data/music/*.ogg*
|
||||||
|
__pycache__
|
||||||
|
settings.py
|
148
README.md
Normal file
148
README.md
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
# IRC!Radio
|
||||||
|
|
||||||
|
IRC!Radio is a radio station for IRC channels. You hang around
|
||||||
|
on IRC, adding YouTube songs to the bot, listening to it with
|
||||||
|
all your friends. Great fun!
|
||||||
|
|
||||||
|
### Stack
|
||||||
|
|
||||||
|
IRC!Radio aims to be minimalistic/small using:
|
||||||
|
|
||||||
|
- Python >= 3.7
|
||||||
|
- SQLite
|
||||||
|
- LiquidSoap >= 1.4.3
|
||||||
|
- Icecast2
|
||||||
|
- Quart web framework
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
## Command list
|
||||||
|
|
||||||
|
```text
|
||||||
|
- !np - current song
|
||||||
|
- !tune - upvote song
|
||||||
|
- !boo - downvote song
|
||||||
|
- !request - search and queue a song by title
|
||||||
|
- !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
|
||||||
|
```
|
0
data/.gitkeep
Normal file
0
data/.gitkeep
Normal file
7477
data/agents.txt
Normal file
7477
data/agents.txt
Normal file
File diff suppressed because it is too large
Load Diff
0
data/music/.gitkeep
Normal file
0
data/music/.gitkeep
Normal file
4
ircradio/__init__.py
Normal file
4
ircradio/__init__.py
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
from ircradio.utils import liquidsoap_check_symlink
|
||||||
|
|
||||||
|
|
||||||
|
liquidsoap_check_symlink()
|
88
ircradio/factory.py
Normal file
88
ircradio/factory.py
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
# SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
# Copyright (c) 2021, dsc@xmr.pm
|
||||||
|
|
||||||
|
from typing import List, Optional
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
import bottom
|
||||||
|
from quart import Quart
|
||||||
|
|
||||||
|
import settings
|
||||||
|
from ircradio.radio import Radio
|
||||||
|
from ircradio.utils import Price, print_banner
|
||||||
|
from ircradio.youtube import YouTube
|
||||||
|
import ircradio.models
|
||||||
|
|
||||||
|
app = None
|
||||||
|
user_agents: List[str] = None
|
||||||
|
websocket_sessions = set()
|
||||||
|
download_queue = asyncio.Queue()
|
||||||
|
irc_bot = None
|
||||||
|
price = Price()
|
||||||
|
soap = Radio()
|
||||||
|
# icecast2 = IceCast2()
|
||||||
|
|
||||||
|
|
||||||
|
async def download_thing():
|
||||||
|
global download_queue
|
||||||
|
|
||||||
|
a = await download_queue.get()
|
||||||
|
e = 1
|
||||||
|
|
||||||
|
|
||||||
|
async def _setup_icecast2(app: Quart):
|
||||||
|
global icecast2
|
||||||
|
await icecast2.write_config()
|
||||||
|
|
||||||
|
|
||||||
|
async def _setup_database(app: Quart):
|
||||||
|
import peewee
|
||||||
|
models = peewee.Model.__subclasses__()
|
||||||
|
for m in models:
|
||||||
|
m.create_table()
|
||||||
|
|
||||||
|
|
||||||
|
async def _setup_irc(app: Quart):
|
||||||
|
global irc_bot
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
irc_bot = bottom.Client(host=settings.irc_host, port=settings.irc_port, ssl=settings.irc_ssl, loop=loop)
|
||||||
|
from ircradio.irc import start, message_worker
|
||||||
|
start()
|
||||||
|
|
||||||
|
asyncio.create_task(message_worker())
|
||||||
|
|
||||||
|
|
||||||
|
async def _setup_user_agents(app: Quart):
|
||||||
|
global user_agents
|
||||||
|
with open(os.path.join(settings.cwd, 'data', 'agents.txt'), 'r') as f:
|
||||||
|
user_agents = [l.strip() for l in f.readlines() if l.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
async def _setup_requirements(app: Quart):
|
||||||
|
ls_reachable = soap.liquidsoap_reachable()
|
||||||
|
if not ls_reachable:
|
||||||
|
raise Exception("liquidsoap is not running, please start it first")
|
||||||
|
|
||||||
|
|
||||||
|
def create_app():
|
||||||
|
global app, soap, icecast2
|
||||||
|
app = Quart(__name__)
|
||||||
|
app.logger.setLevel(logging.INFO)
|
||||||
|
|
||||||
|
@app.before_serving
|
||||||
|
async def startup():
|
||||||
|
await _setup_requirements(app)
|
||||||
|
await _setup_database(app)
|
||||||
|
await _setup_user_agents(app)
|
||||||
|
await _setup_irc(app)
|
||||||
|
import ircradio.routes
|
||||||
|
|
||||||
|
from ircradio.youtube import YouTube
|
||||||
|
asyncio.create_task(YouTube.update_loop())
|
||||||
|
#asyncio.create_task(price.wownero_usd_price_loop())
|
||||||
|
|
||||||
|
print_banner()
|
||||||
|
|
||||||
|
return app
|
377
ircradio/irc.py
Normal file
377
ircradio/irc.py
Normal file
@ -0,0 +1,377 @@
|
|||||||
|
# SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
# Copyright (c) 2021, dsc@xmr.pm
|
||||||
|
|
||||||
|
from typing import List, Optional
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import asyncio
|
||||||
|
import random
|
||||||
|
|
||||||
|
from ircradio.factory import irc_bot as bot
|
||||||
|
from ircradio.radio import Radio
|
||||||
|
from ircradio.youtube import YouTube
|
||||||
|
import settings
|
||||||
|
|
||||||
|
|
||||||
|
msg_queue = asyncio.Queue()
|
||||||
|
|
||||||
|
|
||||||
|
async def message_worker():
|
||||||
|
from ircradio.factory import app
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
data: dict = await msg_queue.get()
|
||||||
|
target = data['target']
|
||||||
|
msg = data['message']
|
||||||
|
bot.send("PRIVMSG", target=target, message=msg)
|
||||||
|
except Exception as ex:
|
||||||
|
app.logger.error(f"message_worker(): {ex}")
|
||||||
|
await asyncio.sleep(0.3)
|
||||||
|
|
||||||
|
|
||||||
|
@bot.on('CLIENT_CONNECT')
|
||||||
|
async def connect(**kwargs):
|
||||||
|
bot.send('NICK', nick=settings.irc_nick)
|
||||||
|
bot.send('USER', user=settings.irc_nick, realname=settings.irc_realname)
|
||||||
|
|
||||||
|
# Don't try to join channels until server sent MOTD
|
||||||
|
done, pending = await asyncio.wait(
|
||||||
|
[bot.wait("RPL_ENDOFMOTD"), bot.wait("ERR_NOMOTD")],
|
||||||
|
loop=bot.loop,
|
||||||
|
return_when=asyncio.FIRST_COMPLETED
|
||||||
|
)
|
||||||
|
|
||||||
|
# Cancel whichever waiter's event didn't come in.
|
||||||
|
for future in pending:
|
||||||
|
future.cancel()
|
||||||
|
|
||||||
|
for chan in settings.irc_channels:
|
||||||
|
if chan.startswith("#"):
|
||||||
|
bot.send('JOIN', channel=chan)
|
||||||
|
|
||||||
|
|
||||||
|
@bot.on('PING')
|
||||||
|
def keepalive(message, **kwargs):
|
||||||
|
bot.send('PONG', message=message)
|
||||||
|
|
||||||
|
|
||||||
|
@bot.on('client_disconnect')
|
||||||
|
def reconnect(**kwargs):
|
||||||
|
from ircradio.factory import app
|
||||||
|
app.logger.warning("Lost IRC server connection")
|
||||||
|
time.sleep(3)
|
||||||
|
bot.loop.create_task(bot.connect())
|
||||||
|
app.logger.warning("Reconnecting to IRC server")
|
||||||
|
|
||||||
|
|
||||||
|
class Commands:
|
||||||
|
LOOKUP = ['np', 'tune', 'boo', 'request', 'dj',
|
||||||
|
'skip', 'listeners', 'queue',
|
||||||
|
'queue_user', 'pop', 'search', 'stats',
|
||||||
|
'rename', 'ban', 'whoami']
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def np(*args, target=None, nick=None, **kwargs):
|
||||||
|
"""current song"""
|
||||||
|
history = Radio.history()
|
||||||
|
if not history:
|
||||||
|
return await send_message(target, f"Nothing is playing?!")
|
||||||
|
song = history[0]
|
||||||
|
|
||||||
|
np = f"Now playing: {song.title} (rating: {song.karma}/10; submitter: {song.added_by}; id: {song.utube_id})"
|
||||||
|
await send_message(target=target, message=np)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def tune(*args, target=None, nick=None, **kwargs):
|
||||||
|
"""upvote song"""
|
||||||
|
history = Radio.history()
|
||||||
|
if not history:
|
||||||
|
return await send_message(target, f"Nothing is playing?!")
|
||||||
|
song = history[0]
|
||||||
|
|
||||||
|
if song.karma <= 9:
|
||||||
|
song.karma += 1
|
||||||
|
song.save()
|
||||||
|
|
||||||
|
msg = f"Rating for \"{song.title}\" is {song.karma}/10 .. PARTY ON!!!!"
|
||||||
|
await send_message(target=target, message=msg)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def boo(*args, target=None, nick=None, **kwargs):
|
||||||
|
"""downvote song"""
|
||||||
|
history = Radio.history()
|
||||||
|
if not history:
|
||||||
|
return await send_message(target, f"Nothing is playing?!")
|
||||||
|
song = history[0]
|
||||||
|
|
||||||
|
if song.karma >= 1:
|
||||||
|
song.karma -= 1
|
||||||
|
song.save()
|
||||||
|
|
||||||
|
msg = f"Rating for \"{song.title}\" is {song.karma}/10 .. BOOO!!!!"
|
||||||
|
await send_message(target=target, message=msg)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def request(*args, target=None, nick=None, **kwargs):
|
||||||
|
"""request a song by title or YouTube id"""
|
||||||
|
from ircradio.models import Song
|
||||||
|
|
||||||
|
if not args:
|
||||||
|
send_message(target=target, message="usage: !request <id>")
|
||||||
|
|
||||||
|
needle = " ".join(args)
|
||||||
|
songs = Song.search(needle)
|
||||||
|
if not songs:
|
||||||
|
return await send_message(target, "Not found!")
|
||||||
|
|
||||||
|
if len(songs) >= 2:
|
||||||
|
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
|
||||||
|
|
||||||
|
song = songs[0]
|
||||||
|
msg = f"Added {song.title} to the queue"
|
||||||
|
Radio.queue(song)
|
||||||
|
return await send_message(target, msg)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def search(*args, target=None, nick=None, **kwargs):
|
||||||
|
"""search for a title"""
|
||||||
|
from ircradio.models import Song
|
||||||
|
|
||||||
|
if not args:
|
||||||
|
return await send_message(target=target, message="usage: !search <id>")
|
||||||
|
|
||||||
|
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)
|
||||||
|
await send_message(target, "Multiple found:")
|
||||||
|
for s in songs[:4]:
|
||||||
|
await send_message(target, f"{s.utube_id} | {s.title}")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def dj(*args, target=None, nick=None, **kwargs):
|
||||||
|
"""add (or remove) a YouTube ID to the radiostream"""
|
||||||
|
from ircradio.models import Song
|
||||||
|
if not args or args[0] not in ["-", "+"]:
|
||||||
|
return await send_message(target, "usage: dj+ <youtube_id>")
|
||||||
|
|
||||||
|
add: bool = args[0] == "+"
|
||||||
|
utube_id = args[1]
|
||||||
|
if not YouTube.is_valid_uid(utube_id):
|
||||||
|
return await send_message(target, "YouTube ID not valid.")
|
||||||
|
|
||||||
|
if add:
|
||||||
|
try:
|
||||||
|
await send_message(target, f"Scheduled download for '{utube_id}'")
|
||||||
|
song = await YouTube.download(utube_id, added_by=nick)
|
||||||
|
await send_message(target, f"'{song.title}' added")
|
||||||
|
except Exception as ex:
|
||||||
|
return await send_message(target, f"Download '{utube_id}' failed; {ex}")
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
Song.delete_song(utube_id)
|
||||||
|
await send_message(target, "Press F to pay respects.")
|
||||||
|
except Exception as ex:
|
||||||
|
await send_message(target, f"Failed to remove {utube_id}; {ex}")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def skip(*args, target=None, nick=None, **kwargs):
|
||||||
|
"""skips current song"""
|
||||||
|
from ircradio.factory import app
|
||||||
|
|
||||||
|
try:
|
||||||
|
Radio.skip()
|
||||||
|
except Exception as ex:
|
||||||
|
app.logger.error(f"{ex}")
|
||||||
|
return await send_message(target=target, message="Error")
|
||||||
|
|
||||||
|
await send_message(target, message="Song skipped. Booo! >:|")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def listeners(*args, target=None, nick=None, **kwargs):
|
||||||
|
"""current amount of listeners"""
|
||||||
|
from ircradio.factory import app
|
||||||
|
try:
|
||||||
|
listeners = await Radio.listeners()
|
||||||
|
if listeners:
|
||||||
|
msg = f"{listeners} client"
|
||||||
|
if listeners >= 2:
|
||||||
|
msg += "s"
|
||||||
|
msg += " connected"
|
||||||
|
return await send_message(target, msg)
|
||||||
|
return await send_message(target, f"no listeners, much sad :((")
|
||||||
|
except Exception as ex:
|
||||||
|
app.logger.error(f"{ex}")
|
||||||
|
await send_message(target=target, message="Error")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def queue(*args, target=None, nick=None, **kwargs):
|
||||||
|
"""show currently queued tracks"""
|
||||||
|
from ircradio.models import Song
|
||||||
|
q: List[Song] = Radio.queues()
|
||||||
|
if not q:
|
||||||
|
return await send_message(target, "queue empty")
|
||||||
|
|
||||||
|
for i, s in enumerate(q):
|
||||||
|
await send_message(target, f"{s.utube_id} | {s.title}")
|
||||||
|
if i >= 12:
|
||||||
|
await send_message(target, "And some more...")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def rename(*args, target=None, nick=None, **kwargs):
|
||||||
|
from ircradio.models import Song
|
||||||
|
|
||||||
|
try:
|
||||||
|
utube_id = args[0]
|
||||||
|
title = " ".join(args[1:])
|
||||||
|
if not utube_id or not title or not YouTube.is_valid_uid(utube_id):
|
||||||
|
raise Exception("bad input")
|
||||||
|
except:
|
||||||
|
return await send_message(target, "usage: !rename <id> <new title>")
|
||||||
|
|
||||||
|
try:
|
||||||
|
song = Song.select().where(Song.utube_id == utube_id).get()
|
||||||
|
if not song:
|
||||||
|
raise Exception("Song not found")
|
||||||
|
except Exception as ex:
|
||||||
|
return await send_message(target, "Song not found.")
|
||||||
|
|
||||||
|
if song.added_by != nick and nick not in settings.irc_admins_nicknames:
|
||||||
|
return await send_message(target, "You may only rename your own songs.")
|
||||||
|
|
||||||
|
try:
|
||||||
|
Song.update(title=title).where(Song.utube_id == utube_id).execute()
|
||||||
|
except Exception as ex:
|
||||||
|
return await send_message(target, "Rename failure.")
|
||||||
|
|
||||||
|
await send_message(target, "Song renamed.")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def queue_user(*args, target=None, nick=None, **kwargs):
|
||||||
|
"""queue random song by username"""
|
||||||
|
from ircradio.models import Song
|
||||||
|
|
||||||
|
added_by = args[0]
|
||||||
|
try:
|
||||||
|
q = Song.select().where(Song.added_by ** f"%{added_by}%")
|
||||||
|
songs = [s for s in q]
|
||||||
|
except:
|
||||||
|
return await send_message(target, "No results.")
|
||||||
|
|
||||||
|
for i in range(0, 5):
|
||||||
|
song = random.choice(songs)
|
||||||
|
|
||||||
|
if Radio.queue(song):
|
||||||
|
return await send_message(target, f"A random {added_by} has appeared in the queue: {song.title}")
|
||||||
|
|
||||||
|
await send_message(target, "queue_user exhausted!")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def stats(*args, target=None, nick=None, **kwargs):
|
||||||
|
"""random stats"""
|
||||||
|
songs = 0
|
||||||
|
try:
|
||||||
|
from ircradio.models import db
|
||||||
|
cursor = db.execute_sql('select count(*) from song;')
|
||||||
|
res = cursor.fetchone()
|
||||||
|
songs = res[0]
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
disk = os.popen(f"du -h {settings.dir_music}").read().split("\t")[0]
|
||||||
|
await send_message(target, f"Songs: {songs} | Disk: {disk}")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def ban(*args, target=None, nick=None, **kwargs):
|
||||||
|
"""add (or remove) a YouTube ID ban (admins only)"""
|
||||||
|
if nick not in settings.irc_admins_nicknames:
|
||||||
|
await send_message(target, "You need to be an admin.")
|
||||||
|
return
|
||||||
|
|
||||||
|
from ircradio.models import Song, Ban
|
||||||
|
if not args or args[0] not in ["-", "+"]:
|
||||||
|
return await send_message(target, "usage: ban+ <youtube_id or nickname>")
|
||||||
|
|
||||||
|
try:
|
||||||
|
add: bool = args[0] == "+"
|
||||||
|
arg = args[1]
|
||||||
|
except:
|
||||||
|
return await send_message(target, "usage: ban+ <youtube_id or nickname>")
|
||||||
|
|
||||||
|
if add:
|
||||||
|
Ban.create(utube_id_or_nick=arg)
|
||||||
|
else:
|
||||||
|
Ban.delete().where(Ban.utube_id_or_nick == arg).execute()
|
||||||
|
await send_message(target, "Redemption")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def whoami(*args, target=None, nick=None, **kwargs):
|
||||||
|
if nick in settings.irc_admins_nicknames:
|
||||||
|
await send_message(target, "admin")
|
||||||
|
else:
|
||||||
|
await send_message(target, "user")
|
||||||
|
|
||||||
|
|
||||||
|
@bot.on('PRIVMSG')
|
||||||
|
async def message(nick, target, message, **kwargs):
|
||||||
|
from ircradio.factory import app
|
||||||
|
from ircradio.models import Ban
|
||||||
|
if nick == settings.irc_nick:
|
||||||
|
return
|
||||||
|
if settings.irc_ignore_pms and not target.startswith("#"):
|
||||||
|
return
|
||||||
|
|
||||||
|
if target == settings.irc_nick:
|
||||||
|
target = nick
|
||||||
|
|
||||||
|
msg = message
|
||||||
|
if msg.startswith(settings.irc_command_prefix):
|
||||||
|
msg = msg[len(settings.irc_command_prefix):]
|
||||||
|
|
||||||
|
try:
|
||||||
|
if nick not in settings.irc_admins_nicknames:
|
||||||
|
banned = Ban.select().filter(utube_id_or_nick=nick).get()
|
||||||
|
if banned:
|
||||||
|
return
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"nick": nick,
|
||||||
|
"target": target
|
||||||
|
}
|
||||||
|
|
||||||
|
spl = msg.split(" ")
|
||||||
|
cmd = spl[0].strip()
|
||||||
|
spl = spl[1:]
|
||||||
|
|
||||||
|
if cmd.endswith("+") or cmd.endswith("-"):
|
||||||
|
spl.insert(0, cmd[-1])
|
||||||
|
cmd = cmd[:-1]
|
||||||
|
|
||||||
|
if cmd in Commands.LOOKUP and hasattr(Commands, cmd):
|
||||||
|
attr = getattr(Commands, cmd)
|
||||||
|
try:
|
||||||
|
await attr(*spl, **data)
|
||||||
|
except Exception as ex:
|
||||||
|
app.logger.error(f"message_worker(): {ex}")
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def start():
|
||||||
|
bot.loop.create_task(bot.connect())
|
||||||
|
|
||||||
|
|
||||||
|
async def send_message(target: str, message: str):
|
||||||
|
await msg_queue.put({"target": target, "message": message})
|
127
ircradio/models.py
Normal file
127
ircradio/models.py
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
# SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
# Copyright (c) 2021, dsc@xmr.pm
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from typing import Optional, List
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import mutagen
|
||||||
|
from peewee import SqliteDatabase, SQL
|
||||||
|
import peewee as pw
|
||||||
|
|
||||||
|
from ircradio.youtube import YouTube
|
||||||
|
import settings
|
||||||
|
|
||||||
|
db = SqliteDatabase(f"{settings.cwd}/data/db.sqlite3")
|
||||||
|
|
||||||
|
|
||||||
|
class Ban(pw.Model):
|
||||||
|
id = pw.AutoField()
|
||||||
|
utube_id_or_nick = pw.CharField(index=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
database = db
|
||||||
|
|
||||||
|
class Song(pw.Model):
|
||||||
|
id = pw.AutoField()
|
||||||
|
date_added = pw.DateTimeField(default=datetime.now)
|
||||||
|
|
||||||
|
title = pw.CharField(index=True)
|
||||||
|
utube_id = pw.CharField(index=True, unique=True)
|
||||||
|
added_by = pw.CharField(index=True, constraints=[SQL('COLLATE NOCASE')]) # ILIKE index
|
||||||
|
duration = pw.IntegerField()
|
||||||
|
karma = pw.IntegerField(default=5, index=True)
|
||||||
|
banned = pw.BooleanField(default=False)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def delete_song(utube_id: str) -> bool:
|
||||||
|
from ircradio.factory import app
|
||||||
|
try:
|
||||||
|
fn = f"{settings.dir_music}/{utube_id}.ogg"
|
||||||
|
Song.delete().where(Song.utube_id == utube_id).execute()
|
||||||
|
os.remove(fn)
|
||||||
|
except Exception as ex:
|
||||||
|
app.logger.error(f"{ex}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def search(needle: str, min_chars=3) -> List['Song']:
|
||||||
|
needle = needle.replace("%", "")
|
||||||
|
if len(needle) < min_chars:
|
||||||
|
raise Exception("Search too short. Wow. More typing plz. Much effort.")
|
||||||
|
|
||||||
|
if YouTube.is_valid_uid(needle):
|
||||||
|
try:
|
||||||
|
song = Song.select().filter(Song.utube_id == needle).get()
|
||||||
|
return [song]
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
q = Song.select().filter(Song.title ** f"%{needle}%")
|
||||||
|
return [s for s in q]
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return []
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def by_uid(uid: str) -> Optional['Song']:
|
||||||
|
try:
|
||||||
|
return Song.select().filter(Song.utube_id == uid).get()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def from_filepath(filepath: str) -> Optional['Song']:
|
||||||
|
fn = os.path.basename(filepath)
|
||||||
|
name, ext = fn.split(".", 1)
|
||||||
|
if not YouTube.is_valid_uid(name):
|
||||||
|
raise Exception("invalid youtube id")
|
||||||
|
try:
|
||||||
|
return Song.select().filter(utube_id=name).get()
|
||||||
|
except:
|
||||||
|
return Song.auto_create_from_filepath(filepath)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def auto_create_from_filepath(filepath: str) -> Optional['Song']:
|
||||||
|
from ircradio.factory import app
|
||||||
|
fn = os.path.basename(filepath)
|
||||||
|
uid, ext = fn.split(".", 1)
|
||||||
|
if not YouTube.is_valid_uid(uid):
|
||||||
|
raise Exception("invalid youtube id")
|
||||||
|
|
||||||
|
metadata = YouTube.metadata_from_filepath(filepath)
|
||||||
|
if not metadata:
|
||||||
|
return
|
||||||
|
|
||||||
|
app.logger.info(f"auto-creating for {fn}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
song = Song.create(
|
||||||
|
duration=metadata['duration'],
|
||||||
|
title=metadata['name'],
|
||||||
|
added_by='radio',
|
||||||
|
karma=5,
|
||||||
|
utube_id=uid)
|
||||||
|
return song
|
||||||
|
except Exception as ex:
|
||||||
|
app.logger.error(f"{ex}")
|
||||||
|
pass
|
||||||
|
|
||||||
|
@property
|
||||||
|
def filepath(self):
|
||||||
|
"""Absolute"""
|
||||||
|
return os.path.join(settings.dir_music, f"{self.utube_id}.ogg")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def filepath_noext(self):
|
||||||
|
"""Absolute filepath without extension ... maybe"""
|
||||||
|
try:
|
||||||
|
return os.path.splitext(self.filepath)[0]
|
||||||
|
except:
|
||||||
|
return self.filepath
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
database = db
|
168
ircradio/radio.py
Normal file
168
ircradio/radio.py
Normal file
@ -0,0 +1,168 @@
|
|||||||
|
# SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
# Copyright (c) 2021, dsc@xmr.pm
|
||||||
|
|
||||||
|
import re
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
from typing import List, Optional, Dict
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import settings
|
||||||
|
from ircradio.models import Song
|
||||||
|
from ircradio.utils import httpget
|
||||||
|
from ircradio.youtube import YouTube
|
||||||
|
|
||||||
|
|
||||||
|
class Radio:
|
||||||
|
@staticmethod
|
||||||
|
def queue(song: Song) -> bool:
|
||||||
|
from ircradio.factory import app
|
||||||
|
queues = Radio.queues()
|
||||||
|
queues_filepaths = [s.filepath for s in queues]
|
||||||
|
|
||||||
|
if song.filepath in queues_filepaths:
|
||||||
|
app.logger.info(f"already added to queue: {song.filepath}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
Radio.command(f"requests.push {song.filepath}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def skip() -> None:
|
||||||
|
Radio.command(f"{settings.liquidsoap_iface}.skip")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def queues() -> Optional[List[Song]]:
|
||||||
|
"""get queued songs"""
|
||||||
|
from ircradio.factory import app
|
||||||
|
|
||||||
|
queues = Radio.command(f"requests.queue")
|
||||||
|
try:
|
||||||
|
queues = [q for q in queues.split(b"\r\n") if q != b"END" and q]
|
||||||
|
if not queues:
|
||||||
|
return []
|
||||||
|
queues = [q.decode() for q in queues[0].split(b" ")]
|
||||||
|
except Exception as ex:
|
||||||
|
app.logger.error(str(ex))
|
||||||
|
raise Exception("Error")
|
||||||
|
|
||||||
|
paths = []
|
||||||
|
for request_id in queues:
|
||||||
|
meta = Radio.command(f"request.metadata {request_id}")
|
||||||
|
path = Radio.filenames_from_strlist(meta.decode(errors="ignore").split("\n"))
|
||||||
|
if path:
|
||||||
|
paths.append(path[0])
|
||||||
|
|
||||||
|
songs = []
|
||||||
|
for fn in list(dict.fromkeys(paths)):
|
||||||
|
try:
|
||||||
|
song = Song.from_filepath(fn)
|
||||||
|
if not song:
|
||||||
|
continue
|
||||||
|
songs.append(song)
|
||||||
|
except Exception as ex:
|
||||||
|
app.logger.warning(f"skipping {fn}; file not found or something: {ex}")
|
||||||
|
|
||||||
|
# remove the now playing song from the queue
|
||||||
|
now_playing = Radio.now_playing()
|
||||||
|
if songs and now_playing:
|
||||||
|
if songs[0].filepath == now_playing.filepath:
|
||||||
|
songs = songs[1:]
|
||||||
|
return songs
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def get_icecast_metadata() -> Optional[Dict]:
|
||||||
|
from ircradio.factory import app
|
||||||
|
# http://127.0.0.1:24100/status-json.xsl
|
||||||
|
url = f"http://{settings.icecast2_bind_host}:{settings.icecast2_bind_port}"
|
||||||
|
url = f"{url}/status-json.xsl"
|
||||||
|
try:
|
||||||
|
blob = await httpget(url, json=True)
|
||||||
|
if not isinstance(blob, dict) or "icestats" not in blob:
|
||||||
|
raise Exception("icecast2 metadata not dict")
|
||||||
|
return blob["icestats"].get('source')
|
||||||
|
except Exception as ex:
|
||||||
|
app.logger.error(f"{ex}")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def history() -> Optional[List[Song]]:
|
||||||
|
# 0 = currently playing
|
||||||
|
from ircradio.factory import app
|
||||||
|
|
||||||
|
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"""
|
||||||
|
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
|
sock.connect((settings.liquidsoap_host, settings.liquidsoap_port))
|
||||||
|
sock.sendall(cmd.encode() + b"\n")
|
||||||
|
data = sock.recv(4096*1000)
|
||||||
|
sock.close()
|
||||||
|
return data
|
||||||
|
|
||||||
|
@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
|
64
ircradio/routes.py
Normal file
64
ircradio/routes.py
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
# SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
# Copyright (c) 2021, dsc@xmr.pm
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Tuple, Optional
|
||||||
|
from quart import request, render_template, abort
|
||||||
|
|
||||||
|
import settings
|
||||||
|
from ircradio.factory import app
|
||||||
|
from ircradio.radio import Radio
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/")
|
||||||
|
async def root():
|
||||||
|
return await render_template("index.html", settings=settings)
|
||||||
|
|
||||||
|
|
||||||
|
history_cache: Optional[Tuple] = None
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/history.txt")
|
||||||
|
async def history():
|
||||||
|
global history_cache
|
||||||
|
now = datetime.now()
|
||||||
|
if history_cache:
|
||||||
|
if (now - history_cache[0]).total_seconds() <= 5:
|
||||||
|
print("from cache")
|
||||||
|
return history_cache[1]
|
||||||
|
|
||||||
|
history = Radio.history()
|
||||||
|
if not history:
|
||||||
|
return "no history"
|
||||||
|
|
||||||
|
data = ""
|
||||||
|
for i, s in enumerate(history[:10]):
|
||||||
|
data += f"{i+1}) <a target=\"_blank\" href=\"https://www.youtube.com/watch?v={s.utube_id}\">{s.utube_id}</a>; {s.title} <br>"
|
||||||
|
|
||||||
|
history_cache = [now, data]
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/library")
|
||||||
|
async def user_library():
|
||||||
|
from ircradio.models import Song
|
||||||
|
name = request.args.get("name")
|
||||||
|
if not name:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
try:
|
||||||
|
by_date = Song.select().filter(Song.added_by == name)\
|
||||||
|
.order_by(Song.date_added.desc())
|
||||||
|
except:
|
||||||
|
by_date = []
|
||||||
|
|
||||||
|
if not by_date:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
try:
|
||||||
|
by_karma = Song.select().filter(Song.added_by == name)\
|
||||||
|
.order_by(Song.karma.desc())
|
||||||
|
except:
|
||||||
|
by_karma = []
|
||||||
|
|
||||||
|
return await render_template("library.html", name=name, by_date=by_date, by_karma=by_karma)
|
17
ircradio/templates/acme.service.jinja2
Normal file
17
ircradio/templates/acme.service.jinja2
Normal 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
|
38
ircradio/templates/base.html
Normal file
38
ircradio/templates/base.html
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<!--
|
||||||
|
░░░░░░░█▐▓▓░████▄▄▄█▀▄▓▓▓▌█ very website
|
||||||
|
░░░░░▄█▌▀▄▓▓▄▄▄▄▀▀▀▄▓▓▓▓▓▌█
|
||||||
|
░░░▄█▀▀▄▓█▓▓▓▓▓▓▓▓▓▓▓▓▀░▓▌█
|
||||||
|
░░█▀▄▓▓▓███▓▓▓███▓▓▓▄░░▄▓▐█▌ such html
|
||||||
|
░█▌▓▓▓▀▀▓▓▓▓███▓▓▓▓▓▓▓▄▀▓▓▐█
|
||||||
|
▐█▐██▐░▄▓▓▓▓▓▀▄░▀▓▓▓▓▓▓▓▓▓▌█▌ WOW
|
||||||
|
█▌███▓▓▓▓▓▓▓▓▐░░▄▓▓███▓▓▓▄▀▐█
|
||||||
|
█▐█▓▀░░▀▓▓▓▓▓▓▓▓▓██████▓▓▓▓▐█
|
||||||
|
▌▓▄▌▀░▀░▐▀█▄▓▓██████████▓▓▓▌█▌
|
||||||
|
▌▓▓▓▄▄▀▀▓▓▓▀▓▓▓▓▓▓▓▓█▓█▓█▓▓▌█▌ many music
|
||||||
|
█▐▓▓▓▓▓▓▄▄▄▓▓▓▓▓▓█▓█▓█▓█▓▓▓▐█
|
||||||
|
-->
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
|
||||||
|
<meta name="HandheldFriendly" content="True">
|
||||||
|
<meta name="MobileOptimized" content="320">
|
||||||
|
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||||
|
<meta name="theme-color" content="#ffffff">
|
||||||
|
<meta name="apple-mobile-web-app-title" content="IRC!Radio">
|
||||||
|
<meta name="application-name" content="IRC!Radio">
|
||||||
|
<meta name="msapplication-TileColor" content="#da532c">
|
||||||
|
<meta name="description" content="IRC!Radio"/>
|
||||||
|
<title>IRC!Radio</title>
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css">
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
{% block content %} {% endblock %}
|
||||||
|
</body>
|
||||||
|
</html>
|
75
ircradio/templates/cross.liq.jinja2
Normal file
75
ircradio/templates/cross.liq.jinja2
Normal 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
|
53
ircradio/templates/icecast.xml.jinja2
Normal file
53
ircradio/templates/icecast.xml.jinja2
Normal 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>
|
65
ircradio/templates/index.html
Normal file
65
ircradio/templates/index.html
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<!-- Page Content -->
|
||||||
|
<div class="container">
|
||||||
|
<div class="row">
|
||||||
|
<!-- Post Content Column -->
|
||||||
|
<div class="col-lg-12">
|
||||||
|
<!-- Title -->
|
||||||
|
<h1 class="mt-4" style="margin-bottom: 2rem;">
|
||||||
|
IRC!Radio
|
||||||
|
</h1>
|
||||||
|
<p>Enjoy the music :)</p>
|
||||||
|
<hr>
|
||||||
|
<audio controls src="/{{ settings.icecast2_mount }}">Your browser does not support the<code>audio</code> element.</audio>
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<h4>Command list:</h4>
|
||||||
|
<pre style="font-size:12px;">!np - current song
|
||||||
|
!tune - upvote song
|
||||||
|
!boo - downvote song
|
||||||
|
!request - search and queue a song by title
|
||||||
|
!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 class="col-md-3">
|
||||||
|
<h4>Library
|
||||||
|
<small style="font-size:12px">(by user)</small>
|
||||||
|
</h4>
|
||||||
|
<form method="GET" action="/library">
|
||||||
|
<div class="input-group mb-3">
|
||||||
|
<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 class="col-md-3"></div>
|
||||||
|
</div>
|
||||||
|
<hr>
|
||||||
|
<h4>IRC</h4>
|
||||||
|
<pre>{{ settings.irc_host }}:{{ settings.irc_port }}
|
||||||
|
{{ settings.irc_channels | join(" ") }}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
31
ircradio/templates/library.html
Normal file
31
ircradio/templates/library.html
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<!-- Page Content -->
|
||||||
|
<div class="container">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-lg-12">
|
||||||
|
<h1 class="mt-4" style="margin-bottom: 2rem;">
|
||||||
|
Library for {{ name }}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-lg-6">
|
||||||
|
<h5>By date</h5>
|
||||||
|
<pre style="font-size:12px;">{% for s in by_date %}<a target="_blank" href="https://www.youtube.com/watch?v={{s.utube_id}}">{{s.utube_id}}</a> {{s.title}}
|
||||||
|
{% endfor %}</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-lg-6">
|
||||||
|
<h5>By karma</h5>
|
||||||
|
<pre style="font-size:12px;">{% for s in by_karma %}<a target="_blank" href="https://www.youtube.com/watch?v={{s.utube_id}}">{{s.karma}}; {{s.title}}</a>
|
||||||
|
{% endfor %}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a href="/">
|
||||||
|
<button type="button" class="btn btn-primary btn-sm">Go back</button>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
48
ircradio/templates/nginx.jinja2
Normal file
48
ircradio/templates/nginx.jinja2
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
49
ircradio/templates/soap.liq.jinja2
Normal file
49
ircradio/templates/soap.liq.jinja2
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
#!/usr/bin/liquidsoap
|
||||||
|
set("log.stdout", true)
|
||||||
|
set("log.file",false)
|
||||||
|
%include "cross.liq"
|
||||||
|
|
||||||
|
# Allow requests from Telnet (Liquidsoap Requester)
|
||||||
|
set("server.telnet", true)
|
||||||
|
set("server.telnet.bind_addr", "{{ liquidsoap_host }}")
|
||||||
|
set("server.telnet.port", {{ liquidsoap_port }})
|
||||||
|
set("server.telnet.reverse_dns", false)
|
||||||
|
|
||||||
|
# WOW's station track auto-playlist
|
||||||
|
#+ randomized track playback from the playlist path
|
||||||
|
#+ play a new random track each time LS performs select()
|
||||||
|
#+ 90-second timeout on remote track preparation processes
|
||||||
|
#+ 1.0-hour maximum file length (in case things "run away")
|
||||||
|
#+ 0.5-hour default file length (in case things "run away")
|
||||||
|
plist = playlist(
|
||||||
|
id="playlist",
|
||||||
|
length=30.0,
|
||||||
|
default_duration=30.0,
|
||||||
|
timeout=90.0,
|
||||||
|
mode="random",
|
||||||
|
reload=300,
|
||||||
|
reload_mode="seconds",
|
||||||
|
mime_type="audio/ogg",
|
||||||
|
"{{ dir_music }}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Request Queue from Telnet (Liquidsoap Requester)
|
||||||
|
requests = request.queue(id="requests")
|
||||||
|
|
||||||
|
# Start building the feed with music
|
||||||
|
radio = plist
|
||||||
|
|
||||||
|
# Add in our on-disk security
|
||||||
|
radio = fallback(id="switcher",track_sensitive = true, [requests, radio, blank(duration=5.)])
|
||||||
|
|
||||||
|
# uncomment to normalize the audio stream
|
||||||
|
#radio = normalize(radio)
|
||||||
|
|
||||||
|
# iTunes-style (so-called "dumb" - but good enough) crossfading
|
||||||
|
full = smart_crossfade(start_next=8., fade_in=6., fade_out=6., width=2., conservative=true, radio)
|
||||||
|
|
||||||
|
output.icecast(%vorbis.cbr(samplerate=48000, channels=2, bitrate=164),
|
||||||
|
host = "{{ icecast2_bind_host }}", port = {{ icecast2_bind_port }},
|
||||||
|
icy_metadata="true", description="{{ liquidsoap_description }}",
|
||||||
|
password = "{{ icecast2_source_password }}", mount = "{{ icecast2_mount }}",
|
||||||
|
full)
|
216
ircradio/utils.py
Normal file
216
ircradio/utils.py
Normal file
@ -0,0 +1,216 @@
|
|||||||
|
# SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
# Copyright (c) 2021, dsc@xmr.pm
|
||||||
|
|
||||||
|
from typing import List, Optional, Union
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import random
|
||||||
|
import time
|
||||||
|
import asyncio
|
||||||
|
from asyncio.subprocess import Process
|
||||||
|
from io import TextIOWrapper
|
||||||
|
|
||||||
|
import aiofiles
|
||||||
|
import aiohttp
|
||||||
|
import jinja2
|
||||||
|
from jinja2 import Environment, PackageLoader, select_autoescape
|
||||||
|
|
||||||
|
import settings
|
||||||
|
|
||||||
|
|
||||||
|
class AsyncSubProcess(object):
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
self.proc: Process = None
|
||||||
|
self.max_buffer: int = 1000
|
||||||
|
self.buffer = []
|
||||||
|
|
||||||
|
@property
|
||||||
|
async def is_running(self) -> bool:
|
||||||
|
return self.proc and self.proc.returncode is None
|
||||||
|
|
||||||
|
async def run(self, args: List[str], ws_type_prefix: str):
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
read_stdout, write_stdout = os.pipe()
|
||||||
|
read_stderr, write_stderr = os.pipe()
|
||||||
|
self.proc = await asyncio.create_subprocess_exec(
|
||||||
|
*args,
|
||||||
|
stdin=asyncio.subprocess.PIPE,
|
||||||
|
stdout=write_stdout,
|
||||||
|
stderr=write_stderr,
|
||||||
|
cwd=settings.cwd
|
||||||
|
)
|
||||||
|
|
||||||
|
os.close(write_stdout)
|
||||||
|
os.close(write_stderr)
|
||||||
|
|
||||||
|
f_stdout = os.fdopen(read_stdout, "r")
|
||||||
|
f_stderr = os.fdopen(read_stderr, "r")
|
||||||
|
|
||||||
|
try:
|
||||||
|
await asyncio.gather(
|
||||||
|
self.consume(fd=f_stdout, _type='stdout', _type_prefix=ws_type_prefix),
|
||||||
|
self.consume(fd=f_stderr, _type='stderr', _type_prefix=ws_type_prefix),
|
||||||
|
self.proc.communicate()
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
f_stdout.close()
|
||||||
|
f_stderr.close()
|
||||||
|
|
||||||
|
async def consume(self, fd: TextIOWrapper, _type: str, _type_prefix: str):
|
||||||
|
from ircradio.factory import app
|
||||||
|
import wow.websockets as websockets
|
||||||
|
_type_int = 0 if _type == "stdout" else 1
|
||||||
|
|
||||||
|
reader = asyncio.StreamReader()
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
await loop.connect_read_pipe(
|
||||||
|
lambda: asyncio.StreamReaderProtocol(reader),
|
||||||
|
fd
|
||||||
|
)
|
||||||
|
|
||||||
|
async for line in reader:
|
||||||
|
line = line.strip()
|
||||||
|
msg = line.decode(errors="ignore")
|
||||||
|
_logger = app.logger.info if _type_int == 0 else app.logger.error
|
||||||
|
_logger(msg)
|
||||||
|
|
||||||
|
self.buffer.append((int(time.time()), _type_int, msg))
|
||||||
|
if len(self.buffer) >= self.max_buffer:
|
||||||
|
self.buffer.pop(0)
|
||||||
|
|
||||||
|
await websockets.broadcast(
|
||||||
|
message=line,
|
||||||
|
message_type=f"{_type_prefix}_{_type}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def loopyloop(secs: int, func, after_func=None):
|
||||||
|
while True:
|
||||||
|
result = await func()
|
||||||
|
if after_func:
|
||||||
|
await after_func(result)
|
||||||
|
await asyncio.sleep(secs)
|
||||||
|
|
||||||
|
|
||||||
|
def jinja2_render(template_name: str, **data):
|
||||||
|
loader = jinja2.FileSystemLoader(searchpath=[
|
||||||
|
os.path.join(settings.cwd, "utils"),
|
||||||
|
os.path.join(settings.cwd, "ircradio/templates")
|
||||||
|
])
|
||||||
|
env = jinja2.Environment(loader=loader, autoescape=select_autoescape())
|
||||||
|
template = env.get_template(template_name)
|
||||||
|
return template.render(**data)
|
||||||
|
|
||||||
|
|
||||||
|
async def write_file(fn: str, data: Union[str, bytes], mode="w"):
|
||||||
|
async with aiofiles.open(fn, mode=mode) as f:
|
||||||
|
f.write(data)
|
||||||
|
|
||||||
|
|
||||||
|
def write_file_sync(fn: str, data: bytes):
|
||||||
|
f = open(fn, "wb")
|
||||||
|
f.write(data)
|
||||||
|
f.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def executeSQL(sql: str, params: tuple = None):
|
||||||
|
from ircradio.factory import db
|
||||||
|
async with db.pool.acquire() as connection:
|
||||||
|
async with connection.transaction():
|
||||||
|
result = connection.fetch(sql, params)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def systemd_servicefile(
|
||||||
|
name: str, description: str, user: str, group: str,
|
||||||
|
path_executable: str, args_executable: str, env: str = None
|
||||||
|
) -> bytes:
|
||||||
|
template = jinja2_render(
|
||||||
|
"acme.service.jinja2",
|
||||||
|
name=name,
|
||||||
|
description=description,
|
||||||
|
user=user,
|
||||||
|
group=group,
|
||||||
|
env=env,
|
||||||
|
path_executable=path_executable,
|
||||||
|
args_executable=args_executable
|
||||||
|
)
|
||||||
|
return template.encode()
|
||||||
|
|
||||||
|
|
||||||
|
def liquidsoap_version():
|
||||||
|
ls = shutil.which("liquidsoap")
|
||||||
|
f = os.popen(f"{ls} --version 2>/dev/null").read()
|
||||||
|
if not f:
|
||||||
|
print("please install liquidsoap\n\napt install -y liquidsoap")
|
||||||
|
sys.exit()
|
||||||
|
|
||||||
|
f = f.lower()
|
||||||
|
match = re.search(r"liquidsoap (\d+.\d+.\d+)", f)
|
||||||
|
if not match:
|
||||||
|
return
|
||||||
|
return match.groups()[0]
|
||||||
|
|
||||||
|
|
||||||
|
def liquidsoap_check_symlink():
|
||||||
|
msg = """
|
||||||
|
Due to a bug you need to create this symlink:
|
||||||
|
|
||||||
|
$ sudo ln -s /usr/share/liquidsoap/ /usr/share/liquidsoap/1.4.1
|
||||||
|
|
||||||
|
info: https://github.com/savonet/liquidsoap/issues/1224
|
||||||
|
"""
|
||||||
|
version = liquidsoap_version()
|
||||||
|
if not os.path.exists(f"/usr/share/liquidsoap/{version}"):
|
||||||
|
print(msg)
|
||||||
|
sys.exit()
|
||||||
|
|
||||||
|
|
||||||
|
async def httpget(url: str, json=True, timeout: int = 5, raise_for_status=True, verify_tls=True):
|
||||||
|
headers = {"User-Agent": random_agent()}
|
||||||
|
opts = {"timeout": aiohttp.ClientTimeout(total=timeout)}
|
||||||
|
|
||||||
|
async with aiohttp.ClientSession(**opts) as session:
|
||||||
|
async with session.get(url, headers=headers, ssl=verify_tls) as response:
|
||||||
|
if raise_for_status:
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
result = await response.json() if json else await response.text()
|
||||||
|
if result is None or (isinstance(result, str) and result == ''):
|
||||||
|
raise Exception("empty response from request")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def random_agent():
|
||||||
|
from ircradio.factory import user_agents
|
||||||
|
return random.choice(user_agents)
|
||||||
|
|
||||||
|
|
||||||
|
class Price:
|
||||||
|
def __init__(self):
|
||||||
|
self.usd = 0.3
|
||||||
|
|
||||||
|
def calculate(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def wownero_usd_price_loop(self):
|
||||||
|
while True:
|
||||||
|
self.usd = await Price.wownero_usd_price()
|
||||||
|
asyncio.sleep(1200)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def wownero_usd_price():
|
||||||
|
url = "https://api.coingecko.com/api/v3/simple/price?ids=wownero&vs_currencies=usd"
|
||||||
|
blob = await httpget(url, json=True)
|
||||||
|
return blob.get('usd', 0)
|
||||||
|
|
||||||
|
|
||||||
|
def print_banner():
|
||||||
|
print("""\033[91m ▪ ▄▄▄ ▄▄· ▄▄▄ ▄▄▄· ·▄▄▄▄ ▪
|
||||||
|
██ ▀▄ █·▐█ ▌▪▀▄ █·▐█ ▀█ ██▪ ██ ██ ▪
|
||||||
|
▐█·▐▀▀▄ ██ ▄▄▐▀▀▄ ▄█▀▀█ ▐█· ▐█▌▐█· ▄█▀▄
|
||||||
|
▐█▌▐█•█▌▐███▌▐█•█▌▐█ ▪▐▌██. ██ ▐█▌▐█▌.▐▌
|
||||||
|
▀▀▀.▀ ▀·▀▀▀ .▀ ▀ ▀ ▀ ▀▀▀▀▀• ▀▀▀ ▀█▄▀▪\033[0m
|
||||||
|
""".strip())
|
149
ircradio/youtube.py
Normal file
149
ircradio/youtube.py
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
# SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
# Copyright (c) 2021, dsc@xmr.pm
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import asyncio
|
||||||
|
import re
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import settings
|
||||||
|
|
||||||
|
|
||||||
|
class YouTube:
|
||||||
|
@staticmethod
|
||||||
|
async def download(utube_id: str, added_by: str) -> Optional['Song']:
|
||||||
|
from ircradio.factory import app
|
||||||
|
from ircradio.models import Song
|
||||||
|
|
||||||
|
output = f"{settings.dir_music}/{utube_id}.ogg"
|
||||||
|
song = Song.by_uid(utube_id)
|
||||||
|
if song:
|
||||||
|
if not os.path.exists(output):
|
||||||
|
# exists in db but not on disk; remove from db
|
||||||
|
Song.delete().where(Song.utube_id == utube_id).execute()
|
||||||
|
else:
|
||||||
|
raise Exception("Song already exists.")
|
||||||
|
|
||||||
|
if os.path.exists(output):
|
||||||
|
song = Song.by_uid(utube_id)
|
||||||
|
if not song:
|
||||||
|
# exists on disk but not in db; add to db
|
||||||
|
return Song.from_filepath(output)
|
||||||
|
|
||||||
|
raise Exception("Song already exists.")
|
||||||
|
|
||||||
|
try:
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
*["youtube-dl",
|
||||||
|
"--add-metadata",
|
||||||
|
"--write-all-thumbnails",
|
||||||
|
"--write-info-json",
|
||||||
|
"-f", "bestaudio",
|
||||||
|
"--max-filesize", "30M",
|
||||||
|
"--extract-audio",
|
||||||
|
"--audio-format", "vorbis",
|
||||||
|
"-o", f"{settings.dir_music}/%(id)s.ogg",
|
||||||
|
f"https://www.youtube.com/watch?v={utube_id}"],
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE)
|
||||||
|
result = await proc.communicate()
|
||||||
|
result = result[0].decode()
|
||||||
|
if "100%" not in result:
|
||||||
|
raise Exception("download did not complete")
|
||||||
|
except Exception as ex:
|
||||||
|
msg = f"download failed: {ex}"
|
||||||
|
app.logger.error(msg)
|
||||||
|
raise Exception(msg)
|
||||||
|
|
||||||
|
try:
|
||||||
|
metadata = YouTube.metadata_from_filepath(output)
|
||||||
|
if not metadata:
|
||||||
|
raise Exception("failed to fetch metadata")
|
||||||
|
|
||||||
|
if metadata['duration'] > settings.liquidsoap_max_song_duration:
|
||||||
|
Song.delete_song(utube_id)
|
||||||
|
raise Exception(f"Song exceeded duration of {settings.liquidsoap_max_song_duration} seconds")
|
||||||
|
|
||||||
|
song = Song.create(
|
||||||
|
duration=metadata['duration'],
|
||||||
|
title=metadata['name'],
|
||||||
|
added_by=added_by,
|
||||||
|
karma=5,
|
||||||
|
utube_id=utube_id)
|
||||||
|
return song
|
||||||
|
except Exception as ex:
|
||||||
|
app.logger.error(f"{ex}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def metadata_from_filepath(filepath: str):
|
||||||
|
from ircradio.factory import app
|
||||||
|
import mutagen
|
||||||
|
|
||||||
|
try:
|
||||||
|
metadata = mutagen.File(filepath)
|
||||||
|
except Exception as ex:
|
||||||
|
app.logger.error(f"mutagen failure on {filepath}")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
duration = metadata.info.length
|
||||||
|
except:
|
||||||
|
duration = 0
|
||||||
|
|
||||||
|
artist = metadata.tags.get('artist')
|
||||||
|
if artist:
|
||||||
|
artist = artist[0]
|
||||||
|
title = metadata.tags.get('title')
|
||||||
|
if title:
|
||||||
|
title = title[0]
|
||||||
|
if not artist or not title:
|
||||||
|
# try .info.json
|
||||||
|
path_info = f"{filepath}.info.json"
|
||||||
|
if os.path.exists(path_info):
|
||||||
|
try:
|
||||||
|
blob = json.load(open(path_info,))
|
||||||
|
artist = blob.get('artist')
|
||||||
|
title = blob.get('title')
|
||||||
|
duration = blob.get('duration', 0)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
artist = 'Unknown'
|
||||||
|
title = 'Unknown'
|
||||||
|
app.logger.warning(f"could not detect artist/title from metadata for {filepath}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"name": f"{artist} - {title}",
|
||||||
|
"data": metadata,
|
||||||
|
"duration": duration,
|
||||||
|
"path": filepath
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def update_loop():
|
||||||
|
while True:
|
||||||
|
await YouTube.update()
|
||||||
|
await asyncio.sleep(3600)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def update():
|
||||||
|
pip_path = os.path.join(os.path.dirname(sys.executable), "pip")
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
*[sys.executable, pip_path, "install", "--upgrade", "youtube-dl"],
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.PIPE)
|
||||||
|
stdout, stderr = await proc.communicate()
|
||||||
|
return stdout.decode()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def update_task():
|
||||||
|
while True:
|
||||||
|
await YouTube.update()
|
||||||
|
await asyncio.sleep(3600)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def is_valid_uid(uid: str) -> bool:
|
||||||
|
return re.match(settings.re_youtube, uid) is not None
|
13
requirements.txt
Normal file
13
requirements.txt
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
quart
|
||||||
|
youtube-dl
|
||||||
|
aiofiles
|
||||||
|
aiohttp
|
||||||
|
bottom
|
||||||
|
tinytag
|
||||||
|
peewee
|
||||||
|
python-dateutil
|
||||||
|
mutagen
|
||||||
|
peewee
|
||||||
|
youtube-dl
|
||||||
|
quart
|
||||||
|
psycopg2
|
94
run.py
Normal file
94
run.py
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
# SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
# Copyright (c) 2021, dsc@xmr.pm
|
||||||
|
|
||||||
|
import os
|
||||||
|
import pwd
|
||||||
|
import logging
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
from quart import render_template
|
||||||
|
import click
|
||||||
|
|
||||||
|
from ircradio.factory import create_app
|
||||||
|
import settings
|
||||||
|
|
||||||
|
|
||||||
|
@click.group()
|
||||||
|
def cli():
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@cli.command(name="generate")
|
||||||
|
def cli_generate_configs(*args, **kwargs):
|
||||||
|
"""Generate icecast2/liquidsoap configs and systemd service files"""
|
||||||
|
from ircradio.utils import jinja2_render, write_file_sync, systemd_servicefile
|
||||||
|
|
||||||
|
templates_dir = os.path.join(settings.cwd, "ircradio", "templates")
|
||||||
|
|
||||||
|
# liquidsoap service file
|
||||||
|
path_liquidsoap = shutil.which("liquidsoap")
|
||||||
|
path_liquidsoap_config = os.path.join(settings.cwd, "data", "soap.liq")
|
||||||
|
|
||||||
|
liquidsoap_systemd_service = systemd_servicefile(
|
||||||
|
name="liquidsoap",
|
||||||
|
description="liquidsoap service",
|
||||||
|
user=pwd.getpwuid(os.getuid()).pw_name,
|
||||||
|
group=pwd.getpwuid(os.getuid()).pw_name,
|
||||||
|
path_executable=path_liquidsoap,
|
||||||
|
args_executable=path_liquidsoap_config,
|
||||||
|
env="")
|
||||||
|
write_file_sync(fn=os.path.join(settings.cwd, "data", "liquidsoap.service"), data=liquidsoap_systemd_service)
|
||||||
|
|
||||||
|
# liquidsoap config
|
||||||
|
template = jinja2_render("soap.liq.jinja2",
|
||||||
|
icecast2_bind_host=settings.icecast2_bind_host,
|
||||||
|
icecast2_bind_port=settings.icecast2_bind_port,
|
||||||
|
liquidsoap_host=settings.liquidsoap_host,
|
||||||
|
liquidsoap_port=settings.liquidsoap_port,
|
||||||
|
icecast2_mount=settings.icecast2_mount,
|
||||||
|
liquidsoap_description=settings.liquidsoap_description,
|
||||||
|
icecast2_source_password=settings.icecast2_source_password,
|
||||||
|
dir_music=settings.dir_music)
|
||||||
|
write_file_sync(fn=os.path.join(settings.cwd, "data", "soap.liq"), data=template.encode())
|
||||||
|
|
||||||
|
# cross.liq
|
||||||
|
path_liquidsoap_cross_template = os.path.join(templates_dir, "cross.liq.jinja2")
|
||||||
|
path_liquidsoap_cross = os.path.join(settings.cwd, "data", "cross.liq")
|
||||||
|
shutil.copyfile(path_liquidsoap_cross_template, path_liquidsoap_cross)
|
||||||
|
|
||||||
|
# icecast2.xml
|
||||||
|
template = jinja2_render("icecast.xml.jinja2",
|
||||||
|
icecast2_bind_host=settings.icecast2_bind_host,
|
||||||
|
icecast2_bind_port=settings.icecast2_bind_port,
|
||||||
|
hostname="localhost",
|
||||||
|
log_dir=settings.icecast2_logdir,
|
||||||
|
source_password=settings.icecast2_source_password,
|
||||||
|
relay_password=settings.icecast2_relay_password,
|
||||||
|
admin_password=settings.icecast2_admin_password,
|
||||||
|
dir_music=settings.dir_music)
|
||||||
|
path_icecast2_config = os.path.join(settings.cwd, "data", "icecast.xml")
|
||||||
|
write_file_sync(path_icecast2_config, data=template.encode())
|
||||||
|
|
||||||
|
# nginx
|
||||||
|
template = jinja2_render("nginx.jinja2",
|
||||||
|
icecast2_bind_host=settings.icecast2_bind_host,
|
||||||
|
icecast2_bind_port=settings.icecast2_bind_port,
|
||||||
|
hostname=settings.icecast2_hostname,
|
||||||
|
icecast2_mount=settings.icecast2_mount,
|
||||||
|
host=settings.host,
|
||||||
|
port=settings.port)
|
||||||
|
path_nginx_config = os.path.join(settings.cwd, "data", "radio_nginx.conf")
|
||||||
|
write_file_sync(path_nginx_config, data=template.encode())
|
||||||
|
print(f"written config files to {os.path.join(settings.cwd, 'data')}")
|
||||||
|
|
||||||
|
|
||||||
|
@cli.command(name="webdev")
|
||||||
|
def webdev(*args, **kwargs):
|
||||||
|
"""Run the web-if, for development purposes"""
|
||||||
|
from ircradio.factory import create_app
|
||||||
|
app = create_app()
|
||||||
|
app.run(settings.host, port=settings.port, debug=settings.debug, use_reloader=False)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
cli()
|
50
settings.py_example
Normal file
50
settings.py_example
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
# SPDX-License-Identifier: BSD-3-Clause
|
||||||
|
# Copyright (c) 2021, dsc@xmr.pm
|
||||||
|
|
||||||
|
import os
|
||||||
|
cwd = os.path.dirname(os.path.realpath(__file__))
|
||||||
|
|
||||||
|
|
||||||
|
def bool_env(val):
|
||||||
|
return val is True or (isinstance(val, str) and (val.lower() == 'true' or val == '1'))
|
||||||
|
|
||||||
|
|
||||||
|
debug = True
|
||||||
|
host = "127.0.0.1"
|
||||||
|
port = 2600
|
||||||
|
timezone = "Europe/Amsterdam"
|
||||||
|
|
||||||
|
dir_music = os.environ.get("DIR_MUSIC", os.path.join(cwd, "data", "music"))
|
||||||
|
|
||||||
|
irc_admins_nicknames = ["dsc_"]
|
||||||
|
irc_host = os.environ.get('IRC_HOST', 'localhost')
|
||||||
|
irc_port = int(os.environ.get('IRC_PORT', 6667))
|
||||||
|
irc_ssl = bool_env(os.environ.get('IRC_SSL', False)) # untested
|
||||||
|
irc_nick = os.environ.get('IRC_NICK', 'DJIRC')
|
||||||
|
irc_channels = os.environ.get('IRC_CHANNELS', '#mychannel').split()
|
||||||
|
irc_realname = os.environ.get('IRC_REALNAME', 'DJIRC')
|
||||||
|
irc_ignore_pms = False
|
||||||
|
irc_command_prefix = "!"
|
||||||
|
|
||||||
|
icecast2_hostname = "localhost"
|
||||||
|
icecast2_max_clients = 32
|
||||||
|
icecast2_bind_host = "127.0.0.1"
|
||||||
|
icecast2_bind_port = 24100
|
||||||
|
icecast2_mount = "radio.ogg"
|
||||||
|
icecast2_source_password = "changeme"
|
||||||
|
icecast2_admin_password = "changeme"
|
||||||
|
icecast2_relay_password = "changeme" # for livestreams
|
||||||
|
icecast2_live_mount = "live.ogg"
|
||||||
|
icecast2_logdir = "/var/log/icecast2/"
|
||||||
|
|
||||||
|
liquidsoap_host = "127.0.0.1"
|
||||||
|
liquidsoap_port = 7555 # telnet
|
||||||
|
liquidsoap_description = "IRC!Radio"
|
||||||
|
liquidsoap_samplerate = 48000
|
||||||
|
liquidsoap_bitrate = 164 # youtube is max 164kbps
|
||||||
|
liquidsoap_crossfades = False # not implemented yet
|
||||||
|
liquidsoap_normalize = False # not implemented yet
|
||||||
|
liquidsoap_iface = icecast2_mount.replace(".", "(dot)")
|
||||||
|
liquidsoap_max_song_duration = 60 * 11 # seconds
|
||||||
|
|
||||||
|
re_youtube = r"[a-zA-Z0-9_-]{11}$"
|
Loading…
Reference in New Issue
Block a user