diff --git a/examples/cps_tracker.py b/examples/cps_tracker.py new file mode 100644 index 0000000..bfdddcb --- /dev/null +++ b/examples/cps_tracker.py @@ -0,0 +1,7 @@ +from ovos_utils import wait_for_exit_signal +from ovos_utils.playback.cps import CPSTracker + + +g = CPSTracker() + +wait_for_exit_signal() diff --git a/examples/pt_iptv.py b/examples/pt_iptv.py new file mode 100644 index 0000000..8a666d9 --- /dev/null +++ b/examples/pt_iptv.py @@ -0,0 +1,366 @@ +from pprint import pprint +from ovos_utils.playback.utils import M3UParser +from ovos_utils.parse import match_all, MatchStrategy +from ovos_utils.playback.ciptv import CommonIPTV + + +iptv = CommonIPTV() +# iptv.setDaemon(True) + +music = "https://raw.githubusercontent.com/Free-IPTV/Countries/master/YZ001_MUSIC.m3u" +usa = "https://raw.githubusercontent.com/Free-IPTV/Countries/master/US01_USA.m3u" +uk = "https://raw.githubusercontent.com/Free-IPTV/Countries/master/UK01_UNITED_KINGDOM.m3u" +pt = "https://raw.githubusercontent.com/Free-IPTV/Countries/master/PT01_PORTUGAL.m3u" +es = "https://raw.githubusercontent.com/Free-IPTV/Countries/master/ES01_SPAIN.m3u" +br = "https://raw.githubusercontent.com/Free-IPTV/Countries/master/BR01_BRAZIL.m3u" + +# iptv.import_m3u8(music, tags=["Music"]) +# iptv.import_m3u8(usa, tags=["USA", "English", "America"]) +# iptv.import_m3u8(uk, tags=["UK", "English", "England", "British"]) +iptv.import_m3u8(pt, tags=["Portugal", "Portuguese"]) +iptv.import_m3u8(es, tags=["Spain", "Spanish"]) +iptv.import_m3u8(br, tags=["Brazil", "Brazilian"]) + + +def import_m3upt(): + url = "https://m3upt.com/iptv" + + tv = M3UParser.get_group_titles(["TV"], url) + + pt_br_tvgs = ['CNN Brasil', 'TV Câmara', 'Futura [Brazil]', + "AgroBrasil TV", "SBT [Brazil]", "TV Rio Preto SJRP", + "Retrô Cartoon"] + pt_mz_tvgs = ["TVM", "TVM Internacional"] + pt_cn_tvgs = ["Teledifusão de Macau (PT)"] + cn_tvgs = ["Teledifusão de Macau (CN)"] + pt_pt_channels = ['RTP 1', 'RTP 2', 'SIC', 'TVI', 'RTP 3', + 'SIC Notícias', 'TVI 24', 'ARTV', 'RTP Memória', + 'RTP Açores', 'RTP Madeira', 'RTP Internacional', + 'RTP África', 'Porto Canal', 'TVI Internacional', + 'SIC Internacional', 'Euronews (PT)'] + es_tvgs = ["TeleSUR", "TVE 24H", "LA 1", "LA 2", + "Canal Extremadura", "RT Español", "CGTN Español", + "DW Espanol"] + eus_tvgs = ["ETB 1", "ETB 2", "EITB.EUS"] + gl_tvgs = ["TV Galícia"] + it_tvgs = ["Rai News 24"] + en_tvgs = ["Red Bull TV", "RUSSIA TODAY (RT)", "RT America", + "RT UK", "RT Documentary", "CGTN", "CGTN Documentary", + "France 24 English", "Al Jazeera English", "Sky News", + "NHK WORLD JAPAN HD", "DW English"] + fr_tvgs = ["TV5 Monde", "RT France", "CGTN Français", + "France 24 Français"] + de_tvgs = ["DW Deutsch", "Deutsche Welle Deutsch+"] + music_tvgs = ["1HD Music", "NRJ HITS", "NRG 91 TV", "Deejay TV", + "DELUXE MUSIC TV", "FM Italia TV", + "California Music Channel", "Country Music Channel", + "Sexy KPOP TV"] + fashion_tvgs = ["FTV"] + documentary_tvgs = ["CGTN Documentary", "RT Documentary"] + news_tvgs = ["RUSSIA TODAY (RT)", "RT America", "RT UK", "CGTN", + "Sky News", + "NHK WORLD JAPAN HD", "France 24 English", + "Al Jazeera English", "DW English", "DW Deutsch", + "Deutsche Welle Deutsch+", "RT Español", "CGTN Español", + "DW Espanol", 'SIC Notícias', + 'Euronews (PT)'] + fr_tvgs + it_tvgs + cartoon_tvgs = ["Retrô Cartoon"] + + def add_entry(entry): + entry["identifier"] = entry.get("identifier") or entry.get("title") + if not entry["identifier"]: + return + if entry["title"] in news_tvgs or entry["identifier"] in news_tvgs: + entry["tags"].append("News") + if entry["title"] in documentary_tvgs or entry["identifier"] in \ + documentary_tvgs: + entry["tags"].append("Documentary") + if entry["title"] in cartoon_tvgs or entry["identifier"] in \ + cartoon_tvgs: + entry["tags"].append("Cartoon") + entry["tags"].append("Kids") + entry["tags"].append("Animation") + entry["skill_id"] = "skill-m3upt.jarbasskills" + iptv.add_channel(entry) + + for r in tv: + for t in [pt_pt_channels]: + if r["title"] in t or r["tvg-id"] in t: + entry = { + "title": r["title"], + "duration": r["duration"], + "category": r.get('group-title'), + "logo": r.get('tvg-logo'), + "stream": r.get("stream"), + "identifier": r.get("tvg-id"), + "tags": ["TV", "IPTV", "Portuguese", "Portugal", + "pt-pt"], + "secondary_langs": ["pt"], + "lang": "pt-pt" + } + add_entry(entry) + for t in [pt_br_tvgs]: + if r["title"] in t or r["tvg-id"] in t: + entry = { + "title": r["title"], + "duration": r["duration"], + "category": r.get('group-title'), + "logo": r.get('tvg-logo'), + "stream": r.get("stream"), + "identifier": r.get("tvg-id"), + "tags": ["TV", "IPTV", "Brazilian Portuguese", + "Brazil", + "pt-br", "Portuguese Ex Colonies"], + "secondary_langs": ["pt"], + "lang": "pt-br" + } + add_entry(entry) + for t in [pt_mz_tvgs]: + if r["title"] in t or r["tvg-id"] in t: + entry = { + "title": r["title"], + "duration": r["duration"], + "category": r.get('group-title'), + "logo": r.get('tvg-logo'), + "stream": r.get("stream"), + "identifier": r.get("tvg-id"), + "tags": ["TV", "IPTV", "Mozambique", + "Portuguese Ex Colonies"], + "secondary_langs": ["pt"], + "lang": "pt-mz" # TODO is there a real lang-code? + } + add_entry(entry) + for t in [pt_cn_tvgs]: + if r["title"] in t or r["tvg-id"] in t: + entry = { + "title": r["title"], + "duration": r["duration"], + "category": r.get('group-title'), + "logo": r.get('tvg-logo'), + "stream": r.get("stream"), + "identifier": r.get("tvg-id"), + "tags": ["TV", "IPTV", "Macau", "China", "Chinese", + "Portuguese Ex Colonies"], + "secondary_langs": ["pt", "zh"], + "lang": "pt-zh" # TODO is there a real lang-code? + } + add_entry(entry) + for t in [cn_tvgs]: + if r["title"] in t or r["tvg-id"] in t: + entry = { + "title": r["title"], + "duration": r["duration"], + "category": r.get('group-title'), + "logo": r.get('tvg-logo'), + "stream": r.get("stream"), + "identifier": r.get("tvg-id"), + "tags": ["TV", "IPTV", "Macau", "China", "Chinese", + "Portuguese Ex Colonies"], + "secondary_langs": ["pt"], + "lang": "zh" # TODO is there a real lang-code? + } + add_entry(entry) + for t in [es_tvgs]: + if r["title"] in t or r["tvg-id"] in t: + entry = { + "title": r["title"], + "duration": r["duration"], + "category": r.get('group-title'), + "logo": r.get('tvg-logo'), + "stream": r.get("stream"), + "identifier": r.get("tvg-id"), + "tags": ["TV", "IPTV", "Spain", "Spanish"], + "secondary_langs": ["es"], + "lang": "es-es" + } + add_entry(entry) + for t in [eus_tvgs]: + if r["title"] in t or r["tvg-id"] in t: + entry = { + "title": r["title"], + "duration": r["duration"], + "category": r.get('group-title'), + "logo": r.get('tvg-logo'), + "stream": r.get("stream"), + "identifier": r.get("tvg-id"), + "tags": ["TV", "IPTV", "Spain", "Spanish", "euskara", + "euskera", "Basque"], + "secondary_langs": ["es", "es-es", "es-eus", "eus-es"], + "lang": "eus" + } + add_entry(entry) + for t in [gl_tvgs]: + if r["title"] in t or r["tvg-id"] in t: + entry = { + "title": r["title"], + "duration": r["duration"], + "category": r.get('group-title'), + "logo": r.get('tvg-logo'), + "stream": r.get("stream"), + "identifier": r.get("tvg-id"), + "tags": ["TV", "IPTV", "Spain", "Spanish", "Galicia", + "Galician"], + "secondary_langs": ["es", "es-es", "es-gl", "gl-es", + "pt"], + "lang": "gl" + } + add_entry(entry) + for t in [en_tvgs]: + if r["title"] in t or r["tvg-id"] in t: + entry = { + "title": r["title"], + "duration": r["duration"], + "category": r.get('group-title'), + "logo": r.get('tvg-logo'), + "stream": r.get("stream"), + "identifier": r.get("tvg-id"), + "tags": ["TV", "IPTV", "English"], + "secondary_langs": [], + "lang": "en" + } + add_entry(entry) + for t in [it_tvgs]: + if r["title"] in t or r["tvg-id"] in t: + entry = { + "title": r["title"], + "duration": r["duration"], + "category": r.get('group-title'), + "logo": r.get('tvg-logo'), + "stream": r.get("stream"), + "identifier": r.get("tvg-id"), + "tags": ["TV", "IPTV", "Italy", "Italian"], + "secondary_langs": ["it"], + "lang": "it-it" + } + add_entry(entry) + for t in [de_tvgs]: + if r["title"] in t or r["tvg-id"] in t: + entry = { + "title": r["title"], + "duration": r["duration"], + "category": r.get('group-title'), + "logo": r.get('tvg-logo'), + "stream": r.get("stream"), + "identifier": r.get("tvg-id"), + "tags": ["TV", "IPTV", "German", "Germany", + "Deutsche"], + "secondary_langs": ["de"], + "lang": "de-de" + } + add_entry(entry) + for t in [fr_tvgs]: + if r["title"] in t or r["tvg-id"] in t: + entry = { + "title": r["title"], + "duration": r["duration"], + "category": r.get('group-title'), + "logo": r.get('tvg-logo'), + "stream": r.get("stream"), + "identifier": r.get("tvg-id"), + "tags": ["TV", "IPTV", "French", "France"], + "secondary_langs": ["fr"], + "lang": "fr-fr" + } + add_entry(entry) + for t in [music_tvgs]: + if r["title"] in t or r["tvg-id"] in t: + entry = { + "title": r["title"], + "duration": r["duration"], + "category": r.get('group-title'), + "logo": r.get('tvg-logo'), + "stream": r.get("stream"), + "identifier": r.get("tvg-id"), + "tags": ["TV", "IPTV", "Music", "Music Channel"] + } + add_entry(entry) + for t in [fashion_tvgs]: + if r["title"] in t or r["tvg-id"] in t: + entry = { + "title": r["title"], + "duration": r["duration"], + "category": r.get('group-title'), + "logo": r.get('tvg-logo'), + "stream": r.get("stream"), + "identifier": r.get("tvg-id"), + "tags": ["TV", "IPTV", "Fashion"] + } + add_entry(entry) + + +def import_ptiptv(): + url = "https://ptiptv.tk/lista.m3u8" + pt_tvgs = ['SIC', 'TVI', 'SICN', 'TVI24', 'ARTV', 'TVI Reality', + 'PORTO', 'TVI Internacional', 'Zig Zag', + '#EstudoEmCasa'] + fashion_tvgs = ['Fashion TV Paris', 'FTV', + 'Fashion TV Midnight Secrets'] + es_tvgs = ['TVE24HD', 'TVE 🇪🇸', 'TVGAL'] + fr_tvgs = ['TV5', 'FR24F'] + it_tvgs = ['RAINEWS'] + en_tvgs = ['BLOOM', 'SKYN', 'FR24I', 'DWTVHD', 'ALJAZ', 'RUSSTHD', + 'NHKHD', 'CGTN', 'CGTNDHD'] + de_tvgs = ['DWTVA'] + + def add_entry(entry): + entry["identifier"] = entry.get("identifier") or \ + entry.get("tvg-id") or \ + entry.get("title") + if not entry["identifier"]: + return + entry["skill_id"] = "skill-ptiptv.jarbasskills" + iptv.add_channel(entry) + + for r in M3UParser.parse_m3u8(url): + if r.get("radio"): + continue + lang = None + if r["title"] in pt_tvgs or r["tvg-id"] in pt_tvgs: + lang = "pt-pt" + if r["title"] in es_tvgs or r["tvg-id"] in es_tvgs: + lang = "es" + if r["title"] in en_tvgs or r["tvg-id"] in en_tvgs: + lang = "en" + if r["title"] in it_tvgs or r["tvg-id"] in it_tvgs: + lang = "it" + if r["title"] in fr_tvgs or r["tvg-id"] in fr_tvgs: + lang = "fr" + if r["title"] in de_tvgs or r["tvg-id"] in de_tvgs: + lang = "de" + entry = { + "title": r["title"], + "duration": r["duration"], + "category": r.get('group-title'), + "logo": r.get('tvg-logo'), + "stream": r.get("stream"), + "identifier": r.get("tvg-id"), + "lang": lang, + "tags": ["TV", "IPTV"] + [r['group-title']] + } + add_entry(entry) + + +import_ptiptv() +import_m3upt() +iptv.import_m3u8(pt, tags=["Portugal", "Portuguese"]) +iptv.import_m3u8(es, tags=["Spain", "Spanish"]) +iptv.import_m3u8(br, tags=["Brazil", "Brazilian"]) + +iptv.find_duplicate_streams() + +dups = iptv.get_duplicated_channels() +pprint(dups) + +exit() +iptv.start() + +exit() +# time.sleep(30) +# iptv.stop() + +results = iptv.get_channels() + +results = iptv.search("music") + +import_m3upt() +results = iptv.filter_by_language("pt") diff --git a/ovos_utils/gui.py b/ovos_utils/gui.py index c5211d4..0c44c70 100644 --- a/ovos_utils/gui.py +++ b/ovos_utils/gui.py @@ -1,8 +1,11 @@ from ovos_utils.system import is_installed, has_screen +from ovos_utils import resolve_ovos_resource_file, resolve_resource_file from ovos_utils.messagebus import wait_for_reply, get_mycroft_bus, Message from ovos_utils.log import LOG from collections import namedtuple import time +from os.path import join +from enum import IntEnum def can_display(): @@ -24,6 +27,13 @@ def is_gui_connected(bus=None): return False +class GUIPlaybackStatus(IntEnum): + STOPPED = 0 + PLAYING = 1 + PAUSED = 2 + UNDEFINED = 3 + + class GUITracker: """ Replicates GUI API from mycroft-core, does not interact with GUI but exactly mimics status""" @@ -268,7 +278,8 @@ def _show(self, namespace, page, index): self.__move_namespace(index, 0) # Find if any new pages needs to be inserted - new_pages = [p for p in pages if p not in self._loaded[0].pages] + new_pages = [p for p in pages if + p not in self._loaded[0].pages] if new_pages: self.__insert_pages(namespace, new_pages) except Exception as e: @@ -369,8 +380,447 @@ def _on_show_idle(self, message): self._is_idle = True +class GUIInterface: + """Interface to the Graphical User Interface, allows interaction with + the mycroft-gui from anywhere + + Values set in this class are synced to the GUI, accessible within QML + via the built-in sessionData mechanism. For example, in Python you can + write in a skill: + self.gui['temp'] = 33 + self.gui.show_page('Weather.qml') + Then in the Weather.qml you'd access the temp via code such as: + text: sessionData.time + """ + + def __init__(self, skill_id, bus=None, remote_server=None): + self.bus = bus or get_mycroft_bus() + self.__session_data = {} # synced to GUI for use by this skill's pages + self.page = None # the active GUI page (e.g. QML template) to show + self.skill_id = skill_id + self.on_gui_changed_callback = None + self.remote_url = remote_server + self._events = [] + self.video_info = None + self.setup_default_handlers() + + @property + def connected(self): + """Returns True if at least 1 gui is connected, else False""" + return is_gui_connected(self.bus) + + # events + def setup_default_handlers(self): + """Sets the handlers for the default messages.""" + self.register_handler('set', self.handle_gui_set) + # should be emitted by self.play_video + self.register_handler('playback.ended', + self.handle_gui_stop) + + def register_handler(self, event, handler): + """Register a handler for GUI events. + + will be prepended with self.skill_id.XXX if missing in event + + When using the triggerEvent method from Qt + triggerEvent("event", {"data": "cool"}) + + Arguments: + event (str): event to catch + handler: function to handle the event + """ + if not event.startswith(f'{self.skill_id}.'): + event = f'{self.skill_id}.' + event + self._events.append((event, handler)) + self.bus.on(event, handler) + + def set_on_gui_changed(self, callback): + """Registers a callback function to run when a value is + changed from the GUI. + + Arguments: + callback: Function to call when a value is changed + """ + self.on_gui_changed_callback = callback + + def send_event(self, event_name, params=None): + """Trigger a gui event. + + Arguments: + event_name (str): name of event to be triggered + params: json serializable object containing any parameters that + should be sent along with the request. + """ + params = params or {} + self.bus.emit(Message("gui.event.send", + {"__from": self.skill_id, + "event_name": event_name, + "params": params})) + + # internals + def handle_gui_stop(self, message): + """Stop video playback in gui""" + self.stop_video() + + def handle_gui_set(self, message): + """Handler catching variable changes from the GUI. + + Arguments: + message: Messagebus message + """ + for key in message.data: + self[key] = message.data[key] + if self.on_gui_changed_callback: + self.on_gui_changed_callback() + + def __setitem__(self, key, value): + """Implements set part of dict-like behaviour with named keys.""" + self.__session_data[key] = value + + if self.page: + # emit notification (but not needed if page has not been shown yet) + data = self.__session_data.copy() + data.update({'__from': self.skill_id}) + self.bus.emit(Message("gui.value.set", data)) + + def __getitem__(self, key): + """Implements get part of dict-like behaviour with named keys.""" + return self.__session_data[key] + + def get(self, *args, **kwargs): + """Implements the get method for accessing dict keys.""" + return self.__session_data.get(*args, **kwargs) + + def __contains__(self, key): + """Implements the "in" operation.""" + return self.__session_data.__contains__(key) + + def _pages2uri(self, page_names): + # Convert pages to full reference + page_urls = [] + for name in page_names: + page = resolve_resource_file(name) or \ + resolve_resource_file(join('ui', name)) or \ + resolve_ovos_resource_file(name) or \ + resolve_ovos_resource_file(join('ui', name)) + + if page: + if self.remote_url: + page_urls.append(self.remote_url + "/" + page) + elif page.startswith("file://"): + page_urls.append(page) + else: + page_urls.append("file://" + page) + else: + LOG.error("Unable to find page: {}".format(name)) + return page_urls + + def shutdown(self): + """Shutdown gui interface. + + Clear pages loaded through this interface and remove the bus events + """ + self.release() + self.video_info = None + for event, handler in self._events: + self.bus.remove(event, handler) + + # base gui interactions + def show_page(self, name, override_idle=None, + override_animations=False): + """Begin showing the page in the GUI + + Arguments: + name (str): Name of page (e.g "mypage.qml") to display + override_idle (boolean, int): + True: Takes over the resting page indefinitely + (int): Delays resting page for the specified number of + seconds. + override_animations (boolean): + True: Disables showing all platform skill animations. + False: 'Default' always show animations. + """ + self.show_pages([name], 0, override_idle, override_animations) + + def show_pages(self, page_names, index=0, override_idle=None, + override_animations=False): + """Begin showing the list of pages in the GUI. + + Arguments: + page_names (list): List of page names (str) to display, such as + ["Weather.qml", "Forecast.qml", "Details.qml"] + index (int): Page number (0-based) to show initially. For the + above list a value of 1 would start on "Forecast.qml" + override_idle (boolean, int): + True: Takes over the resting page indefinitely + (int): Delays resting page for the specified number of + seconds. + override_animations (boolean): + True: Disables showing all platform skill animations. + False: 'Default' always show animations. + """ + if not isinstance(page_names, list): + raise ValueError('page_names must be a list') + + if index > len(page_names): + raise ValueError('Default index is larger than page list length') + + self.page = page_names[index] + + # First sync any data... + data = self.__session_data.copy() + data.update({'__from': self.skill_id}) + self.bus.emit(Message("gui.value.set", data)) + page_urls = self._pages2uri(page_names) + self.bus.emit(Message("gui.page.show", + {"page": page_urls, + "index": index, + "__from": self.skill_id, + "__idle": override_idle, + "__animations": override_animations})) + + def remove_page(self, page): + """Remove a single page from the GUI. + + Arguments: + page (str): Page to remove from the GUI + """ + return self.remove_pages([page]) + + def remove_pages(self, page_names): + """Remove a list of pages in the GUI. + + Arguments: + page_names (list): List of page names (str) to display, such as + ["Weather.qml", "Forecast.qml", "Other.qml"] + """ + if not isinstance(page_names, list): + page_names = [page_names] + page_urls = self._pages2uri(page_names) + self.bus.emit(Message("gui.page.delete", + {"page": page_urls, + "__from": self.skill_id})) + + def clear(self): + """Reset the value dictionary, and remove namespace from GUI. + + This method does not close the GUI for a Skill. For this purpose see + the `release` method. + """ + self.__session_data = {} + self.page = None + self.bus.emit(Message("gui.clear.namespace", + {"__from": self.skill_id})) + + def release(self): + """Signal that this skill is no longer using the GUI, + allow different platforms to properly handle this event. + Also calls self.clear() to reset the state variables + Platforms can close the window or go back to previous page""" + self.clear() + self.bus.emit(Message("mycroft.gui.screen.close", + {"skill_id": self.skill_id})) + + # Utils / Templates + def show_text(self, text, title=None, override_idle=None, + override_animations=False): + """Display a GUI page for viewing simple text. + + Arguments: + text (str): Main text content. It will auto-paginate + title (str): A title to display above the text content. + override_idle (boolean, int): + True: Takes over the resting page indefinitely + (int): Delays resting page for the specified number of + seconds. + override_animations (boolean): + True: Disables showing all platform skill animations. + False: 'Default' always show animations. + """ + self["text"] = text + self["title"] = title + self.show_page("SYSTEM_TextFrame.qml", override_idle, + override_animations) + + def show_image(self, url, caption=None, + title=None, fill=None, + override_idle=None, override_animations=False): + """Display a GUI page for viewing an image. + + Arguments: + url (str): Pointer to the image + caption (str): A caption to show under the image + title (str): A title to display above the image content + fill (str): Fill type supports 'PreserveAspectFit', + 'PreserveAspectCrop', 'Stretch' + override_idle (boolean, int): + True: Takes over the resting page indefinitely + (int): Delays resting page for the specified number of + seconds. + override_animations (boolean): + True: Disables showing all platform skill animations. + False: 'Default' always show animations. + """ + self["image"] = url + self["title"] = title + self["caption"] = caption + self["fill"] = fill + self.show_page("SYSTEM_ImageFrame.qml", override_idle, + override_animations) + + def show_animated_image(self, url, caption=None, + title=None, fill=None, + override_idle=None, override_animations=False): + """Display a GUI page for viewing an image. + + Arguments: + url (str): Pointer to the .gif image + caption (str): A caption to show under the image + title (str): A title to display above the image content + fill (str): Fill type supports 'PreserveAspectFit', + 'PreserveAspectCrop', 'Stretch' + override_idle (boolean, int): + True: Takes over the resting page indefinitely + (int): Delays resting page for the specified number of + seconds. + override_animations (boolean): + True: Disables showing all platform skill animations. + False: 'Default' always show animations. + """ + self["image"] = url + self["title"] = title + self["caption"] = caption + self["fill"] = fill + self.show_page("SYSTEM_AnimatedImageFrame.qml", override_idle, + override_animations) + + def show_html(self, html, resource_url=None, override_idle=None, + override_animations=False): + """Display an HTML page in the GUI. + + Arguments: + html (str): HTML text to display + resource_url (str): Pointer to HTML resources + override_idle (boolean, int): + True: Takes over the resting page indefinitely + (int): Delays resting page for the specified number of + seconds. + override_animations (boolean): + True: Disables showing all platform skill animations. + False: 'Default' always show animations. + """ + self["html"] = html + self["resourceLocation"] = resource_url + self.show_page("SYSTEM_HtmlFrame.qml", override_idle, + override_animations) + + def show_url(self, url, override_idle=None, + override_animations=False): + """Display an HTML page in the GUI. + + Arguments: + url (str): URL to render + override_idle (boolean, int): + True: Takes over the resting page indefinitely + (int): Delays resting page for the specified number of + seconds. + override_animations (boolean): + True: Disables showing all platform skill animations. + False: 'Default' always show animations. + """ + self["url"] = url + self.show_page("SYSTEM_UrlFrame.qml", override_idle, + override_animations) + + def show_confirmation_status(self, text="", override_idle=False, + override_animations=False): + # NOT YET PRed to mycroft-core, taken from gez-mycroft wifi GUI test skill + self.clear() + self["icon"] = resolve_ovos_resource_file("ui/icons/check-circle.svg") + self["label"] = text + self["bgColor"] = "#40DBB0" + self.show_page("SYSTEM_status.qml", override_idle=override_idle, + override_animations=override_animations) + + def show_error_status(self, text="", override_idle=False, + override_animations=False): + # NOT YET PRed to mycroft-core, taken from gez-mycroft wifi GUI test skill + self.clear() + self["icon"] = resolve_ovos_resource_file("ui/icons/times-circle.svg") + self["label"] = text + self["bgColor"] = "#FF0000" + self.show_page("SYSTEM_status.qml", override_idle=override_idle, + override_animations=override_animations) + + # Media playback interactions + def play_video(self, url, title="", repeat=None, override_idle=True, + override_animations=True): + """ Play video stream + + Arguments: + url (str): URL of video source + title (str): Title of media to be displayed + repeat (boolean, int): + True: Infinitly loops the current video track + (int): Loops the video track for specified number of + times. + override_idle (boolean, int): + True: Takes over the resting page indefinitely + (int): Delays resting page for the specified number of + seconds. + override_animations (boolean): + True: Disables showing all platform skill animations. + False: 'Default' always show animations. + """ + self["playStatus"] = "play" + self["video"] = url + self["title"] = title + self["playerRepeat"] = repeat + self.video_info = {"title": title, "url": url} + self.show_page("SYSTEM_VideoPlayer.qml", + override_idle=override_idle, + override_animations=override_animations) + + @property + def is_video_displayed(self): + """Returns whether the gui is in a video playback state. + Eg if the video is paused, it would still be displayed on screen + but the video itself is not "playing" so to speak""" + return self.video_info is not None + + @property + def playback_status(self): + """Returns gui playback status, + indicates if gui is playing, paused or stopped""" + if self.__session_data.get("playStatus", -1) == "play": + return GUIPlaybackStatus.PLAYING + if self.__session_data.get("playStatus", -1) == "pause": + return GUIPlaybackStatus.PAUSED + if self.__session_data.get("playStatus", -1) == "stop": + return GUIPlaybackStatus.STOPPED + return GUIPlaybackStatus.UNDEFINED + + def pause_video(self): + """Pause video playback.""" + if self.is_video_displayed: + self["playStatus"] = "pause" + + def stop_video(self): + """Stop video playback.""" + if self.is_video_displayed: + self["playStatus"] = "stop" + self.release() + self.video_info = None + + def resume_video(self): + """Resume paused video playback.""" + if self.__session_data.get("playStatus", "stop") == "pause": + self["playStatus"] = "play" + + if __name__ == "__main__": from ovos_utils import wait_for_exit_signal + LOG.set_level("DEBUG") g = GUITracker() - wait_for_exit_signal() \ No newline at end of file + wait_for_exit_signal() diff --git a/ovos_utils/playback/__init__.py b/ovos_utils/playback/__init__.py new file mode 100644 index 0000000..86e1627 --- /dev/null +++ b/ovos_utils/playback/__init__.py @@ -0,0 +1,5 @@ +from ovos_utils.playback.cps import CPSPlayback, CPSMatchConfidence, \ + CPSTrackStatus, CPSMatchType, CommonPlayInterface, BetterCommonPlayInterface +from ovos_utils.playback.youtube import get_youtube_metadata, \ + get_youtube_video_stream, get_youtube_audio_stream, is_youtube +from ovos_utils.playback.utils import get_duration_from_url diff --git a/ovos_utils/playback/ciptv.py b/ovos_utils/playback/ciptv.py new file mode 100644 index 0000000..f2271e8 --- /dev/null +++ b/ovos_utils/playback/ciptv.py @@ -0,0 +1,379 @@ +from ovos_utils.messagebus import get_mycroft_bus, Message +from ovos_utils.json_helper import merge_dict, is_compatible_dict +from ovos_utils.playback.utils import check_stream, StreamStatus +from ovos_utils.parse import match_all, MatchStrategy, match_one, fuzzy_match +from ovos_utils.log import LOG +from ovos_utils.playback.utils import M3UParser +import time +from threading import Thread, Event + + +class CommonIPTV(Thread): + stream_providers = {} + channels = {} + dead_channels = {} + remove_threshold = 2 # stream dead N checks in a row is removed + time_between_updates = 30 # minutes between re-checking stream status of + # expired {TTL} channels + max_checks = 15 # max number of streams to check every {time_between_updates} + _duplicates = {} # url:[idx] uniquely identifying duplicate channels + _duplicate_candidates = {} # url:[idx] uniquely identifying possible + # duplicates (fuzzy tvg-id matching) + duplicate_threshold = 0.95 # if fuzzy_match confidence is above, + # merge channels + bus = None # mycroft bus connection + + def __init__(self, bus=None, *args, **kwargs): + if bus: + self.bind(bus) + self.stop_event = Event() + self._last_check = time.time() + super(CommonIPTV, self).__init__(*args, **kwargs) + + @classmethod + def bind(cls, bus=None): + cls.bus = bus or get_mycroft_bus() + + # low level actions + @classmethod + def add_stream_provider(cls, stream_provider): + if isinstance(stream_provider, str): + stream_provider = {"url": stream_provider} + + url = stream_provider["url"] + ttl = stream_provider.get("ttl") or 6 * 60 * 60 + stream_provider["expires"] = time.time() + ttl + cls.stream_providers[url] = stream_provider + cls.import_m3u8(url, stream_provider.get("tags")) + + @classmethod + def delete_stream_provider(cls, stream_provider): + if isinstance(stream_provider, dict): + url = stream_provider["url"] + else: + url = stream_provider + if url in cls.stream_providers: + cls.stream_providers.pop(url) + + @classmethod + def add_channel(cls, channel): + url = channel.get("stream") + channel_id = cls.channel2id(channel) + + if url in [ch.get("stream") for idx, ch in cls.dead_channels.items()]: + LOG.error("Channel has been previously flagged DEAD, refused to " + "add channel") + LOG.debug(str(channel)) + return + + for idx, ch in cls.channels.items(): + ch_url = ch["stream"] + if url != ch_url: + continue + LOG.debug(f"Stream previously added: {url}") + if is_compatible_dict(ch, channel): + LOG.debug(f"merging channel data {channel_id}:{idx}") + cls.channels[idx] = cls.create_merged_channel(ch, channel) + return + + else: + if channel_id in cls.channels: + LOG.error(f"channel data doesn't " + f"match, {channel_id} already in database") + LOG.warning("refused to merge, replacing channel") + + LOG.info(f"Adding channel: {channel_id}") + channel["expires"] = 0 + channel["status"] = StreamStatus.UNKNOWN + channel["_dead_counter"] = 0 + cls.channels[channel_id] = channel + + @classmethod + def delete_channel(cls, key): + if key in cls.channels: + cls.channels.pop(key) + return + for idx, ch in cls.channels.items(): + if ch.get("id") == key: + cls.channels.pop(key) + return + elif ch.get("identifier") == key: + cls.channels.pop(key) + return + elif ch.get("uri") == key: + cls.channels.pop(key) + return + elif ch.get("stream") == key: + cls.channels.pop(key) + return + elif ch.get("url") == key: + cls.channels.pop(key) + return + elif ch.get("title") == key: + cls.channels.pop(key) + return + + @classmethod + def import_m3u8(cls, url, tags=None): + tags = tags or [] + + LOG.info(f"Importing m3u8: {url}") + tv = M3UParser.parse_m3u8(url) + for r in tv: + default_tags = ["TV", "IPTV"] + if r.get("group-title"): + default_tags.append(r['group-title']) + if r.get("stream"): + entry = { + "title": r["title"], + "duration": r["duration"], + "category": r.get('group-title'), + "logo": r.get('tvg-logo'), + "stream": r.get("stream"), + "identifier": r.get("tvg-id"), + "tags": tags + default_tags, + "country": r.get("tvg-country"), + "lang": r.get("tvg-language") + } + if r.get("group-title"): + entry["tags"].append(r["group-title"]) + if r.get("tvg-country"): + entry["tags"].append(r["tvg-country"]) + if r.get("tvg-language"): + entry["tags"].append(r["tvg-language"]) + cls.add_channel(entry) + + @classmethod + def get_channel_status(cls, channel): + if isinstance(channel, str): + channel = cls.find_channel(channel) + stream = channel.get("stream") + if not stream: + if channel.get("stream_callback"): + data = cls.bus.wait_response(Message(channel["stream_callback"])) + # callback mode to ask stream provider (skill) for actual url + # - we have a message type in payload instead of stream + # - we send that bus message and wait reply with actual stream + # - allows searching without extracting (slow), eg, youtube + if data and data.get("stream"): + return check_stream(data["stream"], timeout=5) + raise KeyError("channel has no associated stream") + return check_stream(stream, timeout=5) + + @classmethod + def find_channel(cls, key): + if key in cls.channels: + return cls.channels[key] + for idx, ch in cls.channels.items(): + if ch.get("id") == key: + return ch + elif ch.get("identifier") == key: + return ch + elif ch.get("uri") == key: + return ch + elif ch.get("stream") == key: + return ch + elif ch.get("url") == key: + return ch + elif ch.get("title") == key: + return ch + + @staticmethod + def channel2id(channel): + return channel.get("identifier") or channel.get("id") or \ + channel.get("tvg-id") or channel.get("title") + + # automated actions + @classmethod + def prune_dead_streams(cls, ttl=60): + """ remove dead streams from channel list + set stream status as OK for ttl minutes""" + for idx, ch in dict(cls.channels).items(): + if cls.channels[idx]["status"] != StreamStatus.OK: + cls.channels[idx]["status"] = cls.get_channel_status(ch) + cls.channels[idx]["expires"] = time.time() + ttl * 60 + if cls.channels[idx]["status"] == StreamStatus.OK: + cls.channels[idx]["_dead_counter"] = 0 + else: + cls.channels[idx]["_dead_counter"] += 1 + if cls.channels[idx]["_dead_counter"] >= \ + cls.remove_threshold: + LOG.info(f"Removing dead stream: {idx}") + cls.dead_channels[idx] = ch + cls.delete_channel(idx) + + @classmethod + def update_stream_status(cls, ttl=120): + # order channels by expiration date + channels = sorted([(idx, ch) + for idx, ch in dict(cls.channels).items()], + key=lambda k: k[1]["expires"]) + # update N channels status + for idx, ch in channels[:cls.max_checks]: + if cls.channels[idx]["expires"] - time.time() < 0: + cls.channels[idx]["status"] = cls.get_channel_status(ch) + cls.channels[idx]["expires"] = time.time() + ttl * 60 + LOG.info(f'{idx} stream status: {cls.channels[idx]["status"]}') + + @classmethod + def update_streams(cls): + for url, provider in cls.stream_providers.items(): + if provider["expires"] - time.time() < 0: + # will add new channels and ignore duplicated/known dead ones + # NOTE: dead channels are flagged in a different stage + cls.add_stream_provider(provider) + + @classmethod + def find_duplicate_streams(cls): + """ detect streams that are duplicated by several skills """ + for prev_idx, prev_ch in cls.channels.items(): + prev_url = prev_ch.get("stream") + for idx, ch in dict(cls.channels).items(): + if idx == prev_idx: + continue + url = ch.get("stream") + + if url == prev_url and False: + score = 1.0 + else: + score = fuzzy_match(ch["title"].lower(), + prev_ch["title"].lower()) + + if score >= cls.duplicate_threshold: + if idx not in cls._duplicates: + LOG.info(f"Duplicate channel: {prev_idx}:{idx} - " + f"confidence: {score}") + cls._duplicates[idx] = [prev_idx] + elif prev_idx not in cls._duplicates[idx]: + cls._duplicates[idx].append(prev_idx) + + @classmethod + def merge_duplicate_channels(cls): + for idx, chs in cls._duplicates.items(): + ch = cls.channels.get(idx) + if not ch: + continue + for idx2 in chs: + if idx2 == idx: + continue + ch2 = cls.channels.get(idx2) + if not ch2: + continue + + merged_ch = cls.create_merged_channel(ch, ch2) + if merged_ch: + LOG.debug(f"merging channel data {idx}:{idx2}") + cls.delete_channel(idx) + cls.delete_channel(idx2) + cls.add_channel(merged_ch) + + @staticmethod + def create_merged_channel(base, delta, precedence="longest"): + if is_compatible_dict(base, delta): + if precedence == "base": + return merge_dict(base, delta, merge_lists=True, + new_only=True, no_dupes=True, skip_empty=True) + elif precedence == "delta": + return merge_dict(base, delta, merge_lists=True, + no_dupes=True, skip_empty=True) + elif precedence == "longest": + for key in [k for k in delta if k in base]: + if isinstance(base[key], str) and isinstance(delta[key], str): + if len(base[key]) > len(delta[key]): + delta[key] = base[key] + if delta.get("skill_id") and base.get("skill_id") \ + and base.get("skill_id") not in delta.get("skill_id"): + delta["skill_id"] = delta["skill_id"] + "/" + base["skill_id"] + return merge_dict(base, delta, merge_lists=True, + no_dupes=True, skip_empty=True) + + else: + return None + + # iptv functionality + def get_channels(self, filter_dead=False): + if filter_dead: + return [ch for idx, ch in self.channels.items() + if ch["status"] == StreamStatus.OK] + return [ch for idx, ch in self.channels.items()] + + def search(self, query, lang=None, + strategy=MatchStrategy.TOKEN_SORT_RATIO, filter_dead=False, + minconf=50): + query = query.lower().strip() + if lang: + channels = self.filter_by_language(lang) + else: + channels = self.get_channels() + matches = [] + for ch in channels: + # score name match + names = ch.get("aliases") or [] + names.append(ch.get("title") or ch["identifier"]) + names = [_.lower().strip() for _ in names] + best_name, name_score = match_one(query, names, strategy=strategy) + name_score = name_score * 50 + if query in best_name: + name_score += 30 + + # score tag matches + tags = ch.get("tags", []) + tags = [_.lower().strip() for _ in tags] + tag_scores = match_all(query, tags, strategy=strategy)[:5] + tag_score = sum([0.5 * t[1] for t in tag_scores]) / len( + tag_scores) * 100 + if query in tags: + tag_score += 30 + + score = min(tag_score + name_score, 100) + if score >= minconf: + if not filter_dead: + matches.append((ch, score)) + elif ch["status"] == StreamStatus.OK: + matches.append((ch, score)) + + return sorted(matches, key=lambda k: k[1], reverse=True) + + def filter_by_language(self, lang): + return [c for c in self.get_channels() if + c.get("lang") == lang or + c.get("lang", "").split("-")[0] == lang or + lang in c.get("secondary_langs", [])] + + # event loop + def run(self) -> None: + self.stop_event.clear() + while not self.stop_event.is_set(): + # take note of duplicated channels + self.find_duplicate_streams() + # merge entries + self.merge_duplicate_channels() + # remove any dead streams + self.prune_dead_streams() + # verify streams + if time.time() - self._last_check > self.time_between_updates * 60: + # update new streams + self.update_streams() + # confirm working status of existing streams + self.update_stream_status() + self._last_check = time.time() + + def stop(self): + self.stop_event.set() + + # bus api + def handle_register_m3u(self, message): + url = message.data["url"] + ttl = message.data.get("ttl") or 24 * 60 * 60 + self.add_stream_provider({"url": url, "ttl": ttl}) + + def handle_register_channel(self, message): + self.add_channel(message.data) + + def handle_deregister_m3u(self, message): + url = message.data["url"] + self.delete_stream_provider(url) + + def handle_deregister_channel(self, message): + ch = self.channel2id(message.data) + self.delete_channel(ch) diff --git a/ovos_utils/playback/cps.py b/ovos_utils/playback/cps.py new file mode 100644 index 0000000..e44d534 --- /dev/null +++ b/ovos_utils/playback/cps.py @@ -0,0 +1,757 @@ +from ovos_utils.messagebus import Message, get_mycroft_bus, wait_for_reply +from ovos_utils.skills.audioservice import AudioServiceInterface +from ovos_utils.gui import GUIInterface +from ovos_utils.log import LOG +from ovos_utils.playback.youtube import is_youtube, \ + get_youtube_audio_stream, get_youtube_video_stream +from enum import IntEnum +import time +import random + + +class CPSPlayback(IntEnum): + SKILL = 0 + GUI = 1 + AUDIO = 2 + + +class CPSMatchConfidence(IntEnum): + EXACT = 95 + VERY_HIGH = 90 + HIGH = 80 + AVERAGE_HIGH = 70 + AVERAGE = 50 + AVERAGE_LOW = 30 + LOW = 15 + VERY_LOW = 1 + + +class CPSTrackStatus(IntEnum): + DISAMBIGUATION = 1 # not queued for playback, show in gui + PLAYING = 20 # Skill is handling playback internally + PLAYING_AUDIOSERVICE = 21 # Skill forwarded playback to audio service + PLAYING_GUI = 22 # Skill forwarded playback to gui + PLAYING_ENCLOSURE = 23 # Skill forwarded playback to enclosure + QUEUED = 30 # Waiting playback to be handled inside skill + QUEUED_AUDIOSERVICE = 31 # Waiting playback in audio service + QUEUED_GUI = 32 # Waiting playback in gui + QUEUED_ENCLOSURE = 33 # Waiting for playback in enclosure + PAUSED = 40 # media paused but ready to resume + STALLED = 60 # playback has stalled, reason may be unknown + BUFFERING = 61 # media is buffering from an external source + END_OF_MEDIA = 90 # playback finished, is the default state when CPS loads + + +class CPSMatchType(IntEnum): + GENERIC = 0 + AUDIO = 1 + MUSIC = 2 + VIDEO = 3 + AUDIOBOOK = 4 + GAME = 5 + PODCAST = 6 + RADIO = 7 + NEWS = 8 + TV = 9 + MOVIE = 10 + TRAILER = 11 + ADULT = 12 + VISUAL_STORY = 13 + BEHIND_THE_SCENES = 14 + DOCUMENTARY = 15 + RADIO_THEATRE = 16 + SHORT_FILM = 17 + SILENT_MOVIE = 18 + BLACK_WHITE_MOVIE = 20 + + +class CommonPlayInterface: + """ interface for mycroft common play """ + + def __init__(self, bus=None): + self.bus = bus or get_mycroft_bus() + self.bus.on("play:query.response", self.handle_cps_response) + self.query_replies = {} + self.query_extensions = {} + self.waiting = False + self.start_ts = 0 + + @property + def cps_status(self): + return wait_for_reply('play:status.query', + reply_type="play:status.response", + bus=self.bus).data + + def handle_cps_response(self, message): + search_phrase = message.data["phrase"] + + if ("searching" in message.data and + search_phrase in self.query_extensions): + # Manage requests for time to complete searches + skill_id = message.data["skill_id"] + if message.data["searching"]: + # extend the timeout by N seconds + # IGNORED HERE, used in mycroft-playback-control skill + if skill_id not in self.query_extensions[search_phrase]: + self.query_extensions[search_phrase].append(skill_id) + else: + # Search complete, don't wait on this skill any longer + if skill_id in self.query_extensions[search_phrase]: + self.query_extensions[search_phrase].remove(skill_id) + + elif search_phrase in self.query_replies: + # Collect all replies until the timeout + self.query_replies[message.data["phrase"]].append(message.data) + + def send_query(self, phrase, media_type=CPSMatchType.GENERIC): + self.query_replies[phrase] = [] + self.query_extensions[phrase] = [] + self.bus.emit(Message('play:query', {"phrase": phrase, + "media_type": media_type})) + + def get_results(self, phrase): + if self.query_replies.get(phrase): + return self.query_replies[phrase] + return [] + + def search(self, phrase, media_type=CPSMatchType.GENERIC, timeout=5): + self.send_query(phrase, media_type) + self.waiting = True + start_ts = time.time() + while self.waiting and time.time() - start_ts <= timeout: + time.sleep(0.2) + self.waiting = False + res = self.get_results(phrase) + if res: + return res + if media_type != CPSMatchType.GENERIC: + return self.search(phrase, media_type=CPSMatchType.GENERIC, + timeout=timeout) + return [] + + def search_best(self, phrase, media_type=CPSMatchType.GENERIC, timeout=5): + # check responses + # Look at any replies that arrived before the timeout + # Find response(s) with the highest confidence + best = None + ties = [] + for handler in self.search(phrase, media_type, timeout): + if not best or handler["conf"] > best["conf"]: + best = handler + ties = [] + elif handler["conf"] == best["conf"]: + ties.append(handler) + + if best: + if ties: + # select randomly + skills = ties + [best] + selected = random.choice(skills) + # TODO: Ask user to pick between ties or do it + # automagically + else: + selected = best + + # will_resume = self.playback_status == CPSTrackStatus.PAUSED \ + # and not bool(phrase.strip()) + will_resume = False + return {"skill_id": selected["skill_id"], + "phrase": phrase, + "media_type": media_type, + "trigger_stop": not will_resume, + "callback_data": selected.get("callback_data")} + + return {} + + +class BetterCommonPlayInterface: + """ interface for better common play """ + + def __init__(self, bus=None, min_timeout=1, max_timeout=5, + allow_extensions=True, audio_service=None, gui=None, + backwards_compatibility=True, media_fallback=True): + """ + Arguments: + bus (MessageBus): mycroft messagebus connection + min_timeout (float): minimum time to wait for skill replies, + after this time, if at least 1 result was + found, selection is triggered + max_timeout (float): maximum time to wait for skill replies, + after this time, regardless of number of + results, selection is triggered + allow_extensions (bool): if True, allow skills to request more + time, extend min_timeout for specific + queries up to max_timeout + backwards_compatibility (bool): if True emits the regular + mycroft-core bus messages to get + results from "old style" skills + media_fallback (bool): if no results, perform a second query + with CPSMatchType.GENERIC + """ + self.bus = bus or get_mycroft_bus() + self.audio_service = audio_service or AudioServiceInterface(self.bus) + self.gui = gui or GUIInterface("better-cps", bus=self.bus) + + self.min_timeout = min_timeout + self.max_timeout = max_timeout + self.allow_extensions = allow_extensions + self.media_fallback = media_fallback + if backwards_compatibility: + self.old_cps = CommonPlayInterface(self.bus) + else: + self.old_cps = None + + self.query_replies = {} + self.query_timeouts = {} + self.waiting = False + self.search_start = 0 + self._search_results = [] + + self.playback_status = CPSTrackStatus.END_OF_MEDIA + self.active_backend = None # re-uses CPSTrackStatus.PLAYING_XXX + self.active_skill = None # skill_id currently handling playback + + self.playback_data = {"playing": None, + "playlist": [], + "disambiguation": []} + + self.bus.on("better_cps.query.response", self.handle_cps_response) + self.bus.on("better_cps.status.update", self.handle_cps_status_change) + self.register_gui_handlers() + + def shutdown(self): + self.bus.remove("better_cps.query.response", self.handle_cps_response) + self.bus.remove("better_cps.status.update", + self.handle_cps_status_change) + self.gui.shutdown() + + def handle_cps_response(self, message): + search_phrase = message.data["phrase"] + timeout = message.data.get("timeout") + LOG.debug(f"BetterCPS received results: {message.data['skill_id']}") + + if message.data.get("searching"): + # extend the timeout by N seconds + if timeout and self.allow_extensions and \ + search_phrase in self.query_timeouts: + self.query_timeouts[search_phrase] += timeout + # else -> expired search + + elif search_phrase in self.query_replies: + # Collect replies until the timeout + self.query_replies[search_phrase].append(message.data) + + # abort waiting if we gathered enough results + if time.time() - self.search_start > self.query_timeouts[ + search_phrase]: + self.waiting = False + + def search(self, phrase, media_type=CPSMatchType.GENERIC): + self.query_replies[phrase] = [] + self.query_timeouts[phrase] = self.min_timeout + self.search_start = time.time() + self.waiting = True + self.bus.emit(Message('better_cps.query', + {"phrase": phrase, + "media_type": media_type})) + + # old common play will send the messages expected by the official + # mycroft stack, but skills are know to over match, dont support + # match type, and the GUI is different for every skill, it may also + # cause issues with status tracking and mess up playlists + if self.old_cps: + self.old_cps.send_query(phrase, media_type) + + # if there is no match type defined, lets increase timeout a bit + # since all skills need to search + if media_type == CPSMatchType.GENERIC: + bonus = 3 # timeout bonus + else: + bonus = 0 + + while self.waiting and \ + time.time() - self.search_start <= self.max_timeout + bonus: + time.sleep(0.1) + + self.waiting = False + + # convert the returned data to the expected new format, playback + # type is consider Skill, better cps will not handle the playback + # life cycle but instead delegate to the skill + if self.old_cps: + old_style = self.old_cps.get_results(phrase) + self.query_replies[phrase] += self._convert_to_new_style(old_style, + media_type) + + if self.query_replies.get(phrase): + return [s for s in self.query_replies[phrase] if s.get("results")] + + # fallback to generic media type + if self.media_fallback and media_type != CPSMatchType.GENERIC: + LOG.debug("BetterCPS falling back to CPSMatchType.GENERIC") + return self.search(phrase, media_type=CPSMatchType.GENERIC) + return [] + + def search_skill(self, skill_id, phrase, media_type=CPSMatchType.GENERIC): + res = [r for r in self.search(phrase, media_type) + if r["skill_id"] == skill_id] + if not len(res): + return None + return res[0] + + def process_search(self, selected, results): + # TODO playlist + self._update_current_media(selected) + self._update_disambiguation(results) + self._set_search_results(results, best=selected) + self._set_now_playing(selected) + self.play() + + @staticmethod + def _convert_to_new_style(results, media_type=CPSMatchType.GENERIC): + new_style = [] + for res in results: + data = res['callback_data'] + data["skill_id"] = res["skill_id"] + data["phrase"] = res["phrase"] + data["is_old_style"] = True # internal flag for playback handling + data['match_confidence'] = res["conf"] * 100 + data["uri"] = data.get("stream") or \ + data.get("url") or \ + data.get("uri") + + # Essentially a random guess.... + data["media_type"] = media_type + data["playback"] = CPSPlayback.SKILL + if not data.get("image"): + data["image"] = data.get("logo") or \ + data.get("picture") + if not data.get("bg_image"): + data["bg_image"] = data.get("background") or \ + data.get("bg_picture") or \ + data.get("logo") or \ + data.get("picture") + + new_style.append({'phrase': res["phrase"], + "is_old_style": True, + 'results': [data], + 'searching': False, + 'skill_id': res["skill_id"]}) + return new_style + + # status tracking + def _update_current_media(self, data): + """ Currently playing media """ + self.playback_data["playing"] = data + + def _update_playlist(self, data): + """ List of queued media """ + self.playback_data["playlist"].append(data) + # sort playlist by requested order + self.playback_data["playlist"] = sorted( + self.playback_data["playlist"], + key=lambda i: int(i['playlist_position']) or 0) + + def _update_disambiguation(self, data): + """ List of unused search results """ + self.playback_data["disambiguation"].append(data) + + def handle_cps_status_change(self, message): + # message.data contains the media entry from search results and in + # addition a "status" for that entry, this can be used to control + # the playlist or simply communicate changes from the "playback + # backend" + status = message.data["status"] + + if status == CPSTrackStatus.PLAYING: + # skill is handling playback internally + self._update_current_media(message.data) + self.playback_status = status + self.active_backend = status + elif status == CPSTrackStatus.PLAYING_AUDIOSERVICE: + # audio service is handling playback + self._update_current_media(message.data) + self.playback_status = status + self.active_backend = status + elif status == CPSTrackStatus.PLAYING_GUI: + # gui is handling playback + self._update_current_media(message.data) + self.playback_status = status + self.active_backend = status + + elif status == CPSTrackStatus.DISAMBIGUATION: + # alternative results + self._update_disambiguation(message.data) + elif status == CPSTrackStatus.QUEUED: + # skill is handling playback and this is in playlist + self._update_playlist(message.data) + elif status == CPSTrackStatus.QUEUED_GUI: + # gui is handling playback and this is in playlist + self._update_playlist(message.data) + elif status == CPSTrackStatus.QUEUED_AUDIOSERVICE: + # audio service is handling playback and this is in playlist + self._update_playlist(message.data) + + elif status == CPSTrackStatus.PAUSED: + # media is not being played, but can be resumed anytime + # a new PLAYING status should be sent once playback resumes + self.playback_status = status + elif status == CPSTrackStatus.BUFFERING: + # media is buffering, might want to show in ui + # a new PLAYING status should be sent once playback resumes + self.playback_status = status + elif status == CPSTrackStatus.STALLED: + # media is stalled, might want to show in ui + # a new PLAYING status should be sent once playback resumes + self.playback_status = status + elif status == CPSTrackStatus.END_OF_MEDIA: + # if we add a repeat/loop flag this is the place to check for it + self.playback_status = status + + def update_status(self, status): + self.bus.emit(Message('better_cps.status.update', status)) + + # playback control + def play(self): + + data = self.playback_data.get("playing") or {} + uri = data.get("stream") or data.get("uri") or data.get("url") + skill_id = self.active_skill = data["skill_id"] + + self.stop() + + if data["playback"] == CPSPlayback.AUDIO: + data["status"] = CPSTrackStatus.PLAYING_AUDIOSERVICE + real_url = self.get_stream(uri) + self.audio_service.play(real_url) + + elif data["playback"] == CPSPlayback.SKILL: + data["status"] = CPSTrackStatus.PLAYING + if data.get("is_old_style"): + self.bus.emit(Message('play:start', + {"skill_id": skill_id, + "callback_data": data, + "phrase": data["phrase"]})) + else: + self.bus.emit(Message(f'better_cps.{skill_id}.play', data)) + elif data["playback"] == CPSPlayback.GUI: + pass # plays in display_ui + else: + raise ValueError("invalid playback request") + self.update_status(data) + self._set_now_playing(data) + self.display_ui() + self.update_player_status("Playing") + + @staticmethod + def get_stream(uri, video=False): + real_url = None + if is_youtube(uri): + if not video: + real_url = get_youtube_audio_stream(uri) + if video or not real_url: + real_url = get_youtube_video_stream(uri) + return real_url or uri + + def play_next(self): + # TODO playlist handling + if self.active_backend == CPSTrackStatus.PLAYING_GUI: + pass + elif self.active_backend == CPSTrackStatus.PLAYING_AUDIOSERVICE: + self.audio_service.next() + elif self.active_backend is not None: + self.bus.emit(Message(f'better_cps.{self.active_skill}.next')) + + def play_prev(self): + # TODO playlist handling + if self.active_backend == CPSTrackStatus.PLAYING_GUI: + pass + elif self.active_backend == CPSTrackStatus.PLAYING_AUDIOSERVICE: + self.audio_service.prev() + elif self.active_backend is not None: + self.bus.emit(Message(f'better_cps.{self.active_skill}.prev')) + + def pause(self): + self.update_status({"status": CPSTrackStatus.PAUSED}) + if self.active_backend == CPSTrackStatus.PLAYING_GUI: + self.gui.pause_video() + elif self.active_backend == CPSTrackStatus.PLAYING_AUDIOSERVICE: + self.audio_service.pause() + elif self.active_backend is not None: + self.bus.emit(Message(f'better_cps.{self.active_skill}.pause')) + + def resume(self): + if self.active_backend == CPSTrackStatus.PLAYING_GUI: + self.gui.resume_video() + elif self.active_backend == CPSTrackStatus.PLAYING_AUDIOSERVICE: + self.audio_service.resume() + elif self.active_backend is not None: + self.bus.emit(Message(f'better_cps.{self.active_skill}.resume')) + self.update_status({"status": self.active_backend}) + + def stop(self): + if self.active_backend == CPSTrackStatus.PLAYING_GUI: + self.gui.stop_video() + elif self.active_backend == CPSTrackStatus.PLAYING_AUDIOSERVICE: + self.audio_service.stop() + elif self.active_backend is not None: + self.bus.emit(Message(f'better_cps.{self.active_skill}.stop')) + self.update_status({"status": CPSTrackStatus.END_OF_MEDIA}) + stopped = self.active_backend is not None + self.active_backend = None + self.active_skill = None + return stopped + + # ######### GUI integration ############### + def register_gui_handlers(self): + self.gui.register_handler('better-cps.gui.play', + self.handle_click_resume) + self.gui.register_handler('better-cps.gui.pause', + self.handle_click_pause) + self.gui.register_handler('better-cps.gui.next', + self.handle_click_next) + self.gui.register_handler('better-cps.gui.previous', + self.handle_click_previous) + self.gui.register_handler('better-cps.gui.seek', + self.handle_click_seek) + + self.gui.register_handler('better-cps.gui.playlist.play', + self.handle_play_from_playlist) + self.gui.register_handler('better-cps.gui.search.play', + self.handle_play_from_search) + + def update_player_status(self, status, page=0): + self.gui["media"]["status"] = status + self.display_ui(page=page) + + def display_ui(self, search=None, media=None, playlist=None, page=0): + search_qml = "Disambiguation.qml" + player_qml = "AudioPlayer.qml" + video_player_qml = "VideoPlayer.qml" + playlist_qml = "Playlist.qml" + + media = media or self.gui.get("media") or {} + media["status"] = media.get("status", "Paused") + media["position"] = media.get("position", 0) + media["length"] = media.get("length") or -1 + search = search or self.gui.get("searchModel", {}).get("data") or {} + playlist = playlist or self.gui.get("playlistModel", {}).get("data") or {} + + # remove previous pages + pages = [player_qml, search_qml, playlist_qml, video_player_qml] + self.gui.remove_pages(pages) + + # display "now playing" video page + if media.get("playback", -1) == CPSPlayback.GUI: + uri = media.get("stream") or \ + media.get("url") or \ + media.get("uri") + self.gui["stream"] = self.get_stream(uri, video=True) + self.gui["title"] = media.get("title", "") + self.gui["playStatus"] = "play" + pages = [video_player_qml, search_qml, playlist_qml] + + # display "now playing" music page + else: + pages = [player_qml, search_qml, playlist_qml] + + self.gui["searchModel"] = {"data": search} + self.gui["playlistModel"] = {"data": playlist} + self.gui.show_pages(pages, page, override_idle=True) + + def _set_search_results(self, results, best=None): + best = best or results[0] + for idx, data in enumerate(results): + results[idx]["length"] = data.get("length") or \ + data.get("track_length") or \ + data.get("duration") + self._search_results = results + # send all results for disambiguation + # this can be used in GUI or any other use facing interface to + # override the final selection + for r in self._search_results: + status = dict(r) + status["status"] = CPSTrackStatus.DISAMBIGUATION + self.bus.emit(Message('better_cps.status.update', status)) + results = sorted(results, key=lambda k: k.get("match_confidence"), + reverse=True)[:100] + results = self._res2playlist(results) + playlist = self._res2playlist([best]) # TODO cps playlist + self.display_ui(media=best, playlist=playlist, search=results) + + @staticmethod + def _res2playlist(res): + playlist_data = [] + for r in res: + playlist_data.append({ + "album": r.get('skill_id'), + "duration": r.get('length'), + "image": r.get('image'), + "source": r.get('skill_icon') or r.get('skill_logo'), + "track": r.get("title") + }) + return playlist_data + + def _set_now_playing(self, data): + if data.get("bg_image", "").startswith("/"): + data["bg_image"] = "file:/" + data["bg_image"] + data["skill"] = data.get("skill_id", "better-cps") + data["position"] = data.get("position", 0) + + data["length"] = data.get("length") or data.get("track_length") or \ + data.get("duration") # or get_duration_from_url(url) + + self.gui["media"] = data + self.gui["bg_image"] = data.get("bg_image", + "https://source.unsplash.com/weekly?music") + + # gui events + def handle_click_pause(self, message): + self.audio_service.pause() + self.update_player_status("Paused") + + def handle_click_resume(self, message): + self.audio_service.resume() + self.update_player_status("Playing") + + def handle_click_next(self, message): + pass + + def handle_click_previous(self, message): + pass + + def handle_click_seek(self, message): + position = message.data.get("seekValue", "") + print("seek:", position) + if position: + self.audio_service.set_track_position(position / 1000) + self.gui["media"]["position"] = position + self.display_ui() + + def handle_play_from_playlist(self, message): + playlist_data = message.data["playlistData"] + self.__play(playlist_data) + + def handle_play_from_search(self, message): + res = self._res2playlist(self._search_results) + playlist_data = message.data["playlistData"] + idx = res.index(playlist_data) + self.__play(self._search_results[idx]) + + def __play(self, media): + playlist = self._res2playlist([media]) # TODO cps playlist + self.gui["playlistModel"] = {"data": playlist} + self._update_current_media(media) + self.play() + + +class CPSTracker: + def __init__(self, bus=None, gui=None): + self.bus = bus or get_mycroft_bus() + self.bus.on("better_cps.query.response", self.handle_cps_response) + self.bus.on("better_cps.status.update", self.handle_cps_status_change) + + self.gui = gui or GUIInterface("better-cps", bus=self.bus) + self.register_gui_handlers() + + def register_gui_handlers(self): + self.gui.register_handler('better-cps.gui.play', + self.handle_click_resume) + self.gui.register_handler('better-cps.gui.pause', + self.handle_click_pause) + self.gui.register_handler('better-cps.gui.next', + self.handle_click_next) + self.gui.register_handler('better-cps.gui.previous', + self.handle_click_previous) + self.gui.register_handler('better-cps.gui.seek', + self.handle_click_seek) + + self.gui.register_handler('better-cps.gui.playlist.play', + self.handle_play_from_playlist) + self.gui.register_handler('better-cps.gui.search.play', + self.handle_play_from_search) + + def shutdown(self): + self.bus.remove("better_cps.query.response", self.handle_cps_response) + self.bus.remove("better_cps.status.update", + self.handle_cps_status_change) + self.gui.shutdown() + + def handle_cps_response(self, message): + search_phrase = message.data["phrase"] + skill = message.data['skill_id'] + timeout = message.data.get("timeout") + + if message.data.get("searching"): + if timeout: + self.on_extend_timeout(search_phrase, skill, timeout) + else: + self.on_skill_results(search_phrase, skill, message.data) + + def handle_cps_status_change(self, message): + status = message.data["status"] + print("New status:", status) + + def handle_click_resume(self, message): + print(message.data) + + def handle_click_pause(self, message): + print(message.data) + + def handle_click_next(self, message): + print(message.data) + + def handle_click_previous(self, message): + print(message.data) + + def handle_click_seek(self, message): + print(message.data) + + def handle_play_from_playlist(self, message): + print(message.data) + + def handle_play_from_search(self, message): + print(message.data) + + # users can subclass these + def on_query(self, message): + pass + + def on_skill_results(self, phrase, skill_id, results): + pass + + def on_query_response(self, message): + pass + + def on_status_change(self, message): + pass + + def on_extend_timeout(self, phrase, timeout, skill_id): + print("extending timeout:", timeout, "\n", + "phrase:", phrase, "\n", + "skill:", skill_id, "\n") + + def on_skill_play(self, message): + pass + + def on_audio_play(self, message): + pass + + def on_gui_play(self, message): + pass + + +if __name__ == "__main__": + from pprint import pprint + + cps = BetterCommonPlayInterface(max_timeout=10, min_timeout=2) + + # test lovecraft skills + pprint(cps.search_skill("skill-omeleto", "movie", CPSMatchType.SHORT_FILM)) + + exit() + pprint(cps.search("the thing in the doorstep")) + + pprint(cps.search("dagon", CPSMatchType.VIDEO)) + + pprint(cps.search("dagon hp lovecraft")) diff --git a/ovos_utils/playback/utils.py b/ovos_utils/playback/utils.py new file mode 100644 index 0000000..6fe5f12 --- /dev/null +++ b/ovos_utils/playback/utils.py @@ -0,0 +1,136 @@ +from ovos_utils.playback.youtube import get_youtube_metadata, is_youtube +import requests +from os.path import join +from tempfile import gettempdir +from enum import IntEnum + + +class StreamStatus(IntEnum): + OK = 200 + DEAD = 404 + FORBIDDEN = 401 + ERROR = 500 + UNKNOWN = 0 + + +def check_stream(url, timeout=3): + # verify is url is dead or alive + try: + s = requests.get(url, timeout=timeout).status_code + if s == 200: + return StreamStatus.OK + if s == 404: + return StreamStatus.DEAD + elif str(s).startswith("4"): + return StreamStatus.FORBIDDEN + except Exception as e: + # error, usually a 500 + return StreamStatus.ERROR + return StreamStatus.UNKNOWN + + +class M3UParser: + @staticmethod + def parse_extinf(header): + # remove double spaces + header = " ".join([w for w in header.split(" ") if w]) + values, name = header.replace("#EXTINF:", "").split(",") + values = values.split("=") + _ = values[0].split(" ") + if len(_) == 1: + duration = _[0] + else: + duration, k = _ + data = {"title": name, "duration": int(duration)} + for d in values[1:]: + val = " ".join(d.split(" ")[:-1]) + if val: + data[k] = val.rstrip('"').lstrip('"') + k = d.split(" ")[-1] + else: + data[k] = d.rstrip('"').lstrip('"') + data["tvg-id"] = data.get("tvg-id") or name + return data + + @staticmethod + def parse_m3u8(m3): + if m3.startswith("http"): + content = requests.get(m3).content + m3 = join(gettempdir(), f"{str(hash(m3))[1:]}_pyvod.m3u8") + with open(m3, "wb") as f: + f.write(content) + with open(m3) as f: + m3ustr = f.read().split("\n") + m3ustr = [l for l in m3ustr if l.strip()] + + streamz = [] + m3ustr = [ l for l in m3ustr if + l.startswith("#EXTINF:") or l.startswith("http")] + for idx, line in enumerate(m3ustr): + next_line = m3ustr[idx + 1] if idx + 1 < len(m3ustr) else None + if line.startswith("#EXTINF:"): + data = M3UParser.parse_extinf(line) + if next_line.startswith("http"): + data["stream"] = next_line + streamz.append(data) + + return streamz + + @staticmethod + def get_group_titles(titles, m3): + if isinstance(titles, str): + titles = [titles] + titles = [t.lower().strip() for t in titles] + entries = M3UParser.parse_m3u8(m3) + return [v for v in entries + if v.get("group-title", "").lower().strip() in titles] + + @staticmethod + def get_tvg_id(tvgs, m3): + if isinstance(tvgs, str): + tvgs = [tvgs] + tvgs = [t.lower().strip() for t in tvgs] + entries = M3UParser.parse_m3u8(m3) + return [v for v in entries if + v.get("tvg-id", "").lower().strip() in tvgs] + + @staticmethod + def get_titles(titles, m3): + if isinstance(titles, str): + titles = [titles] + titles = [t.lower().strip() for t in titles] + entries = M3UParser.parse_m3u8(m3) + return [v for v in entries if v.get("title", "").lower() in titles] + + @staticmethod + def get_channel(queries, m3): + if isinstance(queries, str): + queries = [queries] + queries = [t.lower().strip() for t in queries] + entries = M3UParser.parse_m3u8(m3) + return [v for v in entries + if v.get("tvg-id", "").lower() in queries or + v.get("title", "").lower() in queries] + + +def get_duration_from_url(url): + """ return stream duration in milliseconds """ + if not url: + return 0 + if is_youtube(url): + data = get_youtube_metadata(url) + dur = data.get("length", 0) + else: + headers = requests.head(url).headers + # print(headers) + # dur = int(headers.get("Content-Length", 0)) + dur = 0 + return dur + + +def get_title_from_url(url): + """ return stream duration in milliseconds """ + if url and is_youtube(url): + data = get_youtube_metadata(url) + return data.get("title") + return url diff --git a/ovos_utils/playback/youtube.py b/ovos_utils/playback/youtube.py new file mode 100644 index 0000000..028818d --- /dev/null +++ b/ovos_utils/playback/youtube.py @@ -0,0 +1,99 @@ +import subprocess +from os.path import exists, join +from tempfile import gettempdir +from ovos_utils.log import LOG + +try: + import pafy +except ImportError: + pafy = None + + +def get_youtube_audio_stream(url, download=False, convert=False): + if pafy is None: + LOG.error("can not extract audio stream, pafy is not available") + LOG.info("pip install youtube-dl") + LOG.info("pip install pafy") + return url + try: + stream = pafy.new(url) + except: + return None + stream = stream.getbestaudio() + if not stream: + return None + + if download: + path = join(gettempdir(), + url.split("watch?v=")[-1] + "." + stream.extension) + + if not exists(path): + stream.download(path) + + if convert: + mp3 = join(gettempdir(), url.split("watch?v=")[-1] + ".mp3") + if not exists(mp3): + # convert file to mp3 + command = ["ffmpeg", "-n", "-i", path, "-acodec", + "libmp3lame", "-ab", "128k", mp3] + subprocess.call(command) + return mp3 + + return path + + return stream.url + + +def get_youtube_video_stream(url, download=False): + if pafy is None: + LOG.error("can not extract audio stream, pafy is not available") + LOG.info("pip install youtube-dl") + LOG.info("pip install pafy") + return url + try: + stream = pafy.new(url) + except: + return None + stream = stream.getbest() + if not stream: + return None + + if download: + path = join(gettempdir(), + url.split("watch?v=")[-1] + "." + stream.extension) + if not exists(path): + stream.download(path) + return path + return stream.url + + +def is_youtube(url): + # TODO localization + if not url: + return False + return "youtube.com/" in url or "youtu.be/" in url + + +def get_youtube_metadata(url): + if pafy is None: + LOG.error("can not extract audio stream, pafy is not available") + LOG.info("pip install youtube-dl") + LOG.info("pip install pafy") + return url + try: + stream = pafy.new(url) + except: + return {} + return { + "url": url, + #"audio_stream": stream.getbestaudio().url, + #"stream": stream.getbest().url, + "title": stream.title, + "author": stream.author, + "image": stream.getbestthumb().split("?")[0], +# "description": stream.description, + "length": stream.length * 1000, + "category": stream.category, +# "upload_date": stream.published, +# "tags": stream.keywords + } diff --git a/ovos_utils/res/ui/AudioPlayer.qml b/ovos_utils/res/ui/AudioPlayer.qml new file mode 100644 index 0000000..93a31e6 --- /dev/null +++ b/ovos_utils/res/ui/AudioPlayer.qml @@ -0,0 +1,329 @@ +/* + * Copyright 2020 by Aditya Mehra + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +import QtQuick 2.12 +import QtMultimedia 5.12 +import QtQuick.Controls 2.12 as Controls +import QtQuick.Templates 2.12 as T +import QtQuick.Layouts 1.3 +import org.kde.kirigami 2.8 as Kirigami +import QtGraphicalEffects 1.0 +import Mycroft 1.0 as Mycroft + +Mycroft.Delegate { + id: root + skillBackgroundSource: media.bg_image + property alias thumbnail: albumimg.source + fillWidth: true + property int imageWidth: Kirigami.Units.gridUnit * 10 + skillBackgroundColorOverlay: Qt.rgba(0, 0, 0, 0.85) + property bool bigMode: width > 800 && height > 600 ? 1 : 0 + property bool horizontalMode: width >= height * 1.3 ? 1 : 0 + property bool isVertical: sessionData.isVertical + + // Assumption Track_Length is always in milliseconds + // Assumption current_Position is always in milleseconds and relative to track_length if track_length = 530000, position values range from 0 to 530000 + + property var media: sessionData.media + property var compareModel + property var playerDuration: media.length + property real playerPosition: 0 + property var playerState: media.status + property var nextAction: "gui.next" + property var previousAction: "gui.previous" + property bool countdowntimerpaused: false + + onIsVerticalChanged: { + if(isVertical){ + root.horizontalMode = false + } + } + + function formatedDuration(millis){ + var minutes = Math.floor(millis / 60000); + var seconds = ((millis % 60000) / 1000).toFixed(0); + return minutes + ":" + (seconds < 10 ? '0' : '') + seconds; + } + + Controls.ButtonGroup { + id: autoPlayRepeatGroup + buttons: autoPlayRepeatGroupLayout.children + } + + onPlayerStateChanged: { + console.log(playerState) + if(playerState === "Playing"){ + playerPosition = media.position + countdowntimer.running = true + } else if(playerState === "Paused") { + playerPosition = media.position + countdowntimer.running = false + } + } + + Timer { + id: countdowntimer + interval: 1000 + running: false + repeat: true + onTriggered: { + if(media.length > playerPosition){ + if(!countdowntimerpaused){ + playerPosition = playerPosition + 1000 + } + } + } + } + + ColumnLayout { + anchors.fill: parent + anchors.margins: bigMode ? parent.width * 0.025 : 0 + + Rectangle { + Layout.fillWidth: true + Layout.minimumHeight: songtitle.contentHeight + color: "transparent" + + Kirigami.Heading { + id: songtitle + text: media.title + level: 1 + maximumLineCount: 1 + width: parent.width + font.pixelSize: parent.width * 0.060 + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + font.capitalization: Font.Capitalize + font.bold: true + visible: true + enabled: true + } + } + + Rectangle { + Layout.fillWidth: true + Layout.fillHeight: true + color: "transparent" + + GridLayout { + id: mainLayout + anchors.fill: parent + columns: horizontalMode ? 2 : 1 + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: mainLayout.columns > 1 ? parent.height : parent.height / 1.5 + color: "transparent" + + Image { + id: albumimg + visible: true + enabled: true + width: parent.height * 0.9 + height: width + source: media.image + anchors.verticalCenter: parent.verticalCenter + anchors.horizontalCenter: parent.horizontalCenter + z: 100 + } + + + RectangularGlow { + id: effect + anchors.fill: albumimg + glowRadius: 5 + color: Qt.rgba(0, 0, 0, 0.7) + cornerRadius: 10 + } + } + + ColumnLayout { + Layout.fillWidth: true + Layout.fillHeight: true + + Rectangle { + Layout.fillWidth: true + Layout.fillHeight: true + color: "transparent" + + RowLayout { + anchors.fill: parent + anchors.margins: horizontalMode ? Kirigami.Units.largeSpacing : Kirigami.Units.largeSpacing * 2 + spacing: horizontalMode ? Kirigami.Units.largeSpacing * 3 : Kirigami.Units.largeSpacing * 5 + + Controls.Button { + id: previousButton + Layout.fillWidth: true + Layout.fillHeight: true + Layout.alignment: Qt.AlignVCenter + focus: false + KeyNavigation.right: playButton + KeyNavigation.down: seekableslider + onClicked: { + triggerGuiEvent(previousAction, {}) + } + + contentItem: Kirigami.Icon { + source: Qt.resolvedUrl("images/media-seek-backward.svg") + } + + background: Rectangle { + color: "transparent" + } + + Keys.onReturnPressed: { + clicked() + } + } + + Controls.Button { + id: playButton + Layout.fillWidth: true + Layout.fillHeight: true + Layout.alignment: Qt.AlignVCenter + onClicked: { + if (playerState != "Playing"){ + console.log("in resume action") + triggerGuiEvent("gui.play", {"media": { + "image": media.image, + "track": media.track, + "album": media.album, + "skill": media.skill, + "length": media.length, + "position": playerPosition, + "status": "Playing"}}) + } else { + triggerGuiEvent("gui.pause", {"media": { + "image": media.image, + "title": media.title, + "album": media.album, + "skill_id":media.skill, + "length": media.length, + "position": playerPosition, + "status": "Paused"}}) + } + } + + background: Rectangle { + color: "transparent" + } + + contentItem: Kirigami.Icon { + source: playerState === "Playing" ? Qt.resolvedUrl("images/media-playback-pause.svg") : Qt.resolvedUrl("images/media-playback-start.svg") + } + } + + Controls.Button { + id: nextButton + Layout.fillWidth: true + Layout.fillHeight: true + Layout.alignment: Qt.AlignVCenter + onClicked: { + triggerGuiEvent(nextAction, {}) + } + + background: Rectangle { + color: "transparent" + } + + contentItem: Kirigami.Icon { + source: Qt.resolvedUrl("images/media-seek-forward.svg") + } + } + } + } + } + } + } + + T.Slider { + id: seekableslider + to: playerDuration + Layout.fillWidth: true + Layout.minimumHeight: Kirigami.Units.gridUnit * 2 + Layout.leftMargin: Kirigami.Units.largeSpacing + Layout.rightMargin: Kirigami.Units.largeSpacing + Layout.bottomMargin: Kirigami.Units.largeSpacing + Layout.topMargin: Kirigami.Units.smallSpacing + property bool sync: false + live: false + visible: media.length !== -1 ? 1 : 0 + enabled: media.length !== -1 ? 1 : 0 + value: playerPosition + + onPressedChanged: { + if(seekableslider.pressed){ + root.countdowntimerpaused = true + } else ( + root.countdowntimerpaused = false + ) + } + + onValueChanged: { + if(root.countdowntimerpaused){ + triggerGuiEvent("gui.seek", {"seekValue": value}) + } + } + + handle: Item { + x: seekableslider.visualPosition * (parent.width - (Kirigami.Units.largeSpacing + Kirigami.Units.smallSpacing)) + anchors.verticalCenter: parent.verticalCenter + height: Kirigami.Units.iconSizes.large + + Rectangle { + id: hand + anchors.verticalCenter: parent.verticalCenter + implicitWidth: Kirigami.Units.iconSizes.small + Kirigami.Units.smallSpacing + implicitHeight: Kirigami.Units.iconSizes.small + Kirigami.Units.smallSpacing + radius: 100 + color: seekableslider.pressed ? "#f0f0f0" : "#f6f6f6" + border.color: "#bdbebf" + } + + Controls.Label { + anchors.bottom: parent.bottom + anchors.bottomMargin: -Kirigami.Units.smallSpacing + anchors.horizontalCenter: hand.horizontalCenter + //horizontalAlignment: Text.AlignHCenter + text: formatedDuration(playerPosition) + } + } + + background: Rectangle { + x: seekableslider.leftPadding + y: seekableslider.topPadding + seekableslider.availableHeight / 2 - height / 2 + implicitHeight: 10 + width: seekableslider.availableWidth + height: implicitHeight + Kirigami.Units.largeSpacing + radius: 10 + color: "#bdbebf" + + Rectangle { + width: seekableslider.visualPosition * parent.width + height: parent.height + gradient: Gradient { + orientation: Gradient.Horizontal + GradientStop { position: 0.0; color: "#21bea6" } + GradientStop { position: 1.0; color: "#2194be" } + } + radius: 9 + } + } + } + } +} \ No newline at end of file diff --git a/ovos_utils/res/ui/Disambiguation.qml b/ovos_utils/res/ui/Disambiguation.qml new file mode 100644 index 0000000..2b9f85b --- /dev/null +++ b/ovos_utils/res/ui/Disambiguation.qml @@ -0,0 +1,171 @@ +/* + * Copyright 2020 by Aditya Mehra + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +import QtQuick 2.9 +import QtQuick.Controls 2.3 as Controls +import QtQuick.Layouts 1.3 +import org.kde.kirigami 2.8 as Kirigami +import QtGraphicalEffects 1.0 +import Mycroft 1.0 as Mycroft + + +Mycroft.Delegate { + id: delegate + + property var playlistModel: sessionData.searchModel + property Component emptyHighlighter: Item{} + fillWidth: true + + skillBackgroundSource: sessionData.bg_image + + onPlaylistModelChanged: { + playlistListView.forceLayout() + } + + Keys.onBackPressed: { + parent.parent.parent.currentIndex-- + parent.parent.parent.currentItem.contentItem.forceActiveFocus() + } + + function formatedDuration(millis){ + var minutes = Math.floor(millis / 60000); + var seconds = ((millis % 60000) / 1000).toFixed(0); + return minutes + ":" + (seconds < 10 ? '0' : '') + seconds; + } + + ColumnLayout { + id: playlistPlayerColumn + anchors.fill: parent + spacing: Kirigami.Units.smallSpacing + + Kirigami.Heading { + id: watchItemList + text: "Search Results" + level: 2 + } + + Kirigami.Separator { + id: sept2 + Layout.fillWidth: true + Layout.preferredHeight: 1 + z: 100 + } + + ListView { + id: playlistListView + keyNavigationEnabled: true + model: playlistModel.data + focus: false + interactive: true + bottomMargin: delegate.controlBarItem.height + Kirigami.Units.largeSpacing + Layout.fillWidth: true + Layout.fillHeight: true + spacing: Kirigami.Units.largeSpacing + currentIndex: 0 + clip: true + highlightRangeMode: ListView.StrictlyEnforceRange + snapMode: ListView.SnapToItem + + delegate: Controls.ItemDelegate { + width: parent.width + height: Kirigami.Units.gridUnit * 5 + + background: Rectangle { + Kirigami.Theme.colorSet: Kirigami.Theme.Button + color: Qt.rgba(0.2, 0.2, 0.2, 1) + layer.enabled: true + layer.effect: DropShadow { + horizontalOffset: 1 + verticalOffset: 2 + } + } + + + contentItem: Item { + width: parent.width + height: parent.height + + RowLayout { + id: delegateItem + anchors.fill: parent + anchors.margins: Kirigami.Units.smallSpacing + spacing: Kirigami.Units.largeSpacing + + Image { + id: videoImage + source: modelData.image + Layout.preferredHeight: parent.height + Layout.preferredWidth: Kirigami.Units.gridUnit * 4 + Layout.alignment: Qt.AlignHCenter | Qt.AlignVCenter + fillMode: Image.Stretch + } + + ColumnLayout { + Layout.fillWidth: true + + Controls.Label { + id: videoLabel + Layout.fillWidth: true + text: modelData.track + wrapMode: Text.WordWrap + color: "white" + } + Controls.Label { + id: artistLabel + Layout.fillWidth: true + text: modelData.album + opacity: 0.8 + color: "white" + } + } + + Controls.Label { + id: durationTime + Layout.alignment: Qt.AlignRight | Qt.AlignVCenter + color: "white" + opacity: 0.8 + text: formatedDuration(modelData.duration) + } + + Kirigami.Separator { + Layout.fillHeight: true + Layout.preferredWidth: 1 + } + + Image { + id: songSource + Layout.preferredHeight: Kirigami.Units.iconSizes.huge + Kirigami.Units.largeSpacing + Layout.preferredWidth: Kirigami.Units.iconSizes.huge + Kirigami.Units.largeSpacing + Layout.alignment: Qt.AlignHCenter + fillMode: Image.PreserveAspectFit + source: modelData.source + } + } + } + + onClicked: { + triggerGuiEvent("gui.search.play", + {"playlistData": modelData}) + } + } + } + } + + Component.onCompleted: { + playlistListView.forceActiveFocus() + } +} diff --git a/ovos_utils/res/ui/Playlist.qml b/ovos_utils/res/ui/Playlist.qml new file mode 100644 index 0000000..6d4f6e7 --- /dev/null +++ b/ovos_utils/res/ui/Playlist.qml @@ -0,0 +1,170 @@ +/* + * Copyright 2020 by Aditya Mehra + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +import QtQuick 2.9 +import QtQuick.Controls 2.3 as Controls +import QtQuick.Layouts 1.3 +import org.kde.kirigami 2.8 as Kirigami +import QtGraphicalEffects 1.0 +import Mycroft 1.0 as Mycroft + + +Mycroft.Delegate { + id: delegate + + property var playlistModel: sessionData.playlistModel + property Component emptyHighlighter: Item{} + fillWidth: true + + skillBackgroundSource: sessionData.bg_image + + onPlaylistModelChanged: { + playlistListView.forceLayout() + } + + Keys.onBackPressed: { + parent.parent.parent.currentIndex-- + parent.parent.parent.currentItem.contentItem.forceActiveFocus() + } + + function formatedDuration(millis){ + var minutes = Math.floor(millis / 60000); + var seconds = ((millis % 60000) / 1000).toFixed(0); + return minutes + ":" + (seconds < 10 ? '0' : '') + seconds; + } + + ColumnLayout { + id: playlistPlayerColumn + anchors.fill: parent + spacing: Kirigami.Units.smallSpacing + + Kirigami.Heading { + id: watchItemList + text: "Your Playlist" + level: 2 + } + + Kirigami.Separator { + id: sept2 + Layout.fillWidth: true + Layout.preferredHeight: 1 + z: 100 + } + + ListView { + id: playlistListView + keyNavigationEnabled: true + model: playlistModel.data + focus: false + interactive: true + bottomMargin: delegate.controlBarItem.height + Kirigami.Units.largeSpacing + Layout.fillWidth: true + Layout.fillHeight: true + spacing: Kirigami.Units.largeSpacing + currentIndex: 0 + clip: true + highlightRangeMode: ListView.StrictlyEnforceRange + snapMode: ListView.SnapToItem + + delegate: Controls.ItemDelegate { + width: parent.width + height: Kirigami.Units.gridUnit * 5 + + background: Rectangle { + Kirigami.Theme.colorSet: Kirigami.Theme.Button + color: Qt.rgba(0.2, 0.2, 0.2, 1) + layer.enabled: true + layer.effect: DropShadow { + horizontalOffset: 1 + verticalOffset: 2 + } + } + + + contentItem: Item { + width: parent.width + height: parent.height + + RowLayout { + id: delegateItem + anchors.fill: parent + anchors.margins: Kirigami.Units.smallSpacing + spacing: Kirigami.Units.largeSpacing + + Image { + id: videoImage + source: modelData.image + Layout.preferredHeight: parent.height + Layout.preferredWidth: Kirigami.Units.gridUnit * 4 + Layout.alignment: Qt.AlignHCenter | Qt.AlignVCenter + fillMode: Image.Stretch + } + + ColumnLayout { + Layout.fillWidth: true + + Controls.Label { + id: videoLabel + Layout.fillWidth: true + text: modelData.track + wrapMode: Text.WordWrap + color: "white" + } + Controls.Label { + id: artistLabel + Layout.fillWidth: true + text: modelData.album + opacity: 0.8 + color: "white" + } + } + + Controls.Label { + id: durationTime + Layout.alignment: Qt.AlignRight | Qt.AlignVCenter + color: "white" + opacity: 0.8 + text: formatedDuration(modelData.duration) + } + + Kirigami.Separator { + Layout.fillHeight: true + Layout.preferredWidth: 1 + } + + Image { + id: songSource + Layout.preferredHeight: Kirigami.Units.iconSizes.huge + Kirigami.Units.largeSpacing + Layout.preferredWidth: Kirigami.Units.iconSizes.huge + Kirigami.Units.largeSpacing + Layout.alignment: Qt.AlignHCenter + fillMode: Image.PreserveAspectFit + source: modelData.source + } + } + } + + onClicked: { + triggerGuiEvent("gui.playlist.play", {"playlistData": modelData}) + } + } + } + } + + Component.onCompleted: { + playlistListView.forceActiveFocus() + } +} diff --git a/ovos_utils/res/ui/SeekControl.qml b/ovos_utils/res/ui/SeekControl.qml index 7cda31b..0ce605e 100644 --- a/ovos_utils/res/ui/SeekControl.qml +++ b/ovos_utils/res/ui/SeekControl.qml @@ -62,44 +62,7 @@ Item { RowLayout { id: mainLayout2 Layout.fillHeight: true - Controls.RoundButton { - id: backButton - Layout.preferredWidth: parent.width > 600 ? Kirigami.Units.iconSizes.large : Kirigami.Units.iconSizes.medium - Layout.preferredHeight: Layout.preferredWidth - highlighted: focus ? 1 : 0 - z: 1000 - - background: Rectangle { - radius: 200 - color: "#1a1a1a" - border.width: 1.25 - border.color: "white" - } - - contentItem: Item { - Image { - width: parent.width - Kirigami.Units.largeSpacing - height: width - anchors.centerIn: parent - source: "images/back.svg" - } - } - - onClicked: { - Mycroft.MycroftController.sendRequest("mycroft.gui.screen.close", {}); - video.stop(); - } - KeyNavigation.up: video - KeyNavigation.right: button - Keys.onReturnPressed: { - hideTimer.restart(); - Mycroft.MycroftController.sendRequest("mycroft.gui.screen.close", {}); - video.stop(); - } - onFocusChanged: { - hideTimer.restart(); - } - } + Controls.RoundButton { id: button Layout.preferredWidth: parent.width > 600 ? Kirigami.Units.iconSizes.large : Kirigami.Units.iconSizes.medium diff --git a/ovos_utils/res/ui/VideoPlayer.qml b/ovos_utils/res/ui/VideoPlayer.qml new file mode 100644 index 0000000..0b7f18e --- /dev/null +++ b/ovos_utils/res/ui/VideoPlayer.qml @@ -0,0 +1,205 @@ +import QtMultimedia 5.12 +import QtQuick.Layouts 1.4 +import QtQuick 2.9 +import QtQuick.Controls 2.12 as Controls +import org.kde.kirigami 2.10 as Kirigami +import QtQuick.Window 2.3 +import QtGraphicalEffects 1.0 +import Mycroft 1.0 as Mycroft +import "." as Local + +Mycroft.Delegate { + id: root + property var media: sessionData.media + property var videoSource: sessionData.stream + property var videoStatus: media.status + property bool busyIndicate: false + + fillWidth: true + background: Rectangle { + color: "black" + } + leftPadding: 0 + topPadding: 0 + rightPadding: 0 + bottomPadding: 0 + + onEnabledChanged: syncStatusTimer.restart() + onVideoSourceChanged: syncStatusTimer.restart() + + Component.onCompleted: { + syncStatusTimer.restart() + } + + Keys.onDownPressed: { + controlBarItem.opened = true + controlBarItem.forceActiveFocus() + } + + onFocusChanged: { + video.forceActiveFocus(); + } + + onVideoStatusChanged: { + switch(videoStatus){ + case "Stopped": + video.stop(); + break; + case "Paused": + video.pause() + break; + case "Playing": + video.play() + delay(6000, function() { + infomationBar.visible = false; + }) + break; + } + } + + Connections { + target: Window.window + onVisibleChanged: { + if(video.playbackState == MediaPlayer.PlayingState) { + video.stop() + } + } + } + + Timer { + id: syncStatusTimer + interval: 0 + onTriggered: { + if (enabled && videoStatus == "Playing") { + video.play(); + } else if (videoStatus == "Stopped") { + video.stop(); + } else { + video.pause(); + } + } + } + + Timer { + id: delaytimer + } + + function delay(delayTime, cb) { + delaytimer.interval = delayTime; + delaytimer.repeat = false; + delaytimer.triggered.connect(cb); + delaytimer.start(); + } + + controlBar: Local.SeekControl { + id: seekControl + anchors { + bottom: parent.bottom + } + title: media.title + videoControl: video + duration: video.duration + playPosition: video.position + onSeekPositionChanged: video.seek(seekPosition); + z: 1000 + } + + Item { + id: videoRoot + anchors.fill: parent + + Rectangle { + id: infomationBar + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + visible: false + color: Qt.rgba(Kirigami.Theme.backgroundColor.r, Kirigami.Theme.backgroundColor.g, Kirigami.Theme.backgroundColor.b, 0.6) + implicitHeight: vidTitle.implicitHeight + Kirigami.Units.largeSpacing * 2 + z: 1001 + + onVisibleChanged: { + delay(15000, function() { + infomationBar.visible = false; + }) + } + + Controls.Label { + id: vidTitle + visible: true + maximumLineCount: 2 + wrapMode: Text.Wrap + anchors.left: parent.left + anchors.leftMargin: Kirigami.Units.largeSpacing + anchors.verticalCenter: parent.verticalCenter + text: media.title + z: 100 + } + } + + Video { + id: video + anchors.fill: parent + focus: true + autoLoad: true + autoPlay: false + loops: 1 + source: videoSource + + Keys.onReturnPressed: { + video.playbackState == MediaPlayer.PlayingState ? video.pause() : video.play() + } + + Keys.onDownPressed: { + controlBarItem.opened = true + controlBarItem.forceActiveFocus() + } + + MouseArea { + anchors.fill: parent + onClicked: { + controlBarItem.opened = !controlBarItem.opened + } + } + + onStatusChanged: { + console.log(status) + if(status == MediaPlayer.EndOfMedia) { + triggerGuiEvent("video.media.playback.ended", {}) + busyIndicatorPop.enabled = false + } + if(status == MediaPlayer.Loading) { + busyIndicatorPop.visible = true + busyIndicatorPop.enabled = true + } + if(status == MediaPlayer.Loaded || status == MediaPlayer.Buffered){ + busyIndicatorPop.visible = false + busyIndicatorPop.enabled = false + } + } + + Rectangle { + id: busyIndicatorPop + width: parent.width + height: parent.height + color: Qt.rgba(0, 0, 0, 0.2) + visible: false + enabled: false + + Controls.BusyIndicator { + id: busyIndicate + running: busyIndicate + anchors.centerIn: parent + } + + onEnabledChanged: { + if(busyIndicatorPop.enabled){ + busyIndicate.running = true + } else { + busyIndicate.running = false + } + } + } + } + } +} diff --git a/ovos_utils/res/ui/images/media-playback-pause.svg b/ovos_utils/res/ui/images/media-playback-pause.svg index 972dfa2..d6a4dd1 100644 --- a/ovos_utils/res/ui/images/media-playback-pause.svg +++ b/ovos_utils/res/ui/images/media-playback-pause.svg @@ -1,61 +1,8 @@ - - - - - - image/svg+xml - - - - - - - - - + + + diff --git a/ovos_utils/res/ui/images/media-playback-start.svg b/ovos_utils/res/ui/images/media-playback-start.svg index 4627f98..25c5fab 100644 --- a/ovos_utils/res/ui/images/media-playback-start.svg +++ b/ovos_utils/res/ui/images/media-playback-start.svg @@ -1,61 +1,8 @@ - - - - - - image/svg+xml - - - - - - - - - + + + diff --git a/ovos_utils/res/ui/images/media-playback-stop.svg b/ovos_utils/res/ui/images/media-playback-stop.svg new file mode 100644 index 0000000..32a0101 --- /dev/null +++ b/ovos_utils/res/ui/images/media-playback-stop.svg @@ -0,0 +1,8 @@ + + + + diff --git a/ovos_utils/res/ui/images/media-playlist-play.svg b/ovos_utils/res/ui/images/media-playlist-play.svg new file mode 100644 index 0000000..66e47ba --- /dev/null +++ b/ovos_utils/res/ui/images/media-playlist-play.svg @@ -0,0 +1,13 @@ + + + + + + diff --git a/ovos_utils/res/ui/images/media-playlist-repeat.svg b/ovos_utils/res/ui/images/media-playlist-repeat.svg index 01c10dd..6ef525f 100644 --- a/ovos_utils/res/ui/images/media-playlist-repeat.svg +++ b/ovos_utils/res/ui/images/media-playlist-repeat.svg @@ -2,7 +2,7 @@ diff --git a/ovos_utils/res/ui/images/media-seek-backward.svg b/ovos_utils/res/ui/images/media-seek-backward.svg new file mode 100644 index 0000000..8a5d334 --- /dev/null +++ b/ovos_utils/res/ui/images/media-seek-backward.svg @@ -0,0 +1,8 @@ + + + + diff --git a/ovos_utils/res/ui/images/media-seek-forward.svg b/ovos_utils/res/ui/images/media-seek-forward.svg new file mode 100644 index 0000000..5243967 --- /dev/null +++ b/ovos_utils/res/ui/images/media-seek-forward.svg @@ -0,0 +1,8 @@ + + + + diff --git a/ovos_utils/skills/audioservice.py b/ovos_utils/skills/audioservice.py new file mode 100644 index 0000000..93bc650 --- /dev/null +++ b/ovos_utils/skills/audioservice.py @@ -0,0 +1,204 @@ +# Copyright 2017 Mycroft AI Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +# This file is directly copied from mycroft-core, it's a simple utility to +# interface with the AudioService via messagebus outside core +from os.path import abspath +from ovos_utils.messagebus import Message, get_mycroft_bus + + +def ensure_uri(s): + """Interprete paths as file:// uri's. + + Arguments: + s: string to be checked + + Returns: + if s is uri, s is returned otherwise file:// is prepended + """ + if isinstance(s, str): + if '://' not in s: + return 'file://' + abspath(s) + else: + return s + elif isinstance(s, (tuple, list)): + if '://' not in s[0]: + return 'file://' + abspath(s[0]), s[1] + else: + return s + else: + raise ValueError('Invalid track') + + +class AudioServiceInterface: + """AudioService class for interacting with the audio subsystem + + Arguments: + bus: Mycroft messagebus connection + """ + + def __init__(self, bus=None): + self.bus = bus or get_mycroft_bus() + + def queue(self, tracks=None): + """Queue up a track to playing playlist. + + Arguments: + tracks: track uri or list of track uri's + Each track can be added as a tuple with (uri, mime) + to give a hint of the mime type to the system + """ + tracks = tracks or [] + if isinstance(tracks, (str, tuple)): + tracks = [tracks] + elif not isinstance(tracks, list): + raise ValueError + tracks = [ensure_uri(t) for t in tracks] + self.bus.emit(Message('mycroft.audio.service.queue', + data={'tracks': tracks})) + + def play(self, tracks=None, utterance=None, repeat=None): + """Start playback. + + Arguments: + tracks: track uri or list of track uri's + Each track can be added as a tuple with (uri, mime) + to give a hint of the mime type to the system + utterance: forward utterance for further processing by the + audio service. + repeat: if the playback should be looped + """ + repeat = repeat or False + tracks = tracks or [] + utterance = utterance or '' + if isinstance(tracks, (str, tuple)): + tracks = [tracks] + elif not isinstance(tracks, list): + raise ValueError + tracks = [ensure_uri(t) for t in tracks] + self.bus.emit(Message('mycroft.audio.service.play', + data={'tracks': tracks, + 'utterance': utterance, + 'repeat': repeat})) + + def stop(self): + """Stop the track.""" + self.bus.emit(Message('mycroft.audio.service.stop')) + + def next(self): + """Change to next track.""" + self.bus.emit(Message('mycroft.audio.service.next')) + + def prev(self): + """Change to previous track.""" + self.bus.emit(Message('mycroft.audio.service.prev')) + + def pause(self): + """Pause playback.""" + self.bus.emit(Message('mycroft.audio.service.pause')) + + def resume(self): + """Resume paused playback.""" + self.bus.emit(Message('mycroft.audio.service.resume')) + + def get_track_length(self): + """ + getting the duration of the audio in mlilliseconds + """ + info = self.bus.wait_for_response( + Message('mycroft.audio.service.get_track_length'), + timeout=1) + if info: + return info.data.get("length") + return 0 + + def get_track_position(self): + """ + get current position in milliseconds + + Args: + seconds (int): number of seconds of final position + """ + info = self.bus.wait_for_response( + Message('mycroft.audio.service.get_track_position'), + timeout=1) + if info: + return info.data.get("position") + return 0 + + def set_track_position(self, seconds=1): + """Seek X seconds. + + Arguments: + seconds (int): number of seconds to seek, if negative rewind + """ + self.bus.emit(Message('mycroft.audio.service.set_track_position', + {"seconds": seconds})) + + def seek(self, seconds=1): + """Seek X seconds. + + Arguments: + seconds (int): number of seconds to seek, if negative rewind + """ + if seconds < 0: + self.seek_backward(abs(seconds)) + else: + self.seek_forward(seconds) + + def seek_forward(self, seconds=1): + """Skip ahead X seconds. + + Arguments: + seconds (int): number of seconds to skip + """ + self.bus.emit(Message('mycroft.audio.service.seek_forward', + {"seconds": seconds})) + + def seek_backward(self, seconds=1): + """Rewind X seconds + + Arguments: + seconds (int): number of seconds to rewind + """ + self.bus.emit(Message('mycroft.audio.service.seek_backward', + {"seconds": seconds})) + + def track_info(self): + """Request information of current playing track. + + Returns: + Dict with track info. + """ + info = self.bus.wait_for_response( + Message('mycroft.audio.service.track_info'), + reply_type='mycroft.audio.service.track_info_reply', + timeout=1) + return info.data if info else {} + + def available_backends(self): + """Return available audio backends. + + Returns: + dict with backend names as keys + """ + msg = Message('mycroft.audio.service.list_backends') + response = self.bus.wait_for_response(msg) + return response.data if response else {} + + @property + def is_playing(self): + """True if the audioservice is playing, else False.""" + return self.track_info() != {} diff --git a/ovos_utils/skills/templates/common_play.py b/ovos_utils/skills/templates/common_play.py new file mode 100644 index 0000000..9c414d4 --- /dev/null +++ b/ovos_utils/skills/templates/common_play.py @@ -0,0 +1,127 @@ +from abc import abstractmethod +from ovos_utils.skills.templates import OVOSSkill +from ovos_utils.playback import CPSMatchType +from ovos_utils.messagebus import Message + + +class BetterCommonPlaySkill(OVOSSkill): + """ To integrate with the better common play infrastructure of Mycroft + skills should use this base class and override + `CPS_search` (for searching the skill for media to play ) and + `CPS_play` for launching the media if desired. + + The class makes the skill available to queries from the + better-playback-control skill and no special vocab for starting playback + is needed. + """ + + def __init__(self, name=None, bus=None): + super().__init__(name, bus) + self.supported_media = [CPSMatchType.GENERIC, CPSMatchType.AUDIO] + self._current_query = None + # NOTE: derived skills will likely want to override this list + + def bind(self, bus): + """Overrides the normal bind method. + + Adds handlers for play:query and play:start messages allowing + interaction with the playback control skill. + + This is called automatically during setup, and + need not otherwise be used. + """ + if bus: + super().bind(bus) + self.add_event('better_cps.query', self.__handle_cps_query) + self.add_event(f'better_cps.{self.skill_id}.play', + self.__handle_cps_play) + + def __handle_cps_play(self, message): + self.CPS_play(message.data) + + def __handle_cps_query(self, message): + """Query skill if it can start playback from given phrase.""" + search_phrase = message.data["phrase"] + self._current_query = search_phrase + media_type = message.data.get("media_type", CPSMatchType.GENERIC) + + if media_type not in self.supported_media: + return + + # invoke the CPS handler to let the skill perform its search + results = self.CPS_search(search_phrase, media_type) + + if results: + # inject skill id in individual results, will be needed later + # for proper GUI playback handling + for idx, r in enumerate(results): + results[idx]["skill_id"] = self.skill_id + self.bus.emit(message.response({"phrase": search_phrase, + "skill_id": self.skill_id, + "results": results, + "searching": False})) + else: + # Signal we are done (can't handle it) + self.bus.emit(message.response({"phrase": search_phrase, + "skill_id": self.skill_id, + "searching": False})) + + def CPS_extend_timeout(self, timeout=0.5): + """ request more time for searching, limits are defined by + better-common-play framework, by default max total time is 5 seconds + per query """ + if self._current_query: + self.bus.emit(Message("better_cps.query.response", + {"phrase": self._current_query, + "skill_id": self.skill_id, + "timeout": timeout, + "searching": True})) + + @abstractmethod + def CPS_search(self, phrase, media_type): + """Analyze phrase to see if it is a play-able phrase with this skill. + + Arguments: + phrase (str): User phrase uttered after "Play", e.g. "some music" + media_type (CPSMatchType): requested CPSMatchType to search for + + if a result from here is selected with CPSPlayback.SKILL then + CPS_play will be called with result data as argument + + Returns: + search_results (list): list of dictionaries with result entries + { + "match_confidence": CPSMatchConfidence.HIGH, + "media_type": CPSMatchType.MUSIC, + "uri": "https://audioservice.or.gui.will.play.this", + "playback": CPSPlayback.GUI, + "image": "http://optional.audioservice.jpg", + "bg_image": "http://optional.audioservice.background.jpg" + } + """ + return [] + + @abstractmethod + def CPS_play(self, data): + """Skill was selected for playback + + Playback will be handled manually by the skill, eg, spotify or some + other external service + + NOTE: CPSPlayback.AUDIO and CPSPlayback.GUI are handled + automatically by BetterCommonPlay, this is only called for + CPSPlayback.SKILL results + + Arguments: + data (dict): selected data previously returned in CPS_search + + { + "match_confidence": CPSMatchConfidence.HIGH, + "media_type": CPSMatchType.MUSIC, + "uri": "https://audioservice.or.gui.will.play.this", + "playback": CPSPlayback.SKILL, + "image": "http://optional.audioservice.jpg", + "bg_image": "http://optional.audioservice.background.jpg" + } + """ + pass diff --git a/ovos_utils/skills/templates/media_player.py b/ovos_utils/skills/templates/media_player.py deleted file mode 100644 index 6db0fc1..0000000 --- a/ovos_utils/skills/templates/media_player.py +++ /dev/null @@ -1,151 +0,0 @@ -from ovos_utils.waiting_for_mycroft.common_play import CommonPlaySkill, \ - CPSMatchLevel, CPSTrackStatus, CPSMatchType -from ovos_utils import create_daemon -from os.path import join, dirname, basename -from ovos_utils import get_mycroft_root, resolve_ovos_resource_file -from ovos_utils.log import LOG -import random - -try: - from mycroft.skills.core import intent_file_handler -except ImportError: - import sys - - MYCROFT_ROOT_PATH = get_mycroft_root() - if MYCROFT_ROOT_PATH is not None: - sys.path.append(MYCROFT_ROOT_PATH) - from mycroft.skills.core import intent_file_handler - else: - LOG.error("Could not find mycroft root path") - raise ImportError - -try: - import pyvod -except ImportError: - pyvod = None - - -class MediaSkill(CommonPlaySkill): - """ - common play skills can be made by just returning the - expected data in CPS_match_query_phrase - - return (phrase, match, - {"image": self.default_image, # optional - "background": self.default_bg, # optional - "stream": random.choice(self.bootstrap_list)}) - - depending on skill settings - - will handle bootstrapping media (download on startup) - - set self.bootstrap_list to a list of urls in __init__ - - will handle audio only VS video - - will handle conversion to mp3 (compatibility with simple audio backend) - - CPS_start should not be overrided - - supports direct urls - - supports youtube urls - - supports every website youtube-dl supports - - will handle setting initial track status - - will fallback to audio only if GUI not connected - """ - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - if "download_audio" not in self.settings: - self.settings["download_audio"] = False - if "download_video" not in self.settings: - self.settings["download_video"] = False - if "audio_only" not in self.settings: - self.settings["audio_only"] = False - if "audio_with_video_stream" not in self.settings: - self.settings["audio_with_video_stream"] = False - if "mp3_audio" not in self.settings: - self.settings["mp3_audio"] = True - if "preferred_audio_backend" not in self.settings: - self.settings["preferred_audio_backend"] = None - self.message_namespace = basename(dirname(__file__)) + ".ovos_utils" - self.default_bg = "https://github.com/OpenVoiceOS/ovos_assets/raw/master/Logo/ovos-logo-512.png" - self.default_image = resolve_ovos_resource_file( - "ui/images/moviesandfilms.png") - self.bootstrap_list = [] - if pyvod is None: - LOG.error("py_VOD not installed!") - LOG.info("pip install py_VOD>=0.4.0") - raise ImportError - - def initialize(self): - self.add_event( - '{msg_base}.home'.format(msg_base=self.message_namespace), - self.handle_homescreen) - create_daemon(self.handle_bootstrap) - - def handle_bootstrap(self): - # bootstrap, so data is cached - for url in self.bootstrap_list: - try: - if self.settings["download_audio"]: - pyvod.utils.get_audio_stream(url, download=True, - to_mp3=self.settings[ - "mp3_audio"]) - if self.settings["download_video"]: - pyvod.utils.get_video_stream(url, download=True) - except: - pass - - # homescreen - def handle_homescreen(self, message): - # users are supposed to override this - self.CPS_start(self.name, - {"image": self.default_image, - "background": self.default_bg, - "stream": random.choice(self.bootstrap_list)}) - - def CPS_match_query_phrase(self, phrase, media_type): - # users are supposed to override this - original = phrase - match = None - - if match is not None: - return (phrase, match, - {"media_type": media_type, "query": original, - "image": self.default_image, - "background": self.default_bg, - "stream": random.choice(self.bootstrap_list)}) - return None - - def CPS_start(self, phrase, data): - self.play_media(data) - - def play_media(self, data): - bg = data.get("background") or self.default_bg - image = data.get("image") or self.default_image - url = data["stream"] - if self.gui.connected and not self.settings["audio_only"]: - url = pyvod.utils.get_video_stream( - url, download=self.settings["download_video"]) - self.CPS_send_status(uri=url, - image=image, - background_image=bg, - playlist_position=0, - status=CPSTrackStatus.PLAYING_GUI) - self.gui.play_video(url, self.name) - else: - utt = self.settings["preferred_audio_backend"] or self.play_service_string - if self.settings["audio_with_video_stream"]: - # This might look stupid, but for youtube live streams it's - # needed, mycroft-core/pull/2791 should also be in for this - # to work properly - url = pyvod.utils.get_video_stream(url) - else: - url = pyvod.utils.get_audio_stream( - url, download=self.settings["download_audio"], - to_mp3=self.settings["mp3_audio"]) - self.audioservice.play(url, utterance=utt) - self.CPS_send_status(uri=url, - image=image, - background_image=bg, - playlist_position=0, - status=CPSTrackStatus.PLAYING_AUDIOSERVICE) - - def stop(self): - self.gui.release() diff --git a/ovos_utils/skills/templates/video_collection.py b/ovos_utils/skills/templates/video_collection.py index b1f9132..d23e215 100644 --- a/ovos_utils/skills/templates/video_collection.py +++ b/ovos_utils/skills/templates/video_collection.py @@ -1,23 +1,12 @@ -from ovos_utils.waiting_for_mycroft.common_play import CommonPlaySkill, \ - CPSMatchLevel, CPSTrackStatus, CPSMatchType from os.path import join, dirname, basename -import random -from ovos_utils import get_mycroft_root, datestr2ts, resolve_ovos_resource_file +from ovos_utils import datestr2ts, resolve_ovos_resource_file from ovos_utils.log import LOG from ovos_utils.parse import fuzzy_match +from ovos_utils.json_helper import merge_dict from json_database import JsonStorageXDG - -try: - from mycroft.skills.core import intent_file_handler -except ImportError: - import sys - MYCROFT_ROOT_PATH = get_mycroft_root() - if MYCROFT_ROOT_PATH is not None: - sys.path.append(MYCROFT_ROOT_PATH) - from mycroft.skills.core import intent_file_handler - else: - LOG.error("Could not find mycroft root path") - raise ImportError +import random +from ovos_utils.skills.templates.common_play import BetterCommonPlaySkill +from ovos_utils.playback import CPSMatchType, CPSPlayback try: import pyvod @@ -27,7 +16,7 @@ pyvod = None -class VideoCollectionSkill(CommonPlaySkill): +class VideoCollectionSkill(BetterCommonPlaySkill): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -43,11 +32,32 @@ def __init__(self, *args, **kwargs): self.settings["filter_live"] = False if "filter_date" not in self.settings: self.settings["filter_date"] = False + if "min_score" not in self.settings: + self.settings["min_score"] = 40 + if "match_description" not in self.settings: + self.settings["match_description"] = True + if "match_tags" not in self.settings: + self.settings["match_tags"] = True + if "match_title" not in self.settings: + self.settings["match_title"] = True + if "filter_trailers" not in self.settings: + self.settings["filter_trailers"] = True + if "filter_behind_scenes" not in self.settings: + self.settings["filter_behind_scenes"] = True + if "search_depth" not in self.settings: + # after matching and ordering by title + # will match/search metadata for N videos + # some collection can be huge and matching everything will cause + # a timeout, collections with less than N videos wont have any + # problem + self.settings["search_depth"] = 500 if pyvod is None: LOG.error("py_VOD not installed!") LOG.info("pip install py_VOD>=0.4.0") raise ImportError + self.playback_type = CPSPlayback.GUI + self.media_type = CPSMatchType.VIDEO self.default_bg = "https://github.com/OpenVoiceOS/ovos_assets/raw/master/Logo/ovos-logo-512.png" self.default_image = resolve_ovos_resource_file("ui/images/moviesandfilms.png") db_path = join(dirname(__file__), "res", self.name + ".jsondb") @@ -56,7 +66,6 @@ def __init__(self, *args, **kwargs): logo=self.default_image, db_path=db_path) - def initialize(self): self.initialize_media_commons() @@ -91,6 +100,9 @@ def videos(self): else: videos[idx]["url"] = videos[idx].get("stream") or \ videos[idx].get("url") + # convert duration to milliseconds + if v.get("duration"): + videos[idx]["length"] = v["duration"] * 1000 # return sorted return self.sort_videos(videos) except Exception as e: @@ -137,6 +149,20 @@ def filter_videos(self, videos): # TODO filter behind the scenes, clips etc based on # title/tags/description/keywords required or forbidden + # filter trailers + if self.settings["filter_trailers"] and \ + CPSMatchType.TRAILER not in self.supported_media: + # TODO bundle .voc for "trailer" + videos = [v for v in videos + if not self.voc_match(v["title"], "trailer")] + + # filter behind the scenes + if self.settings["filter_behind_scenes"] and \ + CPSMatchType.BEHIND_THE_SCENES not in self.supported_media: + # TODO bundle .voc for "behind_scenes" + videos = [v for v in videos + if not self.voc_match(v["title"], "behind_scenes")] + if self.settings["shuffle_menu"]: random.shuffle(videos) @@ -159,7 +185,7 @@ def handle_homescreen(self, message): def play_video_event(self, message): video_data = message.data["modelData"] if video_data["skill_id"] == self.skill_id: - self.play_video(video_data) + pass # TODO # watch history database def add_to_history(self, video_data): @@ -181,64 +207,79 @@ def handle_clear_history(self, message): # matching def match_media_type(self, phrase, media_type): - match = None - score = 0 - + base_score = 0 if media_type == CPSMatchType.VIDEO: - score += 0.05 - match = CPSMatchLevel.GENERIC - - return match, score + base_score += 5 + if media_type != self.media_type: + base_score -= 20 + return base_score def augment_tags(self, phrase, media_type, tags=None): return tags or [] - def match_tags(self, video, phrase, match, media_type): + def match_tags(self, video, phrase, media_type): score = 0 # score tags - leftover_text = phrase tags = list(set(video.get("tags") or [])) tags = self.augment_tags(phrase, media_type, tags) if tags: # tag match bonus for tag in tags: tag = tag.lower().strip() + if tag in phrase.split(" "): + score += 10 if tag in phrase: - match = CPSMatchLevel.CATEGORY - score += 0.05 - leftover_text = leftover_text.replace(tag, "") - return match, score, leftover_text + score += 3 + return score - def match_description(self, video, phrase, match): + def match_description(self, video, phrase, media_type): # score description score = 0 leftover_text = phrase words = video.get("description", "").split(" ") for word in words: if len(word) > 4 and word in self.normalize_title(leftover_text): - score += 0.05 + score += 1 leftover_text = leftover_text.replace(word, "") - return match, score, leftover_text + return score - def match_title(self, videos, phrase, match): + def match_title(self, video, phrase, media_type): # match video name clean_phrase = self.normalize_title(phrase) - leftover_text = phrase - best_score = 0 - best_video = random.choice(videos) - for video in videos: - title = video["title"] - score = fuzzy_match(clean_phrase, self.normalize_title(title)) - if phrase.lower() in title.lower() or \ - clean_phrase in self.normalize_title(title): - score += 0.3 - if score >= best_score: - # TODO handle ties - match = CPSMatchLevel.TITLE - best_video = video - best_score = score - leftover_text = phrase.replace(title, "") - return match, best_score, best_video, leftover_text + title = video["title"] + score = fuzzy_match(clean_phrase, self.normalize_title(title)) * 100 + if phrase.lower() in title.lower() or \ + clean_phrase in self.normalize_title(title): + score += 25 + if phrase.lower() in title.lower().split(" ") or \ + clean_phrase in self.normalize_title(title).split(" "): + score += 30 + + if media_type == CPSMatchType.TRAILER: + if self.voc_match(title, "trailer"): + score += 20 + else: + score -= 10 + elif self.settings["filter_trailers"] and \ + self.voc_match(title, "trailer") or \ + "trailer" in title.lower(): + # trailer in title, but not in media_type, let's skip it + # TODO bundle trailer.voc in ovos_utils + score = 0 + + if media_type == CPSMatchType.BEHIND_THE_SCENES: + if self.voc_match(title, "behind_scenes"): + score += 20 + else: + score -= 10 + elif self.settings["filter_behind_scenes"] and \ + self.voc_match(title, "behind_scenes") or \ + "behind the scenes" in title.lower(): + # trailer in title, but not in media_type, let's skip it + # TODO bundle behind_scenes.voc in ovos_utils + score = 0 + + return score def normalize_title(self, title): title = title.lower().strip() @@ -250,107 +291,40 @@ def normalize_title(self, title): # spaces # common play - def calc_final_score(self, phrase, base_score, match_level): - return base_score, match_level - - def base_CPS_match(self, phrase, media_type): - best_score = 0 - # see if media type is in query, base_score will depend if "video" is in query - match, base_score = self.match_media_type(phrase, media_type) - videos = list(self.videos) - best_video = random.choice(self.videos) - # match video data - scores = [] - for video in videos: - match, score, _ = self.match_tags(video, phrase, match, media_type) - # match, score, leftover_text = self.match_description(video, leftover_text, match) - scores.append((video, score)) - if score > best_score: - best_video = video - best_score = score - - self.log.debug("Best Tags Match: {s}, {t}".format( - s=best_score, t=best_video["title"])) - - # match video name - match, title_score, best_title, leftover_text = self.match_title( - videos, phrase, match) - self.log.debug("Best Title Match: {s}, {t}".format( - s=title_score, t=best_title["title"])) - - # title more important than tags - if title_score + 0.15 > best_score: - best_video = best_title - best_score = title_score - - # sort matches - scores = sorted(scores, key=lambda k: k[1], reverse=True) - scores.insert(0, (best_title, title_score)) - scores.remove((best_video, best_score)) - scores.insert(0, (best_video, best_score)) - - # choose from top N - if best_score < 0.5: - n = 50 - elif best_score < 0.6: - n = 10 - elif best_score < 0.8: - n = 3 - else: - n = 1 - - candidates = scores[:n] - self.log.info("Choosing randomly from top {n} matches".format( - n=len(candidates))) - best_video = random.choice(candidates)[0] - - # calc final confidence - score = base_score + best_score - score = self.calc_final_score(phrase, score, match) - if isinstance(score, float): - if score >= 0.9: - match = CPSMatchLevel.EXACT - elif score >= 0.7: - match = CPSMatchLevel.MULTI_KEY - elif score >= 0.5: - match = CPSMatchLevel.TITLE - else: - score, match = score - - self.log.info("Best video: " + best_video["title"]) - - if match is not None: - return (leftover_text, match, best_video) - return None - - def CPS_match_query_phrase(self, phrase, media_type): - match = self.base_CPS_match(phrase, media_type) - if match is None: - return None - # match == (leftover_text, CPSMatchLevel, best_video_data) - return match - - def CPS_start(self, phrase, data): - self.play_video(data) - - def play_video(self, data): - self.add_to_history(data) - bg = data.get("background") or self.default_bg - image = data.get("image") or self.default_image - - if len(data.get("streams", [])): - url = data["streams"][0] - else: - url = data.get("stream") or data.get("url") - - title = data.get("name") or self.name - self.CPS_send_status(uri=url, - image=image, - background_image=bg, - playlist_position=0, - status=CPSTrackStatus.PLAYING_GUI) - self.gui.play_video(pyvod.utils.get_video_stream(url), title) - - def stop(self): - self.gui.release() + def CPS_search(self, phrase, media_type): + base_score = self.match_media_type(phrase, media_type) + # penalty for generic searches, they tend to overmatch + if media_type == CPSMatchType.GENERIC: + base_score -= 20 + # match titles and sort + # then match all the metadata up to self.settings["search_depth"] + videos = sorted(self.videos, + key=lambda k: fuzzy_match(k["title"], phrase), + reverse=True) + cps_results = [] + for idx, video in enumerate(videos[:self.settings["search_depth"]]): + score = base_score + fuzzy_match(video["title"], phrase) * 30 + if self.settings["match_tags"]: + score += self.match_tags(video, phrase, media_type) + if self.settings["match_title"]: + score += self.match_title(video, phrase, media_type) + if self.settings["match_description"]: + score += self.match_description(video, phrase, media_type) + if score < self.settings["min_score"]: + continue + cps_results.append(merge_dict(video, { + "match_confidence": min(100, score), + "media_type": self.media_type, + "playback": self.playback_type, + "skill_icon": self.skill_icon, + "skill_logo": self.skill_logo, + "bg_image": video.get("logo") or self.default_bg, + "image": video.get("logo") or self.default_image, + "author": self.name + })) + + cps_results = sorted(cps_results, + key=lambda k: k["match_confidence"], + reverse=True) + return cps_results diff --git a/ovos_utils/waiting_for_mycroft/common_play.py b/ovos_utils/waiting_for_mycroft/common_play.py index de87cb2..163f259 100644 --- a/ovos_utils/waiting_for_mycroft/common_play.py +++ b/ovos_utils/waiting_for_mycroft/common_play.py @@ -1,7 +1,8 @@ from inspect import signature -from enum import IntEnum from abc import abstractmethod +from enum import IntEnum from ovos_utils.waiting_for_mycroft.base_skill import MycroftSkill +from ovos_utils.playback import CPSMatchType, CPSTrackStatus from ovos_utils import ensure_mycroft_import ensure_mycroft_import() @@ -20,38 +21,6 @@ class CPSMatchLevel(IntEnum): GENERIC = 6 -class CPSTrackStatus(IntEnum): - DISAMBIGUATION = 1 # not queued for playback, show in gui - PLAYING = 20 # Skill is handling playback internally - PLAYING_AUDIOSERVICE = 21 # Skill forwarded playback to audio service - PLAYING_GUI = 22 # Skill forwarded playback to gui - PLAYING_ENCLOSURE = 23 # Skill forwarded playback to enclosure - QUEUED = 30 # Waiting playback to be handled inside skill - QUEUED_AUDIOSERVICE = 31 # Waiting playback in audio service - QUEUED_GUI = 32 # Waiting playback in gui - QUEUED_ENCLOSURE = 33 # Waiting for playback in enclosure - PAUSED = 40 # media paused but ready to resume - STALLED = 60 # playback has stalled, reason may be unknown - BUFFERING = 61 # media is buffering from an external source - END_OF_MEDIA = 90 # playback finished, is the default state when CPS loads - - -class CPSMatchType(IntEnum): - GENERIC = 1 - MUSIC = 2 - VIDEO = 3 - AUDIOBOOK = 4 - GAME = 5 - PODCAST = 6 - RADIO = 7 - NEWS = 8 - TV = 9 - MOVIE = 10 - TRAILER = 11 - ADULT = 12 - VISUAL_STORY = 13 - - class CommonPlaySkill(MycroftSkill, _CommonPlaySkill): """ To integrate with the common play infrastructure of Mycroft skills should use this base class and override the two methods diff --git a/setup.py b/setup.py index 7fd2d2e..ddbf023 100644 --- a/setup.py +++ b/setup.py @@ -2,7 +2,7 @@ setup( name='ovos_utils', - version='0.0.7', + version='0.0.8', packages=['ovos_utils', 'ovos_utils.waiting_for_mycroft', 'ovos_utils.intents', @@ -12,9 +12,10 @@ 'ovos_utils.enclosure.mark1.eyes', 'ovos_utils.enclosure.mark1.faceplate', 'ovos_utils.skills', + 'ovos_utils.playback', + 'ovos_utils.plugins', 'ovos_utils.skills.templates', 'ovos_utils.skills.decorators', - 'ovos_utils.plugins', 'ovos_utils.lang'], url='https://github.com/OpenVoiceOS/ovos_utils', install_requires=[