diff --git a/Dockerfile b/Dockerfile index a5621c7..7a54909 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,7 +18,7 @@ MAINTAINER dud1337 # USER root -EXPOSE 8138 8139 +EXPOSE 8138 8139 8140 # Install VLC & Pulse Audio RUN apt update && apt install -y vlc python3-pip diff --git a/README.md b/README.md index 5c22dba..2b55feb 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ ███ (_)\/|_|| | | ███ ███ ███ ███ ███ -v0.10 █████████ 20210315 +v0.11 █████████ 20210321 ``` ## Table of Contents * [About](#about) @@ -28,20 +28,21 @@ v0.10 █████████ 20210315 ## About -Morph Ovum is your free and open source community radio service. +Morph Ovum is a free and open source community radio service. +* HTTP streams your radio's audio * No sound card and no desktop environment required * 3 audio sources (*all optional*): * Music (*e.g. smooth jazz*) * Ambience (*e.g. distant thunderstorms*) * Clips: Approximately every n minutes, play an audio clip (*e.g. "you're listening to morph ovum!"*) * Provides a [Flash-RESTful](https://flask-restful.readthedocs.io/en/latest/index.html)-based API for real-time control -* HTTP streams your radio's audio +* Provides a websocket for frontends to avoid polling * Plays anything [VLC Media Player](https://www.videolan.org/vlc/) can play: * Your personal library * An [airsonic](https://github.com/airsonic/airsonic) playlist * An internet radio station - * A YouTube playlist + * YouTube videos * ... @@ -55,14 +56,15 @@ services: stdin_open: true tty: true ports: - - '8138:8138' - - '8139:8139' + - '8138:8138' + - '8139:8139' + - '8140:8140' # volumes: # - /path/to/your/music:/fm/music # - /path/to/your/ambience:/fm/ambience # - /path/to/your/clips:/fm/clips # - /path/to/your/playlists:/fm/playlists -# - /path/to/your/config_dir:/fm/conf +# - /path/to/your/config_dir:/fm/conf environment: - MORPH_OVUM_PASSWORD=changeme # not used if config_dir is given - TZ="Europe/Zurich" @@ -216,12 +218,14 @@ The API listens by default on http://127.0.0.1:8139/api if ran via the above doc ### Python Requirements Obtained by docker container automatically. See `requirements.txt`. ``` +asyncio decorator flask_limiter flask_restful python-vlc pyyaml requests +websockets ``` ### File Purposes diff --git a/docker-compose.yaml b/docker-compose.yaml index dcc708d..04e88a9 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -8,6 +8,7 @@ services: ports: - '8138:8138' - '8139:8139' + - '8140:8140' volumes: - ./samples/music:/fm/music - ./samples/ambience:/fm/ambience diff --git a/requirements.txt b/requirements.txt index 54b97ba..29b67b4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,8 @@ +asyncio decorator flask_limiter flask_restful python-vlc pyyaml requests +websockets diff --git a/res/morph_ovum.vhosts b/res/morph_ovum.vhosts index a5cef38..ea238e8 100644 --- a/res/morph_ovum.vhosts +++ b/res/morph_ovum.vhosts @@ -24,4 +24,7 @@ ProxyPass / http://127.0.0.1:8139/ ProxyPassReverse / http://127.0.0.1:8139/ + + # Websocket. Requires: a2enmod proxy_wstunnel + ProxyPass /notify ws://127.0.0.1:8140 diff --git a/src/io_functions.py b/src/io_functions.py index 3529fb5..16beac5 100644 --- a/src/io_functions.py +++ b/src/io_functions.py @@ -167,7 +167,7 @@ def wp_funcs(self, url, music_or_ambience, wp_type): return self.make_output_data('url is missing protocol (http or https)', err=True) if re.search('http(?:s?)://?(?:www\.)?youtu\.?be(?:\.com)?', url): - if not re.search('http(?:s?)://(?:www\.)?youtu(?:be\.com\/watch\?v=|\.be\/)([\w\-\_]{11})(\?(amp;)?[\w\=]*|$)', url): + if not re.search('http(?:s?)://(?:www\.)?youtu(?:be\.com\/watch\?v=|\.be\/)([\w\-\_]{11})((amp;)?[\w\=]*|$)', url): return self.make_output_data('YouTube url "' + url + '" abnormal', err=True) try: @@ -473,5 +473,4 @@ def clips_now(self): now = datetime.datetime.today() self.audio_players.clips_thread.clip_schedule = now - return self.make_output_data('clip scheduled for ' + str(now[:-7:])) - + return self.make_output_data('clip scheduled for ' + str(now)[:-7:]) diff --git a/src/player_backend.py b/src/player_backend.py index a590476..3c01ace 100644 --- a/src/player_backend.py +++ b/src/player_backend.py @@ -4,7 +4,8 @@ # # 1. Defines config.yaml loader & validation functions # 2. Define functions for VLC library classes -# 3. Define AudioPlayers class +# 3. Define nofication websockets server class +# 4. Define AudioPlayers class # 4 VLC Instances # 1 Streams the pulse audio virtual sink # 3 Audio players that play to the virtual sink @@ -12,7 +13,7 @@ # 2. Ambience # 3. Clips # 1 ClipsThread -# 4. Define ClipsThread Thread +# 5. Define ClipsThread Thread # Manages clip playing times # ###################################################################### @@ -26,6 +27,8 @@ import yaml import datetime import socket +import asyncio +import websockets from threading import Thread from time import sleep from random import choice, normalvariate, shuffle @@ -368,9 +371,64 @@ def audio_file_dir_walk(directory, allowed_file_extensions={'mp3','wav'}): return music_file_list + +###################################################################### +# +# 3. Define nofication websockets server class +# +###################################################################### +class NotificationWebsocketsServer: + '''Creates a websocket server for clients to connect to and receive + updates about media list changes. Allows not to have to poll for updates''' + users = set() + music_change = False + ambience_change = False + + def __init__(self): + new_loop = asyncio.new_event_loop() + start_server = websockets.serve(self.handler, '0.0.0.0', 8140, loop=new_loop) + t = Thread(target=self.start_loop, args=(new_loop, start_server)) + t.start() + + async def register(self, websocket): + self.users.add(websocket) + + async def unregister(self, websocket): + self.users.remove(websocket) + + async def handler(self, websocket, path): + await self.register(websocket) + await websocket.send('welcome') + try: + while True: + await asyncio.sleep(1) + if self.music_change: + self.music_change = False + await self.music_change_notify() + if self.ambience_change: + self.ambience_change = False + await self.ambience_change_notify() + finally: + await self.unregister(websocket) + + def start_loop(self, loop, server): + loop.run_until_complete(server) + loop.run_forever() + + async def music_change_notify(self): + if self.users: + for user in self.users: + await user.send('music_changed') + + async def ambience_change_notify(self): + if self.users: + for user in self.users: + await user.send('ambience_changed') + + ###################################################################### # -# 3. Define AudioPlayers Class +# 4. Define AudioPlayers Class # ###################################################################### class AudioPlayers: @@ -397,14 +455,16 @@ def __init__(self, config_data): self.mp_music.set_media_list(self.ml_music) self.mp_ambience.set_media_list(self.ml_ambience) - # 3. Event managers (Song history) + # 3. Event managers (Song history & websocket notifications) + self.notification_server = NotificationWebsocketsServer() self.em_music = self.mp_music.get_media_player().event_manager() self.history_music = [] self.em_ambience = self.mp_ambience.get_media_player().event_manager() self.history_ambience = [] - def update_history_list(event, self, music_or_ambience): - '''callback function for event managers to store track history''' + def media_changed_event(event, self, music_or_ambience): + '''callback function for event managers to store track history and notify websockets clients of track changes''' + # update history mp = getattr(self, 'mp_' + music_or_ambience) history = getattr(self, 'history_' + music_or_ambience) if len(history) == 101: @@ -412,8 +472,12 @@ def update_history_list(event, self, music_or_ambience): history.append(media_list_player_get_song(mp)) setattr(self, 'history_' + music_or_ambience, history) - self.em_music.event_attach(vlc.EventType.MediaPlayerMediaChanged, update_history_list, self, 'music') - self.em_ambience.event_attach(vlc.EventType.MediaPlayerMediaChanged, update_history_list, self, 'ambience') + # set notificatoin server thread to notify + if self.notification_server.users: + setattr(self.notification_server, music_or_ambience + '_change', True) + + self.em_music.event_attach(vlc.EventType.MediaPlayerMediaChanged, media_changed_event, self, 'music') + self.em_ambience.event_attach(vlc.EventType.MediaPlayerMediaChanged, media_changed_event, self, 'ambience') # 4. Clips need a special Thread self.clips_thread = Clips(self) @@ -488,9 +552,10 @@ def nice_quit(self): self.mp_vaudio.stop() self.mp_music.get_media_player().audio_set_volume(100) + ###################################################################### # -# 4. Define ClipsThread Thread +# 5. Define ClipsThread Thread # Manages clip playing times # ######################################################################