From f7dd5d3c44efbd313d80dd5a2c65e4e8b0752c76 Mon Sep 17 00:00:00 2001 From: Jacopo Corzani Date: Thu, 25 Nov 2021 15:43:20 +0000 Subject: [PATCH] Isn't it --- setup.cfg | 11 +- src/radio81/__main__.py | 11 + src/radio81/console.py | 72 ++++++ src/radio81/genres.py | 413 +++++++++++++++++++++++++++++++++++ src/radio81/media_wrapper.py | 25 +++ src/radio81/parser.py | 26 +++ src/radio81/player.py | 90 ++++++++ src/radio81/skeleton.py | 143 ------------ tests/test_skeleton.py | 50 ++--- 9 files changed, 669 insertions(+), 172 deletions(-) create mode 100644 src/radio81/__main__.py create mode 100644 src/radio81/console.py create mode 100644 src/radio81/genres.py create mode 100644 src/radio81/media_wrapper.py create mode 100644 src/radio81/parser.py create mode 100644 src/radio81/player.py diff --git a/setup.cfg b/setup.cfg index 1651371..028d42a 100644 --- a/setup.cfg +++ b/setup.cfg @@ -12,10 +12,10 @@ license = MIT license_files = LICENSE.txt long_description = file: README.rst long_description_content_type = text/x-rst; charset=UTF-8 -url = https://github.com/pyscaffold/pyscaffold/ +url = https://github.com/corzani/radio_81 # Add here related links, for example: project_urls = - Documentation = https://pyscaffold.org/ +# Documentation = https://pyscaffold.org/ # Source = https://github.com/pyscaffold/pyscaffold/ # Changelog = https://pyscaffold.org/en/latest/changelog.html # Tracker = https://github.com/pyscaffold/pyscaffold/issues @@ -49,6 +49,9 @@ package_dir = # For more information, check out https://semver.org/. install_requires = importlib-metadata; python_version<"3.8" + aiohttp==3.8.1 + python-vlc==3.0.12118 + inquirerpy~=0.3.0 [options.packages.find] @@ -69,8 +72,8 @@ testing = [options.entry_points] # Add here console scripts like: -# console_scripts = -# script_name = radio81.module:function +console_scripts = + script_name = radio81.main:main # For example: # console_scripts = # fibonacci = radio81.skeleton:run diff --git a/src/radio81/__main__.py b/src/radio81/__main__.py new file mode 100644 index 0000000..05918ff --- /dev/null +++ b/src/radio81/__main__.py @@ -0,0 +1,11 @@ +import os + +os.environ["VLC_VERBOSE"] = "-1" + +from radio81.console import start + +# Press the green button in the gutter to run the script. + + +if __name__ == '__main__': + start() diff --git a/src/radio81/console.py b/src/radio81/console.py new file mode 100644 index 0000000..2c7593d --- /dev/null +++ b/src/radio81/console.py @@ -0,0 +1,72 @@ +from vlc import Meta + +import aiohttp +import asyncio + +from radio81.genres import default_shoutcast_data +from radio81.parser import select_genre, select_station +from radio81.player import load_stations, ShoutCastPlayer, play_station, closePlayer, createShoutCastPlayer +from radio81 import __version__ + + +def logo(): + print() + print('██████╗ █████╗ ██████╗ ██╗ ██████╗ █████╗ ██╗') + print('██╔══██╗██╔══██╗██╔══██╗██║██╔═══██╗ ██╔══██╗███║') + print('██████╔╝███████║██║ ██║██║██║ ██║ ╚█████╔╝╚██║') + print('██╔══██╗██╔══██║██║ ██║██║██║ ██║ ██╔══██╗ ██║') + print('██║ ██║██║ ██║██████╔╝██║╚██████╔╝ ╚█████╔╝ ██║') + print('╚═╝ ╚═╝╚═╝ ╚═╝╚═════╝ ╚═╝ ╚═════╝ ╚════╝ ╚═╝') + print(f'ALPHA {__version__} quit: CTRL+C') + print('----------------------------------------------------') + + +async def console_main(player: ShoutCastPlayer): + logo() + genres = default_shoutcast_data() + selected_genre = await select_genre(genres) + player.genre = selected_genre + + # Should I maintain this client session open? What would it imply? I guess nothing... + async with aiohttp.ClientSession(base_url='https://directory.shoutcast.com') as session: + stations = await load_stations(session, player, player.genre) + station = await select_station(stations) + + try: + media, url = await play_station(player, session, station) + except Exception as e: + print(e) + media = None + + if media is None: + print(f'Error on {station} ({station.id}) - SKIPPED') + else: + + # This is very bad but I can't retrieve the metachanged event from VLC + # So... at the moment I'll stick with this solution + while True: + media_title = media.get_meta(Meta.NowPlaying) + title = media_title if media_title is not None else 'Retrieving title...' + # It must be something better than this, if not the size of the window would change the behaviour + print(f'=> {title}', end='\r') + await asyncio.sleep(5) + + # Are you serious?!?!?!? MediaMetaChanged doesn't work? + # All examples online are with polling :(. Damn... Is it still a bug? + + # print("Press enter for the next Rock station :). Don't you like classic rock? Go back to school...") + + # At the moment this is almost unreachable apart if you pass all the current genre stations :) + closePlayer(player) + + +def start(): + async def start_radio(): + player = createShoutCastPlayer() + + try: + await console_main(player) + except asyncio.CancelledError: + closePlayer(player) + + asyncio.run(start_radio()) diff --git a/src/radio81/genres.py b/src/radio81/genres.py new file mode 100644 index 0000000..bbd2174 --- /dev/null +++ b/src/radio81/genres.py @@ -0,0 +1,413 @@ +from dataclasses import dataclass + +from typing import List, Dict + + +@dataclass +class Station: + name: str + id: str + url: str + + +@dataclass +class SubGenre: + name: str + stations: List[Station] + last_idx: int + + +@dataclass +class Genre(SubGenre): + sub_genres: Dict[str, SubGenre] + + +@dataclass() +class Shoutcast(Dict[str, Genre]): + pass + + +def create_shoutcast_struct(data): + return Genre(name=data['name'], + stations=[], + last_idx=0, + sub_genres={genre: SubGenre(name=genre, stations=[], last_idx=0) for genre in + data['sub_genres']}) + + +def default_shoutcast_data(): + return {genre.name: genre for genre in map(create_shoutcast_struct, shoutcast_genres)} + + +shoutcast_genres = [ + { + 'name': "Alternative", + 'sub_genres': [ + "Adult Alternative", + "Britpop", + "Classic Alternative", + "College", + "Dancepunk", + "Dream Pop", + "Emo", + "Goth", + "Grunge", + "Hardcore", + "Indie Pop", + "Indie Rock", + "Industrial", + "LoFi", + "Modern Rock", + "New Wave", + "Noise Pop", + "Post Punk", + "Power Pop", + "Punk", + "Ska", + "Xtreme"]}, + {'name': "Blues", + 'sub_genres': ["Acoustic Blues", + "Chicago Blues", + "Contemporary Blues", + "Country Blues", + "Delta Blues", + "Electric Blues", + "Cajun and Zydeco"]}, + {'name': "Classical", + 'sub_genres': [ + "Baroque", + "Chamber", + "Choral", + "Classical Period", + "Early Classical", + "Impressionist", + "Modern", + "Opera", + "Piano", + "Romantic", + "Symphony" + ]}, + {'name': "Country", + 'sub_genres': [ + "Alt Country", + "Americana", + "Bluegrass", + "Classic Country", + "Contemporary Bluegrass", + "Contemporary Country", + "Honky Tonk", + "Hot Country Hits", + "Western"]}, + { + 'name': "Easy Listening", + 'sub_genres': [ + "Exotica", + "Light Rock", + "Lounge", + "Orchestral Pop", + "Polka", + "Space Age Pop"]}, + { + 'name': "Electronic", + 'sub_genres': [ + "Acid House", + "Ambient", + "Big Beat", + "Breakbeat", + "Dance", + "Demo", + "Disco", + "Downtempo", + "Drum and Bass", + "Electro", + "Garage", + "Hard House", + "House", + "IDM", + "Jungle", + "Progressive", + "Techno", + "Trance", + "Tribal", + "Trip Hop", + "Dubstep" + ]}, + { + 'name': "Folk", + 'sub_genres': [ + "Alternative Folk", + "Contemporary Folk", + "Folk Rock", + "New Acoustic", + "Traditional Folk", + "World Folk", + "Old Time" + ] + }, { + 'name': "Themes", + 'sub_genres': [ + "Adult", + "Best Of", + "Chill", + "Eclectic", + "Experimental", + "Female", + "Heartache", + "Instrumental", + "LGBT", + "Love and Romance", + "Party Mix", + "Patriotic", + "Rainy Day Mix", + "Reality", + "Sexy", + "Shuffle", + "Travel Mix", + "Tribute", + "Trippy", + "Work Mix"] + }, { + 'name': "Rap", + 'sub_genres': [ + "Alternative Rap", + "Dirty South", + "East Coast Rap", + "Freestyle", + "Hip Hop", + "Gangsta Rap", + "Mixtapes", + "Old School", + "Turntablism", + "Underground Hip Hop", + "West Coast Rap" + ] + }, { + 'name': "Inspirational", + 'sub_genres': [ + "Christian", + "Christian Metal", + "Christian Rap", + "Christian Rock", + "Classic Christian", + "Contemporary Gospel", + "Gospel", + "Praise and Worship", + "Sermons and Services", + "Southern Gospel", + "Traditional Gospel" + ] + }, { + 'name': "International", + 'sub_genres': [ + "African", + "Arabic", + "Asian", + "Bollywood", + "Brazilian", + "Caribbean", + "Celtic", + "Chinese", + "European", + "Filipino", + "French", + "Greek", + "Hawaiian and Pacific", + "Hindi", + "Indian", + "Japanese", + "Hebrew", + "Klezmer", + "Korean", + "Mediterranean", + "Middle Eastern", + "North American", + "Russian", + "Soca", + "South American", + "Tamil", + "Worldbeat", + "Zouk", + "German", + "Turkish", + "Islamic", + "Afrikaans", + "Creole"] + }, { + 'name': "Jazz", + 'sub_genres': [ + "Acid Jazz", + "Avant Garde", + "Big Band", + "Bop", + "Classic Jazz", + "Cool Jazz", + "Fusion", + "Hard Bop", + "Latin Jazz", + "Smooth Jazz", + "Swing", + "Vocal Jazz", + "World Fusion"] + }, { + 'name': "Latin", + 'sub_genres': [ + "Bachata", + "Banda", + "Bossa Nova", + "Cumbia", + "Latin Dance", + "Latin Pop", + "Latin Rap and Hip Hop", + "Latin Rock", + "Mariachi", + "Merengue", + "Ranchera", + "Reggaeton", + "Regional Mexican", + "Salsa", + "Tango", + "Tejano", + "Tropicalia", + "Flamenco", + "Samba"] + }, { + 'name': "Metal", + 'sub_genres': [ + "Black Metal", + "Classic Metal", + "Extreme Metal", + "Grindcore", + "Hair Metal", + "Heavy Metal", + "Metalcore", + "Power Metal", + "Progressive Metal", + "Rap Metal", + "Death Metal", + "Thrash Metal"] + }, { + 'name': "New Age", + 'sub_genres': [ + "Environmental", + "Ethnic Fusion", + "Healing", + "Meditation", + "Spiritual"] + }, { + 'name': "Decades", + 'sub_genres': [ + "30s", + "40s", + "50s", + "60s", + "70s", + "80s", + "90s", + "00s"] + }, { + 'name': "Pop", + 'sub_genres': [ + "Adult Contemporary", + "Barbershop", + "Bubblegum Pop", + "Dance Pop", + "Idols", + "Oldies", + "JPOP", + "Soft Rock", + "Teen Pop", + "Top 40", + "World Pop", + "KPOP"] + }, { + 'name': "R&B and Urban", + 'sub_genres': [ + "Classic R&B", + "Contemporary R&B", + "Doo Wop", + "Funk", + "Motown", + "Neo Soul", + "Quiet Storm", + "Soul", + "Urban Contemporary"] + }, { + 'name': "Reggae", + 'sub_genres': [ + "Contemporary Reggae", + "Dancehall", + "Dub", + "Pop Reggae", + "Ragga", + "Rock Steady", + "Reggae Roots"] + }, { + 'name': "Rock", + 'sub_genres': [ + "Adult Album Alternative", + "British Invasion", + "Classic Rock", + "Garage Rock", + "Glam", + "Hard Rock", + "Jam Bands", + "Piano Rock", + "Prog Rock", + "Psychedelic", + "Rock & Roll", + "Rockabilly", + "Singer and Songwriter", + "Surf", + "JROCK", + "Celtic Rock"] + }, { + 'name': "Seasonal and Holiday", + 'sub_genres': [ + "Anniversary", + "Birthday", + "Christmas", + "Halloween", + "Hanukkah", + "Honeymoon", + "Kwanzaa", + "Valentine", + "Wedding", + "Winter"] + }, { + 'name': "Soundtracks", + 'sub_genres': [ + "Anime", + "Kids", + "Original Score", + "Showtunes", + "Video Game Music"] + }, { + 'name': "Talk", + 'sub_genres': [ + "Comedy", + "Community", + "Educational", + "Government", + "News", + "Old Time Radio", + "Other Talk", + "Political", + "Scanner", + "Spoken Word", + "Sports", + "Technology", + "BlogTalk", + ] + }, { + 'name': "Misc", + 'sub_genres': [] + }, { + 'name': "Public Radio", + 'sub_genres': [ + "News", + "Talk", + "College", + "Sports", + "Weather", + ] + }] diff --git a/src/radio81/media_wrapper.py b/src/radio81/media_wrapper.py new file mode 100644 index 0000000..8d3778c --- /dev/null +++ b/src/radio81/media_wrapper.py @@ -0,0 +1,25 @@ +from asyncio import sleep + +from vlc import Meta, Media + + +class MediaWrapper: + media: Media = None + + def get_media(self) -> Media: + return self.media + + def set_media(self, media: Media): + self.media = media + + +# This simulate the VLC real behaviour... +# This is awful :( +async def media_meta_changed(media_wrapper, callback_fn): + while True: + if media_wrapper.get_media() is not None: + media = media_wrapper.get_media() + title = media.get_meta(Meta.NowPlaying) + callback_fn(title) + sleep(2) + diff --git a/src/radio81/parser.py b/src/radio81/parser.py new file mode 100644 index 0000000..547d157 --- /dev/null +++ b/src/radio81/parser.py @@ -0,0 +1,26 @@ +from itertools import chain + +from InquirerPy import inquirer + + +async def select_genre(genres): + list_genres = map(lambda genre: [genre] + list(genres[genre].sub_genres.keys()), list(genres.keys())) + + flat_list = list(chain(*list_genres)) + selected = inquirer.fuzzy( + message="Select or Type a genre:", + choices=flat_list + ) + + result = await selected.application.run_async() + return result + + +async def select_station(stations): + selected = inquirer.fuzzy( + message="Select or Type a station:", + choices=list(map(lambda station: {'name': station.name, 'value': station}, stations)) + ) + + result = await selected.application.run_async() + return result diff --git a/src/radio81/player.py b/src/radio81/player.py new file mode 100644 index 0000000..a3c7434 --- /dev/null +++ b/src/radio81/player.py @@ -0,0 +1,90 @@ +import dataclasses +from typing import Dict + +import vlc +from vlc import Media, MediaParseFlag, MediaPlayer + +from radio81.genres import Station, default_shoutcast_data + + +# def createShoutCastPlayerByGenre( +# genres=default_shoutcast_data(), +# media_player=MediaPlayer(), +# default_genre=None, +# default_sub_genre=None): +# genre = next(iter(genres.values())) if default_genre is None else genres[default_genre] +# genre = genre if default_sub_genre is None else genre.sub_genres[default_sub_genre] +# +# return ShoutCastPlayer( +# genres, +# media_player, +# genre, +# current_genre +# ) + + +def createShoutCastPlayer( + genres=default_shoutcast_data(), + media_player=MediaPlayer(), + genre='Rock'): + return ShoutCastPlayer( + genres, + media_player, + genre + ) + + +@dataclasses.dataclass +class ShoutCastPlayer: + genres: Dict + media_player: vlc.MediaPlayer + genre: str + + +def closePlayer(shoutcast_player: ShoutCastPlayer): + shoutcast_player.media_player.release() + + +def play_stream(shoutcast_player: ShoutCastPlayer, url): + # This is temporary, I wouldn't execute play and stop within this function + + media_player = shoutcast_player.media_player + + media = Media(url) + media.parse_with_options(MediaParseFlag.network, 0) + media_player.stop() + media_player.set_media(media) + media.parse_with_options(1, 0) # Why???!?!? Do I have to set up the parsing option for every media? + media_player.play() + + return media + + +async def get_genre_stations(session, genre): + async with session.post('/Home/BrowseByGenre', + data={'genrename': genre}, timeout=5) as resp: + return await resp.json() + + +async def get_station_url(session, station_id): + async with session.post('/Player/GetStreamUrl', + data={"station": station_id}, timeout=5) as resp: + return await resp.json() + + +async def load_stations(session, shoutcast_player: ShoutCastPlayer, genre): + shoutcast_player.currentGenreStations = map(lambda station: Station(name=station['Name'], id=station['ID'], url=''), + await get_genre_stations(session, genre)) + return shoutcast_player.currentGenreStations + + +async def play_station(shoutcast_player: ShoutCastPlayer, session, station): + try: + url = await get_station_url(session, station.id) if station.url == '' else station.url + + media = play_stream(shoutcast_player, url) + + return media, url + except Exception as e: + print(e) + return None diff --git a/src/radio81/skeleton.py b/src/radio81/skeleton.py index e04b492..75c39d5 100644 --- a/src/radio81/skeleton.py +++ b/src/radio81/skeleton.py @@ -1,149 +1,6 @@ -""" -This is a skeleton file that can serve as a starting point for a Python -console script. To run this script uncomment the following lines in the -``[options.entry_points]`` section in ``setup.cfg``:: - console_scripts = - fibonacci = radio81.skeleton:run - -Then run ``pip install .`` (or ``pip install -e .`` for editable mode) -which will install the command ``fibonacci`` inside your current environment. - -Besides console scripts, the header (i.e. until ``_logger``...) of this file can -also be used as template for Python modules. - -Note: - This skeleton file can be safely removed if not needed! - -References: - - https://setuptools.readthedocs.io/en/latest/userguide/entry_point.html - - https://pip.pypa.io/en/stable/reference/pip_install -""" - -import argparse -import logging -import sys - -from radio81 import __version__ __author__ = "Jacopo Corzani" __copyright__ = "Jacopo Corzani" __license__ = "MIT" -_logger = logging.getLogger(__name__) - - -# ---- Python API ---- -# The functions defined in this section can be imported by users in their -# Python scripts/interactive interpreter, e.g. via -# `from radio81.skeleton import fib`, -# when using this Python module as a library. - - -def fib(n): - """Fibonacci example function - - Args: - n (int): integer - - Returns: - int: n-th Fibonacci number - """ - assert n > 0 - a, b = 1, 1 - for _i in range(n - 1): - a, b = b, a + b - return a - - -# ---- CLI ---- -# The functions defined in this section are wrappers around the main Python -# API allowing them to be called directly from the terminal as a CLI -# executable/script. - - -def parse_args(args): - """Parse command line parameters - - Args: - args (List[str]): command line parameters as list of strings - (for example ``["--help"]``). - - Returns: - :obj:`argparse.Namespace`: command line parameters namespace - """ - parser = argparse.ArgumentParser(description="Just a Fibonacci demonstration") - parser.add_argument( - "--version", - action="version", - version="radio81 {ver}".format(ver=__version__), - ) - parser.add_argument(dest="n", help="n-th Fibonacci number", type=int, metavar="INT") - parser.add_argument( - "-v", - "--verbose", - dest="loglevel", - help="set loglevel to INFO", - action="store_const", - const=logging.INFO, - ) - parser.add_argument( - "-vv", - "--very-verbose", - dest="loglevel", - help="set loglevel to DEBUG", - action="store_const", - const=logging.DEBUG, - ) - return parser.parse_args(args) - - -def setup_logging(loglevel): - """Setup basic logging - - Args: - loglevel (int): minimum loglevel for emitting messages - """ - logformat = "[%(asctime)s] %(levelname)s:%(name)s:%(message)s" - logging.basicConfig( - level=loglevel, stream=sys.stdout, format=logformat, datefmt="%Y-%m-%d %H:%M:%S" - ) - - -def main(args): - """Wrapper allowing :func:`fib` to be called with string arguments in a CLI fashion - - Instead of returning the value from :func:`fib`, it prints the result to the - ``stdout`` in a nicely formatted message. - - Args: - args (List[str]): command line parameters as list of strings - (for example ``["--verbose", "42"]``). - """ - args = parse_args(args) - setup_logging(args.loglevel) - _logger.debug("Starting crazy calculations...") - print("The {}-th Fibonacci number is {}".format(args.n, fib(args.n))) - _logger.info("Script ends here") - - -def run(): - """Calls :func:`main` passing the CLI arguments extracted from :obj:`sys.argv` - - This function can be used as entry point to create console scripts with setuptools. - """ - main(sys.argv[1:]) - - -if __name__ == "__main__": - # ^ This is a guard statement that will prevent the following code from - # being executed in the case someone imports this file instead of - # executing it as a script. - # https://docs.python.org/3/library/__main__.html - - # After installing your project with pip, users can also run your Python - # modules as scripts via the ``-m`` flag, as defined in PEP 338:: - # - # python -m radio81.skeleton 42 - # - run() diff --git a/tests/test_skeleton.py b/tests/test_skeleton.py index 1723971..2e0bae9 100644 --- a/tests/test_skeleton.py +++ b/tests/test_skeleton.py @@ -1,25 +1,25 @@ -import pytest - -from radio81.skeleton import fib, main - -__author__ = "Jacopo Corzani" -__copyright__ = "Jacopo Corzani" -__license__ = "MIT" - - -def test_fib(): - """API Tests""" - assert fib(1) == 1 - assert fib(2) == 1 - assert fib(7) == 13 - with pytest.raises(AssertionError): - fib(-10) - - -def test_main(capsys): - """CLI Tests""" - # capsys is a pytest fixture that allows asserts agains stdout/stderr - # https://docs.pytest.org/en/stable/capture.html - main(["7"]) - captured = capsys.readouterr() - assert "The 7-th Fibonacci number is 13" in captured.out +# import pytest +# +# from radio81.skeleton import fib, main +# +# __author__ = "Jacopo Corzani" +# __copyright__ = "Jacopo Corzani" +# __license__ = "MIT" +# +# +# def test_fib(): +# """API Tests""" +# assert fib(1) == 1 +# assert fib(2) == 1 +# assert fib(7) == 13 +# with pytest.raises(AssertionError): +# fib(-10) +# +# +# def test_main(capsys): +# """CLI Tests""" +# # capsys is a pytest fixture that allows asserts agains stdout/stderr +# # https://docs.pytest.org/en/stable/capture.html +# main(["7"]) +# captured = capsys.readouterr() +# assert "The 7-th Fibonacci number is 13" in captured.out