From: Marcel van der Veldt Date: Sun, 2 Jun 2019 10:29:04 +0000 (+0200) Subject: add basic support for webradio with tunein provider X-Git-Url: https://git.kitaultman.com/?a=commitdiff_plain;h=02ee873b91322c18f98f14e534e794f63e8fe59f;p=music-assistant-server.git add basic support for webradio with tunein provider --- diff --git a/music_assistant/database.py b/music_assistant/database.py index 3eb48809..a529758c 100755 --- a/music_assistant/database.py +++ b/music_assistant/database.py @@ -4,7 +4,7 @@ import asyncio import os from utils import run_periodic, LOGGER, get_sort_name, try_parse_int -from models import MediaType, Artist, Album, Track, Playlist +from models import MediaType, Artist, Album, Track, Playlist, Radio from typing import List import aiosqlite import operator @@ -42,6 +42,8 @@ class Database(): await db.execute('CREATE TABLE IF NOT EXISTS playlists(playlist_id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, owner TEXT NOT NULL, is_editable BOOLEAN NOT NULL, UNIQUE(name, owner));') await db.execute('CREATE TABLE IF NOT EXISTS playlist_tracks(playlist_id INTEGER NOT NULL, track_id INTEGER NOT NULL, position INTEGER, UNIQUE(playlist_id, track_id));') + await db.execute('CREATE TABLE IF NOT EXISTS radios(radio_id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE);') + await db.commit() await db.execute('VACUUM;') self.db_ready = True @@ -127,6 +129,30 @@ class Database(): playlists.append(playlist) return playlists + async def radios(self, filter_query=None, provider=None, limit=100000, offset=0, orderby='name') -> List[Radio]: + ''' fetch all radio records from table''' + items = [] + sql_query = 'SELECT * FROM radios' + if filter_query: + sql_query += filter_query + elif provider != None: + sql_query += ' WHERE radio_id in (SELECT item_id FROM provider_mappings WHERE provider = "%s" AND media_type = %d)' % (provider,MediaType.Radio) + sql_query += ' ORDER BY %s' % orderby + if limit: + sql_query += ' LIMIT %d OFFSET %d' %(limit, offset) + async with aiosqlite.connect(self.dbfile) as db: + async with db.execute(sql_query) as cursor: + db_rows = await cursor.fetchall() + for db_row in db_rows: + radio = Radio() + radio.item_id = db_row[0] + radio.name = db_row[1] + radio.metadata = await self.__get_metadata(radio.item_id, MediaType.Radio, db) + radio.provider_ids = await self.__get_prov_ids(radio.item_id, MediaType.Radio, db) + radio.in_library = await self.__get_library_providers(radio.item_id, MediaType.Radio, db) + items.append(radio) + return items + async def playlist(self, playlist_id:int) -> Playlist: ''' get playlist record by id ''' playlist_id = try_parse_int(playlist_id) @@ -135,6 +161,14 @@ class Database(): return None return playlists[0] + async def radio(self, radio_id:int) -> Playlist: + ''' get radio record by id ''' + radio_id = try_parse_int(radio_id) + radios = await self.radios(' WHERE radio_id = %s' % radio_id) + if not radios: + return None + return radios[0] + async def add_playlist(self, playlist:Playlist): ''' add a new playlist record into table''' assert(playlist.name) @@ -162,6 +196,30 @@ class Database(): await db.commit() return playlist_id + async def add_radio(self, radio:Radio): + ''' add a new radio record into table''' + assert(radio.name) + async with aiosqlite.connect(self.dbfile, timeout=20) as db: + async with db.execute('SELECT (radio_id) FROM radios WHERE name=?;', (radio.name,)) as cursor: + result = await cursor.fetchone() + if result: + radio_id = result[0] + else: + # insert radio + sql_query = 'INSERT OR REPLACE INTO radios (name) VALUES(?);' + await db.execute(sql_query, (radio.name,)) + # get id from newly created item (the safe way) + async with db.execute('SELECT (radio_id) FROM radios WHERE name=?;', (radio.name,)) as cursor: + radio_id = await cursor.fetchone() + radio_id = radio_id[0] + LOGGER.info('added radio station %s to database: %s' %(radio.name, radio_id)) + # add/update metadata + await self.__add_prov_ids(radio_id, MediaType.Radio, radio.provider_ids, db) + await self.__add_metadata(radio_id, MediaType.Radio, radio.metadata, db) + # save + await db.commit() + return radio_id + async def add_to_library(self, item_id:int, media_type:MediaType, provider:str): ''' add an item to the library (item must already be present in the db!) ''' item_id = try_parse_int(item_id) diff --git a/music_assistant/models.py b/music_assistant/models.py index 5c30f536..06609e34 100755 --- a/music_assistant/models.py +++ b/music_assistant/models.py @@ -16,6 +16,7 @@ class MediaType(IntEnum): Album = 2 Track = 3 Playlist = 4 + Radio = 5 def media_type_from_string(media_type_str): media_type_str = media_type_str.lower() @@ -27,6 +28,8 @@ def media_type_from_string(media_type_str): return MediaType.Track elif 'playlist' in media_type_str or media_type_str == '4': return MediaType.Playlist + elif 'radio' in media_type_str or media_type_str == '5': + return MediaType.Radio else: return None @@ -127,6 +130,20 @@ class Playlist(object): self.in_library = [] self.is_editable = False +class Radio(Track): + ''' representation of a radio station ''' + def __init__(self): + super().__init__() + self.item_id = None + self.provider = 'database' + self.name = '' + self.provider_ids = [] + self.metadata = {} + self.media_type = MediaType.Radio + self.in_library = [] + self.is_editable = False + self.duration = 0 + class MusicProvider(): ''' Model for a Musicprovider @@ -291,6 +308,15 @@ class MusicProvider(): else: return await self.get_playlist(prov_playlist_id) + async def radio(self, prov_radio_id) -> Radio: + ''' return radio details for the given provider playlist id ''' + db_id = await self.mass.db.get_database_id(self.prov_id, prov_radio_id, MediaType.Radio) + if db_id: + # synced radio, return database details + return await self.mass.db.radio(db_id) + else: + return await self.get_radio(prov_radio_id) + async def album_tracks(self, prov_album_id) -> List[Track]: ''' return album tracks for the given provider album id''' items = [] @@ -397,6 +423,10 @@ class MusicProvider(): ''' retrieve library/subscribed playlists from the provider ''' raise NotImplementedError + async def get_radios(self) -> List[Radio]: + ''' retrieve library/subscribed radio stations from the provider ''' + raise NotImplementedError + async def get_artist(self, prov_item_id) -> Artist: ''' get full artist details by id ''' raise NotImplementedError @@ -421,6 +451,10 @@ class MusicProvider(): ''' get full playlist details by id ''' raise NotImplementedError + async def get_radio(self, prov_item_id) -> Radio: + ''' get full radio details by id ''' + raise NotImplementedError + async def get_album_tracks(self, prov_item_id, limit=100, offset=0) -> List[Track]: ''' get album tracks for given album id ''' raise NotImplementedError diff --git a/music_assistant/modules/http_streamer.py b/music_assistant/modules/http_streamer.py index 4b6c4f69..be0df8f7 100755 --- a/music_assistant/modules/http_streamer.py +++ b/music_assistant/modules/http_streamer.py @@ -4,11 +4,14 @@ import asyncio import os from utils import LOGGER, try_parse_int, get_ip, run_async_background_task, run_periodic, get_folder_size -from models import TrackQuality +from models import TrackQuality, MediaType import shutil import xml.etree.ElementTree as ET import random import base64 +import operator +from aiohttp import web +import threading AUDIO_TEMP_DIR = "/tmp/audio_tmp" AUDIO_CACHE_DIR = "/tmp/audio_cache" @@ -47,17 +50,83 @@ class HTTPStreamer(): if not key in self.mass.config['base']['http_streamer']: self.mass.config['base']['http_streamer'][key] = def_value - async def get_audio_stream(self, audioqueue, track_id, provider, player_id=None): + async def stream_track(self, http_request): + ''' start streaming track from provider ''' + player_id = http_request.query.get('player_id') + track_id = http_request.query.get('track_id') + provider = http_request.query.get('provider') + resp = web.StreamResponse(status=200, + reason='OK', + headers={'Content-Type': 'audio/flac'}) + await resp.prepare(http_request) + if http_request.method.upper() != 'HEAD': + # stream audio + queue = asyncio.Queue() + cancelled = threading.Event() + task = run_async_background_task( + self.mass.bg_executor, + self.__get_audio_stream, queue, track_id, provider, player_id, cancelled) + try: + while True: + chunk = await queue.get() + if not chunk: + queue.task_done() + break + await resp.write(chunk) + queue.task_done() + LOGGER.info("Finished streaming %s" % track_id) + except asyncio.CancelledError: + cancelled.set() + LOGGER.info("Streaming interrupted for %s" % track_id) + raise asyncio.CancelledError() + return resp + + async def stream_radio(self, http_request): + ''' start streaming radio from provider ''' + player_id = http_request.query.get('player_id') + radio_id = http_request.query.get('radio_id') + provider = http_request.query.get('provider') + resp = web.StreamResponse(status=200, + reason='OK', + headers={'Content-Type': 'audio/flac'}) + await resp.prepare(http_request) + if http_request.method.upper() != 'HEAD': + # stream audio with sox + sox_effects = await self.__get_player_sox_options(radio_id, provider, player_id, True) + media_item = await self.mass.music.item(radio_id, MediaType.Radio, provider) + stream = sorted(media_item.provider_ids, key=operator.itemgetter('quality'), reverse=True)[0] + stream_url = stream["details"] + if stream["quality"] == TrackQuality.LOSSY_AAC: + input_content_type = "aac" + elif stream["quality"] == TrackQuality.LOSSY_OGG: + input_content_type = "ogg" + else: + input_content_type = "mp3" + if input_content_type == "aac": + args = 'ffmpeg -i "%s" -f flac - | sox -t flac -t flac -C 0 - %s' % (stream_url, sox_effects) + else: + args = 'sox -t %s "%s" -t flac -C 0 - %s' % (input_content_type, stream_url, sox_effects) + LOGGER.info("Running sox with args: %s" % args) + process = await asyncio.create_subprocess_shell(args, stdout=asyncio.subprocess.PIPE) + try: + while not process.stdout.at_eof(): + chunk = await process.stdout.read(128000) + if not chunk: + break + await resp.write(chunk) + await process.wait() + LOGGER.info("streaming of radio_id %s completed" % radio_id) + except asyncio.CancelledError: + process.terminate() + await process.wait() + LOGGER.info("streaming of radio_id %s interrupted" % radio_id) + raise asyncio.CancelledError() + return resp + + async def __get_audio_stream(self, audioqueue, track_id, provider, player_id=None, cancelled=None): ''' get audio stream from provider and apply additional effects/processing where/if needed''' - input_content_type = await self.mass.music.providers[provider].get_stream_content_type(track_id) cachefile = self.__get_track_cache_filename(track_id, provider) - sox_effects = '' - # sox settings - if self.mass.config['base']['http_streamer']['volume_normalisation']: - gain_correct = await self.__get_track_gain_correct(track_id, provider) - LOGGER.info("apply gain correction of %s" % gain_correct) - sox_effects += ' vol %s dB ' % gain_correct - sox_effects += await self.__get_player_sox_options(track_id, provider, player_id) + sox_effects = await self.__get_player_sox_options(track_id, provider, player_id, False) if os.path.isfile(cachefile): # we have a cache file for this track which we can use args = 'sox -t flac %s -t flac -C 0 - %s' % (cachefile, sox_effects) @@ -67,6 +136,8 @@ class HTTPStreamer(): buffer_task = None else: # stream from provider + input_content_type = await self.mass.music.providers[provider].get_stream_content_type(track_id) + assert(input_content_type) args = 'sox -t %s - -t flac -C 0 - %s' % (input_content_type, sox_effects) LOGGER.info("Running sox with args: %s" % args) process = await asyncio.create_subprocess_shell(args, @@ -78,19 +149,21 @@ class HTTPStreamer(): chunk = await process.stdout.read(256000) if not chunk: break - await audioqueue.put(chunk) - if audioqueue.qsize() > 10: - await asyncio.sleep(0.1) # cooldown a bit + if not cancelled.is_set(): + await audioqueue.put(chunk) + if audioqueue.qsize() > 10: + await asyncio.sleep(0.1) # cooldown a bit await process.wait() await audioqueue.put('') # indicate EOF - LOGGER.info("streaming of track_id %s completed" % track_id) + if cancelled.is_set(): + LOGGER.info("streaming of track_id %s interrupted" % track_id) + else: + LOGGER.info("streaming of track_id %s completed" % track_id) - async def __get_player_sox_options(self, track_id, provider, player_id): + async def __get_player_sox_options(self, track_id, provider, player_id, is_radio): ''' get player specific sox options ''' - sox_effects = ' ' - if not player_id: - return '' - if self.mass.config['player_settings'][player_id]['max_sample_rate']: + sox_effects = '' + if player_id and not is_radio and self.mass.config['player_settings'][player_id]['max_sample_rate']: # downsample if needed max_sample_rate = try_parse_int(self.mass.config['player_settings'][player_id]['max_sample_rate']) if max_sample_rate: @@ -110,9 +183,12 @@ class HTTPStreamer(): sox_effects += 'rate -v 96000' elif quality > TrackQuality.FLAC_LOSSLESS_HI_RES_1 and max_sample_rate == 48000: sox_effects += 'rate -v 48000' - if self.mass.config['player_settings'][player_id]['sox_effects']: - sox_effects += self.mass.config['player_settings'][player_id]['sox_effects'] - return sox_effects + ' ' + if player_id and self.mass.config['player_settings'][player_id]['sox_effects']: + sox_effects += ' ' + self.mass.config['player_settings'][player_id]['sox_effects'] + if self.mass.config['base']['http_streamer']['volume_normalisation']: + gain_correct = await self.__get_track_gain_correct(track_id, provider) + sox_effects += ' vol %s dB ' % gain_correct + return sox_effects async def __analyze_audio(self, tmpfile, track_id, provider, content_type): ''' analyze track audio, for now we only calculate EBU R128 loudness ''' diff --git a/music_assistant/modules/music.py b/music_assistant/modules/music.py index b8b9ecc7..3445fb77 100755 --- a/music_assistant/modules/music.py +++ b/music_assistant/modules/music.py @@ -3,10 +3,10 @@ import asyncio import os -from utils import run_periodic, run_async_background_task, LOGGER, try_parse_int +from utils import run_periodic, run_async_background_task, LOGGER, try_parse_int, try_supported import aiohttp from difflib import SequenceMatcher as Matcher -from models import MediaType, Track, Artist, Album, Playlist +from models import MediaType, Track, Artist, Album, Playlist, Radio from typing import List import toolz import operator @@ -37,6 +37,8 @@ class Music(): return await self.track(item_id, provider, lazy=lazy) elif media_type == MediaType.Playlist: return await self.playlist(item_id, provider) + elif media_type == MediaType.Radio: + return await self.radio(item_id, provider) else: return None @@ -56,6 +58,10 @@ class Music(): ''' return all library playlists, optionally filtered by provider ''' return await self.mass.db.playlists(provider=provider_filter, limit=limit, offset=offset, orderby=orderby) + async def radios(self, limit=0, offset=0, orderby='name', provider_filter=None) -> List[Playlist]: + ''' return all library radios, optionally filtered by provider ''' + return await self.mass.db.radios(provider=provider_filter, limit=limit, offset=offset, orderby=orderby) + async def library_items(self, media_type:MediaType, limit=0, offset=0, orderby='name', provider_filter=None) -> List[object]: ''' get multiple music items in library''' if media_type == MediaType.Artist: @@ -66,6 +72,8 @@ class Music(): return await self.library_tracks(limit=limit, offset=offset, orderby=orderby, provider_filter=provider_filter) elif media_type == MediaType.Playlist: return await self.playlists(limit=limit, offset=offset, orderby=orderby, provider_filter=provider_filter) + elif media_type == MediaType.Radio: + return await self.radios(limit=limit, offset=offset, orderby=orderby, provider_filter=provider_filter) async def artist(self, item_id, provider='database', lazy=True) -> Artist: ''' get artist by id ''' @@ -91,12 +99,25 @@ class Music(): return await self.mass.db.playlist(item_id) return await self.providers[provider].playlist(item_id) + async def radio(self, item_id, provider='database') -> Radio: + ''' get radio by id ''' + if not provider or provider == 'database': + return await self.mass.db.radio(item_id) + return await self.providers[provider].radio(item_id) + async def playlist_by_name(self, name) -> Playlist: ''' get playlist by name ''' for playlist in await self.playlists(): if playlist.name == name: return playlist return None + + async def radio_by_name(self, name) -> Radio: + ''' get radio by name ''' + for radio in await self.radios(): + if radio.name == name: + return radio + return None async def artist_toptracks(self, artist_id, provider='database') -> List[Track]: ''' get top tracks for given artist ''' @@ -238,10 +259,11 @@ class Music(): self.sync_running = True for prov_id in self.providers.keys(): # sync library artists - await self.sync_library_artists(prov_id) - await self.sync_library_albums(prov_id) - await self.sync_library_tracks(prov_id) - await self.sync_playlists(prov_id) + await try_supported(self.sync_library_artists(prov_id)) + await try_supported(self.sync_library_albums(prov_id)) + await try_supported(self.sync_library_tracks(prov_id)) + await try_supported(self.sync_playlists(prov_id)) + await try_supported(self.sync_radios(prov_id)) self.sync_running = False async def sync_library_artists(self, prov_id): @@ -347,6 +369,26 @@ class Music(): await self.mass.db.remove_playlist_track(db_playlist_id, db_id) LOGGER.info("Finished syncing Playlist %s tracks for provider %s" % (prov_playlist_id, prov_id)) + async def sync_radios(self, prov_id): + ''' sync library radios for given provider''' + music_provider = self.providers[prov_id] + prev_items = await self.radios(provider_filter=prov_id) + prev_db_ids = [item.item_id for item in prev_items] + cur_items = await music_provider.get_radios() + cur_db_ids = [] + for item in cur_items: + db_id = await self.mass.db.get_database_id(prov_id, item.item_id, MediaType.Radio) + if not db_id: + db_id = await self.mass.db.add_radio(item) + cur_db_ids.append(db_id) + if not db_id in prev_db_ids: + await self.mass.db.add_to_library(db_id, MediaType.Radio, prov_id) + # process deletions + for db_id in prev_db_ids: + if db_id not in cur_db_ids: + await self.mass.db.remove_from_library(db_id, MediaType.Radio, prov_id) + LOGGER.info("Finished syncing Radios for provider %s" % prov_id) + def load_music_providers(self): ''' dynamically load musicproviders ''' for item in os.listdir(MODULES_PATH): diff --git a/music_assistant/modules/musicproviders/tunein.py b/music_assistant/modules/musicproviders/tunein.py new file mode 100644 index 00000000..1350df4e --- /dev/null +++ b/music_assistant/modules/musicproviders/tunein.py @@ -0,0 +1,154 @@ +#!/usr/bin/env python3 +# -*- coding:utf-8 -*- + +import asyncio +import os +from typing import List +import sys +import time +from utils import run_periodic, LOGGER, parse_track_title +from models import MusicProvider, MediaType, TrackQuality, Radio +from constants import CONF_USERNAME, CONF_PASSWORD, CONF_ENABLED +from asyncio_throttle import Throttler +import json +import aiohttp +from modules.cache import use_cache +import concurrent + +def setup(mass): + ''' setup the provider''' + enabled = mass.config["musicproviders"]['tunein'].get(CONF_ENABLED) + username = mass.config["musicproviders"]['tunein'].get(CONF_USERNAME) + password = mass.config["musicproviders"]['tunein'].get(CONF_PASSWORD) + if enabled and username and password: + provider = TuneInProvider(mass, username, password) + return provider + return False + +def config_entries(): + ''' get the config entries for this provider (list with key/value pairs)''' + return [ + (CONF_ENABLED, False, CONF_ENABLED), + (CONF_USERNAME, "", CONF_USERNAME), + (CONF_PASSWORD, "", CONF_PASSWORD) + ] + +class TuneInProvider(MusicProvider): + + + def __init__(self, mass, username, password): + self.name = 'TuneIn Radio' + self.prov_id = 'tunein' + self.mass = mass + self.cache = mass.cache + self.http_session = aiohttp.ClientSession(loop=mass.event_loop, connector=aiohttp.TCPConnector(verify_ssl=False)) + self.throttler = Throttler(rate_limit=1, period=1) + self._username = username + self._password = password + + async def search(self, searchstring, media_types=List[MediaType], limit=5): + ''' perform search on the provider ''' + result = { + "artists": [], + "albums": [], + "tracks": [], + "playlists": [], + "radios": [] + } + return result + + async def get_radios(self): + ''' get favorited/library radio stations ''' + items = [] + params = {"c": "presets"} + result = await self.__get_data("Browse.ashx", params, ignore_cache=True) + if result and "body" in result: + for item in result["body"]: + # TODO: expand folders + if item["type"] == "audio": + radio = await self.__parse_radio(item) + items.append(radio) + return items + + async def get_radio(self, radio_id): + ''' get radio station details ''' + radio = None + params = {"c": "composite", "detail": "listing", "id": radio_id} + result = await self.__get_data("Describe.ashx", params, ignore_cache=True) + if result and result.get("body") and result["body"][0].get("children"): + item = result["body"][0]["children"][0] + radio = await self.__parse_radio(item) + return radio + + async def __parse_radio(self, details): + ''' parse Radio object from json obj returned from api ''' + radio = Radio() + radio.item_id = details['preset_id'] + radio.provider = self.prov_id + if "name" in details: + radio.name = details["name"] + else: + # parse name from text attr + name = details["text"] + if " | " in name: + name = name.split(" | ")[1] + name = name.split(" (")[0] + radio.name = name + # parse stream urls and format + stream_info = await self.__get_stream_urls(radio.item_id) + for stream in stream_info["body"]: + if stream["media_type"] == 'aac': + quality = TrackQuality.LOSSY_AAC + elif stream["media_type"] == 'ogg': + quality = TrackQuality.LOSSY_OGG + else: + quality = TrackQuality.LOSSY_MP3 + radio.provider_ids.append({ + "provider": self.prov_id, + "item_id": details['preset_id'], + "quality": quality, + "details": stream['url'] + }) + # image + if "image" in details: + radio.metadata["image"] = details["image"] + elif "logo" in details: + radio.metadata["image"] = details["logo"] + return radio + + async def __get_stream_urls(self, radio_id): + ''' get the stream urls for the given radio id ''' + params = {"id": radio_id} + res = await self.__get_data("Tune.ashx", params) + return res + + # async def get_stream_content_type(self, radio_id): + # ''' return the content type for the given radio when it will be streamed''' + # return 'flac' #TODO handle other file formats on qobuz? + + # async def get_audio_stream(self, track_id): + # ''' get audio stream for a track ''' + # params = {'format_id': 27, 'track_id': track_id, 'intent': 'stream'} + # # we are called from other thread + # streamdetails_future = asyncio.run_coroutine_threadsafe( + # self.__get_data('track/getFileUrl', params, sign_request=True, ignore_cache=True), + # self.mass.event_loop + + @use_cache(7) + async def __get_data(self, endpoint, params={}, ignore_cache=False, cache_checksum=None): + ''' get data from api''' + url = 'https://opml.radiotime.com/%s' % endpoint + params['render'] = 'json' + params['formats'] = 'ogg,aac,wma,mp3' + params['username'] = self._username + params['partnerId'] = '1' + async with self.throttler: + async with self.http_session.get(url, params=params) as response: + result = await response.json() + if not result or 'error' in result: + LOGGER.error(url) + LOGGER.error(params) + result = None + return result + + \ No newline at end of file diff --git a/music_assistant/modules/player.py b/music_assistant/modules/player.py index fb70db91..1928d1d2 100755 --- a/music_assistant/modules/player.py +++ b/music_assistant/modules/player.py @@ -280,8 +280,7 @@ class Player(): ("disable_volume", False, "player_disable_vol"), ("sox_effects", '', "http_streamer_sox_effects"), ("max_sample_rate", '96000', "max_sample_rate"), - ("force_http_streamer", False, "force_http_streamer"), - ("group_parent", '', "group_parent") + ("force_http_streamer", False, "force_http_streamer") ] if player_details.is_group: config_entries += [ # group player settings @@ -312,7 +311,7 @@ class Player(): ''' play media on a player player_id: id of the player - media_item: media item(s) that should be played (Track, Album, Artist, Playlist) + media_item: media item(s) that should be played (Track, Album, Artist, Playlist, Radio) queue_opt: play, replace, next or add ''' if not player_id in self._players: @@ -336,18 +335,19 @@ class Player(): for track in tracks: # sort by quality match_found = False + is_radio = track.media_type == MediaType.Radio for prov_media in sorted(track.provider_ids, key=operator.itemgetter('quality'), reverse=True): media_provider = prov_media['provider'] media_item_id = prov_media['item_id'] player_supported_provs = player_prov.supported_musicproviders if media_provider in player_supported_provs and not self.mass.config['player_settings'][player_id]['force_http_streamer']: # the provider can handle this media_type directly ! - track.uri = await self.get_track_uri(media_item_id, media_provider, player_id) + track.uri = await self.get_track_uri(media_item_id, media_provider, player_id, is_radio=is_radio) playable_tracks.append(track) match_found = True elif 'http' in player_prov.supported_musicproviders: # fallback to http streaming if supported - track.uri = await self.get_track_uri(media_item_id, media_provider, player_id, True) + track.uri = await self.get_track_uri(media_item_id, media_provider, player_id, True, is_radio=is_radio) playable_tracks.append(track) match_found = True if match_found: @@ -361,19 +361,26 @@ class Player(): else: raise Exception("Musicprovider and/or media not supported by player %s !" % (player_id) ) - async def get_track_uri(self, item_id, provider, player_id, http_stream=False): + async def get_track_uri(self, item_id, provider, player_id, http_stream=False, is_radio=False): ''' generate the URL/URI for a media item ''' uri = "" if http_stream: - params = {"provider": provider, "track_id": str(item_id), "player_id": str(player_id)} - params_str = urllib.parse.urlencode(params) - uri = 'http://%s:%s/stream?%s'% (self.local_ip, self.mass.config['base']['web']['http_port'], params_str) + if is_radio: + params = {"provider": provider, "radio_id": str(item_id), "player_id": str(player_id)} + params_str = urllib.parse.urlencode(params) + uri = 'http://%s:%s/stream_radio?%s'% (self.local_ip, self.mass.config['base']['web']['http_port'], params_str) + else: + params = {"provider": provider, "track_id": str(item_id), "player_id": str(player_id)} + params_str = urllib.parse.urlencode(params) + uri = 'http://%s:%s/stream_track?%s'% (self.local_ip, self.mass.config['base']['web']['http_port'], params_str) elif provider == "spotify": uri = 'spotify://spotify:track:%s' % item_id elif provider == "qobuz": uri = 'qobuz://%s.flac' % item_id elif provider == "file": uri = item_id + else: + uri = "%s://%s" %(provider, item_id) return uri async def player_queue(self, player_id, offset=0, limit=50): diff --git a/music_assistant/modules/playerproviders/chromecast.py b/music_assistant/modules/playerproviders/chromecast.py index ac871066..58d658d1 100644 --- a/music_assistant/modules/playerproviders/chromecast.py +++ b/music_assistant/modules/playerproviders/chromecast.py @@ -230,7 +230,7 @@ class ChromecastProvider(PlayerProvider): 'streamType': 'BUFFERED', 'metadata': { 'title': track.name, - 'artist': track.artists[0].name, + 'artist': track.artists[0].name if track.artists else "", }, 'duration': int(track.duration) } diff --git a/music_assistant/modules/web.py b/music_assistant/modules/web.py index 94e6da86..a1e5d37f 100755 --- a/music_assistant/modules/web.py +++ b/music_assistant/modules/web.py @@ -12,6 +12,7 @@ from functools import partial json_serializer = partial(json.dumps, default=lambda x: x.__dict__) import ssl import concurrent +import threading def setup(mass): ''' setup the module and read/apply config''' @@ -68,7 +69,8 @@ class Web(): app.add_routes([web.get('/jsonrpc.js', self.json_rpc)]) app.add_routes([web.post('/jsonrpc.js', self.json_rpc)]) app.add_routes([web.get('/ws', self.websocket_handler)]) - app.add_routes([web.get('/stream', self.stream)]) + app.add_routes([web.get('/stream_track', self.mass.http_streamer.stream_track)]) + app.add_routes([web.get('/stream_radio', self.mass.http_streamer.stream_radio)]) app.add_routes([web.get('/api/search', self.search)]) app.add_routes([web.get('/api/config', self.get_config)]) app.add_routes([web.post('/api/config', self.save_config)]) @@ -169,6 +171,8 @@ class Web(): media_types.append(MediaType.Track) if not media_types_query or "playlists" in media_types_query: media_types.append(MediaType.Playlist) + if not media_types_query or "radios" in media_types_query: + media_types.append(MediaType.Radio) # get results from database result = await self.mass.music.search(searchquery, media_types, limit=limit, online=online) return web.json_response(result, dumps=json_serializer) @@ -260,30 +264,6 @@ class Web(): self.mass.save_config() return web.Response(text='success') - async def stream(self, request): - ''' start streaming audio from provider ''' - player_id = request.query.get('player_id') - track_id = request.query.get('track_id') - provider = request.query.get('provider') - resp = web.StreamResponse(status=200, - reason='OK', - headers={'Content-Type': 'audio/flac'}) - await resp.prepare(request) - if request.method.upper() != 'HEAD': - # stream audio - queue = asyncio.Queue() - run_async_background_task( - self.mass.bg_executor, self.mass.http_streamer.get_audio_stream, queue, track_id, provider, player_id) - while True: - chunk = await queue.get() - if not chunk: - queue.task_done() - break - await resp.write(chunk) - queue.task_done() - LOGGER.info("Finished streaming %s" % track_id) - return resp - async def json_rpc(self, request): ''' implement LMS jsonrpc interface diff --git a/music_assistant/utils.py b/music_assistant/utils.py index 2d84052e..971037fa 100755 --- a/music_assistant/utils.py +++ b/music_assistant/utils.py @@ -24,6 +24,15 @@ def run_periodic(period): return wrapper return scheduler +async def try_supported(task): + ''' try to execute a task and pass NotImplementedError Exception ''' + ret = None + try: + ret = await task + except NotImplementedError: + pass + return ret + def run_background_task(executor, corofn, *args): ''' run non-async task in background ''' return asyncio.get_event_loop().run_in_executor(executor, corofn, *args) diff --git a/music_assistant/web/components/headermenu.vue.js b/music_assistant/web/components/headermenu.vue.js index 5740e333..b65a5ce9 100755 --- a/music_assistant/web/components/headermenu.vue.js +++ b/music_assistant/web/components/headermenu.vue.js @@ -62,6 +62,7 @@ Vue.component("headermenu", { { title: this.$t('albums'), icon: "album", path: "/albums" }, { title: this.$t('tracks'), icon: "audiotrack", path: "/tracks" }, { title: this.$t('playlists'), icon: "playlist_play", path: "/playlists" }, + { title: this.$t('radios'), icon: "radio", path: "/radios" }, { title: this.$t('search'), icon: "search", path: "/search" }, { title: this.$t('settings'), icon: "settings", path: "/config" } ] diff --git a/music_assistant/web/images/icons/tunein.png b/music_assistant/web/images/icons/tunein.png new file mode 100644 index 00000000..3352c29c Binary files /dev/null and b/music_assistant/web/images/icons/tunein.png differ diff --git a/music_assistant/web/index.html b/music_assistant/web/index.html index 4a82fb6b..14a90a3d 100755 --- a/music_assistant/web/index.html +++ b/music_assistant/web/index.html @@ -57,7 +57,7 @@ endpoint = "/artists/" else if (item.media_type == 2) endpoint = "/albums/" - else if (item.media_type == 3) + else if (item.media_type == 3 || item.media_type == 5) { this.showPlayMenu(item); return; diff --git a/music_assistant/web/strings.js b/music_assistant/web/strings.js index fcbf07b9..b4011f6a 100644 --- a/music_assistant/web/strings.js +++ b/music_assistant/web/strings.js @@ -9,6 +9,7 @@ const messages = { albums: "Albums", tracks: "Tracks", playlists: "Playlists", + radios: "Radio", search: "Search", settings: "Settings", queue: "Queue", @@ -27,6 +28,7 @@ const messages = { http_streamer: "Built-in (sox based) streamer", qobuz: "Qobuz", spotify: "Spotify", + tunein: "TuneIn", file: "Filesystem", chromecast: "Chromecast", lms: "Logitech Media Server", @@ -64,7 +66,10 @@ const messages = { http_streamer_sox_effects: "Custom sox effects to apply to audio (built-in streamer only!) See http://sox.sourceforge.net/sox.html#EFFECTS", max_sample_rate: "Maximum sample rate this player supports, higher will be downsampled", force_http_streamer: "Force use of built-in streamer, even if the player can handle the music provider directly", - not_grouped: "Not grouped" + not_grouped: "Not grouped", + conf_saved: "Configuration saved, restart app to make effective", + audio_cache_folder: "Directory to use for cache files", + audio_cache_max_size_gb: "Maximum size of the cache folder (GB)" }, // player strings players: "Players", @@ -90,6 +95,7 @@ const messages = { albums: "Albums", tracks: "Nummers", playlists: "Afspeellijsten", + radios: "Radio", search: "Zoeken", settings: "Instellingen", queue: "Wachtrij", @@ -108,6 +114,7 @@ const messages = { http_streamer: "Ingebouwde (sox gebaseerde) streamer", qobuz: "Qobuz", spotify: "Spotify", + tunein: "TuneIn", file: "Bestandssysteem", chromecast: "Chromecast", lms: "Logitech Media Server", @@ -146,7 +153,9 @@ const messages = { max_sample_rate: "Maximale sample rate welke deze speler ondersteund, hoger wordt gedownsampled.", force_http_streamer: "Forceer het gebruik van de ingebouwde streamer, ook al heeft de speler directe ondersteuning voor de muziek provider", not_grouped: "Niet gegroepeerd", - conf_saved: "Configutaion saved, restart app to make effective" + conf_saved: "Configuratie is opgeslagen, herstart om actief te maken", + audio_cache_folder: "Map om te gebruiken voor cache bestanden", + audio_cache_max_size_gb: "Maximale grootte van de cache map in GB." }, // player strings players: "Spelers",