Permalink
Cannot retrieve contributors at this time
Name already in use
A tag already exists with the provided branch name. Many Git commands accept both tag and branch names, so creating this branch may cause unexpected behavior. Are you sure you want to create this branch?
mycroft-skill-tunein/__init__.py /
Go to fileThis commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
211 lines (179 sloc)
8.23 KB
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # The MIT License (MIT) | |
| # | |
| # Copyright (c) 2019 John Bartkiw | |
| # | |
| # Permission is hereby granted, free of charge, to any person obtaining a copy | |
| # of this software and associated documentation files (the "Software"), to deal | |
| # in the Software without restriction, including without limitation the rights | |
| # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | |
| # copies of the Software, and to permit persons to whom the Software is | |
| # furnished to do so, subject to the following conditions: | |
| # | |
| # The above copyright notice and this permission notice shall be included in all | |
| # copies or substantial portions of the Software. | |
| # | |
| # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | |
| # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | |
| # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | |
| # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | |
| # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | |
| # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | |
| # SOFTWARE. | |
| import re | |
| import requests | |
| from xml.dom.minidom import parseString | |
| import yaml | |
| import os.path | |
| from os import path | |
| from os.path import expanduser | |
| from mycroft.audio.services.vlc import VlcService | |
| from mycroft.skills.common_play_skill import CommonPlaySkill, CPSMatchLevel | |
| from mycroft.skills.core import intent_file_handler | |
| from mycroft.util.log import LOG | |
| from mycroft.audio import wait_while_speaking | |
| # Static values for tunein search requests | |
| base_url = "http://opml.radiotime.com/Search.ashx" | |
| headers = {} | |
| class TuneinSkill(CommonPlaySkill): | |
| def __init__(self): | |
| super().__init__(name="TuneinSkill") | |
| self.mediaplayer = VlcService(config={'low_volume': 10, 'duck': True}) | |
| self.audio_state = "stopped" # 'playing', 'stopped' | |
| self.station_name = None | |
| self.stream_url = None | |
| self.mpeg_url = None | |
| self.regexes = {} | |
| def CPS_match_query_phrase(self, phrase): | |
| # Look for regex matches starting from the most specific to the least | |
| # Play <data> internet radio on tune in | |
| match = re.search(self.translate_regex('internet_radio_on_tunein'), phrase) | |
| if match: | |
| data = re.sub(self.translate_regex('internet_radio_on_tunein'), '', phrase) | |
| LOG.debug("CPS Match (internet_radio_on_tunein): " + data) | |
| return phrase, CPSMatchLevel.EXACT, data | |
| # Play <data> radio on tune in | |
| match = re.search(self.translate_regex('radio_on_tunein'), phrase) | |
| if match: | |
| data = re.sub(self.translate_regex('radio_on_tunein'), '', phrase) | |
| LOG.debug("CPS Match (radio_on_tunein): " + data) | |
| return phrase, CPSMatchLevel.EXACT, data | |
| # Play <data> on tune in | |
| match = re.search(self.translate_regex('on_tunein'), phrase) | |
| if match: | |
| data = re.sub(self.translate_regex('on_tunein'), '', phrase) | |
| LOG.debug("CPS Match (on_tunein): " + data) | |
| return phrase, CPSMatchLevel.EXACT, data | |
| # Play <data> internet radio | |
| match = re.search(self.translate_regex('internet_radio'), phrase) | |
| if match: | |
| data = re.sub(self.translate_regex('internet_radio'), '', phrase) | |
| LOG.debug("CPS Match (internet_radio): " + data) | |
| return phrase, CPSMatchLevel.CATEGORY, data | |
| # Play <data> radio | |
| match = re.search(self.translate_regex('radio'), phrase) | |
| if match: | |
| data = re.sub(self.translate_regex('radio'), '', phrase) | |
| LOG.debug("CPS Match (radio): " + data) | |
| return phrase, CPSMatchLevel.CATEGORY, data | |
| return phrase, CPSMatchLevel.GENERIC, phrase | |
| def CPS_start(self, phrase, data): | |
| LOG.debug("CPS Start: " + data) | |
| self.find_station(data) | |
| @intent_file_handler('StreamRequest.intent') | |
| def handle_stream_intent(self, message): | |
| self.find_station(message.data["station"]) | |
| LOG.debug("Station data: " + message.data["station"]) | |
| def apply_aliases(self, search_term): | |
| # Allow search terms to be expanded or aliased | |
| home = expanduser('~') | |
| alias_file = home + '/tunein_aliases.yaml' | |
| if path.exists(alias_file): | |
| with open(alias_file, 'r') as file: | |
| alias_list = yaml.load(file) | |
| if search_term in alias_list: | |
| search_term = alias_list[search_term] | |
| return search_term | |
| # Attempt to find the first active station matching the query string | |
| def find_station(self, search_term): | |
| tracklist = [] | |
| LOG.debug("pre-alias search_term: " + search_term); | |
| search_term = self.apply_aliases(search_term) | |
| LOG.debug("aliased search_term: " + search_term); | |
| payload = { "query" : search_term } | |
| # get the response from the TuneIn API | |
| res = requests.post(base_url, data=payload, headers=headers) | |
| dom = parseString(res.text) | |
| # results are each in their own <outline> tag as defined by OPML (https://en.wikipedia.org/wiki/OPML) | |
| entries = dom.getElementsByTagName("outline") | |
| # Loop through outlines in the lists | |
| for entry in entries: | |
| # Only look at outlines that are of type=audio and item=station | |
| if (entry.getAttribute("type") == "audio") and (entry.getAttribute("item") == "station"): | |
| if (entry.getAttribute("key") != "unavailable"): | |
| # stop the current stream if we have one running | |
| if (self.audio_state == "playing"): | |
| self.stop() | |
| # Ignore entries that are marked as unavailable | |
| self.mpeg_url = entry.getAttribute("URL") | |
| self.station_name = entry.getAttribute("text") | |
| # this URL will return audio/x-mpegurl data. This is just a list of URLs to the real streams | |
| self.stream_url = self.get_stream_url(self.mpeg_url) | |
| self.audio_state = "playing" | |
| self.speak_dialog("now.playing", {"station": self.station_name} ) | |
| wait_while_speaking() | |
| LOG.debug("Found stream URL: " + self.stream_url) | |
| tracklist.append(self.stream_url) | |
| self.mediaplayer.add_list(tracklist) | |
| self.mediaplayer.play() | |
| return | |
| # We didn't find any playable stations | |
| self.speak_dialog("not.found") | |
| wait_while_speaking() | |
| LOG.debug("Could not find a station with the query term: " + search_term) | |
| def get_stream_url(self, mpegurl): | |
| res = requests.get(mpegurl) | |
| # Get the first line from the results | |
| for line in res.text.splitlines(): | |
| return self.process_url(line) | |
| def stop(self): | |
| if self.audio_state == "playing": | |
| self.mediaplayer.stop() | |
| self.mediaplayer.clear_list() | |
| LOG.debug("Stopping stream") | |
| self.audio_state = "stopped" | |
| self.station_name = None | |
| self.stream_url = None | |
| self.mpeg_url = None | |
| return True | |
| def shutdown(self): | |
| if self.audio_state == 'playing': | |
| self.mediaplayer.stop() | |
| self.mediaplayer.clear_list() | |
| # Check what kind of url was pulled from the x-mpegurl data | |
| def process_url(self, url): | |
| if (len(url) > 4): | |
| if url[-3:] == 'm3u': | |
| return url[:-4] | |
| if url[-3:] == 'pls': | |
| return self.process_pls(url) | |
| else: | |
| return url | |
| return url | |
| # Pull down the pls data and pull out the real stream url out of it | |
| def process_pls(self, url): | |
| res = requests.get(url) | |
| # Loop through the data looking for the first url | |
| for line in res.text.splitlines(): | |
| if line.startswith("File1="): | |
| return line[6:] | |
| # Get the correct localized regex | |
| def translate_regex(self, regex): | |
| if regex not in self.regexes: | |
| path = self.find_resource(regex + '.regex') | |
| if path: | |
| with open(path) as f: | |
| string = f.read().strip() | |
| self.regexes[regex] = string | |
| return self.regexes[regex] | |
| def create_skill(): | |
| return TuneinSkill() |