add basic support for webradio with tunein provider
authorMarcel van der Veldt <m.vanderveldt@outlook.com>
Sun, 2 Jun 2019 10:29:04 +0000 (12:29 +0200)
committerMarcel van der Veldt <m.vanderveldt@outlook.com>
Sun, 2 Jun 2019 10:29:04 +0000 (12:29 +0200)
13 files changed:
music_assistant/database.py
music_assistant/models.py
music_assistant/modules/http_streamer.py
music_assistant/modules/music.py
music_assistant/modules/musicproviders/tunein.py [new file with mode: 0644]
music_assistant/modules/player.py
music_assistant/modules/playerproviders/chromecast.py
music_assistant/modules/web.py
music_assistant/utils.py
music_assistant/web/components/headermenu.vue.js
music_assistant/web/images/icons/tunein.png [new file with mode: 0644]
music_assistant/web/index.html
music_assistant/web/strings.js

index 3eb48809a4718408c279cc7353811af1fbed76fc..a529758c7c059f100b50ac1f60132f60c9241028 100755 (executable)
@@ -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)
index 5c30f536aa3c8c44104793aa3ced4e958d526212..06609e346f36a6ab20fe8f0b02430fe6c8e3231b 100755 (executable)
@@ -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
index 4b6c4f699d5181b814e858c192b5387e9c8d372e..be0df8f7ff15db8d39fbfa495cbef0bfbacefb28 100755 (executable)
@@ -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 '''
index b8b9ecc70e8aa595372af41d31bde53c22c2dda2..3445fb772410edc84f703a0a354f0a3e39633b82 100755 (executable)
@@ -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 (file)
index 0000000..1350df4
--- /dev/null
@@ -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, "<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
index fb70db9106567e5d442186ca010f636392e987a1..1928d1d2452ee225be974ab39a406e51b32bceba 100755 (executable)
@@ -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", '<player>', "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):
index ac871066a03122f9b86de313be26b4a390985f0d..58d658d1bff6fc595df45ad0bc08b918d31526c5 100644 (file)
@@ -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)
             }
index 94e6da861890af40a8ba301b30f03ea5dcda4539..a1e5d37ff401cb39a870c4fae84fd48a99cf42ab 100755 (executable)
@@ -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 
index 2d84052ecc88d0a918bad742bb1ad88fc57414b6..971037fa848b0e86e2c8ab0f5a121aeadf414108 100755 (executable)
@@ -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)
index 5740e333b0e5589c210c0b523588439cb4b774a5..b65a5ce923367c03ca36d4e5d16f65741ff2afca 100755 (executable)
@@ -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 (file)
index 0000000..3352c29
Binary files /dev/null and b/music_assistant/web/images/icons/tunein.png differ
index 4a82fb6b0974fc207c94094c30f9ad02ead2dfb0..14a90a3d4c202569e252539d763566c58000a472 100755 (executable)
@@ -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;
index fcbf07b94c23aafddf9e374dc2dc508d2826eed7..b4011f6adcabe6257d44f8ca300b4817b778fc12 100644 (file)
@@ -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",