diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index af56d21ad..268aefa65 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -176,11 +176,6 @@ jobs: - name: Sync tests with ${{ matrix.plex }} server if: matrix.plex == 'claimed' - env: - PLEXAPI_HEADER_PROVIDES: 'controller,sync-target' - PLEXAPI_HEADER_PLATFORM: iOS - PLEXAPI_HEADER_PLATFORM_VERSION: 11.4.1 - PLEXAPI_HEADER_DEVICE: iPhone run: | . venv/bin/activate pytest \ diff --git a/plexapi/base.py b/plexapi/base.py index 1b4fa8c9b..fbfedfeae 100644 --- a/plexapi/base.py +++ b/plexapi/base.py @@ -95,7 +95,7 @@ def _buildDetailsKey(self, **kwargs): or disable each parameter individually by setting it to False or 0. """ details_key = self.key - if hasattr(self, '_INCLUDES'): + if details_key and hasattr(self, '_INCLUDES'): includes = {} for k, v in self._INCLUDES.items(): value = kwargs.get(k, v) diff --git a/plexapi/library.py b/plexapi/library.py index 78a548520..edf755b24 100644 --- a/plexapi/library.py +++ b/plexapi/library.py @@ -8,7 +8,7 @@ from plexapi.media import MediaTag from plexapi.settings import Setting -warnings.simplefilter('default') +warnings.simplefilter('default', category=DeprecationWarning) class Library(PlexObject): diff --git a/plexapi/myplex.py b/plexapi/myplex.py index f98853fbf..e902c8f5c 100644 --- a/plexapi/myplex.py +++ b/plexapi/myplex.py @@ -153,14 +153,15 @@ def _loadData(self, data): self.services = None self.joined_at = None - def device(self, name): + def device(self, name=None, clientIdentifier=None): """ Returns the :class:`~plexapi.myplex.MyPlexDevice` that matches the name specified. Parameters: name (str): Name to match against. + clientIdentifier (str): clientIdentifier to match against. """ for device in self.devices(): - if device.name.lower() == name.lower(): + if (name and device.name.lower() == name.lower() or device.clientIdentifier == clientIdentifier): return device raise NotFound('Unable to find device %s' % name) @@ -1098,33 +1099,44 @@ class MyPlexPinLogin(object): requestTimeout (int): timeout in seconds on initial connect to plex.tv (default config.TIMEOUT). Attributes: - PINS (str): 'https://plex.tv/pins.xml' - CHECKPINS (str): 'https://plex.tv/pins/{pinid}.xml' + PINS (str): 'https://plex.tv/api/v2/pins' + CHECKPINS (str): 'https://plex.tv/api/v2/pins/{pinid}' + LINK (str): 'https://plex.tv/api/v2/pins/link' POLLINTERVAL (int): 1 finished (bool): Whether the pin login has finished or not. expired (bool): Whether the pin login has expired or not. token (str): Token retrieved through the pin login. pin (str): Pin to use for the login on https://plex.tv/link. """ - PINS = 'https://plex.tv/pins.xml' # get - CHECKPINS = 'https://plex.tv/pins/{pinid}.xml' # get + PINS = 'https://plex.tv/api/v2/pins' # get + CHECKPINS = 'https://plex.tv/api/v2/pins/{pinid}' # get + LINK = 'https://plex.tv/api/v2/pins/link' # put POLLINTERVAL = 1 - def __init__(self, session=None, requestTimeout=None): + def __init__(self, session=None, requestTimeout=None, headers=None): super(MyPlexPinLogin, self).__init__() self._session = session or requests.Session() self._requestTimeout = requestTimeout or TIMEOUT + self.headers = headers self._loginTimeout = None self._callback = None self._thread = None self._abort = False self._id = None + self._code = None self.finished = False self.expired = False self.token = None - self.pin = self._getPin() + + @property + def pin(self): + if self._code: + return self._code + + self._getCode() + return self._code def run(self, callback=None, timeout=None): """ Starts the thread which monitors the PIN login state. @@ -1187,21 +1199,39 @@ def checkLogin(self): return False - def _getPin(self): - if self.pin: - return self.pin + def link(self, code=None, token=None): + if code is None: + code = self.pin + + url = self.LINK + headers = BASE_HEADERS.copy() + headers.update({ + 'Content-Type': 'application/x-www-form-urlencoded', + 'X-Plex-Product': 'Plex SSO', + }) + + token = token or CONFIG.get('auth.server_token') + if token: + headers['X-Plex-Token'] = token + + data = {'code': code} + self._query(url, self._session.put, headers=headers, data=data) + def _getCode(self): url = self.PINS response = self._query(url, self._session.post) if not response: return None - self._id = response.find('id').text - self.pin = response.find('code').text + self._id = response.attrib.get('id') + self._code = response.attrib.get('code') - return self.pin + return self._code def _checkLogin(self): + if not self._code: + self._getCode() + if not self._id: return False @@ -1213,7 +1243,7 @@ def _checkLogin(self): if not response: return False - token = response.find('auth_token').text + token = response.attrib.get('authToken') if not token: return False @@ -1241,11 +1271,19 @@ def _pollLogin(self): finally: self.finished = True - def _query(self, url, method=None): + def _headers(self, **kwargs): + """ Returns dict containing base headers for all requests for pin login. """ + headers = BASE_HEADERS.copy() + if self.headers: + headers.update(self.headers) + headers.update(kwargs) + return headers + + def _query(self, url, method=None, headers=None, **kwargs): method = method or self._session.get log.debug('%s %s', method.__name__.upper(), url) - headers = BASE_HEADERS.copy() - response = method(url, headers=headers, timeout=self._requestTimeout) + headers = headers or self._headers() + response = method(url, headers=headers, timeout=self._requestTimeout, **kwargs) if not response.ok: # pragma: no cover codename = codes.get(response.status_code)[0] errtext = response.text.replace('\n', ' ') diff --git a/plexapi/utils.py b/plexapi/utils.py index 1e754e667..1c06a93d4 100644 --- a/plexapi/utils.py +++ b/plexapi/utils.py @@ -11,7 +11,7 @@ from urllib.parse import quote import requests -from plexapi.exceptions import NotFound +from plexapi.exceptions import BadRequest, NotFound try: from tqdm import tqdm @@ -379,6 +379,31 @@ def getMyPlexAccount(opts=None): # pragma: no cover return MyPlexAccount(username, password) +def createMyPlexDevice(headers, timeout=None): # pragma: no cover + """ Helper function to create a new MyPlexDevice. + + Parameters: + headers (dict): Provide the X-Plex- headers for the new device. + A unique X-Plex-Client-Identifier is required. + timeout (int): Timeout in seconds to wait for device login. + """ + from plexapi.myplex import MyPlexPinLogin + + if 'X-Plex-Client-Identifier' not in headers: + raise BadRequest('The X-Plex-Client-Identifier header is required.') + + clientIdentifier = headers['X-Plex-Client-Identifier'] + + pinlogin = MyPlexPinLogin(headers=headers) + pinlogin.run(timeout=timeout) + pinlogin.link() + pinlogin.waitForLogin() + + account = getMyPlexAccount() + device = account.device(clientIdentifier=clientIdentifier) + return device + + def choose(msg, items, attr): # pragma: no cover """ Command line helper to display a list of choices, asking the user to choose one of the options. diff --git a/tests/conftest.py b/tests/conftest.py index ebf28be58..46fd6949f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -8,8 +8,10 @@ import pytest import requests from plexapi.client import PlexClient +from plexapi.exceptions import NotFound from plexapi.myplex import MyPlexAccount from plexapi.server import PlexServer +from plexapi.utils import createMyPlexDevice from .payloads import ACCOUNT_XML @@ -47,6 +49,15 @@ "windows", "windows_phone", } +SYNC_DEVICE_IDENTIFIER = "test-sync-client-%s" % plexapi.X_PLEX_IDENTIFIER +SYNC_DEVICE_HEADERS = { + "X-Plex-Provides": "sync-target", + "X-Plex-Platform": "iOS", + "X-Plex-Platform-Version": "11.4.1", + "X-Plex-Device": "iPhone", + "X-Plex-Device-Name": "Test Sync Device", + "X-Plex-Client-Identifier": SYNC_DEVICE_IDENTIFIER +} TEST_AUTHENTICATED = "authenticated" TEST_ANONYMOUSLY = "anonymously" @@ -125,19 +136,10 @@ def account_plexpass(account): @pytest.fixture(scope="session") def account_synctarget(account_plexpass): - assert "sync-target" in plexapi.X_PLEX_PROVIDES, ( - "You have to set env var " "PLEXAPI_HEADER_PROVIDES=sync-target,controller" - ) - assert "sync-target" in plexapi.BASE_HEADERS["X-Plex-Provides"] - assert ( - "iOS" == plexapi.X_PLEX_PLATFORM - ), "You have to set env var PLEXAPI_HEADER_PLATFORM=iOS" - assert ( - "11.4.1" == plexapi.X_PLEX_PLATFORM_VERSION - ), "You have to set env var PLEXAPI_HEADER_PLATFORM_VERSION=11.4.1" - assert ( - "iPhone" == plexapi.X_PLEX_DEVICE - ), "You have to set env var PLEXAPI_HEADER_DEVICE=iPhone" + assert "sync-target" in SYNC_DEVICE_HEADERS["X-Plex-Provides"] + assert "iOS" == SYNC_DEVICE_HEADERS["X-Plex-Platform"] + assert "11.4.1" == SYNC_DEVICE_HEADERS["X-Plex-Platform-Version"] + assert "iPhone" == SYNC_DEVICE_HEADERS["X-Plex-Device"] return account_plexpass @@ -159,24 +161,23 @@ def plex(request): @pytest.fixture() -def device(account): - d = None - for device in account.devices(): - if device.clientIdentifier == plexapi.X_PLEX_IDENTIFIER: - d = device - break - - assert d - return d +def sync_device(account_synctarget): + try: + device = account_synctarget.device(clientIdentifier=SYNC_DEVICE_IDENTIFIER) + except NotFound: + device = createMyPlexDevice(SYNC_DEVICE_HEADERS, timeout=10) + + assert device + return device @pytest.fixture() -def clear_sync_device(device, account_synctarget, plex): - sync_items = account_synctarget.syncItems(clientId=device.clientIdentifier) +def clear_sync_device(sync_device, plex): + sync_items = sync_device.syncItems() for item in sync_items.items: item.delete() plex.refreshSync() - return device + return sync_device @pytest.fixture diff --git a/tests/test_myplex.py b/tests/test_myplex.py index d09e1ed1c..b4c896b54 100644 --- a/tests/test_myplex.py +++ b/tests/test_myplex.py @@ -56,10 +56,7 @@ def test_myplex_devices(account): def test_myplex_device(account, plex): - from plexapi import X_PLEX_DEVICE_NAME - assert account.device(plex.friendlyName) - assert account.device(X_PLEX_DEVICE_NAME) def _test_myplex_connect_to_device(account): diff --git a/tests/test_sync.py b/tests/test_sync.py index 200d5f7ad..0b1ec21ec 100644 --- a/tests/test_sync.py +++ b/tests/test_sync.py @@ -5,15 +5,15 @@ from . import conftest as utils -def get_sync_item_from_server(device, sync_item): - sync_list = device.syncItems() +def get_sync_item_from_server(sync_device, sync_item): + sync_list = sync_device.syncItems() for item in sync_list.items: if item.id == sync_item.id: return item -def is_sync_item_missing(device, sync_item): - return not get_sync_item_from_server(device, sync_item) +def is_sync_item_missing(sync_device, sync_item): + return not get_sync_item_from_server(sync_device, sync_item) def test_current_device_got_sync_target(clear_sync_device): @@ -38,7 +38,7 @@ def test_add_movie_to_sync(clear_sync_device, movie): get_sync_item_from_server, delay=0.5, timeout=3, - device=clear_sync_device, + sync_device=clear_sync_device, sync_item=new_item, ) media_list = utils.wait_until( @@ -55,7 +55,7 @@ def test_delete_sync_item(clear_sync_device, movie): get_sync_item_from_server, delay=0.5, timeout=3, - device=clear_sync_device, + sync_device=clear_sync_device, sync_item=new_item, ) sync_items = clear_sync_device.syncItems() @@ -65,7 +65,7 @@ def test_delete_sync_item(clear_sync_device, movie): is_sync_item_missing, delay=0.5, timeout=3, - device=clear_sync_device, + sync_device=clear_sync_device, sync_item=new_item_in_myplex, ) @@ -77,7 +77,7 @@ def test_add_show_to_sync(clear_sync_device, show): get_sync_item_from_server, delay=0.5, timeout=3, - device=clear_sync_device, + sync_device=clear_sync_device, sync_item=new_item, ) episodes = show.episodes() @@ -96,7 +96,7 @@ def test_add_season_to_sync(clear_sync_device, show): get_sync_item_from_server, delay=0.5, timeout=3, - device=clear_sync_device, + sync_device=clear_sync_device, sync_item=new_item, ) episodes = season.episodes() @@ -114,7 +114,7 @@ def test_add_episode_to_sync(clear_sync_device, episode): get_sync_item_from_server, delay=0.5, timeout=3, - device=clear_sync_device, + sync_device=clear_sync_device, sync_item=new_item, ) media_list = utils.wait_until( @@ -134,7 +134,7 @@ def test_limited_watched(clear_sync_device, show): get_sync_item_from_server, delay=0.5, timeout=3, - device=clear_sync_device, + sync_device=clear_sync_device, sync_item=new_item, ) episodes = show.episodes()[:5] @@ -162,7 +162,7 @@ def test_limited_unwatched(clear_sync_device, show): get_sync_item_from_server, delay=0.5, timeout=3, - device=clear_sync_device, + sync_device=clear_sync_device, sync_item=new_item, ) episodes = show.episodes(viewCount=0)[:5] @@ -191,7 +191,7 @@ def test_unlimited_and_watched(clear_sync_device, show): get_sync_item_from_server, delay=0.5, timeout=3, - device=clear_sync_device, + sync_device=clear_sync_device, sync_item=new_item, ) episodes = show.episodes() @@ -220,7 +220,7 @@ def test_unlimited_and_unwatched(clear_sync_device, show): get_sync_item_from_server, delay=0.5, timeout=3, - device=clear_sync_device, + sync_device=clear_sync_device, sync_item=new_item, ) episodes = show.episodes(viewCount=0) @@ -246,7 +246,7 @@ def test_add_music_artist_to_sync(clear_sync_device, artist): get_sync_item_from_server, delay=0.5, timeout=3, - device=clear_sync_device, + sync_device=clear_sync_device, sync_item=new_item, ) tracks = artist.tracks() @@ -264,7 +264,7 @@ def test_add_music_album_to_sync(clear_sync_device, album): get_sync_item_from_server, delay=0.5, timeout=3, - device=clear_sync_device, + sync_device=clear_sync_device, sync_item=new_item, ) tracks = album.tracks() @@ -282,7 +282,7 @@ def test_add_music_track_to_sync(clear_sync_device, track): get_sync_item_from_server, delay=0.5, timeout=3, - device=clear_sync_device, + sync_device=clear_sync_device, sync_item=new_item, ) media_list = utils.wait_until( @@ -300,7 +300,7 @@ def test_add_photo_to_sync(clear_sync_device, photoalbum): get_sync_item_from_server, delay=0.5, timeout=3, - device=clear_sync_device, + sync_device=clear_sync_device, sync_item=new_item, ) media_list = utils.wait_until( @@ -317,7 +317,7 @@ def test_sync_entire_library_movies(clear_sync_device, movies): get_sync_item_from_server, delay=0.5, timeout=3, - device=clear_sync_device, + sync_device=clear_sync_device, sync_item=new_item, ) section_content = movies.all() @@ -335,7 +335,7 @@ def test_sync_entire_library_tvshows(clear_sync_device, tvshows): get_sync_item_from_server, delay=0.5, timeout=3, - device=clear_sync_device, + sync_device=clear_sync_device, sync_item=new_item, ) section_content = tvshows.searchEpisodes() @@ -353,7 +353,7 @@ def test_sync_entire_library_music(clear_sync_device, music): get_sync_item_from_server, delay=0.5, timeout=3, - device=clear_sync_device, + sync_device=clear_sync_device, sync_item=new_item, ) section_content = music.searchTracks() @@ -371,7 +371,7 @@ def test_sync_entire_library_photos(clear_sync_device, photos): get_sync_item_from_server, delay=0.5, timeout=3, - device=clear_sync_device, + sync_device=clear_sync_device, sync_item=new_item, ) # It's not that easy, to just get all the photos within the library, so let`s query for photos with device!=0x0 @@ -394,7 +394,7 @@ def test_playlist_movie_sync(plex, clear_sync_device, movies): get_sync_item_from_server, delay=0.5, timeout=3, - device=clear_sync_device, + sync_device=clear_sync_device, sync_item=new_item, ) media_list = utils.wait_until( @@ -416,7 +416,7 @@ def test_playlist_tvshow_sync(plex, clear_sync_device, show): get_sync_item_from_server, delay=0.5, timeout=3, - device=clear_sync_device, + sync_device=clear_sync_device, sync_item=new_item, ) media_list = utils.wait_until( @@ -438,7 +438,7 @@ def test_playlist_mixed_sync(plex, clear_sync_device, movie, episode): get_sync_item_from_server, delay=0.5, timeout=3, - device=clear_sync_device, + sync_device=clear_sync_device, sync_item=new_item, ) media_list = utils.wait_until( @@ -460,7 +460,7 @@ def test_playlist_music_sync(plex, clear_sync_device, artist): get_sync_item_from_server, delay=0.5, timeout=3, - device=clear_sync_device, + sync_device=clear_sync_device, sync_item=new_item, ) media_list = utils.wait_until( @@ -482,7 +482,7 @@ def test_playlist_photos_sync(plex, clear_sync_device, photoalbum): get_sync_item_from_server, delay=0.5, timeout=3, - device=clear_sync_device, + sync_device=clear_sync_device, sync_item=new_item, ) media_list = utils.wait_until( diff --git a/tools/plex-teardowntest.py b/tools/plex-teardowntest.py index a1e383f95..f852ec859 100755 --- a/tools/plex-teardowntest.py +++ b/tools/plex-teardowntest.py @@ -11,11 +11,21 @@ if __name__ == '__main__': myplex = MyPlexAccount() plex = PlexServer(token=myplex.authenticationToken) + + # Remove the test server for device in plex.myPlexAccount().devices(): if device.clientIdentifier == plex.machineIdentifier: print('Removing device "%s", with id "%s"' % (device.name, device. clientIdentifier)) device.delete() + # Remove the test sync client + sync_client_identifier = 'test-sync-client-%s' % X_PLEX_IDENTIFIER + for device in plex.myPlexAccount().devices(): + if device.clientIdentifier == sync_client_identifier: + print('Removing device "%s", with id "%s"' % (device.name, device. clientIdentifier)) + device.delete() + break + # If we suddenly remove the client first we wouldn't be able to authenticate to delete the server for device in plex.myPlexAccount().devices(): if device.clientIdentifier == X_PLEX_IDENTIFIER: