diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..0a3c0ce --- /dev/null +++ b/.editorconfig @@ -0,0 +1,43 @@ +# This file is for unifying the coding style for different editors and IDEs +# editorconfig.org + +# PHP PSR-2 Coding Standards +# http://www.php-fig.org/psr/psr-2/ + +root = true + +[*.php] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 4 + +[*.js] +charset = utf-8 +trim_trailing_whitespace = true +indent_style = space +indent_size = 4 + +[*.css] +charset = utf-8 +trim_trailing_whitespace = true +indent_style = space +indent_size = 4 + +[*.py] +indent_style = space +indent_size = 4 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.sh] +indent_style = space +indent_size = 4 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..5ebf6e5 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,35 @@ +matrix: + include: + - language: php + php: 5.6 + before_script: + - find . -type f -name *.php | xargs -n1 php -l + script: + - cd ${TRAVIS_BUILD_DIR} + - pwd + after_success: + - cd ${TRAVIS_BUILD_DIR} + - ls -latr + after_failure: + - cd ${TRAVIS_BUILD_DIR} + - ls -latr + + - language: python + python: 2.7 + install: + - pip install pylint + script: + - cd ${TRAVIS_BUILD_DIR} + - ./tests/tools/lintAllPythonFiles.sh + - language: markdown + addons: + apt: + packages: + - aspell + - aspell-fr + script: + - gem install mdl + - cd ${TRAVIS_BUILD_DIR} + - ./tests/tools/setCustomMDWarnings.sh + - mdl -r $MDLWAR *.md docs/fr_FR/*.md + - ./tests/tools/spellCheckMD.sh diff --git a/ISSUE_TEMPLATE.md b/ISSUE_TEMPLATE.md new file mode 100644 index 0000000..5391379 --- /dev/null +++ b/ISSUE_TEMPLATE.md @@ -0,0 +1,18 @@ +## Description + +## Etapes à reproduire (pour les bugs) + +1. +2. +3. +4. + +## Contexte: + +## Proposition de solution (optionnel): + +## Environnement: + +* **Version Jeedom**: +* **Plateforme**: +* **Version du Plugin**: diff --git a/core/php/googlecast.ajax.php b/core/php/googlecast.ajax.php index 2923484..c06f711 100644 --- a/core/php/googlecast.ajax.php +++ b/core/php/googlecast.ajax.php @@ -53,5 +53,5 @@ throw new Exception(__('Aucune methode correspondante à : ', __FILE__) . init('action')); /* * *********Catch exeption*************** */ } catch (Exception $e) { - ajax::error(displayExeption($e), $e->getCode()); + ajax::error(displayException($e), $e->getCode()); } diff --git a/resources/globals.py b/resources/globals.py index 9c594dd..bcee1bc 100644 --- a/resources/globals.py +++ b/resources/globals.py @@ -40,7 +40,7 @@ DISCOVERY_FREQUENCY = 14400 # every 4 hours DISCOVERY_LAST = int(time.time()) # when last started -LOSTDEVICE_RESENDNOTIFDELAY = 60*5 # not used yet +LOSTDEVICE_RESENDNOTIFDELAY = 60*15 # Resent offline msg after 15 minutes IFACE_DEVICE = 0 diff --git a/resources/googlecast.py b/resources/googlecast.py index e7f44d4..3aabcf2 100644 --- a/resources/googlecast.py +++ b/resources/googlecast.py @@ -12,8 +12,12 @@ # # You should have received a copy of the GNU General Public License # along with Jeedom. If not, see . +# +# pylint: disable=C +# pylint: disable=R +# pylint: disable=W +# pylint: disable=E -import subprocess import os,re import logging import sys @@ -23,21 +27,33 @@ import signal import json import traceback -import select +import _thread as thread import globals -from threading import Timer -import _thread as thread +# check imports try: import pychromecast.pychromecast as pychromecast +except ImportError: + logging.error("ERROR: Main pychromecast module not loaded !") + logging.error(traceback.format_exc()) + sys.exit(1) + +try: import pychromecast.pychromecast.controllers.dashcast as dashcast - import pychromecast.pychromecast.controllers.youtube as youtube import pychromecast.pychromecast.controllers.plex as plex import pychromecast.pychromecast.controllers.spotify as Spotify except ImportError: - print("Error: importing pychromecast module") - sys.exit(1) + logging.error("ERROR: One or several pychromecast controllers are not loaded !") + print(traceback.format_exc()) + pass + +try: + import pychromecast.pychromecast.customcontrollers.youtube as youtube +except ImportError: + logging.error("ERROR: Custom controller not loaded !") + logging.error(traceback.format_exc()) + pass try: from jeedom.jeedom import * @@ -45,7 +61,9 @@ print("Error: importing module from jeedom folder") sys.exit(1) +# ------------------------ +# class JeedomChromeCast class JeedomChromeCast : def __init__(self, gcast, options=None, scan_mode=False): self.uuid = str(gcast.device.uuid) @@ -53,11 +71,10 @@ def __init__(self, gcast, options=None, scan_mode=False): self.gcast = gcast self.previous_status = {"uuid" : self.uuid, "online" : False} self.now_playing = False - self.now_playing_thread = False self.online = True - self.being_shutdown = False self.scan_mode = scan_mode if scan_mode == False : + self.being_shutdown = False self.customplayer = None self.customplayername = "" self.nowplaying_lastupdated = 0 @@ -83,9 +100,10 @@ def device(self): return self.gcast.device def startNowPlaying(self): - if self.now_playing == False and self.online == True and self.now_playing_thread == False: + if self.now_playing == False and self.online == True : logging.debug("JEEDOMCHROMECAST------ Starting monitoring of " + self.uuid) self.now_playing = True + self.sendNowPlaying(force=True) def stopNowPlaying(self): logging.debug("JEEDOMCHROMECAST------ Stopping monitoring of " + self.uuid) @@ -129,8 +147,8 @@ def sendDeviceStatusIfNew(self): self.sendDeviceStatus(False) def disconnect(self): - self.being_shutdown = True if self.scan_mode==False : + self.being_shutdown = True self._internal_refresh_status(True) if self.now_playing == True : self._internal_send_now_playing() @@ -147,28 +165,32 @@ def loadPlayer(self, playername, params=None, token=None) : if params and 'forcereload' in params : forceReload = True if not self.customplayer or self.customplayername != playername or forceReload==True : - if playername == 'web' : - player = dashcast.DashCastController() - self.gcast.register_handler(player) - elif playername == 'youtube' : - player = youtube.YouTubeController() - self.gcast.register_handler(player) - elif playername == 'spotify' : - player = spotify.SpotifyController(token) - self.gcast.register_handler(player) - elif playername == 'plex' : - player = plex.PlexController() - else : - player = self.gcast.media_controller - logging.debug("JEEDOMCHROMECAST------ Initiating player " + str(player.namespace)) - self.customplayer = player - self.customplayername = playername - if params and 'waitbeforequit' in params : - time.sleep(params['waitbeforequit']) - if params and 'quitapp' in params : - self.gcast.quit_app() - if params and 'wait' in params : - time.sleep(params['wait']) + try: + if playername == 'web' : + player = dashcast.DashCastController() + self.gcast.register_handler(player) + elif playername == 'youtube' : + player = youtube.YouTubeController() + self.gcast.register_handler(player) + elif playername == 'spotify' : + player = spotify.SpotifyController(token) + self.gcast.register_handler(player) + elif playername == 'plex' : + player = plex.PlexController() + else : + player = self.gcast.media_controller + logging.debug("JEEDOMCHROMECAST------ Initiating player " + str(player.namespace)) + self.customplayer = player + self.customplayername = playername + if params and 'waitbeforequit' in params : + time.sleep(params['waitbeforequit']) + if params and 'quitapp' in params : + self.gcast.quit_app() + if params and 'wait' in params : + time.sleep(params['wait']) + except Exception : + player = None + pass self.sendNowPlaying(force=True) return self.customplayer return None @@ -263,14 +285,14 @@ def sendNowPlaying(self, force=False): if force==True : self._internal_send_now_playing() elif self.now_playing==True: - logging.debug("JEEDOMCHROMECAST------ NOW PLAYGIN " + str(int(time.time())-self.nowplaying_lastupdated)) + logging.debug("JEEDOMCHROMECAST------ NOW PLAYING " + str(int(time.time())-self.nowplaying_lastupdated)) if (int(time.time())-self.nowplaying_lastupdated)>=globals.NOWPLAYING_FREQUENCY : self._internal_trigger_now_playing_update() def sendNowPlaying_heartbeat(self): if self.now_playing==True: if (int(datetime.utcnow().timestamp())-self.nowplaying_lastupdated)>=globals.NOWPLAYING_FREQUENCY : - logging.debug("JEEDOMCHROMECAST------ NOW PLAYGIN heartbeat " + str(int(datetime.utcnow().timestamp())-self.nowplaying_lastupdated)) + logging.debug("JEEDOMCHROMECAST------ NOW PLAYING heartbeat " + str(int(datetime.utcnow().timestamp())-self.nowplaying_lastupdated)) self._internal_trigger_now_playing_update() def _internal_send_now_playing(self): @@ -652,7 +674,6 @@ def scanner(name): uuid = cast.uuid current_time = int(time.time()) - # starting event thread if uuid in globals.KNOWN_DEVICES : globals.KNOWN_DEVICES[uuid]['online'] = True globals.KNOWN_DEVICES[uuid]['lastScan'] = current_time @@ -702,32 +723,30 @@ def scanner(name): globals.KNOWN_DEVICES[known]['online'] = False globals.KNOWN_DEVICES[known]['lastOfflineSent'] = current_time globals.KNOWN_DEVICES[known]['status'] = status = { - "uuid" : known, - "friendly_name" : "", - "is_stand_by" : False, - "app_id" : "", - "display_name" : "", - "status_text" : "", - "idle" : False, + "uuid" : known, "friendly_name" : "", + "is_stand_by" : False, "is_active_input" : False, + "app_id" : "", "display_name" : "", "status_text" : "", + "is_busy" : False, } #globals.JEEDOM_COM.add_changes('devices::'+known, globals.KNOWN_DEVICES[known]) globals.JEEDOM_COM.send_change_immediate_device(known, globals.KNOWN_DEVICES[known]) globals.KNOWN_DEVICES[known]['lastSent'] = current_time if known in globals.NOWPLAYING_DEVICES: + del globals.NOWPLAYING_DEVICES[known] data = { "uuid" : known, "online" : False, "friendly_name" : "", - "is_active_input" : False, "is_stand_by" : False, + "is_active_input" : False, "is_stand_by" : False, "app_id" : "", "display_name" : "", "status_text" : "", - "idle" : False, "title" : "", - "album_artist" : "","metadata_type" : "", - "album_name" : "", "current_time" : 0, - "artist" : "", "image" : None, - 'series_title': "", 'season': "", 'episode': "", + "is_busy" : False, "title" : "", + "album_artist" : "", "metadata_type" : "", + "album_name" : "", "current_time" : 0, + "artist" : "", "image" : None, + 'series_title': "", 'season': "", 'episode': "", "stream_type" : "", "track" : "", - "player_state" : "","supported_media_commands" : 0, - "supports_pause" : "", 'duration': 0, - 'content_type': "", 'idle_reason': "" + "player_state" : "", "supported_media_commands" : 0, + "supports_pause" : "", "duration": 0, + "content_type": "", "idle_reason": "" } globals.JEEDOM_COM.send_change_immediate({'uuid' : known, 'nowplaying':data}); diff --git a/resources/install_check.sh b/resources/install_check.sh index 1160b2d..7c8a48e 100644 --- a/resources/install_check.sh +++ b/resources/install_check.sh @@ -1,4 +1,3 @@ -rm -f /tmp/dependancycheck_googlecast if ! python3 -V 2>&1 | grep -q "Python 3"; then echo "nok" exit 0 @@ -8,12 +7,12 @@ pip3cmd=$(compgen -ac | grep -E '^pip-?3' | sort -r | head -1) if [[ ! -z $pip3cmd ]]; then # pip3 found $(sudo $pip3cmd list 2>/dev/null | grep -E "zeroconf|requests|protobuf" | wc -l > /tmp/dependancycheck_googlecast) content=$(cat /tmp/dependancycheck_googlecast 2>/dev/null) + rm -f /tmp/dependancycheck_googlecast if [[ -z $content ]]; then - $content = 0 + content=0 fi if [ "$content" -lt 3 ];then echo "nok" - rm -f /tmp/dependancycheck_googlecast exit 0 fi else @@ -21,5 +20,4 @@ else exit 0 fi echo "ok" -rm -f /tmp/dependancycheck_googlecast exit 0 diff --git a/resources/jeedom/jeedom.py b/resources/jeedom/jeedom.py index 3f2532d..8a74017 100644 --- a/resources/jeedom/jeedom.py +++ b/resources/jeedom/jeedom.py @@ -22,14 +22,10 @@ from datetime import datetime import collections import os -from os.path import join import socket from multiprocessing import Queue import socketserver as SocketServer from socketserver import (TCPServer, StreamRequestHandler) -import signal -import unicodedata - # ------------------------------------------------------------------------------ diff --git a/resources/pychromecast/pychromecast/controllers/youtube.py b/resources/pychromecast/pychromecast/controllers/youtube.py index fc8fd4f..b0f24cb 100644 --- a/resources/pychromecast/pychromecast/controllers/youtube.py +++ b/resources/pychromecast/pychromecast/controllers/youtube.py @@ -1,383 +1,59 @@ """ Controller to interface with the YouTube-app. -""" - -import re -import threading -try: - from json import JSONDecodeError -except ImportError: - JSONDecodeError = ValueError -import requests +Use the media controller to play, pause etc. +""" from . import BaseController -YOUTUBE_BASE_URL = "https://www.youtube.com/" -YOUTUBE_WATCH_VIDEO_URL = YOUTUBE_BASE_URL + "watch?v=" - -# id param is const(YouTube sets it as random xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx so it should be fine). -RANDOM_ID = "12345678-9ABC-4DEF-0123-0123456789AB" -VIDEO_ID_PARAM = '%7B%22videoId%22%3A%22{video_id}%22%2C%22currentTime%22%3A5%2C%22currentIndex%22%3A0%7D' -TERMINATE_PARAM = "terminate" - -REQUEST_URL_SET_PLAYLIST = YOUTUBE_BASE_URL + "api/lounge/bc/bind?" -BASE_REQUEST_PARAMS = {"device": "REMOTE_CONTROL", "id": RANDOM_ID, "name": "Desktop&app=youtube-desktop", - "mdx-version": 3, "loungeIdToken": None, "VER": 8, "v": 2, "t": 1, "ui": 1, "RID": 75956, - "CVER": 1} - -SET_PLAYLIST_METHOD = {"method": "setPlaylist", "params": VIDEO_ID_PARAM, "TYPE": None} -REQUEST_PARAMS_SET_PLAYLIST = dict(**dict(BASE_REQUEST_PARAMS, **SET_PLAYLIST_METHOD)) - -REQUEST_DATA_SET_PLAYLIST = "count=0" -REQUEST_DATA_ADD_TO_PLAYLIST = "count=1&ofs=%d&req0__sc=addVideo&req0_videoId=%s" -REQUEST_DATA_REMOVE_FROM_PLAYLIST = "count=1&ofs=%d&req0__sc=removeVideo&req0_videoId=%s" -REQUEST_DATA_CLEAR_PLAYLIST = "count=1&ofs=%d&req0__sc=clearPlaylist" - -REQUEST_URL_LOUNGE_TOKEN = YOUTUBE_BASE_URL + "api/lounge/pairing/get_lounge_token_batch" -REQUEST_DATA_LOUNGE_TOKEN = "screen_ids={screenId}&session_token={XSRFToken}" - -YOUTUBE_SESSION_TOKEN_REGEX = 'XSRF_TOKEN\W*(.*)="' -SID_REGEX = '"c","(.*?)",\"' -PLAYLIST_ID_REGEX = 'listId":"(.*?)"' -FIRST_VIDEO_ID_REGEX = 'firstVideoId":"(.*?)"' -GSESSION_ID_REGEX = '"S","(.*?)"]' -NOW_PLAYING_REGEX = 'videoId":"(.*?)"' - -EXPIRED_LOUNGE_ID_RESPONSE_CONTENT = "Expired lounge id token" - -MEDIA_NAMESPACE = "urn:x-cast:com.google.cast.media" MESSAGE_TYPE = "type" -TYPE_GET_SCREEN_ID = "getMdxSessionStatus" TYPE_STATUS = "mdxSessionStatus" ATTR_SCREEN_ID = "screenId" -TYPE_PLAY = "PLAY" -TYPE_PAUSE = "PAUSE" -TYPE_STOP = "STOP" - - -class YoutubeSessionError(Exception): - pass - - -class YoutubeControllerError(Exception): - pass class YouTubeController(BaseController): - """ Controller to interact with Youtube.""" + """ Controller to interact with Youtube namespace. """ def __init__(self): super(YouTubeController, self).__init__( "urn:x-cast:com.google.youtube.mdx", "233637DE") - self._xsrf_token = None - self._lounge_token = None - self._gsession_id = None - self._sid = None - self._ofs = 0 - self._first_video = None - self._playlist_id = None - self.screen_id = None - self.video_id = None - self.playlist = None - self._now_playing = None - self.status_update_event = threading.Event() - - @property - def video_url(self): - """Returns the base watch video url with the current video_id""" - video = self._now_playing or self.video_id - return YOUTUBE_WATCH_VIDEO_URL + video - - @property - def status(self): - """ Returns the media_controller status handler when Youtube app is launched.""" - if self.is_active: - return self._socket_client.media_controller.status - else: - return None - - @property - def in_session(self): - """ Returns True if session params are not None.""" - if self._gsession_id and self._sid and self._lounge_token: - return True - else: - return False - - def _do_post(self, url, data, params=None, referer=None): - """ - Does all post requests. - will raise if response is not 200-ok - :param url:(str)the request url - :param data:(str) the request body - :param params:(dict) the request urlparams - :param referer:(str) the referer. default is the video url that started the session. - :return: the response - """ - headers = { - "Origin": YOUTUBE_BASE_URL, - "Content-Type": "application/x-www-form-urlencoded", - "Referer": (referer or self.video_url) - } - response = requests.post(url, headers=headers, data=data, params=params) - response.raise_for_status() - return response - - def update_screen_id(self): - """ - Sends a getMdxSessionStatus to get the screen id and waits for response. - This function is blocking but if connected we should always get a response - (send message will launch app if it is not running). - """ - self.status_update_event.clear() - self.send_message({MESSAGE_TYPE: TYPE_GET_SCREEN_ID}) - self.status_update_event.wait() - self.status_update_event.clear() - - def _get_xsrf_token(self): - """ - Get the xsrf_token used as the session token. - video_id must be initialized. - Sets the session token(xsrf_token). - """ - if not self.video_id: - raise ValueError("Cant start a session without the video_id.") - response = requests.get(self.video_url) - response.raise_for_status() - token = re.search(YOUTUBE_SESSION_TOKEN_REGEX, str(response.content)) - if not token: - raise YoutubeSessionError("Could not fetch the xsrf token") - self._xsrf_token = token.group(1) - - def _get_lounge_id(self): - """ - Gets the lounge_token. - session_token(xsrf_token) and screenId must be initialized. - Sets the lounge token. - """ - if not self.screen_id: - raise ValueError("Screen id is None. update_screen_id must be called.") - if not self._xsrf_token: - raise ValueError("xsrf token is None. Get xsrf token must be called.") - data = REQUEST_DATA_LOUNGE_TOKEN.format(screenId=self.screen_id, XSRFToken=self._xsrf_token) - response = self._do_post(REQUEST_URL_LOUNGE_TOKEN, data=data) - if response.status_code == 401: - # Screen id is not None and it is updated with a message from the Chromecast. - # It is very unlikely that screen_id caused the problem. - raise YoutubeSessionError("Could not get lounge id. XSRF token has expired or is not valid.") - response.raise_for_status() - try: - lounge_token = response.json()["screens"][0]["loungeToken"] - except JSONDecodeError: - raise YoutubeSessionError("Could not get lounge id. XSRF token has expired or not valid.") - self._lounge_token = lounge_token - - def _set_playlist(self): - """ - Sends a POST to start the session. - Uses loung_token and video id as parameters. - Sets session SID and gsessionid on success. - """ - if not self.video_id: - raise ValueError("Can't start a session without the video_id.") - if not self._lounge_token: - raise ValueError("lounge token is None. _get_lounge_token must be called") - url_params = REQUEST_PARAMS_SET_PLAYLIST.copy() - url_params['loungeIdToken'] = self._lounge_token - url_params['params'] = VIDEO_ID_PARAM.format(video_id=self.video_id) - response = self._do_post(REQUEST_URL_SET_PLAYLIST, data=REQUEST_DATA_SET_PLAYLIST, params=url_params) - content = str(response.content) - if response.status_code == 401 and content.find(EXPIRED_LOUNGE_ID_RESPONSE_CONTENT) != -1: - raise YoutubeSessionError("The lounge token expired.") - response.raise_for_status() - if not self.in_session: - self._extract_session_parameters(content) - - def _update_session_parameters(self): - """ - Sends a POST with no playlist parameters. - Gets the playlist id, SID, gsession id. - First video(the playlist base video) and now playing are also returned if playlist is initialized. - """ - url_params = BASE_REQUEST_PARAMS.copy() - url_params['loungeIdToken'] = self._lounge_token - response = self._do_post(REQUEST_URL_SET_PLAYLIST, data='', params=url_params) - self._extract_session_parameters(str(response.content)) - return response - - def _extract_session_parameters(self, response_packet_content): - """ - Extracts the playlist id, SID, gsession id, first video(the playlist base video) - and now playing from a session response. - :param response_packet_content: (str) the response packet content - """ - content = response_packet_content - playlist_id = re.search(PLAYLIST_ID_REGEX, content) - sid = re.search(SID_REGEX, content) - gsession = re.search(GSESSION_ID_REGEX, content) - first_video = re.search(FIRST_VIDEO_ID_REGEX, content) - now_playing = re.search(NOW_PLAYING_REGEX, content) - if not (sid and gsession and playlist_id): - raise YoutubeSessionError("Could not parse session parameters.") - self._sid = sid.group(1) - self._gsession_id = gsession.group(1) - self._playlist_id = playlist_id.group(1) - if first_video: - self._first_video = first_video.group(1) - else: - self._first_video = None - if now_playing: - self._now_playing = now_playing.group(1) - else: - self._now_playing = None - - def _manage_playlist(self, data, referer=None, **kwargs): - """ - Manages all request to an existing session. - _gsession_id, _sid, video_id and _lounge_token must be initialized. - :param data: data of the request - :param video_id: video id in the request - :param refer: used for the request heders referer field.video_url by default. - """ - if not self._gsession_id: - raise ValueError("gsession must be initialized to manage playlist") - if not self._sid: - raise ValueError("sid must be initialized to manage playlist") - if not self.video_id: - raise ValueError("video_id can't be empty") - if self.in_session: - self._update_session_parameters() - param_video_id = self._first_video or self.video_id - - url_params = REQUEST_PARAMS_SET_PLAYLIST.copy() - url_params["loungeIdToken"] = self._lounge_token - url_params["params"] = VIDEO_ID_PARAM.format(video_id=param_video_id) - url_params["gsessionid"] = self._gsession_id - url_params["SID"] = self._sid - for key in kwargs: - if key in url_params: - url_params[key] = kwargs[key] - try: - self._do_post(REQUEST_URL_SET_PLAYLIST, referer=referer, data=data, params=url_params) - except requests.HTTPError: - # Try to re-get session variables and post again. - self._set_playlist() - url_params["loungeIdToken"] = self._lounge_token - url_params["params"] = VIDEO_ID_PARAM.format(video_id=self._first_video) - url_params["gsessionid"] = self._gsession_id - url_params["SID"] = self._sid - self._ofs = 0 - self._do_post(REQUEST_URL_SET_PLAYLIST, referer=referer, data=data, params=url_params) - - def clear_playlist(self, terminate_session=False): - """ - clears all tracks on queue without closing the session. - terminate_session: close the existing session after clearing playlist. - App closes after a few minutes idle so terminate session if idle for a few minutes. - """ - self._ofs += 1 - self._manage_playlist(REQUEST_DATA_CLEAR_PLAYLIST % self._ofs) - if terminate_session: - self.terminate_session() - self.playlist = None - - def terminate_session(self): - """ - terminates the open lounge session. - """ - try: - self.clear_playlist() - self._manage_playlist(data='', video_id=self.video_id, TYPE=TERMINATE_PARAM) - except requests.RequestException: - # Session has expired or not in sync.Clean session parameters anyway. - pass self.screen_id = None - self.video_id = None - self._xsrf_token = None - self._lounge_token = None - self._gsession_id = None - self._sid = None - self._ofs = 0 - self.playlist = None - self._first_video = None def receive_message(self, message, data): """ Called when a media message is received. """ if data[MESSAGE_TYPE] == TYPE_STATUS: - self._process_status(data.get("data")) + self._process_status(data.get('data')) return True - else: - return False - - def start_new_session(self, youtube_id): - self.video_id = youtube_id - self.update_screen_id() - self._get_xsrf_token() - self._get_lounge_id() - self._update_session_parameters() + return False def play_video(self, youtube_id): """ Starts playing a video in the YouTube app. - The youtube id is also a session identifier used in all requests for the session. - :param youtube_id: The video id to play. - """ - if not self.in_session: - self.start_new_session(youtube_id) - if self._first_video: - self.clear_playlist() - self._set_playlist() - self._update_session_parameters() - def add_to_queue(self, youtube_id): - """ - Adds a video to the queue video will play after the currently playing video ends. - If video is buffering it wil not be added! - :param youtube_id: The video id to add to the queue + Only works if there is no video playing. """ - if not self.in_session: - raise YoutubeSessionError('Session must be initialized to add to queue') - if not self.playlist: - self.playlist = [self.video_id] - elif youtube_id in self.playlist: - raise YoutubeControllerError("Video already in queue") - self.update_screen_id() - # if self.status.player_is_idle: - # raise YoutubeControllerError("Can't add to queue while video is idle") - if self.status.player_state == "BUFFERING": - raise YoutubeControllerError("Can't add to queue while video is buffering") - self._ofs += 1 - self._manage_playlist(data=REQUEST_DATA_ADD_TO_PLAYLIST % (self._ofs, youtube_id)) - self.playlist.append(youtube_id) + def callback(): + """Plays requested video after app launched.""" + self.start_play(youtube_id) - def _send_command(self, message, namespace=MEDIA_NAMESPACE): + self.launch(callback_function=callback) + + def start_play(self, youtube_id): """ - Sends a message to a specific namespace. - :param message:(dict) the message to sent to chromecast - :param namespace:(str) the namespace to send the message to. default is media namespace. + Sends the play message to the YouTube app. """ - self._socket_client.send_app_message(namespace, message) - - def play(self): - self._send_command({MESSAGE_TYPE: TYPE_PLAY}) - - def pause(self): - self._send_command({MESSAGE_TYPE: TYPE_PAUSE}) + msg = { + "type": "flingVideo", + "data": { + "currentTime": 0, + "videoId": youtube_id + } + } - def stop(self, clear_queue=True): - if clear_queue: - self.clear_playlist() - self._send_command({MESSAGE_TYPE: TYPE_STOP}) + self.send_message(msg, inc_session_id=True) def _process_status(self, status): """ Process latest status update. """ self.screen_id = status.get(ATTR_SCREEN_ID) - self.status_update_event.set() - - def tear_down(self): - """ Called when controller is destroyed. """ - super(YouTubeController, self).tear_down() - self.terminate_session() diff --git a/resources/pychromecast/pychromecast/controllers/youtube.py.bak b/resources/pychromecast/pychromecast/controllers/youtube.py.bak deleted file mode 100644 index b0f24cb..0000000 --- a/resources/pychromecast/pychromecast/controllers/youtube.py.bak +++ /dev/null @@ -1,59 +0,0 @@ -""" -Controller to interface with the YouTube-app. - -Use the media controller to play, pause etc. -""" -from . import BaseController - -MESSAGE_TYPE = "type" -TYPE_STATUS = "mdxSessionStatus" -ATTR_SCREEN_ID = "screenId" - - -class YouTubeController(BaseController): - """ Controller to interact with Youtube namespace. """ - - def __init__(self): - super(YouTubeController, self).__init__( - "urn:x-cast:com.google.youtube.mdx", "233637DE") - - self.screen_id = None - - def receive_message(self, message, data): - """ Called when a media message is received. """ - if data[MESSAGE_TYPE] == TYPE_STATUS: - self._process_status(data.get('data')) - - return True - - return False - - def play_video(self, youtube_id): - """ - Starts playing a video in the YouTube app. - - Only works if there is no video playing. - """ - def callback(): - """Plays requested video after app launched.""" - self.start_play(youtube_id) - - self.launch(callback_function=callback) - - def start_play(self, youtube_id): - """ - Sends the play message to the YouTube app. - """ - msg = { - "type": "flingVideo", - "data": { - "currentTime": 0, - "videoId": youtube_id - } - } - - self.send_message(msg, inc_session_id=True) - - def _process_status(self, status): - """ Process latest status update. """ - self.screen_id = status.get(ATTR_SCREEN_ID) diff --git a/resources/pychromecast/pychromecast/customcontrollers/youtube.py b/resources/pychromecast/pychromecast/customcontrollers/youtube.py new file mode 100644 index 0000000..c510391 --- /dev/null +++ b/resources/pychromecast/pychromecast/customcontrollers/youtube.py @@ -0,0 +1,383 @@ +""" +Controller to interface with the YouTube-app. +""" + +import re +import threading +try: + from json import JSONDecodeError +except ImportError: + JSONDecodeError = ValueError + +import requests +from ..controllers import BaseController + +YOUTUBE_BASE_URL = "https://www.youtube.com/" +YOUTUBE_WATCH_VIDEO_URL = YOUTUBE_BASE_URL + "watch?v=" + +# id param is const(YouTube sets it as random xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx so it should be fine). +RANDOM_ID = "12345678-9ABC-4DEF-0123-0123456789AB" +VIDEO_ID_PARAM = '%7B%22videoId%22%3A%22{video_id}%22%2C%22currentTime%22%3A5%2C%22currentIndex%22%3A0%7D' +TERMINATE_PARAM = "terminate" + +REQUEST_URL_SET_PLAYLIST = YOUTUBE_BASE_URL + "api/lounge/bc/bind?" +BASE_REQUEST_PARAMS = {"device": "REMOTE_CONTROL", "id": RANDOM_ID, "name": "Desktop&app=youtube-desktop", + "mdx-version": 3, "loungeIdToken": None, "VER": 8, "v": 2, "t": 1, "ui": 1, "RID": 75956, + "CVER": 1} + +SET_PLAYLIST_METHOD = {"method": "setPlaylist", "params": VIDEO_ID_PARAM, "TYPE": None} +REQUEST_PARAMS_SET_PLAYLIST = dict(**dict(BASE_REQUEST_PARAMS, **SET_PLAYLIST_METHOD)) + +REQUEST_DATA_SET_PLAYLIST = "count=0" +REQUEST_DATA_ADD_TO_PLAYLIST = "count=1&ofs=%d&req0__sc=addVideo&req0_videoId=%s" +REQUEST_DATA_REMOVE_FROM_PLAYLIST = "count=1&ofs=%d&req0__sc=removeVideo&req0_videoId=%s" +REQUEST_DATA_CLEAR_PLAYLIST = "count=1&ofs=%d&req0__sc=clearPlaylist" + +REQUEST_URL_LOUNGE_TOKEN = YOUTUBE_BASE_URL + "api/lounge/pairing/get_lounge_token_batch" +REQUEST_DATA_LOUNGE_TOKEN = "screen_ids={screenId}&session_token={XSRFToken}" + +YOUTUBE_SESSION_TOKEN_REGEX = 'XSRF_TOKEN\W*(.*)="' +SID_REGEX = '"c","(.*?)",\"' +PLAYLIST_ID_REGEX = 'listId":"(.*?)"' +FIRST_VIDEO_ID_REGEX = 'firstVideoId":"(.*?)"' +GSESSION_ID_REGEX = '"S","(.*?)"]' +NOW_PLAYING_REGEX = 'videoId":"(.*?)"' + +EXPIRED_LOUNGE_ID_RESPONSE_CONTENT = "Expired lounge id token" + +MEDIA_NAMESPACE = "urn:x-cast:com.google.cast.media" +MESSAGE_TYPE = "type" +TYPE_GET_SCREEN_ID = "getMdxSessionStatus" +TYPE_STATUS = "mdxSessionStatus" +ATTR_SCREEN_ID = "screenId" +TYPE_PLAY = "PLAY" +TYPE_PAUSE = "PAUSE" +TYPE_STOP = "STOP" + + +class YoutubeSessionError(Exception): + pass + + +class YoutubeControllerError(Exception): + pass + + +class YouTubeController(BaseController): + """ Controller to interact with Youtube.""" + + def __init__(self): + super(YouTubeController, self).__init__( + "urn:x-cast:com.google.youtube.mdx", "233637DE") + + self._xsrf_token = None + self._lounge_token = None + self._gsession_id = None + self._sid = None + self._ofs = 0 + self._first_video = None + self._playlist_id = None + self.screen_id = None + self.video_id = None + self.playlist = None + self._now_playing = None + self.status_update_event = threading.Event() + + @property + def video_url(self): + """Returns the base watch video url with the current video_id""" + video = self._now_playing or self.video_id + return YOUTUBE_WATCH_VIDEO_URL + video + + @property + def status(self): + """ Returns the media_controller status handler when Youtube app is launched.""" + if self.is_active: + return self._socket_client.media_controller.status + else: + return None + + @property + def in_session(self): + """ Returns True if session params are not None.""" + if self._gsession_id and self._sid and self._lounge_token: + return True + else: + return False + + def _do_post(self, url, data, params=None, referer=None): + """ + Does all post requests. + will raise if response is not 200-ok + :param url:(str)the request url + :param data:(str) the request body + :param params:(dict) the request urlparams + :param referer:(str) the referer. default is the video url that started the session. + :return: the response + """ + headers = { + "Origin": YOUTUBE_BASE_URL, + "Content-Type": "application/x-www-form-urlencoded", + "Referer": (referer or self.video_url) + } + response = requests.post(url, headers=headers, data=data, params=params) + response.raise_for_status() + return response + + def update_screen_id(self): + """ + Sends a getMdxSessionStatus to get the screen id and waits for response. + This function is blocking but if connected we should always get a response + (send message will launch app if it is not running). + """ + self.status_update_event.clear() + self.send_message({MESSAGE_TYPE: TYPE_GET_SCREEN_ID}) + self.status_update_event.wait() + self.status_update_event.clear() + + def _get_xsrf_token(self): + """ + Get the xsrf_token used as the session token. + video_id must be initialized. + Sets the session token(xsrf_token). + """ + if not self.video_id: + raise ValueError("Cant start a session without the video_id.") + response = requests.get(self.video_url) + response.raise_for_status() + token = re.search(YOUTUBE_SESSION_TOKEN_REGEX, str(response.content)) + if not token: + raise YoutubeSessionError("Could not fetch the xsrf token") + self._xsrf_token = token.group(1) + + def _get_lounge_id(self): + """ + Gets the lounge_token. + session_token(xsrf_token) and screenId must be initialized. + Sets the lounge token. + """ + if not self.screen_id: + raise ValueError("Screen id is None. update_screen_id must be called.") + if not self._xsrf_token: + raise ValueError("xsrf token is None. Get xsrf token must be called.") + data = REQUEST_DATA_LOUNGE_TOKEN.format(screenId=self.screen_id, XSRFToken=self._xsrf_token) + response = self._do_post(REQUEST_URL_LOUNGE_TOKEN, data=data) + if response.status_code == 401: + # Screen id is not None and it is updated with a message from the Chromecast. + # It is very unlikely that screen_id caused the problem. + raise YoutubeSessionError("Could not get lounge id. XSRF token has expired or is not valid.") + response.raise_for_status() + try: + lounge_token = response.json()["screens"][0]["loungeToken"] + except JSONDecodeError: + raise YoutubeSessionError("Could not get lounge id. XSRF token has expired or not valid.") + self._lounge_token = lounge_token + + def _set_playlist(self): + """ + Sends a POST to start the session. + Uses loung_token and video id as parameters. + Sets session SID and gsessionid on success. + """ + if not self.video_id: + raise ValueError("Can't start a session without the video_id.") + if not self._lounge_token: + raise ValueError("lounge token is None. _get_lounge_token must be called") + url_params = REQUEST_PARAMS_SET_PLAYLIST.copy() + url_params['loungeIdToken'] = self._lounge_token + url_params['params'] = VIDEO_ID_PARAM.format(video_id=self.video_id) + response = self._do_post(REQUEST_URL_SET_PLAYLIST, data=REQUEST_DATA_SET_PLAYLIST, params=url_params) + content = str(response.content) + if response.status_code == 401 and content.find(EXPIRED_LOUNGE_ID_RESPONSE_CONTENT) != -1: + raise YoutubeSessionError("The lounge token expired.") + response.raise_for_status() + if not self.in_session: + self._extract_session_parameters(content) + + def _update_session_parameters(self): + """ + Sends a POST with no playlist parameters. + Gets the playlist id, SID, gsession id. + First video(the playlist base video) and now playing are also returned if playlist is initialized. + """ + url_params = BASE_REQUEST_PARAMS.copy() + url_params['loungeIdToken'] = self._lounge_token + response = self._do_post(REQUEST_URL_SET_PLAYLIST, data='', params=url_params) + self._extract_session_parameters(str(response.content)) + return response + + def _extract_session_parameters(self, response_packet_content): + """ + Extracts the playlist id, SID, gsession id, first video(the playlist base video) + and now playing from a session response. + :param response_packet_content: (str) the response packet content + """ + content = response_packet_content + playlist_id = re.search(PLAYLIST_ID_REGEX, content) + sid = re.search(SID_REGEX, content) + gsession = re.search(GSESSION_ID_REGEX, content) + first_video = re.search(FIRST_VIDEO_ID_REGEX, content) + now_playing = re.search(NOW_PLAYING_REGEX, content) + if not (sid and gsession and playlist_id): + raise YoutubeSessionError("Could not parse session parameters.") + self._sid = sid.group(1) + self._gsession_id = gsession.group(1) + self._playlist_id = playlist_id.group(1) + if first_video: + self._first_video = first_video.group(1) + else: + self._first_video = None + if now_playing: + self._now_playing = now_playing.group(1) + else: + self._now_playing = None + + def _manage_playlist(self, data, referer=None, **kwargs): + """ + Manages all request to an existing session. + _gsession_id, _sid, video_id and _lounge_token must be initialized. + :param data: data of the request + :param video_id: video id in the request + :param refer: used for the request heders referer field.video_url by default. + """ + if not self._gsession_id: + raise ValueError("gsession must be initialized to manage playlist") + if not self._sid: + raise ValueError("sid must be initialized to manage playlist") + if not self.video_id: + raise ValueError("video_id can't be empty") + if self.in_session: + self._update_session_parameters() + param_video_id = self._first_video or self.video_id + + url_params = REQUEST_PARAMS_SET_PLAYLIST.copy() + url_params["loungeIdToken"] = self._lounge_token + url_params["params"] = VIDEO_ID_PARAM.format(video_id=param_video_id) + url_params["gsessionid"] = self._gsession_id + url_params["SID"] = self._sid + for key in kwargs: + if key in url_params: + url_params[key] = kwargs[key] + try: + self._do_post(REQUEST_URL_SET_PLAYLIST, referer=referer, data=data, params=url_params) + except requests.HTTPError: + # Try to re-get session variables and post again. + self._set_playlist() + url_params["loungeIdToken"] = self._lounge_token + url_params["params"] = VIDEO_ID_PARAM.format(video_id=self._first_video) + url_params["gsessionid"] = self._gsession_id + url_params["SID"] = self._sid + self._ofs = 0 + self._do_post(REQUEST_URL_SET_PLAYLIST, referer=referer, data=data, params=url_params) + + def clear_playlist(self, terminate_session=False): + """ + clears all tracks on queue without closing the session. + terminate_session: close the existing session after clearing playlist. + App closes after a few minutes idle so terminate session if idle for a few minutes. + """ + self._ofs += 1 + self._manage_playlist(REQUEST_DATA_CLEAR_PLAYLIST % self._ofs) + if terminate_session: + self.terminate_session() + self.playlist = None + + def terminate_session(self): + """ + terminates the open lounge session. + """ + try: + self.clear_playlist() + self._manage_playlist(data='', video_id=self.video_id, TYPE=TERMINATE_PARAM) + except requests.RequestException: + # Session has expired or not in sync.Clean session parameters anyway. + pass + self.screen_id = None + self.video_id = None + self._xsrf_token = None + self._lounge_token = None + self._gsession_id = None + self._sid = None + self._ofs = 0 + self.playlist = None + self._first_video = None + + def receive_message(self, message, data): + """ Called when a media message is received. """ + if data[MESSAGE_TYPE] == TYPE_STATUS: + self._process_status(data.get("data")) + + return True + + else: + return False + + def start_new_session(self, youtube_id): + self.video_id = youtube_id + self.update_screen_id() + self._get_xsrf_token() + self._get_lounge_id() + self._update_session_parameters() + + def play_video(self, youtube_id): + """ + Starts playing a video in the YouTube app. + The youtube id is also a session identifier used in all requests for the session. + :param youtube_id: The video id to play. + """ + if not self.in_session: + self.start_new_session(youtube_id) + if self._first_video: + self.clear_playlist() + self._set_playlist() + self._update_session_parameters() + + def add_to_queue(self, youtube_id): + """ + Adds a video to the queue video will play after the currently playing video ends. + If video is buffering it wil not be added! + :param youtube_id: The video id to add to the queue + """ + if not self.in_session: + raise YoutubeSessionError('Session must be initialized to add to queue') + if not self.playlist: + self.playlist = [self.video_id] + elif youtube_id in self.playlist: + raise YoutubeControllerError("Video already in queue") + self.update_screen_id() + # if self.status.player_is_idle: + # raise YoutubeControllerError("Can't add to queue while video is idle") + if self.status.player_state == "BUFFERING": + raise YoutubeControllerError("Can't add to queue while video is buffering") + self._ofs += 1 + self._manage_playlist(data=REQUEST_DATA_ADD_TO_PLAYLIST % (self._ofs, youtube_id)) + self.playlist.append(youtube_id) + + def _send_command(self, message, namespace=MEDIA_NAMESPACE): + """ + Sends a message to a specific namespace. + :param message:(dict) the message to sent to chromecast + :param namespace:(str) the namespace to send the message to. default is media namespace. + """ + self._socket_client.send_app_message(namespace, message) + + def play(self): + self._send_command({MESSAGE_TYPE: TYPE_PLAY}) + + def pause(self): + self._send_command({MESSAGE_TYPE: TYPE_PAUSE}) + + def stop(self, clear_queue=True): + if clear_queue: + self.clear_playlist() + self._send_command({MESSAGE_TYPE: TYPE_STOP}) + + def _process_status(self, status): + """ Process latest status update. """ + self.screen_id = status.get(ATTR_SCREEN_ID) + self.status_update_event.set() + + def tear_down(self): + """ Called when controller is destroyed. """ + super(YouTubeController, self).tear_down() + self.terminate_session() diff --git a/tests/tools/.aspell.fr.pws b/tests/tools/.aspell.fr.pws new file mode 100644 index 0000000..e568394 --- /dev/null +++ b/tests/tools/.aspell.fr.pws @@ -0,0 +1,25 @@ +personal_ws-1.1 fr 0 utf-8 +Etapes +plateforme +docs +fr +FR +presentation.md +https +téléchargement +prévisualisation +xxxx +FAQ +releases +release +bold +market +desktop +bug +bugtracker +bugs +changelog +config +jeedom +plugin +screenshot \ No newline at end of file diff --git a/tests/tools/.pylintrc b/tests/tools/.pylintrc new file mode 100644 index 0000000..4d21b94 --- /dev/null +++ b/tests/tools/.pylintrc @@ -0,0 +1,4 @@ +[BASIC] + +const-rgx=[a-z_][a-z0-9_]{2,30}$ +module-rgx=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ diff --git a/tests/tools/.pylintrctest b/tests/tools/.pylintrctest new file mode 100644 index 0000000..421a173 --- /dev/null +++ b/tests/tools/.pylintrctest @@ -0,0 +1,6 @@ +[MESSAGES CONTROL] +disable=unused-argument, + missing-docstring, + too-many-public-methods, + invalid-name + \ No newline at end of file diff --git a/tests/tools/lintAllPythonFiles.sh b/tests/tools/lintAllPythonFiles.sh new file mode 100644 index 0000000..9a4505e --- /dev/null +++ b/tests/tools/lintAllPythonFiles.sh @@ -0,0 +1,7 @@ +#!/bin/bash +for file in `find . -name "*.py" ! -name "__init__.py"`; + do + echo "Check $file with pylint" + python -m pylint --rcfile=tests/tools/.pylintrc $file + done + \ No newline at end of file diff --git a/tests/tools/setCustomMDWarnings.sh b/tests/tools/setCustomMDWarnings.sh new file mode 100644 index 0000000..e0ebd67 --- /dev/null +++ b/tests/tools/setCustomMDWarnings.sh @@ -0,0 +1,2 @@ +#!/bin/bash +export MDLWAR="MD003,MD004,MD005,MD006,MD007,MD008,MD009,MD010,MD011,MD012,MD014,MD018,MD019,MD020,MD021,MD022,MD023,MD024,MD027,MD028,MD029,MD030,MD031,MD032,MD033,MD034,MD035,MD036,MD038,MD039" diff --git a/tests/tools/spellCheckMD.sh b/tests/tools/spellCheckMD.sh new file mode 100644 index 0000000..e3f2ebd --- /dev/null +++ b/tests/tools/spellCheckMD.sh @@ -0,0 +1,12 @@ +#!/bin/bash +for file in *.md docs/fr_FR/*.md; + do + echo $files + if [ $file = "docs/fr_FR/index-template.md" ] || [ $file = "docs/fr_FR/index.md" ] + then + echo "skip "$file + else + echo "process "$file + cat $file | aspell --personal=./tests/tools/.aspell.fr.pws --lang=fr --encoding=utf-8 list; + fi +done