Compare commits
No commits in common. "46fae8ace25652aedfd1c5ed9ac8e0d80c8ee47e" and "21160e17c55953a3d161850782e445edc1f908b2" have entirely different histories.
46fae8ace2
...
21160e17c5
1
.gitignore
vendored
@ -2,6 +2,5 @@
|
|||||||
data/music/*.jpg
|
data/music/*.jpg
|
||||||
data/music/*.webp
|
data/music/*.webp
|
||||||
data/music/*.ogg*
|
data/music/*.ogg*
|
||||||
data/music/*.json
|
|
||||||
__pycache__
|
__pycache__
|
||||||
settings.py
|
settings.py
|
||||||
|
143
README.md
@ -6,6 +6,145 @@ all your friends. Great fun!
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
# How to install
|
### Stack
|
||||||
|
|
||||||
Good luck!
|
IRC!Radio aims to be minimalistic/small using:
|
||||||
|
|
||||||
|
- Python >= 3.7
|
||||||
|
- SQLite
|
||||||
|
- LiquidSoap >= 1.4.3
|
||||||
|
- Icecast2
|
||||||
|
- Quart web framework
|
||||||
|
|
||||||
|
## Command list
|
||||||
|
|
||||||
|
```text
|
||||||
|
- !np - current song
|
||||||
|
- !tune - upvote song
|
||||||
|
- !boo - downvote song
|
||||||
|
- !request - search and queue a song by title or YouTube id
|
||||||
|
- !dj+ - add a YouTube ID to the radiostream
|
||||||
|
- !dj- - remove a YouTube ID
|
||||||
|
- !ban+ - ban a YouTube ID and/or nickname
|
||||||
|
- !ban- - unban a YouTube ID and/or nickname
|
||||||
|
- !skip - skips current song
|
||||||
|
- !listeners - show current amount of listeners
|
||||||
|
- !queue - show queued up music
|
||||||
|
- !queue_user - queue a random song by user
|
||||||
|
- !search - search for a title
|
||||||
|
- !stats - stats
|
||||||
|
```
|
||||||
|
|
||||||
|
## Ubuntu installation
|
||||||
|
|
||||||
|
No docker. The following assumes you have a VPS somewhere with root access.
|
||||||
|
|
||||||
|
#### 1. Requirements
|
||||||
|
|
||||||
|
As `root`:
|
||||||
|
|
||||||
|
```
|
||||||
|
apt install -y liquidsoap icecast2 nginx python3-certbot-nginx python3-virtualenv libogg-dev ffmpeg sqlite3
|
||||||
|
ufw allow 80
|
||||||
|
ufw allow 443
|
||||||
|
```
|
||||||
|
|
||||||
|
When the installation asks for icecast2 configuration, skip it.
|
||||||
|
|
||||||
|
#### 2. Create system user
|
||||||
|
|
||||||
|
As `root`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
adduser radio
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. Clone this project
|
||||||
|
|
||||||
|
As `radio`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
su radio
|
||||||
|
cd ~/
|
||||||
|
|
||||||
|
git clone https://git.wownero.com/dsc/ircradio.git
|
||||||
|
cd ircradio/
|
||||||
|
virtualenv -p /usr/bin/python3 venv
|
||||||
|
source venv/bin/activate
|
||||||
|
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. Generate some configs
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp settings.py_example settings.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Look at `settings.py` and configure it to your liking:
|
||||||
|
|
||||||
|
- Change `icecast2_hostname` to your hostname, i.e: `radio.example.com`
|
||||||
|
- Change `irc_host`, `irc_port`, `irc_channels`, and `irc_admins_nicknames`
|
||||||
|
- Change the passwords under `icecast2_`
|
||||||
|
- Change the `liquidsoap_description` to whatever
|
||||||
|
|
||||||
|
When you are done, execute this command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python run generate
|
||||||
|
```
|
||||||
|
|
||||||
|
This will write icecast2/liquidsoap/nginx configuration files into `data/`.
|
||||||
|
|
||||||
|
#### 4. Applying configuration
|
||||||
|
|
||||||
|
As `root`, copy the following files:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp data/icecast.xml /etc/icecast2/
|
||||||
|
cp data/liquidsoap.service /etc/systemd/system/
|
||||||
|
cp data/radio_nginx.conf /etc/nginx/sites-enabled/
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 5. Starting some stuff
|
||||||
|
|
||||||
|
As `root` 'enable' icecast2/liquidsoap/nginx, this is to
|
||||||
|
make sure these applications start when the server reboots.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl enable liquidsoap
|
||||||
|
sudo systemctl enable nginx
|
||||||
|
sudo systemctl enable icecast2
|
||||||
|
```
|
||||||
|
|
||||||
|
And start them:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl start icecast2
|
||||||
|
sudo systemctl start liquidsoap
|
||||||
|
```
|
||||||
|
|
||||||
|
Reload & start nginx:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl reload nginx
|
||||||
|
sudo systemctl start nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Run the webif and IRC bot:
|
||||||
|
|
||||||
|
As `radio`, issue the following command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 run webdev
|
||||||
|
```
|
||||||
|
|
||||||
|
Run it in `screen` or `tux` to keep it up, or write a systemd unit file for it.
|
||||||
|
|
||||||
|
### 7. Generate HTTPs certificate
|
||||||
|
|
||||||
|
```bash
|
||||||
|
certbot --nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
Pick "Yes" for redirects.
|
||||||
|
@ -1,202 +0,0 @@
|
|||||||
#!/usr/bin/liquidsoap
|
|
||||||
set("log.stdout", true)
|
|
||||||
set("log.file",false)
|
|
||||||
|
|
||||||
# Allow requests from Telnet (Liquidsoap Requester)
|
|
||||||
set("server.telnet", true)
|
|
||||||
set("server.telnet.bind_addr", "127.0.0.1")
|
|
||||||
set("server.telnet.port", 7555)
|
|
||||||
set("server.telnet.reverse_dns", false)
|
|
||||||
|
|
||||||
|
|
||||||
pmain = playlist(
|
|
||||||
id="playlist",
|
|
||||||
timeout=90.0,
|
|
||||||
mode="random",
|
|
||||||
reload=300,
|
|
||||||
reload_mode="seconds",
|
|
||||||
mime_type="audio/ogg",
|
|
||||||
"/home/radio/ircradio/data/music"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ==== ANJUNADEEP
|
|
||||||
panjunadeep = playlist(
|
|
||||||
id="panjunadeep",
|
|
||||||
timeout=90.0,
|
|
||||||
mode="random",
|
|
||||||
reload=300,
|
|
||||||
reload_mode="seconds",
|
|
||||||
mime_type="audio/ogg",
|
|
||||||
"/home/radio/mixes/anjunadeep/"
|
|
||||||
)
|
|
||||||
|
|
||||||
# ==== BERLIN
|
|
||||||
pberlin = playlist(
|
|
||||||
id="berlin",
|
|
||||||
timeout=90.0,
|
|
||||||
mode="random",
|
|
||||||
reload=300,
|
|
||||||
reload_mode="seconds",
|
|
||||||
mime_type="audio/ogg",
|
|
||||||
"/home/radio/mixes/berlin/"
|
|
||||||
)
|
|
||||||
|
|
||||||
# ==== BREAKBEAT
|
|
||||||
pbreaks = playlist(
|
|
||||||
id="breaks",
|
|
||||||
mode="random",
|
|
||||||
reload=300,
|
|
||||||
reload_mode="seconds",
|
|
||||||
mime_type="audio/ogg",
|
|
||||||
"/home/radio/mixes/breakbeat/"
|
|
||||||
)
|
|
||||||
|
|
||||||
# ==== DNB
|
|
||||||
pdnb = playlist(
|
|
||||||
id="dnb",
|
|
||||||
timeout=90.0,
|
|
||||||
mode="random",
|
|
||||||
reload=300,
|
|
||||||
reload_mode="seconds",
|
|
||||||
mime_type="audio/ogg",
|
|
||||||
"/home/radio/mixes/dnb/"
|
|
||||||
)
|
|
||||||
|
|
||||||
# ==== RAVES
|
|
||||||
praves = playlist(
|
|
||||||
id="raves",
|
|
||||||
timeout=90.0,
|
|
||||||
mode="random",
|
|
||||||
reload=300,
|
|
||||||
reload_mode="seconds",
|
|
||||||
mime_type="audio/ogg",
|
|
||||||
"/home/radio/mixes/raves/"
|
|
||||||
)
|
|
||||||
|
|
||||||
# ==== TRANCE
|
|
||||||
ptrance = playlist(
|
|
||||||
id="trance",
|
|
||||||
timeout=90.0,
|
|
||||||
mode="random",
|
|
||||||
reload=300,
|
|
||||||
reload_mode="seconds",
|
|
||||||
mime_type="audio/ogg",
|
|
||||||
"/home/radio/mixes/trance/"
|
|
||||||
)
|
|
||||||
|
|
||||||
# ==== WEED
|
|
||||||
pweed = playlist(
|
|
||||||
id="weed",
|
|
||||||
timeout=90.0,
|
|
||||||
mode="random",
|
|
||||||
reload=300,
|
|
||||||
reload_mode="seconds",
|
|
||||||
mime_type="audio/ogg",
|
|
||||||
"/home/radio/mixes/weed/"
|
|
||||||
)
|
|
||||||
|
|
||||||
req_pmain = request.queue(id="pmain")
|
|
||||||
req_panjunadeep = request.queue(id="panjunadeep")
|
|
||||||
req_pberlin = request.queue(id="pberlin")
|
|
||||||
req_pbreaks = request.queue(id="pbreaks")
|
|
||||||
req_pdnb = request.queue(id="pdnb")
|
|
||||||
req_praves = request.queue(id="praves")
|
|
||||||
req_ptrance = request.queue(id="ptrance")
|
|
||||||
req_pweed = request.queue(id="pweed")
|
|
||||||
|
|
||||||
pmain = fallback(id="switcher",track_sensitive = true, [req_pmain, pmain, blank(duration=5.)])
|
|
||||||
panjunadeep = fallback(id="switcher",track_sensitive = true, [req_panjunadeep, panjunadeep, blank(duration=5.)])
|
|
||||||
pberlin = fallback(id="switcher",track_sensitive = true, [req_pberlin, pberlin, blank(duration=5.)])
|
|
||||||
pbreaks = fallback(id="switcher",track_sensitive = true, [req_pbreaks, pbreaks, blank(duration=5.)])
|
|
||||||
pdnb = fallback(id="switcher",track_sensitive = true, [req_pdnb, pdnb, blank(duration=5.)])
|
|
||||||
praves = fallback(id="switcher",track_sensitive = true, [req_praves, praves, blank(duration=5.)])
|
|
||||||
ptrance = fallback(id="switcher",track_sensitive = true, [req_ptrance, ptrance, blank(duration=5.)])
|
|
||||||
pweed = fallback(id="switcher",track_sensitive = true, [req_pweed, pweed, blank(duration=5.)])
|
|
||||||
|
|
||||||
# iTunes-style (so-called "dumb" - but good enough) crossfading
|
|
||||||
pmain_crossed = crossfade(pmain)
|
|
||||||
pmain_crossed = mksafe(pmain_crossed)
|
|
||||||
|
|
||||||
output.icecast(%vorbis.cbr(samplerate=48000, channels=2, bitrate=164),
|
|
||||||
host = "10.7.0.3", port = 24100,
|
|
||||||
send_icy_metadata=true, description="WOW!Radio",
|
|
||||||
password = "lel", mount = "wow.ogg",
|
|
||||||
pmain_crossed)
|
|
||||||
|
|
||||||
|
|
||||||
output.icecast(%vorbis.cbr(samplerate=48000, channels=2, bitrate=164),
|
|
||||||
host = "10.7.0.3", port = 24100,
|
|
||||||
send_icy_metadata=true, description="WOW!Radio | Anjunadeep",
|
|
||||||
password = "lel", mount = "anjunadeep.ogg",
|
|
||||||
panjunadeep)
|
|
||||||
|
|
||||||
|
|
||||||
output.icecast(%vorbis.cbr(samplerate=48000, channels=2, bitrate=164),
|
|
||||||
host = "10.7.0.3", port = 24100,
|
|
||||||
send_icy_metadata=true, description="WOW!Radio | Berlin",
|
|
||||||
password = "lel", mount = "berlin.ogg",
|
|
||||||
pberlin)
|
|
||||||
|
|
||||||
|
|
||||||
output.icecast(%vorbis.cbr(samplerate=48000, channels=2, bitrate=164),
|
|
||||||
host = "10.7.0.3", port = 24100,
|
|
||||||
send_icy_metadata=true, description="WOW!Radio | Breakbeat",
|
|
||||||
password = "lel", mount = "breaks.ogg",
|
|
||||||
pbreaks)
|
|
||||||
|
|
||||||
|
|
||||||
output.icecast(%vorbis.cbr(samplerate=48000, channels=2, bitrate=164),
|
|
||||||
host = "10.7.0.3", port = 24100,
|
|
||||||
send_icy_metadata=true, description="WOW!Radio | Dnb",
|
|
||||||
password = "lel", mount = "dnb.ogg",
|
|
||||||
pdnb)
|
|
||||||
|
|
||||||
|
|
||||||
output.icecast(%vorbis.cbr(samplerate=48000, channels=2, bitrate=164),
|
|
||||||
host = "10.7.0.3", port = 24100,
|
|
||||||
send_icy_metadata=true, description="WOW!Radio | Trance",
|
|
||||||
password = "lel", mount = "trance.ogg",
|
|
||||||
ptrance)
|
|
||||||
|
|
||||||
|
|
||||||
output.icecast(%vorbis.cbr(samplerate=48000, channels=2, bitrate=164),
|
|
||||||
host = "10.7.0.3", port = 24100,
|
|
||||||
send_icy_metadata=true, description="WOW!Radio | Weed",
|
|
||||||
password = "lel", mount = "weed.ogg",
|
|
||||||
pweed)
|
|
||||||
|
|
||||||
output.icecast(%vorbis.cbr(samplerate=48000, channels=2, bitrate=128),
|
|
||||||
host = "10.7.0.3", port = 24100,
|
|
||||||
send_icy_metadata=true, description="WOW!Radio | Rave",
|
|
||||||
password = "lel", mount = "rave.ogg",
|
|
||||||
praves)
|
|
||||||
|
|
||||||
|
|
||||||
def get_now_playing_filepaths(_)
|
|
||||||
def null_list(key, _list)
|
|
||||||
list.assoc.mem(key, _list) ? list.assoc(key, _list) : null()
|
|
||||||
end
|
|
||||||
|
|
||||||
pmain_meta = pmain.last_metadata() ?? []
|
|
||||||
panjunadeep_meta = panjunadeep.last_metadata() ?? []
|
|
||||||
pberlin_meta = pberlin.last_metadata() ?? []
|
|
||||||
pbreaks_meta = pbreaks.last_metadata() ?? []
|
|
||||||
pdnb_meta = pdnb.last_metadata() ?? []
|
|
||||||
praves_meta = praves.last_metadata() ?? []
|
|
||||||
ptrance_meta = ptrance.last_metadata() ?? []
|
|
||||||
pweed_meta = pweed.last_metadata() ?? []
|
|
||||||
|
|
||||||
pmain_filename = null_list("filename", pmain_meta)
|
|
||||||
panjunadeep_filename = null_list("filename", panjunadeep_meta)
|
|
||||||
pberlin_filename = null_list("filename", pberlin_meta)
|
|
||||||
pbreaks_filename = null_list("filename", pbreaks_meta)
|
|
||||||
pdnb_filename = null_list("filename", pdnb_meta)
|
|
||||||
praves_filename = null_list("filename", praves_meta)
|
|
||||||
ptrance_filename = null_list("filename", ptrance_meta)
|
|
||||||
pweed_filename = null_list("filename", pweed_meta)
|
|
||||||
|
|
||||||
"pmain_filename=#{pmain_filename}\npanjunadeep_filename=#{panjunadeep_filename}\npberlin_filename=#{pberlin_filename}\npbreaks_filename=#{pbreaks_filename}\npdnb_filename=#{pdnb_filename}\npraves_filename=#{praves_filename}\nptrance_filename=#{ptrance_filename}\npweed_filename=#{pweed_filename}\n"
|
|
||||||
end
|
|
||||||
|
|
||||||
server.register("now_playing", get_now_playing_filepaths)
|
|
@ -1,8 +1,4 @@
|
|||||||
import os
|
from ircradio.utils import liquidsoap_check_symlink
|
||||||
from typing import List, Optional
|
|
||||||
|
|
||||||
import settings
|
|
||||||
|
|
||||||
with open(os.path.join(settings.cwd, 'data', 'agents.txt'), 'r') as f:
|
liquidsoap_check_symlink()
|
||||||
user_agents: Optional[List[str]] = [
|
|
||||||
l.strip() for l in f.readlines() if l.strip()]
|
|
||||||
|
@ -1,37 +1,35 @@
|
|||||||
# SPDX-License-Identifier: BSD-3-Clause
|
# SPDX-License-Identifier: BSD-3-Clause
|
||||||
# Copyright (c) 2021, dsc@xmr.pm
|
# Copyright (c) 2021, dsc@xmr.pm
|
||||||
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
import collections
|
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
import asyncio
|
import asyncio
|
||||||
from asyncio import Queue
|
|
||||||
|
|
||||||
import bottom
|
import bottom
|
||||||
from quart import Quart, session, redirect, url_for
|
from quart import Quart
|
||||||
from quart_keycloak import Keycloak, KeycloakAuthToken
|
|
||||||
from quart_session import Session
|
|
||||||
from asyncio_multisubscriber_queue import MultisubscriberQueue
|
|
||||||
|
|
||||||
import settings
|
import settings
|
||||||
from ircradio.radio import Radio
|
from ircradio.radio import Radio
|
||||||
from ircradio.station import Station
|
from ircradio.utils import Price, print_banner
|
||||||
from ircradio.utils import print_banner
|
|
||||||
from ircradio.youtube import YouTube
|
from ircradio.youtube import YouTube
|
||||||
import ircradio.models
|
import ircradio.models
|
||||||
|
|
||||||
app = None
|
app = None
|
||||||
user_agents: Optional[List[str]] = None
|
user_agents: List[str] = None
|
||||||
websocket_status_bus = MultisubscriberQueue()
|
websocket_sessions = set()
|
||||||
irc_message_announce_bus = MultisubscriberQueue()
|
download_queue = asyncio.Queue()
|
||||||
websocket_status_bus_last_item: Optional[dict[str, Station]] = None
|
|
||||||
irc_bot = None
|
irc_bot = None
|
||||||
keycloak = None
|
price = Price()
|
||||||
NP_MAP = {} # station, filepath
|
|
||||||
soap = Radio()
|
soap = Radio()
|
||||||
|
# icecast2 = IceCast2()
|
||||||
|
|
||||||
|
|
||||||
|
async def download_thing():
|
||||||
|
global download_queue
|
||||||
|
|
||||||
|
a = await download_queue.get()
|
||||||
|
e = 1
|
||||||
|
|
||||||
|
|
||||||
async def _setup_icecast2(app: Quart):
|
async def _setup_icecast2(app: Quart):
|
||||||
@ -46,114 +44,45 @@ async def _setup_database(app: Quart):
|
|||||||
m.create_table()
|
m.create_table()
|
||||||
|
|
||||||
|
|
||||||
async def _setup_tasks(app: Quart):
|
|
||||||
from ircradio.utils import radio_update_task_run_forever
|
|
||||||
asyncio.create_task(radio_update_task_run_forever())
|
|
||||||
|
|
||||||
async def last_websocket_item_updater():
|
|
||||||
global websocket_status_bus_last_item
|
|
||||||
|
|
||||||
async for data in websocket_status_bus.subscribe():
|
|
||||||
websocket_status_bus_last_item = data
|
|
||||||
|
|
||||||
async def irc_announce_task():
|
|
||||||
from ircradio.irc import send_message
|
|
||||||
async for data in irc_message_announce_bus.subscribe():
|
|
||||||
await send_message(settings.irc_channels[0], data)
|
|
||||||
|
|
||||||
asyncio.create_task(last_websocket_item_updater())
|
|
||||||
asyncio.create_task(irc_announce_task())
|
|
||||||
asyncio.create_task(_now_playing_watch())
|
|
||||||
|
|
||||||
|
|
||||||
async def _setup_irc(app: Quart):
|
async def _setup_irc(app: Quart):
|
||||||
global irc_bot
|
global irc_bot
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
|
irc_bot = bottom.Client(host=settings.irc_host, port=settings.irc_port, ssl=settings.irc_ssl, loop=loop)
|
||||||
bottom_client = bottom.Client
|
|
||||||
if sys.version_info.major == 3 and sys.version_info.minor >= 10:
|
|
||||||
|
|
||||||
class Python310Client(bottom.Client):
|
|
||||||
def __init__(self, host: str, port: int, *, encoding: str = "utf-8", ssl: bool = True,
|
|
||||||
loop: Optional[asyncio.AbstractEventLoop] = None) -> None:
|
|
||||||
"""Fix 3.10 error: https://github.com/numberoverzero/bottom/issues/60"""
|
|
||||||
super().__init__(host, port, encoding=encoding, ssl=ssl, loop=loop)
|
|
||||||
self._events = collections.defaultdict(lambda: asyncio.Event())
|
|
||||||
bottom_client = Python310Client
|
|
||||||
|
|
||||||
irc_bot = bottom_client(host=settings.irc_host, port=settings.irc_port, ssl=settings.irc_ssl, loop=loop)
|
|
||||||
from ircradio.irc import start, message_worker
|
from ircradio.irc import start, message_worker
|
||||||
start()
|
start()
|
||||||
|
|
||||||
asyncio.create_task(message_worker())
|
asyncio.create_task(message_worker())
|
||||||
|
|
||||||
|
|
||||||
|
async def _setup_user_agents(app: Quart):
|
||||||
|
global user_agents
|
||||||
|
with open(os.path.join(settings.cwd, 'data', 'agents.txt'), 'r') as f:
|
||||||
|
user_agents = [l.strip() for l in f.readlines() if l.strip()]
|
||||||
|
|
||||||
|
|
||||||
async def _setup_requirements(app: Quart):
|
async def _setup_requirements(app: Quart):
|
||||||
ls_reachable = soap.liquidsoap_reachable()
|
ls_reachable = soap.liquidsoap_reachable()
|
||||||
if not ls_reachable:
|
if not ls_reachable:
|
||||||
raise Exception("liquidsoap is not running, please start it first")
|
raise Exception("liquidsoap is not running, please start it first")
|
||||||
|
|
||||||
|
|
||||||
async def _setup_cache(app: Quart):
|
|
||||||
app.config['SESSION_TYPE'] = 'redis'
|
|
||||||
app.config['SESSION_URI'] = settings.redis_uri
|
|
||||||
Session(app)
|
|
||||||
|
|
||||||
|
|
||||||
async def _now_playing_watch():
|
|
||||||
global NP_MAP
|
|
||||||
proc = await asyncio.create_subprocess_exec(
|
|
||||||
"journalctl", "-n15000", "-xefu", "liquidsoap",
|
|
||||||
stdout=asyncio.subprocess.PIPE,
|
|
||||||
)
|
|
||||||
|
|
||||||
line = await proc.stdout.readline()
|
|
||||||
while line:
|
|
||||||
line = line.decode().strip()
|
|
||||||
if '] Prepared "/' in line and ".ogg" in line:
|
|
||||||
try:
|
|
||||||
filename = re.findall(r"\"(.*\.ogg)\"", line)[0]
|
|
||||||
radio = re.findall(r"\[(\w+)\:\d\]", line)[0]
|
|
||||||
if radio == "playlist":
|
|
||||||
radio = "pmain"
|
|
||||||
NP_MAP[radio] = filename
|
|
||||||
except Exception as ex:
|
|
||||||
print(f"_now_playing_watch: {ex}")
|
|
||||||
|
|
||||||
line = await proc.stdout.readline()
|
|
||||||
|
|
||||||
|
|
||||||
def create_app():
|
def create_app():
|
||||||
global app, soap, icecast2
|
global app, soap, icecast2
|
||||||
app = Quart(__name__)
|
app = Quart(__name__)
|
||||||
app.config['TEMPLATES_AUTO_RELOAD'] = True
|
app.logger.setLevel(logging.INFO)
|
||||||
app.logger.setLevel(logging.DEBUG if settings.debug else logging.INFO)
|
|
||||||
if settings.redis_uri:
|
|
||||||
pass
|
|
||||||
|
|
||||||
@app.before_serving
|
@app.before_serving
|
||||||
async def startup():
|
async def startup():
|
||||||
global keycloak
|
await _setup_requirements(app)
|
||||||
await _setup_database(app)
|
await _setup_database(app)
|
||||||
await _setup_cache(app)
|
await _setup_user_agents(app)
|
||||||
await _setup_irc(app)
|
await _setup_irc(app)
|
||||||
await _setup_tasks(app)
|
|
||||||
import ircradio.routes
|
import ircradio.routes
|
||||||
|
|
||||||
keycloak = Keycloak(app, **settings.openid_keycloak_config)
|
|
||||||
|
|
||||||
@app.context_processor
|
|
||||||
def inject_all_templates():
|
|
||||||
return dict(settings=settings, logged_in='auth_token' in session)
|
|
||||||
|
|
||||||
@keycloak.after_login()
|
|
||||||
async def handle_user_login(auth_token: KeycloakAuthToken):
|
|
||||||
user = await keycloak.user_info(auth_token.access_token)
|
|
||||||
session['auth_token'] = user
|
|
||||||
return redirect(url_for('root'))
|
|
||||||
|
|
||||||
from ircradio.youtube import YouTube
|
from ircradio.youtube import YouTube
|
||||||
asyncio.create_task(YouTube.update_loop())
|
asyncio.create_task(YouTube.update_loop())
|
||||||
|
#asyncio.create_task(price.wownero_usd_price_loop())
|
||||||
|
|
||||||
print_banner()
|
print_banner()
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
269
ircradio/irc.py
@ -1,6 +1,6 @@
|
|||||||
# SPDX-License-Identifier: BSD-3-Clause
|
# SPDX-License-Identifier: BSD-3-Clause
|
||||||
# Copyright (c) 2021, dsc@xmr.pm
|
# Copyright (c) 2021, dsc@xmr.pm
|
||||||
import sys
|
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
@ -8,15 +8,12 @@ import asyncio
|
|||||||
import random
|
import random
|
||||||
|
|
||||||
from ircradio.factory import irc_bot as bot
|
from ircradio.factory import irc_bot as bot
|
||||||
from ircradio.station import Station
|
from ircradio.radio import Radio
|
||||||
from ircradio.youtube import YouTube
|
from ircradio.youtube import YouTube
|
||||||
import settings
|
import settings
|
||||||
from settings import radio_stations
|
|
||||||
|
|
||||||
radio_default = radio_stations["wow"]
|
|
||||||
msg_queue = asyncio.Queue()
|
msg_queue = asyncio.Queue()
|
||||||
YT_DLP_LOCK = asyncio.Lock()
|
|
||||||
DU_LOCK = asyncio.Lock()
|
|
||||||
|
|
||||||
|
|
||||||
async def message_worker():
|
async def message_worker():
|
||||||
@ -38,14 +35,11 @@ async def connect(**kwargs):
|
|||||||
bot.send('NICK', nick=settings.irc_nick)
|
bot.send('NICK', nick=settings.irc_nick)
|
||||||
bot.send('USER', user=settings.irc_nick, realname=settings.irc_realname)
|
bot.send('USER', user=settings.irc_nick, realname=settings.irc_realname)
|
||||||
|
|
||||||
# @TODO: get rid of this nonsense after a while
|
# Don't try to join channels until server sent MOTD
|
||||||
args = {"return_when": asyncio.FIRST_COMPLETED}
|
|
||||||
if sys.version_info.major == 3 and sys.version_info.minor < 10:
|
|
||||||
args["loop"] = bot.loop
|
|
||||||
|
|
||||||
done, pending = await asyncio.wait(
|
done, pending = await asyncio.wait(
|
||||||
[bot.wait("RPL_ENDOFMOTD"), bot.wait("ERR_NOMOTD")],
|
[bot.wait("RPL_ENDOFMOTD"), bot.wait("ERR_NOMOTD")],
|
||||||
**args
|
loop=bot.loop,
|
||||||
|
return_when=asyncio.FIRST_COMPLETED
|
||||||
)
|
)
|
||||||
|
|
||||||
# Cancel whichever waiter's event didn't come in.
|
# Cancel whichever waiter's event didn't come in.
|
||||||
@ -72,43 +66,31 @@ def reconnect(**kwargs):
|
|||||||
|
|
||||||
|
|
||||||
class Commands:
|
class Commands:
|
||||||
LOOKUP = ['np', 'tune', 'boo', 'request', 'dj', 'url', 'urls',
|
LOOKUP = ['np', 'tune', 'boo', 'request', 'dj',
|
||||||
'skip', 'listeners', 'queue',
|
'skip', 'listeners', 'queue',
|
||||||
'queue_user', 'pop', 'search', 'searchq', 'stats',
|
'queue_user', 'pop', 'search', 'stats',
|
||||||
'rename', 'ban', 'whoami']
|
'rename', 'ban', 'whoami']
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def np(*args, target=None, nick=None, **kwargs):
|
async def np(*args, target=None, nick=None, **kwargs):
|
||||||
"""current song"""
|
"""current song"""
|
||||||
radio_station = await Commands._parse_radio_station(args, target)
|
history = Radio.history()
|
||||||
if not radio_station:
|
if not history:
|
||||||
return
|
|
||||||
song = await radio_station.np()
|
|
||||||
if not song:
|
|
||||||
return await send_message(target, f"Nothing is playing?!")
|
return await send_message(target, f"Nothing is playing?!")
|
||||||
|
song = history[0]
|
||||||
|
|
||||||
np = "Now playing"
|
np = f"Now playing: {song.title} (rating: {song.karma}/10; submitter: {song.added_by}; id: {song.utube_id})"
|
||||||
if radio_station.id != "wow":
|
await send_message(target=target, message=np)
|
||||||
np += f" @{radio_station.id}"
|
|
||||||
|
|
||||||
message = f"{np}: {song.title_cleaned}"
|
|
||||||
|
|
||||||
if song.id:
|
|
||||||
message += f" (rating: {song.karma}/10; by: {song.added_by}; id: {song.utube_id})"
|
|
||||||
|
|
||||||
time_status_str = song.time_status_str()
|
|
||||||
if time_status_str:
|
|
||||||
message += f" {time_status_str}"
|
|
||||||
|
|
||||||
await send_message(target=target, message=message)
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def tune(*args, target=None, nick=None, **kwargs):
|
async def tune(*args, target=None, nick=None, **kwargs):
|
||||||
"""upvote song, only wow supported, not mixes"""
|
"""upvote song"""
|
||||||
song = await radio_default.np()
|
history = Radio.history()
|
||||||
if not song:
|
if not history:
|
||||||
return await send_message(target, f"Nothing is playing?!")
|
return await send_message(target, f"Nothing is playing?!")
|
||||||
|
song = history[0]
|
||||||
|
|
||||||
|
if song.karma <= 9:
|
||||||
song.karma += 1
|
song.karma += 1
|
||||||
song.save()
|
song.save()
|
||||||
|
|
||||||
@ -118,9 +100,10 @@ class Commands:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
async def boo(*args, target=None, nick=None, **kwargs):
|
async def boo(*args, target=None, nick=None, **kwargs):
|
||||||
"""downvote song"""
|
"""downvote song"""
|
||||||
song = await radio_default.np()
|
history = Radio.history()
|
||||||
if not song:
|
if not history:
|
||||||
return await send_message(target, f"Nothing is playing?!")
|
return await send_message(target, f"Nothing is playing?!")
|
||||||
|
song = history[0]
|
||||||
|
|
||||||
if song.karma >= 1:
|
if song.karma >= 1:
|
||||||
song.karma -= 1
|
song.karma -= 1
|
||||||
@ -133,110 +116,55 @@ class Commands:
|
|||||||
async def request(*args, target=None, nick=None, **kwargs):
|
async def request(*args, target=None, nick=None, **kwargs):
|
||||||
"""request a song by title or YouTube id"""
|
"""request a song by title or YouTube id"""
|
||||||
from ircradio.models import Song
|
from ircradio.models import Song
|
||||||
|
|
||||||
if not args:
|
if not args:
|
||||||
await send_message(target=target, message="usage: !request <id>")
|
send_message(target=target, message="usage: !request <id>")
|
||||||
|
|
||||||
songs = await Commands._return_song_results(*args, target=target, nick=nick, **kwargs)
|
|
||||||
|
|
||||||
if songs and len(songs) == 1:
|
|
||||||
song = songs[0]
|
|
||||||
await radio_default.queue_push(song.filepath)
|
|
||||||
msg = f"Added {song.title} to the queue"
|
|
||||||
|
|
||||||
return await send_message(target, msg)
|
|
||||||
|
|
||||||
if songs:
|
|
||||||
return await Commands._print_song_results(*args, target=target, nick=nick, songs=songs, **kwargs)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def search(*args, target=None, nick=None, **kwargs):
|
|
||||||
from ircradio.models import Song
|
|
||||||
if not args:
|
|
||||||
return await send_message(target=target, message="usage: !search <id>")
|
|
||||||
|
|
||||||
return await Commands._search(*args, target=target, nick=nick, **kwargs)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def searchq(*args, target=None, nick=None, **kwargs):
|
|
||||||
from ircradio.models import Song
|
|
||||||
if not args:
|
|
||||||
return await send_message(target=target, message="usage: !searchq <id>")
|
|
||||||
|
|
||||||
return await Commands._search(*args, target=target, nick=nick, report_quality=True, **kwargs)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def _search(*args, target=None, nick=None, **kwargs) -> Optional[List['Song']]:
|
|
||||||
"""search for a title"""
|
|
||||||
from ircradio.models import Song
|
|
||||||
|
|
||||||
report_quality = kwargs.get('report_quality')
|
|
||||||
|
|
||||||
songs = await Commands._return_song_results(*args, target=target, nick=nick, **kwargs)
|
|
||||||
|
|
||||||
if songs:
|
|
||||||
return await Commands._print_song_results(*args, target=target, nick=nick, report_quality=report_quality, songs=songs, **kwargs)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def _return_song_results(*args, target=None, nick=None, **kwargs) -> Optional[List['Song']]:
|
|
||||||
from ircradio.models import Song
|
|
||||||
|
|
||||||
needle = " ".join(args)
|
needle = " ".join(args)
|
||||||
|
|
||||||
# https://git.wownero.com/dsc/ircradio/issues/1
|
|
||||||
needle_2nd = None
|
|
||||||
if "|" in needle:
|
|
||||||
spl = needle.split('|', 1)
|
|
||||||
a = spl[0].strip()
|
|
||||||
b = spl[1].strip()
|
|
||||||
needle = a
|
|
||||||
needle_2nd = b
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
songs = Song.search(needle)
|
songs = Song.search(needle)
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
return await send_message(target, f"{ex}")
|
return await send_message(target, f"{ex}")
|
||||||
if not songs:
|
if not songs:
|
||||||
return await send_message(target, "No song(s) found!")
|
return await send_message(target, "Not found!")
|
||||||
|
|
||||||
if songs and needle_2nd:
|
if len(songs) >= 2:
|
||||||
songs = [s for s in songs if s.title and needle_2nd in s.title.lower()]
|
random.shuffle(songs)
|
||||||
|
await send_message(target, "Multiple found:")
|
||||||
|
for s in songs[:4]:
|
||||||
|
await send_message(target, f"{s.utube_id} | {s.title}")
|
||||||
|
return
|
||||||
|
|
||||||
if not songs:
|
song = songs[0]
|
||||||
return await send_message(target, "No song(s) found after '|'!")
|
msg = f"Added {song.title} to the queue"
|
||||||
|
Radio.queue(song)
|
||||||
return songs
|
return await send_message(target, msg)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def _print_song_results(*args, target=None, nick=None, report_quality=None, songs=None, **kwargs):
|
async def search(*args, target=None, nick=None, **kwargs):
|
||||||
|
"""search for a title"""
|
||||||
from ircradio.models import Song
|
from ircradio.models import Song
|
||||||
|
|
||||||
len_songs = len(songs)
|
if not args:
|
||||||
max_songs = 6
|
return await send_message(target=target, message="usage: !search <id>")
|
||||||
moar = len_songs > max_songs
|
|
||||||
if len_songs > 1:
|
|
||||||
await send_message(target, "Multiple found:")
|
|
||||||
|
|
||||||
|
needle = " ".join(args)
|
||||||
|
songs = Song.search(needle)
|
||||||
|
if not songs:
|
||||||
|
return await send_message(target, "No song(s) found!")
|
||||||
|
|
||||||
|
if len(songs) == 1:
|
||||||
|
song = songs[0]
|
||||||
|
await send_message(target, f"{song.utube_id} | {song.title}")
|
||||||
|
else:
|
||||||
random.shuffle(songs)
|
random.shuffle(songs)
|
||||||
|
await send_message(target, "Multiple found:")
|
||||||
for s in songs[:max_songs]:
|
for s in songs[:4]:
|
||||||
msg = f"{s.utube_id} | {s.title}"
|
await send_message(target, f"{s.utube_id} | {s.title}")
|
||||||
await s.scan(s.path or s.filepath)
|
|
||||||
|
|
||||||
if report_quality and s.meta:
|
|
||||||
if s.meta.bitrate:
|
|
||||||
msg += f" ({s.meta.bitrate / 1000}kbps)"
|
|
||||||
if s.meta.channels:
|
|
||||||
msg += f" (channels: {s.meta.channels}) "
|
|
||||||
if s.meta.sample_rate:
|
|
||||||
msg += f" (sample_rate: {s.meta.sample_rate}) "
|
|
||||||
await send_message(target, msg)
|
|
||||||
|
|
||||||
if moar:
|
|
||||||
await send_message(target, "[...]")
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def dj(*args, target=None, nick=None, **kwargs):
|
async def dj(*args, target=None, nick=None, **kwargs):
|
||||||
"""add (or remove) a YouTube ID to the default radio"""
|
"""add (or remove) a YouTube ID to the radiostream"""
|
||||||
from ircradio.models import Song
|
from ircradio.models import Song
|
||||||
if not args or args[0] not in ["-", "+"]:
|
if not args or args[0] not in ["-", "+"]:
|
||||||
return await send_message(target, "usage: dj+ <youtube_id>")
|
return await send_message(target, "usage: dj+ <youtube_id>")
|
||||||
@ -247,7 +175,6 @@ class Commands:
|
|||||||
return await send_message(target, "YouTube ID not valid.")
|
return await send_message(target, "YouTube ID not valid.")
|
||||||
|
|
||||||
if add:
|
if add:
|
||||||
async with YT_DLP_LOCK:
|
|
||||||
try:
|
try:
|
||||||
await send_message(target, f"Scheduled download for '{utube_id}'")
|
await send_message(target, f"Scheduled download for '{utube_id}'")
|
||||||
song = await YouTube.download(utube_id, added_by=nick)
|
song = await YouTube.download(utube_id, added_by=nick)
|
||||||
@ -265,64 +192,43 @@ class Commands:
|
|||||||
async def skip(*args, target=None, nick=None, **kwargs):
|
async def skip(*args, target=None, nick=None, **kwargs):
|
||||||
"""skips current song"""
|
"""skips current song"""
|
||||||
from ircradio.factory import app
|
from ircradio.factory import app
|
||||||
radio_station = await Commands._parse_radio_station(args, target)
|
|
||||||
if not radio_station:
|
|
||||||
return
|
|
||||||
|
|
||||||
# song = radio_station.np()
|
|
||||||
# if not song:
|
|
||||||
# app.logger.error(f"nothing is playing?")
|
|
||||||
# return await send_message(target=target, message="Nothing is playing ?!")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await radio_station.skip()
|
Radio.skip()
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
app.logger.error(f"{ex}")
|
app.logger.error(f"{ex}")
|
||||||
return await send_message(target=target, message="Nothing is playing ?!")
|
return await send_message(target=target, message="Error")
|
||||||
|
|
||||||
if radio_station.id == "wow":
|
await send_message(target, message="Song skipped. Booo! >:|")
|
||||||
_type = "Song"
|
|
||||||
else:
|
|
||||||
_type = "Mix"
|
|
||||||
|
|
||||||
await send_message(target, message=f"{_type} skipped. Booo! >:|")
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def listeners(*args, target=None, nick=None, **kwargs):
|
async def listeners(*args, target=None, nick=None, **kwargs):
|
||||||
"""current amount of listeners"""
|
"""current amount of listeners"""
|
||||||
from ircradio.factory import app
|
from ircradio.factory import app
|
||||||
radio_station = await Commands._parse_radio_station(args, target)
|
try:
|
||||||
if not radio_station:
|
listeners = await Radio.listeners()
|
||||||
return
|
if listeners:
|
||||||
|
|
||||||
listeners = await radio_station.get_listeners()
|
|
||||||
if listeners is None:
|
|
||||||
return await send_message(target, f"something went wrong")
|
|
||||||
if listeners == 0:
|
|
||||||
await send_message(target, f"no listeners, much sad :((")
|
|
||||||
|
|
||||||
msg = f"{listeners} client"
|
msg = f"{listeners} client"
|
||||||
if listeners >= 2:
|
if listeners >= 2:
|
||||||
msg += "s"
|
msg += "s"
|
||||||
msg += " connected"
|
msg += " connected"
|
||||||
return await send_message(target, msg)
|
return await send_message(target, msg)
|
||||||
|
return await send_message(target, f"no listeners, much sad :((")
|
||||||
|
except Exception as ex:
|
||||||
|
app.logger.error(f"{ex}")
|
||||||
|
await send_message(target=target, message="Error")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def queue(*args, target=None, nick=None, **kwargs):
|
async def queue(*args, target=None, nick=None, **kwargs):
|
||||||
"""show currently queued tracks"""
|
"""show currently queued tracks"""
|
||||||
from ircradio.models import Song
|
from ircradio.models import Song
|
||||||
from ircradio.factory import app
|
q: List[Song] = Radio.queues()
|
||||||
radio_station = await Commands._parse_radio_station(args, target)
|
|
||||||
if not radio_station:
|
|
||||||
return
|
|
||||||
|
|
||||||
q: List[Song] = await radio_station.queue_get()
|
|
||||||
if not q:
|
if not q:
|
||||||
return await send_message(target, "queue empty")
|
return await send_message(target, "queue empty")
|
||||||
|
|
||||||
for i, s in enumerate(q):
|
for i, s in enumerate(q):
|
||||||
await send_message(target, f"{s.utube_id} | {s.title}")
|
await send_message(target, f"{s.utube_id} | {s.title}")
|
||||||
if i >= 8:
|
if i >= 12:
|
||||||
await send_message(target, "And some more...")
|
await send_message(target, "And some more...")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@ -368,8 +274,8 @@ class Commands:
|
|||||||
|
|
||||||
for i in range(0, 5):
|
for i in range(0, 5):
|
||||||
song = random.choice(songs)
|
song = random.choice(songs)
|
||||||
res = await radio_default.queue(song.filepath)
|
|
||||||
if res:
|
if Radio.queue(song):
|
||||||
return await send_message(target, f"A random {added_by} has appeared in the queue: {song.title}")
|
return await send_message(target, f"A random {added_by} has appeared in the queue: {song.title}")
|
||||||
|
|
||||||
await send_message(target, "queue_user exhausted!")
|
await send_message(target, "queue_user exhausted!")
|
||||||
@ -386,11 +292,8 @@ class Commands:
|
|||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
async with DU_LOCK:
|
|
||||||
disk = os.popen(f"du -h {settings.dir_music}").read().split("\t")[0]
|
disk = os.popen(f"du -h {settings.dir_music}").read().split("\t")[0]
|
||||||
mixes = os.popen(f"du -h /home/radio/mixes/").read().split("\n")[-2].split("\t")[0]
|
await send_message(target, f"Songs: {songs} | Disk: {disk}")
|
||||||
|
|
||||||
await send_message(target, f"Songs: {songs} | Mixes: {mixes} | Songs: {disk}")
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def ban(*args, target=None, nick=None, **kwargs):
|
async def ban(*args, target=None, nick=None, **kwargs):
|
||||||
@ -422,45 +325,6 @@ class Commands:
|
|||||||
else:
|
else:
|
||||||
await send_message(target, "user")
|
await send_message(target, "user")
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def url(*args, target=None, nick=None, **kwargs):
|
|
||||||
radio_station = await Commands._parse_radio_station(args, target)
|
|
||||||
if not radio_station:
|
|
||||||
return
|
|
||||||
|
|
||||||
msg = f"https://{settings.icecast2_hostname}/{radio_station.mount_point}"
|
|
||||||
await send_message(target, msg)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def urls(*args, target=None, nick=None, **kwargs):
|
|
||||||
url = f"https://{settings.icecast2_hostname}/{radio_stations['wow'].mount_point}"
|
|
||||||
msg = f"main programming: {url}"
|
|
||||||
await send_message(target, msg)
|
|
||||||
|
|
||||||
msg = "mixes: "
|
|
||||||
for _, radio_station in radio_stations.items():
|
|
||||||
if _ == "wow":
|
|
||||||
continue
|
|
||||||
url = f"https://{settings.icecast2_hostname}/{radio_station.mount_point}"
|
|
||||||
msg += f"{url} "
|
|
||||||
await send_message(target, msg)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def _parse_radio_station(args, target) -> Optional[Station]:
|
|
||||||
extras = " ".join(args).strip()
|
|
||||||
if not extras or len(args) >= 2:
|
|
||||||
return radio_default
|
|
||||||
|
|
||||||
err = f", available streams: " + " ".join(radio_stations.keys())
|
|
||||||
if not extras:
|
|
||||||
msg = "nothing not found (?) :-P" + err
|
|
||||||
return await send_message(target, msg)
|
|
||||||
extra = extras.strip()
|
|
||||||
if extra not in radio_stations:
|
|
||||||
msg = f"station \"{extra}\" not found" + err
|
|
||||||
return await send_message(target, msg)
|
|
||||||
return radio_stations[extra]
|
|
||||||
|
|
||||||
|
|
||||||
@bot.on('PRIVMSG')
|
@bot.on('PRIVMSG')
|
||||||
async def message(nick, target, message, **kwargs):
|
async def message(nick, target, message, **kwargs):
|
||||||
@ -507,6 +371,7 @@ async def message(nick, target, message, **kwargs):
|
|||||||
await attr(*spl, **data)
|
await attr(*spl, **data)
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
app.logger.error(f"message_worker(): {ex}")
|
app.logger.error(f"message_worker(): {ex}")
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def start():
|
def start():
|
||||||
|
@ -1,21 +1,14 @@
|
|||||||
# SPDX-License-Identifier: BSD-3-Clause
|
# SPDX-License-Identifier: BSD-3-Clause
|
||||||
# Copyright (c) 2021, dsc@xmr.pm
|
# Copyright (c) 2021, dsc@xmr.pm
|
||||||
|
|
||||||
import functools
|
|
||||||
import os
|
import os
|
||||||
import logging
|
|
||||||
import json
|
|
||||||
import re
|
import re
|
||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime
|
||||||
from dataclasses import dataclass
|
|
||||||
|
|
||||||
import mutagen
|
import mutagen
|
||||||
from mutagen.oggvorbis import OggVorbisInfo, OggVCommentDict
|
|
||||||
import aiofiles
|
|
||||||
from peewee import SqliteDatabase, SQL
|
from peewee import SqliteDatabase, SQL
|
||||||
import peewee as pw
|
import peewee as pw
|
||||||
from quart import current_app
|
|
||||||
|
|
||||||
from ircradio.youtube import YouTube
|
from ircradio.youtube import YouTube
|
||||||
import settings
|
import settings
|
||||||
@ -30,46 +23,16 @@ class Ban(pw.Model):
|
|||||||
class Meta:
|
class Meta:
|
||||||
database = db
|
database = db
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class SongMeta:
|
|
||||||
title: Optional[str] = None
|
|
||||||
description: Optional[str] = None
|
|
||||||
artist: Optional[str] = None
|
|
||||||
album: Optional[str] = None
|
|
||||||
album_cover: Optional[str] = None # path to image
|
|
||||||
bitrate: Optional[int] = None
|
|
||||||
length: Optional[float] = None
|
|
||||||
sample_rate: Optional[int] = None
|
|
||||||
channels: Optional[int] = None
|
|
||||||
mime: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
class Song(pw.Model):
|
class Song(pw.Model):
|
||||||
id = pw.AutoField()
|
id = pw.AutoField()
|
||||||
date_added: datetime = pw.DateTimeField(default=datetime.now)
|
date_added = pw.DateTimeField(default=datetime.now)
|
||||||
|
|
||||||
title: str = pw.CharField(index=True)
|
title = pw.CharField(index=True)
|
||||||
utube_id: str = pw.CharField(index=True, unique=True)
|
utube_id = pw.CharField(index=True, unique=True)
|
||||||
added_by: str = pw.CharField(index=True, constraints=[SQL('COLLATE NOCASE')]) # ILIKE index
|
added_by = pw.CharField(index=True, constraints=[SQL('COLLATE NOCASE')]) # ILIKE index
|
||||||
duration: int = pw.IntegerField() # seconds
|
duration = pw.IntegerField()
|
||||||
karma: int = pw.IntegerField(default=5, index=True)
|
karma = pw.IntegerField(default=5, index=True)
|
||||||
banned: bool = pw.BooleanField(default=False)
|
banned = pw.BooleanField(default=False)
|
||||||
|
|
||||||
meta: SongMeta = None # directly from file (exif) or metadata json
|
|
||||||
path: Optional[str] = None
|
|
||||||
remaining: int = None # liquidsoap playing status in seconds
|
|
||||||
|
|
||||||
@property
|
|
||||||
def to_json(self):
|
|
||||||
return {
|
|
||||||
"title": self.title,
|
|
||||||
"utube_id": self.utube_id,
|
|
||||||
"added_by": self.added_by,
|
|
||||||
"duration": self.duration,
|
|
||||||
"karma": self.karma,
|
|
||||||
"banned": self.banned
|
|
||||||
}
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def delete_song(utube_id: str) -> bool:
|
def delete_song(utube_id: str) -> bool:
|
||||||
@ -110,107 +73,43 @@ class Song(pw.Model):
|
|||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@classmethod
|
@staticmethod
|
||||||
async def from_filepath(cls, path: str) -> 'Song':
|
def from_filepath(filepath: str) -> Optional['Song']:
|
||||||
if not os.path.exists(path):
|
fn = os.path.basename(filepath)
|
||||||
raise Exception("filepath does not exist")
|
name, ext = fn.split(".", 1)
|
||||||
|
if not YouTube.is_valid_uid(name):
|
||||||
# try to detect youtube id in filename
|
raise Exception("invalid youtube id")
|
||||||
basename = os.path.splitext(os.path.basename(path))[0]
|
|
||||||
|
|
||||||
if YouTube.is_valid_uid(basename):
|
|
||||||
try:
|
try:
|
||||||
song = cls.select().where(Song.utube_id == basename).get()
|
return Song.select().filter(utube_id=name).get()
|
||||||
except Exception as ex:
|
|
||||||
song = Song()
|
|
||||||
else:
|
|
||||||
song = Song()
|
|
||||||
|
|
||||||
# scan for metadata
|
|
||||||
await song.scan(path)
|
|
||||||
return song
|
|
||||||
|
|
||||||
async def scan(self, path: str = None):
|
|
||||||
# update with metadata, etc.
|
|
||||||
if path is None:
|
|
||||||
path = self.filepath
|
|
||||||
|
|
||||||
if not os.path.exists(path):
|
|
||||||
raise Exception(f"filepath {path} does not exist")
|
|
||||||
basename = os.path.splitext(os.path.basename(path))[0]
|
|
||||||
|
|
||||||
self.meta = SongMeta()
|
|
||||||
self.path = path
|
|
||||||
|
|
||||||
# EXIF direct
|
|
||||||
from ircradio.utils import mutagen_file
|
|
||||||
_m = await mutagen_file(path)
|
|
||||||
|
|
||||||
if _m:
|
|
||||||
if _m.info:
|
|
||||||
self.meta.channels = _m.info.channels
|
|
||||||
self.meta.length = int(_m.info.length)
|
|
||||||
if hasattr(_m.info, 'bitrate'):
|
|
||||||
self.meta.bitrate = _m.info.bitrate
|
|
||||||
if hasattr(_m.info, 'sample_rate'):
|
|
||||||
self.meta.sample_rate = _m.info.sample_rate
|
|
||||||
if _m.tags:
|
|
||||||
if hasattr(_m.tags, 'as_dict'):
|
|
||||||
_tags = _m.tags.as_dict()
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
_tags = {k: v for k, v in _m.tags.items()}
|
|
||||||
except:
|
except:
|
||||||
_tags = {}
|
return Song.auto_create_from_filepath(filepath)
|
||||||
|
|
||||||
for k, v in _tags.items():
|
@staticmethod
|
||||||
if isinstance(v, list):
|
def auto_create_from_filepath(filepath: str) -> Optional['Song']:
|
||||||
v = v[0]
|
from ircradio.factory import app
|
||||||
elif isinstance(v, (str, int, float)):
|
fn = os.path.basename(filepath)
|
||||||
pass
|
uid, ext = fn.split(".", 1)
|
||||||
else:
|
if not YouTube.is_valid_uid(uid):
|
||||||
continue
|
raise Exception("invalid youtube id")
|
||||||
|
|
||||||
if k in ["title", "description", "language", "date", "purl", "artist"]:
|
metadata = YouTube.metadata_from_filepath(filepath)
|
||||||
if hasattr(self.meta, k):
|
if not metadata:
|
||||||
setattr(self.meta, k, v)
|
return
|
||||||
|
|
||||||
|
app.logger.info(f"auto-creating for {fn}")
|
||||||
|
|
||||||
# yt-dlp metadata json file
|
|
||||||
fn_utube_meta = os.path.join(settings.dir_meta, f"{basename}.info.json")
|
|
||||||
utube_meta = {}
|
|
||||||
if os.path.exists(fn_utube_meta):
|
|
||||||
async with aiofiles.open(fn_utube_meta, mode="r") as f:
|
|
||||||
try:
|
try:
|
||||||
utube_meta = json.loads(await f.read())
|
song = Song.create(
|
||||||
|
duration=metadata['duration'],
|
||||||
|
title=metadata['name'],
|
||||||
|
added_by='radio',
|
||||||
|
karma=5,
|
||||||
|
utube_id=uid)
|
||||||
|
return song
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
logging.error(f"could not parse {fn_utube_meta}, {ex}")
|
app.logger.error(f"{ex}")
|
||||||
|
|
||||||
if utube_meta:
|
|
||||||
# utube_meta file does not have anything we care about
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
if not self.title and not self.meta.title:
|
|
||||||
# just adopt filename
|
|
||||||
self.title = os.path.basename(self.path)
|
|
||||||
|
|
||||||
return self
|
|
||||||
|
|
||||||
@property
|
|
||||||
def title_for_real_nocap(self):
|
|
||||||
if self.title:
|
|
||||||
return self.title
|
|
||||||
if self.meta.title:
|
|
||||||
return self.meta.title
|
|
||||||
return "unknown!"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def title_cleaned(self):
|
|
||||||
_title = self.title_for_real_nocap
|
|
||||||
_title = re.sub(r"\(official\)", "", _title, flags=re.IGNORECASE)
|
|
||||||
_title = re.sub(r"\(official \w+\)", "", _title, flags=re.IGNORECASE)
|
|
||||||
_title = _title.replace(" - Topic - ", "")
|
|
||||||
return _title
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def filepath(self):
|
def filepath(self):
|
||||||
"""Absolute"""
|
"""Absolute"""
|
||||||
@ -224,61 +123,5 @@ class Song(pw.Model):
|
|||||||
except:
|
except:
|
||||||
return self.filepath
|
return self.filepath
|
||||||
|
|
||||||
def image(self) -> Optional[str]:
|
|
||||||
dirname = os.path.dirname(self.path)
|
|
||||||
basename = os.path.basename(self.path)
|
|
||||||
name, ext = os.path.splitext(basename)
|
|
||||||
|
|
||||||
for _dirname in [dirname, settings.dir_meta]:
|
|
||||||
for _ext in ["", ext]:
|
|
||||||
meta_json = os.path.join(_dirname, name + _ext + ".info.json")
|
|
||||||
if os.path.exists(meta_json):
|
|
||||||
try:
|
|
||||||
f = open(meta_json, "r")
|
|
||||||
data = f.read()
|
|
||||||
f.close()
|
|
||||||
|
|
||||||
blob = json.loads(data)
|
|
||||||
if "thumbnails" in blob and isinstance(blob['thumbnails'], list):
|
|
||||||
thumbs = list(sorted(blob['thumbnails'], key=lambda k: int(k['id']), reverse=True))
|
|
||||||
image_id = thumbs[0]['id']
|
|
||||||
|
|
||||||
for sep in ['.', "_"]:
|
|
||||||
_fn = os.path.join(_dirname, name + _ext + f"{sep}{image_id}")
|
|
||||||
for img_ext in ['webp', 'jpg', 'jpeg', 'png']:
|
|
||||||
_fn_full = f"{_fn}.{img_ext}"
|
|
||||||
if os.path.exists(_fn_full):
|
|
||||||
return os.path.basename(_fn_full)
|
|
||||||
except Exception as ex:
|
|
||||||
logging.error(f"could not parse {meta_json}, {ex}")
|
|
||||||
|
|
||||||
def time_status(self) -> Optional[tuple]:
|
|
||||||
if not self.remaining:
|
|
||||||
return
|
|
||||||
|
|
||||||
duration = self.duration
|
|
||||||
if not duration:
|
|
||||||
if self.meta.length:
|
|
||||||
duration = int(self.meta.length)
|
|
||||||
if not duration:
|
|
||||||
return
|
|
||||||
|
|
||||||
_ = lambda k: timedelta(seconds=k)
|
|
||||||
a = _(duration - self.remaining)
|
|
||||||
b = _(duration)
|
|
||||||
return a, b
|
|
||||||
|
|
||||||
def time_status_str(self) -> Optional[str]:
|
|
||||||
_status = self.time_status()
|
|
||||||
if not _status:
|
|
||||||
return
|
|
||||||
|
|
||||||
a, b = map(str, _status)
|
|
||||||
if a.startswith("0:") and \
|
|
||||||
b.startswith("0:"):
|
|
||||||
a = a[2:]
|
|
||||||
b = b[2:]
|
|
||||||
return f"({a}/{b})"
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
database = db
|
database = db
|
||||||
|
@ -1,8 +1,7 @@
|
|||||||
# SPDX-License-Identifier: BSD-3-Clause
|
# SPDX-License-Identifier: BSD-3-Clause
|
||||||
# Copyright (c) 2021, dsc@xmr.pm
|
# Copyright (c) 2021, dsc@xmr.pm
|
||||||
import logging
|
|
||||||
import re
|
import re
|
||||||
import json
|
|
||||||
import os
|
import os
|
||||||
import socket
|
import socket
|
||||||
from typing import List, Optional, Dict
|
from typing import List, Optional, Dict
|
||||||
@ -11,70 +10,159 @@ import sys
|
|||||||
|
|
||||||
import settings
|
import settings
|
||||||
from ircradio.models import Song
|
from ircradio.models import Song
|
||||||
from ircradio.station import Station
|
|
||||||
from ircradio.utils import httpget
|
from ircradio.utils import httpget
|
||||||
from ircradio.youtube import YouTube
|
from ircradio.youtube import YouTube
|
||||||
|
|
||||||
|
|
||||||
class Radio:
|
class Radio:
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def icecast_metadata(radio: Station) -> dict:
|
def queue(song: Song) -> bool:
|
||||||
cache_key = f"icecast_meta_{radio.id}"
|
from ircradio.factory import app
|
||||||
|
queues = Radio.queues()
|
||||||
|
queues_filepaths = [s.filepath for s in queues]
|
||||||
|
|
||||||
from quart import current_app
|
if song.filepath in queues_filepaths:
|
||||||
if current_app: # caching only when Quart is active
|
app.logger.info(f"already added to queue: {song.filepath}")
|
||||||
cache: SessionInterface = current_app.session_interface
|
return False
|
||||||
res = await cache.get(cache_key)
|
|
||||||
if res:
|
|
||||||
return json.loads(res)
|
|
||||||
|
|
||||||
|
Radio.command(f"requests.push {song.filepath}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def skip() -> None:
|
||||||
|
Radio.command(f"{settings.liquidsoap_iface}.skip")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def queues() -> Optional[List[Song]]:
|
||||||
|
"""get queued songs"""
|
||||||
|
from ircradio.factory import app
|
||||||
|
|
||||||
|
queues = Radio.command(f"requests.queue")
|
||||||
|
try:
|
||||||
|
queues = [q for q in queues.split(b"\r\n") if q != b"END" and q]
|
||||||
|
if not queues:
|
||||||
|
return []
|
||||||
|
queues = [q.decode() for q in queues[0].split(b" ")]
|
||||||
|
except Exception as ex:
|
||||||
|
app.logger.error(str(ex))
|
||||||
|
raise Exception("Error")
|
||||||
|
|
||||||
|
paths = []
|
||||||
|
for request_id in queues:
|
||||||
|
meta = Radio.command(f"request.metadata {request_id}")
|
||||||
|
path = Radio.filenames_from_strlist(meta.decode(errors="ignore").split("\n"))
|
||||||
|
if path:
|
||||||
|
paths.append(path[0])
|
||||||
|
|
||||||
|
songs = []
|
||||||
|
for fn in list(dict.fromkeys(paths)):
|
||||||
|
try:
|
||||||
|
song = Song.from_filepath(fn)
|
||||||
|
if not song:
|
||||||
|
continue
|
||||||
|
songs.append(song)
|
||||||
|
except Exception as ex:
|
||||||
|
app.logger.warning(f"skipping {fn}; file not found or something: {ex}")
|
||||||
|
|
||||||
|
# remove the now playing song from the queue
|
||||||
|
now_playing = Radio.now_playing()
|
||||||
|
if songs and now_playing:
|
||||||
|
if songs[0].filepath == now_playing.filepath:
|
||||||
|
songs = songs[1:]
|
||||||
|
return songs
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def get_icecast_metadata() -> Optional[Dict]:
|
||||||
|
from ircradio.factory import app
|
||||||
|
# http://127.0.0.1:24100/status-json.xsl
|
||||||
url = f"http://{settings.icecast2_bind_host}:{settings.icecast2_bind_port}"
|
url = f"http://{settings.icecast2_bind_host}:{settings.icecast2_bind_port}"
|
||||||
url = f"{url}/status-json.xsl"
|
url = f"{url}/status-json.xsl"
|
||||||
|
try:
|
||||||
blob = await httpget(url, json=True)
|
blob = await httpget(url, json=True)
|
||||||
if not isinstance(blob, dict) or "icestats" not in blob:
|
if not isinstance(blob, dict) or "icestats" not in blob:
|
||||||
raise Exception("icecast2 metadata not dict")
|
raise Exception("icecast2 metadata not dict")
|
||||||
|
return blob["icestats"].get('source')
|
||||||
arr = blob["icestats"].get('source')
|
|
||||||
if not isinstance(arr, list) or not arr:
|
|
||||||
raise Exception("no metadata results #1")
|
|
||||||
|
|
||||||
try:
|
|
||||||
res = next(r for r in arr if radio.mount_point == r['server_name'])
|
|
||||||
|
|
||||||
from quart import current_app
|
|
||||||
if current_app: # caching only when Quart is active
|
|
||||||
cache: SessionInterface = current_app.session_interface
|
|
||||||
await cache.set(cache_key, json.dumps(res), expiry=4)
|
|
||||||
|
|
||||||
return res
|
|
||||||
|
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
raise Exception("no metadata results #2")
|
app.logger.error(f"{ex}")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def command(cmd: str) -> bytes:
|
def history() -> Optional[List[Song]]:
|
||||||
|
# 0 = currently playing
|
||||||
|
from ircradio.factory import app
|
||||||
|
|
||||||
|
try:
|
||||||
|
status = Radio.command(f"{settings.liquidsoap_iface}.metadata")
|
||||||
|
status = status.decode(errors="ignore")
|
||||||
|
except Exception as ex:
|
||||||
|
app.logger.error(f"{ex}")
|
||||||
|
raise Exception("failed to contact liquidsoap")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# paths = re.findall(r"filename=\"(.*)\"", status)
|
||||||
|
paths = Radio.filenames_from_strlist(status.split("\n"))
|
||||||
|
# reverse, limit
|
||||||
|
paths = paths[::-1][:5]
|
||||||
|
|
||||||
|
songs = []
|
||||||
|
for fn in list(dict.fromkeys(paths)):
|
||||||
|
try:
|
||||||
|
song = Song.from_filepath(fn)
|
||||||
|
if not song:
|
||||||
|
continue
|
||||||
|
songs.append(song)
|
||||||
|
except Exception as ex:
|
||||||
|
app.logger.warning(f"skipping {fn}; file not found or something: {ex}")
|
||||||
|
except Exception as ex:
|
||||||
|
app.logger.error(f"{ex}")
|
||||||
|
app.logger.error(f"liquidsoap status:\n{status}")
|
||||||
|
raise Exception("error parsing liquidsoap status")
|
||||||
|
return songs
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def command(cmd: str) -> bytes:
|
||||||
"""via LiquidSoap control port"""
|
"""via LiquidSoap control port"""
|
||||||
from datetime import datetime
|
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
|
sock.connect((settings.liquidsoap_host, settings.liquidsoap_port))
|
||||||
if settings.debug:
|
sock.sendall(cmd.encode() + b"\n")
|
||||||
print(f"cmd: {cmd}")
|
data = sock.recv(4096*1000)
|
||||||
|
sock.close()
|
||||||
try:
|
|
||||||
reader, writer = await asyncio.open_connection(
|
|
||||||
settings.liquidsoap_host, settings.liquidsoap_port)
|
|
||||||
except Exception as ex:
|
|
||||||
raise Exception(f"error connecting to {settings.liquidsoap_host}:{settings.liquidsoap_port}: {ex}")
|
|
||||||
|
|
||||||
writer.write(cmd.encode() + b"\n")
|
|
||||||
await writer.drain()
|
|
||||||
|
|
||||||
try:
|
|
||||||
task = reader.readuntil(b"\x0d\x0aEND\x0d\x0a")
|
|
||||||
data = await asyncio.wait_for(task, 1)
|
|
||||||
except Exception as ex:
|
|
||||||
logging.error(ex)
|
|
||||||
return b""
|
|
||||||
|
|
||||||
writer.close()
|
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def liquidsoap_reachable():
|
||||||
|
from ircradio.factory import app
|
||||||
|
try:
|
||||||
|
Radio.command("help")
|
||||||
|
except Exception as ex:
|
||||||
|
app.logger.error("liquidsoap not reachable")
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def now_playing():
|
||||||
|
try:
|
||||||
|
now_playing = Radio.history()
|
||||||
|
if now_playing:
|
||||||
|
return now_playing[0]
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def listeners():
|
||||||
|
data: dict = await Radio.get_icecast_metadata()
|
||||||
|
if not data:
|
||||||
|
return 0
|
||||||
|
return data.get('listeners', 0)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def filenames_from_strlist(strlist: List[str]) -> List[str]:
|
||||||
|
paths = []
|
||||||
|
for line in strlist:
|
||||||
|
if not line.startswith("filename"):
|
||||||
|
continue
|
||||||
|
line = line[10:]
|
||||||
|
fn = line[:-1]
|
||||||
|
if not os.path.exists(fn):
|
||||||
|
continue
|
||||||
|
paths.append(fn)
|
||||||
|
return paths
|
||||||
|
@ -1,11 +1,9 @@
|
|||||||
# SPDX-License-Identifier: BSD-3-Clause
|
# SPDX-License-Identifier: BSD-3-Clause
|
||||||
# Copyright (c) 2021, dsc@xmr.pm
|
# Copyright (c) 2021, dsc@xmr.pm
|
||||||
|
|
||||||
import os, re, dataclasses, random
|
|
||||||
from glob import glob
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Tuple, Optional
|
from typing import Tuple, Optional
|
||||||
from quart import request, render_template, abort, jsonify, send_from_directory, current_app, websocket, redirect, session, url_for
|
from quart import request, render_template, abort, jsonify
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
|
|
||||||
@ -16,15 +14,31 @@ from ircradio.radio import Radio
|
|||||||
|
|
||||||
@app.route("/")
|
@app.route("/")
|
||||||
async def root():
|
async def root():
|
||||||
return await render_template("index.html", settings=settings, radio_stations=settings.radio_stations.values())
|
return await render_template("index.html", settings=settings)
|
||||||
|
|
||||||
|
|
||||||
@app.route("/login")
|
history_cache: Optional[Tuple] = None
|
||||||
async def login():
|
|
||||||
from ircradio.factory import keycloak
|
|
||||||
if 'auth_token' not in session:
|
@app.route("/history.txt")
|
||||||
return redirect(url_for(keycloak.endpoint_name_login))
|
async def history():
|
||||||
return redirect('root')
|
global history_cache
|
||||||
|
now = datetime.now()
|
||||||
|
if history_cache:
|
||||||
|
if (now - history_cache[0]).total_seconds() <= 5:
|
||||||
|
print("from cache")
|
||||||
|
return history_cache[1]
|
||||||
|
|
||||||
|
history = Radio.history()
|
||||||
|
if not history:
|
||||||
|
return "no history"
|
||||||
|
|
||||||
|
data = ""
|
||||||
|
for i, s in enumerate(history[:10]):
|
||||||
|
data += f"{i+1}) <a target=\"_blank\" href=\"https://www.youtube.com/watch?v={s.utube_id}\">{s.utube_id}</a>; {s.title} <br>"
|
||||||
|
|
||||||
|
history_cache = [now, data]
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
@app.route("/search")
|
@app.route("/search")
|
||||||
@ -75,14 +89,10 @@ async def search():
|
|||||||
|
|
||||||
@app.route("/library")
|
@app.route("/library")
|
||||||
async def user_library():
|
async def user_library():
|
||||||
from ircradio.factory import keycloak
|
|
||||||
if 'auth_token' not in session:
|
|
||||||
return redirect(url_for(keycloak.endpoint_name_login))
|
|
||||||
|
|
||||||
from ircradio.models import Song
|
from ircradio.models import Song
|
||||||
name = request.args.get("name")
|
name = request.args.get("name")
|
||||||
if not name:
|
if not name:
|
||||||
return await render_template('user.html')
|
abort(404)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
by_date = Song.select().filter(Song.added_by == name)\
|
by_date = Song.select().filter(Song.added_by == name)\
|
||||||
@ -99,243 +109,25 @@ async def user_library():
|
|||||||
except:
|
except:
|
||||||
by_karma = []
|
by_karma = []
|
||||||
|
|
||||||
return await render_template("user_library.html", name=name, by_date=by_date, by_karma=by_karma)
|
return await render_template("library.html", name=name, by_date=by_date, by_karma=by_karma)
|
||||||
|
|
||||||
|
|
||||||
@app.route("/request")
|
|
||||||
async def request_song():
|
|
||||||
from ircradio.factory import keycloak
|
|
||||||
if 'auth_token' not in session:
|
|
||||||
return redirect(url_for(keycloak.endpoint_name_login))
|
|
||||||
return await render_template('request.html')
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/songs')
|
|
||||||
async def api_songs():
|
|
||||||
from ircradio.factory import keycloak
|
|
||||||
from ircradio.models import Song, db
|
|
||||||
if 'auth_token' not in session:
|
|
||||||
return abort(403)
|
|
||||||
|
|
||||||
q = """
|
|
||||||
SELECT title, utube_id, added_by, karma
|
|
||||||
FROM song
|
|
||||||
WHERE
|
|
||||||
ORDER BY date_added DESC
|
|
||||||
LIMIT ? OFFSET ?;
|
|
||||||
"""
|
|
||||||
|
|
||||||
limit = int(request.args.get('limit', 150))
|
|
||||||
offset = int(request.args.get('offset', 0))
|
|
||||||
search = request.args.get('search', '')
|
|
||||||
sort_by = request.args.get('sort')
|
|
||||||
order = request.args.get('order', 'DESC')
|
|
||||||
if order.lower() in ['desc', 'asc']:
|
|
||||||
order = "desc" if order == "asc" else "desc" # yolo
|
|
||||||
q = q.replace('DESC', order)
|
|
||||||
if sort_by == "karma":
|
|
||||||
q = q.replace('date_added', 'karma')
|
|
||||||
|
|
||||||
args = [limit, offset]
|
|
||||||
|
|
||||||
if isinstance(search, str):
|
|
||||||
search = search[:8]
|
|
||||||
search = search.replace('%', '')
|
|
||||||
args.insert(0, f"%{search}%")
|
|
||||||
q = q.replace('WHERE', f'WHERE title LIKE ?')
|
|
||||||
else:
|
|
||||||
q = q.replace('WHERE', f'')
|
|
||||||
|
|
||||||
songs = []
|
|
||||||
cursor = db.execute_sql(q, tuple(args)) # no sqli for all the naughty people!!
|
|
||||||
for row in cursor.fetchall():
|
|
||||||
songs.append({'title': row[0], 'uid': row[1], 'added_by': row[2], 'karma': row[3]})
|
|
||||||
|
|
||||||
return jsonify(songs)
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/request/<path:utube_id>')
|
|
||||||
async def api_request(utube_id: str = None):
|
|
||||||
from ircradio.models import Song
|
|
||||||
from ircradio.factory import irc_message_announce_bus
|
|
||||||
if not utube_id:
|
|
||||||
return abort(500)
|
|
||||||
if 'auth_token' not in session:
|
|
||||||
return abort(403)
|
|
||||||
user = session['auth_token']
|
|
||||||
username = user.get('preferred_username')
|
|
||||||
|
|
||||||
try:
|
|
||||||
song = Song.select().filter(Song.utube_id == utube_id).get()
|
|
||||||
except Exception as ex:
|
|
||||||
return abort(404)
|
|
||||||
|
|
||||||
radio_default = settings.radio_stations['wow']
|
|
||||||
await radio_default.queue_push(song.path or song.filepath)
|
|
||||||
|
|
||||||
msg = f"{username} added {song.title} to the queue via webif"
|
|
||||||
await irc_message_announce_bus.put(msg)
|
|
||||||
|
|
||||||
return jsonify({})
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/boo/<path:radio_id>')
|
|
||||||
async def api_boo(radio_id: str):
|
|
||||||
from ircradio.models import Song
|
|
||||||
from ircradio.factory import irc_message_announce_bus
|
|
||||||
if not radio_id or radio_id not in settings.radio_stations:
|
|
||||||
return abort(500)
|
|
||||||
if 'auth_token' not in session:
|
|
||||||
return abort(403)
|
|
||||||
user = session['auth_token']
|
|
||||||
username = user.get('preferred_username')
|
|
||||||
|
|
||||||
# throttling
|
|
||||||
cache_key = f"throttle_api_boo_{username}"
|
|
||||||
res = await current_app.session_interface.get(cache_key)
|
|
||||||
if res:
|
|
||||||
return jsonify({}) # silently fail
|
|
||||||
|
|
||||||
radio_default = settings.radio_stations['wow']
|
|
||||||
song = await radio_default.np()
|
|
||||||
if not song:
|
|
||||||
current_app.logger.error(f"Nothing is playing?!")
|
|
||||||
return abort(500)
|
|
||||||
|
|
||||||
if song.karma >= 1:
|
|
||||||
song.karma -= 1
|
|
||||||
song.save()
|
|
||||||
|
|
||||||
# set cache
|
|
||||||
await current_app.session_interface.set(cache_key, b'1', 15)
|
|
||||||
|
|
||||||
hates = ['throwing shade', 'hating', 'boo\'ing', 'throwing tomatoes', 'flipping tables', 'raging']
|
|
||||||
msg = f"{username} {random.choice(hates)} from webif .. \"{song.title}\" is now {song.karma}/10 .. BOOO!!!!"
|
|
||||||
await irc_message_announce_bus.put(msg)
|
|
||||||
return jsonify({'msg': msg})
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/tune/<path:radio_id>')
|
|
||||||
async def api_tune(radio_id: str):
|
|
||||||
from ircradio.models import Song
|
|
||||||
from ircradio.factory import irc_message_announce_bus
|
|
||||||
if not radio_id or radio_id not in settings.radio_stations:
|
|
||||||
return abort(500)
|
|
||||||
if 'auth_token' not in session:
|
|
||||||
return abort(403)
|
|
||||||
user = session['auth_token']
|
|
||||||
username = user.get('preferred_username')
|
|
||||||
|
|
||||||
# throttling
|
|
||||||
cache_key = f"throttle_api_tune_{username}"
|
|
||||||
res = await current_app.session_interface.get(cache_key)
|
|
||||||
if res:
|
|
||||||
return jsonify({}) # silently fail
|
|
||||||
|
|
||||||
radio_default = settings.radio_stations['wow']
|
|
||||||
song = await radio_default.np()
|
|
||||||
if not song:
|
|
||||||
return await send_message(target, f"Nothing is playing?!")
|
|
||||||
|
|
||||||
song.karma += 1
|
|
||||||
song.save()
|
|
||||||
|
|
||||||
# set cache
|
|
||||||
await current_app.session_interface.set(cache_key, b'1', 15)
|
|
||||||
|
|
||||||
loves = ['dancing', 'vibin\'', 'boppin\'', 'breakdancing', 'raving', 'chair dancing']
|
|
||||||
msg = f"{username} {random.choice(loves)} .. \"{song.title}\" is now {song.karma}/10 .. PARTY ON!!!!"
|
|
||||||
await irc_message_announce_bus.put(msg)
|
|
||||||
return jsonify({'msg': msg})
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/skip/<path:radio_id>')
|
|
||||||
async def api_skip(radio_id: str):
|
|
||||||
from ircradio.models import Song
|
|
||||||
from ircradio.factory import irc_message_announce_bus
|
|
||||||
if not radio_id or radio_id not in settings.radio_stations:
|
|
||||||
return abort(500)
|
|
||||||
if 'auth_token' not in session:
|
|
||||||
return abort(403)
|
|
||||||
user = session['auth_token']
|
|
||||||
username = user.get('preferred_username')
|
|
||||||
|
|
||||||
# throttling
|
|
||||||
cache_key = f"throttle_api_skip_{radio_id}_{username}"
|
|
||||||
res = await current_app.session_interface.get(cache_key)
|
|
||||||
if res:
|
|
||||||
return jsonify({}) # silently fail
|
|
||||||
|
|
||||||
radio_station = settings.radio_stations[radio_id]
|
|
||||||
await radio_station.skip()
|
|
||||||
|
|
||||||
# set cache
|
|
||||||
await current_app.session_interface.set(cache_key, b'1', 15)
|
|
||||||
|
|
||||||
hates = ['Booo', 'Rude', 'Wtf']
|
|
||||||
msg = f"{username} skipped. {random.choice(hates)}! >:|"
|
|
||||||
|
|
||||||
if radio_station.id == "wow":
|
|
||||||
await irc_message_announce_bus.put(msg)
|
|
||||||
|
|
||||||
return jsonify({'msg': msg})
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/history")
|
|
||||||
async def history():
|
|
||||||
from ircradio.factory import keycloak
|
|
||||||
if 'auth_token' not in session:
|
|
||||||
return redirect(url_for(keycloak.endpoint_name_login))
|
|
||||||
|
|
||||||
radio_default = settings.radio_stations['wow']
|
|
||||||
songs = await radio_default.history()
|
|
||||||
if not songs:
|
|
||||||
return "no history"
|
|
||||||
|
|
||||||
return await render_template('history.html', songs=songs)
|
|
||||||
|
|
||||||
|
|
||||||
@app.websocket("/ws")
|
@app.websocket("/ws")
|
||||||
async def ws():
|
async def np():
|
||||||
current_app.logger.info('websocket client connected')
|
last_song = ""
|
||||||
from ircradio.factory import websocket_status_bus, websocket_status_bus_last_item
|
|
||||||
from ircradio.station import Station
|
|
||||||
|
|
||||||
async def send_all(data: dict[str, Station]):
|
|
||||||
return await websocket.send_json({
|
|
||||||
k: dataclasses.asdict(v) for k, v in data.items()
|
|
||||||
})
|
|
||||||
|
|
||||||
if isinstance(websocket_status_bus_last_item, dict):
|
|
||||||
current_app.logger.debug('sending data to ws peer')
|
|
||||||
await send_all(websocket_status_bus_last_item)
|
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
async for data in websocket_status_bus.subscribe():
|
"""get current song from history"""
|
||||||
current_app.logger.debug('sending data to ws peer')
|
history = Radio.history()
|
||||||
await send_all(data)
|
val = ""
|
||||||
|
if not history:
|
||||||
|
val = f"Nothing is playing?!"
|
||||||
|
else:
|
||||||
|
song = history[0]
|
||||||
|
val = song.title
|
||||||
|
|
||||||
|
if val != last_song:
|
||||||
|
data = json.dumps({"now_playing": val})
|
||||||
|
await websocket.send(f"{data}")
|
||||||
|
|
||||||
@app.route("/assets/art/<path:path>")
|
last_song = val
|
||||||
async def assets_art(path: str):
|
await asyncio.sleep(5)
|
||||||
img_default = "album_art_default.jpg"
|
|
||||||
_base = os.path.join(settings.cwd, "ircradio", "static")
|
|
||||||
mix_dirs = glob(settings.dir_mixes + "/*")
|
|
||||||
|
|
||||||
try:
|
|
||||||
for _dirname in [settings.dir_meta, settings.dir_music, *mix_dirs]:
|
|
||||||
_path = os.path.join(_dirname, path)
|
|
||||||
if os.path.exists(_path):
|
|
||||||
return await send_from_directory(_dirname, path)
|
|
||||||
except Exception as ex:
|
|
||||||
current_app.logger.debug(ex)
|
|
||||||
return await send_from_directory(_base, img_default), 500
|
|
||||||
|
|
||||||
return await send_from_directory(_base, img_default), 404
|
|
||||||
|
|
||||||
|
|
||||||
@app.route("/static_music_meta/<path:path>")
|
|
||||||
async def static_music_meta(path: str):
|
|
||||||
return await send_from_directory(
|
|
||||||
settings.dir_meta,
|
|
||||||
file_name=path)
|
|
||||||
|
Before Width: | Height: | Size: 35 KiB |
Before Width: | Height: | Size: 1.4 MiB |
Before Width: | Height: | Size: 1.1 MiB |
Before Width: | Height: | Size: 12 KiB |
Before Width: | Height: | Size: 224 KiB |
Before Width: | Height: | Size: 145 KiB |
@ -1,43 +0,0 @@
|
|||||||
.hero {max-width:700px;}
|
|
||||||
a{color: #82b2e5;}
|
|
||||||
body {background-color: inherit !important; background: linear-gradient(45deg, #07070a, #001929);}
|
|
||||||
button, small.footer { text-align: left !important; }
|
|
||||||
button[data-playing="true"] { color: white; }
|
|
||||||
.container {max-width:96%;}
|
|
||||||
.card {border: 1px solid rgba(250,250,250,.1) !important;}
|
|
||||||
.card-body {background-color: #151515;}
|
|
||||||
.card-header{background-color: rgb(25, 25, 25) !important; text-align: center;}
|
|
||||||
.text-muted { color: #dedede !important;}
|
|
||||||
.btn-outline-primary {
|
|
||||||
color: #5586b7;
|
|
||||||
border-color: #527ca8;
|
|
||||||
}
|
|
||||||
@keyframes gradient {
|
|
||||||
0% { background-position: 0% 50%; }
|
|
||||||
50% { background-position: 100% 50%; }
|
|
||||||
100% { background-position: 0% 50%; }
|
|
||||||
}
|
|
||||||
.btn-active {
|
|
||||||
color:white;
|
|
||||||
background: linear-gradient(45deg, rgba(255, 42, 212, 1) 0%, rgba(255, 204, 0, 1) 100%);
|
|
||||||
background-size: 400% 400%;
|
|
||||||
animation: gradient 3s ease infinite;
|
|
||||||
}
|
|
||||||
.bootstrap-table .search {
|
|
||||||
float: none !important;
|
|
||||||
width: 100% !important;
|
|
||||||
}
|
|
||||||
.card-header {padding: .5rem 1rem;}
|
|
||||||
.card-header .font-weight-normal {font-size: 1.2rem;}
|
|
||||||
.card-img-top {
|
|
||||||
width: 100%;
|
|
||||||
max-height: 170px;
|
|
||||||
object-fit: cover;
|
|
||||||
}
|
|
||||||
.card-footer {
|
|
||||||
border:none !important;
|
|
||||||
background-color: #151515 !important;
|
|
||||||
}
|
|
||||||
@media screen and (min-width:1100px){
|
|
||||||
.container {max-width:1200px;}
|
|
||||||
}
|
|
@ -1,24 +0,0 @@
|
|||||||
function ws_connect(ws_url, onData) {
|
|
||||||
console.log('connecting');
|
|
||||||
var ws = new WebSocket(ws_url);
|
|
||||||
ws.onopen = function() {
|
|
||||||
// nothing
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onmessage = function(e) {
|
|
||||||
console.log('Message:', e.data);
|
|
||||||
onData(e.data);
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onclose = function(e) {
|
|
||||||
console.log('Socket is closed. Reconnect will be attempted in 2 seconds.', e.reason);
|
|
||||||
setTimeout(function() {
|
|
||||||
ws_connect(ws_url, onData);
|
|
||||||
}, 2000);
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onerror = function(err) {
|
|
||||||
console.error('Socket encountered error: ', err.message, 'Closing socket');
|
|
||||||
ws.close();
|
|
||||||
};
|
|
||||||
}
|
|
Before Width: | Height: | Size: 94 KiB |
Before Width: | Height: | Size: 129 KiB |
Before Width: | Height: | Size: 200 KiB |
79
ircradio/static/search.js
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
// tracks input in 'search' field (id=general)
|
||||||
|
let input_so_far = "";
|
||||||
|
|
||||||
|
// cached song list and cached queries
|
||||||
|
var queries = [];
|
||||||
|
var songs = new Map([]);
|
||||||
|
|
||||||
|
// track async fetch and processing
|
||||||
|
var returned = false;
|
||||||
|
|
||||||
|
$("#general").keyup( function() {
|
||||||
|
|
||||||
|
input_so_far = document.getElementsByName("general")[0].value;
|
||||||
|
|
||||||
|
if (input_so_far.length < 3) {
|
||||||
|
$("#table tr").remove();
|
||||||
|
return
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!queries.includes(input_so_far.toLowerCase() ) ) {
|
||||||
|
queries.push(input_so_far.toLowerCase() );
|
||||||
|
returned = false;
|
||||||
|
|
||||||
|
const sanitized_input = encodeURIComponent( input_so_far );
|
||||||
|
const url = 'https://' + document.domain + ':' + location.port + '/search?name=' + sanitized_input + '&limit=15&offset=0'
|
||||||
|
|
||||||
|
const LoadData = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(url);
|
||||||
|
console.log("Status code 200 or similar: " + res.ok);
|
||||||
|
const data = await res.json();
|
||||||
|
return data;
|
||||||
|
} catch(err) {
|
||||||
|
console.error(err)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
LoadData().then(newSongsJson => {
|
||||||
|
newSongsJson.forEach( (new_song) => {
|
||||||
|
let already_have = false;
|
||||||
|
songs.forEach( (_v, key) => {
|
||||||
|
if (new_song.id == key) { already_have = true; return; };
|
||||||
|
})
|
||||||
|
if (!already_have) { songs.set(new_song.utube_id, new_song) }
|
||||||
|
})
|
||||||
|
}).then( () => { returned = true } );
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
function renderTable () {
|
||||||
|
|
||||||
|
if (returned) {
|
||||||
|
|
||||||
|
$("#table tr").remove();
|
||||||
|
|
||||||
|
var filtered = new Map(
|
||||||
|
[...songs]
|
||||||
|
.filter(([k, v]) =>
|
||||||
|
( v.title.toLowerCase().includes( input_so_far.toLowerCase() ) ) ||
|
||||||
|
( v.added_by.toLowerCase().includes( input_so_far.toLowerCase() ) ) )
|
||||||
|
);
|
||||||
|
|
||||||
|
filtered.forEach( (song) => {
|
||||||
|
let added = song.added_by;
|
||||||
|
let added_link = '<a href="/library?name=' + added + '" target="_blank" rel="noopener noreferrer">' + added + '</a>';
|
||||||
|
let title = song.title;
|
||||||
|
let id = song.utube_id;
|
||||||
|
let id_link = '<a href="https://www.youtube.com/watch?v=' + id + '" target="_blank" rel="noopener noreferrer">' + id + '</a>';
|
||||||
|
$('#table tbody').append('<tr><td>'+id_link+'</td><td>'+added_link+'</td><td>'+title+'</td></tr>')
|
||||||
|
})
|
||||||
|
|
||||||
|
} else {
|
||||||
|
setTimeout(renderTable, 30); // try again in 30 milliseconds
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
renderTable();
|
||||||
|
|
||||||
|
});
|
Before Width: | Height: | Size: 130 KiB |
Before Width: | Height: | Size: 38 KiB |
Before Width: | Height: | Size: 42 KiB |
Before Width: | Height: | Size: 10 KiB |
@ -1,195 +0,0 @@
|
|||||||
import re, os, json, logging
|
|
||||||
from collections import OrderedDict
|
|
||||||
from typing import List, Optional
|
|
||||||
from dataclasses import dataclass
|
|
||||||
|
|
||||||
import mutagen
|
|
||||||
import aiofiles
|
|
||||||
from aiocache import cached, Cache
|
|
||||||
from aiocache.serializers import PickleSerializer
|
|
||||||
|
|
||||||
import settings
|
|
||||||
from ircradio.models import Song
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class SongDataclass:
|
|
||||||
title: str
|
|
||||||
karma: int
|
|
||||||
utube_id: str
|
|
||||||
added_by: str
|
|
||||||
image: Optional[str]
|
|
||||||
duration: Optional[int]
|
|
||||||
progress: Optional[int] # pct
|
|
||||||
progress_str: Optional[str] = None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class Station:
|
|
||||||
id: str
|
|
||||||
music_dir: str # /full/path/to/music/
|
|
||||||
mount_point: str # wow.ogg
|
|
||||||
request_id: str # pberlin (for telnet requests)
|
|
||||||
title: str # for webif
|
|
||||||
description: str # for webif
|
|
||||||
image: str # for webif
|
|
||||||
listeners: int = 0
|
|
||||||
|
|
||||||
song: Optional[SongDataclass] = None
|
|
||||||
|
|
||||||
async def skip(self) -> bool:
|
|
||||||
from ircradio.radio import Radio
|
|
||||||
try:
|
|
||||||
await Radio.command(self.telnet_cmd_skip)
|
|
||||||
return True
|
|
||||||
except Exception as ex:
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def np(self) -> Optional[Song]:
|
|
||||||
history = await self.history()
|
|
||||||
if history:
|
|
||||||
return history[-1]
|
|
||||||
|
|
||||||
@cached(ttl=4, cache=Cache.MEMORY,
|
|
||||||
key_builder=lambda *args, **kw: f"history_station_{args[1].id}",
|
|
||||||
serializer=PickleSerializer())
|
|
||||||
async def history(self) -> List[Song]:
|
|
||||||
from ircradio.radio import Radio
|
|
||||||
# 1. ask liquidsoap for history
|
|
||||||
# 2. check database (Song.from_filepath)
|
|
||||||
# 3. check direct file exif (Song.from_filepath)
|
|
||||||
# 4. check .ogg.json metadata file (Song.from_filepath)
|
|
||||||
# 5. verify the above by comparing icecast metadata
|
|
||||||
|
|
||||||
# find a better way to get current song
|
|
||||||
liq_filenames = []
|
|
||||||
from ircradio.factory import NP_MAP
|
|
||||||
np_uid = self.id
|
|
||||||
if np_uid == "main":
|
|
||||||
np_uid = "pmain"
|
|
||||||
elif np_uid == "wow":
|
|
||||||
np_uid = "pmain"
|
|
||||||
|
|
||||||
if np_uid in NP_MAP:
|
|
||||||
liq_filenames = [NP_MAP[np_uid]]
|
|
||||||
|
|
||||||
liq_remaining = await Radio.command(self.telnet_cmd_remaining)
|
|
||||||
liq_remaining = liq_remaining.decode(errors="ignore")
|
|
||||||
|
|
||||||
remaining = None
|
|
||||||
if re.match('\d+.\d+', liq_remaining):
|
|
||||||
remaining = int(liq_remaining.split('.')[0])
|
|
||||||
|
|
||||||
songs = []
|
|
||||||
for liq_fn in liq_filenames:
|
|
||||||
try:
|
|
||||||
song = await Song.from_filepath(liq_fn)
|
|
||||||
songs.append(song)
|
|
||||||
except Exception as ex:
|
|
||||||
logging.error(ex)
|
|
||||||
|
|
||||||
if not songs:
|
|
||||||
return []
|
|
||||||
|
|
||||||
# icecast compare, silliness ahead
|
|
||||||
meta: dict = await Radio.icecast_metadata(self)
|
|
||||||
if meta:
|
|
||||||
meta_title = meta.get('title')
|
|
||||||
if meta_title and meta_title.lower() in [' ', 'unknown', 'error', 'empty', 'bleepbloopblurp']:
|
|
||||||
meta_title = None
|
|
||||||
|
|
||||||
if meta_title and len(songs) > 1 and songs:
|
|
||||||
title_a = songs[-1].title_for_real_nocap.lower()
|
|
||||||
title_b = meta_title.lower()
|
|
||||||
|
|
||||||
if title_a not in title_b and title_b not in title_a:
|
|
||||||
# song detection and actual icecast metadata differ
|
|
||||||
logging.error(f"song detection and icecast metadata differ:\n{meta_title}\n{songs[-1].title}")
|
|
||||||
songs[-1].title = meta_title
|
|
||||||
|
|
||||||
if remaining:
|
|
||||||
songs[-1].remaining = remaining
|
|
||||||
|
|
||||||
return songs
|
|
||||||
|
|
||||||
async def queue_get(self) -> List[Song]:
|
|
||||||
from ircradio.radio import Radio
|
|
||||||
|
|
||||||
queues = await Radio.command(self.telnet_cmd_queue)
|
|
||||||
queue_ids = re.findall(b"\d+", queues)
|
|
||||||
if not queue_ids:
|
|
||||||
return []
|
|
||||||
|
|
||||||
queue_ids: List[int] = list(reversed(list(map(int, queue_ids)))) # yolo
|
|
||||||
|
|
||||||
songs = []
|
|
||||||
for request_id in queue_ids:
|
|
||||||
liq_meta = await Radio.command(f"request.metadata {request_id}")
|
|
||||||
liq_meta = liq_meta.decode(errors='none')
|
|
||||||
liq_fn = re.findall(r"filename=\"(.*)\"", liq_meta)
|
|
||||||
if not liq_fn:
|
|
||||||
continue
|
|
||||||
liq_fn = liq_fn[0]
|
|
||||||
|
|
||||||
try:
|
|
||||||
song = await Song.from_filepath(liq_fn)
|
|
||||||
songs.append(song)
|
|
||||||
except Exception as ex:
|
|
||||||
logging.error(ex)
|
|
||||||
|
|
||||||
# remove the now playing song from the queue
|
|
||||||
now_playing = await self.np()
|
|
||||||
if songs and now_playing:
|
|
||||||
if songs[0].filepath == now_playing.filepath:
|
|
||||||
songs = songs[1:]
|
|
||||||
return songs
|
|
||||||
|
|
||||||
async def queue_push(self, filepath: str) -> bool:
|
|
||||||
from ircradio.radio import Radio
|
|
||||||
from ircradio.factory import app
|
|
||||||
|
|
||||||
if not os.path.exists(filepath):
|
|
||||||
logging.error(f"file does not exist: {filepath}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
current_queue = await self.queue_get()
|
|
||||||
if filepath in [c.filepath for c in current_queue]:
|
|
||||||
logging.error(f"already added to queue: {song.filepath}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
try:
|
|
||||||
await Radio.command(f"{self.telnet_cmd_push} {filepath}")
|
|
||||||
except Exception as ex:
|
|
||||||
logging.error(f"failed to push, idunno; {ex}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
async def get_listeners(self) -> int:
|
|
||||||
from ircradio.radio import Radio
|
|
||||||
meta: dict = await Radio.icecast_metadata(self)
|
|
||||||
return meta.get('listeners', 0)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def telnet_cmd_push(self): # push into queue
|
|
||||||
return f"{self.request_id}.push"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def telnet_cmd_queue(self): # view queue_ids
|
|
||||||
return f"{self.request_id}.queue"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def telnet_cmd_metadata(self):
|
|
||||||
return f"now_playing"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def telnet_cmd_remaining(self):
|
|
||||||
return f"{self.mount_point.replace('.', '_')}.remaining"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def telnet_cmd_skip(self):
|
|
||||||
return f"{self.mount_point.replace('.', '_')}.skip"
|
|
||||||
|
|
||||||
@property
|
|
||||||
def stream_url(self):
|
|
||||||
return f"http://{settings.icecast2_hostname}/{self.mount_point}"
|
|
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
|
@ -1,7 +1,7 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<!--
|
<!--
|
||||||
░░░░░░░█▐▓▓░████▄▄▄█▀▄▓▓▓▌█ very radio
|
░░░░░░░█▐▓▓░████▄▄▄█▀▄▓▓▓▌█ very website
|
||||||
░░░░░▄█▌▀▄▓▓▄▄▄▄▀▀▀▄▓▓▓▓▓▌█
|
░░░░░▄█▌▀▄▓▓▄▄▄▄▀▀▀▄▓▓▓▓▓▌█
|
||||||
░░░▄█▀▀▄▓█▓▓▓▓▓▓▓▓▓▓▓▓▀░▓▌█
|
░░░▄█▀▀▄▓█▓▓▓▓▓▓▓▓▓▓▓▓▀░▓▌█
|
||||||
░░█▀▄▓▓▓███▓▓▓███▓▓▓▄░░▄▓▐█▌ such html
|
░░█▀▄▓▓▓███▓▓▓███▓▓▓▄░░▄▓▐█▌ such html
|
||||||
@ -10,11 +10,10 @@
|
|||||||
█▌███▓▓▓▓▓▓▓▓▐░░▄▓▓███▓▓▓▄▀▐█
|
█▌███▓▓▓▓▓▓▓▓▐░░▄▓▓███▓▓▓▄▀▐█
|
||||||
█▐█▓▀░░▀▓▓▓▓▓▓▓▓▓██████▓▓▓▓▐█
|
█▐█▓▀░░▀▓▓▓▓▓▓▓▓▓██████▓▓▓▓▐█
|
||||||
▌▓▄▌▀░▀░▐▀█▄▓▓██████████▓▓▓▌█▌
|
▌▓▄▌▀░▀░▐▀█▄▓▓██████████▓▓▓▌█▌
|
||||||
▌▓▓▓▄▄▀▀▓▓▓▀▓▓▓▓▓▓▓▓█▓█▓█▓▓▌█▌ much music
|
▌▓▓▓▄▄▀▀▓▓▓▀▓▓▓▓▓▓▓▓█▓█▓█▓▓▌█▌ many music
|
||||||
█▐▓▓▓▓▓▓▄▄▄▓▓▓▓▓▓█▓█▓█▓█▓▓▓▐█
|
█▐▓▓▓▓▓▓▄▄▄▓▓▓▓▓▓█▓█▓█▓█▓▓▓▐█
|
||||||
-->
|
-->
|
||||||
<head>
|
<head>
|
||||||
<title>IRC!Radio</title>
|
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
@ -28,22 +27,37 @@
|
|||||||
<meta name="application-name" content="IRC!Radio">
|
<meta name="application-name" content="IRC!Radio">
|
||||||
<meta name="msapplication-TileColor" content="#da532c">
|
<meta name="msapplication-TileColor" content="#da532c">
|
||||||
<meta name="description" content="IRC!Radio"/>
|
<meta name="description" content="IRC!Radio"/>
|
||||||
|
<title>IRC!Radio</title>
|
||||||
|
|
||||||
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css">
|
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css">
|
||||||
|
|
||||||
|
{% if ENABLE_SEARCH_ROUTE %}
|
||||||
<link rel="stylesheet" href="https://code.jquery.com/ui/1.12.1/themes/base/jquery-ui.css"></link>
|
<link rel="stylesheet" href="https://code.jquery.com/ui/1.12.1/themes/base/jquery-ui.css"></link>
|
||||||
|
|
||||||
<script src="https://code.jquery.com/jquery-3.6.0.min.js" integrity="sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4=" crossorigin="anonymous"></script>
|
<script src="https://code.jquery.com/jquery-3.6.0.min.js" integrity="sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4=" crossorigin="anonymous"></script>
|
||||||
<script src="https://code.jquery.com/ui/1.12.1/jquery-ui.min.js" integrity="sha256-VazP97ZCwtekAsvgPBSUwPFKdrwD3unUfSGVYrahUqU=" crossorigin="anonymous"></script>
|
<script src="https://code.jquery.com/ui/1.12.1/jquery-ui.min.js" integrity="sha256-VazP97ZCwtekAsvgPBSUwPFKdrwD3unUfSGVYrahUqU=" crossorigin="anonymous"></script>
|
||||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,300i,400,400i,700,700i%7CRoboto+Mono:400,400i,700,700i&display=fallback">
|
{% endif %}
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/vinorodrigues/bootstrap-dark@0.6.1/dist/bootstrap-dark.min.css">
|
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='index.css') }}">
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<div class="hero px-3 py-3 pt-md-4 pb-md-3 mx-auto text-center">
|
<script>
|
||||||
<a href="/" style="color: inherit; text-decoration: none;">
|
|
||||||
<h1 class="display-4 d-inline-flex">Radio!WOW</h1>
|
var ws = new WebSocket('wss://' + document.domain + ':' + location.port + '/ws');
|
||||||
</a>
|
|
||||||
<p class="lead">Enjoy the music :)</p>
|
ws.onmessage = function (event) {
|
||||||
</div>
|
// console.log(event.data);
|
||||||
|
json = JSON.parse(event.data);
|
||||||
|
np = json.now_playing;
|
||||||
|
document.querySelector("#now_playing").innerText = np;
|
||||||
|
// console.log(np);
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = function(event) {
|
||||||
|
console.log("WebSocket is closed now.");
|
||||||
|
};
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
{% block content %} {% endblock %}
|
{% block content %} {% endblock %}
|
||||||
|
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
|
@ -1,24 +0,0 @@
|
|||||||
<footer class="pt-4 my-md-5 pt-md-5 border-top">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-12 col-md">
|
|
||||||
<img class="mb-2" src="{{ url_for('static', filename='wow.png') }}" alt="" width="24" height="24">
|
|
||||||
<small class="d-block mb-3 text-muted">IRC!Radio</small>
|
|
||||||
</div>
|
|
||||||
<div class="col-4 col-md">
|
|
||||||
<h5>Menu</h5>
|
|
||||||
<ul class="list-unstyled text-small">
|
|
||||||
<li><a class="text-muted" href=" {{ url_for('login') }} ">Login</a></li>
|
|
||||||
<li><a class="text-muted" href=" {{ url_for('history') }} ">History</a></li>
|
|
||||||
<li><a class="text-muted" href=" {{ url_for('user_library') }} ">User Library</a></li>
|
|
||||||
<li><a class="text-muted" href=" {{ url_for('request_song') }} ">Request</a></li>
|
|
||||||
<li><a class="text-muted" href="https://git.wownero.com/dsc/ircradio">Source</a></li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-4 col-md">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-4 col-md">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
@ -1,18 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
{% block content %}
|
|
||||||
<!-- Page Content -->
|
|
||||||
<div class="container">
|
|
||||||
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-lg-8">
|
|
||||||
<h5>History</h5>
|
|
||||||
<pre style="color:white;font-family:monospace;font-size:14px;">{% for s in songs %}<a target="_blank" href="https://www.youtube.com/watch?v={{s.utube_id}}">{{s.utube_id}}</a> {{s.title}}
|
|
||||||
{% endfor %}</pre>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% include 'footer.html' %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
{% endblock %}
|
|
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>
|
@ -1,245 +1,93 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
{% set radio_default = settings.radio_stations['wow'] %}
|
|
||||||
|
|
||||||
<!-- Page Content -->
|
<!-- Page Content -->
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
{% for rs in radio_stations %}
|
<!-- Post Content Column -->
|
||||||
<div class="col-md-4 col-sm-6 col-xl-3 d-flex">
|
<div class="col-lg-12">
|
||||||
<div data-radio="{{ rs.id }}" class="card box-shadow mb-4 flex-fill">
|
<!-- Title -->
|
||||||
<div class="card-header">
|
<h1 class="mt-4" style="margin-bottom: 2rem;">
|
||||||
<h5 class="my-0 font-weight-normal">{{ rs.title }}</h5>
|
IRC!Radio
|
||||||
</div>
|
</h1>
|
||||||
<img class="img_header card-img-top" src="{{ url_for('static', filename=rs.image) }}" alt="">
|
<p>Enjoy the music :)</p>
|
||||||
<div class="card-body text-center">
|
<hr>
|
||||||
<h5 class="title_str card-title pricing-card-title text-muted">|</h5>
|
<audio controls src="/{{ settings.icecast2_mount }}">Your browser does not support the<code>audio</code> element.</audio>
|
||||||
<ul class="list-unstyled mt-3 mb-4">
|
<p> </p>
|
||||||
<li class="d-none listeners_str">0 listeners</li>
|
<h5>Now playing: </h5>
|
||||||
<li class="progress_str text-muted">00:00 / 00:00</li>
|
<div id="now_playing">Nothing here yet</div>
|
||||||
</ul>
|
<hr>
|
||||||
</div>
|
|
||||||
<div class="card-footer">
|
|
||||||
<small class="footer d-block text-muted mt-3">{{ rs.description | safe }}</small>
|
|
||||||
</div>
|
|
||||||
<div class="card-footer">
|
|
||||||
<button data-playing="false" data-url="{{ settings.icecast2_scheme + settings.icecast2_hostname + "/" + rs.mount_point }}" data-radio="{{ rs.id }}" type="button" class="btn btn-play btn-block btn-outline-primary mb-2">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="btn_audio_icon bi bi-play" viewBox="0 0 16 16">
|
|
||||||
<path d="M10.804 8 5 4.633v6.734L10.804 8zm.792-.696a.802.802 0 0 1 0 1.392l-6.363 3.692C4.713 12.69 4 12.345 4 11.692V4.308c0-.653.713-.998 1.233-.696l6.363 3.692z"></path>
|
|
||||||
</svg>
|
|
||||||
<span>Play</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{% if logged_in %}
|
<h4>Command list:</h4>
|
||||||
{% if rs.id == radio_default.id %}
|
<pre style="font-size:12px;">!np - current song
|
||||||
<div class="btnMeta mb-2" data-url="{{ url_for('api_tune', radio_id=rs.id) }}">
|
!tune - upvote song
|
||||||
<button data-playing="false" data-radio="{{ rs.id }}" type="button" class="btn btn-block btn-outline-primary">
|
!boo - downvote song
|
||||||
<span>Tune</span>
|
!request - search and queue a song by title or YouTube id
|
||||||
</button>
|
!dj+ - add a YouTube ID to the radiostream
|
||||||
|
!dj- - remove a YouTube ID
|
||||||
|
!ban+ - ban a YouTube ID and/or nickname
|
||||||
|
!ban- - unban a YouTube ID and/or nickname
|
||||||
|
!skip - skips current song
|
||||||
|
!listeners - show current amount of listeners
|
||||||
|
!queue - show queued up music
|
||||||
|
!queue_user - queue a random song by user
|
||||||
|
!search - search for a title
|
||||||
|
!stats - stats
|
||||||
|
</pre>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<h4>History</h4>
|
||||||
|
<a href="/history.txt">history.txt</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="btnMeta mb-2" data-url="{{ url_for('api_boo', radio_id=rs.id) }}">
|
<div class="col-md-5">
|
||||||
<button data-playing="false" data-radio="{{ rs.id }}" type="button" class="btn btn-block btn-outline-primary">
|
<h4>Library
|
||||||
<span>Boo</span>
|
<small style="font-size:12px">(by user)</small>
|
||||||
</button>
|
</h4>
|
||||||
|
<form method="GET" action="/library">
|
||||||
|
<div class="input-group mb-3 style=no-gutters">
|
||||||
|
<input type="text" class="form-control" id="name" name="name" placeholder="username...">
|
||||||
|
<div class="input-group-append">
|
||||||
|
<input class="btn btn-outline-secondary" type="submit" value="Search">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if ENABLE_SEARCH_ROUTE %}
|
||||||
|
<hr>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-8">
|
||||||
|
<h4>Quick Search
|
||||||
|
<small style="font-size:12px">(general)</small>
|
||||||
|
</h4>
|
||||||
|
<div class="input-group mb-3">
|
||||||
|
<input type="text" class="form-control" id="general" name="general" placeholder="query...">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-12">
|
||||||
|
<table class="table table-sm table-hover table-bordered" id="table" style="font-size:12px">
|
||||||
|
<thead>
|
||||||
|
<tbody style="">
|
||||||
|
</tbody>
|
||||||
|
</thead>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
<hr>
|
||||||
<div class="btnMeta mb-2" data-url="{{ url_for('api_skip', radio_id=rs.id) }}">
|
<h4>IRC</h4>
|
||||||
<button data-playing="false" data-radio="{{ rs.id }}" type="button" class="btn btn-block btn-outline-primary">
|
<pre>{{ settings.irc_host }}:{{ settings.irc_port }}
|
||||||
<span>Skip</span>
|
{{ settings.irc_channels | join(" ") }}
|
||||||
</button>
|
</pre>
|
||||||
</div>
|
|
||||||
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
|
|
||||||
<div class="audio d-none">
|
|
||||||
{% for rs in radio_stations %}
|
|
||||||
<audio data-url="{{ rs.stream_url }}" id="player_{{ rs.id }}" controls preload="none">
|
|
||||||
<source src="{{ rs.stream_url }}" type="audio/ogg">
|
|
||||||
</audio>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% include 'footer.html' %}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
{% if ENABLE_SEARCH_ROUTE %}
|
||||||
var radio_default_images = {
|
<script src="static/search.js"></script>
|
||||||
{% for rs in radio_stations %}
|
{% endif %}
|
||||||
"{{ rs.id }}": "{{ url_for('static', filename=rs.image) }}",
|
|
||||||
{% endfor %}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<script src="static/index.js"></script>
|
|
||||||
<script>
|
|
||||||
var radio_data = null;
|
|
||||||
// jquery selection caches
|
|
||||||
var audio_np = null;
|
|
||||||
|
|
||||||
// lookup, to keep order
|
|
||||||
let radio_station_ids = [{% for rs in radio_stations %}'{{ rs.id }}',{% endfor %}];
|
|
||||||
|
|
||||||
var sel_radio_cards = {};
|
|
||||||
var url_icecast = '{{ settings.icecast2_hostname }}';
|
|
||||||
var url_album_art = '/assets/art/';
|
|
||||||
var ws_url = '{{ settings.ws_url }}';
|
|
||||||
|
|
||||||
var icon_play = `
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="btn_audio_icon bi bi-play" viewBox="0 0 16 16">
|
|
||||||
<path d="M10.804 8 5 4.633v6.734L10.804 8zm.792-.696a.802.802 0 0 1 0 1.392l-6.363 3.692C4.713 12.69 4 12.345 4 11.692V4.308c0-.653.713-.998 1.233-.696l6.363 3.692z"></path>
|
|
||||||
</svg>
|
|
||||||
`;
|
|
||||||
var icon_stop = `
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="btn_audio_icon bi bi-stop" viewBox="0 0 16 16">
|
|
||||||
<path d="M3.5 5A1.5 1.5 0 0 1 5 3.5h6A1.5 1.5 0 0 1 12.5 5v6a1.5 1.5 0 0 1-1.5 1.5H5A1.5 1.5 0 0 1 3.5 11V5zM5 4.5a.5.5 0 0 0-.5.5v6a.5.5 0 0 0 .5.5h6a.5.5 0 0 0 .5-.5V5a.5.5 0 0 0-.5-.5H5z"></path>
|
|
||||||
</svg>
|
|
||||||
`;
|
|
||||||
|
|
||||||
function stopRadio(radio_id) {
|
|
||||||
let radio = document.getElementById("player_" + radio_id);
|
|
||||||
radio.pause();
|
|
||||||
radio.src = radio.src; // trick to force disconnect from radio
|
|
||||||
}
|
|
||||||
|
|
||||||
function playRadio(radio_id) {
|
|
||||||
let radio = document.getElementById("player_" + radio_id);
|
|
||||||
let sel = $(radio);
|
|
||||||
|
|
||||||
radio.src = sel.attr('data-url');
|
|
||||||
radio.play();
|
|
||||||
}
|
|
||||||
|
|
||||||
function listeners_str(amount){
|
|
||||||
if(amount >= 2 || amount === 0) return `${amount} listeners`;
|
|
||||||
return `${amount} listener`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function btnPlayChangeText(radio_id, text) {
|
|
||||||
sel_radio_cards[radio_id].find('.btn-play span').text(text);
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetAll() {
|
|
||||||
for (let radio_id of radio_station_ids) {
|
|
||||||
stopRadio(radio_id);
|
|
||||||
btnPlayChangeActive(radio_id, false);
|
|
||||||
btnPlayChangeText(radio_id, 'Play');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function btnPlayChangeActive(radio_id, active) {
|
|
||||||
let _sel = sel_radio_cards[radio_id].find('.btn-play');
|
|
||||||
_sel.find('.btn_audio_icon').remove();
|
|
||||||
|
|
||||||
if(active) {
|
|
||||||
_sel.addClass('btn-active');
|
|
||||||
_sel.attr('data-playing', 'true');
|
|
||||||
_sel.prepend(icon_stop);
|
|
||||||
} else {
|
|
||||||
_sel.removeClass('btn-active');
|
|
||||||
_sel.attr('data-playing', 'false');
|
|
||||||
_sel.prepend(icon_play);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function labelSetListeners(radio_id) {
|
|
||||||
if(!radio_data.hasOwnProperty(radio_id)) return;
|
|
||||||
|
|
||||||
let listeners = radio_data[radio_id].listeners;
|
|
||||||
sel_radio_cards[radio_id].find('.listeners_str').text(listeners_str(listeners));
|
|
||||||
}
|
|
||||||
|
|
||||||
function btnPlay(event) {
|
|
||||||
let playing = $(event.currentTarget).attr('data-playing');
|
|
||||||
let url = $(event.currentTarget).attr('data-url');
|
|
||||||
let uid = $(event.currentTarget).attr('data-radio');
|
|
||||||
|
|
||||||
resetAll();
|
|
||||||
|
|
||||||
if(playing === "true" && audio_np !== null && audio_np.uid === uid) {
|
|
||||||
stopRadio(uid);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
playRadio(uid);
|
|
||||||
audio_np = {'url': url, 'uid': uid}
|
|
||||||
radio_data[uid].listeners += 1;
|
|
||||||
|
|
||||||
labelSetListeners(uid);
|
|
||||||
btnPlayChangeActive(uid, true);
|
|
||||||
btnPlayChangeText(uid, 'Pause');
|
|
||||||
}
|
|
||||||
|
|
||||||
$(document).ready(() => {
|
|
||||||
for (let radio_station_id of radio_station_ids) {
|
|
||||||
let _sel = $("div[data-radio=" + radio_station_id + "]");
|
|
||||||
_sel.find('.btn-play').on('click', btnPlay); // playBtn click handler
|
|
||||||
sel_radio_cards[radio_station_id] = _sel;
|
|
||||||
}
|
|
||||||
|
|
||||||
function onData(data) {
|
|
||||||
let blob = JSON.parse(data);
|
|
||||||
radio_data = blob;
|
|
||||||
|
|
||||||
let listeners = 0;
|
|
||||||
for (let radio_station of radio_station_ids) {
|
|
||||||
if(!blob.hasOwnProperty(radio_station)) continue;
|
|
||||||
|
|
||||||
let rs = blob[radio_station];
|
|
||||||
if(rs.song !== null) {
|
|
||||||
let song = rs.song;
|
|
||||||
console.log(song.album_art);
|
|
||||||
|
|
||||||
if(song.hasOwnProperty('image') && song.image !== null) {
|
|
||||||
sel_radio_cards[rs.id].find('.img_header').attr('src', `${url_album_art}/${song.image}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
sel_radio_cards[rs.id].find('.title_str').text(song.title);
|
|
||||||
//labelSetListeners(rs.id, rs.listeners);
|
|
||||||
listeners += rs.listeners;
|
|
||||||
sel_radio_cards[rs.id].find('.progress_str').text(song.progress_str);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if(listeners >= 1) {
|
|
||||||
$('p.lead').text(listeners_str(listeners));
|
|
||||||
} else {
|
|
||||||
$('p.lead').text('Enjoy the music :)');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// btnMeta: skip, boo, tune
|
|
||||||
$(document).on('click', '.btnMeta', async (ev) => {
|
|
||||||
let sel = $(ev.currentTarget);
|
|
||||||
let url = sel.attr('data-url');
|
|
||||||
|
|
||||||
fetch(url).then((response) => {
|
|
||||||
if (response.ok) {
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
throw new Error('Something went wrong');
|
|
||||||
})
|
|
||||||
.then((responseJson) => {
|
|
||||||
if(responseJson.hasOwnProperty('msg')) {
|
|
||||||
let msg = responseJson['msg'];
|
|
||||||
alert(msg);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
alert(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
ws_connect(ws_url, onData);
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
@ -11,13 +11,13 @@
|
|||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-lg-6">
|
<div class="col-lg-6">
|
||||||
<h5>By date</h5>
|
<h5>By date</h5>
|
||||||
<pre style="color:white;font-family:monospace;font-size:14px;">{% for s in by_date %}<a target="_blank" href="https://www.youtube.com/watch?v={{s.utube_id}}">{{s.utube_id}}</a> {{s.title}}
|
<pre style="font-size:12px;">{% for s in by_date %}<a target="_blank" href="https://www.youtube.com/watch?v={{s.utube_id}}">{{s.utube_id}}</a> {{s.title}}
|
||||||
{% endfor %}</pre>
|
{% endfor %}</pre>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-lg-6">
|
<div class="col-lg-6">
|
||||||
<h5>By karma</h5>
|
<h5>By karma</h5>
|
||||||
<pre style="color:white;font-family:monospace;font-size:14px;">{% for s in by_karma %}<a target="_blank" href="https://www.youtube.com/watch?v={{s.utube_id}}">{{s.utube_id}}</a> {{s.karma}} - {{s.title}}
|
<pre style="font-size:12px;">{% for s in by_karma %}<a target="_blank" href="https://www.youtube.com/watch?v={{s.utube_id}}">{{s.utube_id}}</a> {{s.karma}} - {{s.title}}
|
||||||
{% endfor %}</pre>
|
{% endfor %}</pre>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -27,7 +27,5 @@
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% include 'footer.html' %}
|
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
56
ircradio/templates/nginx.jinja2
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name {{ hostname }};
|
||||||
|
root /var/www/html;
|
||||||
|
|
||||||
|
access_log /dev/null;
|
||||||
|
error_log /var/log/nginx/radio_error;
|
||||||
|
|
||||||
|
client_max_body_size 120M;
|
||||||
|
fastcgi_read_timeout 1600;
|
||||||
|
proxy_read_timeout 1600;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
error_page 403 /403.html;
|
||||||
|
location = /403.html {
|
||||||
|
root /var/www/html;
|
||||||
|
allow all;
|
||||||
|
internal;
|
||||||
|
}
|
||||||
|
|
||||||
|
location '/.well-known/acme-challenge' {
|
||||||
|
default_type "text/plain";
|
||||||
|
root /tmp/letsencrypt;
|
||||||
|
autoindex on;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
root /var/www/html/;
|
||||||
|
proxy_pass http://{{ host }}:{{ port }};
|
||||||
|
proxy_redirect off;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
allow all;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /{{ icecast2_mount }} {
|
||||||
|
allow all;
|
||||||
|
add_header 'Access-Control-Allow-Origin' '*';
|
||||||
|
proxy_pass http://{{ icecast2_bind_host }}:{{ icecast2_bind_port }}/{{ icecast2_mount }};
|
||||||
|
proxy_redirect off;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /ws {
|
||||||
|
proxy_pass http://{{ host }}:{{ port }}/ws;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "Upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
}
|
||||||
|
}
|
@ -1,87 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
{% block content %}
|
|
||||||
<link rel="stylesheet" href="https://unpkg.com/bootstrap-table@1.21.3/dist/bootstrap-table.min.css">
|
|
||||||
<script src="https://unpkg.com/bootstrap-table@1.21.3/dist/bootstrap-table.min.js"></script>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.card-body .form-control {
|
|
||||||
color: #b1b1b1;
|
|
||||||
background-color: #171717;
|
|
||||||
border: 1px solid #515151;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
.fixed-table-pagination {display: none !important;}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<!-- Page Content -->
|
|
||||||
<div class="container">
|
|
||||||
|
|
||||||
<div class="row mb-5">
|
|
||||||
<div class="col-lg-12">
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header">
|
|
||||||
<h5 class="mb-0">Request song</h5>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<p style="color: #626262 !important;">Note: Requesting a song can take a minute or two before it starts playing.</p>
|
|
||||||
<table
|
|
||||||
id="songsTable"
|
|
||||||
data-side-pagination="server"
|
|
||||||
data-classes="table"
|
|
||||||
data-pagination="true"
|
|
||||||
data-toggle="table"
|
|
||||||
data-flat="true"
|
|
||||||
data-page-size="150"
|
|
||||||
data-search="true"
|
|
||||||
data-url="{{url_for('api_songs')}}">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th data-formatter="btnMaker" data-sortable="false"></th>
|
|
||||||
<th data-field="title" data-sortable="false">Name</th>
|
|
||||||
<th data-field="karma" data-sortable="true">Karma</th>
|
|
||||||
<th data-field="added_by" data-sortable="false">User</th>
|
|
||||||
<th data-formatter="utubeMaker" data-field="utube_id" data-sortable="false"></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<script>
|
|
||||||
function utubeMaker(value, row, index) {
|
|
||||||
return `<a target="_blank" href="https://youtube.com/watch?v=${row.uid}">${row.uid}</a>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function btnMaker(value, row, index) {
|
|
||||||
return `<span class="btnRequest" style="cursor:pointer;color:#82b2e5;" data-uid="${row.uid}">request</span>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
$(document).ready(() => {
|
|
||||||
$(document).on('click', '.btnRequest', async (ev) => {
|
|
||||||
let sel = $(ev.currentTarget);
|
|
||||||
let uid = sel.attr('data-uid');
|
|
||||||
|
|
||||||
let url = "{{ url_for('api_request', utube_id='') }}" + uid;
|
|
||||||
|
|
||||||
fetch(url).then((response) => {
|
|
||||||
if (response.ok) {
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
throw new Error('Something went wrong');
|
|
||||||
})
|
|
||||||
.then((responseJson) => {
|
|
||||||
sel.text('added');
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
alert(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
{% include 'footer.html' %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% endblock %}
|
|
@ -1,25 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
{% block content %}
|
|
||||||
<!-- Page Content -->
|
|
||||||
<div class="container">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-5">
|
|
||||||
<h4>View User Library
|
|
||||||
<small style="font-size:12px"></small>
|
|
||||||
</h4>
|
|
||||||
<form target="_blank" method="GET" action="/library">
|
|
||||||
<div class="input-group mb-3 style=no-gutters">
|
|
||||||
<input type="text" class="form-control" id="name" name="name" placeholder="username...">
|
|
||||||
<div class="input-group-append">
|
|
||||||
<input class="btn btn-outline-secondary" type="submit" value="Search">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% include 'footer.html' %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% endblock %}
|
|
@ -11,16 +11,11 @@ import time
|
|||||||
import asyncio
|
import asyncio
|
||||||
from asyncio.subprocess import Process
|
from asyncio.subprocess import Process
|
||||||
from io import TextIOWrapper
|
from io import TextIOWrapper
|
||||||
from dataclasses import dataclass
|
|
||||||
|
|
||||||
import mutagen
|
|
||||||
import aiofiles
|
import aiofiles
|
||||||
import aiohttp
|
import aiohttp
|
||||||
import jinja2
|
import jinja2
|
||||||
from aiocache import cached, Cache
|
|
||||||
from aiocache.serializers import PickleSerializer
|
|
||||||
from jinja2 import Environment, PackageLoader, select_autoescape
|
from jinja2 import Environment, PackageLoader, select_autoescape
|
||||||
from quart import current_app
|
|
||||||
|
|
||||||
import settings
|
import settings
|
||||||
|
|
||||||
@ -145,6 +140,34 @@ def systemd_servicefile(
|
|||||||
return template.encode()
|
return template.encode()
|
||||||
|
|
||||||
|
|
||||||
|
def liquidsoap_version():
|
||||||
|
ls = shutil.which("liquidsoap")
|
||||||
|
f = os.popen(f"{ls} --version 2>/dev/null").read()
|
||||||
|
if not f:
|
||||||
|
print("please install liquidsoap\n\napt install -y liquidsoap")
|
||||||
|
sys.exit()
|
||||||
|
|
||||||
|
f = f.lower()
|
||||||
|
match = re.search(r"liquidsoap (\d+.\d+.\d+)", f)
|
||||||
|
if not match:
|
||||||
|
return
|
||||||
|
return match.groups()[0]
|
||||||
|
|
||||||
|
|
||||||
|
def liquidsoap_check_symlink():
|
||||||
|
msg = """
|
||||||
|
Due to a bug you need to create this symlink:
|
||||||
|
|
||||||
|
$ sudo ln -s /usr/share/liquidsoap/ /usr/share/liquidsoap/1.4.1
|
||||||
|
|
||||||
|
info: https://github.com/savonet/liquidsoap/issues/1224
|
||||||
|
"""
|
||||||
|
version = liquidsoap_version()
|
||||||
|
if not os.path.exists(f"/usr/share/liquidsoap/{version}"):
|
||||||
|
print(msg)
|
||||||
|
sys.exit()
|
||||||
|
|
||||||
|
|
||||||
async def httpget(url: str, json=True, timeout: int = 5, raise_for_status=True, verify_tls=True):
|
async def httpget(url: str, json=True, timeout: int = 5, raise_for_status=True, verify_tls=True):
|
||||||
headers = {"User-Agent": random_agent()}
|
headers = {"User-Agent": random_agent()}
|
||||||
opts = {"timeout": aiohttp.ClientTimeout(total=timeout)}
|
opts = {"timeout": aiohttp.ClientTimeout(total=timeout)}
|
||||||
@ -161,10 +184,29 @@ async def httpget(url: str, json=True, timeout: int = 5, raise_for_status=True,
|
|||||||
|
|
||||||
|
|
||||||
def random_agent():
|
def random_agent():
|
||||||
from ircradio import user_agents
|
from ircradio.factory import user_agents
|
||||||
return random.choice(user_agents)
|
return random.choice(user_agents)
|
||||||
|
|
||||||
|
|
||||||
|
class Price:
|
||||||
|
def __init__(self):
|
||||||
|
self.usd = 0.3
|
||||||
|
|
||||||
|
def calculate(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def wownero_usd_price_loop(self):
|
||||||
|
while True:
|
||||||
|
self.usd = await Price.wownero_usd_price()
|
||||||
|
asyncio.sleep(1200)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def wownero_usd_price():
|
||||||
|
url = "https://api.coingecko.com/api/v3/simple/price?ids=wownero&vs_currencies=usd"
|
||||||
|
blob = await httpget(url, json=True)
|
||||||
|
return blob.get('usd', 0)
|
||||||
|
|
||||||
|
|
||||||
def print_banner():
|
def print_banner():
|
||||||
print("""\033[91m ▪ ▄▄▄ ▄▄· ▄▄▄ ▄▄▄· ·▄▄▄▄ ▪
|
print("""\033[91m ▪ ▄▄▄ ▄▄· ▄▄▄ ▄▄▄· ·▄▄▄▄ ▪
|
||||||
██ ▀▄ █·▐█ ▌▪▀▄ █·▐█ ▀█ ██▪ ██ ██ ▪
|
██ ▀▄ █·▐█ ▌▪▀▄ █·▐█ ▀█ ██▪ ██ ██ ▪
|
||||||
@ -172,82 +214,3 @@ def print_banner():
|
|||||||
▐█▌▐█•█▌▐███▌▐█•█▌▐█ ▪▐▌██. ██ ▐█▌▐█▌.▐▌
|
▐█▌▐█•█▌▐███▌▐█•█▌▐█ ▪▐▌██. ██ ▐█▌▐█▌.▐▌
|
||||||
▀▀▀.▀ ▀·▀▀▀ .▀ ▀ ▀ ▀ ▀▀▀▀▀• ▀▀▀ ▀█▄▀▪\033[0m
|
▀▀▀.▀ ▀·▀▀▀ .▀ ▀ ▀ ▀ ▀▀▀▀▀• ▀▀▀ ▀█▄▀▪\033[0m
|
||||||
""".strip())
|
""".strip())
|
||||||
|
|
||||||
|
|
||||||
async def radio_update_task_run_forever():
|
|
||||||
while True:
|
|
||||||
sleep_secs = 15
|
|
||||||
try:
|
|
||||||
sleep_secs = await radio_update_task(sleep_secs)
|
|
||||||
await asyncio.sleep(sleep_secs)
|
|
||||||
except Exception as ex:
|
|
||||||
current_app.logger.error(ex)
|
|
||||||
await asyncio.sleep(sleep_secs)
|
|
||||||
|
|
||||||
|
|
||||||
async def radio_update_task(sleep_secs) -> int:
|
|
||||||
from ircradio.factory import websocket_status_bus
|
|
||||||
from ircradio.station import SongDataclass
|
|
||||||
from ircradio.radio import Radio
|
|
||||||
if len(websocket_status_bus.subscribers) >= 1:
|
|
||||||
sleep_secs = 4
|
|
||||||
|
|
||||||
blob = {}
|
|
||||||
radio_stations = list(settings.radio_stations.values())
|
|
||||||
# radio_stations = radio_stations[1:]
|
|
||||||
# radio_stations = radio_stations[:2]
|
|
||||||
|
|
||||||
for radio_station in radio_stations:
|
|
||||||
radio_station.song = None
|
|
||||||
|
|
||||||
data = {
|
|
||||||
'added_by': 'system',
|
|
||||||
'image': None,
|
|
||||||
'duration': None,
|
|
||||||
'progress': None,
|
|
||||||
}
|
|
||||||
|
|
||||||
np = await radio_station.np()
|
|
||||||
if np:
|
|
||||||
listeners = await radio_station.get_listeners()
|
|
||||||
if listeners is not None:
|
|
||||||
radio_station.listeners = listeners
|
|
||||||
|
|
||||||
data['title'] = np.title_cleaned
|
|
||||||
data['karma'] = np.karma
|
|
||||||
data['utube_id'] = np.utube_id
|
|
||||||
data['image'] = np.image()
|
|
||||||
data['duration'] = np.duration
|
|
||||||
data['added_by'] = np.added_by
|
|
||||||
|
|
||||||
time_status = np.time_status()
|
|
||||||
if time_status:
|
|
||||||
a, b = time_status
|
|
||||||
pct = percentage(a.seconds, b.seconds)
|
|
||||||
if pct >= 100:
|
|
||||||
pct = 100
|
|
||||||
data['progress'] = int(pct)
|
|
||||||
data['progress_str'] = " / ".join(map(str, time_status))
|
|
||||||
|
|
||||||
radio_station.song = SongDataclass(**data)
|
|
||||||
blob[radio_station.id] = radio_station
|
|
||||||
|
|
||||||
if blob:
|
|
||||||
await websocket_status_bus.put(blob)
|
|
||||||
|
|
||||||
return sleep_secs
|
|
||||||
|
|
||||||
|
|
||||||
@cached(ttl=3600, cache=Cache.MEMORY,
|
|
||||||
key_builder=lambda *args, **kw: f"mutagen_file_{args[1]}",
|
|
||||||
serializer=PickleSerializer())
|
|
||||||
async def mutagen_file(path):
|
|
||||||
from quart import current_app
|
|
||||||
if current_app:
|
|
||||||
return await current_app.sync_to_async(mutagen.File)(path)
|
|
||||||
else:
|
|
||||||
return mutagen.File(path)
|
|
||||||
|
|
||||||
|
|
||||||
def percentage(part, whole):
|
|
||||||
return 100 * float(part)/float(whole)
|
|
||||||
|
@ -17,7 +17,7 @@ class YouTube:
|
|||||||
from ircradio.factory import app
|
from ircradio.factory import app
|
||||||
from ircradio.models import Song
|
from ircradio.models import Song
|
||||||
|
|
||||||
output = f"{settings.dir_music}/{utube_id}"
|
output = f"{settings.dir_music}/{utube_id}.ogg"
|
||||||
song = Song.by_uid(utube_id)
|
song = Song.by_uid(utube_id)
|
||||||
if song:
|
if song:
|
||||||
if not os.path.exists(output):
|
if not os.path.exists(output):
|
||||||
@ -29,13 +29,14 @@ class YouTube:
|
|||||||
if os.path.exists(output):
|
if os.path.exists(output):
|
||||||
song = Song.by_uid(utube_id)
|
song = Song.by_uid(utube_id)
|
||||||
if not song:
|
if not song:
|
||||||
raise Exception("exists on disk but not in db")
|
# exists on disk but not in db; add to db
|
||||||
|
return Song.from_filepath(output)
|
||||||
|
|
||||||
raise Exception("Song already exists.")
|
raise Exception("Song already exists.")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
path_yt_dlp = os.path.join(settings.cwd, "venv", "bin", "yt-dlp")
|
|
||||||
proc = await asyncio.create_subprocess_exec(
|
proc = await asyncio.create_subprocess_exec(
|
||||||
*[path_yt_dlp,
|
*["yt-dlp",
|
||||||
"--add-metadata",
|
"--add-metadata",
|
||||||
"--write-all-thumbnails",
|
"--write-all-thumbnails",
|
||||||
"--write-info-json",
|
"--write-info-json",
|
||||||
@ -43,7 +44,7 @@ class YouTube:
|
|||||||
"--max-filesize", "30M",
|
"--max-filesize", "30M",
|
||||||
"--extract-audio",
|
"--extract-audio",
|
||||||
"--audio-format", "vorbis",
|
"--audio-format", "vorbis",
|
||||||
"-o", f"{settings.dir_music}/%(id)s",
|
"-o", f"{settings.dir_music}/%(id)s.ogg",
|
||||||
f"https://www.youtube.com/watch?v={utube_id}"],
|
f"https://www.youtube.com/watch?v={utube_id}"],
|
||||||
stdout=asyncio.subprocess.PIPE,
|
stdout=asyncio.subprocess.PIPE,
|
||||||
stderr=asyncio.subprocess.PIPE)
|
stderr=asyncio.subprocess.PIPE)
|
||||||
@ -81,13 +82,6 @@ class YouTube:
|
|||||||
from ircradio.factory import app
|
from ircradio.factory import app
|
||||||
import mutagen
|
import mutagen
|
||||||
|
|
||||||
if not filepath.endswith('.ogg'):
|
|
||||||
filepath = filepath + ".ogg"
|
|
||||||
|
|
||||||
if not os.path.exists(filepath):
|
|
||||||
app.logger.error(f"path does not exist: {filepath}")
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
metadata = mutagen.File(filepath)
|
metadata = mutagen.File(filepath)
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
@ -121,9 +115,8 @@ class YouTube:
|
|||||||
title = 'Unknown'
|
title = 'Unknown'
|
||||||
app.logger.warning(f"could not detect artist/title from metadata for {filepath}")
|
app.logger.warning(f"could not detect artist/title from metadata for {filepath}")
|
||||||
|
|
||||||
title = title if '-' in title else f"{artist} - {title}"
|
|
||||||
return {
|
return {
|
||||||
"name": f"{title}",
|
"name": f"{artist} - {title}",
|
||||||
"data": metadata,
|
"data": metadata,
|
||||||
"duration": duration,
|
"duration": duration,
|
||||||
"path": filepath
|
"path": filepath
|
||||||
|
@ -1,14 +1,10 @@
|
|||||||
asyncio-multisubscriber-queue
|
|
||||||
quart
|
quart
|
||||||
quart-session
|
|
||||||
quart-keycloak
|
|
||||||
yt-dlp
|
yt-dlp
|
||||||
aiofiles
|
aiofiles
|
||||||
aiohttp
|
aiohttp
|
||||||
aiocache
|
|
||||||
redis
|
|
||||||
bottom
|
bottom
|
||||||
tinytag
|
tinytag
|
||||||
|
peewee
|
||||||
python-dateutil
|
python-dateutil
|
||||||
mutagen
|
mutagen
|
||||||
peewee
|
peewee
|
||||||
|
@ -9,154 +9,44 @@ def bool_env(val):
|
|||||||
return val is True or (isinstance(val, str) and (val.lower() == 'true' or val == '1'))
|
return val is True or (isinstance(val, str) and (val.lower() == 'true' or val == '1'))
|
||||||
|
|
||||||
|
|
||||||
debug = False
|
debug = True
|
||||||
host = "127.0.0.1"
|
host = "127.0.0.1"
|
||||||
ws_url = "http://127.0.0.1:2600/ws"
|
|
||||||
port = 2600
|
port = 2600
|
||||||
timezone = "Europe/Amsterdam"
|
timezone = "Europe/Amsterdam"
|
||||||
|
|
||||||
redis_uri = os.environ.get('REDIS_URI', 'redis://localhost:6379')
|
|
||||||
|
|
||||||
dir_music = os.environ.get("DIR_MUSIC", os.path.join(cwd, "data", "music"))
|
dir_music = os.environ.get("DIR_MUSIC", os.path.join(cwd, "data", "music"))
|
||||||
dir_meta = os.environ.get("DIR_MUSIC", os.path.join(cwd, "data", "music_metadata"))
|
|
||||||
dir_mixes = "/home/radio/mixes/"
|
|
||||||
|
|
||||||
if not os.path.exists(dir_music):
|
enable_search_route = bool_env(os.environ.get("ENABLE_SEARCH_ROUTE", False))
|
||||||
os.mkdir(dir_music)
|
|
||||||
if not os.path.exists(dir_meta):
|
|
||||||
os.mkdir(dir_meta)
|
|
||||||
|
|
||||||
irc_admins_nicknames = ["dsc_", "qvqc", "lza_menace", "wowario", "scoobybejesus", "JockChamp[m]", "wowario[m]"]
|
irc_admins_nicknames = ["dsc_"]
|
||||||
# irc_host = os.environ.get('IRC_HOST', 'irc.OFTC.net')
|
irc_host = os.environ.get('IRC_HOST', 'localhost')
|
||||||
irc_host = os.environ.get('IRC_HOST', '127.0.0.1')
|
|
||||||
irc_port = int(os.environ.get('IRC_PORT', 6667))
|
irc_port = int(os.environ.get('IRC_PORT', 6667))
|
||||||
irc_ssl = bool_env(os.environ.get('IRC_SSL', False)) # untested
|
irc_ssl = bool_env(os.environ.get('IRC_SSL', False)) # untested
|
||||||
irc_nick = os.environ.get('IRC_NICK', 'DjWow')
|
irc_nick = os.environ.get('IRC_NICK', 'DJIRC')
|
||||||
irc_channels = os.environ.get('IRC_CHANNELS', '#wownero-music').split()
|
irc_channels = os.environ.get('IRC_CHANNELS', '#mychannel').split()
|
||||||
irc_realname = os.environ.get('IRC_REALNAME', 'DjWow')
|
irc_realname = os.environ.get('IRC_REALNAME', 'DJIRC')
|
||||||
irc_ignore_pms = False
|
irc_ignore_pms = False
|
||||||
irc_command_prefix = "!"
|
irc_command_prefix = "!"
|
||||||
|
|
||||||
icecast2_hostname = "radio.wownero.com"
|
icecast2_hostname = "localhost"
|
||||||
icecast2_scheme = "https"
|
|
||||||
icecast2_max_clients = 32
|
icecast2_max_clients = 32
|
||||||
icecast2_bind_host = "127.0.0.1"
|
icecast2_bind_host = "127.0.0.1"
|
||||||
icecast2_bind_port = 24100
|
icecast2_bind_port = 24100
|
||||||
icecast2_mount = "wow.ogg"
|
icecast2_mount = "radio.ogg"
|
||||||
icecast2_source_password = ""
|
icecast2_source_password = "changeme"
|
||||||
icecast2_admin_password = ""
|
icecast2_admin_password = "changeme"
|
||||||
icecast2_relay_password = "" # for livestreams
|
icecast2_relay_password = "changeme" # for livestreams
|
||||||
icecast2_live_mount = "live.ogg"
|
icecast2_live_mount = "live.ogg"
|
||||||
icecast2_logdir = "/var/log/icecast2/"
|
icecast2_logdir = "/var/log/icecast2/"
|
||||||
|
|
||||||
liquidsoap_host = "127.0.0.1"
|
liquidsoap_host = "127.0.0.1"
|
||||||
liquidsoap_port = 7555 # telnet
|
liquidsoap_port = 7555 # telnet
|
||||||
liquidsoap_description = "WOW!Radio"
|
liquidsoap_description = "IRC!Radio"
|
||||||
liquidsoap_samplerate = 48000
|
liquidsoap_samplerate = 48000
|
||||||
liquidsoap_bitrate = 164 # youtube is max 164kbps
|
liquidsoap_bitrate = 164 # youtube is max 164kbps
|
||||||
liquidsoap_crossfades = False # not implemented yet
|
liquidsoap_crossfades = False # not implemented yet
|
||||||
liquidsoap_normalize = False # not implemented yet
|
liquidsoap_normalize = False # not implemented yet
|
||||||
liquidsoap_iface = icecast2_mount.replace(".", "(dot)")
|
liquidsoap_iface = icecast2_mount.replace(".", "(dot)")
|
||||||
liquidsoap_max_song_duration = 60 * 14 # seconds
|
liquidsoap_max_song_duration = 60 * 11 # seconds
|
||||||
|
|
||||||
re_youtube = r"[a-zA-Z0-9_-]{11}$"
|
re_youtube = r"[a-zA-Z0-9_-]{11}$"
|
||||||
|
|
||||||
openid_keycloak_config = {
|
|
||||||
"client_id": "",
|
|
||||||
"client_secret": "",
|
|
||||||
"configuration": "https://login.wownero.com/realms/master/.well-known/openid-configuration"
|
|
||||||
}
|
|
||||||
|
|
||||||
from ircradio.station import Station
|
|
||||||
radio_stations = {
|
|
||||||
"wow": Station(
|
|
||||||
id="wow",
|
|
||||||
music_dir=dir_music,
|
|
||||||
mount_point="wow.ogg",
|
|
||||||
request_id="pmain",
|
|
||||||
title="Radio!WOW",
|
|
||||||
description="random programming",
|
|
||||||
image="wow.jpg"
|
|
||||||
),
|
|
||||||
"berlin": Station(
|
|
||||||
id="berlin",
|
|
||||||
music_dir="/home/radio/mixes/berlin",
|
|
||||||
mount_point="berlin.ogg",
|
|
||||||
request_id="pberlin",
|
|
||||||
title="Berlin",
|
|
||||||
description="Progressive, techno, minimal, tech-trance",
|
|
||||||
image="berlin.jpg"
|
|
||||||
),
|
|
||||||
"dnb": Station(
|
|
||||||
id="dnb",
|
|
||||||
music_dir="/home/radio/mixes/dnb",
|
|
||||||
mount_point="dnb.ogg",
|
|
||||||
request_id="pdnb",
|
|
||||||
title="Drum and Bass",
|
|
||||||
description="Big up selecta",
|
|
||||||
image="dnb.jpg"
|
|
||||||
),
|
|
||||||
"trance": Station(
|
|
||||||
id="trance",
|
|
||||||
music_dir="/home/radio/mixes/trance",
|
|
||||||
mount_point="trance.ogg",
|
|
||||||
request_id="ptrance",
|
|
||||||
title="Trance",
|
|
||||||
description="du-du-du",
|
|
||||||
image="trance.jpg"
|
|
||||||
),
|
|
||||||
"chiptune": Station(
|
|
||||||
id="chiptune",
|
|
||||||
music_dir="/home/radio/mixes/chiptune",
|
|
||||||
mount_point="chiptune.ogg",
|
|
||||||
request_id="pchiptune",
|
|
||||||
title="Chiptune",
|
|
||||||
description="8-bit, 16-bit, PSG sound chips, consoles, handhelds, demoscene",
|
|
||||||
image="chiptune.webp"
|
|
||||||
),
|
|
||||||
"anju": Station(
|
|
||||||
id="anju",
|
|
||||||
music_dir="/home/radio/mixes/anjunadeep",
|
|
||||||
mount_point="anjunadeep.ogg",
|
|
||||||
request_id="panjunadeep",
|
|
||||||
title="Anjunadeep",
|
|
||||||
description="a collection of the anjunadeep edition podcasts",
|
|
||||||
image="anjunadeep.jpg"
|
|
||||||
),
|
|
||||||
"breaks": Station(
|
|
||||||
id="breaks",
|
|
||||||
music_dir="/home/radio/mixes/breaks",
|
|
||||||
mount_point="breaks.ogg",
|
|
||||||
request_id="pbreaks",
|
|
||||||
title="Breakbeat",
|
|
||||||
description="Breakbeat, breakstep, Florida breaks",
|
|
||||||
image="breakbeat.webp"
|
|
||||||
),
|
|
||||||
"raves": Station(
|
|
||||||
id="raves",
|
|
||||||
music_dir="/home/radio/mixes/raves",
|
|
||||||
mount_point="raves.ogg",
|
|
||||||
title="90s rave",
|
|
||||||
request_id="praves",
|
|
||||||
description="Abandoned warehouses, empty apartment lofts, under bridges, open fields",
|
|
||||||
image="raves.jpg"
|
|
||||||
),
|
|
||||||
"weed": Station(
|
|
||||||
id="weed",
|
|
||||||
music_dir="/home/radio/mixes/weed",
|
|
||||||
mount_point="weed.ogg",
|
|
||||||
title="Chill vibes 🌿",
|
|
||||||
description="psybient, psychill, psydub, psyduck <img width=32px height=48px src=\"/static/psyduck.png\">",
|
|
||||||
image="weed.jpg",
|
|
||||||
request_id="pweed"
|
|
||||||
),
|
|
||||||
"rock": Station(
|
|
||||||
id="rock",
|
|
||||||
music_dir="/home/radio/mixes/rock",
|
|
||||||
mount_point="rock.ogg",
|
|
||||||
request_id="prock",
|
|
||||||
title="Rock 🎸",
|
|
||||||
description="Rock & metal",
|
|
||||||
image="rock.webp"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|