mirror of
				https://git.wownero.com/dsc/ircradio.git
				synced 2024-08-15 01:03:15 +00:00 
			
		
		
		
	Initial commit
This commit is contained in:
		
						commit
						9c2d2b365f
					
				
					 24 changed files with 9357 additions and 0 deletions
				
			
		
							
								
								
									
										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…
	
	Add table
		Add a link
		
	
		Reference in a new issue