diff --git a/.coveragerc b/.coveragerc index c6341b7aa..e75e85e79 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1,3 +1,8 @@ +[run] +omit = */site-packages/plexapi/* + + + [report] exclude_lines = pragma: no cover diff --git a/.flake8 b/.flake8 new file mode 100644 index 000000000..9c6d41247 --- /dev/null +++ b/.flake8 @@ -0,0 +1,16 @@ +# Flake8 configuration +# Copy or symlink this file to ~/.flake8 +# -------------------------------------- +# E128: continuation line under-indented for visual indent +# E701: multiple statements on one line (colon) +# E702: multiple statements on one line (semicolon) +# E731: do not assign a lambda expression, use a def +# W293: blank line contains whitespace +# W503: line break before binary operator +# W605: invalid escape sequence +[flake8] +ignore=E128,E701,E702,E731,W293,W503,W605 +exclude=compat.py +max-complexity = -1 +max-line-length = 125 +show-source = True diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 000000000..4850cb9e2 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,27 @@ +--- +name: bug_report +about: Create a report to help us improve +title: '' +labels: '' +assignees: '' + +--- + +**Describe the issue** +A clear and concise description of what the issue is. + +**Code snipppets** +Add code snippet to help explain your problem. + - please use markdown [Code and Syntax Highlighting](https://github.com/adam-p/markdown-here/wiki/Markdown-Cheatsheet#code-and-syntax-highlighting) for code snippet + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Enviroment (please complete the following information):** + - OS: [e.g. Linux, Windows, Docker] + - Plex version [eg. version 1.19.3.2852] + - Python Version [e.g. 2.7.15, 3.6] + - PlexAPI version [e.g. 3.6.0] + +**Additional context** +Add any other context about the issue here. diff --git a/.github/PULL_REQUEST_TEMPLATE/pull_request_template.md b/.github/PULL_REQUEST_TEMPLATE/pull_request_template.md new file mode 100644 index 000000000..af7bd96df --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE/pull_request_template.md @@ -0,0 +1,23 @@ +## Description + +Please include a summary of the change and which issue is fixed. + +Fixes # (issue) + +## Type of change + +Please delete options that are not relevant. + +- [ ] Bug fix (non-breaking change which fixes an issue) +- [ ] New feature (non-breaking change which adds functionality) +- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) +- [ ] This change requires a documentation update + + +## Checklist: + +- [ ] My code follows the style guidelines of this project +- [ ] I have performed a self-review of my own code +- [ ] I have commented my code, particularly in hard-to-understand areas +- [ ] I have added or updated the docstring for new or existing methods +- [ ] I have added tests when applicable diff --git a/.gitignore b/.gitignore index 599bb3b24..39439fb46 100644 --- a/.gitignore +++ b/.gitignore @@ -18,8 +18,10 @@ config.ini ipython_config.py dist docs/_build/ +docs/src/ htmlcov include/ lib/ pip-selfcheck.json -pyvenv.cfg \ No newline at end of file +pyvenv.cfg +MANIFEST diff --git a/.readthedocs.yml b/.readthedocs.yml new file mode 100644 index 000000000..5ab142a3c --- /dev/null +++ b/.readthedocs.yml @@ -0,0 +1,21 @@ +# .readthedocs.yml + +version: 2 + +build: + image: latest + +sphinx: + configuration: docs/conf.py + +formats: all + +python: + version: 3.7 + install: + - requirements: requirements_dev.txt + - method: pip + path: . + + system_packages: true + diff --git a/.travis.yml b/.travis.yml index 23eb20206..daf17fa83 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,16 +2,13 @@ language: python stages: - test - - name: deploy - if: tag = present + - deploy sudo: required services: - docker python: - - 2.7 - - 3.4 - 3.6 env: @@ -23,8 +20,10 @@ env: before_install: - pip install --upgrade pip - pip install --upgrade setuptools - - pip install --upgrade pytest pytest-cov coveralls install: + - pip install . + - python -c "import plexapi; print('installation ok')" + - pip install --upgrade pytest pytest-cov coveralls - pip install -r requirements_dev.txt - '[ -z "${PLEXAPI_AUTH_MYPLEX_USERNAME}" ] && PYTHONPATH="$PWD:$PYTHONPATH" python -u tools/plex-bootstraptest.py --destination plex --advertise-ip=127.0.0.1 --bootstrap-timeout 540 --docker-tag $PLEX_CONTAINER_TAG --unclaimed || @@ -43,6 +42,10 @@ after_success: after_script: - '[ -z "${PLEXAPI_AUTH_MYPLEX_USERNAME}" ] || PYTHONPATH="$PWD:$PYTHONPATH" python -u tools/plex-teardowntest.py' + +notifications: + webhooks: https://coveralls.io/webhook + jobs: include: - python: 3.6 @@ -56,7 +59,7 @@ jobs: - stage: test python: 3.6 env: - - PLEX_CONTAINER_TAG=1.10.1.4602-f54242b6b + - PLEX_CONTAINER_TAG=latest - TEST_ACCOUNT_ONCE=1 - stage: test python: 3.6 @@ -73,8 +76,8 @@ jobs: - PLEX_CONTAINER_TAG=latest deploy: provider: pypi - user: mjs7231 + user: hellowlol password: - secure: UhuEN9GAp9zMEXdVTxSrbhfYf4HjTcj47l093Qh1HYKmZACxJM/+JkQCm7+oHPJpo7YDLk2we9oEsQ41maZBr9WgZI1lwR6m590M12vPhPI7NCVzINxJqebc0uZhCFsAFFKA3kzpRQbDfsBUG4yL/AzeMcvJMgIg3m07KRVhBywnnRhQ77trbBI0Io5MBzfW9PYDeGJqlNDBM7SbB4tK0udGZQT9wmFwvIoJODPDnM15Ry4vpkVNww/vVgyHklmnYlPzQgvhSMOXk0+MWlYtaKmu6uuLAiRccT1Fsmi1POKuFEq8S0Z7w4LmwxCVRaCvsZdNW5eXWgPDhZXNcLrKMwjgJt9Vj3VcD+NCywux/C1hTq7tecBocA13kzbgg4fd2sATOjQT5iaRPGrDtKm8e00hxr125n0StDxXdYGl2W5sH0LCkZE6Vq1GjXYjKFXZeTk3Fzav/3N8IxHBX3CliJB/vbloJ2mpz1kXL4UTORl9pghPyGOOq2yJPYSSWly/RsAD7UDrL1/lezaPSJGKbZJ0CMyfA83kd82/hgZflOuBuTcPHCZSU3zMCs0fsImZZxr6Qm1tbff+iyNS/ufoYgeVfsWhlEl9FoLv1g4HG6oA+uDHz+jKz9uSRHcGqD6P4JJK+H+yy0PeYfo7b6eSqFxgt8q8QfifUaCrVoCiY+c= + secure: cwMf16s+PxIUjt6/pKE7KeQPsWg4Atf9JvipkLTjx1VrVBilSjrZ7fkSDDjglkz4sCynw5/fzQRkBWqLdyqPigwKICYbpc6QWwvR+WWQLfe04zl+d4AmDljN2rtNkThlpH2qKNaFX7Up3AAcTf+GtQ7weAAyjMyJaiWBTFcgc2eBMDgEkS3bhiF4qdbfdAYbHVurJwCWXfHjkIiBJxDHA15cQRhJpkqQGIYzAst6ZwEr/Aw9FwqfC3lvFM3uIQ7sfFa/UdIesQZ50IT//roI1bvTg2T4gAMRYMs09jFm1E5mnPn4qxvK2hzsiNNesw3wSXhCehJFym8cO5jX+EYnfNJuWJtappIZeJGKldVC2g2v3PNWhmqbbKnyc446rkPtjVQqrSSUjPNOhTG61n872JVnizopo8ISDAtceSoC/mySItzjRQnRDrelkhHdV33WSyOsJKTw0H2LzjZDQRxxTqABmmCwzn7h+ycQ2Xk2INu9gt0hgIOco6Lt6VeoDMXhD6wAGDAaD7XBQwD/JSbGZns3VIgoLMxyHda9S6UeDmwwoAWhHM0mbzt0L618R+7CK0E+3vQ2k2Ee23tKIOq7DvZMpmLLjUE3wlckoKCPDrgP7Tuf5nUoz6kWv3Hxsb0wZCuVO0npSCFx+JOSQ6/7ONU4eh3hvjnnb6vqAgKqQ80= on: tags: true diff --git a/README.rst b/README.rst index 43dea560e..9237fca09 100644 --- a/README.rst +++ b/README.rst @@ -6,6 +6,12 @@ Python-PlexAPI :target: https://travis-ci.org/pkkid/python-plexapi .. image:: https://coveralls.io/repos/github/pkkid/python-plexapi/badge.svg?branch=master :target: https://coveralls.io/github/pkkid/python-plexapi?branch=master +.. image:: https://img.shields.io/github/tag/pkkid/python-plexapi.svg?label=github+release + :target: https://github.com/pkkid/python-plexapi/releases +.. image:: https://badge.fury.io/py/PlexAPI.svg + :target: https://badge.fury.io/py/PlexAPI +.. image:: https://img.shields.io/github/last-commit/pkkid/python-plexapi.svg + :target: https://img.shields.io/github/last-commit/pkkid/python-plexapi.svg Overview @@ -15,7 +21,7 @@ Plex Web Client. A few of the many features we currently support are: * Navigate local or remote shared libraries. * Perform library actions such as scan, analyze, empty trash. -* Remote control and play media on connected clients. +* Remote control and play media on connected clients, including `Controlling Sonos speakers`_ * Listen in on all Plex Server notifications. @@ -118,48 +124,76 @@ Usage Examples .. code-block:: python - # Example 8: Get a URL to stream a movie or show in another client - die_hard = plex.library.section('Movies').get('Elephants Dream') - print('Run running the following command to play in VLC:') - print('vlc "%s"' % die_hard.getStreamURL(videoResolution='800x600')) + # Example 8: Get audio/video/all playlists + for playlist in plex.playlists(): + print(playlist.title) .. code-block:: python - # Example 9: Get audio/video/all playlists - for playlist in plex.playlists(): - print(playlist.title) + # Example 9: Rate the 100 four stars. + plex.library.section('TV Shows').get('The 100').rate(8.0) + + +Controlling Sonos speakers +-------------------------- + +To control Sonos speakers directly using Plex APIs, the following requirements must be met: + +1. Active Plex Pass subscription +2. Sonos account linked to Plex account +3. Plex remote access enabled + +Due to the design of Sonos music services, the API calls to control Sonos speakers route through https://sonos.plex.tv +and back via the Plex server's remote access. Actual media playback is local unless networking restrictions prevent the +Sonos speakers from connecting to the Plex server directly. + +.. code-block:: python + + from plexapi.myplex import MyPlexAccount + from plexapi.server import PlexServer + + baseurl = 'http://plexserver:32400' + token = '2ffLuB84dqLswk9skLos' + + account = MyPlexAccount(token) + server = PlexServer(baseurl, token) + + # List available speakers/groups + for speaker in account.sonos_speakers(): + print(speaker.title) + + # Obtain PlexSonosPlayer instance + speaker = account.sonos_speaker("Kitchen") + + album = server.library.section('Music').get('Stevie Wonder').album('Innervisions') + + # Speaker control examples + speaker.playMedia(album) + speaker.pause() + speaker.setVolume(10) + speaker.skipNext() Running tests over PlexAPI -------------------------- -In order to test the PlexAPI library you have to prepare a Plex Server instance with following libraries: - -1. Movies section (agent `com.plexapp.agents.imdb`) containing both movies: - * Sintel - https://durian.blender.org/ - * Elephants Dream - https://orange.blender.org/ - * Sita Sings the Blues - http://www.sitasingstheblues.com/ - * Big Buck Bunny - https://peach.blender.org/ -2. TV Show section (agent `com.plexapp.agents.thetvdb`) containing the shows: - * Game of Thrones (Season 1 and 2) - * The 100 (Seasons 1 and 2) - * (or symlink the above movies with proper names) -3. Music section (agent `com.plexapp.agents.lastfm`) containing the albums: - * Infinite State - Unmastered Impulses - https://github.com/kennethreitz/unmastered-impulses - * Broke For Free - Layers - http://freemusicarchive.org/music/broke_for_free/Layers/ -4. A Photos section (any agent) containing the photoalbums (photoalbum is just a folder on your disk): - * `Cats` - * Within `Cats` album you need to place 3 photos (cute cat photos, of course) - * Within `Cats` album you should place 3 more photoalbums (one of them should be named `Cats in bed`, - names of others doesn't matter) - * Within `Cats in bed` you need to place 7 photos - * Within other 2 albums you should place 1 photo in each - -Instead of manual creation of the library you could use a script `tools/plex-boostraptest.py` with appropriate +Use: + +.. code-block:: bash + + tools/plex-boostraptest.py + +with appropriate arguments and add this new server to a shared user which username is defined in environment veriable `SHARED_USERNAME`. It uses `official docker image`_ to create a proper instance. +For skipping the docker and reuse a existing server use + +.. code-block:: bash + + python plex-boostraptest.py --no-docker -username USERNAME --password PASSWORD --server-name NAME-OF-YOUR-SEVER + Also in order to run most of the tests you have to provide some environment variables: * `PLEXAPI_AUTH_SERVER_BASEURL` containing an URL to your Plex instance, e.g. `http://127.0.0.1:32400` (without trailing diff --git a/docs/conf.py b/docs/conf.py index e84a2246b..8ea0894fa 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -60,8 +60,8 @@ def new_resolve_xref(*args): # You can specify multiple suffix as a list of string: # source_suffix = ['.rst', '.md'] # source_suffix = '.rst' -source_parsers = {'.md': CommonMarkParser} -source_suffix = ['.rst', '.md'] +# source_parsers = {'.md': CommonMarkParser} # deprecated +# source_suffix = ['.rst', '.md'] # deprecated # The encoding of source files. # source_encoding = 'utf-8-sig' diff --git a/docs/modules/gdm.rst b/docs/modules/gdm.rst new file mode 100644 index 000000000..5258fa884 --- /dev/null +++ b/docs/modules/gdm.rst @@ -0,0 +1,7 @@ +.. include:: ../global.rst + +Gdm :modname:`plexapi.gdm` +-------------------------------- +.. automodule:: plexapi.gdm + :members: + :show-inheritance: \ No newline at end of file diff --git a/docs/modules/sonos.rst b/docs/modules/sonos.rst new file mode 100644 index 000000000..53335d852 --- /dev/null +++ b/docs/modules/sonos.rst @@ -0,0 +1,7 @@ +.. include:: ../global.rst + +Sonos :modname:`plexapi.sonos` +-------------------------------- +.. automodule:: plexapi.sonos + :members: + :show-inheritance: \ No newline at end of file diff --git a/docs/toc.rst b/docs/toc.rst index 0820c6bfd..b75f850ed 100644 --- a/docs/toc.rst +++ b/docs/toc.rst @@ -17,6 +17,7 @@ modules/client modules/config modules/exceptions + modules/gdm modules/library modules/media modules/myplex @@ -25,6 +26,8 @@ modules/playqueue modules/server modules/settings + modules/sonos modules/sync modules/utils modules/video + diff --git a/package.json b/package.json new file mode 100644 index 000000000..9257a6605 --- /dev/null +++ b/package.json @@ -0,0 +1,5 @@ +{ + "scripts": { + "pypi": "python setup.py sdist upload" + } +} diff --git a/plexapi/__init__.py b/plexapi/__init__.py index 355b046ff..09f1953fb 100644 --- a/plexapi/__init__.py +++ b/plexapi/__init__.py @@ -3,9 +3,10 @@ import os from logging.handlers import RotatingFileHandler from platform import uname +from uuid import getnode + from plexapi.config import PlexConfig, reset_base_headers from plexapi.utils import SecretsFilter -from uuid import getnode # Load User Defined Config DEFAULT_CONFIG_PATH = os.path.expanduser('~/.config/plexapi/config.ini') @@ -14,7 +15,7 @@ # PlexAPI Settings PROJECT = 'PlexAPI' -VERSION = '3.1.0' +VERSION = '4.0.0' TIMEOUT = CONFIG.get('plexapi.timeout', 30, int) X_PLEX_CONTAINER_SIZE = CONFIG.get('plexapi.container_size', 100, int) X_PLEX_ENABLE_FAST_CONNECT = CONFIG.get('plexapi.enable_fast_connect', False, bool) diff --git a/plexapi/alert.py b/plexapi/alert.py index 2a19c6d88..1a5469abe 100644 --- a/plexapi/alert.py +++ b/plexapi/alert.py @@ -1,14 +1,14 @@ # -*- coding: utf-8 -*- import json import threading -import websocket + from plexapi import log class AlertListener(threading.Thread): - """ Creates a websocket connection to the PlexServer to optionally recieve alert notifications. + """ Creates a websocket connection to the PlexServer to optionally receive alert notifications. These often include messages from Plex about media scans as well as updates to currently running - Transcode Sessions. This class implements threading.Thread, therfore to start monitoring + Transcode Sessions. This class implements threading.Thread, therefore to start monitoring alerts you must call .start() on the object once it's created. When calling `PlexServer.startAlertListener()`, the thread will be started for you. @@ -26,9 +26,9 @@ class AlertListener(threading.Thread): Parameters: server (:class:`~plexapi.server.PlexServer`): PlexServer this listener is connected to. - callback (func): Callback function to call on recieved messages. The callback function + callback (func): Callback function to call on received messages. The callback function will be sent a single argument 'data' which will contain a dictionary of data - recieved from the server. :samp:`def my_callback(data): ...` + received from the server. :samp:`def my_callback(data): ...` """ key = '/:/websockets/notifications' @@ -40,6 +40,11 @@ def __init__(self, server, callback=None): self._ws = None def run(self): + try: + import websocket + except ImportError: + log.warning("Can't use the AlertListener without websocket") + return # create the websocket connection url = self._server.url(self.key, includeToken=True).replace('http', 'ws') log.info('Starting AlertListener: %s', url) @@ -48,15 +53,21 @@ def run(self): self._ws.run_forever() def stop(self): - """ Stop the AlertListener thread. Once the notifier is stopped, it cannot be diractly + """ Stop the AlertListener thread. Once the notifier is stopped, it cannot be directly started again. You must call :func:`plexapi.server.PlexServer.startAlertListener()` from a PlexServer instance. """ log.info('Stopping AlertListener.') self._ws.close() - def _onMessage(self, ws, message): - """ Called when websocket message is recieved. """ + def _onMessage(self, *args): + """ Called when websocket message is received. + In earlier releases, websocket-client returned a tuple of two parameters: a websocket.app.WebSocketApp + object and the message as a STR. Current releases appear to only return the message. + We are assuming the last argument in the tuple is the message. + This is to support compatibility with current and previous releases of websocket-client. + """ + message = args[-1] try: data = json.loads(message)['NotificationContainer'] log.debug('Alert: %s %s %s', *data) @@ -65,6 +76,12 @@ def _onMessage(self, ws, message): except Exception as err: # pragma: no cover log.error('AlertListener Msg Error: %s', err) - def _onError(self, ws, err): # pragma: no cover - """ Called when websocket error is recieved. """ + def _onError(self, *args): # pragma: no cover + """ Called when websocket error is received. + In earlier releases, websocket-client returned a tuple of two parameters: a websocket.app.WebSocketApp + object and the error. Current releases appear to only return the error. + We are assuming the last argument in the tuple is the message. + This is to support compatibility with current and previous releases of websocket-client. + """ + err = args[-1] log.error('AlertListener Error: %s' % err) diff --git a/plexapi/audio.py b/plexapi/audio.py index d6826831f..b48dc8c53 100644 --- a/plexapi/audio.py +++ b/plexapi/audio.py @@ -1,7 +1,8 @@ # -*- coding: utf-8 -*- +from urllib.parse import quote_plus + from plexapi import media, utils from plexapi.base import Playable, PlexPartialObject -from plexapi.compat import quote_plus class Audio(PlexPartialObject): @@ -284,15 +285,15 @@ class Track(Audio, Playable): art (str): Track artwork (/library/metadata//art/) chapterSource (TYPE): Unknown duration (int): Length of this album in seconds. - grandparentArt (str): Artist artowrk. - grandparentKey (str): Artist API URL. - grandparentRatingKey (str): Unique key identifying artist. - grandparentThumb (str): URL to artist thumbnail image. - grandparentTitle (str): Name of the artist for this track. + grandparentArt (str): Album artist artwork. + grandparentKey (str): Album artist API URL. + grandparentRatingKey (str): Unique key identifying album artist. + grandparentThumb (str): URL to album artist thumbnail image. + grandparentTitle (str): Name of the album artist for this track. guid (str): Unknown (unique ID). media (list): List of :class:`~plexapi.media.Media` objects for this track. moods (list): List of :class:`~plexapi.media.Mood` objects for this track. - originalTitle (str): Original track title (if translated). + originalTitle (str): Track artist. parentIndex (int): Album index. parentKey (str): Album API URL. parentRatingKey (int): Unique key identifying album. diff --git a/plexapi/base.py b/plexapi/base.py index 24fc75be0..101a0b435 100644 --- a/plexapi/base.py +++ b/plexapi/base.py @@ -1,11 +1,12 @@ # -*- coding: utf-8 -*- import re +from urllib.parse import quote_plus, urlencode from plexapi import log, utils -from plexapi.compat import quote_plus, urlencode from plexapi.exceptions import BadRequest, NotFound, UnknownType, Unsupported from plexapi.utils import tag_helper +DONT_RELOAD_FOR_KEYS = ['key', 'session'] OPERATORS = { 'exact': lambda v, q: v == q, 'iexact': lambda v, q: v.lower() == q.lower(), @@ -131,6 +132,8 @@ def fetchItem(self, ekey, cls=None, **kwargs): * __regex: Value matches the specified regular expression. * __startswith: Value starts with specified arg. """ + if ekey is None: + raise BadRequest('ekey was not provided') if isinstance(ekey, int): ekey = '/library/metadata/%s' % ekey for elem in self._server.query(ekey): @@ -139,13 +142,27 @@ def fetchItem(self, ekey, cls=None, **kwargs): clsname = cls.__name__ if cls else 'None' raise NotFound('Unable to find elem: cls=%s, attrs=%s' % (clsname, kwargs)) - def fetchItems(self, ekey, cls=None, **kwargs): + def fetchItems(self, ekey, cls=None, container_start=None, container_size=None, **kwargs): """ Load the specified key to find and build all items with the specified tag and attrs. See :func:`~plexapi.base.PlexObject.fetchItem` for more details on how this is used. + + Parameters: + container_start (None, int): offset to get a subset of the data + container_size (None, int): How many items in data + """ - data = self._server.query(ekey) + url_kw = {} + if container_start is not None: + url_kw["X-Plex-Container-Start"] = container_start + if container_size is not None: + url_kw["X-Plex-Container-Size"] = container_size + + if ekey is None: + raise BadRequest('ekey was not provided') + data = self._server.query(ekey, params=url_kw) items = self.findItems(data, cls, ekey, **kwargs) + librarySectionID = data.attrib.get('librarySectionID') if librarySectionID: for item in items: @@ -278,7 +295,8 @@ def __getattribute__(self, attr): # Dragons inside.. :-/ value = super(PlexPartialObject, self).__getattribute__(attr) # Check a few cases where we dont want to reload - if attr == 'key' or attr.startswith('_'): return value + if attr in DONT_RELOAD_FOR_KEYS: return value + if attr.startswith('_'): return value if value not in (None, []): return value if self.isFullObject(): return value # Log the reload. @@ -306,6 +324,8 @@ def analyze(self): Playing screen to show a graphical representation of where playback is. Video preview thumbnails creation is a CPU-intensive process akin to transcoding the file. + * Generate intro video markers: Detects show intros, exposing the + 'Skip Intro' button in clients. """ key = '/%s/analyze' % self.key.lstrip('/') self._server.query(key, method=self._server._session.put) @@ -419,6 +439,141 @@ def delete(self): 'havnt allowed items to be deleted' % self.key) raise + def history(self, maxresults=9999999, mindate=None): + """ Get Play History for a media item. + Parameters: + maxresults (int): Only return the specified number of results (optional). + mindate (datetime): Min datetime to return results from. + """ + return self._server.history(maxresults=maxresults, mindate=mindate, ratingKey=self.ratingKey) + + def posters(self): + """ Returns list of available poster objects. :class:`~plexapi.media.Poster`. """ + + return self.fetchItems('%s/posters' % self.key) + + def uploadPoster(self, url=None, filepath=None): + """ Upload poster from url or filepath. :class:`~plexapi.media.Poster` to :class:`~plexapi.video.Video`. """ + if url: + key = '%s/posters?url=%s' % (self.key, quote_plus(url)) + self._server.query(key, method=self._server._session.post) + elif filepath: + key = '%s/posters?' % self.key + data = open(filepath, 'rb').read() + self._server.query(key, method=self._server._session.post, data=data) + + def setPoster(self, poster): + """ Set . :class:`~plexapi.media.Poster` to :class:`~plexapi.video.Video` """ + poster.select() + + def arts(self): + """ Returns list of available art objects. :class:`~plexapi.media.Poster`. """ + + return self.fetchItems('%s/arts' % self.key) + + def uploadArt(self, url=None, filepath=None): + """ Upload art from url or filepath. :class:`~plexapi.media.Poster` to :class:`~plexapi.video.Video`. """ + if url: + key = '/library/metadata/%s/arts?url=%s' % (self.ratingKey, quote_plus(url)) + self._server.query(key, method=self._server._session.post) + elif filepath: + key = '/library/metadata/%s/arts?' % self.ratingKey + data = open(filepath, 'rb').read() + self._server.query(key, method=self._server._session.post, data=data) + + def setArt(self, art): + """ Set :class:`~plexapi.media.Poster` to :class:`~plexapi.video.Video` """ + art.select() + + def unmatch(self): + """ Unmatches metadata match from object. """ + key = '/library/metadata/%s/unmatch' % self.ratingKey + self._server.query(key, method=self._server._session.put) + + def matches(self, agent=None, title=None, year=None, language=None): + """ Return list of (:class:`~plexapi.media.SearchResult`) metadata matches. + + Parameters: + agent (str): Agent name to be used (imdb, thetvdb, themoviedb, etc.) + title (str): Title of item to search for + year (str): Year of item to search in + language (str) : Language of item to search in + + Examples: + 1. video.matches() + 2. video.matches(title="something", year=2020) + 3. video.matches(title="something") + 4. video.matches(year=2020) + 5. video.matches(title="something", year="") + 6. video.matches(title="", year=2020) + 7. video.matches(title="", year="") + + 1. The default behaviour in Plex Web = no params in plexapi + 2. Both title and year specified by user + 3. Year automatically filled in + 4. Title automatically filled in + 5. Explicitly searches for title with blank year + 6. Explicitly searches for blank title with year + 7. I don't know what the user is thinking... return the same result as 1 + + For 2 to 7, the agent and language is automatically filled in + """ + key = '/library/metadata/%s/matches' % self.ratingKey + params = {'manual': 1} + + if agent and not any([title, year, language]): + params['language'] = self.section().language + params['agent'] = utils.getAgentIdentifier(self.section(), agent) + else: + if any(x is not None for x in [agent, title, year, language]): + if title is None: + params['title'] = self.title + else: + params['title'] = title + + if year is None: + params['year'] = self.year + else: + params['year'] = year + + params['language'] = language or self.section().language + + if agent is None: + params['agent'] = self.section().agent + else: + params['agent'] = utils.getAgentIdentifier(self.section(), agent) + + key = key + '?' + urlencode(params) + data = self._server.query(key, method=self._server._session.get) + return self.findItems(data, initpath=key) + + def fixMatch(self, searchResult=None, auto=False, agent=None): + """ Use match result to update show metadata. + + Parameters: + auto (bool): True uses first match from matches + False allows user to provide the match + searchResult (:class:`~plexapi.media.SearchResult`): Search result from + ~plexapi.base.matches() + agent (str): Agent name to be used (imdb, thetvdb, themoviedb, etc.) + """ + key = '/library/metadata/%s/match' % self.ratingKey + if auto: + autoMatch = self.matches(agent=agent) + if autoMatch: + searchResult = autoMatch[0] + else: + raise NotFound('No matches found using this agent: (%s:%s)' % (agent, autoMatch)) + elif not searchResult: + raise NotFound('fixMatch() requires either auto=True or ' + 'searchResult=:class:`~plexapi.media.SearchResult`.') + + params = {'guid': searchResult.guid, + 'name': searchResult.name} + + data = key + '?' + urlencode(params) + self._server.query(data, method=self._server._session.put) + # The photo tag cant be built atm. TODO # def arts(self): # part = '%s/arts' % self.key @@ -507,6 +662,14 @@ def split(self): key = '%s/split' % self.key return self._server.query(key, method=self._server._session.put) + def merge(self, ratingKeys): + """Merge duplicate items.""" + if not isinstance(ratingKeys, list): + ratingKeys = str(ratingKeys).split(",") + + key = '%s/merge?ids=%s' % (self.key, ','.join(ratingKeys)) + return self._server.query(key, method=self._server._session.put) + def unmatch(self): """Unmatch a media file.""" key = '%s/unmatch' % self.key @@ -571,7 +734,7 @@ def updateProgress(self, time, state='stopped'): time, state) self._server.query(key) self.reload() - + def updateTimeline(self, time, state='stopped', duration=None): """ Set the timeline progress for this video. diff --git a/plexapi/client.py b/plexapi/client.py index 3695b57be..9e720d62c 100644 --- a/plexapi/client.py +++ b/plexapi/client.py @@ -1,15 +1,13 @@ # -*- coding: utf-8 -*- import time -import requests +from xml.etree import ElementTree -from requests.status_codes import _codes as codes -from plexapi import BASE_HEADERS, CONFIG, TIMEOUT -from plexapi import log, logfilter, utils +import requests +from plexapi import BASE_HEADERS, CONFIG, TIMEOUT, log, logfilter, utils from plexapi.base import PlexObject -from plexapi.compat import ElementTree -from plexapi.exceptions import BadRequest, Unsupported +from plexapi.exceptions import BadRequest, NotFound, Unauthorized, Unsupported from plexapi.playqueue import PlayQueue - +from requests.status_codes import _codes as codes DEFAULT_MTYPE = 'video' @@ -71,7 +69,7 @@ def __init__(self, server=None, data=None, initpath=None, baseurl=None, self._proxyThroughServer = False self._commandId = 0 self._last_call = 0 - if not any([data, initpath, baseurl, token]): + if not any([data is not None, initpath, baseurl, token]): self._baseurl = CONFIG.get('auth.client_baseurl', 'http://localhost:32433') self._token = logfilter.add_secret(CONFIG.get('auth.client_token')) if connect and self._baseurl: @@ -159,11 +157,16 @@ def query(self, path, method=None, headers=None, timeout=None, **kwargs): log.debug('%s %s', method.__name__.upper(), url) headers = self._headers(**headers or {}) response = method(url, headers=headers, timeout=timeout, **kwargs) - if response.status_code not in (200, 201): + if response.status_code not in (200, 201, 204): codename = codes.get(response.status_code)[0] errtext = response.text.replace('\n', ' ') - log.warning('BadRequest (%s) %s %s; %s' % (response.status_code, codename, response.url, errtext)) - raise BadRequest('(%s) %s; %s %s' % (response.status_code, codename, response.url, errtext)) + message = '(%s) %s; %s %s' % (response.status_code, codename, response.url, errtext) + if response.status_code == 401: + raise Unauthorized(message) + elif response.status_code == 404: + raise NotFound(message) + else: + raise BadRequest(message) data = response.text.encode('utf8') return ElementTree.fromstring(data) if data.strip() else None @@ -187,24 +190,34 @@ def sendCommand(self, command, proxy=None, **params): log.debug('Client %s doesnt support %s controller.' 'What your trying might not work' % (self.title, controller)) + proxy = self._proxyThroughServer if proxy is None else proxy + query = self._server.query if proxy else self.query + # Workaround for ptp. See https://github.com/pkkid/python-plexapi/issues/244 t = time.time() - if t - self._last_call >= 80 and self.product in ('ptp', 'Plex Media Player'): - url = '/player/timeline/poll?wait=0&commandID=%s' % self._nextCommandId() - if proxy: - self._server.query(url, headers=headers) - else: - self.query(url, headers=headers) + if command == 'timeline/poll': self._last_call = t + elif t - self._last_call >= 80 and self.product in ('ptp', 'Plex Media Player'): + self._last_call = t + self.timeline(wait=0) params['commandID'] = self._nextCommandId() key = '/player/%s%s' % (command, utils.joinArgs(params)) - proxy = self._proxyThroughServer if proxy is None else proxy - - if proxy: - return self._server.query(key, headers=headers) - return self.query(key, headers=headers) + try: + return query(key, headers=headers) + except ElementTree.ParseError: + # Workaround for players which don't return valid XML on successful commands + # - Plexamp, Plex for Android: `b'OK'` + # - Plex for Samsung: `b''` + if self.product in ( + 'Plexamp', + 'Plex for Android (TV)', + 'Plex for Android (Mobile)', + 'Plex for Samsung', + ): + return + raise def url(self, key, includeToken=False): """ Build a URL string with proper token argument. Token will be appended to the URL @@ -294,6 +307,8 @@ def goToMedia(self, media, **params): 'address': server_url[1].strip('/'), 'port': server_url[-1], 'key': media.key, + 'protocol': server_url[0], + 'token': media._server.createToken() }, **params)) # ------------------- @@ -459,14 +474,17 @@ def playMedia(self, media, offset=0, **params): server_url = media._server._baseurl.split(':') server_port = server_url[-1].strip('/') - if self.product != 'OpenPHT': - try: - self.sendCommand('timeline/subscribe', port=server_port, protocol='http') - except: # noqa: E722 - # some clients dont need or like this and raises http 400. - # We want to include the exception in the log, - # but it might still work so we swallow it. - log.exception('%s failed to subscribe ' % self.title) + if hasattr(media, "playlistType"): + mediatype = media.playlistType + else: + if isinstance(media, PlayQueue): + mediatype = media.items[0].listType + else: + mediatype = media.listType + + # mediatype must be in ["video", "music", "photo"] + if mediatype == "audio": + mediatype = "music" playqueue = media if isinstance(media, PlayQueue) else self._server.createPlayQueue(media) self.sendCommand('playback/playMedia', **dict({ @@ -475,7 +493,8 @@ def playMedia(self, media, offset=0, **params): 'port': server_port, 'offset': offset, 'key': media.key, - 'token': media._server._token, + 'token': media._server.createToken(), + 'type': mediatype, 'containerKey': '/playQueues/%s?window=100&own=1' % playqueue.playQueueID, }, **params)) @@ -521,9 +540,9 @@ def setStreams(self, audioStreamID=None, subtitleStreamID=None, videoStreamID=No # ------------------- # Timeline Commands - def timeline(self): + def timeline(self, wait=1): """ Poll the current timeline and return the XML response. """ - return self.sendCommand('timeline/poll', wait=1) + return self.sendCommand('timeline/poll', wait=wait) def isPlayingMedia(self, includePaused=False): """ Returns True if any media is currently playing. @@ -532,7 +551,7 @@ def isPlayingMedia(self, includePaused=False): includePaused (bool): Set True to treat currently paused items as playing (optional; default True). """ - for mediatype in self.timeline(): + for mediatype in self.timeline(wait=0): if mediatype.get('state') == 'playing': return True if includePaused and mediatype.get('state') == 'paused': diff --git a/plexapi/compat.py b/plexapi/compat.py deleted file mode 100644 index 77409fbaa..000000000 --- a/plexapi/compat.py +++ /dev/null @@ -1,118 +0,0 @@ -# -*- coding: utf-8 -*- -# Python 2/3 compatability -# Always try Py3 first -import os -import sys -from sys import version_info - -ustr = str -if version_info < (3,): - ustr = unicode - -try: - string_type = basestring -except NameError: - string_type = str - -try: - from urllib.parse import urlencode -except ImportError: - from urllib import urlencode - -try: - from urllib.parse import quote -except ImportError: - from urllib import quote - -try: - from urllib.parse import quote_plus -except ImportError: - from urllib import quote_plus - -try: - from urllib.parse import unquote -except ImportError: - from urllib import unquote - -try: - from configparser import ConfigParser -except ImportError: - from ConfigParser import ConfigParser - -try: - from xml.etree import cElementTree as ElementTree -except ImportError: - from xml.etree import ElementTree - - -def makedirs(name, mode=0o777, exist_ok=False): - """ Mimicks os.makedirs() from Python 3. """ - try: - os.makedirs(name, mode) - except OSError: - if not os.path.isdir(name) or not exist_ok: - raise - - -def which(cmd, mode=os.F_OK | os.X_OK, path=None): - """Given a command, mode, and a PATH string, return the path which - conforms to the given mode on the PATH, or None if there is no such - file. - - `mode` defaults to os.F_OK | os.X_OK. `path` defaults to the result - of os.environ.get("PATH"), or can be overridden with a custom search - path. - - Copied from https://hg.python.org/cpython/file/default/Lib/shutil.py - """ - # Check that a given file can be accessed with the correct mode. - # Additionally check that `file` is not a directory, as on Windows - # directories pass the os.access check. - def _access_check(fn, mode): - return (os.path.exists(fn) and os.access(fn, mode) - and not os.path.isdir(fn)) - - # If we're given a path with a directory part, look it up directly rather - # than referring to PATH directories. This includes checking relative to the - # current directory, e.g. ./script - if os.path.dirname(cmd): - if _access_check(cmd, mode): - return cmd - return None - - if path is None: - path = os.environ.get("PATH", os.defpath) - if not path: - return None - path = path.split(os.pathsep) - - if sys.platform == "win32": - # The current directory takes precedence on Windows. - if not os.curdir in path: - path.insert(0, os.curdir) - - # PATHEXT is necessary to check on Windows. - pathext = os.environ.get("PATHEXT", "").split(os.pathsep) - # See if the given file matches any of the expected path extensions. - # This will allow us to short circuit when given "python.exe". - # If it does match, only test that one, otherwise we have to try - # others. - if any(cmd.lower().endswith(ext.lower()) for ext in pathext): - files = [cmd] - else: - files = [cmd + ext for ext in pathext] - else: - # On other platforms you don't have things like PATHEXT to tell you - # what file suffixes are executable, so just pass on cmd as-is. - files = [cmd] - - seen = set() - for dir in path: - normdir = os.path.normcase(dir) - if not normdir in seen: - seen.add(normdir) - for thefile in files: - name = os.path.join(dir, thefile) - if _access_check(name, mode): - return name - return None diff --git a/plexapi/config.py b/plexapi/config.py index 47eebd8bf..e78fa193d 100644 --- a/plexapi/config.py +++ b/plexapi/config.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- import os from collections import defaultdict -from plexapi.compat import ConfigParser +from configparser import ConfigParser class PlexConfig(ConfigParser): @@ -13,6 +13,7 @@ class PlexConfig(ConfigParser): Parameters: path (str): Path of the configuration file to load. """ + def __init__(self, path): ConfigParser.__init__(self) self.read(path) diff --git a/plexapi/exceptions.py b/plexapi/exceptions.py index 45da9f230..c269c38ea 100644 --- a/plexapi/exceptions.py +++ b/plexapi/exceptions.py @@ -26,6 +26,6 @@ class Unsupported(PlexApiException): pass -class Unauthorized(PlexApiException): - """ Invalid username or password. """ +class Unauthorized(BadRequest): + """ Invalid username/password or token. """ pass diff --git a/plexapi/gdm.py b/plexapi/gdm.py new file mode 100644 index 000000000..84c7acafc --- /dev/null +++ b/plexapi/gdm.py @@ -0,0 +1,148 @@ +""" +Support for discovery using GDM (Good Day Mate), multicast protocol by Plex. + +# Licensed Apache 2.0 +# From https://github.com/home-assistant/netdisco/netdisco/gdm.py + +Inspired by: + hippojay's plexGDM: https://github.com/hippojay/script.plexbmc.helper/resources/lib/plexgdm.py + iBaa's PlexConnect: https://github.com/iBaa/PlexConnect/PlexAPI.py +""" +import socket +import struct + + +class GDM: + """Base class to discover GDM services.""" + + def __init__(self): + self.entries = [] + self.last_scan = None + + def scan(self, scan_for_clients=False): + """Scan the network.""" + self.update(scan_for_clients) + + def all(self): + """Return all found entries. + + Will scan for entries if not scanned recently. + """ + self.scan() + return list(self.entries) + + def find_by_content_type(self, value): + """Return a list of entries that match the content_type.""" + self.scan() + return [entry for entry in self.entries + if value in entry['data']['Content_Type']] + + def find_by_data(self, values): + """Return a list of entries that match the search parameters.""" + self.scan() + return [entry for entry in self.entries + if all(item in entry['data'].items() + for item in values.items())] + + def update(self, scan_for_clients): + """Scan for new GDM services. + + Examples of the dict list assigned to self.entries by this function: + + Server: + + [{'data': { + 'Content-Type': 'plex/media-server', + 'Host': '53f4b5b6023d41182fe88a99b0e714ba.plex.direct', + 'Name': 'myfirstplexserver', + 'Port': '32400', + 'Resource-Identifier': '646ab0aa8a01c543e94ba975f6fd6efadc36b7', + 'Updated-At': '1585769946', + 'Version': '1.18.8.2527-740d4c206', + }, + 'from': ('10.10.10.100', 32414)}] + + Clients: + + [{'data': {'Content-Type': 'plex/media-player', + 'Device-Class': 'stb', + 'Name': 'plexamp', + 'Port': '36000', + 'Product': 'Plexamp', + 'Protocol': 'plex', + 'Protocol-Capabilities': 'timeline,playback,playqueues,playqueues-creation', + 'Protocol-Version': '1', + 'Resource-Identifier': 'b6e57a3f-e0f8-494f-8884-f4b58501467e', + 'Version': '1.1.0', + }, + 'from': ('10.10.10.101', 32412)}] + """ + + gdm_msg = 'M-SEARCH * HTTP/1.0'.encode('ascii') + gdm_timeout = 1 + + self.entries = [] + known_responses = [] + + # setup socket for discovery -> multicast message + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.settimeout(gdm_timeout) + + # Set the time-to-live for messages for local network + sock.setsockopt(socket.IPPROTO_IP, + socket.IP_MULTICAST_TTL, + struct.pack("B", gdm_timeout)) + + if scan_for_clients: + # setup socket for broadcast to Plex clients + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) + gdm_ip = '255.255.255.255' + gdm_port = 32412 + else: + # setup socket for multicast to Plex server(s) + gdm_ip = '239.0.0.250' + gdm_port = 32414 + + try: + # Send data to the multicast group + sock.sendto(gdm_msg, (gdm_ip, gdm_port)) + + # Look for responses from all recipients + while True: + try: + bdata, host = sock.recvfrom(1024) + data = bdata.decode('utf-8') + if '200 OK' in data.splitlines()[0]: + ddata = {k: v.strip() for (k, v) in ( + line.split(':') for line in + data.splitlines() if ':' in line)} + identifier = ddata.get('Resource-Identifier') + if identifier and identifier in known_responses: + continue + known_responses.append(identifier) + self.entries.append({'data': ddata, + 'from': host}) + except socket.timeout: + break + finally: + sock.close() + + +def main(): + """Test GDM discovery.""" + from pprint import pprint + + gdm = GDM() + + pprint("Scanning GDM for servers...") + gdm.scan() + pprint(gdm.entries) + + pprint("Scanning GDM for clients...") + gdm.scan(scan_for_clients=True) + pprint(gdm.entries) + + +if __name__ == "__main__": + main() diff --git a/plexapi/library.py b/plexapi/library.py index f0883bc0c..397f5c2ef 100644 --- a/plexapi/library.py +++ b/plexapi/library.py @@ -1,9 +1,11 @@ # -*- coding: utf-8 -*- +from urllib.parse import quote, quote_plus, unquote, urlencode + from plexapi import X_PLEX_CONTAINER_SIZE, log, utils from plexapi.base import PlexObject -from plexapi.compat import unquote, urlencode, quote_plus -from plexapi.media import MediaTag from plexapi.exceptions import BadRequest, NotFound +from plexapi.media import MediaTag +from plexapi.settings import Setting class Library(PlexObject): @@ -294,6 +296,17 @@ def add(self, name='', type='', agent='', scanner='', location='', language='en' part += urlencode(kwargs) return self._server.query(part, method=self._server._session.post) + def history(self, maxresults=9999999, mindate=None): + """ Get Play History for all library Sections for the owner. + Parameters: + maxresults (int): Only return the specified number of results (optional). + mindate (datetime): Min datetime to return results from. + """ + hist = [] + for section in self.sections(): + hist.extend(section.history(maxresults=maxresults, mindate=mindate)) + return hist + class LibrarySection(PlexObject): """ Base class for a single library section. @@ -320,6 +333,8 @@ class LibrarySection(PlexObject): type (str): Type of content section represents (movie, artist, photo, show). updatedAt (datetime): Datetime this library section was last updated. uuid (str): Unique id for this section (32258d7c-3e6c-4ac5-98ad-bad7a3b78c63) + totalSize (int): Total number of item in the library + """ ALLOWED_FILTERS = () ALLOWED_SORT = () @@ -343,6 +358,51 @@ def _loadData(self, data): self.type = data.attrib.get('type') self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt')) self.uuid = data.attrib.get('uuid') + # Private attrs as we dont want a reload. + self._total_size = None + + def fetchItems(self, ekey, cls=None, container_start=None, container_size=None, **kwargs): + """ Load the specified key to find and build all items with the specified tag + and attrs. See :func:`~plexapi.base.PlexObject.fetchItem` for more details + on how this is used. + + Parameters: + container_start (None, int): offset to get a subset of the data + container_size (None, int): How many items in data + + """ + url_kw = {} + if container_start is not None: + url_kw["X-Plex-Container-Start"] = container_start + if container_size is not None: + url_kw["X-Plex-Container-Size"] = container_size + + if ekey is None: + raise BadRequest('ekey was not provided') + data = self._server.query(ekey, params=url_kw) + + if '/all' in ekey: + # totalSize is only included in the xml response + # if container size is used. + total_size = data.attrib.get("totalSize") or data.attrib.get("size") + self._total_size = utils.cast(int, total_size) + + items = self.findItems(data, cls, ekey, **kwargs) + + librarySectionID = data.attrib.get('librarySectionID') + if librarySectionID: + for item in items: + item.librarySectionID = librarySectionID + return items + + @property + def totalSize(self): + if self._total_size is None: + part = '/library/sections/%s/all?X-Plex-Container-Start=0&X-Plex-Container-Size=1' % self.key + data = self._server.query(part) + self._total_size = int(data.attrib.get("totalSize")) + + return self._total_size def delete(self): """ Delete a library section. """ @@ -354,13 +414,18 @@ def delete(self): log.error(msg) raise - def edit(self, **kwargs): + def reload(self, key=None): + return self._server.library.section(self.title) + + def edit(self, agent=None, **kwargs): """ Edit a library (Note: agent is required). See :class:`~plexapi.library.Library` for example usage. Parameters: kwargs (dict): Dict of settings to edit. """ - part = '/library/sections/%s?%s' % (self.key, urlencode(kwargs)) + if not agent: + agent = self.agent + part = '/library/sections/%s?agent=%s&%s' % (self.key, agent, urlencode(kwargs)) self._server.query(part, method=self._server._session.put) # Reload this way since the self.key dont have a full path, but is simply a id. @@ -374,7 +439,7 @@ def get(self, title): Parameters: title (str): Title of the item to return. """ - key = '/library/sections/%s/all' % self.key + key = '/library/sections/%s/all?title=%s' % (self.key, quote(title, safe='')) return self.fetchItem(key, title__iexact=title) def all(self, sort=None, **kwargs): @@ -386,10 +451,21 @@ def all(self, sort=None, **kwargs): sortStr = '' if sort is not None: sortStr = '?sort=' + sort - + key = '/library/sections/%s/all%s' % (self.key, sortStr) return self.fetchItems(key, **kwargs) + def agents(self): + """ Returns a list of available `:class:`~plexapi.media.Agent` for this library section. + """ + return self._server.agents(utils.searchType(self.type)) + + def settings(self): + """ Returns a list of all library settings. """ + key = '/library/sections/%s/prefs' % self.key + data = self._server.query(key) + return self.findItems(data, cls=Setting) + def onDeck(self): """ Returns a list of media items on deck from this library section. """ key = '/library/sections/%s/onDeck' % self.key @@ -464,9 +540,9 @@ def listChoices(self, category, libtype=None, **kwargs): key = '/library/sections/%s/%s%s' % (self.key, category, utils.joinArgs(args)) return self.fetchItems(key, cls=FilterChoice) - def search(self, title=None, sort=None, maxresults=999999, libtype=None, **kwargs): - """ Search the library. If there are many results, they will be fetched from the server - in batches of X_PLEX_CONTAINER_SIZE amounts. If you're only looking for the first + def search(self, title=None, sort=None, maxresults=None, + libtype=None, container_start=0, container_size=X_PLEX_CONTAINER_SIZE, **kwargs): + """ Search the library. The http requests will be batched in container_size. If you're only looking for the first results, it would be wise to set the maxresults option to that amount so this functions doesn't iterate over all results on the server. @@ -477,6 +553,8 @@ def search(self, title=None, sort=None, maxresults=999999, libtype=None, **kwarg maxresults (int): Only return the specified number of results (optional). libtype (str): Filter results to a spcifiec libtype (movie, show, episode, artist, album, track; optional). + container_start (int): default 0 + container_size (int): default X_PLEX_CONTAINER_SIZE in your config file. **kwargs (dict): Any of the available filters for the current library section. Partial string matches allowed. Multiple matches OR together. Negative filtering also possible, just add an exclamation mark to the end of filter name, e.g. `resolution!=1x1`. @@ -508,15 +586,37 @@ def search(self, title=None, sort=None, maxresults=999999, libtype=None, **kwarg args['sort'] = self._cleanSearchSort(sort) if libtype is not None: args['type'] = utils.searchType(libtype) - # iterate over the results - results, subresults = [], '_init' - args['X-Plex-Container-Start'] = 0 - args['X-Plex-Container-Size'] = min(X_PLEX_CONTAINER_SIZE, maxresults) - while subresults and maxresults > len(results): + + results = [] + subresults = [] + offset = container_start + + if maxresults is not None: + container_size = min(container_size, maxresults) + while True: key = '/library/sections/%s/all%s' % (self.key, utils.joinArgs(args)) - subresults = self.fetchItems(key) - results += subresults[:maxresults - len(results)] - args['X-Plex-Container-Start'] += args['X-Plex-Container-Size'] + subresults = self.fetchItems(key, container_start=container_start, + container_size=container_size) + if not len(subresults): + if offset > self.totalSize: + log.info("container_start is higher then the number of items in the library") + break + + results.extend(subresults) + + # self.totalSize is not used as a condition in the while loop as + # this require a additional http request. + # self.totalSize is updated from .fetchItems + wanted_number_of_items = self.totalSize - offset + if maxresults is not None: + wanted_number_of_items = min(maxresults, wanted_number_of_items) + container_size = min(container_size, maxresults - len(results)) + + if wanted_number_of_items <= len(results): + break + + container_start += container_size + return results def _cleanSearchFilter(self, category, value, libtype=None): @@ -543,7 +643,7 @@ def _cleanSearchFilter(self, category, value, libtype=None): matches = [k for t, k in lookup.items() if item in t] if matches: map(result.add, matches); continue # nothing matched; use raw item value - log.warning('Filter value not listed, using raw item value: %s' % item) + log.debug('Filter value not listed, using raw item value: %s' % item) result.add(item) return ','.join(result) @@ -633,6 +733,14 @@ def sync(self, policy, mediaSettings, client=None, clientId=None, title=None, so return myplex.sync(client=client, clientId=clientId, sync_item=sync_item) + def history(self, maxresults=9999999, mindate=None): + """ Get Play History for this library Section for the owner. + Parameters: + maxresults (int): Only return the specified number of results (optional). + mindate (datetime): Min datetime to return results from. + """ + return self._server.history(maxresults=maxresults, mindate=mindate, librarySectionID=self.key, accountID=1) + class MovieSection(LibrarySection): """ Represents a :class:`~plexapi.library.LibrarySection` section containing movies. @@ -649,7 +757,8 @@ class MovieSection(LibrarySection): """ ALLOWED_FILTERS = ('unwatched', 'duplicate', 'year', 'decade', 'genre', 'contentRating', 'collection', 'director', 'actor', 'country', 'studio', 'resolution', - 'guid', 'label') + 'guid', 'label', 'writer', 'producer', 'subtitleLanguage', 'audioLanguage', + 'lastViewedAt', 'viewCount', 'addedAt') ALLOWED_SORT = ('addedAt', 'originallyAvailableAt', 'lastViewedAt', 'titleSort', 'rating', 'mediaHeight', 'duration') TAG = 'Directory' @@ -709,7 +818,11 @@ class ShowSection(LibrarySection): TYPE (str): 'show' """ ALLOWED_FILTERS = ('unwatched', 'year', 'genre', 'contentRating', 'network', 'collection', - 'guid', 'duplicate', 'label') + 'guid', 'duplicate', 'label', 'show.title', 'show.year', 'show.userRating', + 'show.viewCount', 'show.lastViewedAt', 'show.actor', 'show.addedAt', 'episode.title', + 'episode.originallyAvailableAt', 'episode.resolution', 'episode.subtitleLanguage', + 'episode.unwatched', 'episode.addedAt', 'episode.userRating', 'episode.viewCount', + 'episode.lastViewedAt') ALLOWED_SORT = ('addedAt', 'lastViewedAt', 'originallyAvailableAt', 'titleSort', 'rating', 'unwatched') TAG = 'Directory' @@ -784,7 +897,12 @@ class MusicSection(LibrarySection): TAG (str): 'Directory' TYPE (str): 'artist' """ - ALLOWED_FILTERS = ('genre', 'country', 'collection', 'mood', 'year', 'track.userRating') + ALLOWED_FILTERS = ('genre', 'country', 'collection', 'mood', 'year', 'track.userRating', 'artist.title', + 'artist.userRating', 'artist.genre', 'artist.country', 'artist.collection', 'artist.addedAt', + 'album.title', 'album.userRating', 'album.genre', 'album.decade', 'album.collection', + 'album.viewCount', 'album.lastViewedAt', 'album.studio', 'album.addedAt', 'track.title', + 'track.userRating', 'track.viewCount', 'track.lastViewedAt', 'track.skipCount', + 'track.lastSkippedAt') ALLOWED_SORT = ('addedAt', 'lastViewedAt', 'viewCount', 'titleSort', 'userRating') TAG = 'Directory' TYPE = 'artist' @@ -858,7 +976,8 @@ class PhotoSection(LibrarySection): TAG (str): 'Directory' TYPE (str): 'photo' """ - ALLOWED_FILTERS = ('all', 'iso', 'make', 'lens', 'aperture', 'exposure', 'device', 'resolution') + ALLOWED_FILTERS = ('all', 'iso', 'make', 'lens', 'aperture', 'exposure', 'device', 'resolution', 'place', + 'originallyAvailableAt', 'addedAt', 'title', 'userRating', 'tag', 'year') ALLOWED_SORT = ('addedAt',) TAG = 'Directory' TYPE = 'photo' @@ -957,6 +1076,7 @@ def _loadData(self, data): self.size = utils.cast(int, data.attrib.get('size')) self.title = data.attrib.get('title') self.type = data.attrib.get('type') + self.key = data.attrib.get('key') self.items = self.findItems(data) def __len__(self): @@ -968,9 +1088,11 @@ class Collections(PlexObject): TAG = 'Directory' TYPE = 'collection' + _include = "?includeExternalMedia=1&includePreferences=1" def _loadData(self, data): self.ratingKey = utils.cast(int, data.attrib.get('ratingKey')) + self._details_key = "/library/metadata/%s%s" % (self.ratingKey, self._include) self.key = data.attrib.get('key') self.type = data.attrib.get('type') self.title = data.attrib.get('title') @@ -983,6 +1105,8 @@ def _loadData(self, data): self.childCount = utils.cast(int, data.attrib.get('childCount')) self.minYear = utils.cast(int, data.attrib.get('minYear')) self.maxYear = utils.cast(int, data.attrib.get('maxYear')) + self.collectionMode = data.attrib.get('collectionMode') + self.collectionSort = data.attrib.get('collectionSort') @property def children(self): @@ -995,5 +1119,86 @@ def delete(self): part = '/library/metadata/%s' % self.ratingKey return self._server.query(part, method=self._server._session.delete) + def modeUpdate(self, mode=None): + """ Update Collection Mode + + Parameters: + mode: default (Library default) + hide (Hide Collection) + hideItems (Hide Items in this Collection) + showItems (Show this Collection and its Items) + Example: + + collection = 'plexapi.library.Collections' + collection.updateMode(mode="hide") + """ + mode_dict = {'default': '-2', + 'hide': '0', + 'hideItems': '1', + 'showItems': '2'} + key = mode_dict.get(mode) + if key is None: + raise BadRequest('Unknown collection mode : %s. Options %s' % (mode, list(mode_dict))) + part = '/library/metadata/%s/prefs?collectionMode=%s' % (self.ratingKey, key) + return self._server.query(part, method=self._server._session.put) + + def sortUpdate(self, sort=None): + """ Update Collection Sorting + + Parameters: + sort: realease (Order Collection by realease dates) + alpha (Order Collection Alphabetically) + + Example: + + colleciton = 'plexapi.library.Collections' + collection.updateSort(mode="alpha") + """ + sort_dict = {'release': '0', + 'alpha': '1'} + key = sort_dict.get(sort) + if key is None: + raise BadRequest('Unknown sort dir: %s. Options: %s' % (sort, list(sort_dict))) + part = '/library/metadata/%s/prefs?collectionSort=%s' % (self.ratingKey, key) + return self._server.query(part, method=self._server._session.put) + + def posters(self): + """ Returns list of available poster objects. :class:`~plexapi.media.Poster`. """ + + return self.fetchItems('/library/metadata/%s/posters' % self.ratingKey) + + def uploadPoster(self, url=None, filepath=None): + """ Upload poster from url or filepath. :class:`~plexapi.media.Poster` to :class:`~plexapi.video.Video`. """ + if url: + key = '/library/metadata/%s/posters?url=%s' % (self.ratingKey, quote_plus(url)) + self._server.query(key, method=self._server._session.post) + elif filepath: + key = '/library/metadata/%s/posters?' % self.ratingKey + data = open(filepath, 'rb').read() + self._server.query(key, method=self._server._session.post, data=data) + + def setPoster(self, poster): + """ Set . :class:`~plexapi.media.Poster` to :class:`~plexapi.video.Video` """ + poster.select() + + def arts(self): + """ Returns list of available art objects. :class:`~plexapi.media.Poster`. """ + + return self.fetchItems('/library/metadata/%s/arts' % self.ratingKey) + + def uploadArt(self, url=None, filepath=None): + """ Upload art from url or filepath. :class:`~plexapi.media.Poster` to :class:`~plexapi.video.Video`. """ + if url: + key = '/library/metadata/%s/arts?url=%s' % (self.ratingKey, quote_plus(url)) + self._server.query(key, method=self._server._session.post) + elif filepath: + key = '/library/metadata/%s/arts?' % self.ratingKey + data = open(filepath, 'rb').read() + self._server.query(key, method=self._server._session.post, data=data) + + def setArt(self, art): + """ Set :class:`~plexapi.media.Poster` to :class:`~plexapi.video.Video` """ + art.select() + # def edit(self, **kwargs): # TODO diff --git a/plexapi/media.py b/plexapi/media.py index 5badc2be7..7a106232e 100644 --- a/plexapi/media.py +++ b/plexapi/media.py @@ -1,5 +1,9 @@ # -*- coding: utf-8 -*- -from plexapi import log, utils + +import xml +from urllib.parse import quote_plus + +from plexapi import log, settings, utils from plexapi.base import PlexObject from plexapi.exceptions import BadRequest from plexapi.utils import cast @@ -85,6 +89,8 @@ class MediaPart(PlexObject): key (str): Key used to access this media part (ex: /library/parts/46618/1389985872/file.avi). size (int): Size of this file in bytes (ex: 733884416). streams (list<:class:`~plexapi.media.MediaPartStream`>): List of streams in this media part. + exists (bool): Determine if file exists + accessible (bool): Determine if file is accessible """ TAG = 'Part' @@ -104,6 +110,8 @@ def _loadData(self, data): self.syncState = data.attrib.get('syncState') self.videoProfile = data.attrib.get('videoProfile') self.streams = self._buildStreams(data) + self.exists = cast(bool, data.attrib.get('exists')) + self.accessible = cast(bool, data.attrib.get('accessible')) def _buildStreams(self, data): streams = [] @@ -139,7 +147,7 @@ def setDefaultAudioStream(self, stream): def setDefaultSubtitleStream(self, stream): """ Set the default :class:`~plexapi.media.SubtitleStream` for this MediaPart. - + Parameters: stream (:class:`~plexapi.media.SubtitleStream`): SubtitleStream to set as default. """ @@ -345,6 +353,118 @@ def _loadData(self, data): self.width = cast(int, data.attrib.get('width')) +@utils.registerPlexObject +class TranscodeJob(PlexObject): + """ Represents an Optimizing job. + TrancodeJobs are the process for optimizing conversions. + Active or paused optimization items. Usually one item as a time""" + TAG = 'TranscodeJob' + + def _loadData(self, data): + self._data = data + self.generatorID = data.attrib.get('generatorID') + self.key = data.attrib.get('key') + self.progress = data.attrib.get('progress') + self.ratingKey = data.attrib.get('ratingKey') + self.size = data.attrib.get('size') + self.targetTagID = data.attrib.get('targetTagID') + self.thumb = data.attrib.get('thumb') + self.title = data.attrib.get('title') + self.type = data.attrib.get('type') + + +@utils.registerPlexObject +class Optimized(PlexObject): + """ Represents a Optimized item. + Optimized items are optimized and queued conversions items.""" + TAG = 'Item' + + def _loadData(self, data): + self._data = data + self.id = data.attrib.get('id') + self.composite = data.attrib.get('composite') + self.title = data.attrib.get('title') + self.type = data.attrib.get('type') + self.target = data.attrib.get('target') + self.targetTagID = data.attrib.get('targetTagID') + + def remove(self): + """ Remove an Optimized item""" + key = '%s/%s' % (self._initpath, self.id) + self._server.query(key, method=self._server._session.delete) + + def rename(self, title): + """ Rename an Optimized item""" + key = '%s/%s?Item[title]=%s' % (self._initpath, self.id, title) + self._server.query(key, method=self._server._session.put) + + def reprocess(self, ratingKey): + """ Reprocess a removed Conversion item that is still a listed Optimize item""" + key = '%s/%s/%s/enable' % (self._initpath, self.id, ratingKey) + self._server.query(key, method=self._server._session.put) + + +@utils.registerPlexObject +class Conversion(PlexObject): + """ Represents a Conversion item. + Conversions are items queued for optimization or being actively optimized.""" + TAG = 'Video' + + def _loadData(self, data): + self._data = data + self.addedAt = data.attrib.get('addedAt') + self.art = data.attrib.get('art') + self.chapterSource = data.attrib.get('chapterSource') + self.contentRating = data.attrib.get('contentRating') + self.duration = data.attrib.get('duration') + self.generatorID = data.attrib.get('generatorID') + self.generatorType = data.attrib.get('generatorType') + self.guid = data.attrib.get('guid') + self.key = data.attrib.get('key') + self.lastViewedAt = data.attrib.get('lastViewedAt') + self.librarySectionID = data.attrib.get('librarySectionID') + self.librarySectionKey = data.attrib.get('librarySectionKey') + self.librarySectionTitle = data.attrib.get('librarySectionTitle') + self.originallyAvailableAt = data.attrib.get('originallyAvailableAt') + self.playQueueItemID = data.attrib.get('playQueueItemID') + self.playlistID = data.attrib.get('playlistID') + self.primaryExtraKey = data.attrib.get('primaryExtraKey') + self.rating = data.attrib.get('rating') + self.ratingKey = data.attrib.get('ratingKey') + self.studio = data.attrib.get('studio') + self.summary = data.attrib.get('summary') + self.tagline = data.attrib.get('tagline') + self.target = data.attrib.get('target') + self.thumb = data.attrib.get('thumb') + self.title = data.attrib.get('title') + self.type = data.attrib.get('type') + self.updatedAt = data.attrib.get('updatedAt') + self.userID = data.attrib.get('userID') + self.username = data.attrib.get('username') + self.viewOffset = data.attrib.get('viewOffset') + self.year = data.attrib.get('year') + + def remove(self): + """ Remove Conversion from queue """ + key = '/playlists/%s/items/%s/%s/disable' % (self.playlistID, self.generatorID, self.ratingKey) + self._server.query(key, method=self._server._session.put) + + def move(self, after): + """ Move Conversion items position in queue + after (int): Place item after specified playQueueItemID. '-1' is the active conversion. + + Example: + Move 5th conversion Item to active conversion + conversions[4].move('-1') + + Move 4th conversion Item to 3rd in conversion queue + conversions[3].move(conversions[1].playQueueItemID) + """ + + key = '%s/items/%s/move?after=%s' % (self._initpath, self.playQueueItemID, after) + self._server.query(key, method=self._server._session.put) + + class MediaTag(PlexObject): """ Base class for media tags used for filtering and searching your library items or navigating the metadata of media items in your library. Tags are @@ -415,6 +535,25 @@ class Label(MediaTag): FILTER = 'label' +@utils.registerPlexObject +class Tag(MediaTag): + """ Represents a single tag media tag. + + Attributes: + TAG (str): 'tag' + FILTER (str): 'tag' + """ + TAG = 'Tag' + FILTER = 'tag' + + def _loadData(self, data): + self._data = data + self.id = cast(int, data.attrib.get('id', 0)) + self.filter = data.attrib.get('filter') + self.tag = data.attrib.get('tag') + self.title = self.tag + + @utils.registerPlexObject class Country(MediaTag): """ Represents a single Country media tag. @@ -463,6 +602,31 @@ class Mood(MediaTag): FILTER = 'mood' +@utils.registerPlexObject +class Poster(PlexObject): + """ Represents a Poster. + + Attributes: + TAG (str): 'Photo' + """ + TAG = 'Photo' + + def _loadData(self, data): + self._data = data + self.key = data.attrib.get('key') + self.ratingKey = data.attrib.get('ratingKey') + self.selected = data.attrib.get('selected') + self.thumb = data.attrib.get('thumb') + + def select(self): + key = self._initpath[:-1] + data = '%s?url=%s' % (key, quote_plus(self.ratingKey)) + try: + self._server.query(data, method=self._server._session.put) + except xml.etree.ElementTree.ParseError: + pass + + @utils.registerPlexObject class Producer(MediaTag): """ Represents a single Producer media tag. @@ -531,6 +695,28 @@ def _loadData(self, data): self.end = cast(int, data.attrib.get('endTimeOffset')) +@utils.registerPlexObject +class Marker(PlexObject): + """ Represents a single Marker media tag. + + Attributes: + TAG (str): 'Marker' + """ + TAG = 'Marker' + + def __repr__(self): + name = self._clean(self.firstAttr('type')) + start = utils.millisecondToHumanstr(self._clean(self.firstAttr('start'))) + end = utils.millisecondToHumanstr(self._clean(self.firstAttr('end'))) + return '<%s:%s %s - %s>' % (self.__class__.__name__, name, start, end) + + def _loadData(self, data): + self._data = data + self.type = data.attrib.get('type') + self.start = cast(int, data.attrib.get('startTimeOffset')) + self.end = cast(int, data.attrib.get('endTimeOffset')) + + @utils.registerPlexObject class Field(PlexObject): """ Represents a single Field. @@ -544,3 +730,74 @@ def _loadData(self, data): self._data = data self.name = data.attrib.get('name') self.locked = cast(bool, data.attrib.get('locked')) + + +@utils.registerPlexObject +class SearchResult(PlexObject): + """ Represents a single SearchResult. + + Attributes: + TAG (str): 'SearchResult' + """ + TAG = 'SearchResult' + + def __repr__(self): + name = self._clean(self.firstAttr('name')) + score = self._clean(self.firstAttr('score')) + return '<%s>' % ':'.join([p for p in [self.__class__.__name__, name, score] if p]) + + def _loadData(self, data): + self._data = data + self.guid = data.attrib.get('guid') + self.lifespanEnded = data.attrib.get('lifespanEnded') + self.name = data.attrib.get('name') + self.score = cast(int, data.attrib.get('score')) + self.year = data.attrib.get('year') + + +@utils.registerPlexObject +class Agent(PlexObject): + """ Represents a single Agent. + + Attributes: + TAG (str): 'Agent' + """ + TAG = 'Agent' + + def __repr__(self): + uid = self._clean(self.firstAttr('shortIdentifier')) + return '<%s>' % ':'.join([p for p in [self.__class__.__name__, uid] if p]) + + def _loadData(self, data): + self._data = data + self.hasAttribution = data.attrib.get('hasAttribution') + self.hasPrefs = data.attrib.get('hasPrefs') + self.identifier = data.attrib.get('identifier') + self.primary = data.attrib.get('primary') + self.shortIdentifier = self.identifier.rsplit('.', 1)[1] + if 'mediaType' in self._initpath: + self.name = data.attrib.get('name') + self.languageCode = [] + for code in data: + self.languageCode += [code.attrib.get('code')] + else: + self.mediaTypes = [AgentMediaType(server=self._server, data=d) for d in data] + + def _settings(self): + key = '/:/plugins/%s/prefs' % self.identifier + data = self._server.query(key) + return self.findItems(data, cls=settings.Setting) + + +class AgentMediaType(Agent): + + def __repr__(self): + uid = self._clean(self.firstAttr('name')) + return '<%s>' % ':'.join([p for p in [self.__class__.__name__, uid] if p]) + + def _loadData(self, data): + self.mediaType = cast(int, data.attrib.get('mediaType')) + self.name = data.attrib.get('name') + self.languageCode = [] + for code in data: + self.languageCode += [code.attrib.get('code')] diff --git a/plexapi/myplex.py b/plexapi/myplex.py index ef6ffef47..8806fdb55 100644 --- a/plexapi/myplex.py +++ b/plexapi/myplex.py @@ -1,18 +1,21 @@ # -*- coding: utf-8 -*- import copy -import requests +import threading import time -from requests.status_codes import _codes as codes -from plexapi import BASE_HEADERS, CONFIG, TIMEOUT, X_PLEX_IDENTIFIER, X_PLEX_ENABLE_FAST_CONNECT -from plexapi import log, logfilter, utils +from xml.etree import ElementTree + +import requests +from plexapi import (BASE_HEADERS, CONFIG, TIMEOUT, X_PLEX_ENABLE_FAST_CONNECT, + X_PLEX_IDENTIFIER, log, logfilter, utils) from plexapi.base import PlexObject -from plexapi.exceptions import BadRequest, NotFound from plexapi.client import PlexClient -from plexapi.compat import ElementTree +from plexapi.exceptions import BadRequest, NotFound, Unauthorized from plexapi.library import LibrarySection from plexapi.server import PlexServer -from plexapi.sync import SyncList, SyncItem +from plexapi.sonos import PlexSonosClient +from plexapi.sync import SyncItem, SyncList from plexapi.utils import joinArgs +from requests.status_codes import _codes as codes class MyPlexAccount(PlexObject): @@ -62,14 +65,23 @@ class MyPlexAccount(PlexObject): _session (obj): Requests session object used to access this client. """ FRIENDINVITE = 'https://plex.tv/api/servers/{machineId}/shared_servers' # post with data + HOMEUSERCREATE = 'https://plex.tv/api/home/users?title={title}' # post with data + EXISTINGUSER = 'https://plex.tv/api/home/users?invitedEmail={username}' # post with data FRIENDSERVERS = 'https://plex.tv/api/servers/{machineId}/shared_servers/{serverId}' # put with data PLEXSERVERS = 'https://plex.tv/api/servers/{machineId}' # get FRIENDUPDATE = 'https://plex.tv/api/friends/{userId}' # put with args, delete + REMOVEHOMEUSER = 'https://plex.tv/api/home/users/{userId}' # delete REMOVEINVITE = 'https://plex.tv/api/invites/requested/{userId}?friend=0&server=1&home=0' # delete REQUESTED = 'https://plex.tv/api/invites/requested' # get REQUESTS = 'https://plex.tv/api/invites/requests' # get SIGNIN = 'https://plex.tv/users/sign_in.xml' # get with auth WEBHOOKS = 'https://plex.tv/api/v2/user/webhooks' # get, post with data + # Hub sections + VOD = 'https://vod.provider.plex.tv/' # get + WEBSHOWS = 'https://webshows.provider.plex.tv/' # get + NEWS = 'https://news.provider.plex.tv/' # get + PODCASTS = 'https://podcasts.provider.plex.tv/' # get + MUSIC = 'https://music.provider.plex.tv/' # get # Key may someday switch to the following url. For now the current value works. # https://plex.tv/api/v2/user?X-Plex-Token={token}&X-Plex-Client-Identifier={clientId} key = 'https://plex.tv/users/account' @@ -77,6 +89,8 @@ class MyPlexAccount(PlexObject): def __init__(self, username=None, password=None, token=None, session=None, timeout=None): self._token = token self._session = session or requests.Session() + self._sonos_cache = [] + self._sonos_cache_timestamp = 0 data, initpath = self._signin(username, password, timeout) super(MyPlexAccount, self).__init__(self, data, initpath) @@ -125,7 +139,7 @@ def _loadData(self, data): roles = data.find('roles') self.roles = [] - if roles: + if roles is not None: for role in roles.iter('role'): self.roles.append(role.attrib.get('id')) @@ -172,7 +186,13 @@ def query(self, url, method=None, headers=None, timeout=None, **kwargs): if response.status_code not in (200, 201, 204): # pragma: no cover codename = codes.get(response.status_code)[0] errtext = response.text.replace('\n', ' ') - raise BadRequest('(%s) %s %s; %s' % (response.status_code, codename, response.url, errtext)) + message = '(%s) %s; %s %s' % (response.status_code, codename, response.url, errtext) + if response.status_code == 401: + raise Unauthorized(message) + elif response.status_code == 404: + raise NotFound(message) + else: + raise BadRequest(message) data = response.text.encode('utf8') return ElementTree.fromstring(data) if data.strip() else None @@ -192,14 +212,33 @@ def resources(self): data = self.query(MyPlexResource.key) return [MyPlexResource(self, elem) for elem in data] + def sonos_speakers(self): + if 'companions_sonos' not in self.subscriptionFeatures: + return [] + + t = time.time() + if t - self._sonos_cache_timestamp > 5: + self._sonos_cache_timestamp = t + data = self.query('https://sonos.plex.tv/resources') + self._sonos_cache = [PlexSonosClient(self, elem) for elem in data] + + return self._sonos_cache + + def sonos_speaker(self, name): + return next((x for x in self.sonos_speakers() if x.title.split("+")[0].strip() == name), None) + + def sonos_speaker_by_id(self, identifier): + return next((x for x in self.sonos_speakers() if x.machineIdentifier.startswith(identifier)), None) + def inviteFriend(self, user, server, sections=None, allowSync=False, allowCameraUpload=False, - allowChannels=False, filterMovies=None, filterTelevision=None, filterMusic=None): + allowChannels=False, filterMovies=None, filterTelevision=None, filterMusic=None): """ Share library content with the specified user. Parameters: user (str): MyPlexUser, username, email of the user to be added. server (PlexServer): PlexServer object or machineIdentifier containing the library sections to share. - sections ([Section]): Library sections, names or ids to be shared (default None shares all sections). + sections ([Section]): Library sections, names or ids to be shared (default None). + [Section] must be defined in order to update shared sections. allowSync (Bool): Set True to allow user to sync content. allowCameraUpload (Bool): Set True to allow user to upload photos. allowChannels (Bool): Set True to allow user to utilize installed channels. @@ -229,6 +268,102 @@ def inviteFriend(self, user, server, sections=None, allowSync=False, allowCamera url = self.FRIENDINVITE.format(machineId=machineId) return self.query(url, self._session.post, json=params, headers=headers) + def createHomeUser(self, user, server, sections=None, allowSync=False, allowCameraUpload=False, + allowChannels=False, filterMovies=None, filterTelevision=None, filterMusic=None): + """ Share library content with the specified user. + + Parameters: + user (str): MyPlexUser, username, email of the user to be added. + server (PlexServer): PlexServer object or machineIdentifier containing the library sections to share. + sections ([Section]): Library sections, names or ids to be shared (default None shares all sections). + allowSync (Bool): Set True to allow user to sync content. + allowCameraUpload (Bool): Set True to allow user to upload photos. + allowChannels (Bool): Set True to allow user to utilize installed channels. + filterMovies (Dict): Dict containing key 'contentRating' and/or 'label' each set to a list of + values to be filtered. ex: {'contentRating':['G'], 'label':['foo']} + filterTelevision (Dict): Dict containing key 'contentRating' and/or 'label' each set to a list of + values to be filtered. ex: {'contentRating':['G'], 'label':['foo']} + filterMusic (Dict): Dict containing key 'label' set to a list of values to be filtered. + ex: {'label':['foo']} + """ + machineId = server.machineIdentifier if isinstance(server, PlexServer) else server + sectionIds = self._getSectionIds(server, sections) + + headers = {'Content-Type': 'application/json'} + url = self.HOMEUSERCREATE.format(title=user) + # UserID needs to be created and referenced when adding sections + user_creation = self.query(url, self._session.post, headers=headers) + userIds = {} + for elem in user_creation.findall("."): + # Find userID + userIds['id'] = elem.attrib.get('id') + log.debug(userIds) + params = { + 'server_id': machineId, + 'shared_server': {'library_section_ids': sectionIds, 'invited_id': userIds['id']}, + 'sharing_settings': { + 'allowSync': ('1' if allowSync else '0'), + 'allowCameraUpload': ('1' if allowCameraUpload else '0'), + 'allowChannels': ('1' if allowChannels else '0'), + 'filterMovies': self._filterDictToStr(filterMovies or {}), + 'filterTelevision': self._filterDictToStr(filterTelevision or {}), + 'filterMusic': self._filterDictToStr(filterMusic or {}), + }, + } + url = self.FRIENDINVITE.format(machineId=machineId) + library_assignment = self.query(url, self._session.post, json=params, headers=headers) + return user_creation, library_assignment + + def createExistingUser(self, user, server, sections=None, allowSync=False, allowCameraUpload=False, + allowChannels=False, filterMovies=None, filterTelevision=None, filterMusic=None): + """ Share library content with the specified user. + + Parameters: + user (str): MyPlexUser, username, email of the user to be added. + server (PlexServer): PlexServer object or machineIdentifier containing the library sections to share. + sections ([Section]): Library sections, names or ids to be shared (default None shares all sections). + allowSync (Bool): Set True to allow user to sync content. + allowCameraUpload (Bool): Set True to allow user to upload photos. + allowChannels (Bool): Set True to allow user to utilize installed channels. + filterMovies (Dict): Dict containing key 'contentRating' and/or 'label' each set to a list of + values to be filtered. ex: {'contentRating':['G'], 'label':['foo']} + filterTelevision (Dict): Dict containing key 'contentRating' and/or 'label' each set to a list of + values to be filtered. ex: {'contentRating':['G'], 'label':['foo']} + filterMusic (Dict): Dict containing key 'label' set to a list of values to be filtered. + ex: {'label':['foo']} + """ + headers = {'Content-Type': 'application/json'} + # If user already exists, carry over sections and settings. + if isinstance(user, MyPlexUser): + username = user.username + elif user in [_user.username for _user in self.users()]: + username = self.user(user).username + else: + # If user does not already exists, treat request as new request and include sections and settings. + newUser = user + url = self.EXISTINGUSER.format(username=newUser) + user_creation = self.query(url, self._session.post, headers=headers) + machineId = server.machineIdentifier if isinstance(server, PlexServer) else server + sectionIds = self._getSectionIds(server, sections) + params = { + 'server_id': machineId, + 'shared_server': {'library_section_ids': sectionIds, 'invited_email': newUser}, + 'sharing_settings': { + 'allowSync': ('1' if allowSync else '0'), + 'allowCameraUpload': ('1' if allowCameraUpload else '0'), + 'allowChannels': ('1' if allowChannels else '0'), + 'filterMovies': self._filterDictToStr(filterMovies or {}), + 'filterTelevision': self._filterDictToStr(filterTelevision or {}), + 'filterMusic': self._filterDictToStr(filterMusic or {}), + }, + } + url = self.FRIENDINVITE.format(machineId=machineId) + library_assignment = self.query(url, self._session.post, json=params, headers=headers) + return user_creation, library_assignment + + url = self.EXISTINGUSER.format(username=username) + return self.query(url, self._session.post, headers=headers) + def removeFriend(self, user): """ Remove the specified user from all sharing. @@ -240,6 +375,16 @@ def removeFriend(self, user): url = url.format(userId=user.id) return self.query(url, self._session.delete) + def removeHomeUser(self, user): + """ Remove the specified managed user from home. + + Parameters: + user (str): MyPlexUser, username, email of the user to be removed from home. + """ + user = self.user(user) + url = self.REMOVEHOMEUSER.format(userId=user.id) + return self.query(url, self._session.delete) + def updateFriend(self, user, server, sections=None, removeSections=False, allowSync=None, allowCameraUpload=None, allowChannels=None, filterMovies=None, filterTelevision=None, filterMusic=None): """ Update the specified user's share settings. @@ -247,7 +392,8 @@ def updateFriend(self, user, server, sections=None, removeSections=False, allowS Parameters: user (str): MyPlexUser, username, email of the user to be added. server (PlexServer): PlexServer object or machineIdentifier containing the library sections to share. - sections: ([Section]): Library sections, names or ids to be shared (default None shares all sections). + sections: ([Section]): Library sections, names or ids to be shared (default None). + [Section] must be defined in order to update shared sections. removeSections (Bool): Set True to remove all shares. Supersedes sections. allowSync (Bool): Set True to allow user to sync content. allowCameraUpload (Bool): Set True to allow user to upload photos. @@ -273,8 +419,8 @@ def updateFriend(self, user, server, sections=None, removeSections=False, allowS params = {'server_id': machineId, 'shared_server': {'library_section_ids': sectionIds}} url = self.FRIENDSERVERS.format(machineId=machineId, serverId=serverId) else: - params = {'server_id': machineId, 'shared_server': {'library_section_ids': sectionIds, - "invited_id": user.id}} + params = {'server_id': machineId, + 'shared_server': {'library_section_ids': sectionIds, 'invited_id': user.id}} url = self.FRIENDINVITE.format(machineId=machineId) # Remove share sections, add shares to user without shares, or update shares if not user_servers or sectionIds: @@ -318,7 +464,7 @@ def user(self, username): return user elif (user.username and user.email and user.id and username.lower() in - (user.username.lower(), user.email.lower(), str(user.id))): + (user.username.lower(), user.email.lower(), str(user.id))): return user raise NotFound('Unable to find user %s' % username) @@ -489,6 +635,54 @@ def claimToken(self): raise BadRequest('(%s) %s %s; %s' % (response.status_code, codename, response.url, errtext)) return response.json()['token'] + def history(self, maxresults=9999999, mindate=None): + """ Get Play History for all library sections on all servers for the owner. + Parameters: + maxresults (int): Only return the specified number of results (optional). + mindate (datetime): Min datetime to return results from. + """ + servers = [x for x in self.resources() if x.provides == 'server' and x.owned] + hist = [] + for server in servers: + conn = server.connect() + hist.extend(conn.history(maxresults=maxresults, mindate=mindate, accountID=1)) + return hist + + def videoOnDemand(self): + """ Returns a list of VOD Hub items :class:`~plexapi.library.Hub` + """ + req = requests.get(self.VOD + 'hubs/', headers={'X-Plex-Token': self._token}) + elem = ElementTree.fromstring(req.text) + return self.findItems(elem) + + def webShows(self): + """ Returns a list of Webshow Hub items :class:`~plexapi.library.Hub` + """ + req = requests.get(self.WEBSHOWS + 'hubs/', headers={'X-Plex-Token': self._token}) + elem = ElementTree.fromstring(req.text) + return self.findItems(elem) + + def news(self): + """ Returns a list of News Hub items :class:`~plexapi.library.Hub` + """ + req = requests.get(self.NEWS + 'hubs/sections/all', headers={'X-Plex-Token': self._token}) + elem = ElementTree.fromstring(req.text) + return self.findItems(elem) + + def podcasts(self): + """ Returns a list of Podcasts Hub items :class:`~plexapi.library.Hub` + """ + req = requests.get(self.PODCASTS + 'hubs/', headers={'X-Plex-Token': self._token}) + elem = ElementTree.fromstring(req.text) + return self.findItems(elem) + + def tidal(self): + """ Returns a list of tidal Hub items :class:`~plexapi.library.Hub` + """ + req = requests.get(self.MUSIC + 'hubs/', headers={'X-Plex-Token': self._token}) + elem = ElementTree.fromstring(req.text) + return self.findItems(elem) + class MyPlexUser(PlexObject): """ This object represents non-signed in users such as friends and linked @@ -516,6 +710,7 @@ class MyPlexUser(PlexObject): thumb (str): Link to the users avatar. title (str): Seems to be an aliad for username. username (str): User's username. + servers: Servers shared between user and friend """ TAG = 'User' key = 'https://plex.tv/api/users/' @@ -542,6 +737,8 @@ def _loadData(self, data): self.title = data.attrib.get('title', '') self.username = data.attrib.get('username', '') self.servers = self.findItems(data, MyPlexServerShare) + for server in self.servers: + server.accountID = self.id def get_token(self, machineIdentifier): try: @@ -551,6 +748,29 @@ def get_token(self, machineIdentifier): except Exception: log.exception('Failed to get access token for %s' % self.title) + def server(self, name): + """ Returns the :class:`~plexapi.myplex.MyPlexServerShare` that matches the name specified. + + Parameters: + name (str): Name of the server to return. + """ + for server in self.servers: + if name.lower() == server.name.lower(): + return server + + raise NotFound('Unable to find server %s' % name) + + def history(self, maxresults=9999999, mindate=None): + """ Get all Play History for a user in all shared servers. + Parameters: + maxresults (int): Only return the specified number of results (optional). + mindate (datetime): Min datetime to return results from. + """ + hist = [] + for server in self.servers: + hist.extend(server.history(maxresults=maxresults, mindate=mindate)) + return hist + class Section(PlexObject): """ This refers to a shared section. The raw xml for the data presented here @@ -577,6 +797,16 @@ def _loadData(self, data): self.type = data.attrib.get('type') self.shared = utils.cast(bool, data.attrib.get('shared')) + def history(self, maxresults=9999999, mindate=None): + """ Get all Play History for a user for this section in this shared server. + Parameters: + maxresults (int): Only return the specified number of results (optional). + mindate (datetime): Min datetime to return results from. + """ + server = self._server._server.resource(self._server.name).connect() + return server.history(maxresults=maxresults, mindate=mindate, + accountID=self._server.accountID, librarySectionID=self.sectionKey) + class MyPlexServerShare(PlexObject): """ Represents a single user's server reference. Used for library sharing. @@ -599,6 +829,7 @@ def _loadData(self, data): """ Load attribute values from Plex XML response. """ self._data = data self.id = utils.cast(int, data.attrib.get('id')) + self.accountID = utils.cast(int, data.attrib.get('accountID')) self.serverId = utils.cast(int, data.attrib.get('serverId')) self.machineIdentifier = data.attrib.get('machineIdentifier') self.name = data.attrib.get('name') @@ -608,7 +839,21 @@ def _loadData(self, data): self.owned = utils.cast(bool, data.attrib.get('owned')) self.pending = utils.cast(bool, data.attrib.get('pending')) + def section(self, name): + """ Returns the :class:`~plexapi.myplex.Section` that matches the name specified. + + Parameters: + name (str): Name of the section to return. + """ + for section in self.sections(): + if name.lower() == section.title.lower(): + return section + + raise NotFound('Unable to find section %s' % name) + def sections(self): + """ Returns a list of all :class:`~plexapi.myplex.Section` objects shared with this user. + """ url = MyPlexAccount.FRIENDSERVERS.format(machineId=self.machineIdentifier, serverId=self.id) data = self._server.query(url) sections = [] @@ -619,6 +864,15 @@ def sections(self): return sections + def history(self, maxresults=9999999, mindate=None): + """ Get all Play History for a user in this shared server. + Parameters: + maxresults (int): Only return the specified number of results (optional). + mindate (datetime): Min datetime to return results from. + """ + server = self._server.resource(self.name).connect() + return server.history(maxresults=maxresults, mindate=mindate, accountID=self.accountID) + class MyPlexResource(PlexObject): """ This object represents resources connected to your Plex server that can provide @@ -820,6 +1074,186 @@ def syncItems(self): return self._server.syncItems(client=self) +class MyPlexPinLogin(object): + """ + MyPlex PIN login class which supports getting the four character PIN which the user must + enter on https://plex.tv/link to authenticate the client and provide an access token to + create a :class:`~plexapi.myplex.MyPlexAccount` instance. + This helper class supports a polling, threaded and callback approach. + + - The polling approach expects the developer to periodically check if the PIN login was + successful using :func:`plexapi.myplex.MyPlexPinLogin.checkLogin`. + - The threaded approach expects the developer to call + :func:`plexapi.myplex.MyPlexPinLogin.run` and then at a later time call + :func:`plexapi.myplex.MyPlexPinLogin.waitForLogin` to wait for and check the result. + - The callback approach is an extension of the threaded approach and expects the developer + to pass the `callback` parameter to the call to :func:`plexapi.myplex.MyPlexPinLogin.run`. + The callback will be called when the thread waiting for the PIN login to succeed either + finishes or expires. The parameter passed to the callback is the received authentication + token or `None` if the login expired. + + Parameters: + session (requests.Session, optional): Use your own session object if you want to + cache the http responses from PMS + 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' + 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 + POLLINTERVAL = 1 + + def __init__(self, session=None, requestTimeout=None): + super(MyPlexPinLogin, self).__init__() + self._session = session or requests.Session() + self._requestTimeout = requestTimeout or TIMEOUT + + self._loginTimeout = None + self._callback = None + self._thread = None + self._abort = False + self._id = None + + self.finished = False + self.expired = False + self.token = None + self.pin = self._getPin() + + def run(self, callback=None, timeout=None): + """ Starts the thread which monitors the PIN login state. + Parameters: + callback (Callable[str]): Callback called with the received authentication token (optional). + timeout (int): Timeout in seconds waiting for the PIN login to succeed (optional). + + Raises: + :class:`RuntimeError`: if the thread is already running. + :class:`RuntimeError`: if the PIN login for the current PIN has expired. + """ + if self._thread and not self._abort: + raise RuntimeError('MyPlexPinLogin thread is already running') + if self.expired: + raise RuntimeError('MyPlexPinLogin has expired') + + self._loginTimeout = timeout + self._callback = callback + self._abort = False + self.finished = False + self._thread = threading.Thread(target=self._pollLogin, name='plexapi.myplex.MyPlexPinLogin') + self._thread.start() + + def waitForLogin(self): + """ Waits for the PIN login to succeed or expire. + Parameters: + callback (Callable[str]): Callback called with the received authentication token (optional). + timeout (int): Timeout in seconds waiting for the PIN login to succeed (optional). + + Returns: + `True` if the PIN login succeeded or `False` otherwise. + """ + if not self._thread or self._abort: + return False + + self._thread.join() + if self.expired or not self.token: + return False + + return True + + def stop(self): + """ Stops the thread monitoring the PIN login state. """ + if not self._thread or self._abort: + return + + self._abort = True + self._thread.join() + + def checkLogin(self): + """ Returns `True` if the PIN login has succeeded. """ + if self._thread: + return False + + try: + return self._checkLogin() + except Exception: + self.expired = True + self.finished = True + + return False + + def _getPin(self): + if self.pin: + return self.pin + + 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 + + return self.pin + + def _checkLogin(self): + if not self._id: + return False + + if self.token: + return True + + url = self.CHECKPINS.format(pinid=self._id) + response = self._query(url) + if not response: + return False + + token = response.find('auth_token').text + if not token: + return False + + self.token = token + self.finished = True + return True + + def _pollLogin(self): + try: + start = time.time() + while not self._abort and (not self._loginTimeout or (time.time() - start) < self._loginTimeout): + try: + result = self._checkLogin() + except Exception: + self.expired = True + break + + if result: + break + + time.sleep(self.POLLINTERVAL) + + if self.token and self._callback: + self._callback(self.token) + finally: + self.finished = True + + def _query(self, url, method=None): + 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) + if not response.ok: # pragma: no cover + codename = codes.get(response.status_code)[0] + errtext = response.text.replace('\n', ' ') + raise BadRequest('(%s) %s %s; %s' % (response.status_code, codename, response.url, errtext)) + data = response.text.encode('utf8') + return ElementTree.fromstring(data) if data.strip() else None + + def _connect(cls, url, token, timeout, results, i, job_is_done_event=None): """ Connects to the specified cls with url and token. Stores the connection information to results[i] in a threadsafe way. diff --git a/plexapi/photo.py b/plexapi/photo.py index bf1383c30..52bd8bf7a 100644 --- a/plexapi/photo.py +++ b/plexapi/photo.py @@ -1,8 +1,9 @@ # -*- coding: utf-8 -*- +from urllib.parse import quote_plus + from plexapi import media, utils from plexapi.base import PlexPartialObject -from plexapi.exceptions import NotFound, BadRequest -from plexapi.compat import quote_plus +from plexapi.exceptions import BadRequest, NotFound @utils.registerPlexObject @@ -117,6 +118,7 @@ def _loadData(self, data): self.updatedAt = utils.toDatetime(data.attrib.get('updatedAt')) self.year = utils.cast(int, data.attrib.get('year')) self.media = self.findItems(data, media.Media) + self.tag = self.findItems(data, media.Tag) def photoalbum(self): """ Return this photo's :class:`~plexapi.photo.Photoalbum`. """ diff --git a/plexapi/playlist.py b/plexapi/playlist.py index a40665d87..c640ae34f 100644 --- a/plexapi/playlist.py +++ b/plexapi/playlist.py @@ -1,11 +1,12 @@ # -*- coding: utf-8 -*- +from urllib.parse import quote_plus + from plexapi import utils -from plexapi.base import PlexPartialObject, Playable +from plexapi.base import Playable, PlexPartialObject from plexapi.exceptions import BadRequest, Unsupported from plexapi.library import LibrarySection from plexapi.playqueue import PlayQueue from plexapi.utils import cast, toDatetime -from plexapi.compat import quote_plus @utils.registerPlexObject @@ -268,3 +269,41 @@ def sync(self, videoQuality=None, photoResolution=None, audioBitrate=None, clien raise Unsupported('Unsupported playlist content') return myplex.sync(sync_item, client=client, clientId=clientId) + + def posters(self): + """ Returns list of available poster objects. :class:`~plexapi.media.Poster`. """ + + return self.fetchItems('/library/metadata/%s/posters' % self.ratingKey) + + def uploadPoster(self, url=None, filepath=None): + """ Upload poster from url or filepath. :class:`~plexapi.media.Poster` to :class:`~plexapi.video.Video`. """ + if url: + key = '/library/metadata/%s/posters?url=%s' % (self.ratingKey, quote_plus(url)) + self._server.query(key, method=self._server._session.post) + elif filepath: + key = '/library/metadata/%s/posters?' % self.ratingKey + data = open(filepath, 'rb').read() + self._server.query(key, method=self._server._session.post, data=data) + + def setPoster(self, poster): + """ Set . :class:`~plexapi.media.Poster` to :class:`~plexapi.video.Video` """ + poster.select() + + def arts(self): + """ Returns list of available art objects. :class:`~plexapi.media.Poster`. """ + + return self.fetchItems('/library/metadata/%s/arts' % self.ratingKey) + + def uploadArt(self, url=None, filepath=None): + """ Upload art from url or filepath. :class:`~plexapi.media.Poster` to :class:`~plexapi.video.Video`. """ + if url: + key = '/library/metadata/%s/arts?url=%s' % (self.ratingKey, quote_plus(url)) + self._server.query(key, method=self._server._session.post) + elif filepath: + key = '/library/metadata/%s/arts?' % self.ratingKey + data = open(filepath, 'rb').read() + self._server.query(key, method=self._server._session.post, data=data) + + def setArt(self, art): + """ Set :class:`~plexapi.media.Poster` to :class:`~plexapi.video.Video` """ + art.select() diff --git a/plexapi/server.py b/plexapi/server.py index ff4d1bbb3..e61eb5cbf 100644 --- a/plexapi/server.py +++ b/plexapi/server.py @@ -1,22 +1,29 @@ # -*- coding: utf-8 -*- +from urllib.parse import urlencode +from xml.etree import ElementTree + import requests -from requests.status_codes import _codes as codes -from plexapi import BASE_HEADERS, CONFIG, TIMEOUT, X_PLEX_CONTAINER_SIZE -from plexapi import log, logfilter, utils +# Need these imports to populate utils.PLEXOBJECTS +from plexapi import (BASE_HEADERS, CONFIG, TIMEOUT, X_PLEX_CONTAINER_SIZE, log, + logfilter) +from plexapi import media as _media # noqa: F401 +from plexapi import photo as _photo # noqa: F401 +from plexapi import playlist as _playlist # noqa: F401 +from plexapi import utils +from plexapi import video as _video # noqa: F401 from plexapi.alert import AlertListener from plexapi.base import PlexObject from plexapi.client import PlexClient -from plexapi.compat import ElementTree, urlencode -from plexapi.exceptions import BadRequest, NotFound -from plexapi.library import Library, Hub -from plexapi.settings import Settings +from plexapi.exceptions import BadRequest, NotFound, Unauthorized +from plexapi.library import Hub, Library +from plexapi.media import Conversion, Optimized from plexapi.playlist import Playlist from plexapi.playqueue import PlayQueue +from plexapi.settings import Settings from plexapi.utils import cast +from requests.status_codes import _codes as codes -# Need these imports to populate utils.PLEXOBJECTS -from plexapi import (audio as _audio, video as _video, # noqa: F401 - photo as _photo, media as _media, playlist as _playlist) # noqa: F401 +from plexapi import audio as _audio # noqa: F401; noqa: F401 class PlexServer(PlexObject): @@ -183,8 +190,18 @@ def account(self): data = self.query(Account.key) return Account(self, data) + def agents(self, mediaType=None): + """ Returns the `:class:`~plexapi.media.Agent` objects this server has available. """ + key = '/system/agents' + if mediaType: + key += '?mediaType=%s' % mediaType + return self.fetchItems(key) + def createToken(self, type='delegation', scope='all'): """Create a temp access token for the server.""" + if not self._token: + # Handle unclaimed servers + return None q = self.query('/security/token?type=%s&scope=%s' % (type, scope)) return q.attrib.get('token') @@ -322,7 +339,7 @@ def installUpdate(self): # figure out what method this is.. return self.query(part, method=self._session.put) - def history(self, maxresults=9999999, mindate=None): + def history(self, maxresults=9999999, mindate=None, ratingKey=None, accountID=None, librarySectionID=None): """ Returns a list of media items from watched history. If there are many results, they will be fetched from the server in batches of X_PLEX_CONTAINER_SIZE amounts. If you're only looking for the first results, it would be wise to set the maxresults option to that @@ -332,9 +349,18 @@ def history(self, maxresults=9999999, mindate=None): maxresults (int): Only return the specified number of results (optional). mindate (datetime): Min datetime to return results from. This really helps speed up the result listing. For example: datetime.now() - timedelta(days=7) + ratingKey (int/str) Request history for a specific ratingKey item. + accountID (int/str) Request history for a specific account ID. + librarySectionID (int/str) Request history for a specific library section ID. """ results, subresults = [], '_init' - args = {'sort':'viewedAt:desc'} + args = {'sort': 'viewedAt:desc'} + if ratingKey: + args['metadataItemID'] = ratingKey + if accountID: + args['accountID'] = accountID + if librarySectionID: + args['librarySectionID'] = librarySectionID if mindate: args['viewedAt>'] = int(mindate.timestamp()) args['X-Plex-Container-Start'] = 0 @@ -363,6 +389,36 @@ def playlist(self, title): """ return self.fetchItem('/playlists', title=title) + def optimizedItems(self, removeAll=None): + """ Returns list of all :class:`~plexapi.media.Optimized` objects connected to server. """ + if removeAll is True: + key = '/playlists/generators?type=42' + self.query(key, method=self._server._session.delete) + else: + backgroundProcessing = self.fetchItem('/playlists?type=42') + return self.fetchItems('%s/items' % backgroundProcessing.key, cls=Optimized) + + def optimizedItem(self, optimizedID): + """ Returns single queued optimized item :class:`~plexapi.media.Video` object. + Allows for using optimized item ID to connect back to source item. + """ + + backgroundProcessing = self.fetchItem('/playlists?type=42') + return self.fetchItem('%s/items/%s/items' % (backgroundProcessing.key, optimizedID)) + + def conversions(self, pause=None): + """ Returns list of all :class:`~plexapi.media.Conversion` objects connected to server. """ + if pause is True: + self.query('/:/prefs?BackgroundQueueIdlePaused=1', method=self._server._session.put) + elif pause is False: + self.query('/:/prefs?BackgroundQueueIdlePaused=0', method=self._server._session.put) + else: + return self.fetchItems('/playQueues/1', cls=Conversion) + + def currentBackgroundProcess(self): + """ Returns list of all :class:`~plexapi.media.TranscodeJob` objects running or paused on server. """ + return self.fetchItems('/status/sessions/background') + def query(self, key, method=None, headers=None, timeout=None, **kwargs): """ Main method used to handle HTTPS requests to the Plex server. This method helps by encoding the response to utf-8 and parsing the returned XML into and @@ -377,8 +433,13 @@ def query(self, key, method=None, headers=None, timeout=None, **kwargs): if response.status_code not in (200, 201): codename = codes.get(response.status_code)[0] errtext = response.text.replace('\n', ' ') - log.warning('BadRequest (%s) %s %s; %s' % (response.status_code, codename, response.url, errtext)) - raise BadRequest('(%s) %s; %s %s' % (response.status_code, codename, response.url, errtext)) + message = '(%s) %s; %s %s' % (response.status_code, codename, response.url, errtext) + if response.status_code == 401: + raise Unauthorized(message) + elif response.status_code == 404: + raise NotFound(message) + else: + raise BadRequest(message) data = response.text.encode('utf8') return ElementTree.fromstring(data) if data.strip() else None @@ -472,6 +533,25 @@ def refreshSync(self): self.refreshSynclist() self.refreshContent() + def _allowMediaDeletion(self, toggle=False): + """ Toggle allowMediaDeletion. + Parameters: + toggle (bool): True enables Media Deletion + False or None disable Media Deletion (Default) + """ + if self.allowMediaDeletion and toggle is False: + log.debug('Plex is currently allowed to delete media. Toggling off.') + elif self.allowMediaDeletion and toggle is True: + log.debug('Plex is currently allowed to delete media. Toggle set to allow, exiting.') + raise BadRequest('Plex is currently allowed to delete media. Toggle set to allow, exiting.') + elif self.allowMediaDeletion is None and toggle is True: + log.debug('Plex is currently not allowed to delete media. Toggle set to allow.') + else: + log.debug('Plex is currently not allowed to delete media. Toggle set to not allow, exiting.') + raise BadRequest('Plex is currently not allowed to delete media. Toggle set to not allow, exiting.') + value = 1 if toggle is True else 0 + return self.query('/:/prefs?allowMediaDeletion=%s' % value, self._session.put) + class Account(PlexObject): """ Contains the locally cached MyPlex account information. The properties provided don't diff --git a/plexapi/settings.py b/plexapi/settings.py index 0bbc70c86..88b8e4f68 100644 --- a/plexapi/settings.py +++ b/plexapi/settings.py @@ -1,9 +1,9 @@ # -*- coding: utf-8 -*- from collections import defaultdict +from urllib.parse import quote from plexapi import log, utils from plexapi.base import PlexObject -from plexapi.compat import quote, string_type from plexapi.exceptions import BadRequest, NotFound @@ -106,7 +106,7 @@ class Setting(PlexObject): 'bool': {'type': bool, 'cast': _bool_cast, 'tostr': _bool_str}, 'double': {'type': float, 'cast': float, 'tostr': _str}, 'int': {'type': int, 'cast': int, 'tostr': _str}, - 'text': {'type': string_type, 'cast': _str, 'tostr': _str}, + 'text': {'type': str, 'cast': _str, 'tostr': _str}, } def _loadData(self, data): @@ -124,8 +124,8 @@ def _loadData(self, data): self.enumValues = self._getEnumValues(data) def _cast(self, value): - """ Cast the specifief value to the type of this setting. """ - if self.type != 'text': + """ Cast the specific value to the type of this setting. """ + if self.type != 'enum': value = utils.cast(self.TYPES.get(self.type)['cast'], value) return value @@ -155,3 +155,15 @@ def set(self, value): def toUrl(self): """Helper for urls""" return '%s=%s' % (self.id, self._value or self.value) + + +@utils.registerPlexObject +class Preferences(Setting): + """ Represents a single Preferences. + + Attributes: + TAG (str): 'Preferences' + FILTER (str): 'preferences' + """ + TAG = 'Preferences' + FILTER = 'preferences' diff --git a/plexapi/sonos.py b/plexapi/sonos.py new file mode 100644 index 000000000..3bdfc1f21 --- /dev/null +++ b/plexapi/sonos.py @@ -0,0 +1,116 @@ +# -*- coding: utf-8 -*- +import requests +from plexapi import CONFIG, X_PLEX_IDENTIFIER +from plexapi.client import PlexClient +from plexapi.exceptions import BadRequest +from plexapi.playqueue import PlayQueue + + +class PlexSonosClient(PlexClient): + """ Class for interacting with a Sonos speaker via the Plex API. This class + makes requests to an external Plex API which then forwards the + Sonos-specific commands back to your Plex server & Sonos speakers. Use + of this feature requires an active Plex Pass subscription and Sonos + speakers linked to your Plex account. It also requires remote access to + be working properly. + + More details on the Sonos integration are avaialble here: + https://support.plex.tv/articles/218237558-requirements-for-using-plex-for-sonos/ + + The Sonos API emulates the Plex player control API closely: + https://github.com/plexinc/plex-media-player/wiki/Remote-control-API + + Parameters: + account (:class:`~plexapi.myplex.PlexAccount`): PlexAccount instance this + Sonos speaker is associated with. + data (ElementTree): Response from Plex Sonos API used to build this client. + + Attributes: + deviceClass (str): "speaker" + lanIP (str): Local IP address of speaker. + machineIdentifier (str): Unique ID for this device. + platform (str): "Sonos" + platformVersion (str): Build version of Sonos speaker firmware. + product (str): "Sonos" + protocol (str): "plex" + protocolCapabilities (list): List of client capabilities (timeline, playback, + playqueues, provider-playback) + server (:class:`~plexapi.server.PlexServer`): Server this client is connected to. + session (:class:`~requests.Session`): Session object used for connection. + title (str): Name of this Sonos speaker. + token (str): X-Plex-Token used for authenication + _baseurl (str): Address of public Plex Sonos API endpoint. + _commandId (int): Counter for commands sent to Plex API. + _token (str): Token associated with linked Plex account. + _session (obj): Requests session object used to access this client. + """ + + def __init__(self, account, data): + self._data = data + self.deviceClass = data.attrib.get("deviceClass") + self.machineIdentifier = data.attrib.get("machineIdentifier") + self.product = data.attrib.get("product") + self.platform = data.attrib.get("platform") + self.platformVersion = data.attrib.get("platformVersion") + self.protocol = data.attrib.get("protocol") + self.protocolCapabilities = data.attrib.get("protocolCapabilities") + self.lanIP = data.attrib.get("lanIP") + self.title = data.attrib.get("title") + self._baseurl = "https://sonos.plex.tv" + self._commandId = 0 + self._token = account._token + self._session = account._session or requests.Session() + + # Dummy values for PlexClient inheritance + self._last_call = 0 + self._proxyThroughServer = False + self._showSecrets = CONFIG.get("log.show_secrets", "").lower() == "true" + + def playMedia(self, media, offset=0, **params): + + if hasattr(media, "playlistType"): + mediatype = media.playlistType + else: + if isinstance(media, PlayQueue): + mediatype = media.items[0].listType + else: + mediatype = media.listType + + if mediatype == "audio": + mediatype = "music" + else: + raise BadRequest("Sonos currently only supports music for playback") + + server_protocol, server_address, server_port = media._server._baseurl.split(":") + server_address = server_address.strip("/") + server_port = server_port.strip("/") + + playqueue = ( + media + if isinstance(media, PlayQueue) + else media._server.createPlayQueue(media) + ) + self.sendCommand( + "playback/playMedia", + **dict( + { + "type": "music", + "providerIdentifier": "com.plexapp.plugins.library", + "containerKey": "/playQueues/{}?own=1".format( + playqueue.playQueueID + ), + "key": media.key, + "offset": offset, + "machineIdentifier": media._server.machineIdentifier, + "protocol": server_protocol, + "address": server_address, + "port": server_port, + "token": media._server.createToken(), + "commandID": self._nextCommandId(), + "X-Plex-Client-Identifier": X_PLEX_IDENTIFIER, + "X-Plex-Token": media._server._token, + "X-Plex-Target-Client-Identifier": self.machineIdentifier, + }, + **params + ) + ) diff --git a/plexapi/utils.py b/plexapi/utils.py index 531e406ab..58e9be0be 100644 --- a/plexapi/utils.py +++ b/plexapi/utils.py @@ -2,16 +2,23 @@ import logging import os import re -import requests import time import zipfile -from datetime import datetime +from datetime import datetime, timedelta from getpass import getpass -from threading import Thread, Event -from tqdm import tqdm -from plexapi import compat +from threading import Event, Thread +from urllib.parse import quote + +import requests from plexapi.exceptions import NotFound +try: + from tqdm import tqdm +except ImportError: + tqdm = None + +log = logging.getLogger('plexapi') + # Search Types - Plex uses these to filter specific media types when searching. # Library Types - Populated at runtime SEARCHTYPES = {'movie': 1, 'show': 2, 'season': 3, 'episode': 4, 'trailer': 5, 'comic': 6, 'person': 7, @@ -34,7 +41,7 @@ def add_secret(self, secret): def filter(self, record): cleanargs = list(record.args) for i in range(len(cleanargs)): - if isinstance(cleanargs[i], compat.string_type): + if isinstance(cleanargs[i], str): for secret in self.secrets: cleanargs[i] = cleanargs[i].replace(secret, '') record.args = tuple(cleanargs) @@ -57,7 +64,7 @@ def registerPlexObject(cls): def cast(func, value): """ Cast the specified value to the specified type (returned by func). Currently this - only support int, float, bool. Should be extended if needed. + only support str, int, float, bool. Should be extended if needed. Parameters: func (func): Calback function to used cast to type (int, bool, float). @@ -65,7 +72,13 @@ def cast(func, value): """ if value is not None: if func == bool: - return bool(int(value)) + if value in (1, True, "1", "true"): + return True + elif value in (0, False, "0", "false"): + return False + else: + raise ValueError(value) + elif func in (int, float): try: return func(value) @@ -86,8 +99,8 @@ def joinArgs(args): return '' arglist = [] for key in sorted(args, key=lambda x: x.lower()): - value = compat.ustr(args[key]) - arglist.append('%s=%s' % (key, compat.quote(value))) + value = str(args[key]) + arglist.append('%s=%s' % (key, quote(value, safe=''))) return '?%s' % '&'.join(arglist) @@ -97,7 +110,7 @@ def lowerFirst(s): def rget(obj, attrstr, default=None, delim='.'): # pragma: no cover """ Returns the value at the specified attrstr location within a nexted tree of - dicts, lists, tuples, functions, classes, etc. The lookup is done recursivley + dicts, lists, tuples, functions, classes, etc. The lookup is done recursively for each key in attrstr (split by by the delimiter) This function is heavily influenced by the lookups used in Django templates. @@ -135,8 +148,8 @@ def searchType(libtype): Raises: :class:`plexapi.exceptions.NotFound`: Unknown libtype """ - libtype = compat.ustr(libtype) - if libtype in [compat.ustr(v) for v in SEARCHTYPES.values()]: + libtype = str(libtype) + if libtype in [str(v) for v in SEARCHTYPES.values()]: return libtype if SEARCHTYPES.get(libtype) is not None: return SEARCHTYPES[libtype] @@ -176,7 +189,11 @@ def toDatetime(value, format=None): """ if value and value is not None: if format: - value = datetime.strptime(value, format) + try: + value = datetime.strptime(value, format) + except ValueError: + log.info('Failed to parse %s to datetime, defaulting to None', value) + return None else: # https://bugs.python.org/issue30684 # And platform support for before epoch seems to be flaky. @@ -187,6 +204,19 @@ def toDatetime(value, format=None): return value +def millisecondToHumanstr(milliseconds): + """ Returns human readable time duration from milliseconds. + HH:MM:SS:MMMM + + Parameters: + milliseconds (str,int): time duration in milliseconds. + """ + milliseconds = int(milliseconds) + r = datetime.datetime.utcfromtimestamp(milliseconds / 1000) + f = r.strftime("%H:%M:%S.%f") + return f[:-2] + + def toList(value, itemcast=None, delim=','): """ Returns a list of strings from the specified value. @@ -252,15 +282,13 @@ def download(url, token, filename=None, savepath=None, session=None, chunksize=4 >>> download(a_episode.getStreamURL(), a_episode.location) /path/to/file """ - - from plexapi import log # fetch the data to be saved session = session or requests.Session() headers = {'X-Plex-Token': token} response = session.get(url, headers=headers, stream=True) # make sure the savepath directory exists savepath = savepath or os.getcwd() - compat.makedirs(savepath, exist_ok=True) + os.makedirs(savepath, exist_ok=True) # try getting filename from header if not specified in arguments (used for logs, db) if not filename and response.headers.get('Content-Disposition'): @@ -283,17 +311,17 @@ def download(url, token, filename=None, savepath=None, session=None, chunksize=4 # save the file to disk log.info('Downloading: %s', fullpath) - if showstatus: # pragma: no cover + if showstatus and tqdm: # pragma: no cover total = int(response.headers.get('content-length', 0)) bar = tqdm(unit='B', unit_scale=True, total=total, desc=filename) with open(fullpath, 'wb') as handle: for chunk in response.iter_content(chunk_size=chunksize): handle.write(chunk) - if showstatus: + if showstatus and tqdm: bar.update(len(chunk)) - if showstatus: # pragma: no cover + if showstatus and tqdm: # pragma: no cover bar.close() # check we want to unzip the contents if fullpath.endswith('zip') and unpack: @@ -371,3 +399,15 @@ def choose(msg, items, attr): # pragma: no cover except (ValueError, IndexError): pass + + +def getAgentIdentifier(section, agent): + """ Return the full agent identifier from a short identifier, name, or confirm full identifier. """ + agents = [] + for ag in section.agents(): + identifiers = [ag.identifier, ag.shortIdentifier, ag.name] + if agent in identifiers: + return ag.identifier + agents += identifiers + raise NotFound('Couldnt find "%s" in agents list (%s)' % + (agent, ', '.join(agents))) diff --git a/plexapi/video.py b/plexapi/video.py index 837331a01..5396d87fa 100644 --- a/plexapi/video.py +++ b/plexapi/video.py @@ -1,8 +1,10 @@ # -*- coding: utf-8 -*- -from plexapi import media, utils -from plexapi.exceptions import BadRequest, NotFound +import os +from urllib.parse import quote_plus, urlencode + +from plexapi import media, utils, settings, library from plexapi.base import Playable, PlexPartialObject -from plexapi.compat import quote_plus +from plexapi.exceptions import BadRequest, NotFound class Video(PlexPartialObject): @@ -78,10 +80,124 @@ def markUnwatched(self): self._server.query(key) self.reload() + def rate(self, rate): + """ Rate video. """ + key = '/:/rate?key=%s&identifier=com.plexapp.plugins.library&rating=%s' % (self.ratingKey, rate) + + self._server.query(key) + self.reload() + def _defaultSyncTitle(self): """ Returns str, default title for a new syncItem. """ return self.title + def subtitleStreams(self): + """ Returns a list of :class:`~plexapi.media.SubtitleStream` objects for all MediaParts. """ + streams = [] + + parts = self.iterParts() + for part in parts: + streams += part.subtitleStreams() + return streams + + def uploadSubtitles(self, filepath): + """ Upload Subtitle file for video. """ + url = '%s/subtitles' % self.key + filename = os.path.basename(filepath) + subFormat = os.path.splitext(filepath)[1][1:] + with open(filepath, 'rb') as subfile: + params = {'title': filename, + 'format': subFormat + } + headers = {'Accept': 'text/plain, */*'} + self._server.query(url, self._server._session.post, data=subfile, params=params, headers=headers) + + def removeSubtitles(self, streamID=None, streamTitle=None): + """ Remove Subtitle from movie's subtitles listing. + + Note: If subtitle file is located inside video directory it will bbe deleted. + Files outside of video directory are not effected. + """ + for stream in self.subtitleStreams(): + if streamID == stream.id or streamTitle == stream.title: + self._server.query(stream.key, self._server._session.delete) + + def optimize(self, title=None, target="", targetTagID=None, locationID=-1, policyScope='all', + policyValue="", policyUnwatched=0, videoQuality=None, deviceProfile=None): + """ Optimize item + + locationID (int): -1 in folder with orginal items + 2 library path + + target (str): custom quality name. + if none provided use "Custom: {deviceProfile}" + + targetTagID (int): Default quality settings + 1 Mobile + 2 TV + 3 Original Quality + + deviceProfile (str): Android, IOS, Universal TV, Universal Mobile, Windows Phone, + Windows, Xbox One + + Example: + Optimize for Mobile + item.optimize(targetTagID="Mobile") or item.optimize(targetTagID=1") + Optimize for Android 10 MBPS 1080p + item.optimize(deviceProfile="Android", videoQuality=10) + Optimize for IOS Original Quality + item.optimize(deviceProfile="IOS", videoQuality=-1) + + * see sync.py VIDEO_QUALITIES for additional information for using videoQuality + """ + tagValues = [1, 2, 3] + tagKeys = ["Mobile", "TV", "Original Quality"] + tagIDs = tagKeys + tagValues + + if targetTagID not in tagIDs and (deviceProfile is None or videoQuality is None): + raise BadRequest('Unexpected or missing quality profile.') + + if isinstance(targetTagID, str): + tagIndex = tagKeys.index(targetTagID) + targetTagID = tagValues[tagIndex] + + if title is None: + title = self.title + + backgroundProcessing = self.fetchItem('/playlists?type=42') + key = '%s/items?' % backgroundProcessing.key + params = { + 'Item[type]': 42, + 'Item[target]': target, + 'Item[targetTagID]': targetTagID if targetTagID else '', + 'Item[locationID]': locationID, + 'Item[Policy][scope]': policyScope, + 'Item[Policy][value]': policyValue, + 'Item[Policy][unwatched]': policyUnwatched + } + + if deviceProfile: + params['Item[Device][profile]'] = deviceProfile + + if videoQuality: + from plexapi.sync import MediaSettings + mediaSettings = MediaSettings.createVideo(videoQuality) + params['Item[MediaSettings][videoQuality]'] = mediaSettings.videoQuality + params['Item[MediaSettings][videoResolution]'] = mediaSettings.videoResolution + params['Item[MediaSettings][maxVideoBitrate]'] = mediaSettings.maxVideoBitrate + params['Item[MediaSettings][audioBoost]'] = '' + params['Item[MediaSettings][subtitleSize]'] = '' + params['Item[MediaSettings][musicBitrate]'] = '' + params['Item[MediaSettings][photoQuality]'] = '' + + titleParam = {'Item[title]': title} + section = self._server.library.sectionByID(self.librarySectionID) + params['Item[Location][uri]'] = 'library://' + section.uuid + '/item/' + \ + quote_plus(self.key + '?includeExternalMedia=1') + + data = key + urlencode(params) + '&' + urlencode(titleParam) + return self._server.query(data, method=self._server._session.put) + def sync(self, videoQuality, client=None, clientId=None, limit=None, unwatched=False, title=None): """ Add current video (movie, tv-show, season or episode) as sync item for specified device. See :func:`plexapi.myplex.MyPlexAccount.sync()` for possible exceptions. @@ -212,14 +328,6 @@ def locations(self): """ return [part.file for part in self.iterParts() if part] - def subtitleStreams(self): - """ Returns a list of :class:`~plexapi.media.SubtitleStream` objects for all MediaParts. """ - streams = [] - for elem in self.media: - for part in elem.parts: - streams += part.subtitleStreams() - return streams - def _prettyfilename(self): # This is just for compat. return self.title @@ -245,7 +353,7 @@ def download(self, savepath=None, keep_original_name=False, **kwargs): else: self._server.url('%s?download=1' % location.key) filepath = utils.download(url, self._server._token, filename=name, - savepath=savepath, session=self._server._session) + savepath=savepath, session=self._server._session) if filepath: filepaths.append(filepath) return filepaths @@ -282,6 +390,10 @@ class Show(Video): TYPE = 'show' METADATA_TYPE = 'episode' + _include = ('?checkFiles=1&includeExtras=1&includeRelated=1' + '&includeOnDeck=1&includeChapters=1&includePopularLeaves=1' + '&includeMarkers=1&includeConcerts=1&includePreferences=1') + def __iter__(self): for season in self.seasons(): yield season @@ -291,6 +403,7 @@ def _loadData(self, data): Video._loadData(self, data) # fix key if loaded from search self.key = self.key.replace('/children', '') + self._details_key = self.key + self._include self.art = data.attrib.get('art') self.banner = data.attrib.get('banner') self.childCount = utils.cast(int, data.attrib.get('childCount')) @@ -323,6 +436,29 @@ def isWatched(self): """ Returns True if this show is fully watched. """ return bool(self.viewedLeafCount == self.leafCount) + def preferences(self): + """ Returns a list of :class:`~plexapi.settings.Preferences` objects. """ + items = [] + data = self._server.query(self._details_key) + for item in data.iter('Preferences'): + for elem in item: + items.append(settings.Preferences(data=elem, server=self._server)) + + return items + + def hubs(self): + """ Returns a list of :class:`~plexapi.library.Hub` objects. """ + data = self._server.query(self._details_key) + for item in data.iter('Related'): + return self.findItems(item, library.Hub) + + def onDeck(self): + """ Returns shows On Deck :class:`~plexapi.video.Video` object. + If show is unwatched, return will likely be the first episode. + """ + data = self._server.query(self._details_key) + return self.findItems([item for item in data.iter('OnDeck')][0])[0] + def seasons(self, **kwargs): """ Returns a list of :class:`~plexapi.video.Season` objects. """ key = '/library/metadata/%s/children?excludeAllLeaves=1' % self.ratingKey @@ -461,7 +597,7 @@ def episode(self, title=None, episode=None): key = '/library/metadata/%s/children' % self.ratingKey if title: return self.fetchItem(key, title=title) - return self.fetchItem(key, seasonNumber=self.index, index=episode) + return self.fetchItem(key, parentIndex=self.index, index=episode) def get(self, title=None, episode=None): """ Alias to :func:`~plexapi.video.Season.episode()`. """ @@ -469,7 +605,7 @@ def get(self, title=None, episode=None): def show(self): """ Return this seasons :func:`~plexapi.video.Show`.. """ - return self.fetchItem(self.parentKey) + return self.fetchItem(int(self.parentRatingKey)) def watched(self): """ Returns list of watched :class:`~plexapi.video.Episode` objects. """ @@ -537,7 +673,7 @@ class Episode(Playable, Video): _include = ('?checkFiles=1&includeExtras=1&includeRelated=1' '&includeOnDeck=1&includeChapters=1&includePopularLeaves=1' - '&includeConcerts=1&includePreferences=1') + '&includeMarkers=1&includeConcerts=1&includePreferences=1') def _loadData(self, data): """ Load attribute values from Plex XML response. """ @@ -573,6 +709,7 @@ def _loadData(self, data): self.labels = self.findItems(data, media.Label) self.collections = self.findItems(data, media.Collection) self.chapters = self.findItems(data, media.Chapter) + self.markers = self.findItems(data, media.Marker) def __repr__(self): return '<%s>' % ':'.join([p for p in [ @@ -604,14 +741,46 @@ def seasonEpisode(self): """ Returns the s00e00 string containing the season and episode. """ return 's%se%s' % (str(self.seasonNumber).zfill(2), str(self.index).zfill(2)) + @property + def hasIntroMarker(self): + """ Returns True if this episode has an intro marker in the xml. """ + if not self.isFullObject(): + self.reload() + return any(marker.type == 'intro' for marker in self.markers) + def season(self): """" Return this episodes :func:`~plexapi.video.Season`.. """ return self.fetchItem(self.parentKey) def show(self): """" Return this episodes :func:`~plexapi.video.Show`.. """ - return self.fetchItem(self.grandparentKey) + return self.fetchItem(int(self.grandparentRatingKey)) def _defaultSyncTitle(self): """ Returns str, default title for a new syncItem. """ return '%s - %s - (%s) %s' % (self.grandparentTitle, self.parentTitle, self.seasonEpisode, self.title) + + +@utils.registerPlexObject +class Clip(Playable, Video): + """ Represents a single Clip.""" + + TAG = 'Video' + TYPE = 'clip' + METADATA_TYPE = 'clip' + + def _loadData(self, data): + self._data = data + self.addedAt = data.attrib.get('addedAt') + self.duration = data.attrib.get('duration') + self.guid = data.attrib.get('guid') + self.key = data.attrib.get('key') + self.originallyAvailableAt = data.attrib.get('originallyAvailableAt') + self.ratingKey = data.attrib.get('ratingKey') + self.skipDetails = utils.cast(int, data.attrib.get('skipDetails')) + self.subtype = data.attrib.get('subtype') + self.thumb = data.attrib.get('thumb') + self.thumbAspectRatio = data.attrib.get('thumbAspectRatio') + self.title = data.attrib.get('title') + self.type = data.attrib.get('type') + self.year = data.attrib.get('year') diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 000000000..a6a6e3715 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,6 @@ +[pytest] +markers = + client: this is a client test. + req_client: require a client to run this test. + anonymously: test plexapi anonymously. + authenticated: test plexapi authenticated. diff --git a/requirements.txt b/requirements.txt index 08a0faac9..ac8922b4d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,5 +3,3 @@ # pip install -r requirements.txt #--------------------------------------------------------- requests -tqdm -websocket-client diff --git a/requirements_dev.txt b/requirements_dev.txt index 3fb657cc3..79d5d2422 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -8,11 +8,19 @@ pillow pytest pytest-cache pytest-cov -pytest-mock +pytest-mock<=1.11.1 recommonmark requests +requests-mock sphinx -sphinx-rtd-theme sphinxcontrib-napoleon tqdm -websocket-client==0.48.0 +websocket-client +mock; python_version < '3.3' + + +# Installing sphinx-rtd-theme directly from github above is used until a point release +# above 0.4.3 is released. https://github.com/readthedocs/sphinx_rtd_theme/issues/739 +#sphinx-rtd-theme +-e git+https://github.com/readthedocs/sphinx_rtd_theme.git@feb0beb44a444f875f3369a945e6055965ee993f#egg=sphinx_rtd_theme + diff --git a/setup.py b/setup.py index 12e97bf9c..e9b83bf99 100644 --- a/setup.py +++ b/setup.py @@ -34,4 +34,7 @@ install_requires=requirements, long_description=readme, keywords=['plex', 'api'], + classifiers=[ + 'License :: OSI Approved :: BSD License', + ] ) diff --git a/tests/conftest.py b/tests/conftest.py index aae10c835..d1493002c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,74 +4,88 @@ from functools import partial from os import environ +import plexapi import pytest import requests - +from plexapi.client import PlexClient from plexapi.myplex import MyPlexAccount +from plexapi.server import PlexServer + +from .payloads import ACCOUNT_XML try: - from unittest.mock import patch, MagicMock + from unittest.mock import patch, MagicMock, mock_open except ImportError: - from mock import patch, MagicMock + from mock import patch, MagicMock, mock_open -import plexapi -from plexapi import compat -from plexapi.client import PlexClient - -from plexapi.server import PlexServer - - -SERVER_BASEURL = plexapi.CONFIG.get('auth.server_baseurl') -MYPLEX_USERNAME = plexapi.CONFIG.get('auth.myplex_username') -MYPLEX_PASSWORD = plexapi.CONFIG.get('auth.myplex_password') -CLIENT_BASEURL = plexapi.CONFIG.get('auth.client_baseurl') -CLIENT_TOKEN = plexapi.CONFIG.get('auth.client_token') +SERVER_BASEURL = plexapi.CONFIG.get("auth.server_baseurl") +MYPLEX_USERNAME = plexapi.CONFIG.get("auth.myplex_username") +MYPLEX_PASSWORD = plexapi.CONFIG.get("auth.myplex_password") +CLIENT_BASEURL = plexapi.CONFIG.get("auth.client_baseurl") +CLIENT_TOKEN = plexapi.CONFIG.get("auth.client_token") MIN_DATETIME = datetime(1999, 1, 1) -REGEX_EMAIL = r'(^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$)' -REGEX_IPADDR = r'^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$' +REGEX_EMAIL = r"(^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$)" +REGEX_IPADDR = r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$" AUDIOCHANNELS = {2, 6} -AUDIOLAYOUTS = {'5.1', '5.1(side)', 'stereo'} -CODECS = {'aac', 'ac3', 'dca', 'h264', 'mp3', 'mpeg4'} -CONTAINERS = {'avi', 'mp4', 'mkv'} -CONTENTRATINGS = {'TV-14', 'TV-MA', 'G', 'NR', 'Not Rated'} -FRAMERATES = {'24p', 'PAL', 'NTSC'} -PROFILES = {'advanced simple', 'main', 'constrained baseline'} -RESOLUTIONS = {'sd', '480', '576', '720', '1080'} -ENTITLEMENTS = {'ios', 'roku', 'android', 'xbox_one', 'xbox_360', 'windows', 'windows_phone'} - -TEST_AUTHENTICATED = 'authenticated' -TEST_ANONYMOUSLY = 'anonymously' - - +AUDIOLAYOUTS = {"5.1", "5.1(side)", "stereo"} +CODECS = {"aac", "ac3", "dca", "h264", "mp3", "mpeg4"} +CONTAINERS = {"avi", "mp4", "mkv"} +CONTENTRATINGS = {"TV-14", "TV-MA", "G", "NR", "Not Rated"} +FRAMERATES = {"24p", "PAL", "NTSC"} +PROFILES = {"advanced simple", "main", "constrained baseline"} +RESOLUTIONS = {"sd", "480", "576", "720", "1080"} +ENTITLEMENTS = { + "ios", + "roku", + "android", + "xbox_one", + "xbox_360", + "windows", + "windows_phone", +} + +TEST_AUTHENTICATED = "authenticated" +TEST_ANONYMOUSLY = "anonymously" ANON_PARAM = pytest.param(TEST_ANONYMOUSLY, marks=pytest.mark.anonymous) AUTH_PARAM = pytest.param(TEST_AUTHENTICATED, marks=pytest.mark.authenticated) def pytest_addoption(parser): - parser.addoption('--client', action='store_true', default=False, help='Run client tests.') + parser.addoption( + "--client", action="store_true", default=False, help="Run client tests." + ) def pytest_generate_tests(metafunc): - if 'plex' in metafunc.fixturenames: - if 'account' in metafunc.fixturenames or TEST_AUTHENTICATED in metafunc.definition.keywords: - metafunc.parametrize('plex', [AUTH_PARAM], indirect=True) + if "plex" in metafunc.fixturenames: + if ( + "account" in metafunc.fixturenames + or TEST_AUTHENTICATED in metafunc.definition.keywords + ): + metafunc.parametrize("plex", [AUTH_PARAM], indirect=True) else: - metafunc.parametrize('plex', [ANON_PARAM, AUTH_PARAM], indirect=True) - elif 'account' in metafunc.fixturenames: - metafunc.parametrize('account', [AUTH_PARAM], indirect=True) + metafunc.parametrize("plex", [ANON_PARAM, AUTH_PARAM], indirect=True) + elif "account" in metafunc.fixturenames: + metafunc.parametrize("account", [AUTH_PARAM], indirect=True) def pytest_runtest_setup(item): - if 'client' in item.keywords and not item.config.getvalue('client'): - return pytest.skip('Need --client option to run.') - if TEST_AUTHENTICATED in item.keywords and not (MYPLEX_USERNAME and MYPLEX_PASSWORD): - return pytest.skip('You have to specify MYPLEX_USERNAME and MYPLEX_PASSWORD to run authenticated tests') + if "client" in item.keywords and not item.config.getvalue("client"): + return pytest.skip("Need --client option to run.") + if TEST_AUTHENTICATED in item.keywords and not ( + MYPLEX_USERNAME and MYPLEX_PASSWORD + ): + return pytest.skip( + "You have to specify MYPLEX_USERNAME and MYPLEX_PASSWORD to run authenticated tests" + ) if TEST_ANONYMOUSLY in item.keywords and MYPLEX_USERNAME and MYPLEX_PASSWORD: - return pytest.skip('Anonymous tests should be ran on unclaimed server, without providing MYPLEX_USERNAME and ' - 'MYPLEX_PASSWORD') + return pytest.skip( + "Anonymous tests should be ran on unclaimed server, without providing MYPLEX_USERNAME and " + "MYPLEX_PASSWORD" + ) # --------------------------------- @@ -83,41 +97,56 @@ def get_account(): return MyPlexAccount() -@pytest.fixture(scope='session') +@pytest.fixture(scope="session") def account(): - assert MYPLEX_USERNAME, 'Required MYPLEX_USERNAME not specified.' - assert MYPLEX_PASSWORD, 'Required MYPLEX_PASSWORD not specified.' + assert MYPLEX_USERNAME, "Required MYPLEX_USERNAME not specified." + assert MYPLEX_PASSWORD, "Required MYPLEX_PASSWORD not specified." return get_account() -@pytest.fixture(scope='session') +@pytest.fixture(scope="session") def account_once(account): - if environ.get('TEST_ACCOUNT_ONCE') != '1' and environ.get('CI') == 'true': - pytest.skip('Do not forget to test this by providing TEST_ACCOUNT_ONCE=1') + if environ.get("TEST_ACCOUNT_ONCE") != "1" and environ.get("CI") == "true": + pytest.skip("Do not forget to test this by providing TEST_ACCOUNT_ONCE=1") return account -@pytest.fixture(scope='session') +@pytest.fixture(scope="session") def account_plexpass(account): if not account.subscriptionActive: - pytest.skip('PlexPass subscription is not active, unable to test sync-stuff, be careful!') + pytest.skip( + "PlexPass subscription is not active, unable to test sync-stuff, be careful!" + ) return account -@pytest.fixture(scope='session') +@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 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" return account_plexpass -@pytest.fixture(scope='session') +@pytest.fixture() +def mocked_account(requests_mock): + requests_mock.get("https://plex.tv/users/account", text=ACCOUNT_XML) + return MyPlexAccount(token="faketoken") + + +@pytest.fixture(scope="session") def plex(request): - assert SERVER_BASEURL, 'Required SERVER_BASEURL not specified.' + assert SERVER_BASEURL, "Required SERVER_BASEURL not specified." session = requests.Session() if request.param == TEST_AUTHENTICATED: token = get_account().authenticationToken @@ -153,112 +182,141 @@ def fresh_plex(): @pytest.fixture() -def plex2(): +def plex2(plex): return plex() @pytest.fixture() -def client(request): - return PlexClient(plex(), baseurl=CLIENT_BASEURL, token=CLIENT_TOKEN) +def client(request, plex): + return PlexClient(plex, baseurl=CLIENT_BASEURL, token=CLIENT_TOKEN) @pytest.fixture() def tvshows(plex): - return plex.library.section('TV Shows') + return plex.library.section("TV Shows") @pytest.fixture() def movies(plex): - return plex.library.section('Movies') + return plex.library.section("Movies") @pytest.fixture() def music(plex): - return plex.library.section('Music') + return plex.library.section("Music") @pytest.fixture() def photos(plex): - return plex.library.section('Photos') + return plex.library.section("Photos") @pytest.fixture() def movie(movies): - return movies.get('Elephants Dream') + return movies.get("Elephants Dream") + + +@pytest.fixture() +def collection(plex): + try: + return plex.library.section("Movies").collection()[0] + except IndexError: + movie = plex.library.section("Movies").get("Elephants Dream") + movie.addCollection(["marvel"]) + + n = plex.library.section("Movies").reload() + return n.collection()[0] @pytest.fixture() def artist(music): - return music.get('Infinite State') + return music.get("Broke For Free") @pytest.fixture() def album(artist): - return artist.album('Unmastered Impulses') + return artist.album("Layers") @pytest.fixture() def track(album): - return album.track('Holy Moment') + return album.track("As Colourful as Ever") @pytest.fixture() def show(tvshows): - return tvshows.get('Game of Thrones') + return tvshows.get("Game of Thrones") @pytest.fixture() def episode(show): - return show.get('Winter Is Coming') + return show.get("Winter Is Coming") @pytest.fixture() def photoalbum(photos): try: - return photos.get('Cats') - except: - return photos.get('photo_album1') + return photos.get("Cats") + except Exception: + return photos.get("photo_album1") + + +@pytest.fixture() +def subtitle(): + mopen = mock_open() + with patch("__main__.open", mopen): + with open("subtitle.srt", "w") as handler: + handler.write("test") + return handler @pytest.fixture() def shared_username(account): - username = environ.get('SHARED_USERNAME', 'PKKid') + username = environ.get("SHARED_USERNAME", "PKKid") for user in account.users(): if user.title.lower() == username.lower(): return username - elif (user.username and user.email and user.id and username.lower() in - (user.username.lower(), user.email.lower(), str(user.id))): + elif ( + user.username + and user.email + and user.id + and username.lower() + in (user.username.lower(), user.email.lower(), str(user.id)) + ): return username - pytest.skip('Shared user %s wasn`t found in your MyPlex account' % username) + pytest.skip("Shared user %s wasn`t found in your MyPlex account" % username) @pytest.fixture() def monkeydownload(request, monkeypatch): - monkeypatch.setattr('plexapi.utils.download', partial(plexapi.utils.download, mocked=True)) + monkeypatch.setattr( + "plexapi.utils.download", partial(plexapi.utils.download, mocked=True) + ) yield monkeypatch.undo() def callable_http_patch(): """This intented to stop some http requests inside some tests.""" - return patch('plexapi.server.requests.sessions.Session.send', - return_value=MagicMock(status_code=200, - text='')) + return patch( + "plexapi.server.requests.sessions.Session.send", + return_value=MagicMock(status_code=200, text=""), + ) @pytest.fixture() def empty_response(mocker): - response = mocker.MagicMock(status_code=200, text='') + response = mocker.MagicMock(status_code=200, text="") return response @pytest.fixture() def patched_http_call(mocker): """This will stop any http calls inside any test.""" - return mocker.patch('plexapi.server.requests.sessions.Session.send', - return_value=MagicMock(status_code=200, - text='') - ) + return mocker.patch( + "plexapi.server.requests.sessions.Session.send", + return_value=MagicMock(status_code=200, text=""), + ) # --------------------------------- @@ -276,7 +334,7 @@ def is_float(value, gte=1.0): return float(value) >= gte -def is_metadata(key, prefix='/library/metadata/', contains='', suffix=''): +def is_metadata(key, prefix="/library/metadata/", contains="", suffix=""): try: assert key.startswith(prefix) assert contains in key @@ -287,19 +345,19 @@ def is_metadata(key, prefix='/library/metadata/', contains='', suffix=''): def is_part(key): - return is_metadata(key, prefix='/library/parts/') + return is_metadata(key, prefix="/library/parts/") def is_section(key): - return is_metadata(key, prefix='/library/sections/') + return is_metadata(key, prefix="/library/sections/") def is_string(value, gte=1): - return isinstance(value, compat.string_type) and len(value) >= gte + return isinstance(value, str) and len(value) >= gte def is_thumb(key): - return is_metadata(key, contains='/thumb/') + return is_metadata(key, contains="/thumb/") def wait_until(condition_function, delay=0.25, timeout=1, *args, **kwargs): @@ -311,6 +369,9 @@ def wait_until(condition_function, delay=0.25, timeout=1, *args, **kwargs): time.sleep(delay) ready = condition_function(*args, **kwargs) - assert ready, 'Wait timeout after %d retries, %.2f seconds' % (retries, time.time() - start) + assert ready, "Wait timeout after %d retries, %.2f seconds" % ( + retries, + time.time() - start, + ) return ready diff --git a/tests/data/audio_stub.mp3 b/tests/data/audio_stub.mp3 new file mode 100644 index 000000000..3b14f6ca9 Binary files /dev/null and b/tests/data/audio_stub.mp3 differ diff --git a/tests/data/cute_cat.jpg b/tests/data/cute_cat.jpg new file mode 100644 index 000000000..d0ba21a4b Binary files /dev/null and b/tests/data/cute_cat.jpg differ diff --git a/tests/data/video_stub.mp4 b/tests/data/video_stub.mp4 new file mode 100644 index 000000000..d9a10e310 Binary files /dev/null and b/tests/data/video_stub.mp4 differ diff --git a/tests/payloads.py b/tests/payloads.py new file mode 100644 index 000000000..940e0d574 --- /dev/null +++ b/tests/payloads.py @@ -0,0 +1,24 @@ +ACCOUNT_XML = """ + + + + + + + + + + + testuser + testuser@email.com + 2000-01-01 12:348:56 UTC + faketoken + +""" + +SONOS_RESOURCES = """ + + + + +""" diff --git a/tests/test_actions.py b/tests/test_actions.py index 614bbbd45..994068d8d 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -19,3 +19,12 @@ def test_refresh_section(tvshows): def test_refresh_video(movie): movie.refresh() + + +def test_rate_movie(movie): + oldrate = movie.userRating + if oldrate is None: + oldrate = 1 + movie.rate(10.0) + assert movie.userRating == 10.0, 'User rating 10.0 after rating five stars.' + movie.rate(oldrate) diff --git a/tests/test_audio.py b/tests/test_audio.py index a3463b81d..0cc2b1cc9 100644 --- a/tests/test_audio.py +++ b/tests/test_audio.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- from datetime import datetime + from . import conftest as utils @@ -7,104 +8,122 @@ def test_audio_Artist_attr(artist): artist.reload() assert utils.is_datetime(artist.addedAt) assert artist.countries == [] - assert [i.tag for i in artist.genres] == ['Electronic'] + assert "Electronic" in [i.tag for i in artist.genres] assert utils.is_string(artist.guid, gte=5) - assert artist.index == '1' + assert artist.index == "1" assert utils.is_metadata(artist._initpath) assert utils.is_metadata(artist.key) assert utils.is_int(artist.librarySectionID) - assert artist.listType == 'audio' + assert artist.listType == "audio" assert len(artist.locations) == 1 assert len(artist.locations[0]) >= 10 assert artist.ratingKey >= 1 assert artist._server._baseurl == utils.SERVER_BASEURL - assert [a.tag for a in artist.similar] == ['Kenneth Reitz'] # flaky? - assert artist.summary == '' - assert artist.title == 'Infinite State' - assert artist.titleSort == 'Infinite State' - assert artist.type == 'artist' + assert isinstance(artist.similar, list) + assert "Alias" in artist.summary + assert artist.title == "Broke For Free" + assert artist.titleSort == "Broke For Free" + assert artist.type == "artist" assert utils.is_datetime(artist.updatedAt) assert utils.is_int(artist.viewCount, gte=0) def test_audio_Artist_get(artist, music): - artist == music.searchArtists(**{'title': 'Infinite State'})[0] - artist.title == 'Infinite State' + artist == music.searchArtists(**{"title": "Broke For Free"})[0] + artist.title == "Broke For Free" + + +def test_audio_Artist_history(artist): + history = artist.history() + assert isinstance(history, list) def test_audio_Artist_track(artist): - track = artist.track('Holy Moment') - assert track.title == 'Holy Moment' + track = artist.track("As Colourful as Ever") + assert track.title == "As Colourful as Ever" def test_audio_Artist_tracks(artist): tracks = artist.tracks() - assert len(tracks) == 14 + assert len(tracks) == 1 def test_audio_Artist_album(artist): - album = artist.album('Unmastered Impulses') - assert album.title == 'Unmastered Impulses' + album = artist.album("Layers") + assert album.title == "Layers" def test_audio_Artist_albums(artist): albums = artist.albums() - assert len(albums) == 1 and albums[0].title == 'Unmastered Impulses' + assert len(albums) == 1 and albums[0].title == "Layers" def test_audio_Album_attrs(album): assert utils.is_datetime(album.addedAt) - assert [i.tag for i in album.genres] == ['Electronic'] - assert album.index == '1' + assert isinstance(album.genres, list) + assert album.index == "1" assert utils.is_metadata(album._initpath) assert utils.is_metadata(album.key) assert utils.is_int(album.librarySectionID) - assert album.listType == 'audio' - assert album.originallyAvailableAt == datetime(2016, 1, 1) + assert album.listType == "audio" + if album.originallyAvailableAt: + assert utils.is_datetime(album.originallyAvailableAt) assert utils.is_metadata(album.parentKey) assert utils.is_int(album.parentRatingKey) if album.parentThumb: - assert utils.is_metadata(album.parentThumb, contains='/thumb/') - assert album.parentTitle == 'Infinite State' + assert utils.is_metadata(album.parentThumb, contains="/thumb/") + assert album.parentTitle == "Broke For Free" assert album.ratingKey >= 1 assert album._server._baseurl == utils.SERVER_BASEURL assert album.studio is None - assert album.summary == '' - assert utils.is_metadata(album.thumb, contains='/thumb/') - assert album.title == 'Unmastered Impulses' - assert album.titleSort == 'Unmastered Impulses' - assert album.type == 'album' + assert album.summary == "" + if album.thumb: + assert utils.is_metadata(album.thumb, contains="/thumb/") + assert album.title == "Layers" + assert album.titleSort == "Layers" + assert album.type == "album" assert utils.is_datetime(album.updatedAt) assert utils.is_int(album.viewCount, gte=0) - assert album.year == 2016 + assert album.year in (2012,) assert album.artUrl is None + +def test_audio_Album_history(album): + history = album.history() + assert isinstance(history, list) + + +def test_audio_Track_history(track): + history = track.history() + assert isinstance(history, list) + + def test_audio_Album_tracks(album): tracks = album.tracks() track = tracks[0] - assert len(tracks) == 14 + assert len(tracks) == 1 assert utils.is_metadata(track.grandparentKey) assert utils.is_int(track.grandparentRatingKey) - assert track.grandparentTitle == 'Infinite State' - assert track.index == '1' + assert track.grandparentTitle == "Broke For Free" + assert track.index == "1" assert utils.is_metadata(track._initpath) assert utils.is_metadata(track.key) - assert track.listType == 'audio' - assert track.originalTitle == 'Kenneth Reitz' - assert utils.is_int(track.parentIndex) + assert track.listType == "audio" + assert track.originalTitle in (None, "Broke For Free") + # assert utils.is_int(track.parentIndex) assert utils.is_metadata(track.parentKey) assert utils.is_int(track.parentRatingKey) - assert utils.is_metadata(track.parentThumb, contains='/thumb/') - assert track.parentTitle == 'Unmastered Impulses' - assert track.ratingCount == 9 + assert utils.is_metadata(track.parentThumb, contains="/thumb/") + assert track.parentTitle == "Layers" + # assert track.ratingCount == 9 # Flaky assert utils.is_int(track.ratingKey) assert track._server._baseurl == utils.SERVER_BASEURL assert track.summary == "" - assert utils.is_metadata(track.thumb, contains='/thumb/') - assert track.title == 'Holy Moment' - assert track.titleSort == 'Holy Moment' + assert utils.is_metadata(track.thumb, contains="/thumb/") + assert track.title == "As Colourful as Ever" + assert track.titleSort == "As Colourful as Ever" assert not track.transcodeSessions - assert track.type == 'track' + assert track.type == "track" assert utils.is_datetime(track.updatedAt) assert utils.is_int(track.viewCount, gte=0) assert track.viewOffset == 0 @@ -112,46 +131,47 @@ def test_audio_Album_tracks(album): def test_audio_Album_track(album, track=None): # this is not reloaded. its not that much info missing. - track = track or album.track('Holy Moment') + track = track or album.track("As Colourful As Ever") assert utils.is_datetime(track.addedAt) - assert track.duration == 298606 + assert utils.is_int(track.duration) assert utils.is_metadata(track.grandparentKey) assert utils.is_int(track.grandparentRatingKey) - assert track.grandparentTitle == 'Infinite State' + assert track.grandparentTitle == "Broke For Free" assert int(track.index) == 1 assert utils.is_metadata(track._initpath) assert utils.is_metadata(track.key) - assert track.listType == 'audio' + assert track.listType == "audio" # Assign 0 track.media media = track.media[0] - assert track.originalTitle == 'Kenneth Reitz' + assert track.originalTitle in (None, "As Colourful As Ever") + # Fix me assert utils.is_int(track.parentIndex) assert utils.is_metadata(track.parentKey) assert utils.is_int(track.parentRatingKey) - assert utils.is_metadata(track.parentThumb, contains='/thumb/') - assert track.parentTitle == 'Unmastered Impulses' - assert track.ratingCount == 9 + assert utils.is_metadata(track.parentThumb, contains="/thumb/") + assert track.parentTitle == "Layers" + # assert track.ratingCount == 9 assert utils.is_int(track.ratingKey) assert track._server._baseurl == utils.SERVER_BASEURL - assert track.summary == '' - assert utils.is_metadata(track.thumb, contains='/thumb/') - assert track.title == 'Holy Moment' - assert track.titleSort == 'Holy Moment' + assert track.summary == "" + assert utils.is_metadata(track.thumb, contains="/thumb/") + assert track.title == "As Colourful as Ever" + assert track.titleSort == "As Colourful as Ever" assert not track.transcodeSessions - assert track.type == 'track' + assert track.type == "track" assert utils.is_datetime(track.updatedAt) assert utils.is_int(track.viewCount, gte=0) assert track.viewOffset == 0 assert media.aspectRatio is None assert media.audioChannels == 2 - assert media.audioCodec == 'mp3' - assert media.bitrate == 385 - assert media.container == 'mp3' - assert media.duration == 298606 - assert media.height is None + assert media.audioCodec == "mp3" + assert media.bitrate == 128 + assert media.container == "mp3" + assert utils.is_int(media.duration) + assert media.height in (None, 1080) assert utils.is_int(media.id, gte=1) assert utils.is_metadata(media._initpath) - assert media.optimizedForStreaming is None + assert media.optimizedForStreaming in (None, True) # Assign 0 media.parts part = media.parts[0] assert media._server._baseurl == utils.SERVER_BASEURL @@ -159,69 +179,69 @@ def test_audio_Album_track(album, track=None): assert media.videoFrameRate is None assert media.videoResolution is None assert media.width is None - assert part.container == 'mp3' - assert part.duration == 298606 - assert part.file.endswith('.mp3') + assert part.container == "mp3" + assert utils.is_int(part.duration) + assert part.file.endswith(".mp3") assert utils.is_int(part.id) assert utils.is_metadata(part._initpath) assert utils.is_part(part.key) assert part._server._baseurl == utils.SERVER_BASEURL - assert part.size == 14360402 + assert part.size == 3761053 assert track.artUrl is None def test_audio_Album_get(album): # alias for album.track() - track = album.get('Holy Moment') + track = album.get("As Colourful As Ever") test_audio_Album_track(album, track=track) def test_audio_Album_artist(album): artist = album.artist() - artist.title == 'Infinite State' + artist.title == "Broke For Free" def test_audio_Track_attrs(album): - track = album.get('Holy Moment').reload() + track = album.get("As Colourful As Ever").reload() assert utils.is_datetime(track.addedAt) assert track.art is None assert track.chapterSource is None - assert track.duration == 298606 + assert utils.is_int(track.duration) assert track.grandparentArt is None assert utils.is_metadata(track.grandparentKey) assert utils.is_int(track.grandparentRatingKey) if track.grandparentThumb: - assert utils.is_metadata(track.grandparentThumb, contains='/thumb/') - assert track.grandparentTitle == 'Infinite State' - assert track.guid.startswith('local://') + assert utils.is_metadata(track.grandparentThumb, contains="/thumb/") + assert track.grandparentTitle == "Broke For Free" + assert track.guid.startswith("local://") assert int(track.index) == 1 assert utils.is_metadata(track._initpath) assert utils.is_metadata(track.key) if track.lastViewedAt: assert utils.is_datetime(track.lastViewedAt) assert utils.is_int(track.librarySectionID) - assert track.listType == 'audio' + assert track.listType == "audio" # Assign 0 track.media media = track.media[0] assert track.moods == [] - assert track.originalTitle == 'Kenneth Reitz' + assert track.originalTitle in (None, "Broke For Free") assert int(track.parentIndex) == 1 assert utils.is_metadata(track.parentKey) assert utils.is_int(track.parentRatingKey) - assert utils.is_metadata(track.parentThumb, contains='/thumb/') - assert track.parentTitle == 'Unmastered Impulses' + assert utils.is_metadata(track.parentThumb, contains="/thumb/") + assert track.parentTitle == "Layers" assert track.playlistItemID is None assert track.primaryExtraKey is None - assert track.ratingCount == 9 + # assert utils.is_int(track.ratingCount) assert utils.is_int(track.ratingKey) assert track._server._baseurl == utils.SERVER_BASEURL assert track.sessionKey is None - assert track.summary == '' - assert utils.is_metadata(track.thumb, contains='/thumb/') - assert track.title == 'Holy Moment' - assert track.titleSort == 'Holy Moment' + assert track.summary == "" + assert utils.is_metadata(track.thumb, contains="/thumb/") + assert track.title == "As Colourful as Ever" + assert track.titleSort == "As Colourful as Ever" assert not track.transcodeSessions - assert track.type == 'track' + assert track.type == "track" assert utils.is_datetime(track.updatedAt) assert utils.is_int(track.viewCount, gte=0) assert track.viewOffset == 0 @@ -229,10 +249,10 @@ def test_audio_Track_attrs(album): assert track.year is None assert media.aspectRatio is None assert media.audioChannels == 2 - assert media.audioCodec == 'mp3' - assert media.bitrate == 385 - assert media.container == 'mp3' - assert media.duration == 298606 + assert media.audioCodec == "mp3" + assert media.bitrate == 128 + assert media.container == "mp3" + assert utils.is_int(media.duration) assert media.height is None assert utils.is_int(media.id, gte=1) assert utils.is_metadata(media._initpath) @@ -244,23 +264,23 @@ def test_audio_Track_attrs(album): assert media.videoFrameRate is None assert media.videoResolution is None assert media.width is None - assert part.container == 'mp3' - assert part.duration == 298606 - assert part.file.endswith('.mp3') + assert part.container == "mp3" + assert utils.is_int(part.duration) + assert part.file.endswith(".mp3") assert utils.is_int(part.id) assert utils.is_metadata(part._initpath) assert utils.is_part(part.key) - #assert part.media == + # assert part.media == assert part._server._baseurl == utils.SERVER_BASEURL - assert part.size == 14360402 + assert part.size == 3761053 # Assign 0 part.streams stream = part.streams[0] - assert stream.audioChannelLayout == 'stereo' + assert stream.audioChannelLayout == "stereo" assert stream.bitDepth is None - assert stream.bitrate == 320 + assert stream.bitrate == 128 assert stream.bitrateMode is None assert stream.channels == 2 - assert stream.codec == 'mp3' + assert stream.codec == "mp3" assert stream.codecID is None assert stream.dialogNorm is None assert stream.duration is None @@ -269,8 +289,8 @@ def test_audio_Track_attrs(album): assert utils.is_metadata(stream._initpath) assert stream.language is None assert stream.languageCode is None - #assert stream.part == - assert stream.samplingRate == 44100 + # assert stream.part == + assert stream.samplingRate == 48000 assert stream.selected is True assert stream._server._baseurl == utils.SERVER_BASEURL assert stream.streamType == 2 @@ -302,13 +322,13 @@ def test_audio_Track_download(monkeydownload, tmpdir, track): def test_audio_album_download(monkeydownload, album, tmpdir): f = album.download(savepath=str(tmpdir)) - assert len(f) == 14 + assert len(f) == 1 def test_audio_Artist_download(monkeydownload, artist, tmpdir): f = artist.download(savepath=str(tmpdir)) - assert len(f) == 14 + assert len(f) == 1 def test_audio_Album_label(album, patched_http_call): - album.addLabel('YO') + album.addLabel("YO") diff --git a/tests/test_client.py b/tests/test_client.py index 220b827b2..f5863543b 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1,12 +1,17 @@ # -*- coding: utf-8 -*- -import pytest, time +import time + +import pytest def _check_capabilities(client, capabilities): supported = client.protocolCapabilities for capability in capabilities: if capability not in supported: - pytest.skip('Client doesnt support %s capability.', capability) + pytest.skip( + "Client %s doesnt support %s capability support %s" + % (client.title, capability, supported) + ) def _check_proxy(plex, client, proxy): @@ -16,68 +21,123 @@ def _check_proxy(plex, client, proxy): @pytest.mark.client def test_list_clients(account, plex): - assert account.resources(), 'MyPlex is not listing any devlices.' - assert account.devices(), 'MyPlex is not listing any devlices.' - assert plex.clients(), 'PlexServer is not listing any clients.' + assert account.resources(), "MyPlex is not listing any devlices." + assert account.devices(), "MyPlex is not listing any devlices." + assert plex.clients(), "PlexServer is not listing any clients." @pytest.mark.client -@pytest.mark.parametrize('proxy', [False, True]) +@pytest.mark.parametrize("proxy", [False, True]) def test_client_navigation(plex, client, episode, artist, proxy): - _check_capabilities(client, ['navigation']) - _check_proxy(plex, client, proxy) + + _check_capabilities(client, ["navigation"]) + client.proxyThroughServer(proxy) try: - print('\nclient.moveUp()'); client.moveUp(); time.sleep(0.5) - print('client.moveLeft()'); client.moveLeft(); time.sleep(0.5) - print('client.moveDown()'); client.moveDown(); time.sleep(0.5) - print('client.moveRight()'); client.moveRight(); time.sleep(0.5) - print('client.select()'); client.select(); time.sleep(3) - print('client.goBack()'); client.goBack(); time.sleep(1) - print('client.goToMedia(episode)'); client.goToMedia(episode); time.sleep(5) - print('client.goToMedia(artist)'); client.goToMedia(artist); time.sleep(5) - #print('client.contextMenu'); client.contextMenu(); time.sleep(3) # socket.timeout + print("\nclient.moveUp()") + client.moveUp() + time.sleep(0.5) + print("client.moveLeft()") + client.moveLeft() + time.sleep(0.5) + print("client.moveDown()") + client.moveDown() + time.sleep(0.5) + print("client.moveRight()") + client.moveRight() + time.sleep(0.5) + print("client.select()") + client.select() + time.sleep(3) + print("client.goBack()") + client.goBack() + time.sleep(1) + print("client.goToMedia(episode)") + client.goToMedia(episode) + time.sleep(5) + print("client.goToMedia(artist)") + client.goToMedia(artist) + time.sleep(5) + # print('client.contextMenu'); client.contextMenu(); time.sleep(3) # socket.timeout finally: - print('client.goToHome()'); client.goToHome(); time.sleep(2) + print("client.goToHome()") + client.goToHome() + time.sleep(2) @pytest.mark.client -@pytest.mark.parametrize('proxy', [False, True]) -def test_client_playback(plex, client, movie, proxy): - _check_capabilities(client, ['playback']) - _check_proxy(plex, client, proxy) +@pytest.mark.parametrize("proxy", [False, True]) +def test_client_playback(plex, client, movies, proxy): + + movie = movies.get("Big buck bunny") + + _check_capabilities(client, ["playback"]) + client.proxyThroughServer(proxy) + try: # Need a movie with subtitles - print('mtype=video'); mtype = 'video' - movie = plex.library.section('Movies').get('Moana').reload() - subs = [stream for stream in movie.subtitleStreams() if stream.language == 'English'] - print('client.playMedia(movie)'); client.playMedia(movie); time.sleep(5) - print('client.pause(mtype)'); client.pause(mtype); time.sleep(2) - print('client.stepForward(mtype)'); client.stepForward(mtype); time.sleep(5) - print('client.play(mtype)'); client.play(mtype); time.sleep(3) - print('client.stepBack(mtype)'); client.stepBack(mtype); time.sleep(5) - print('client.play(mtype)'); client.play(mtype); time.sleep(3) - print('client.seekTo(10*60*1000)'); client.seekTo(10*60*1000); time.sleep(5) - print('client.setSubtitleStream(0)'); client.setSubtitleStream(0, mtype); time.sleep(10) - print('client.setSubtitleStream(subs[0])'); client.setSubtitleStream(subs[0].id, mtype); time.sleep(10) - print('client.stop(mtype)'); client.stop(mtype); time.sleep(1) + mtype = "video" + subs = [ + stream for stream in movie.subtitleStreams() if stream.language == "English" + ] + print("client.playMedia(%s)" % movie.title) + client.playMedia(movie) + time.sleep(5) + print("client.pause(%s)" % mtype) + client.pause(mtype) + time.sleep(2) + print("client.stepForward(%s)" % mtype) + client.stepForward(mtype) + time.sleep(5) + print("client.play(%s)" % mtype) + client.play(mtype) + time.sleep(3) + print("client.stepBack(%s)" % mtype) + client.stepBack(mtype) + time.sleep(5) + print("client.play(%s)" % mtype) + client.play(mtype) + time.sleep(3) + print("client.seekTo(1*60*1000)") + client.seekTo(1 * 60 * 1000) + time.sleep(5) + print("client.setSubtitleStream(0)") + client.setSubtitleStream(0, mtype) + time.sleep(10) + if subs: + print("client.setSubtitleStream(subs[0])") + client.setSubtitleStream(subs[0].id, mtype) + time.sleep(10) + print("client.stop(%s)" % mtype) + client.stop(mtype) + time.sleep(1) finally: - print('movie.markWatched'); movie.markWatched(); time.sleep(2) + print("movie.markWatched") + movie.markWatched() + time.sleep(2) @pytest.mark.client -@pytest.mark.parametrize('proxy', [False, True]) -def test_client_timeline(plex, client, movie, proxy): - _check_capabilities(client, ['timeline']) +@pytest.mark.parametrize("proxy", [False, True]) +def test_client_timeline(plex, client, movies, proxy): + + movie = movies.get("Big buck bunny") + _check_capabilities(client, ["timeline"]) _check_proxy(plex, client, proxy) try: # Note: We noticed the isPlaying flag could take up to a full # 30 seconds to be updated, hence the long sleeping. - print('mtype=video'); mtype = 'video' - print('time.sleep(30)'); time.sleep(30) # clear isPlaying flag + mtype = "video" + client.stop(mtype) assert client.isPlayingMedia() is False - print('client.playMedia(movie)'); client.playMedia(movie); time.sleep(30) + print("client.playMedia(movie)") + client.playMedia(movie) + time.sleep(10) assert client.isPlayingMedia() is True - print('client.stop(mtype)'); client.stop(mtype); time.sleep(30) + print("client.stop(%s)" % mtype) + client.stop(mtype) + time.sleep(10) assert client.isPlayingMedia() is False finally: - print('movie.markWatched()'); movie.markWatched(); time.sleep(2) + print("movie.markWatched()") + movie.markWatched() + time.sleep(2) diff --git a/tests/test_gdm.py b/tests/test_gdm.py new file mode 100644 index 000000000..f1cb5ac63 --- /dev/null +++ b/tests/test_gdm.py @@ -0,0 +1,15 @@ +import pytest +from plexapi.gdm import GDM + + +@pytest.mark.xfail(reason="Might fail on docker", strict=False) +def test_gdm(plex): + gdm = GDM() + + gdm_enabled = plex.settings.get("GdmEnabled") + + gdm.scan(timeout=2) + if gdm_enabled: + assert len(gdm.entries) + else: + assert not len(gdm.entries) diff --git a/tests/test_history.py b/tests/test_history.py new file mode 100644 index 000000000..af5c65fef --- /dev/null +++ b/tests/test_history.py @@ -0,0 +1,97 @@ +# -*- coding: utf-8 -*- +from datetime import datetime + +import pytest +from plexapi.exceptions import BadRequest, NotFound + +from . import conftest as utils + + +def test_history_Movie(movie): + movie.markWatched() + history = movie.history() + assert len(history) + movie.markUnwatched() + + +def test_history_Show(show): + show.markWatched() + history = show.history() + assert len(history) + show.markUnwatched() + + +def test_history_Season(show): + season = show.season("Season 1") + season.markWatched() + history = season.history() + assert len(history) + season.markUnwatched() + + +def test_history_Episode(episode): + episode.markWatched() + history = episode.history() + assert len(history) + episode.markUnwatched() + + +def test_history_Artist(artist): + history = artist.history() + + +def test_history_Album(album): + history = album.history() + + +def test_history_Track(track): + history = track.history() + + +def test_history_MyAccount(account, movie, show): + movie.markWatched() + show.markWatched() + history = account.history() + assert len(history) + movie.markUnwatched() + show.markUnwatched() + + +def test_history_MyLibrary(plex, movie, show): + movie.markWatched() + show.markWatched() + history = plex.library.history() + assert len(history) + movie.markUnwatched() + show.markUnwatched() + + +def test_history_MySection(plex, movie): + movie.markWatched() + history = plex.library.section("Movies").history() + assert len(history) + movie.markUnwatched() + + +def test_history_MyServer(plex, movie): + movie.markWatched() + history = plex.history() + assert len(history) + movie.markUnwatched() + + +def test_history_User(account, shared_username): + user = account.user(shared_username) + history = user.history() + + +def test_history_UserServer(account, shared_username, plex): + userSharedServer = account.user(shared_username).server(plex.friendlyName) + history = userSharedServer.history() + + +def test_history_UserSection(account, shared_username, plex): + userSharedServerSection = ( + account.user(shared_username).server(plex.friendlyName).section("Movies") + ) + history = userSharedServerSection.history() diff --git a/tests/test_library.py b/tests/test_library.py index f753328b3..17f8f54a0 100644 --- a/tests/test_library.py +++ b/tests/test_library.py @@ -1,16 +1,17 @@ # -*- coding: utf-8 -*- import pytest from plexapi.exceptions import NotFound + from . import conftest as utils def test_library_Library_section(plex): sections = plex.library.sections() assert len(sections) >= 3 - section_name = plex.library.section('TV Shows') - assert section_name.title == 'TV Shows' + section_name = plex.library.section("TV Shows") + assert section_name.title == "TV Shows" with pytest.raises(NotFound): - assert plex.library.section('cant-find-me') + assert plex.library.section("cant-find-me") def test_library_Library_sectionByID_is_equal_section(plex, movies): @@ -20,29 +21,39 @@ def test_library_Library_sectionByID_is_equal_section(plex, movies): def test_library_sectionByID_with_attrs(plex, movies): - assert movies.agent == 'com.plexapp.agents.imdb' - assert movies.allowSync is ('sync' in plex.ownerFeatures) - assert movies.art == '/:/resources/movie-fanart.jpg' - assert utils.is_metadata(movies.composite, prefix='/library/sections/', contains='/composite/') + assert movies.agent == "com.plexapp.agents.imdb" + # This seems to fail for some reason. + # my account alloew of sync, didnt find any about settings about the library. + # assert movies.allowSync is ('sync' in plex.ownerFeatures) + assert movies.art == "/:/resources/movie-fanart.jpg" + assert utils.is_metadata( + movies.composite, prefix="/library/sections/", contains="/composite/" + ) assert utils.is_datetime(movies.createdAt) - assert movies.filters == '1' - assert movies._initpath == '/library/sections' + assert movies.filters == "1" + assert movies._initpath == "/library/sections" assert utils.is_int(movies.key) - assert movies.language == 'en' + assert movies.language == "en" assert len(movies.locations) == 1 assert len(movies.locations[0]) >= 10 assert movies.refreshing is False - assert movies.scanner == 'Plex Movie Scanner' + assert movies.scanner == "Plex Movie Scanner" assert movies._server._baseurl == utils.SERVER_BASEURL - assert movies.thumb == '/:/resources/movie.png' - assert movies.title == 'Movies' - assert movies.type == 'movie' + assert movies.thumb == "/:/resources/movie.png" + assert movies.title == "Movies" + assert movies.type == "movie" assert utils.is_datetime(movies.updatedAt) assert len(movies.uuid) == 36 def test_library_section_get_movie(plex): - assert plex.library.section('Movies').get('Sita Sings the Blues') + assert plex.library.section("Movies").get("Sita Sings the Blues") + + +def test_library_section_movies_all(movies): + # size should always be none unless pagenation is being used. + assert movies.totalSize == 4 + assert len(movies.all(container_start=0, container_size=1)) == 1 def test_library_section_delete(movies, patched_http_call): @@ -50,9 +61,9 @@ def test_library_section_delete(movies, patched_http_call): def test_library_fetchItem(plex, movie): - item1 = plex.library.fetchItem('/library/metadata/%s' % movie.ratingKey) + item1 = plex.library.fetchItem("/library/metadata/%s" % movie.ratingKey) item2 = plex.library.fetchItem(movie.ratingKey) - assert item1.title == 'Elephants Dream' + assert item1.title == "Elephants Dream" assert item1 == item2 == movie @@ -68,14 +79,20 @@ def test_library_recentlyAdded(plex): def test_library_add_edit_delete(plex): # Dont add a location to prevent scanning scanning - section_name = 'plexapi_test_section' - plex.library.add(name=section_name, type='movie', agent='com.plexapp.agents.imdb', - scanner='Plex Movie Scanner', language='en') + section_name = "plexapi_test_section" + plex.library.add( + name=section_name, + type="movie", + agent="com.plexapp.agents.imdb", + scanner="Plex Movie Scanner", + language="en", + ) assert plex.library.section(section_name) - edited_library = plex.library.section(section_name).edit(name='a renamed lib', - type='movie', agent='com.plexapp.agents.imdb') - assert edited_library.title == 'a renamed lib' - plex.library.section('a renamed lib').delete() + edited_library = plex.library.section(section_name).edit( + name="a renamed lib", type="movie", agent="com.plexapp.agents.imdb" + ) + assert edited_library.title == "a renamed lib" + plex.library.section("a renamed lib").delete() def test_library_Library_cleanBundle(plex): @@ -108,13 +125,13 @@ def test_library_Library_deleteMediaPreviews(plex): def test_library_Library_all(plex): - assert len(plex.library.all(title__iexact='The 100')) + assert len(plex.library.all(title__iexact="The 100")) def test_library_Library_search(plex): - item = plex.library.search('Elephants Dream')[0] - assert item.title == 'Elephants Dream' - assert len(plex.library.search(libtype='episode')) + item = plex.library.search("Elephants Dream")[0] + assert item.title == "Elephants Dream" + assert len(plex.library.search(libtype="episode")) def test_library_MovieSection_update(movies): @@ -122,7 +139,7 @@ def test_library_MovieSection_update(movies): def test_library_ShowSection_all(tvshows): - assert len(tvshows.all(title__iexact='The 100')) + assert len(tvshows.all(title__iexact="The 100")) def test_library_MovieSection_refresh(movies, patched_http_call): @@ -130,7 +147,7 @@ def test_library_MovieSection_refresh(movies, patched_http_call): def test_library_MovieSection_search_genre(movie, movies): - animation = [i for i in movie.genres if i.tag == 'Animation'] + animation = [i for i in movie.genres if i.tag == "Animation"] assert len(movies.search(genre=animation[0])) > 1 @@ -160,11 +177,11 @@ def test_library_MovieSection_analyze(movies): def test_library_ShowSection_searchShows(tvshows): - assert tvshows.searchShows(title='The 100') + assert tvshows.searchShows(title="The 100") def test_library_ShowSection_searchEpisodes(tvshows): - assert tvshows.searchEpisodes(title='Winter Is Coming') + assert tvshows.searchEpisodes(title="Winter Is Coming") def test_library_ShowSection_recentlyAdded(tvshows): @@ -176,11 +193,11 @@ def test_library_MusicSection_albums(music): def test_library_MusicSection_searchTracks(music): - assert len(music.searchTracks(title='Holy Moment')) + assert len(music.searchTracks(title="As Colourful As Ever")) def test_library_MusicSection_searchAlbums(music): - assert len(music.searchAlbums(title='Unmastered Impulses')) + assert len(music.searchAlbums(title="Layers")) def test_library_PhotoSection_searchAlbums(photos, photoalbum): @@ -195,27 +212,56 @@ def test_library_PhotoSection_searchPhotos(photos, photoalbum): def test_library_and_section_search_for_movie(plex): - find = '16 blocks' + find = "16 blocks" l_search = plex.library.search(find) - s_search = plex.library.section('Movies').search(find) + s_search = plex.library.section("Movies").search(find) assert l_search == s_search -# This started failing on more recent Plex Server builds -@pytest.mark.xfail -def test_search_with_apostrophe(plex): - show_title = 'Marvel\'s Daredevil' - result_root = plex.search(show_title) - result_shows = plex.library.section('TV Shows').search(show_title) +def test_library_Collection_modeUpdate(collection): + mode_dict = {"default": "-2", "hide": "0", "hideItems": "1", "showItems": "2"} + for key, value in mode_dict.items(): + collection.modeUpdate(key) + collection.reload() + assert collection.collectionMode == value + + +def test_library_Colletion_sortAlpha(collection): + collection.sortUpdate(sort="alpha") + collection.reload() + assert collection.collectionSort == "1" + + +def test_library_Colletion_sortRelease(collection): + collection.sortUpdate(sort="release") + collection.reload() + assert collection.collectionSort == "0" + + +def test_search_with_weird_a(plex): + ep_title = "Coup de Grâce" + result_root = plex.search(ep_title) + result_shows = plex.library.section("TV Shows").searchEpisodes(title=ep_title) assert result_root assert result_shows assert result_root == result_shows def test_crazy_search(plex, movie): - movies = plex.library.section('Movies') - assert movie in movies.search(actor=movie.actors[0], sort='titleSort'), 'Unable to search movie by actor.' - assert movie in movies.search(director=movie.directors[0]), 'Unable to search movie by director.' - assert movie in movies.search(year=['2006', '2007']), 'Unable to search movie by year.' - assert movie not in movies.search(year=2007), 'Unable to filter movie by year.' + movies = plex.library.section("Movies") + assert movie in movies.search( + actor=movie.actors[0], sort="titleSort" + ), "Unable to search movie by actor." + assert movie in movies.search( + director=movie.directors[0] + ), "Unable to search movie by director." + assert movie in movies.search( + year=["2006", "2007"] + ), "Unable to search movie by year." + assert movie not in movies.search(year=2007), "Unable to filter movie by year." assert movie in movies.search(actor=movie.actors[0].tag) + assert len(movies.search(container_start=2, maxresults=1)) == 1 + assert len(movies.search(container_size=None)) == 4 + assert len(movies.search(container_size=1)) == 4 + assert len(movies.search(container_start=9999, container_size=1)) == 0 + assert len(movies.search(container_start=2, container_size=1)) == 2 diff --git a/tests/test_misc.py b/tests/test_misc.py index cffe39e6b..4a3eb20aa 100644 --- a/tests/test_misc.py +++ b/tests/test_misc.py @@ -1,25 +1,28 @@ # -*- coding: utf-8 -*- import os -import pytest import shlex import subprocess from os.path import abspath, dirname, join -SKIP_EXAMPLES = ['Example 4'] +import pytest + +SKIP_EXAMPLES = ["Example 4"] -@pytest.mark.skipif(os.name == 'nt', reason='No make.bat specified for Windows') +@pytest.mark.skipif(os.name == "nt", reason="No make.bat specified for Windows") def test_build_documentation(): - docroot = join(dirname(dirname(abspath(__file__))), 'docs') - cmd = shlex.split('sphinx-build -aE . _build') - proc = subprocess.Popen(cmd, cwd=docroot, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + docroot = join(dirname(dirname(abspath(__file__))), "docs") + cmd = shlex.split("sphinx-build -aE . _build") + proc = subprocess.Popen( + cmd, cwd=docroot, stdout=subprocess.PIPE, stderr=subprocess.PIPE + ) status = proc.wait() assert status == 0 issues = [] for output in proc.communicate(): - for line in str(output).split('\\n'): + for line in str(output).split("\\n"): line = line.lower().strip() - if 'warning' in line or 'error' in line or 'traceback' in line: + if "warning" in line or "error" in line or "traceback" in line: issues.append(line) for line in issues: print(line) @@ -29,30 +32,30 @@ def test_build_documentation(): def test_readme_examples(plex): failed = 0 examples = _fetch_examples() - assert len(examples), 'No examples found in README' + assert len(examples), "No examples found in README" for title, example in examples: if _check_run_example(title): try: - print('\n%s\n%s' % (title, '-' * len(title))) - exec('\n'.join(example)) + print("\n%s\n%s" % (title, "-" * len(title))) + exec("\n".join(example)) except Exception as err: failed += 1 - print('Error running test: %s\nError: %s' % (title, err)) - assert not failed, '%s examples raised an exception.' % failed + print("Error running test: %s\nError: %s" % (title, err)) + assert not failed, "%s examples raised an exception." % failed def _fetch_examples(): parsing = False examples = [] - filepath = join(dirname(dirname(abspath(__file__))), 'README.rst') - with open(filepath, 'r') as handle: - for line in handle.read().split('\n'): + filepath = join(dirname(dirname(abspath(__file__))), "README.rst") + with open(filepath, "r") as handle: + for line in handle.read().split("\n"): line = line[4:] - if line.startswith('# Example '): + if line.startswith("# Example "): parsing = True - title = line.lstrip('# ') + title = line.lstrip("# ") examples.append([title, []]) - elif parsing and line == '': + elif parsing and line == "": parsing = False elif parsing: examples[-1][1].append(line) diff --git a/tests/test_myplex.py b/tests/test_myplex.py index 2762aef95..d09e1ed1c 100644 --- a/tests/test_myplex.py +++ b/tests/test_myplex.py @@ -1,40 +1,41 @@ # -*- coding: utf-8 -*- import pytest from plexapi.exceptions import BadRequest, NotFound + from . import conftest as utils def test_myplex_accounts(account, plex): - assert account, 'Must specify username, password & resource to run this test.' - print('MyPlexAccount:') - print('username: %s' % account.username) - print('email: %s' % account.email) - print('home: %s' % account.home) - print('queueEmail: %s' % account.queueEmail) - assert account.username, 'Account has no username' - assert account.authenticationToken, 'Account has no authenticationToken' - assert account.email, 'Account has no email' - assert account.home is not None, 'Account has no home' - assert account.queueEmail, 'Account has no queueEmail' + assert account, "Must specify username, password & resource to run this test." + print("MyPlexAccount:") + print("username: %s" % account.username) + print("email: %s" % account.email) + print("home: %s" % account.home) + print("queueEmail: %s" % account.queueEmail) + assert account.username, "Account has no username" + assert account.authenticationToken, "Account has no authenticationToken" + assert account.email, "Account has no email" + assert account.home is not None, "Account has no home" + assert account.queueEmail, "Account has no queueEmail" account = plex.account() - print('Local PlexServer.account():') - print('username: %s' % account.username) - #print('authToken: %s' % account.authToken) - print('signInState: %s' % account.signInState) - assert account.username, 'Account has no username' - assert account.authToken, 'Account has no authToken' - assert account.signInState, 'Account has no signInState' + print("Local PlexServer.account():") + print("username: %s" % account.username) + # print('authToken: %s' % account.authToken) + print("signInState: %s" % account.signInState) + assert account.username, "Account has no username" + assert account.authToken, "Account has no authToken" + assert account.signInState, "Account has no signInState" def test_myplex_resources(account): - assert account, 'Must specify username, password & resource to run this test.' + assert account, "Must specify username, password & resource to run this test." resources = account.resources() for resource in resources: - name = resource.name or 'Unknown' + name = resource.name or "Unknown" connections = [c.uri for c in resource.connections] - connections = ', '.join(connections) if connections else 'None' - print('%s (%s): %s' % (name, resource.product, connections)) - assert resources, 'No resources found for account: %s' % account.name + connections = ", ".join(connections) if connections else "None" + print("%s (%s): %s" % (name, resource.product, connections)) + assert resources, "No resources found for account: %s" % account.name def test_myplex_connect_to_resource(plex, account): @@ -48,14 +49,15 @@ def test_myplex_connect_to_resource(plex, account): def test_myplex_devices(account): devices = account.devices() for device in devices: - name = device.name or 'Unknown' - connections = ', '.join(device.connections) if device.connections else 'None' - print('%s (%s): %s' % (name, device.product, connections)) - assert devices, 'No devices found for account: %s' % account.name + name = device.name or "Unknown" + connections = ", ".join(device.connections) if device.connections else "None" + print("%s (%s): %s" % (name, device.product, connections)) + assert devices, "No devices found for account: %s" % account.name 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) @@ -63,22 +65,24 @@ def test_myplex_device(account, plex): def _test_myplex_connect_to_device(account): devices = account.devices() for device in devices: - if device.name == 'some client name' and len(device.connections): + if device.name == "some client name" and len(device.connections): break client = device.connect() - assert client, 'Unable to connect to device' + assert client, "Unable to connect to device" def test_myplex_users(account): users = account.users() if not len(users): - return pytest.skip('You have to add a shared account into your MyPlex') - print('Found %s users.' % len(users)) + return pytest.skip("You have to add a shared account into your MyPlex") + print("Found %s users." % len(users)) user = account.user(users[0].title) - print('Found user: %s' % user) - assert user, 'Could not find user %s' % users[0].title + print("Found user: %s" % user) + assert user, "Could not find user %s" % users[0].title - assert len(users[0].servers[0].sections()) > 0, "Couldn't info about the shared libraries" + assert ( + len(users[0].servers[0].sections()) > 0 + ), "Couldn't info about the shared libraries" def test_myplex_resource(account, plex): @@ -95,25 +99,25 @@ def test_myplex_webhooks(account): def test_myplex_addwebhooks(account): if account.subscriptionActive: - assert 'http://example.com' in account.addWebhook('http://example.com') + assert "http://example.com" in account.addWebhook("http://example.com") else: with pytest.raises(BadRequest): - account.addWebhook('http://example.com') + account.addWebhook("http://example.com") def test_myplex_deletewebhooks(account): if account.subscriptionActive: - assert 'http://example.com' not in account.deleteWebhook('http://example.com') + assert "http://example.com" not in account.deleteWebhook("http://example.com") else: with pytest.raises(BadRequest): - account.deleteWebhook('http://example.com') + account.deleteWebhook("http://example.com") def test_myplex_optout(account_once): def enabled(): - ele = account_once.query('https://plex.tv/api/v2/user/privacy') - lib = ele.attrib.get('optOutLibraryStats') - play = ele.attrib.get('optOutPlayback') + ele = account_once.query("https://plex.tv/api/v2/user/privacy") + lib = ele.attrib.get("optOutLibraryStats") + play = ele.attrib.get("optOutPlayback") return bool(int(lib)), bool(int(play)) account_once.optOut(library=True, playback=True) @@ -123,17 +127,25 @@ def enabled(): def test_myplex_inviteFriend_remove(account, plex, mocker): - inv_user = 'hellowlol' - vid_filter = {'contentRating': ['G'], 'label': ['foo']} + inv_user = "hellowlol" + vid_filter = {"contentRating": ["G"], "label": ["foo"]} secs = plex.library.sections() ids = account._getSectionIds(plex.machineIdentifier, secs) - with mocker.patch.object(account, '_getSectionIds', return_value=ids): + with mocker.patch.object(account, "_getSectionIds", return_value=ids): with utils.callable_http_patch(): - account.inviteFriend(inv_user, plex, secs, allowSync=True, allowCameraUpload=True, - allowChannels=False, filterMovies=vid_filter, filterTelevision=vid_filter, - filterMusic={'label': ['foo']}) + account.inviteFriend( + inv_user, + plex, + secs, + allowSync=True, + allowCameraUpload=True, + allowChannels=False, + filterMovies=vid_filter, + filterTelevision=vid_filter, + filterMusic={"label": ["foo"]}, + ) assert inv_user not in [u.title for u in account.users()] @@ -143,29 +155,68 @@ def test_myplex_inviteFriend_remove(account, plex, mocker): def test_myplex_updateFriend(account, plex, mocker, shared_username): - vid_filter = {'contentRating': ['G'], 'label': ['foo']} + vid_filter = {"contentRating": ["G"], "label": ["foo"]} secs = plex.library.sections() user = account.user(shared_username) ids = account._getSectionIds(plex.machineIdentifier, secs) - with mocker.patch.object(account, '_getSectionIds', return_value=ids): - with mocker.patch.object(account, 'user', return_value=user): + with mocker.patch.object(account, "_getSectionIds", return_value=ids): + with mocker.patch.object(account, "user", return_value=user): with utils.callable_http_patch(): - account.updateFriend(shared_username, plex, secs, allowSync=True, removeSections=True, - allowCameraUpload=True, allowChannels=False, filterMovies=vid_filter, - filterTelevision=vid_filter, filterMusic={'label': ['foo']}) + account.updateFriend( + shared_username, + plex, + secs, + allowSync=True, + removeSections=True, + allowCameraUpload=True, + allowChannels=False, + filterMovies=vid_filter, + filterTelevision=vid_filter, + filterMusic={"label": ["foo"]}, + ) + + +def test_myplex_createExistingUser(account, plex, shared_username): + user = account.user(shared_username) + url = "https://plex.tv/api/invites/requested/{}?friend=0&server=0&home=1".format( + user.id + ) + + account.createExistingUser(user, plex) + assert shared_username in [u.username for u in account.users() if u.home is True] + # Remove Home invite + account.query(url, account._session.delete) + # Confirm user was removed from home and has returned to friend + assert shared_username not in [ + u.username for u in plex.myPlexAccount().users() if u.home is True + ] + assert shared_username in [ + u.username for u in plex.myPlexAccount().users() if u.home is False + ] + + +@pytest.mark.skip(reason="broken test?") +def test_myplex_createHomeUser_remove(account, plex): + homeuser = "New Home User" + account.createHomeUser(homeuser, plex) + assert homeuser in [u.title for u in plex.myPlexAccount().users() if u.home is True] + account.removeHomeUser(homeuser) + assert homeuser not in [ + u.title for u in plex.myPlexAccount().users() if u.home is True + ] def test_myplex_plexpass_attributes(account_plexpass): assert account_plexpass.subscriptionActive - assert account_plexpass.subscriptionStatus == 'Active' + assert account_plexpass.subscriptionStatus == "Active" assert account_plexpass.subscriptionPlan - assert 'sync' in account_plexpass.subscriptionFeatures - assert 'premium_music_metadata' in account_plexpass.subscriptionFeatures - assert 'plexpass' in account_plexpass.roles + assert "sync" in account_plexpass.subscriptionFeatures + assert "premium_music_metadata" in account_plexpass.subscriptionFeatures + assert "plexpass" in account_plexpass.roles assert set(account_plexpass.entitlements) == utils.ENTITLEMENTS def test_myplex_claimToken(account): - assert account.claimToken().startswith('claim-') + assert account.claimToken().startswith("claim-") diff --git a/tests/test_navigation.py b/tests/test_navigation.py index e8f4d8d2e..00f2f083d 100644 --- a/tests/test_navigation.py +++ b/tests/test_navigation.py @@ -2,37 +2,33 @@ def test_navigate_around_show(account, plex): - show = plex.library.section('TV Shows').get('The 100') + show = plex.library.section("TV Shows").get("The 100") seasons = show.seasons() - season = show.season('Season 1') + season = show.season("Season 1") episodes = show.episodes() - episode = show.episode('Pilot') - assert 'Season 1' in [s.title for s in seasons], 'Unable to list season:' - assert 'Pilot' in [e.title for e in episodes], 'Unable to list episode:' + episode = show.episode("Pilot") + assert "Season 1" in [s.title for s in seasons], "Unable to list season:" + assert "Pilot" in [e.title for e in episodes], "Unable to list episode:" assert show.season(1) == season - assert show.episode('Pilot') == episode, 'Unable to get show episode:' - assert season.episode('Pilot') == episode, 'Unable to get season episode:' - assert season.show() == show, 'season.show() doesnt match expected show.' - assert episode.show() == show, 'episode.show() doesnt match expected show.' - assert episode.season() == season, 'episode.season() doesnt match expected season.' + assert show.episode("Pilot") == episode, "Unable to get show episode:" + assert season.episode("Pilot") == episode, "Unable to get season episode:" + assert season.show() == show, "season.show() doesnt match expected show." + assert episode.show() == show, "episode.show() doesnt match expected show." + assert episode.season() == season, "episode.season() doesnt match expected season." def test_navigate_around_artist(account, plex): - artist = plex.library.section('Music').get('Infinite State') + artist = plex.library.section("Music").get("Broke For Free") albums = artist.albums() - album = artist.album('Unmastered Impulses') + album = artist.album("Layers") tracks = artist.tracks() - track = artist.track('Mantra') - print('Navigating around artist: %s' % artist) - print('Albums: %s...' % albums[:3]) - print('Album: %s' % album) - print('Tracks: %s...' % tracks[:3]) - print('Track: %s' % track) - assert 'Unmastered Impulses' in [a.title for a in albums], 'Unable to list album.' - assert 'Mantra' in [e.title for e in tracks], 'Unable to list track.' - assert artist.album('Unmastered Impulses') == album, 'Unable to get artist album.' - assert artist.track('Mantra') == track, 'Unable to get artist track.' - assert album.track('Mantra') == track, 'Unable to get album track.' - assert album.artist() == artist, 'album.artist() doesnt match expected artist.' - assert track.artist() == artist, 'track.artist() doesnt match expected artist.' - assert track.album() == album, 'track.album() doesnt match expected album.' + track = artist.track("As Colourful as Ever") + print("Navigating around artist: %s" % artist) + print("Album: %s" % album) + print("Tracks: %s..." % tracks) + print("Track: %s" % track) + assert artist.track("As Colourful as Ever") == track, "Unable to get artist track." + assert album.track("As Colourful as Ever") == track, "Unable to get album track." + assert album.artist() == artist, "album.artist() doesnt match expected artist." + assert track.artist() == artist, "track.artist() doesnt match expected artist." + assert track.album() == album, "track.album() doesnt match expected album." diff --git a/tests/test_photo.py b/tests/test_photo.py index efde68197..b4eadb98f 100644 --- a/tests/test_photo.py +++ b/tests/test_photo.py @@ -1,9 +1,7 @@ - - def test_photo_Photoalbum(photoalbum): assert len(photoalbum.albums()) == 3 assert len(photoalbum.photos()) == 3 - cats_in_bed = photoalbum.album('Cats in bed') + cats_in_bed = photoalbum.album("Cats in bed") assert len(cats_in_bed.photos()) == 7 - a_pic = cats_in_bed.photo('photo7') + a_pic = cats_in_bed.photo("photo7") assert a_pic diff --git a/tests/test_playlist.py b/tests/test_playlist.py index 1d2b78915..7303b7bb3 100644 --- a/tests/test_playlist.py +++ b/tests/test_playlist.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- import time + import pytest diff --git a/tests/test_server.py b/tests/test_server.py index eb4f96a55..73b094b6c 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -1,10 +1,14 @@ # -*- coding: utf-8 -*- -import pytest, re, time +import re +import time + +import pytest +from PIL import Image, ImageStat from plexapi.exceptions import BadRequest, NotFound from plexapi.server import PlexServer from plexapi.utils import download -from PIL import Image, ImageStat from requests import Session + from . import conftest as utils @@ -14,11 +18,11 @@ def test_server_attr(plex, account): assert len(plex.machineIdentifier) == 40 assert plex.myPlex is True # if you run the tests very shortly after server creation the state in rare cases may be `unknown` - assert plex.myPlexMappingState in ('mapped', 'unknown') - assert plex.myPlexSigninState == 'ok' + assert plex.myPlexMappingState in ("mapped", "unknown") + assert plex.myPlexSigninState == "ok" assert utils.is_int(plex.myPlexSubscription, gte=0) assert re.match(utils.REGEX_EMAIL, plex.myPlexUsername) - assert plex.platform in ('Linux', 'Windows') + assert plex.platform in ("Linux", "Windows") assert len(plex.platformVersion) >= 5 assert plex._token == account.authenticationToken assert utils.is_int(plex.transcoderActiveVideoSessions, gte=0) @@ -49,28 +53,37 @@ def test_server_library(plex): def test_server_url(plex): - assert 'ohno' in plex.url('ohno') + assert "ohno" in plex.url("ohno") def test_server_transcodeImage(tmpdir, plex, show): width, height = 500, 500 imgurl = plex.transcodeImage(show.banner, height, width) gray = imgurl = plex.transcodeImage(show.banner, height, width, saturation=0) - resized_img = download(imgurl, plex._token, savepath=str(tmpdir), filename='resize_image') - original_img = download(show._server.url(show.banner), plex._token, savepath=str(tmpdir), filename='original_img') - grayscale_img = download(gray, plex._token, savepath=str(tmpdir), filename='grayscale_img') + resized_img = download( + imgurl, plex._token, savepath=str(tmpdir), filename="resize_image" + ) + original_img = download( + show._server.url(show.banner), + plex._token, + savepath=str(tmpdir), + filename="original_img", + ) + grayscale_img = download( + gray, plex._token, savepath=str(tmpdir), filename="grayscale_img" + ) with Image.open(resized_img) as image: assert width, height == image.size with Image.open(original_img) as image: assert width, height != image.size - assert _detect_color_image(grayscale_img, thumb_size=150) == 'grayscale' + assert _detect_color_image(grayscale_img, thumb_size=150) == "grayscale" def _detect_color_image(file, thumb_size=150, MSE_cutoff=22, adjust_color_bias=True): # http://stackoverflow.com/questions/20068945/detect-if-image-is-color-grayscale-or-black-and-white-with-python-pil pilimg = Image.open(file) bands = pilimg.getbands() - if bands == ('R', 'G', 'B') or bands == ('R', 'G', 'B', 'A'): + if bands == ("R", "G", "B") or bands == ("R", "G", "B", "A"): thumb = pilimg.resize((thumb_size, thumb_size)) sse, bias = 0, [0, 0, 0] if adjust_color_bias: @@ -78,26 +91,34 @@ def _detect_color_image(file, thumb_size=150, MSE_cutoff=22, adjust_color_bias=T bias = [b - sum(bias) / 3 for b in bias] for pixel in thumb.getdata(): mu = sum(pixel) / 3 - sse += sum((pixel[i] - mu - bias[i]) * (pixel[i] - mu - bias[i]) for i in [0, 1, 2]) + sse += sum( + (pixel[i] - mu - bias[i]) * (pixel[i] - mu - bias[i]) for i in [0, 1, 2] + ) mse = float(sse) / (thumb_size * thumb_size) - return 'grayscale' if mse <= MSE_cutoff else 'color' + return "grayscale" if mse <= MSE_cutoff else "color" elif len(bands) == 1: - return 'blackandwhite' + return "blackandwhite" + + +def test_server_fetchitem_notfound(plex): + with pytest.raises(NotFound): + plex.fetchItem(123456789) def test_server_search(plex, movie): title = movie.title + # this search seem to fail on my computer but not at travis, wtf. assert plex.search(title) - assert plex.search(title, mediatype='movie') + assert plex.search(title, mediatype="movie") def test_server_playlist(plex, show): episodes = show.episodes() - playlist = plex.createPlaylist('test_playlist', episodes[:3]) + playlist = plex.createPlaylist("test_playlist", episodes[:3]) try: - assert playlist.title == 'test_playlist' + assert playlist.title == "test_playlist" with pytest.raises(NotFound): - plex.playlist('') + plex.playlist("") finally: playlist.delete() @@ -106,7 +127,7 @@ def test_server_playlists(plex, show): playlists = plex.playlists() count = len(playlists) episodes = show.episodes() - playlist = plex.createPlaylist('test_playlist', episodes[:3]) + playlist = plex.createPlaylist("test_playlist", episodes[:3]) try: playlists = plex.playlists() assert len(playlists) == count + 1 @@ -121,22 +142,10 @@ def test_server_history(plex, movie): movie.markUnwatched() -@pytest.mark.anonymously def test_server_Server_query(plex): - assert plex.query('/') - with pytest.raises(BadRequest): - assert plex.query('/asdf/1234/asdf', headers={'random_headers': '1234'}) - - -@pytest.mark.authenticated -def test_server_Server_query_authenticated(plex): - assert plex.query('/') - with pytest.raises(BadRequest): - assert plex.query('/asdf/1234/asdf', headers={'random_headers': '1234'}) - with pytest.raises(BadRequest): - # This is really requests.exceptions.HTTPError - # 401 Client Error: Unauthorized for url - PlexServer(utils.SERVER_BASEURL, '1234') + assert plex.query("/") + with pytest.raises(NotFound): + assert plex.query("/asdf/1234/asdf", headers={"random_headers": "1234"}) def test_server_Server_session(account): @@ -145,28 +154,31 @@ class MySession(Session): def __init__(self): super(self.__class__, self).__init__() self.plexapi_session_test = True + # Test Code - plex = PlexServer(utils.SERVER_BASEURL, account.authenticationToken, session=MySession()) - assert hasattr(plex._session, 'plexapi_session_test') + plex = PlexServer( + utils.SERVER_BASEURL, account.authenticationToken, session=MySession() + ) + assert hasattr(plex._session, "plexapi_session_test") @pytest.mark.authenticated def test_server_token_in_headers(plex): headers = plex._headers() - assert 'X-Plex-Token' in headers - assert len(headers['X-Plex-Token']) >= 1 + assert "X-Plex-Token" in headers + assert len(headers["X-Plex-Token"]) >= 1 def test_server_createPlayQueue(plex, movie): playqueue = plex.createPlayQueue(movie, shuffle=1, repeat=1) - assert 'shuffle=1' in playqueue._initpath - assert 'repeat=1' in playqueue._initpath + assert "shuffle=1" in playqueue._initpath + assert "repeat=1" in playqueue._initpath assert playqueue.playQueueShuffled is True def test_server_client_not_found(plex): with pytest.raises(NotFound): - plex.client('') + plex.client("") def test_server_sessions(plex): @@ -175,89 +187,93 @@ def test_server_sessions(plex): def test_server_isLatest(plex, mocker): from os import environ + is_latest = plex.isLatest() - if environ.get('PLEX_CONTAINER_TAG') and environ['PLEX_CONTAINER_TAG'] != 'latest': + if environ.get("PLEX_CONTAINER_TAG") and environ["PLEX_CONTAINER_TAG"] != "latest": assert not is_latest else: - return pytest.skip('Do not forget to run with PLEX_CONTAINER_TAG != latest to ensure that update is available') + return pytest.skip( + "Do not forget to run with PLEX_CONTAINER_TAG != latest to ensure that update is available" + ) def test_server_installUpdate(plex, mocker): - m = mocker.MagicMock(release='aa') - mocker.patch('plexapi.server.PlexServer.check_for_update', return_value=m) - with utils.callable_http_patch(): - plex.installUpdate() + m = mocker.MagicMock(release="aa") + with utils.patch('plexapi.server.PlexServer.check_for_update', return_value=m): + with utils.callable_http_patch(): + plex.installUpdate() def test_server_check_for_update(plex, mocker): - class R(): + class R: def __init__(self, **kwargs): - self.download_key = 'plex.tv/release/1337' - self.version = '1337' - self.added = 'gpu transcode' - self.fixed = 'fixed rare bug' - self.downloadURL = 'http://path-to-update' - self.state = 'downloaded' - - with mocker.patch('plexapi.server.PlexServer.check_for_update', return_value=R()): + self.download_key = "plex.tv/release/1337" + self.version = "1337" + self.added = "gpu transcode" + self.fixed = "fixed rare bug" + self.downloadURL = "http://path-to-update" + self.state = "downloaded" + + with utils.patch('plexapi.server.PlexServer.check_for_update', return_value=R()): rel = plex.check_for_update(force=False, download=True) - assert rel.download_key == 'plex.tv/release/1337' - assert rel.version == '1337' - assert rel.added == 'gpu transcode' - assert rel.fixed == 'fixed rare bug' - assert rel.downloadURL == 'http://path-to-update' - assert rel.state == 'downloaded' + assert rel.download_key == "plex.tv/release/1337" + assert rel.version == "1337" + assert rel.added == "gpu transcode" + assert rel.fixed == "fixed rare bug" + assert rel.downloadURL == "http://path-to-update" + assert rel.state == "downloaded" @pytest.mark.client def test_server_clients(plex): assert len(plex.clients()) client = plex.clients()[0] - assert client._baseurl == 'http://127.0.0.1:32400' - assert client.device is None - assert client.deviceClass == 'pc' - assert client.machineIdentifier == '89hgkrbqxaxmf45o1q2949ru' - assert client.model is None - assert client.platform is None - assert client.platformVersion is None - assert client.product == 'Plex Web' + assert client._baseurl == utils.CLIENT_BASEURL + assert client._server._baseurl == utils.SERVER_BASEURL assert client.protocol == 'plex' - assert client.protocolCapabilities == ['timeline', 'playback', 'navigation', 'mirror', 'playqueues'] - assert client.protocolVersion == '1' - assert client._server._baseurl == 'http://138.68.157.5:32400' - assert client.state is None - assert client.title == 'Plex Web (Chrome)' - assert client.token is None - assert client.vendor is None - assert client.version == '2.12.5' + assert int(client.protocolVersion) in range(4) + assert isinstance(client.machineIdentifier, str) + assert client.deviceClass in ['phone', 'tablet', 'stb', 'tv', 'pc'] + assert set(client.protocolCapabilities).issubset({'timeline', 'playback', 'navigation', 'mirror', 'playqueues'}) @pytest.mark.authenticated +@pytest.mark.xfail(strict=False) def test_server_account(plex): account = plex.account() assert account.authToken # TODO: Figure out why this is missing from time to time. # assert account.mappingError == 'publisherror' assert account.mappingErrorMessage is None - assert account.mappingState == 'mapped' - if account.mappingError != 'unreachable': - assert re.match(utils.REGEX_IPADDR, account.privateAddress) + assert account.mappingState == "mapped" + if account.mappingError != "unreachable": + if account.privateAddress is not None: + # This seems to fail way to often.. + if len(account.privateAddress): + assert re.match(utils.REGEX_IPADDR, account.privateAddress) + else: + assert account.privateAddress == "" + assert int(account.privatePort) >= 1000 assert re.match(utils.REGEX_IPADDR, account.publicAddress) assert int(account.publicPort) >= 1000 else: - assert account.privateAddress == '' + assert account.privateAddress == "" assert int(account.privatePort) == 0 - assert account.publicAddress == '' + assert account.publicAddress == "" assert int(account.publicPort) == 0 - assert account.signInState == 'ok' + assert account.signInState == "ok" assert isinstance(account.subscriptionActive, bool) if account.subscriptionActive: assert len(account.subscriptionFeatures) # Below check keeps failing.. it should go away. # else: assert sorted(account.subscriptionFeatures) == ['adaptive_bitrate', # 'download_certificates', 'federated-auth', 'news'] - assert account.subscriptionState == 'Active' if account.subscriptionActive else 'Unknown' + assert ( + account.subscriptionState == "Active" + if account.subscriptionActive + else "Unknown" + ) assert re.match(utils.REGEX_EMAIL, account.username) @@ -269,3 +285,42 @@ def test_server_downloadLogs(tmpdir, plex): def test_server_downloadDatabases(tmpdir, plex): plex.downloadDatabases(savepath=str(tmpdir), unpack=True) assert len(tmpdir.listdir()) > 1 + + +def test_server_allowMediaDeletion(account): + plex = PlexServer(utils.SERVER_BASEURL, account.authenticationToken) + # Check server current allowMediaDeletion setting + if plex.allowMediaDeletion: + # If allowed then test disallowed + plex._allowMediaDeletion(False) + time.sleep(1) + plex = PlexServer(utils.SERVER_BASEURL, account.authenticationToken) + assert plex.allowMediaDeletion is None + # Test redundant toggle + with pytest.raises(BadRequest): + plex._allowMediaDeletion(False) + + plex._allowMediaDeletion(True) + time.sleep(1) + plex = PlexServer(utils.SERVER_BASEURL, account.authenticationToken) + assert plex.allowMediaDeletion is True + # Test redundant toggle + with pytest.raises(BadRequest): + plex._allowMediaDeletion(True) + else: + # If disallowed then test allowed + plex._allowMediaDeletion(True) + time.sleep(1) + plex = PlexServer(utils.SERVER_BASEURL, account.authenticationToken) + assert plex.allowMediaDeletion is True + # Test redundant toggle + with pytest.raises(BadRequest): + plex._allowMediaDeletion(True) + + plex._allowMediaDeletion(False) + time.sleep(1) + plex = PlexServer(utils.SERVER_BASEURL, account.authenticationToken) + assert plex.allowMediaDeletion is None + # Test redundant toggle + with pytest.raises(BadRequest): + plex._allowMediaDeletion(False) diff --git a/tests/test_settings.py b/tests/test_settings.py index 7f5aea172..3d3a0d9c1 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -1,28 +1,30 @@ - def test_settings_group(plex): - assert plex.settings.group('general') + assert plex.settings.group("general") def test_settings_get(plex): # This is the value since it we havnt set any friendlyname # plex just default to computer name but it NOT in the settings. - assert plex.settings.get('FriendlyName').value == '' + # check this one. why is this bytes instead of string. + value = plex.settings.get("FriendlyName").value + # Should not be bytes, fix this when py2 is dropped + assert isinstance(value, bytes) def test_settings_set(plex): - cd = plex.settings.get('autoEmptyTrash') + cd = plex.settings.get("autoEmptyTrash") old_value = cd.value new_value = not old_value cd.set(new_value) plex.settings.save() plex._settings = None - assert plex.settings.get('autoEmptyTrash').value == new_value + assert plex.settings.get("autoEmptyTrash").value == new_value def test_settings_set_str(plex): - cd = plex.settings.get('OnDeckWindow') + cd = plex.settings.get("OnDeckWindow") new_value = 99 cd.set(new_value) plex.settings.save() plex._settings = None - assert plex.settings.get('OnDeckWindow').value == 99 + assert plex.settings.get("OnDeckWindow").value == 99 diff --git a/tests/test_sonos.py b/tests/test_sonos.py new file mode 100644 index 000000000..ebd4b1d82 --- /dev/null +++ b/tests/test_sonos.py @@ -0,0 +1,28 @@ +# -*- coding: utf-8 -*- +from .payloads import SONOS_RESOURCES + + +def test_sonos_resources(mocked_account, requests_mock): + requests_mock.get("https://sonos.plex.tv/resources", text=SONOS_RESOURCES) + + speakers = mocked_account.sonos_speakers() + assert len(speakers) == 3 + + # Finds individual speaker by name + speaker1 = mocked_account.sonos_speaker("Speaker 1") + assert speaker1.machineIdentifier == "RINCON_12345678901234561:1234567891" + + # Finds speaker as part of group + speaker1 = mocked_account.sonos_speaker("Speaker 2") + assert speaker1.machineIdentifier == "RINCON_12345678901234562:1234567892" + + # Finds speaker by Plex identifier + speaker3 = mocked_account.sonos_speaker_by_id("RINCON_12345678901234563:1234567893") + assert speaker3.title == "Speaker 3" + + # Finds speaker by Sonos identifier + speaker3 = mocked_account.sonos_speaker_by_id("RINCON_12345678901234563") + assert speaker3.title == "Speaker 3" + + assert mocked_account.sonos_speaker("Speaker X") is None + assert mocked_account.sonos_speaker_by_id("ID_DOES_NOT_EXIST") is None diff --git a/tests/test_sync.py b/tests/test_sync.py index ec9d7f8b2..200d5f7ad 100644 --- a/tests/test_sync.py +++ b/tests/test_sync.py @@ -1,7 +1,8 @@ from plexapi.exceptions import BadRequest -from . import conftest as utils +from plexapi.sync import (AUDIO_BITRATE_192_KBPS, PHOTO_QUALITY_MEDIUM, + VIDEO_QUALITY_3_MBPS_720p) -from plexapi.sync import VIDEO_QUALITY_3_MBPS_720p, AUDIO_BITRATE_192_KBPS, PHOTO_QUALITY_MEDIUM +from . import conftest as utils def get_sync_item_from_server(device, sync_item): @@ -16,14 +17,14 @@ def is_sync_item_missing(device, sync_item): def test_current_device_got_sync_target(clear_sync_device): - assert 'sync-target' in clear_sync_device.provides + assert "sync-target" in clear_sync_device.provides def get_media(item, server): try: return item.getMedia() except BadRequest as e: - if 'not_found' in str(e): + if "not_found" in str(e): server.refreshSync() return None else: @@ -33,9 +34,16 @@ def get_media(item, server): def test_add_movie_to_sync(clear_sync_device, movie): new_item = movie.sync(VIDEO_QUALITY_3_MBPS_720p, client=clear_sync_device) movie._server.refreshSync() - item = utils.wait_until(get_sync_item_from_server, delay=0.5, timeout=3, device=clear_sync_device, - sync_item=new_item) - media_list = utils.wait_until(get_media, delay=0.25, timeout=3, item=item, server=movie._server) + item = utils.wait_until( + get_sync_item_from_server, + delay=0.5, + timeout=3, + device=clear_sync_device, + sync_item=new_item, + ) + media_list = utils.wait_until( + get_media, delay=0.25, timeout=3, item=item, server=movie._server + ) assert len(media_list) == 1 assert media_list[0].ratingKey == movie.ratingKey @@ -43,33 +51,58 @@ def test_add_movie_to_sync(clear_sync_device, movie): def test_delete_sync_item(clear_sync_device, movie): new_item = movie.sync(VIDEO_QUALITY_3_MBPS_720p, client=clear_sync_device) movie._server.refreshSync() - new_item_in_myplex = utils.wait_until(get_sync_item_from_server, delay=0.5, timeout=3, device=clear_sync_device, - sync_item=new_item) + new_item_in_myplex = utils.wait_until( + get_sync_item_from_server, + delay=0.5, + timeout=3, + device=clear_sync_device, + sync_item=new_item, + ) sync_items = clear_sync_device.syncItems() for item in sync_items.items: item.delete() - utils.wait_until(is_sync_item_missing, delay=0.5, timeout=3, device=clear_sync_device, sync_item=new_item_in_myplex) + utils.wait_until( + is_sync_item_missing, + delay=0.5, + timeout=3, + device=clear_sync_device, + sync_item=new_item_in_myplex, + ) def test_add_show_to_sync(clear_sync_device, show): new_item = show.sync(VIDEO_QUALITY_3_MBPS_720p, client=clear_sync_device) show._server.refreshSync() - item = utils.wait_until(get_sync_item_from_server, delay=0.5, timeout=3, device=clear_sync_device, - sync_item=new_item) + item = utils.wait_until( + get_sync_item_from_server, + delay=0.5, + timeout=3, + device=clear_sync_device, + sync_item=new_item, + ) episodes = show.episodes() - media_list = utils.wait_until(get_media, delay=0.25, timeout=3, item=item, server=show._server) + media_list = utils.wait_until( + get_media, delay=0.25, timeout=3, item=item, server=show._server + ) assert len(episodes) == len(media_list) assert [e.ratingKey for e in episodes] == [m.ratingKey for m in media_list] def test_add_season_to_sync(clear_sync_device, show): - season = show.season('Season 1') + season = show.season("Season 1") new_item = season.sync(VIDEO_QUALITY_3_MBPS_720p, client=clear_sync_device) season._server.refreshSync() - item = utils.wait_until(get_sync_item_from_server, delay=0.5, timeout=3, device=clear_sync_device, - sync_item=new_item) + item = utils.wait_until( + get_sync_item_from_server, + delay=0.5, + timeout=3, + device=clear_sync_device, + sync_item=new_item, + ) episodes = season.episodes() - media_list = utils.wait_until(get_media, delay=0.25, timeout=3, item=item, server=season._server) + media_list = utils.wait_until( + get_media, delay=0.25, timeout=3, item=item, server=season._server + ) assert len(episodes) == len(media_list) assert [e.ratingKey for e in episodes] == [m.ratingKey for m in media_list] @@ -77,80 +110,131 @@ def test_add_season_to_sync(clear_sync_device, show): def test_add_episode_to_sync(clear_sync_device, episode): new_item = episode.sync(VIDEO_QUALITY_3_MBPS_720p, client=clear_sync_device) episode._server.refreshSync() - item = utils.wait_until(get_sync_item_from_server, delay=0.5, timeout=3, device=clear_sync_device, - sync_item=new_item) - media_list = utils.wait_until(get_media, delay=0.25, timeout=3, item=item, server=episode._server) + item = utils.wait_until( + get_sync_item_from_server, + delay=0.5, + timeout=3, + device=clear_sync_device, + sync_item=new_item, + ) + media_list = utils.wait_until( + get_media, delay=0.25, timeout=3, item=item, server=episode._server + ) assert 1 == len(media_list) assert episode.ratingKey == media_list[0].ratingKey def test_limited_watched(clear_sync_device, show): show.markUnwatched() - new_item = show.sync(VIDEO_QUALITY_3_MBPS_720p, client=clear_sync_device, limit=5, unwatched=False) + new_item = show.sync( + VIDEO_QUALITY_3_MBPS_720p, client=clear_sync_device, limit=5, unwatched=False + ) show._server.refreshSync() - item = utils.wait_until(get_sync_item_from_server, delay=0.5, timeout=3, device=clear_sync_device, - sync_item=new_item) + item = utils.wait_until( + get_sync_item_from_server, + delay=0.5, + timeout=3, + device=clear_sync_device, + sync_item=new_item, + ) episodes = show.episodes()[:5] - media_list = utils.wait_until(get_media, delay=0.25, timeout=3, item=item, server=show._server) + media_list = utils.wait_until( + get_media, delay=0.25, timeout=3, item=item, server=show._server + ) assert 5 == len(media_list) assert [e.ratingKey for e in episodes] == [m.ratingKey for m in media_list] episodes[0].markWatched() show._server.refreshSync() - media_list = utils.wait_until(get_media, delay=0.25, timeout=3, item=item, server=show._server) + media_list = utils.wait_until( + get_media, delay=0.25, timeout=3, item=item, server=show._server + ) assert 5 == len(media_list) assert [e.ratingKey for e in episodes] == [m.ratingKey for m in media_list] def test_limited_unwatched(clear_sync_device, show): show.markUnwatched() - new_item = show.sync(VIDEO_QUALITY_3_MBPS_720p, client=clear_sync_device, limit=5, unwatched=True) + new_item = show.sync( + VIDEO_QUALITY_3_MBPS_720p, client=clear_sync_device, limit=5, unwatched=True + ) show._server.refreshSync() - item = utils.wait_until(get_sync_item_from_server, delay=0.5, timeout=3, device=clear_sync_device, - sync_item=new_item) + item = utils.wait_until( + get_sync_item_from_server, + delay=0.5, + timeout=3, + device=clear_sync_device, + sync_item=new_item, + ) episodes = show.episodes(viewCount=0)[:5] - media_list = utils.wait_until(get_media, delay=0.25, timeout=3, item=item, server=show._server) + media_list = utils.wait_until( + get_media, delay=0.25, timeout=3, item=item, server=show._server + ) assert len(episodes) == len(media_list) assert [e.ratingKey for e in episodes] == [m.ratingKey for m in media_list] episodes[0].markWatched() show._server.refreshSync() episodes = show.episodes(viewCount=0)[:5] - media_list = utils.wait_until(get_media, delay=0.25, timeout=3, item=item, server=show._server) + media_list = utils.wait_until( + get_media, delay=0.25, timeout=3, item=item, server=show._server + ) assert len(episodes) == len(media_list) assert [e.ratingKey for e in episodes] == [m.ratingKey for m in media_list] def test_unlimited_and_watched(clear_sync_device, show): show.markUnwatched() - new_item = show.sync(VIDEO_QUALITY_3_MBPS_720p, client=clear_sync_device, unwatched=False) + new_item = show.sync( + VIDEO_QUALITY_3_MBPS_720p, client=clear_sync_device, unwatched=False + ) show._server.refreshSync() - item = utils.wait_until(get_sync_item_from_server, delay=0.5, timeout=3, device=clear_sync_device, - sync_item=new_item) + item = utils.wait_until( + get_sync_item_from_server, + delay=0.5, + timeout=3, + device=clear_sync_device, + sync_item=new_item, + ) episodes = show.episodes() - media_list = utils.wait_until(get_media, delay=0.25, timeout=3, item=item, server=show._server) + media_list = utils.wait_until( + get_media, delay=0.25, timeout=3, item=item, server=show._server + ) assert len(episodes) == len(media_list) assert [e.ratingKey for e in episodes] == [m.ratingKey for m in media_list] episodes[0].markWatched() show._server.refreshSync() episodes = show.episodes() - media_list = utils.wait_until(get_media, delay=0.25, timeout=3, item=item, server=show._server) + media_list = utils.wait_until( + get_media, delay=0.25, timeout=3, item=item, server=show._server + ) assert len(episodes) == len(media_list) assert [e.ratingKey for e in episodes] == [m.ratingKey for m in media_list] def test_unlimited_and_unwatched(clear_sync_device, show): show.markUnwatched() - new_item = show.sync(VIDEO_QUALITY_3_MBPS_720p, client=clear_sync_device, unwatched=True) + new_item = show.sync( + VIDEO_QUALITY_3_MBPS_720p, client=clear_sync_device, unwatched=True + ) show._server.refreshSync() - item = utils.wait_until(get_sync_item_from_server, delay=0.5, timeout=3, device=clear_sync_device, - sync_item=new_item) + item = utils.wait_until( + get_sync_item_from_server, + delay=0.5, + timeout=3, + device=clear_sync_device, + sync_item=new_item, + ) episodes = show.episodes(viewCount=0) - media_list = utils.wait_until(get_media, delay=0.25, timeout=3, item=item, server=show._server) + media_list = utils.wait_until( + get_media, delay=0.25, timeout=3, item=item, server=show._server + ) assert len(episodes) == len(media_list) assert [e.ratingKey for e in episodes] == [m.ratingKey for m in media_list] episodes[0].markWatched() show._server.refreshSync() episodes = show.episodes(viewCount=0) - media_list = utils.wait_until(get_media, delay=0.25, timeout=3, item=item, server=show._server) + media_list = utils.wait_until( + get_media, delay=0.25, timeout=3, item=item, server=show._server + ) assert len(episodes) == len(media_list) assert [e.ratingKey for e in episodes] == [m.ratingKey for m in media_list] @@ -158,10 +242,17 @@ def test_unlimited_and_unwatched(clear_sync_device, show): def test_add_music_artist_to_sync(clear_sync_device, artist): new_item = artist.sync(AUDIO_BITRATE_192_KBPS, client=clear_sync_device) artist._server.refreshSync() - item = utils.wait_until(get_sync_item_from_server, delay=0.5, timeout=3, device=clear_sync_device, - sync_item=new_item) + item = utils.wait_until( + get_sync_item_from_server, + delay=0.5, + timeout=3, + device=clear_sync_device, + sync_item=new_item, + ) tracks = artist.tracks() - media_list = utils.wait_until(get_media, delay=0.25, timeout=3, item=item, server=artist._server) + media_list = utils.wait_until( + get_media, delay=0.25, timeout=3, item=item, server=artist._server + ) assert len(tracks) == len(media_list) assert [t.ratingKey for t in tracks] == [m.ratingKey for m in media_list] @@ -169,10 +260,17 @@ def test_add_music_artist_to_sync(clear_sync_device, artist): def test_add_music_album_to_sync(clear_sync_device, album): new_item = album.sync(AUDIO_BITRATE_192_KBPS, client=clear_sync_device) album._server.refreshSync() - item = utils.wait_until(get_sync_item_from_server, delay=0.5, timeout=3, device=clear_sync_device, - sync_item=new_item) + item = utils.wait_until( + get_sync_item_from_server, + delay=0.5, + timeout=3, + device=clear_sync_device, + sync_item=new_item, + ) tracks = album.tracks() - media_list = utils.wait_until(get_media, delay=0.25, timeout=3, item=item, server=album._server) + media_list = utils.wait_until( + get_media, delay=0.25, timeout=3, item=item, server=album._server + ) assert len(tracks) == len(media_list) assert [t.ratingKey for t in tracks] == [m.ratingKey for m in media_list] @@ -180,20 +278,34 @@ def test_add_music_album_to_sync(clear_sync_device, album): def test_add_music_track_to_sync(clear_sync_device, track): new_item = track.sync(AUDIO_BITRATE_192_KBPS, client=clear_sync_device) track._server.refreshSync() - item = utils.wait_until(get_sync_item_from_server, delay=0.5, timeout=3, device=clear_sync_device, - sync_item=new_item) - media_list = utils.wait_until(get_media, delay=0.25, timeout=3, item=item, server=track._server) + item = utils.wait_until( + get_sync_item_from_server, + delay=0.5, + timeout=3, + device=clear_sync_device, + sync_item=new_item, + ) + media_list = utils.wait_until( + get_media, delay=0.25, timeout=3, item=item, server=track._server + ) assert 1 == len(media_list) assert track.ratingKey == media_list[0].ratingKey def test_add_photo_to_sync(clear_sync_device, photoalbum): - photo = photoalbum.photo('photo1') + photo = photoalbum.photo("photo1") new_item = photo.sync(PHOTO_QUALITY_MEDIUM, client=clear_sync_device) photo._server.refreshSync() - item = utils.wait_until(get_sync_item_from_server, delay=0.5, timeout=3, device=clear_sync_device, - sync_item=new_item) - media_list = utils.wait_until(get_media, delay=0.25, timeout=3, item=item, server=photo._server) + item = utils.wait_until( + get_sync_item_from_server, + delay=0.5, + timeout=3, + device=clear_sync_device, + sync_item=new_item, + ) + media_list = utils.wait_until( + get_media, delay=0.25, timeout=3, item=item, server=photo._server + ) assert 1 == len(media_list) assert photo.ratingKey == media_list[0].ratingKey @@ -201,10 +313,17 @@ def test_add_photo_to_sync(clear_sync_device, photoalbum): def test_sync_entire_library_movies(clear_sync_device, movies): new_item = movies.sync(VIDEO_QUALITY_3_MBPS_720p, client=clear_sync_device) movies._server.refreshSync() - item = utils.wait_until(get_sync_item_from_server, delay=0.5, timeout=3, device=clear_sync_device, - sync_item=new_item) + item = utils.wait_until( + get_sync_item_from_server, + delay=0.5, + timeout=3, + device=clear_sync_device, + sync_item=new_item, + ) section_content = movies.all() - media_list = utils.wait_until(get_media, delay=0.25, timeout=3, item=item, server=movies._server) + media_list = utils.wait_until( + get_media, delay=0.25, timeout=3, item=item, server=movies._server + ) assert len(section_content) == len(media_list) assert [e.ratingKey for e in section_content] == [m.ratingKey for m in media_list] @@ -212,10 +331,17 @@ def test_sync_entire_library_movies(clear_sync_device, movies): def test_sync_entire_library_tvshows(clear_sync_device, tvshows): new_item = tvshows.sync(VIDEO_QUALITY_3_MBPS_720p, client=clear_sync_device) tvshows._server.refreshSync() - item = utils.wait_until(get_sync_item_from_server, delay=0.5, timeout=3, device=clear_sync_device, - sync_item=new_item) + item = utils.wait_until( + get_sync_item_from_server, + delay=0.5, + timeout=3, + device=clear_sync_device, + sync_item=new_item, + ) section_content = tvshows.searchEpisodes() - media_list = utils.wait_until(get_media, delay=0.25, timeout=3, item=item, server=tvshows._server) + media_list = utils.wait_until( + get_media, delay=0.25, timeout=3, item=item, server=tvshows._server + ) assert len(section_content) == len(media_list) assert [e.ratingKey for e in section_content] == [m.ratingKey for m in media_list] @@ -223,10 +349,17 @@ def test_sync_entire_library_tvshows(clear_sync_device, tvshows): def test_sync_entire_library_music(clear_sync_device, music): new_item = music.sync(AUDIO_BITRATE_192_KBPS, client=clear_sync_device) music._server.refreshSync() - item = utils.wait_until(get_sync_item_from_server, delay=0.5, timeout=3, device=clear_sync_device, - sync_item=new_item) + item = utils.wait_until( + get_sync_item_from_server, + delay=0.5, + timeout=3, + device=clear_sync_device, + sync_item=new_item, + ) section_content = music.searchTracks() - media_list = utils.wait_until(get_media, delay=0.25, timeout=3, item=item, server=music._server) + media_list = utils.wait_until( + get_media, delay=0.25, timeout=3, item=item, server=music._server + ) assert len(section_content) == len(media_list) assert [e.ratingKey for e in section_content] == [m.ratingKey for m in media_list] @@ -234,23 +367,39 @@ def test_sync_entire_library_music(clear_sync_device, music): def test_sync_entire_library_photos(clear_sync_device, photos): new_item = photos.sync(PHOTO_QUALITY_MEDIUM, client=clear_sync_device) photos._server.refreshSync() - item = utils.wait_until(get_sync_item_from_server, delay=0.5, timeout=3, device=clear_sync_device, - sync_item=new_item) + item = utils.wait_until( + get_sync_item_from_server, + delay=0.5, + timeout=3, + 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 - section_content = photos.search(libtype='photo', **{'device!': '0x0'}) - media_list = utils.wait_until(get_media, delay=0.25, timeout=3, item=item, server=photos._server) + section_content = photos.search(libtype="photo", **{"device!": "0x0"}) + media_list = utils.wait_until( + get_media, delay=0.25, timeout=3, item=item, server=photos._server + ) assert len(section_content) == len(media_list) assert [e.ratingKey for e in section_content] == [m.ratingKey for m in media_list] def test_playlist_movie_sync(plex, clear_sync_device, movies): items = movies.all() - playlist = plex.createPlaylist('Sync: Movies', items) - new_item = playlist.sync(videoQuality=VIDEO_QUALITY_3_MBPS_720p, client=clear_sync_device) + playlist = plex.createPlaylist("Sync: Movies", items) + new_item = playlist.sync( + videoQuality=VIDEO_QUALITY_3_MBPS_720p, client=clear_sync_device + ) playlist._server.refreshSync() - item = utils.wait_until(get_sync_item_from_server, delay=0.5, timeout=3, device=clear_sync_device, - sync_item=new_item) - media_list = utils.wait_until(get_media, delay=0.25, timeout=3, item=item, server=playlist._server) + item = utils.wait_until( + get_sync_item_from_server, + delay=0.5, + timeout=3, + device=clear_sync_device, + sync_item=new_item, + ) + media_list = utils.wait_until( + get_media, delay=0.25, timeout=3, item=item, server=playlist._server + ) assert len(items) == len(media_list) assert [e.ratingKey for e in items] == [m.ratingKey for m in media_list] playlist.delete() @@ -258,12 +407,21 @@ def test_playlist_movie_sync(plex, clear_sync_device, movies): def test_playlist_tvshow_sync(plex, clear_sync_device, show): items = show.episodes() - playlist = plex.createPlaylist('Sync: TV Show', items) - new_item = playlist.sync(videoQuality=VIDEO_QUALITY_3_MBPS_720p, client=clear_sync_device) + playlist = plex.createPlaylist("Sync: TV Show", items) + new_item = playlist.sync( + videoQuality=VIDEO_QUALITY_3_MBPS_720p, client=clear_sync_device + ) playlist._server.refreshSync() - item = utils.wait_until(get_sync_item_from_server, delay=0.5, timeout=3, device=clear_sync_device, - sync_item=new_item) - media_list = utils.wait_until(get_media, delay=0.25, timeout=3, item=item, server=playlist._server) + item = utils.wait_until( + get_sync_item_from_server, + delay=0.5, + timeout=3, + device=clear_sync_device, + sync_item=new_item, + ) + media_list = utils.wait_until( + get_media, delay=0.25, timeout=3, item=item, server=playlist._server + ) assert len(items) == len(media_list) assert [e.ratingKey for e in items] == [m.ratingKey for m in media_list] playlist.delete() @@ -271,12 +429,21 @@ def test_playlist_tvshow_sync(plex, clear_sync_device, show): def test_playlist_mixed_sync(plex, clear_sync_device, movie, episode): items = [movie, episode] - playlist = plex.createPlaylist('Sync: Mixed', items) - new_item = playlist.sync(videoQuality=VIDEO_QUALITY_3_MBPS_720p, client=clear_sync_device) + playlist = plex.createPlaylist("Sync: Mixed", items) + new_item = playlist.sync( + videoQuality=VIDEO_QUALITY_3_MBPS_720p, client=clear_sync_device + ) playlist._server.refreshSync() - item = utils.wait_until(get_sync_item_from_server, delay=0.5, timeout=3, device=clear_sync_device, - sync_item=new_item) - media_list = utils.wait_until(get_media, delay=0.25, timeout=3, item=item, server=playlist._server) + item = utils.wait_until( + get_sync_item_from_server, + delay=0.5, + timeout=3, + device=clear_sync_device, + sync_item=new_item, + ) + media_list = utils.wait_until( + get_media, delay=0.25, timeout=3, item=item, server=playlist._server + ) assert len(items) == len(media_list) assert [e.ratingKey for e in items] == [m.ratingKey for m in media_list] playlist.delete() @@ -284,12 +451,21 @@ def test_playlist_mixed_sync(plex, clear_sync_device, movie, episode): def test_playlist_music_sync(plex, clear_sync_device, artist): items = artist.tracks() - playlist = plex.createPlaylist('Sync: Music', items) - new_item = playlist.sync(audioBitrate=AUDIO_BITRATE_192_KBPS, client=clear_sync_device) + playlist = plex.createPlaylist("Sync: Music", items) + new_item = playlist.sync( + audioBitrate=AUDIO_BITRATE_192_KBPS, client=clear_sync_device + ) playlist._server.refreshSync() - item = utils.wait_until(get_sync_item_from_server, delay=0.5, timeout=3, device=clear_sync_device, - sync_item=new_item) - media_list = utils.wait_until(get_media, delay=0.25, timeout=3, item=item, server=playlist._server) + item = utils.wait_until( + get_sync_item_from_server, + delay=0.5, + timeout=3, + device=clear_sync_device, + sync_item=new_item, + ) + media_list = utils.wait_until( + get_media, delay=0.25, timeout=3, item=item, server=playlist._server + ) assert len(items) == len(media_list) assert [e.ratingKey for e in items] == [m.ratingKey for m in media_list] playlist.delete() @@ -297,12 +473,21 @@ def test_playlist_music_sync(plex, clear_sync_device, artist): def test_playlist_photos_sync(plex, clear_sync_device, photoalbum): items = photoalbum.photos() - playlist = plex.createPlaylist('Sync: Photos', items) - new_item = playlist.sync(photoResolution=PHOTO_QUALITY_MEDIUM, client=clear_sync_device) + playlist = plex.createPlaylist("Sync: Photos", items) + new_item = playlist.sync( + photoResolution=PHOTO_QUALITY_MEDIUM, client=clear_sync_device + ) playlist._server.refreshSync() - item = utils.wait_until(get_sync_item_from_server, delay=0.5, timeout=3, device=clear_sync_device, - sync_item=new_item) - media_list = utils.wait_until(get_media, delay=0.25, timeout=3, item=item, server=playlist._server) + item = utils.wait_until( + get_sync_item_from_server, + delay=0.5, + timeout=3, + device=clear_sync_device, + sync_item=new_item, + ) + media_list = utils.wait_until( + get_media, delay=0.25, timeout=3, item=item, server=playlist._server + ) assert len(items) == len(media_list) assert [e.ratingKey for e in items] == [m.ratingKey for m in media_list] playlist.delete() diff --git a/tests/test_utils.py b/tests/test_utils.py index d870f6eb2..8587eca80 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,18 +1,23 @@ # -*- coding: utf-8 -*- -import pytest, time +import time + import plexapi.utils as utils +import pytest from plexapi.exceptions import NotFound def test_utils_toDatetime(): - assert str(utils.toDatetime('2006-03-03', format='%Y-%m-%d')) == '2006-03-03 00:00:00' - #assert str(utils.toDatetime('0'))[:-9] in ['1970-01-01', '1969-12-31'] + assert ( + str(utils.toDatetime("2006-03-03", format="%Y-%m-%d")) == "2006-03-03 00:00:00" + ) + # assert str(utils.toDatetime('0'))[:-9] in ['1970-01-01', '1969-12-31'] def test_utils_threaded(): def _squared(num, results, i, job_is_done_event=None): time.sleep(0.5) results[i] = num * num + starttime = time.time() results = utils.threaded(_squared, [[1], [2], [3], [4], [5]]) assert results == [1, 4, 9, 16, 25] @@ -26,28 +31,28 @@ def test_utils_downloadSessionImages(): def test_utils_searchType(): - st = utils.searchType('movie') + st = utils.searchType("movie") assert st == 1 movie = utils.searchType(1) - assert movie == '1' + assert movie == "1" with pytest.raises(NotFound): - utils.searchType('kekekekeke') + utils.searchType("kekekekeke") def test_utils_joinArgs(): - test_dict = {'genre': 'action', 'type': 1337} - assert utils.joinArgs(test_dict) == '?genre=action&type=1337' + test_dict = {"genre": "action", "type": 1337} + assert utils.joinArgs(test_dict) == "?genre=action&type=1337" def test_utils_cast(): int_int = utils.cast(int, 1) - int_str = utils.cast(int, '1') - bool_str = utils.cast(bool, '1') + int_str = utils.cast(int, "1") + bool_str = utils.cast(bool, "1") bool_int = utils.cast(bool, 1) float_int = utils.cast(float, 1) float_float = utils.cast(float, 1.0) - float_str = utils.cast(float, '1.2') - float_nan = utils.cast(float, 'wut?') + float_str = utils.cast(float, "1.2") + float_nan = utils.cast(float, "wut?") assert int_int == 1 and isinstance(int_int, int) assert int_str == 1 and isinstance(int_str, int) assert bool_str is True @@ -57,7 +62,7 @@ def test_utils_cast(): assert float_str == 1.2 and isinstance(float_str, float) assert float_nan != float_nan # nan is never equal with pytest.raises(ValueError): - bool_str = utils.cast(bool, 'kek') + bool_str = utils.cast(bool, "kek") def test_utils_download(plex, episode): @@ -65,5 +70,15 @@ def test_utils_download(plex, episode): locations = episode.locations[0] session = episode._server._session assert utils.download(url, plex._token, filename=locations, mocked=True) - assert utils.download(url, plex._token, filename=locations, session=session, mocked=True) - assert utils.download(episode.thumbUrl, plex._token, filename=episode.title, mocked=True) + assert utils.download( + url, plex._token, filename=locations, session=session, mocked=True + ) + assert utils.download( + episode.thumbUrl, plex._token, filename=episode.title, mocked=True + ) + + + +def test_millisecondToHumanstr(): + res = utils.millisecondToHumanstr(1000) + assert res == "00:00:01:0000" diff --git a/tests/test_video.py b/tests/test_video.py index 1c92069a0..139031e09 100644 --- a/tests/test_video.py +++ b/tests/test_video.py @@ -1,7 +1,12 @@ # -*- coding: utf-8 -*- -import pytest +import os from datetime import datetime +from time import sleep +from urllib.parse import quote_plus + +import pytest from plexapi.exceptions import BadRequest, NotFound + from . import conftest as utils @@ -9,20 +14,33 @@ def test_video_Movie(movies, movie): movie2 = movies.get(movie.title) assert movie2.title == movie.title + def test_video_Movie_attributeerror(movie): with pytest.raises(AttributeError): movie.asshat + def test_video_ne(movies): - assert len(movies.fetchItems('/library/sections/1/all', title__ne='Sintel')) == 3 + assert ( + len( + movies.fetchItems( + "/library/sections/%s/all" % movies.key, title__ne="Sintel" + ) + ) + == 3 + ) def test_video_Movie_delete(movie, patched_http_call): movie.delete() +def test_video_Movie_merge(movie, patched_http_call): + movie.merge(1337) + + def test_video_Movie_addCollection(movie): - labelname = 'Random_label' + labelname = "Random_label" org_collection = [tag.tag for tag in movie.collections if tag] assert labelname not in org_collection movie.addCollection(labelname) @@ -35,12 +53,18 @@ def test_video_Movie_addCollection(movie): def test_video_Movie_getStreamURL(movie, account): key = movie.ratingKey - assert movie.getStreamURL() == '{0}/video/:/transcode/universal/start.m3u8?X-Plex-Platform=Chrome©ts=1&mediaIndex=0&offset=0&path=%2Flibrary%2Fmetadata%2F{1}&X-Plex-Token={2}'.format(utils.SERVER_BASEURL, key, account.authenticationToken) # noqa - assert movie.getStreamURL(videoResolution='800x600') == '{0}/video/:/transcode/universal/start.m3u8?X-Plex-Platform=Chrome©ts=1&mediaIndex=0&offset=0&path=%2Flibrary%2Fmetadata%2F{1}&videoResolution=800x600&X-Plex-Token={2}'.format(utils.SERVER_BASEURL, key, account.authenticationToken) # noqa + assert movie.getStreamURL() == "{0}/video/:/transcode/universal/start.m3u8?X-Plex-Platform=Chrome©ts=1&mediaIndex=0&offset=0&path=%2Flibrary%2Fmetadata%2F{1}&X-Plex-Token={2}".format( + utils.SERVER_BASEURL, key, account.authenticationToken + ) # noqa + assert movie.getStreamURL( + videoResolution="800x600" + ) == "{0}/video/:/transcode/universal/start.m3u8?X-Plex-Platform=Chrome©ts=1&mediaIndex=0&offset=0&path=%2Flibrary%2Fmetadata%2F{1}&videoResolution=800x600&X-Plex-Token={2}".format( + utils.SERVER_BASEURL, key, account.authenticationToken + ) # noqa def test_video_Movie_isFullObject_and_reload(plex): - movie = plex.library.section('Movies').get('Sita Sings the Blues') + movie = plex.library.section("Movies").get("Sita Sings the Blues") assert movie.isFullObject() is False movie.reload() assert movie.isFullObject() is True @@ -48,7 +72,7 @@ def test_video_Movie_isFullObject_and_reload(plex): assert movie_via_search.isFullObject() is False movie_via_search.reload() assert movie_via_search.isFullObject() is True - movie_via_section_search = plex.library.section('Movies').search(movie.title)[0] + movie_via_section_search = plex.library.section("Movies").search(movie.title)[0] assert movie_via_section_search.isFullObject() is False movie_via_section_search.reload() assert movie_via_section_search.isFullObject() is True @@ -76,7 +100,7 @@ def test_video_Movie_iterParts(movie): def test_video_Movie_download(monkeydownload, tmpdir, movie): filepaths1 = movie.download(savepath=str(tmpdir)) assert len(filepaths1) >= 1 - filepaths2 = movie.download(savepath=str(tmpdir), videoResolution='500x300') + filepaths2 = movie.download(savepath=str(tmpdir), videoResolution="500x300") assert len(filepaths2) >= 1 @@ -84,57 +108,102 @@ def test_video_Movie_subtitlestreams(movie): assert not movie.subtitleStreams() +def test_video_Episode_subtitlestreams(episode): + assert not episode.subtitleStreams() + + +def test_video_Movie_upload_select_remove_subtitle(movie, subtitle): + + filepath = os.path.realpath(subtitle.name) + + movie.uploadSubtitles(filepath) + movie.reload() + subtitles = [sub.title for sub in movie.subtitleStreams()] + subname = subtitle.name.rsplit(".", 1)[0] + assert subname in subtitles + + subtitleSelection = movie.subtitleStreams()[0] + parts = [part for part in movie.iterParts()] + parts[0].setDefaultSubtitleStream(subtitleSelection) + movie.reload() + + subtitleSelection = movie.subtitleStreams()[0] + assert subtitleSelection.selected + + movie.removeSubtitles(streamTitle=subname) + movie.reload() + subtitles = [sub.title for sub in movie.subtitleStreams()] + assert subname not in subtitles + + try: + os.remove(filepath) + except: + pass + + def test_video_Movie_attrs(movies): - movie = movies.get('Sita Sings the Blues') + movie = movies.get("Sita Sings the Blues") assert len(movie.locations[0]) >= 10 assert utils.is_datetime(movie.addedAt) assert utils.is_metadata(movie.art) assert movie.artUrl assert movie.audienceRating == 8.5 # Disabled this since it failed on the last run, wasnt in the original xml result. - #assert movie.audienceRatingImage == 'rottentomatoes://image.rating.upright' + # assert movie.audienceRatingImage == 'rottentomatoes://image.rating.upright' movie.reload() # RELOAD assert movie.chapterSource is None assert movie.collections == [] assert movie.contentRating in utils.CONTENTRATINGS - assert all([i.tag in ['US', 'USA'] for i in movie.countries]) - assert [i.tag for i in movie.directors] == ['Nina Paley'] + assert all([i.tag in ["US", "USA"] for i in movie.countries]) + assert [i.tag for i in movie.directors] == ["Nina Paley"] assert movie.duration >= 160000 assert movie.fields == [] - assert sorted([i.tag for i in movie.genres]) == ['Animation', 'Comedy', 'Fantasy', 'Musical', 'Romance'] - assert movie.guid == 'com.plexapp.agents.imdb://tt1172203?lang=en' + assert movie.posters() + assert sorted([i.tag for i in movie.genres]) == [ + "Animation", + "Comedy", + "Fantasy", + "Musical", + "Romance", + ] + assert movie.guid == "com.plexapp.agents.imdb://tt1172203?lang=en" assert utils.is_metadata(movie._initpath) assert utils.is_metadata(movie.key) if movie.lastViewedAt: assert utils.is_datetime(movie.lastViewedAt) assert int(movie.librarySectionID) >= 1 - assert movie.listType == 'video' + assert movie.listType == "video" assert movie.originalTitle is None - assert movie.originallyAvailableAt.strftime('%Y-%m-%d') in ('2008-01-11', '2008-02-11') + assert utils.is_datetime(movie.originallyAvailableAt) assert movie.playlistItemID is None if movie.primaryExtraKey: assert utils.is_metadata(movie.primaryExtraKey) assert [i.tag for i in movie.producers] == [] assert float(movie.rating) >= 6.4 - #assert movie.ratingImage == 'rottentomatoes://image.rating.ripe' + # assert movie.ratingImage == 'rottentomatoes://image.rating.ripe' assert movie.ratingKey >= 1 - assert set(sorted([i.tag for i in movie.roles])) >= {'Aladdin Ullah', 'Annette Hanshaw', 'Aseem Chhabra', 'Debargo Sanyal'} # noqa + assert set(sorted([i.tag for i in movie.roles])) >= { + "Aladdin Ullah", + "Annette Hanshaw", + "Aseem Chhabra", + "Debargo Sanyal", + } # noqa assert movie._server._baseurl == utils.SERVER_BASEURL assert movie.sessionKey is None - assert movie.studio == 'Nina Paley' + assert movie.studio == "Nina Paley" assert utils.is_string(movie.summary, gte=100) - assert movie.tagline == 'The Greatest Break-Up Story Ever Told' + assert movie.tagline == "The Greatest Break-Up Story Ever Told" assert utils.is_thumb(movie.thumb) - assert movie.title == 'Sita Sings the Blues' - assert movie.titleSort == 'Sita Sings the Blues' + assert movie.title == "Sita Sings the Blues" + assert movie.titleSort == "Sita Sings the Blues" assert not movie.transcodeSessions - assert movie.type == 'movie' + assert movie.type == "movie" assert movie.updatedAt > datetime(2017, 1, 1) assert movie.userRating is None assert movie.viewCount == 0 assert utils.is_int(movie.viewOffset, gte=0) assert movie.viewedAt is None - assert sorted([i.tag for i in movie.writers][:4]) == ['Nina Paley'] # noqa + assert sorted([i.tag for i in movie.writers][:4]) == ["Nina Paley"] # noqa assert movie.year == 2008 # Audio audio = movie.media[0].parts[0].audioStreams()[0] @@ -178,10 +247,13 @@ def test_video_Movie_attrs(movies): assert utils.is_int(media.width, gte=200) # Video video = movie.media[0].parts[0].videoStreams()[0] - assert video.bitDepth in (8, None) # Different versions of Plex Server return different values + assert video.bitDepth in ( + 8, + None, + ) # Different versions of Plex Server return different values assert utils.is_int(video.bitrate) assert video.cabac is None - assert video.chromaSubsampling in ('4:2:0', None) + assert video.chromaSubsampling in ("4:2:0", None) assert video.codec in utils.CODECS assert video.codecID is None assert video.colorSpace is None @@ -198,7 +270,7 @@ def test_video_Movie_attrs(movies): assert utils.is_int(video.level) assert video.profile in utils.PROFILES assert utils.is_int(video.refFrames) - assert video.scanType in ('progressive', None) + assert video.scanType in ("progressive", None) assert video.selected is False assert video._server._baseurl == utils.SERVER_BASEURL assert utils.is_int(video.streamType) @@ -215,12 +287,14 @@ def test_video_Movie_attrs(movies): assert len(part.key) >= 10 assert part._server._baseurl == utils.SERVER_BASEURL assert utils.is_int(part.size, gte=1000000) + assert part.exists + assert part.accessible # Stream 1 stream1 = part.streams[0] assert stream1.bitDepth in (8, None) assert utils.is_int(stream1.bitrate) assert stream1.cabac is None - assert stream1.chromaSubsampling in ('4:2:0', None) + assert stream1.chromaSubsampling in ("4:2:0", None) assert stream1.codec in utils.CODECS assert stream1.codecID is None assert stream1.colorSpace is None @@ -237,7 +311,7 @@ def test_video_Movie_attrs(movies): assert utils.is_int(stream1.level) assert stream1.profile in utils.PROFILES assert utils.is_int(stream1.refFrames) - assert stream1.scanType in ('progressive', None) + assert stream1.scanType in ("progressive", None) assert stream1.selected is False assert stream1._server._baseurl == utils.SERVER_BASEURL assert utils.is_int(stream1.streamType) @@ -269,8 +343,114 @@ def test_video_Movie_attrs(movies): assert stream2.type == 2 +def test_video_Movie_history(movie): + movie.markWatched() + history = movie.history() + assert len(history) + movie.markUnwatched() + + +def test_video_Movie_match(movies): + sectionAgent = movies.agent + sectionAgents = [agent.identifier for agent in movies.agents() if agent.shortIdentifier != 'none'] + sectionAgents.remove(sectionAgent) + altAgent = sectionAgents[0] + + movie = movies.all()[0] + title = movie.title + year = str(movie.year) + titleUrlEncode = quote_plus(title) + + def parse_params(key): + params = key.split('?', 1)[1] + params = params.split("&") + return {x.split("=")[0]: x.split("=")[1] for x in params} + + results = movie.matches(title="", year="") + if results: + initpath = results[0]._initpath + assert initpath.startswith(movie.key) + params = initpath.split(movie.key)[1] + parsedParams = parse_params(params) + assert parsedParams.get('manual') == '1' + assert parsedParams.get('title') == "" + assert parsedParams.get('year') == "" + assert parsedParams.get('agent') == sectionAgent + else: + assert len(results) == 0 + + results = movie.matches(title=title, year="", agent=sectionAgent) + if results: + initpath = results[0]._initpath + assert initpath.startswith(movie.key) + params = initpath.split(movie.key)[1] + parsedParams = parse_params(params) + assert parsedParams.get('manual') == '1' + assert parsedParams.get('title') == titleUrlEncode + assert parsedParams.get('year') == "" + assert parsedParams.get('agent') == sectionAgent + else: + assert len(results) == 0 + + results = movie.matches(title=title, agent=sectionAgent) + if results: + initpath = results[0]._initpath + assert initpath.startswith(movie.key) + params = initpath.split(movie.key)[1] + parsedParams = parse_params(params) + assert parsedParams.get('manual') == '1' + assert parsedParams.get('title') == titleUrlEncode + assert parsedParams.get('year') == year + assert parsedParams.get('agent') == sectionAgent + else: + assert len(results) == 0 + + results = movie.matches(title="", year="") + if results: + initpath = results[0]._initpath + assert initpath.startswith(movie.key) + params = initpath.split(movie.key)[1] + parsedParams = parse_params(params) + assert parsedParams.get('manual') == '1' + assert parsedParams.get('agent') == sectionAgent + else: + assert len(results) == 0 + + results = movie.matches(title="", year="", agent=altAgent) + if results: + initpath = results[0]._initpath + assert initpath.startswith(movie.key) + params = initpath.split(movie.key)[1] + parsedParams = parse_params(params) + assert parsedParams.get('manual') == '1' + assert parsedParams.get('agent') == altAgent + else: + assert len(results) == 0 + + results = movie.matches(agent=altAgent) + if results: + initpath = results[0]._initpath + assert initpath.startswith(movie.key) + params = initpath.split(movie.key)[1] + parsedParams = parse_params(params) + assert parsedParams.get('manual') == '1' + assert parsedParams.get('agent') == altAgent + else: + assert len(results) == 0 + + results = movie.matches() + if results: + initpath = results[0]._initpath + assert initpath.startswith(movie.key) + params = initpath.split(movie.key)[1] + parsedParams = parse_params(params) + assert parsedParams.get('manual') == '1' + else: + assert len(results) == 0 + + def test_video_Show(show): - assert show.title == 'Game of Thrones' + assert show.title == "Game of Thrones" def test_video_Episode_split(episode, patched_http_call): @@ -283,27 +463,33 @@ def test_video_Episode_unmatch(episode, patched_http_call): def test_video_Episode_updateProgress(episode, patched_http_call): episode.updateProgress(10 * 60 * 1000) # 10 minutes. - + + def test_video_Episode_updateTimeline(episode, patched_http_call): - episode.updateTimeline(10 * 60 * 1000, state='playing', duration=episode.duration) # 10 minutes. + episode.updateTimeline( + 10 * 60 * 1000, state="playing", duration=episode.duration + ) # 10 minutes. + def test_video_Episode_stop(episode, mocker, patched_http_call): - mocker.patch.object(episode, 'session', return_value=list(mocker.MagicMock(id='hello'))) + mocker.patch.object( + episode, "session", return_value=list(mocker.MagicMock(id="hello")) + ) episode.stop(reason="It's past bedtime!") def test_video_Show_attrs(show): assert utils.is_datetime(show.addedAt) - assert utils.is_metadata(show.art, contains='/art/') - assert utils.is_metadata(show.banner, contains='/banner/') + assert utils.is_metadata(show.art, contains="/art/") + assert utils.is_metadata(show.banner, contains="/banner/") assert utils.is_int(show.childCount) assert show.contentRating in utils.CONTENTRATINGS assert utils.is_int(show.duration, gte=1600000) assert utils.is_section(show._initpath) # Check reloading the show loads the full list of genres - assert not {'Adventure', 'Drama'} - {i.tag for i in show.genres} + assert not {"Adventure", "Drama"} - {i.tag for i in show.genres} show.reload() - assert sorted([i.tag for i in show.genres]) == ['Adventure', 'Drama', 'Fantasy'] + assert sorted([i.tag for i in show.genres]) == ["Adventure", "Drama", "Fantasy"] # So the initkey should have changed because of the reload assert utils.is_metadata(show._initpath) assert utils.is_int(show.index) @@ -311,37 +497,54 @@ def test_video_Show_attrs(show): if show.lastViewedAt: assert utils.is_datetime(show.lastViewedAt) assert utils.is_int(show.leafCount) - assert show.listType == 'video' + assert show.listType == "video" assert len(show.locations[0]) >= 10 - assert show.originallyAvailableAt.strftime('%Y-%m-%d') == '2011-04-17' + assert utils.is_datetime(show.originallyAvailableAt) assert show.rating >= 8.0 assert utils.is_int(show.ratingKey) - assert sorted([i.tag for i in show.roles])[:4] == ['Aidan Gillen', 'Aimee Richardson', 'Alexander Siddig', 'Alfie Allen'] # noqa - assert sorted([i.tag for i in show.actors])[:4] == ['Aidan Gillen', 'Aimee Richardson', 'Alexander Siddig', 'Alfie Allen'] # noqa + assert sorted([i.tag for i in show.roles])[:4] == [ + "Aidan Gillen", + "Aimee Richardson", + "Alexander Siddig", + "Alfie Allen", + ] # noqa + assert sorted([i.tag for i in show.actors])[:4] == [ + "Aidan Gillen", + "Aimee Richardson", + "Alexander Siddig", + "Alfie Allen", + ] # noqa assert show._server._baseurl == utils.SERVER_BASEURL - assert show.studio == 'HBO' + assert show.studio == "HBO" assert utils.is_string(show.summary, gte=100) - assert utils.is_metadata(show.theme, contains='/theme/') - assert utils.is_metadata(show.thumb, contains='/thumb/') - assert show.title == 'Game of Thrones' - assert show.titleSort == 'Game of Thrones' - assert show.type == 'show' + assert utils.is_metadata(show.theme, contains="/theme/") + assert utils.is_metadata(show.thumb, contains="/thumb/") + assert show.title == "Game of Thrones" + assert show.titleSort == "Game of Thrones" + assert show.type == "show" assert utils.is_datetime(show.updatedAt) assert utils.is_int(show.viewCount, gte=0) assert utils.is_int(show.viewedLeafCount, gte=0) - assert show.year == 2011 + assert show.year in (2011, 2010) assert show.url(None) is None +def test_video_Show_history(show): + show.markWatched() + history = show.history() + assert len(history) + show.markUnwatched() + + def test_video_Show_watched(tvshows): - show = tvshows.get('The 100') + show = tvshows.get("The 100") show.episodes()[0].markWatched() watched = show.watched() - assert len(watched) == 1 and watched[0].title == 'Pilot' + assert len(watched) == 1 and watched[0].title == "Pilot" def test_video_Show_unwatched(tvshows): - show = tvshows.get('The 100') + show = tvshows.get("The 100") episodes = show.episodes() episodes[0].markWatched() unwatched = show.unwatched() @@ -351,21 +554,21 @@ def test_video_Show_unwatched(tvshows): def test_video_Show_location(plex): # This should be a part of test test_video_Show_attrs but is excluded # because of https://github.com/mjs7231/python-plexapi/issues/97 - show = plex.library.section('TV Shows').get('The 100') + show = plex.library.section("TV Shows").get("The 100") assert len(show.locations) >= 1 def test_video_Show_reload(plex): - show = plex.library.section('TV Shows').get('Game of Thrones') - assert utils.is_metadata(show._initpath, prefix='/library/sections/') + show = plex.library.section("TV Shows").get("Game of Thrones") + assert utils.is_metadata(show._initpath, prefix="/library/sections/") assert len(show.roles) == 3 show.reload() - assert utils.is_metadata(show._initpath, prefix='/library/metadata/') + assert utils.is_metadata(show._initpath, prefix="/library/metadata/") assert len(show.roles) > 3 def test_video_Show_episodes(tvshows): - show = tvshows.get('The 100') + show = tvshows.get("The 100") episodes = show.episodes() episodes[0].markWatched() unwatched = show.episodes(viewCount=0) @@ -379,7 +582,7 @@ def test_video_Show_download(monkeydownload, tmpdir, show): def test_video_Season_download(monkeydownload, tmpdir, show): - season = show.season('Season 1') + season = show.season("Season 1") filepaths = season.download(savepath=str(tmpdir)) assert len(filepaths) >= 4 @@ -387,14 +590,16 @@ def test_video_Season_download(monkeydownload, tmpdir, show): def test_video_Episode_download(monkeydownload, tmpdir, episode): f = episode.download(savepath=str(tmpdir)) assert len(f) == 1 - with_sceen_size = episode.download(savepath=str(tmpdir), **{'videoResolution': '500x300'}) + with_sceen_size = episode.download( + savepath=str(tmpdir), **{"videoResolution": "500x300"} + ) assert len(with_sceen_size) == 1 def test_video_Show_thumbUrl(show): assert utils.SERVER_BASEURL in show.thumbUrl - assert '/library/metadata/' in show.thumbUrl - assert '/thumb/' in show.thumbUrl + assert "/library/metadata/" in show.thumbUrl + assert "/thumb/" in show.thumbUrl # Analyze seems to fail intermittently @@ -418,7 +623,7 @@ def test_video_Show_refresh(show): def test_video_Show_get(show): - assert show.get('Winter Is Coming').title == 'Winter Is Coming' + assert show.get("Winter Is Coming").title == "Winter Is Coming" def test_video_Show_isWatched(show): @@ -427,11 +632,11 @@ def test_video_Show_isWatched(show): def test_video_Show_section(show): section = show.section() - assert section.title == 'TV Shows' + assert section.title == "TV Shows" def test_video_Episode(show): - episode = show.episode('Winter Is Coming') + episode = show.episode("Winter Is Coming") assert episode == show.episode(season=1, episode=1) with pytest.raises(BadRequest): show.episode() @@ -439,41 +644,51 @@ def test_video_Episode(show): show.episode(season=1337, episode=1337) +def test_video_Episode_history(episode): + episode.markWatched() + history = episode.history() + assert len(history) + episode.markUnwatched() + + # Analyze seems to fail intermittently @pytest.mark.xfail def test_video_Episode_analyze(tvshows): - episode = tvshows.get('Game of Thrones').episode(season=1, episode=1) + episode = tvshows.get("Game of Thrones").episode(season=1, episode=1) episode.analyze() def test_video_Episode_attrs(episode): assert utils.is_datetime(episode.addedAt) assert episode.contentRating in utils.CONTENTRATINGS - assert [i.tag for i in episode.directors] == ['Tim Van Patten'] + if len(episode.directors): + assert [i.tag for i in episode.directors] == ["Tim Van Patten"] assert utils.is_int(episode.duration, gte=120000) - assert episode.grandparentTitle == 'Game of Thrones' + assert episode.grandparentTitle == "Game of Thrones" assert episode.index == 1 assert utils.is_metadata(episode._initpath) assert utils.is_metadata(episode.key) - assert episode.listType == 'video' - assert episode.originallyAvailableAt.strftime('%Y-%m-%d') == '2011-04-17' + assert episode.listType == "video" + assert utils.is_datetime(episode.originallyAvailableAt) assert utils.is_int(episode.parentIndex) assert utils.is_metadata(episode.parentKey) assert utils.is_int(episode.parentRatingKey) - assert utils.is_metadata(episode.parentThumb, contains='/thumb/') + assert utils.is_metadata(episode.parentThumb, contains="/thumb/") assert episode.rating >= 7.7 assert utils.is_int(episode.ratingKey) assert episode._server._baseurl == utils.SERVER_BASEURL assert utils.is_string(episode.summary, gte=100) - assert utils.is_metadata(episode.thumb, contains='/thumb/') - assert episode.title == 'Winter Is Coming' - assert episode.titleSort == 'Winter Is Coming' + assert utils.is_metadata(episode.thumb, contains="/thumb/") + assert episode.title == "Winter Is Coming" + assert episode.titleSort == "Winter Is Coming" assert not episode.transcodeSessions - assert episode.type == 'episode' + assert episode.type == "episode" assert utils.is_datetime(episode.updatedAt) assert utils.is_int(episode.viewCount, gte=0) assert episode.viewOffset == 0 - assert [i.tag for i in episode.writers] == ['David Benioff', 'D. B. Weiss'] + assert sorted([i.tag for i in episode.writers]) == sorted( + ["David Benioff", "D. B. Weiss"] + ) assert episode.year == 2011 assert episode.isWatched in [True, False] # Media @@ -504,17 +719,27 @@ def test_video_Episode_attrs(episode): assert len(part.key) >= 10 assert part._server._baseurl == utils.SERVER_BASEURL assert utils.is_int(part.size, gte=18184197) + assert part.exists + assert part.accessible def test_video_Season(show): seasons = show.seasons() assert len(seasons) == 2 - assert ['Season 1', 'Season 2'] == [s.title for s in seasons[:2]] - assert show.season('Season 1') == seasons[0] + assert ["Season 1", "Season 2"] == [s.title for s in seasons[:2]] + assert show.season("Season 1") == seasons[0] + + +def test_video_Season_history(show): + season = show.season("Season 1") + season.markWatched() + history = season.history() + assert len(history) + season.markUnwatched() def test_video_Season_attrs(show): - season = show.season('Season 1') + season = show.season("Season 1") assert utils.is_datetime(season.addedAt) assert season.index == 1 assert utils.is_metadata(season._initpath) @@ -522,17 +747,17 @@ def test_video_Season_attrs(show): if season.lastViewedAt: assert utils.is_datetime(season.lastViewedAt) assert utils.is_int(season.leafCount, gte=3) - assert season.listType == 'video' + assert season.listType == "video" assert utils.is_metadata(season.parentKey) assert utils.is_int(season.parentRatingKey) - assert season.parentTitle == 'Game of Thrones' + assert season.parentTitle == "Game of Thrones" assert utils.is_int(season.ratingKey) assert season._server._baseurl == utils.SERVER_BASEURL - assert season.summary == '' - assert utils.is_metadata(season.thumb, contains='/thumb/') - assert season.title == 'Season 1' - assert season.titleSort == 'Season 1' - assert season.type == 'season' + assert season.summary == "" + assert utils.is_metadata(season.thumb, contains="/thumb/") + assert season.title == "Season 1" + assert season.titleSort == "Season 1" + assert season.type == "season" assert utils.is_datetime(season.updatedAt) assert utils.is_int(season.viewCount, gte=0) assert utils.is_int(season.viewedLeafCount, gte=0) @@ -541,34 +766,39 @@ def test_video_Season_attrs(show): def test_video_Season_show(show): season = show.seasons()[0] - season_by_name = show.season('Season 1') + season_by_name = show.season("Season 1") assert show.ratingKey == season.parentRatingKey and season_by_name.parentRatingKey assert season.ratingKey == season_by_name.ratingKey def test_video_Season_watched(tvshows): - show = tvshows.get('Game of Thrones') + show = tvshows.get("Game of Thrones") season = show.season(1) - sne = show.season('Season 1') + sne = show.season("Season 1") assert season == sne season.markWatched() assert season.isWatched def test_video_Season_unwatched(tvshows): - season = tvshows.get('Game of Thrones').season(1) + season = tvshows.get("Game of Thrones").season(1) season.markUnwatched() assert not season.isWatched def test_video_Season_get(show): - episode = show.season(1).get('Winter Is Coming') - assert episode.title == 'Winter Is Coming' + episode = show.season(1).get("Winter Is Coming") + assert episode.title == "Winter Is Coming" def test_video_Season_episode(show): - episode = show.season(1).get('Winter Is Coming') - assert episode.title == 'Winter Is Coming' + episode = show.season(1).get("Winter Is Coming") + assert episode.title == "Winter Is Coming" + + +def test_video_Season_episode_by_index(show): + episode = show.season(1).episode(episode=1) + assert episode.index == 1 def test_video_Season_episodes(show): @@ -578,32 +808,94 @@ def test_video_Season_episodes(show): def test_that_reload_return_the_same_object(plex): # we want to check this that all the urls are correct - movie_library_search = plex.library.section('Movies').search('Elephants Dream')[0] - movie_search = plex.search('Elephants Dream')[0] - movie_section_get = plex.library.section('Movies').get('Elephants Dream') + movie_library_search = plex.library.section("Movies").search("Elephants Dream")[0] + movie_search = plex.search("Elephants Dream")[0] + movie_section_get = plex.library.section("Movies").get("Elephants Dream") movie_library_search_key = movie_library_search.key movie_search_key = movie_search.key movie_section_get_key = movie_section_get.key - assert movie_library_search_key == movie_library_search.reload().key == movie_search_key == movie_search.reload().key == movie_section_get_key == movie_section_get.reload().key # noqa - tvshow_library_search = plex.library.section('TV Shows').search('The 100')[0] - tvshow_search = plex.search('The 100')[0] - tvshow_section_get = plex.library.section('TV Shows').get('The 100') + assert ( + movie_library_search_key + == movie_library_search.reload().key + == movie_search_key + == movie_search.reload().key + == movie_section_get_key + == movie_section_get.reload().key + ) # noqa + tvshow_library_search = plex.library.section("TV Shows").search("The 100")[0] + tvshow_search = plex.search("The 100")[0] + tvshow_section_get = plex.library.section("TV Shows").get("The 100") tvshow_library_search_key = tvshow_library_search.key tvshow_search_key = tvshow_search.key tvshow_section_get_key = tvshow_section_get.key - assert tvshow_library_search_key == tvshow_library_search.reload().key == tvshow_search_key == tvshow_search.reload().key == tvshow_section_get_key == tvshow_section_get.reload().key # noqa + assert ( + tvshow_library_search_key + == tvshow_library_search.reload().key + == tvshow_search_key + == tvshow_search.reload().key + == tvshow_section_get_key + == tvshow_section_get.reload().key + ) # noqa season_library_search = tvshow_library_search.season(1) season_search = tvshow_search.season(1) season_section_get = tvshow_section_get.season(1) season_library_search_key = season_library_search.key season_search_key = season_search.key season_section_get_key = season_section_get.key - assert season_library_search_key == season_library_search.reload().key == season_search_key == season_search.reload().key == season_section_get_key == season_section_get.reload().key # noqa + assert ( + season_library_search_key + == season_library_search.reload().key + == season_search_key + == season_search.reload().key + == season_section_get_key + == season_section_get.reload().key + ) # noqa episode_library_search = tvshow_library_search.episode(season=1, episode=1) episode_search = tvshow_search.episode(season=1, episode=1) episode_section_get = tvshow_section_get.episode(season=1, episode=1) episode_library_search_key = episode_library_search.key episode_search_key = episode_search.key episode_section_get_key = episode_section_get.key - assert episode_library_search_key == episode_library_search.reload().key == episode_search_key == episode_search.reload().key == episode_section_get_key == episode_section_get.reload().key # noqa - + assert ( + episode_library_search_key + == episode_library_search.reload().key + == episode_search_key + == episode_search.reload().key + == episode_section_get_key + == episode_section_get.reload().key + ) # noqa + + +def test_video_exists_accessible(movie, episode): + assert movie.media[0].parts[0].exists is None + assert movie.media[0].parts[0].accessible is None + movie.reload() + assert movie.media[0].parts[0].exists is True + assert movie.media[0].parts[0].accessible is True + + assert episode.media[0].parts[0].exists is None + assert episode.media[0].parts[0].accessible is None + episode.reload() + assert episode.media[0].parts[0].exists is True + assert episode.media[0].parts[0].accessible is True + + +@pytest.mark.skip( + reason="broken? assert len(plex.conversions()) == 1 may fail on some builds" +) +def test_video_optimize(movie, plex): + plex.optimizedItems(removeAll=True) + movie.optimize(targetTagID=1) + plex.conversions(pause=True) + sleep(1) + assert len(plex.optimizedItems()) == 1 + assert len(plex.conversions()) == 1 + conversion = plex.conversions()[0] + conversion.remove() + assert len(plex.conversions()) == 0 + assert len(plex.optimizedItems()) == 1 + optimized = plex.optimizedItems()[0] + video = plex.optimizedItem(optimizedID=optimized.id) + assert movie.key == video.key + plex.optimizedItems(removeAll=True) + assert len(plex.optimizedItems()) == 0 diff --git a/tools/plex-bootstraptest.py b/tools/plex-bootstraptest.py index 80055b42f..01bd77fec 100755 --- a/tools/plex-bootstraptest.py +++ b/tools/plex-bootstraptest.py @@ -1,355 +1,586 @@ -""" The script is used to bootstrap a docker container with Plex and with all the libraries required for testing. +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- """ +The script is used to bootstrap a the test enviroment for plexapi +with all the libraries required for testing. +By default this uses a docker. + +It can be used manually using: +python plex-bootraptest.py --no-docker --server-name name_of_server --account Hellowlol --password yourpassword + +""" import argparse import os +import shutil +import socket +import time from glob import glob -from shutil import copyfile, rmtree +from os import makedirs +from shutil import copyfile, which from subprocess import call -from time import time, sleep from uuid import uuid4 -from tqdm import tqdm import plexapi -from plexapi.compat import which, makedirs from plexapi.exceptions import BadRequest, NotFound from plexapi.myplex import MyPlexAccount from plexapi.server import PlexServer -from plexapi.utils import download, SEARCHTYPES +from plexapi.utils import SEARCHTYPES +from tqdm import tqdm DOCKER_CMD = [ - 'docker', 'run', '-d', - '--name', 'plex-test-%(container_name_extra)s%(image_tag)s', - '--restart', 'on-failure', - '-p', '32400:32400/tcp', - '-p', '3005:3005/tcp', - '-p', '8324:8324/tcp', - '-p', '32469:32469/tcp', - '-p', '1900:1900/udp', - '-p', '32410:32410/udp', - '-p', '32412:32412/udp', - '-p', '32413:32413/udp', - '-p', '32414:32414/udp', - '-e', 'TZ="Europe/London"', - '-e', 'PLEX_CLAIM=%(claim_token)s', - '-e', 'ADVERTISE_IP=http://%(advertise_ip)s:32400/', - '-h', '%(hostname)s', - '-e', 'TZ="%(timezone)s"', - '-v', '%(destination)s/db:/config', - '-v', '%(destination)s/transcode:/transcode', - '-v', '%(destination)s/media:/data', - 'plexinc/pms-docker:%(image_tag)s' + "docker", + "run", + "-d", + "--name", + "plex-test-%(container_name_extra)s%(image_tag)s", + "--restart", + "on-failure", + "-p", + "32400:32400/tcp", + "-p", + "3005:3005/tcp", + "-p", + "8324:8324/tcp", + "-p", + "32469:32469/tcp", + "-p", + "1900:1900/udp", + "-p", + "32410:32410/udp", + "-p", + "32412:32412/udp", + "-p", + "32413:32413/udp", + "-p", + "32414:32414/udp", + "-e", + 'TZ="Europe/London"', + "-e", + "PLEX_CLAIM=%(claim_token)s", + "-e", + "ADVERTISE_IP=http://%(advertise_ip)s:32400/", + "-h", + "%(hostname)s", + "-e", + 'TZ="%(timezone)s"', + "-v", + "%(destination)s/db:/config", + "-v", + "%(destination)s/transcode:/transcode", + "-v", + "%(destination)s/media:/data", + "plexinc/pms-docker:%(image_tag)s", ] -def get_ips(): - import socket - return list(set([i[4][0] for i in socket.getaddrinfo(socket.gethostname(), None) - if i[4][0] not in ('127.0.0.1', '::1') and not i[4][0].startswith('fe80:')])) +BASE_DIR_PATH = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +STUB_MOVIE_PATH = os.path.join(BASE_DIR_PATH, "tests", "data", "video_stub.mp4") +STUB_MP3_PATH = os.path.join(BASE_DIR_PATH, "tests", "data", "audio_stub.mp3") +STUB_IMAGE_PATH = os.path.join(BASE_DIR_PATH, "tests", "data", "cute_cat.jpg") -def create_section(server, section): - processed_media = 0 - expected_media_count = section.pop('expected_media_count', 0) +def check_ext(path, ext): + """I hate glob so much.""" + result = [] + for root, dirs, fil in os.walk(path): + for f in fil: + fp = os.path.join(root, f) + if fp.endswith(ext): + result.append(fp) + + return result + + +class ExistingSection(Exception): + """This server has sections, exiting""" + + def __init__(self, *args): + raise SystemExit("This server has sections exiting") + + +def clean_pms(server, path): + for section in server.library.sections(): + print("Deleting %s" % section.title) + section.delete() + + server.library.cleanBundles() + server.library.optimize() + print("optimized db and removed any bundles") + + shutil.rmtree(path, ignore_errors=False, onerror=None) + print("Deleted %s" % path) + + +def setup_music(music_path): + print("Setup files for music section..") + makedirs(music_path, exist_ok=True) + + all_music = { + + "Broke for free": { + "Layers": [ + "1 - As Colorful As Ever.mp3", + #"02 - Knock Knock.mp3", + #"03 - Only Knows.mp3", + #"04 - If.mp3", + #"05 - Note Drop.mp3", + #"06 - Murmur.mp3", + #"07 - Spellbound.mp3", + #"08 - The Collector.mp3", + #"09 - Quit Bitching.mp3", + #"10 - A Year.mp3", + ] + }, + + } + + for artist, album in all_music.items(): + for k, v in album.items(): + artist_album = os.path.join(music_path, artist, k) + makedirs(artist_album, exist_ok=True) + for song in v: + copyfile(STUB_MP3_PATH, os.path.join(artist_album, song)) + + return len(check_ext(music_path, (".mp3"))) + + +def setup_movies(movies_path): + print("Setup files for the Movies section..") + makedirs(movies_path, exist_ok=True) + if len(glob(movies_path + "/*.mkv", recursive=True)) == 4: + return 4 + + required_movies = { + "Elephants Dream": 2006, + "Sita Sings the Blues": 2008, + "Big Buck Bunny": 2008, + "Sintel": 2010, + } + expected_media_count = 0 + for name, year in required_movies.items(): + expected_media_count += 1 + if not os.path.isfile(get_movie_path(movies_path, name, year)): + copyfile(STUB_MOVIE_PATH, get_movie_path(movies_path, name, year)) + + return expected_media_count + + +def setup_images(photos_path): + print("Setup files for the Photos section..") + + makedirs(photos_path, exist_ok=True) + # expected_photo_count = 0 + folders = { + ("Cats",): 3, + ("Cats", "Cats in bed"): 7, + ("Cats", "Cats not in bed"): 1, + ("Cats", "Not cats in bed"): 1, + } + has_photos = 0 + for folder_path, required_cnt in folders.items(): + folder_path = os.path.join(photos_path, *folder_path) + makedirs(folder_path, exist_ok=True) + photos_in_folder = len(glob(os.path.join(folder_path, "/*.jpg"))) + while photos_in_folder < required_cnt: + # Dunno why this is need got permission error on photo0.jpg + photos_in_folder += 1 + full_path = os.path.join(folder_path, "photo%d.jpg" % photos_in_folder) + copyfile(STUB_IMAGE_PATH, full_path) + has_photos += photos_in_folder + + return len(check_ext(photos_path, (".jpg"))) + + +def setup_show(tvshows_path): + print("Setup files for the TV-Shows section..") + makedirs(tvshows_path, exist_ok=True) + makedirs(os.path.join(tvshows_path, "Game of Thrones"), exist_ok=True) + makedirs(os.path.join(tvshows_path, "The 100"), exist_ok=True) + required_tv_shows = { + "Game of Thrones": [list(range(1, 11)), list(range(1, 11))], + "The 100": [list(range(1, 14)), list(range(1, 17))], + } + expected_media_count = 0 + for show_name, seasons in required_tv_shows.items(): + for season_id, episodes in enumerate(seasons, start=1): + for episode_id in episodes: + expected_media_count += 1 + episode_path = get_tvshow_path( + tvshows_path, show_name, season_id, episode_id + ) + if not os.path.isfile(episode_path): + copyfile(STUB_MOVIE_PATH, episode_path) + + return expected_media_count + + +def get_default_ip(): + """ Return the first IP address of the current machine if available. """ + available_ips = list( + set( + [ + i[4][0] + for i in socket.getaddrinfo(socket.gethostname(), None) + if i[4][0] not in ("127.0.0.1", "::1") + and not i[4][0].startswith("fe80:") + ] + ) + ) + return available_ips[0] if len(available_ips) else None + + +def get_plex_account(opts): + """ Authenitcate with Plex using the command line options. """ + if not opts.unclaimed: + if opts.token: + return MyPlexAccount(token=opts.token) + return plexapi.utils.getMyPlexAccount(opts) + return None + + +def get_movie_path(movies_path, name, year): + """ Return a movie path given its title and year. """ + return os.path.join(movies_path, "%s (%d).mp4" % (name, year)) + + +def get_tvshow_path(tvshows_path, name, season, episode): + """ Return a TV show path given its title, season, and episode. """ + return os.path.join(tvshows_path, name, "S%02dE%02d.mp4" % (season, episode)) + + +def add_library_section(server, section): + """ Add the specified section to our Plex instance. This tends to be a bit + flaky, so we retry a few times here. + """ + start = time.time() + runtime = 0 + while runtime < 60: + try: + server.library.add(**section) + return True + except BadRequest as err: + if "server is still starting up. Please retry later" in str(err): + time.sleep(1) + continue + raise + runtime = time.time() - start + raise SystemExit("Timeout adding section to Plex instance.") - expected_media_type = (section['type'], ) - if section['type'] == 'artist': - expected_media_type = ('artist', 'album', 'track') +def create_section(server, section, opts): + processed_media = 0 + expected_media_count = section.pop("expected_media_count", 0) + expected_media_type = (section["type"],) + if section["type"] == "artist": + expected_media_type = ("artist", "album", "track") expected_media_type = tuple(SEARCHTYPES[t] for t in expected_media_type) def alert_callback(data): + """ Listen to the Plex notifier to determine when metadata scanning is complete. """ global processed_media - if data['type'] == 'timeline': - for entry in data['TimelineEntry']: - if entry.get('identifier', 'com.plexapp.plugins.library') == 'com.plexapp.plugins.library': + if data["type"] == "timeline": + for entry in data["TimelineEntry"]: + if ( + entry.get("identifier", "com.plexapp.plugins.library") + == "com.plexapp.plugins.library" + ): # Missed mediaState means that media was processed (analyzed & thumbnailed) - if 'mediaState' not in entry and entry['type'] in expected_media_type: + if ( + "mediaState" not in entry + and entry["type"] in expected_media_type + ): # state=5 means record processed, applicable only when metadata source was set - if entry['state'] == 5: + if entry["state"] == 5: cnt = 1 - - # Workaround for old Plex versions which not reports individual episodes' progress - if entry['type'] == SEARCHTYPES['show']: - show = server.library.sectionByID(str(entry['sectionID'])).get(entry['title']) + if entry["type"] == SEARCHTYPES["show"]: + show = server.library.sectionByID( + str(entry["sectionID"]) + ).get(entry["title"]) cnt = show.leafCount bar.update(cnt) - + processed_media += cnt # state=1 means record processed, when no metadata source was set - elif entry['state'] == 1 and entry['type'] == SEARCHTYPES['photo']: + elif ( + entry["state"] == 1 + and entry["type"] == SEARCHTYPES["photo"] + ): bar.update() + processed_media += 1 - bar = tqdm(desc='Scanning section ' + section['name'], total=expected_media_count) + runtime = 0 + start = time.time() + bar = tqdm(desc="Scanning section " + section["name"], total=expected_media_count) notifier = server.startAlertListener(alert_callback) - - # I don't know how to determinate of plex successfully started, so let's do it in creepy way - success = False - start_time = time() - while not success and (time() - start_time < opts.bootstrap_timeout): - try: - server.library.add(**section) - success = True - except BadRequest as e: - if 'the server is still starting up. Please retry later' in str(e): - sleep(1) - else: - raise - - if not success: - print('Something went wrong :(') - exit(1) - + time.sleep(3) + add_library_section(server, section) while bar.n < bar.total: - if time() - start_time >= opts.bootstrap_timeout: - print('Metadata scan takes too long, probably something went really wrong') - exit(1) - sleep(3) - + if runtime >= 120: + print("Metadata scan taking too long, but will continue anyway..") + break + time.sleep(3) + runtime = int(time.time() - start) bar.close() - notifier.stop() -if __name__ == '__main__': - if which('docker') is None: - print('Docker is required to be available') - exit(1) - - default_ip = None - available_ips = get_ips() - if len(available_ips) > 0: - default_ip = available_ips[0] - +if __name__ == "__main__": + default_ip = get_default_ip() parser = argparse.ArgumentParser(description=__doc__) - + # Authentication arguments mg = parser.add_mutually_exclusive_group() - g = mg.add_argument_group() - g.add_argument('--username', help='Your Plex username') - g.add_argument('--password', help='Your Plex password') - mg.add_argument('--token', help='Plex.tv authentication token', default=plexapi.CONFIG.get('auth.server_token')) - mg.add_argument('--unclaimed', help='Do not claim the server', default=False, action='store_true') - - parser.add_argument('--timezone', help='Timezone to set inside plex', default='UTC') - parser.add_argument('--destination', help='Local path where to store all the media', - default=os.path.join(os.getcwd(), 'plex')) - parser.add_argument('--advertise-ip', help='IP address which should be advertised by new Plex instance', - required=default_ip is None, default=default_ip) - parser.add_argument('--docker-tag', help='Docker image tag to install', default='latest') - parser.add_argument('--bootstrap-timeout', help='Timeout for each step of bootstrap, in seconds (default: ' - '%(default)s)', - default=180, type=int) - parser.add_argument('--server-name', help='Name for the new server', default='plex-test-docker-%s' % str(uuid4())) - parser.add_argument('--accept-eula', help='Accept Plex`s EULA', default=False, action='store_true') - parser.add_argument('--without-movies', help='Do not create Movies section', default=True, dest='with_movies', - action='store_false') - parser.add_argument('--without-shows', help='Do not create TV Shows section', default=True, dest='with_shows', - action='store_false') - parser.add_argument('--without-music', help='Do not create Music section', default=True, dest='with_music', - action='store_false') - parser.add_argument('--without-photos', help='Do not create Photos section', default=True, dest='with_photos', - action='store_false') - parser.add_argument('--show-token', help='Display access token after bootstrap', default=False, action='store_true') + g.add_argument("--username", help="Your Plex username") + g.add_argument("--password", help="Your Plex password") + mg.add_argument( + "--token", + help="Plex.tv authentication token", + default=plexapi.CONFIG.get("auth.server_token"), + ) + mg.add_argument( + "--unclaimed", + help="Do not claim the server", + default=False, + action="store_true", + ) + # Test environment arguments + parser.add_argument( + "--no-docker", help="Use docker", default=False, action="store_true" + ) + parser.add_argument( + "--timezone", help="Timezone to set inside plex", default="UTC" + ) # noqa + parser.add_argument( + "--destination", + help="Local path where to store all the media", + default=os.path.join(os.getcwd(), "plex"), + ) # noqa + parser.add_argument( + "--advertise-ip", + help="IP address which should be advertised by new Plex instance", + required=default_ip is None, + default=default_ip, + ) # noqa + parser.add_argument( + "--docker-tag", help="Docker image tag to install", default="latest" + ) # noqa + parser.add_argument( + "--bootstrap-timeout", + help="Timeout for each step of bootstrap, in seconds (default: %(default)s)", + default=180, + type=int, + ) # noqa + parser.add_argument( + "--server-name", + help="Name for the new server", + default="plex-test-docker-%s" % str(uuid4()), + ) # noqa + parser.add_argument( + "--accept-eula", help="Accept Plex`s EULA", default=False, action="store_true" + ) # noqa + parser.add_argument( + "--without-movies", + help="Do not create Movies section", + default=True, + dest="with_movies", + action="store_false", + ) # noqa + parser.add_argument( + "--without-shows", + help="Do not create TV Shows section", + default=True, + dest="with_shows", + action="store_false", + ) # noqa + parser.add_argument( + "--without-music", + help="Do not create Music section", + default=True, + dest="with_music", + action="store_false", + ) # noqa + parser.add_argument( + "--without-photos", + help="Do not create Photos section", + default=True, + dest="with_photos", + action="store_false", + ) # noqa + parser.add_argument( + "--show-token", + help="Display access token after bootstrap", + default=False, + action="store_true", + ) # noqa opts = parser.parse_args() - print('I`m going to create a plex instance named %s with advertised ip "%s", be prepared!' % (opts.server_name, - opts.advertise_ip)) - if call(['docker', 'pull', 'plexinc/pms-docker:%s' % opts.docker_tag]) != 0: - print('Got an error when executing docker pull!') - exit(1) - account = None - if not opts.unclaimed: - if opts.token: - account = MyPlexAccount(token=opts.token) - else: - account = plexapi.utils.getMyPlexAccount(opts) + account = get_plex_account(opts) path = os.path.realpath(os.path.expanduser(opts.destination)) - makedirs(os.path.join(path, 'media'), exist_ok=True) - arg_bindings = { - 'destination': path, - 'hostname': opts.server_name, - 'claim_token': account.claimToken() if account else '', - 'timezone': opts.timezone, - 'advertise_ip': opts.advertise_ip, - 'image_tag': opts.docker_tag, - 'container_name_extra': '' if account else 'unclaimed-' - } - - docker_cmd = [c % arg_bindings for c in DOCKER_CMD] - exit_code = call(docker_cmd) + media_path = os.path.join(path, "media") + makedirs(media_path, exist_ok=True) + + # Download the Plex Docker image + if opts.no_docker is False: + print( + "Creating Plex instance named %s with advertised ip %s" + % (opts.server_name, opts.advertise_ip) + ) + if which("docker") is None: + print("Docker is required to be available") + exit(1) + if call(["docker", "pull", "plexinc/pms-docker:%s" % opts.docker_tag]) != 0: + print("Got an error when executing docker pull!") + exit(1) - if exit_code != 0: - exit(exit_code) + # Start the Plex Docker container - print('Let`s wait while the instance boots...') - start_time = time() + arg_bindings = { + "destination": path, + "hostname": opts.server_name, + "claim_token": account.claimToken() if account else "", + "timezone": opts.timezone, + "advertise_ip": opts.advertise_ip, + "image_tag": opts.docker_tag, + "container_name_extra": "" if account else "unclaimed-", + } + docker_cmd = [c % arg_bindings for c in DOCKER_CMD] + exit_code = call(docker_cmd) + if exit_code != 0: + raise SystemExit( + "Error %s while starting the Plex docker container" % exit_code + ) + + # Wait for the Plex container to start + print("Waiting for the Plex to start..") + start = time.time() + runtime = 0 server = None - while not server and (time() - start_time < opts.bootstrap_timeout): + while not server and (runtime < opts.bootstrap_timeout): try: if account: - device = account.device(opts.server_name) - server = device.connect() + server = account.device(opts.server_name).connect() else: - server = PlexServer('http://%s:32400' % opts.advertise_ip) + server = PlexServer("http://%s:32400" % opts.advertise_ip) if opts.accept_eula: - server.settings.get('acceptedEULA').set(True) + server.settings.get("acceptedEULA").set(True) server.settings.save() - except Exception as e: - print(e) - sleep(1) - if not server: - print('Server didn`t appeared in your account after a lot of time, I have no idea what to do :( Dig into ' - 'docker logs, check your internet connection, do something!') - exit(1) + except KeyboardInterrupt: + break - print('Ok, I got the server instance, let`s download what you`re missing') - - def get_tvshow_path(name, season, episode): - return os.path.join(tvshows_path, name, 'S%02dE%02d.mp4' % (season, episode)) - - if opts.with_movies or opts.with_shows: - def get_movie_path(name, year): - return os.path.join(movies_path, '%s (%d).mp4' % (name, year)) - - media_stub_path = os.path.join(path, 'media', 'video_stub.mp4') - if not os.path.isfile(media_stub_path): - download('http://www.mytvtestpatterns.com/mytvtestpatterns/Default/GetFile?p=PhilipsCircleMP4', '', - filename='video_stub.mp4', savepath=os.path.join(path, 'media'), showstatus=True) + except Exception as err: + print(err) + time.sleep(1) + runtime = time.time() - start + if not server: + raise SystemExit( + "Server didnt appear in your account after %ss" % opts.bootstrap_timeout + ) + print("Plex container started after %ss, setting up content" % int(runtime)) sections = [] - if opts.with_movies: - movies_path = os.path.join(path, 'media', 'Movies') - makedirs(movies_path, exist_ok=True) - - required_movies = { - 'Elephants Dream': 2006, - 'Sita Sings the Blues': 2008, - 'Big Buck Bunny': 2008, - 'Sintel': 2010, - } - expected_media_count = 0 - for name, year in required_movies.items(): - expected_media_count += 1 - if not os.path.isfile(get_movie_path(name, year)): - copyfile(media_stub_path, get_movie_path(name, year)) - - print('Finished with movies...') - sections.append(dict(name='Movies', type='movie', location='/data/Movies', agent='com.plexapp.agents.imdb', - scanner='Plex Movie Scanner', expected_media_count=expected_media_count)) + # Lets add a check here do somebody dont mess up + # there normal server if they run manual tests. + # Like i did.... + if len(server.library.sections()) and opts.no_docker is True: + ans = input( + "The server has %s sections, do you wish to remove it?\n> " + % len(server.library.sections()) + ) + if ans in ("y", "Y", "Yes"): + ans = input( + "Are you really sure you want to delete %s libraries? There is no way back\n> " + % len(server.library.sections()) + ) + if ans in ("y", "Y", "Yes"): + clean_pms(server, path) + else: + raise ExistingSection() + else: + raise ExistingSection() + # Prepare Movies section + if opts.with_movies: + movies_path = os.path.join(media_path, "Movies") + num_movies = setup_movies(movies_path) + sections.append( + dict( + name="Movies", + type="movie", + location="/data/Movies" if opts.no_docker is False else movies_path, + agent="com.plexapp.agents.imdb", + scanner="Plex Movie Scanner", + expected_media_count=num_movies, + ) + ) + + # Prepare TV Show section if opts.with_shows: - tvshows_path = os.path.join(path, 'media', 'TV-Shows') - makedirs(os.path.join(tvshows_path, 'Game of Thrones'), exist_ok=True) - makedirs(os.path.join(tvshows_path, 'The 100'), exist_ok=True) - - required_tv_shows = { - 'Game of Thrones': [ - list(range(1, 11)), - list(range(1, 11)), - ], - 'The 100': [ - list(range(1, 14)), - list(range(1, 17)), - ] - } - - expected_media_count = 0 - for show_name, seasons in required_tv_shows.items(): - for season_id, episodes in enumerate(seasons, start=1): - for episode_id in episodes: - expected_media_count += 1 - episode_path = get_tvshow_path(show_name, season_id, episode_id) - if not os.path.isfile(episode_path): - copyfile(get_movie_path('Sintel', 2010), episode_path) - - print('Finished with TV Shows...') - sections.append(dict(name='TV Shows', type='show', location='/data/TV-Shows', agent='com.plexapp.agents.thetvdb', - scanner='Plex Series Scanner', expected_media_count=expected_media_count)) - + tvshows_path = os.path.join(media_path, "TV-Shows") + num_ep = setup_show(tvshows_path) + + sections.append( + dict( + name="TV Shows", + type="show", + location="/data/TV-Shows" if opts.no_docker is False else tvshows_path, + agent="com.plexapp.agents.thetvdb", + scanner="Plex Series Scanner", + expected_media_count=num_ep, + ) + ) + + # Prepare Music section if opts.with_music: - music_path = os.path.join(path, 'media', 'Music') - makedirs(music_path, exist_ok=True) - expected_media_count = 0 - - artist_dst = os.path.join(music_path, 'Infinite State') - dest_path = os.path.join(artist_dst, 'Unmastered Impulses') - if not os.path.isdir(dest_path): - zip_path = os.path.join(artist_dst, 'Unmastered Impulses.zip') - if os.path.isfile(zip_path): - import zipfile - with zipfile.ZipFile(zip_path, 'r') as handle: - handle.extractall(artist_dst) - else: - download('https://github.com/kennethreitz/unmastered-impulses/archive/master.zip', '', - filename='Unmastered Impulses.zip', savepath=artist_dst, unpack=True, showstatus=True) - os.rename(os.path.join(artist_dst, 'unmastered-impulses-master', 'mp3'), dest_path) - rmtree(os.path.join(artist_dst, 'unmastered-impulses-master')) - - expected_media_count += len(glob(os.path.join(dest_path, '*.mp3'))) + 2 # wait for artist & album - - artist_dst = os.path.join(music_path, 'Broke For Free') - dest_path = os.path.join(artist_dst, 'Layers') - if not os.path.isdir(dest_path): - zip_path = os.path.join(artist_dst, 'Layers.zip') - if not os.path.isfile(zip_path): - download('https://archive.org/compress/Layers-11520/formats=VBR%20MP3&file=/Layers-11520.zip', '', - filename='Layers.zip', savepath=artist_dst, showstatus=True) - makedirs(dest_path, exist_ok=True) - import zipfile - with zipfile.ZipFile(zip_path, 'r') as handle: - handle.extractall(dest_path) - - expected_media_count += len(glob(os.path.join(dest_path, '*.mp3'))) + 2 # wait for artist & album - - print('Finished with Music...') - sections.append(dict(name='Music', type='artist', location='/data/Music', agent='com.plexapp.agents.lastfm', - scanner='Plex Music Scanner', expected_media_count=expected_media_count)) - + music_path = os.path.join(media_path, "Music") + song_c = setup_music(music_path) + + sections.append( + dict( + name="Music", + type="artist", + location="/data/Music" if opts.no_docker is False else music_path, + agent="com.plexapp.agents.lastfm", + scanner="Plex Music Scanner", + expected_media_count=song_c, + ) + ) + + # Prepare Photos section if opts.with_photos: - photos_path = os.path.join(path, 'media', 'Photos') - makedirs(photos_path, exist_ok=True) - expected_photo_count = 0 - - folders = { - ('Cats', ): 3, - ('Cats', 'Cats in bed'): 7, - ('Cats', 'Cats not in bed'): 1, - ('Cats', 'Not cats in bed'): 1, - } - - has_photos = 0 - for folder_path, required_cnt in folders.items(): - folder_path = os.path.join(photos_path, *folder_path) - photos_in_folder = len(glob(os.path.join(folder_path, '*.jpg'))) - while photos_in_folder < required_cnt: - photos_in_folder += 1 - download('https://picsum.photos/800/600/?random', '', - filename='photo%d.jpg' % photos_in_folder, savepath=folder_path) - has_photos += photos_in_folder - - print('Finished with photos...') - sections.append(dict(name='Photos', type='photo', location='/data/Photos', agent='com.plexapp.agents.none', - scanner='Plex Photo Scanner', expected_media_count=has_photos)) - + photos_path = os.path.join(media_path, "Photos") + has_photos = setup_images(photos_path) + + sections.append( + dict( + name="Photos", + type="photo", + location="/data/Photos" if opts.no_docker is False else photos_path, + agent="com.plexapp.agents.none", + scanner="Plex Photo Scanner", + expected_media_count=has_photos, + ) + ) + + # Create the Plex library in our instance if sections: - print('Ok, got the media, it`s time to create a library for you!') - + print("Creating the Plex libraries on %s" % server.friendlyName) for section in sections: - create_section(server, section) + create_section(server, section, opts) + # Share this instance with the specified username if account: - shared_username = os.environ.get('SHARED_USERNAME', 'PKKid') + shared_username = os.environ.get("SHARED_USERNAME", "PKKid") try: user = account.user(shared_username) account.updateFriend(user, server) - print('The server was shared with user "%s"' % shared_username) + print("The server was shared with user %s" % shared_username) except NotFound: pass - print('Base URL is %s' % server.url('', False)) + # Finished: Display our Plex details + print("Base URL is %s" % server.url("", False)) if account and opts.show_token: - print('Auth token is %s' % account.authenticationToken) - - print('Server %s is ready to use!' % opts.server_name) + print("Auth token is %s" % account.authenticationToken) + print("Server %s is ready to use!" % opts.server_name) diff --git a/tools/plex-download.py b/tools/plex-download.py index f1cda2959..0173f16bf 100755 --- a/tools/plex-download.py +++ b/tools/plex-download.py @@ -10,10 +10,9 @@ import argparse import os import re -import shutil +from urllib.parse import unquote from plexapi import utils -from plexapi.compat import unquote from plexapi.video import Episode, Movie, Show VALID_TYPES = (Movie, Episode, Show) @@ -63,7 +62,7 @@ def get_item_from_url(url): raise SystemExit('Unknown or ambiguous client id: %s' % clientid) server = servers[0].connect() return server.fetchItem(key) - + if __name__ == '__main__': # Command line parser from plexapi import CONFIG @@ -73,7 +72,7 @@ def get_item_from_url(url): default=CONFIG.get('auth.myplex_username')) parser.add_argument('-p', '--password', help='Your Plex password', default=CONFIG.get('auth.myplex_password')) - parser.add_argument('--url', default=None, help='Download from URL (only paste after !)') + parser.add_argument('--url', default=None, help='Download from URL (only paste after !)') opts = parser.parse_args() # Search item to download account = utils.getMyPlexAccount(opts) @@ -86,4 +85,3 @@ def get_item_from_url(url): filepath = utils.download(url, token=account.authenticationToken, filename=filename, savepath=os.getcwd(), session=item._server._session, showstatus=True) #print(' %s' % filepath) - diff --git a/tools/plex-markwatched.py b/tools/plex-markwatched.py index d389cb800..5b32fad9f 100755 --- a/tools/plex-markwatched.py +++ b/tools/plex-markwatched.py @@ -40,7 +40,7 @@ def _iter_items(search): if __name__ == '__main__': - datestr = lambda: datetime.now().strftime('%Y-%m-%d %H:%M:%S') + datestr = lambda: datetime.now().strftime('%Y-%m-%d %H:%M:%S') # noqa print(f'{datestr()} Starting plex-markwatched script..') plex = PlexServer() for section in plex.library.sections():